// Render tests for the License card (#347). No jsdom needed: react-dom/server // renders in vitest's node environment, and the license context and machine // hook are replaced by fakes so each test pins one visible state. The fake // hook computes its flags with the real selectors. import { describe, it, expect, vi, beforeEach } from "vitest"; import type { ReactElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn(() => Promise.resolve()) })); let language = "fr"; vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (k: string, o?: Record) => o ? `${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : k, i18n: { language }, }), })); let context: Record; vi.mock("../../contexts/LicenseContext", async (importOriginal) => ({ ...(await importOriginal()), useLicenseContext: () => context, })); let machines: Record; vi.mock("../../hooks/useLicenseMachines", async (importOriginal) => ({ ...(await importOriginal()), useLicenseMachines: () => machines, })); import LicenseCard from "./LicenseCard"; import { licenseIdentity } from "../../contexts/LicenseContext"; import { initialLicenseMachinesState, selectCanActivate, selectCanDeactivate, selectShowEmptyState, type LicenseMachinesState, } from "../../hooks/useLicenseMachines"; import { parseLicenseError, type LicenseInfo, type MachineInfo } from "../../services/licenseService"; const DAY_S = 86400; const nowS = () => Math.floor(Date.now() / 1000); // Noon UTC, so the calendar day is the same in UTC (CI) and America/Toronto. const TEN_YEAR_EXPIRY_S = Date.UTC(2036, 8, 26, 12) / 1000; const machine = (id: string, isThisMachine = false): MachineInfo => ({ machine_id: id, machine_name: `host-${id}`, activated_at: "2026-09-01T12:00:00Z", last_seen_at: "2026-09-20T12:00:00Z", is_this_machine: isThisMachine, }); /** What the real hook returns for `state`, with its real selectors. */ function hookView(partial: Partial, revoked = false) { const state: LicenseMachinesState = { ...initialLicenseMachinesState, ...partial }; return { state, revoked, activate: vi.fn(), deactivate: vi.fn(), toggleList: vi.fn(), showEmptyState: selectShowEmptyState(state), canDeactivate: selectCanDeactivate(state), canActivate: selectCanActivate(state, revoked), }; } const notActivated = { is_activated: false }; const loadedList = (list: MachineInfo[]) => ({ listOpen: true, listLoaded: true, machines: list, }); const render = () => renderToStaticMarkup(); beforeEach(() => { language = "fr"; context = { status: "ready", edition: "base", features: [], info: { edition: "base", email: "user@example.com", features: [], machine_limit: 3, issued_at: nowS(), expires_at: TEN_YEAR_EXPIRY_S, revoked: false, }, error: null, validating: false, validationError: null, revoked: false, refresh: vi.fn(), submitKey: vi.fn(), }; machines = hookView({}); }); describe("LicenseCard load error", () => { it("names a serialized LicenseError instead of printing it", () => { context = { ...context, status: "error", error: '{"code":"storage"}' }; const html = render(); expect(html).toContain("license.errors.storage"); expect(html).not.toContain("{"code""); }); it("never prints a raw backend string, even one that is not a LicenseError", () => { context = { ...context, status: "error", error: "Cannot read license file: permission denied" }; const html = render(); expect(html).toContain("license.errors.unknown"); expect(html).not.toContain("Cannot read license file"); }); }); // The main case since #365: Rust persists the revocation and resolves the edition of a // revoked key to Free. The card says why, and offers a way out. describe("LicenseCard revoked key (Free edition)", () => { beforeEach(() => { context = { ...context, edition: "free", revoked: true, info: { ...(context.info as LicenseInfo), revoked: true }, }; machines = hookView({}, true); }); it("shows the Free edition, names the revocation once, and offers to buy or enter a key", () => { const html = render(); expect(html).toContain("license.editions.free"); expect(html.split("license.errors.licenseRevoked")).toHaveLength(2); expect(html).toContain("license.purchase"); expect(html).toContain("license.enterKey"); }); it("shows no expiry date: it would read as a date until which the key works", () => { const html = render(); expect(html).not.toContain("license.expiresAt"); expect(html).not.toContain("license.noExpiry"); expect(html).not.toContain("2036-09-26"); }); it("shows neither the Machines section nor the edition's green check", () => { const html = render(); expect(html).not.toContain("license.machines.title"); expect(html).not.toContain("license.activate"); expect(html).not.toContain("lucide-circle-check-big"); }); it("keeps the expiry date of a key that is not revoked", () => { context = { ...context, edition: "base", revoked: false, info: { ...(context.info as LicenseInfo), revoked: false }, }; machines = hookView({}); const html = render(); expect(html).toContain("license.expiresAt"); expect(html).toContain("2036-09-26"); expect(html).not.toContain("license.errors.licenseRevoked"); }); }); // The corner that keeps a revoked key under a paid edition: Premium coming from the // Maximus account while a revoked key is stored. The Machines section is then still shown. describe("LicenseCard revoked key under an account Premium", () => { beforeEach(() => { context = { ...context, edition: "premium", revoked: true, info: { ...(context.info as LicenseInfo), revoked: true }, }; }); it("names the revocation at card level with the list closed, and blocks Activate", () => { machines = hookView({ activation: notActivated }, true); const html = render(); expect(html).toContain("license.errors.licenseRevoked"); expect(html).toContain("license.activate"); expect(html).toContain('disabled=""'); }); it("keeps Activate live without a revocation", () => { machines = hookView({ activation: notActivated }); expect(render()).not.toContain('disabled=""'); }); it("swaps the edition's green check for an alert", () => { // Machine not activated: the "Activated" badge carries the same check icon. machines = hookView({ activation: notActivated }, true); expect(render()).not.toContain("lucide-circle-check-big"); machines = hookView({ activation: notActivated }); expect(render()).toContain("lucide-circle-check-big"); }); it("hides the Activated badge: the server deactivated every machine of a revoked key", () => { const activated = { is_activated: true }; machines = hookView({ activation: activated }, true); expect(render()).not.toContain("license.machines.activated"); machines = hookView({ activation: activated }); expect(render()).toContain("license.machines.activated"); }); it("does not repeat the revocation under Machines", () => { const revokedError = parseLicenseError('{"code":"license_revoked"}'); machines = hookView( { activation: notActivated, activationError: revokedError, listOpen: true, listError: revokedError }, true, ); expect(render().split("license.errors.licenseRevoked")).toHaveLength(2); }); }); describe("LicenseCard machines", () => { it("keeps the other errors visible under Machines", () => { // Only the revocation moves to card level: the machine-limit message of the D2 path // (#270) and a failed list load still show where they happen. machines = hookView({ activation: notActivated, activationError: parseLicenseError('{"code":"machine_limit_reached"}'), }); expect(render()).toContain("license.errors.machineLimitReached"); machines = hookView({ listOpen: true, listError: parseLicenseError('{"code":"network"}') }); expect(render()).toContain("license.errors.network"); }); it("counts the activated machines against the key's limit", () => { machines = hookView(loadedList([machine("a"), machine("b")])); expect(render()).toContain("license.machines.count[count=2,limit=3]"); }); it("shows no count without a stored key (account Premium)", () => { context = { ...context, edition: "premium", info: null }; machines = hookView(loadedList([machine("a")])); expect(render()).not.toContain("license.machines.count"); }); // The backend decides which rows name this machine (#350): it knows both forms of the // identifier, the webview knows neither. const THIS_MACHINE = "(license.machines.thisMachine)"; /** The row of `id`: from its host name up to its Deactivate button. */ const rowOf = (html: string, id: string) => { const start = html.indexOf(`host-${id}`); expect(start).toBeGreaterThan(-1); return html.slice(start, html.indexOf("license.machines.deactivate", start)); }; it("marks the row the backend flags as this machine, and only that row", () => { machines = hookView(loadedList([machine("a"), machine("b", true), machine("c")])); const html = render(); expect(rowOf(html, "b")).toContain(THIS_MACHINE); expect(rowOf(html, "a")).not.toContain(THIS_MACHINE); expect(rowOf(html, "c")).not.toContain(THIS_MACHINE); expect(html.split(THIS_MACHINE)).toHaveLength(2); }); it("marks every flagged row: an old activation of this machine next to its new one", () => { machines = hookView(loadedList([machine("old", true), machine("new", true), machine("c")])); expect(render().split(THIS_MACHINE)).toHaveLength(3); }); it("marks no row when none is flagged, whatever the activation status", () => { for (const activation of [null, notActivated, { is_activated: true }]) { machines = hookView({ ...loadedList([machine("a"), machine("b")]), activation }); expect(render()).not.toContain(THIS_MACHINE); } // And the other way round: a flagged row is marked without any activation status. machines = hookView({ ...loadedList([machine("a", true)]), activation: null }); expect(render()).toContain(THIS_MACHINE); }); it("lets an empty list say so on its own line", () => { machines = hookView(loadedList([])); const html = render(); expect(html).toContain("license.machines.noMachines"); expect(html).not.toContain("license.machines.count"); }); it("marks this machine as not activated next to Activate", () => { machines = hookView({ activation: notActivated }); const html = render(); expect(html).toContain("license.machines.notActivated"); expect(html).toContain("license.activate"); }); it("locks key entry while an activation runs, and Activate while a key is stored", () => { // Either order would leave the previous key's token next to the new key (#364 review). const enterKeyDisabled = /]*disabled=""[^>]*>license\.enterKey<\/button>/; const activateDisabled = /]*disabled=""[^>]*>license\.activate<\/button>/; machines = hookView({ activation: notActivated, activating: true }); expect(render()).toMatch(enterKeyDisabled); machines = hookView({ activation: notActivated }); context = { ...context, validating: true }; expect(render()).toMatch(activateDisabled); context = { ...context, validating: false }; const idle = render(); expect(idle).not.toMatch(enterKeyDisabled); expect(idle).not.toMatch(activateDisabled); }); it("labels the buttons while an activation or a deactivation runs", () => { machines = hookView({ activation: notActivated, activating: true }); expect(render()).toContain("license.machines.activating"); machines = hookView({ ...loadedList([machine("a")]), deactivatingId: "a" }); expect(render()).toContain("license.machines.deactivating"); }); }); describe("LicenseCard dates", () => { it("shows no expiry date for a 100-year key", () => { context = { ...context, info: { ...(context.info as object), expires_at: nowS() + 36525 * DAY_S }, }; const html = render(); expect(html).toContain("license.noExpiry"); expect(html).not.toContain("license.expiresAt"); }); it.each([ ["fr", "fr-CA"], ["en", "en-CA"], ])("formats every date for the app language (%s → %s)", (lng, locale) => { language = lng; machines = hookView(loadedList([machine("a")])); const spy = vi.spyOn(Date.prototype, "toLocaleDateString"); try { const html = render(); const locales = spy.mock.calls.map((call) => call[0]); // The expiry date and the machine's activation date. expect(locales).toHaveLength(2); expect(locales).toEqual([locale, locale]); expect(html).toContain("license.expiresAt"); expect(html).toContain("2036-09-26"); expect(html).toContain("2026-09-01"); } finally { spy.mockRestore(); } }); }); describe("LicenseCard key change", () => { it("remounts for another key, so the previous key's activation and machines are dropped", () => { const first = LicenseCard() as ReactElement; expect(first.key).toBe(licenseIdentity(context.info as LicenseInfo)); context = { ...context, info: { ...(context.info as LicenseInfo), issued_at: 1 } }; const second = LicenseCard() as ReactElement; expect(second.key).not.toBe(first.key); }); });