diff --git a/.gitleaks.toml b/.gitleaks.toml index b222e6bb7..e14d42375 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -19,6 +19,13 @@ paths = ['''(^|/)packages/(browser-extension/test/protocol\.test\.ts|webui/test/ regexTarget = "secret" regexes = ['''^00112233445566778899aabbccddeeff$'''] +[[rules.allowlists]] +description = "WebUI shortcut storage key name, not a credential" +condition = "AND" +paths = ['''(^|/)packages/webui/src/client/projection/common/shortcuts\.ts$'''] +regexTarget = "match" +regexes = ['''^SHORTCUT_STORAGE_KEY = "webui-shortcuts\.v1"$'''] + [[rules.allowlists]] description = "Documented keyboard shortcut" condition = "AND" diff --git a/packages/webui/src/client/features/workspace/ui/WorkspacePanels.tsx b/packages/webui/src/client/features/workspace/ui/WorkspacePanels.tsx index 3cab3ec5a..dc1139c08 100644 --- a/packages/webui/src/client/features/workspace/ui/WorkspacePanels.tsx +++ b/packages/webui/src/client/features/workspace/ui/WorkspacePanels.tsx @@ -525,7 +525,12 @@ export function WebuiWorkspacePanel({ state, dispatch, sessionId, workspaceDir, terminal.open(host); let disposed = false; let initialFitFrame: number | undefined; - void document.fonts.ready.then(() => { + // The grid is measured once the fonts have landed: a measurement taken + // against fallback glyphs is a measurement of the wrong grid. The wait + // comes from the browser platform, because a panel that reached for + // `document` itself would be a panel that could only be placed against a + // browser. + void dom.whenFontsReady().then(() => { if (disposed) return; initialFitFrame = requestAnimationFrame(() => { if (disposed) return; @@ -570,7 +575,7 @@ export function WebuiWorkspacePanel({ state, dispatch, sessionId, workspaceDir, const input = terminal.onData((data) => { if (terminalOwner?.canWrite) void terminalOwner.write(terminalId, data); }); let disposed = false; let initialFitFrame: number | undefined; - void document.fonts.ready.then(() => { + void dom.whenFontsReady().then(() => { if (disposed) return; initialFitFrame = requestAnimationFrame(() => { if (disposed) return; diff --git a/packages/webui/src/client/platform/browser-dom.ts b/packages/webui/src/client/platform/browser-dom.ts index 369074c83..ec2c93351 100644 --- a/packages/webui/src/client/platform/browser-dom.ts +++ b/packages/webui/src/client/platform/browser-dom.ts @@ -101,6 +101,20 @@ export interface WebuiBrowserDom { * palette. */ readonly readCssVariable: (name: string, fallback: string) => string; + /** + * Resolves once the document's fonts have loaded. + * + * A terminal measures its grid after the fonts land: a measurement taken + * against fallback glyphs is a measurement of the wrong grid, and the shell + * that follows it resizes to a size the terminal never had. The wait + * belongs to the platform for the same reason the resize listener does — a + * component that reaches for `document` directly is a component that cannot + * be placed against anything but a browser. + * + * A platform with no document resolves at once: there are no fonts to wait + * for, so the caller measures the grid it has. + */ + readonly whenFontsReady: () => Promise; /** * Creates a drawing surface. * @@ -223,6 +237,11 @@ export function createWebuiBrowserDom( .trim(); return value || fallback; }, + whenFontsReady: () => { + const fonts = documentObject?.fonts; + if (!fonts) return Promise.resolve(); + return fonts.ready.then(() => undefined); + }, createCanvas: (width, height) => { if (!documentObject) return null; const canvas = documentObject.createElement("canvas"); diff --git a/packages/webui/test/browser/architecture-acceptance.spec.mjs b/packages/webui/test/browser/architecture-acceptance.spec.mjs index 82eebf9a6..261e436dd 100644 --- a/packages/webui/test/browser/architecture-acceptance.spec.mjs +++ b/packages/webui/test/browser/architecture-acceptance.spec.mjs @@ -21,14 +21,30 @@ for (const state of ["absent", "not-ready"]) { }, state); await openApp(page); await openTerminal(page); - await expect(page.getByRole("button", { name: "新建终端", exact: true })).toBeDisabled(); + + // The terminal action is off: the panel says why it is off, and it opens + // no terminal and watches none. await expect(page.getByTestId("workspace-panel")).toContainText(state === "absent" ? "未提供终端" : "终端服务尚未就绪"); + await expect.poll(() => page.evaluate(() => window.__fixture.terminals().length)).toBe(0); + expect(await page.evaluate(() => window.__fixture.watchedTerminalIds())).toEqual([]); + + // Only that action is off: the rest of the client keeps working. await expect(page.getByPlaceholder("输入消息…(输入 / 唤起命令)")).toBeEnabled(); + + // The service comes back. The client re-reads the handshake, so the + // readiness refreshes and the panel stops saying the terminal is missing. await page.evaluate(() => { window.__fixture.setResponse("version", { version: "fixture", protocolVersion: 1, capabilities: { terminal: "supported" } }); window.__fixture.dropAll(); }); - await expect(page.getByRole("button", { name: "新建终端", exact: true })).toBeEnabled(); + await expect(page.getByTestId("workspace-panel")).not.toContainText(state === "absent" ? "未提供终端" : "终端服务尚未就绪", { timeout: 15_000 }); + + // And the action works again: the terminal opens for the tab that asked + // for it, and the panel watches it. + await page.getByRole("button", { name: "关闭面板" }).click(); + await page.getByTestId("workspace-panel-controls").getByRole("button", { name: "浏览器" }).click(); + await expect.poll(() => page.evaluate(() => window.__fixture.terminals().length)).toBe(1); + await expect.poll(() => page.evaluate(() => window.__fixture.watchedTerminalIds().length)).toBe(1); }); } @@ -36,12 +52,16 @@ test("V19/V35 an older roster permits an unverified attempt and permission denia await configureFixture(page, () => window.__fixture.refuseOperation("createTerminal", "permission_denied", "Synthetic terminal permission denied")); await openApp(page); await openTerminal(page); - const create = page.getByRole("button", { name: "新建终端", exact: true }); - await expect(create).toBeEnabled(); - await create.click(); + + // An older service declares nothing, so the attempt is still permitted: + // the panel does not claim the terminal is missing, and the create goes out. + await expect(page.getByTestId("workspace-panel")).not.toContainText("未提供终端", { timeout: 15_000 }); + await expect.poll(() => page.evaluate(() => window.__fixture.requests.some((request) => request.operation === "createTerminal"))).toBe(true); + + // The refusal stays a call refusal: the panel reports it and nothing else + // stops working. await expect(page.getByTestId("workspace-panel")).toContainText("Synthetic terminal permission denied"); - await expect(create).toBeEnabled(); - await expect(page.getByTestId("workspace-panel")).not.toContainText("未提供终端"); + await expect(page.getByPlaceholder("输入消息…(输入 / 唤起命令)")).toBeEnabled(); }); test("V29 protocol incompatibility is a startup failure before business ingress", async ({ page }) => { diff --git a/packages/webui/test/browser/browser-workspace.spec.mjs b/packages/webui/test/browser/browser-workspace.spec.mjs index b19096a6c..f6f405cab 100644 --- a/packages/webui/test/browser/browser-workspace.spec.mjs +++ b/packages/webui/test/browser/browser-workspace.spec.mjs @@ -51,7 +51,9 @@ test("the browser control shows its browser glyph and opens a new tab page by de await openApp(page); const browserButton = page.getByTestId("workspace-panel-controls").getByRole("button", { name: "浏览器" }); - await expect(browserButton).toHaveAttribute("title", "浏览器"); + // The control is an icon, so it carries its name in `aria-label` rather + // than in a tooltip title. + await expect(browserButton).toHaveAttribute("aria-label", "浏览器"); // The control's glyph is the tabler browser icon. It used to be a globe // drawn as an SVG circle, and the library draws this one as paths, so the // assertion names the icon rather than the shape it happens to be made of. @@ -107,7 +109,7 @@ test("the launcher's address box opens a page, and the panel is a window around // The window: a toolbar with the url in an address bar, and the page. await expect(page.getByTestId("browser-toolbar")).toBeVisible(); - await expect(page.getByTestId("browser-toolbar-url")).toHaveText("https://example.test/"); + await expect(page.getByTestId("browser-toolbar-url")).toHaveValue("https://example.test/"); await expect(page.getByTestId("browser-panel-frame")).toBeVisible(); // A window has no heading above its page, and no list of the browser's pages @@ -249,7 +251,7 @@ test("the strip shows the page the runtime published, and the panel follows it", // Choosing one brings its page to the front, and the address bar names it. await page.getByRole("tab", { name: "Another Page" }).click(); - await expect(page.getByTestId("browser-toolbar-url")).toHaveText("https://another.test/"); + await expect(page.getByTestId("browser-toolbar-url")).toHaveValue("https://another.test/"); await expect(page.getByTestId("browser-panel-frame")).toBeVisible(); }); @@ -288,7 +290,7 @@ test("a gesture names the page in front, not the page the Agent is on", async ({ await openApp(page); await openPanel(page); await page.getByRole("tab", { name: "Person page" }).click(); - await expect(page.getByTestId("browser-toolbar-url")).toHaveText("https://person.test/"); + await expect(page.getByTestId("browser-toolbar-url")).toHaveValue("https://person.test/"); await page.getByTestId("browser-panel-frame").click(); diff --git a/packages/webui/test/browser/fixture.mjs b/packages/webui/test/browser/fixture.mjs index 298a0dce5..a738c0080 100644 --- a/packages/webui/test/browser/fixture.mjs +++ b/packages/webui/test/browser/fixture.mjs @@ -393,8 +393,11 @@ export function installFixtureTransport() { // would make every terminal vanish with the page that opened it, and the // cross-page assertion would then be asserting the fixture rather than the // client. The runtime's own shape is reproduced exactly — `{terminalId, - // status, output}` per `server/operation/handlers/terminal.ts` — because a - // roster that answered `{}` would only be testing the client's tolerance. + // status, output, workspaceTabId}` per `server/operation/handlers/terminal.ts` + // — because a roster that answered `{}` would only be testing the client's + // tolerance. The `workspaceTabId` is the tab the terminal was opened for: + // the client binds a terminal to the workspace tab that asked for it, and + // reads that binding back out of the roster. if (operation === "createTerminal") { const sequence = Number(window.localStorage.getItem(terminalSequenceKey) ?? "0") + 1; window.localStorage.setItem(terminalSequenceKey, String(sequence)); @@ -406,12 +409,18 @@ export function installFixtureTransport() { // The synthetic process returns a banner so the client can distinguish // an opened terminal from an empty xterm host. output: `synthetic shell ${terminalId}\r\n$ `, + ...(typeof body?.workspaceTabId === "string" ? { workspaceTabId: body.workspaceTabId } : {}), }); writeTerminals(terminals); return { terminalId, status: "running" }; } if (operation === "listTerminals") - return readTerminals().map(({ terminalId, status, output }) => ({ terminalId, status, output })); + return readTerminals().map(({ terminalId, status, output, workspaceTabId }) => ({ + terminalId, + status, + output, + ...(typeof workspaceTabId === "string" ? { workspaceTabId } : {}), + })); if (operation === "writeTerminal") { // The echo is what makes a written byte observable in the browser: the // client sends it, and only a process that answers with it can prove the @@ -823,7 +832,12 @@ export function installFixtureTransport() { }, /** The PTY processes the runtime currently holds. */ terminals() { - return readTerminals().map(({ terminalId, status, output }) => ({ terminalId, status, output })); + return readTerminals().map(({ terminalId, status, output, workspaceTabId }) => ({ + terminalId, + status, + output, + ...(typeof workspaceTabId === "string" ? { workspaceTabId } : {}), + })); }, /** Which terminals have at least one live watcher attached right now. */ watchedTerminalIds() { diff --git a/packages/webui/test/browser/native-runtime.spec.mjs b/packages/webui/test/browser/native-runtime.spec.mjs index 792b27f6a..0a671bea4 100644 --- a/packages/webui/test/browser/native-runtime.spec.mjs +++ b/packages/webui/test/browser/native-runtime.spec.mjs @@ -71,7 +71,8 @@ test("V04/V06: native PTYs survive another page, reload and a full five-slot ros await expect(page.locator(`[data-webui-session-link="${created.session.sessionId}"]`)).toHaveAttribute("data-webui-session-active", "true") .catch((error) => { throw new Error(`${error.message}\n${JSON.stringify(wire)}`); }); await openTerminal(page); - await page.getByRole("button", { name: "新建终端" }).click(); + // The terminal tab asked the runtime for a terminal, and the runtime made + // one: there is no separate "create" gesture any more. await expect.poll(async () => (await rpc(page, "listTerminals")).length).toBe(1) .catch(async (error) => { throw new Error(`${error.message}\n${await page.locator("body").innerText()}`); }); const [terminal] = await rpc(page, "listTerminals"); @@ -101,9 +102,10 @@ test("V04/V06: native PTYs survive another page, reload and a full five-slot ros const latePage = await context.newPage(); await latePage.goto(url); await expect(latePage.locator(`[data-webui-session-link="${created.session.sessionId}"]`)).toHaveAttribute("data-webui-session-active", "true"); - await openTerminal(latePage); const spawned = once(child, "message"); - await latePage.getByRole("button", { name: "新建终端" }).click(); + // The terminal tab asks for its terminal as soon as it is open, so the hold + // has to be armed before the tab is. + await openTerminal(latePage); expect((await spawned)[0]).toEqual({ created: true }); await latePage.close(); const released = once(child, "message"); child.send("release-create"); await released; diff --git a/packages/webui/test/browser/terminal-cross-page.spec.mjs b/packages/webui/test/browser/terminal-cross-page.spec.mjs index cbd5b3ca5..378337eea 100644 --- a/packages/webui/test/browser/terminal-cross-page.spec.mjs +++ b/packages/webui/test/browser/terminal-cross-page.spec.mjs @@ -6,6 +6,12 @@ // was no longer listed or watchable anywhere. The Terminal owner now holds that // truth, and process lifetime belongs to `disposeProcess` / `disposeAll`. // +// A terminal is opened for a workspace tab, and the runtime records that +// binding: the tab that asked for the PTY is the tab that finds it again in +// the roster. Two pages that open the same terminal tab therefore meet the +// same process, and the panel that goes away releases its watch without +// ending it. +// // A unit test can prove the owner keeps a record, but only the built client can // prove what the row is actually about: that a PTY created while session A was // on screen is still listed, still watched and still producing output after the @@ -32,20 +38,14 @@ const openTerminalTab = async (page) => { await expect(page.getByTestId("workspace-panel")).toHaveAttribute("data-active-tab", "terminal"); }; -const createTerminal = async (page) => { - await page.getByRole("button", { name: "新建终端" }).click(); - await expect - .poll(() => page.evaluate(() => window.__fixture.terminals().length)) - .toBeGreaterThan(0); -}; - test("a PTY created on one page is listed and watched from another", async ({ page }) => { await configureFixture(page, () => undefined); await openApp(page); await openTerminalTab(page); - await createTerminal(page); - + // The tab asked the runtime for a terminal, and the runtime made one: the + // process belongs to the runtime, not to the tab that opened it. + await expect.poll(() => page.evaluate(() => window.__fixture.terminals().length)).toBe(1); const created = await page.evaluate(() => window.__fixture.terminals()); expect(created).toHaveLength(1); const { terminalId } = created[0]; @@ -61,7 +61,9 @@ test("a PTY created on one page is listed and watched from another", async ({ pa await configureFixture(pageB, () => undefined); await openApp(pageB); await openTerminalTab(pageB); - await expect(pageB.getByTestId("workspace-panel")).toContainText("#1"); + // The catalogue the runtime answers is the runtime's: it still holds the PTY + // the first page opened, and the second page adopts that process rather than + // asking for another one. await expect .poll(() => pageB.evaluate(() => window.__fixture.watchedTerminalIds())) .toContain(terminalId); @@ -84,7 +86,7 @@ test("a PTY created on one page is listed and watched from another", async ({ pa expect(await page.evaluate(() => window.__fixture.terminals()[0]?.status)).toBe("running"); // The roster holds exactly the PTY created before the round trip. A fixture - // or an owner that re-created on attach would show two tabs here. + // or an owner that re-created on attach would show two rows here. const final = await page.evaluate(() => window.__fixture.terminals()); expect(final).toHaveLength(1); expect(final[0].terminalId).toBe(terminalId); @@ -97,15 +99,14 @@ test("a PTY created on one page is listed and watched from another", async ({ pa expect(delivered).toBe(1); }); -test("a watch is released without ending the process, and a disposed PTY does not come back", async ({ +test("a watch is released without ending the process, and an ended PTY does not come back", async ({ page, }) => { await configureFixture(page, () => undefined); await openApp(page); await openTerminalTab(page); - await createTerminal(page); - + await expect.poll(() => page.evaluate(() => window.__fixture.terminals().length)).toBe(1); const { terminalId } = (await page.evaluate(() => window.__fixture.terminals()))[0]; // Closing the panel detaches the watch. The row is about the two halves being @@ -119,15 +120,16 @@ test("a watch is released without ending the process, and a disposed PTY does no expect(stillRunning).toHaveLength(1); expect(stillRunning[0].status).toBe("running"); - // An explicit close is the other half: it ends the process, and the roster - // loses the row rather than reporting it as exited-but-present. - await openTerminalTab(page); - await page.locator(".webui-terminal-actions").getByRole("button", { name: "关闭" }).click(); - - await expect - .poll(() => page.evaluate(() => window.__fixture.terminals().length)) - .toBe(0); - await expect - .poll(() => page.evaluate(() => window.__fixture.watchedTerminalIds())) - .not.toContain(terminalId); + // The other half: the process ends on the runtime's side. The roster keeps + // the row, so the client still knows that tab's terminal, and it must not + // answer the ending by opening another process for the same tab. + const creates = () => page.evaluate(() => window.__fixture.requests.filter( + (request) => request.operation === "createTerminal", + ).length); + const before = await creates(); + expect(await page.evaluate((id) => window.__fixture.exitTerminal(id), terminalId)).toBe(true); + await page.getByTestId("workspace-panel-controls").getByRole("button", { name: "浏览器" }).click(); + await expect(page.getByTestId("workspace-panel")).toHaveAttribute("data-active-tab", "terminal"); + await expect.poll(() => page.evaluate(() => window.__fixture.terminals()[0]?.status)).toBe("exited"); + expect(await creates()).toBe(before); }); diff --git a/packages/webui/test/browser/visual-baselines.spec.mjs-snapshots/embedded-browser-panel-linux.png b/packages/webui/test/browser/visual-baselines.spec.mjs-snapshots/embedded-browser-panel-linux.png index 4e8c5cd13..03108e2ea 100644 Binary files a/packages/webui/test/browser/visual-baselines.spec.mjs-snapshots/embedded-browser-panel-linux.png and b/packages/webui/test/browser/visual-baselines.spec.mjs-snapshots/embedded-browser-panel-linux.png differ diff --git a/packages/webui/test/integration/workspace-browser-tab-acceptance.test.ts b/packages/webui/test/integration/workspace-browser-tab-acceptance.test.ts index 02aea1ec9..841302446 100644 --- a/packages/webui/test/integration/workspace-browser-tab-acceptance.test.ts +++ b/packages/webui/test/integration/workspace-browser-tab-acceptance.test.ts @@ -530,7 +530,10 @@ describe.skipIf(!available)("the acceptance of the workspace browser tab model", const state = reduceWorkspacePanelState(panel(), { type: "open-new-tab" }); const tabId = state.activeTabId!; const url = webuiWorkspaceLauncherUrl("example.test"); - expect(url).toBe("https://example.test"); + // The launcher hands the browser the same canonical address the + // address bar uses, so a bare host arrives with the root path the + // URL parser gives it. + expect(url).toBe("https://example.test/"); const entry = handler("openBrowserWebTab"); const opened = (await entry.handle( diff --git a/packages/webui/test/unit/browser-web-tab-toolbar.test.tsx b/packages/webui/test/unit/browser-web-tab-toolbar.test.tsx index 8c58d40d4..59c7bc0ed 100644 --- a/packages/webui/test/unit/browser-web-tab-toolbar.test.tsx +++ b/packages/webui/test/unit/browser-web-tab-toolbar.test.tsx @@ -136,11 +136,12 @@ describe("a web tab's toolbar", () => { }); it("is carried by the browser business's host, over the tab's target", () => { - // The toolbar is rendered by the host the strip mounts, and the strip hands - // it the tab's own target and url — so the row and the frame describe the - // same page rather than a pair that has drifted. - expect(WORKSPACE_PANELS_SOURCE).toContain("canGoBack={activeTab.content.canGoBack}"); - expect(WORKSPACE_PANELS_SOURCE).toContain("canGoForward={activeTab.content.canGoForward}"); + // The toolbar is rendered by the host the panel mounts, and the panel hands + // it one tab's own target and url — so the row and the frame describe the + // same page rather than a pair that has drifted. That tab is the one the + // panel kept mounted, which is the one whose frame the reader is looking at. + expect(WORKSPACE_PANELS_SOURCE).toContain("canGoBack={browserTab.content.canGoBack}"); + expect(WORKSPACE_PANELS_SOURCE).toContain("canGoForward={browserTab.content.canGoForward}"); expect(WORKSPACE_PANELS_SOURCE).toContain('type: "return-web-tab-to-launcher"'); }); diff --git a/packages/webui/test/unit/webui-shell.test.ts b/packages/webui/test/unit/webui-shell.test.ts index d96f8ddfe..9225bc9f1 100644 --- a/packages/webui/test/unit/webui-shell.test.ts +++ b/packages/webui/test/unit/webui-shell.test.ts @@ -315,7 +315,10 @@ describe("WebUI shell", () => { const controls = renderToStaticMarkup(createElement(WebuiWorkspacePanelControls, { browserPanelOpen: false, progressPanelOpen: true, onOpenBrowser: () => undefined, onToggleProgressPanel: () => undefined })); expect(controls).toContain('aria-label="浏览器"'); - expect(controls).toContain('title="浏览器"'); + // The control is an icon button now, so the label is the accessible name + // rather than a tooltip: `aria-label` is what a screen reader reads, and a + // `title` would only add a hover affordance the glyph already carries. + expect(controls).not.toContain('title="浏览器"'); // The control's glyph is the tabler browser icon. It used to be a globe // drawn by hand, so the assertion names the icon rather than the shape it // happens to be made of. diff --git a/packages/webui/test/unit/webui-w0-css-structure.test.ts b/packages/webui/test/unit/webui-w0-css-structure.test.ts index ccdcc26d5..9255f555a 100644 --- a/packages/webui/test/unit/webui-w0-css-structure.test.ts +++ b/packages/webui/test/unit/webui-w0-css-structure.test.ts @@ -238,7 +238,11 @@ beforeAll(() => { .map((match) => match[1]) .filter( (specifier) => - !specifier.startsWith("katex/") && + // Only the shell's own stylesheets count. A package stylesheet — + // `katex/`, and now `@xterm/` — is not the shell's CSS: its rules sit + // outside the components layer, so counting them would answer questions + // the assertions below never asked. + specifier.startsWith(".") && !carved.has(specifier.replace(/^.*\//u, "")), ) .map((specifier) => path.resolve(stylesDir, specifier)); @@ -512,6 +516,9 @@ describe("W0 · stylesheet composition", () => { expect(imports).toEqual([ "./tokens.css", "./shell.css", + // The terminal's own stylesheet, brought in by the package that owns it + // rather than copied into the shell's tree. + "@xterm/xterm/css/xterm.css", // The shared dropdown surface: the look, the motion and the shape every // floating popup in the client shares. "./dropdown.css", diff --git a/packages/webui/test/unit/workspace-panel-state.test.ts b/packages/webui/test/unit/workspace-panel-state.test.ts index efdd56b54..a00e8582b 100644 --- a/packages/webui/test/unit/workspace-panel-state.test.ts +++ b/packages/webui/test/unit/workspace-panel-state.test.ts @@ -417,14 +417,14 @@ describe("the terminal roster is read before it is shown", () => { it("does not paint an empty roster before the first answer lands", () => { // On a reload whose sessions still hold PTYs, an empty first paint reads as - // "all my terminals exited" and offers a create button that would exceed the + // "all my terminals exited" and invites a create that would exceed the // limit. The panel waits for the roster instead. expect(panelSource).toContain("terminalsReady"); - expect(panelSource).toContain("{!terminalsReady ? <>

{DESKTOP_COPY.terminalLoading}

"); - // The standalone create button lives outside the ready/not-ready branch, so - // it is gated explicitly: offering it before the roster lands is what invites - // the create that exceeds the limit. - expect(panelSource).toContain("terminalsReady && terminals.length === 0"); + expect(panelSource).toContain("{!terminalsReady ? DESKTOP_COPY.terminalLoading"); + // The create waits for the roster too: the panel opens a terminal only once + // the first answer has landed, so a reload cannot open a second shell + // before the roster has said what is already running. + expect(panelSource).toContain("!terminalsReady || terminalTargetId"); }); it("drops a roster read that a later read has superseded", () => { @@ -435,16 +435,20 @@ describe("the terminal roster is read before it is shown", () => { expect(panelSource).toContain("if (rosterRead.current !== read) return;"); }); - it("says the limit rather than discovering it through a refused create", () => { - // The panel knows the rule the process half enforces, so a full set is - // stated before the request instead of after it. - expect(panelSource).toContain("const DESKTOP_TERMINAL_LIMIT = 5;"); - expect(panelSource).toContain("const terminalSlotsFull = terminals.length >= DESKTOP_TERMINAL_LIMIT;"); - expect(panelSource).toContain("const terminalCreateDisabled = terminalSlotsFull || !terminalOwner?.canCreate;"); - expect(panelSource).toContain("disabled={terminalCreateDisabled}"); - // One helper, not three copies of the create-then-catch dance. - const createHandlers = panelSource.split("onClick={createTerminal}").length - 1; - expect(createHandlers).toBe(2); - expect(panelSource).not.toContain("terminalOwner.create(activeWorkspace).then"); + it("states what the terminal service says rather than discovering a refusal", () => { + // The limit is the process half's rule now, so the panel keeps no copy of + // it. It asks the owner what it can do and says that answer — an absent + // service, a service that is not ready, an interface that does not match — + // instead of letting a refused create speak for itself. + expect(panelSource).toContain("terminalSupport"); + expect(panelSource).toContain("terminalAvailability"); + expect(panelSource).toContain("terminalError"); + expect(panelSource).toContain("data-webui-terminal-availability={terminalSupport}"); + // The create is gated by what the owner can do, so the panel never opens a + // terminal the service would refuse. + expect(panelSource).toContain("!terminalOwner?.canCreate"); + // One create call, not a copy per button. + const createCalls = panelSource.split("terminalOwner.create(").length - 1; + expect(createCalls).toBe(1); }); }); diff --git a/packages/webui/test/unit/workspace-tab-strip.test.tsx b/packages/webui/test/unit/workspace-tab-strip.test.tsx index 348bbfa7a..5e28f7eab 100644 --- a/packages/webui/test/unit/workspace-tab-strip.test.tsx +++ b/packages/webui/test/unit/workspace-tab-strip.test.tsx @@ -415,11 +415,14 @@ describe("the new tab page", () => { }); it("passes a bare host to the browser as a url it can open", () => { - expect(webuiWorkspaceLauncherUrl("example.test")).toBe("https://example.test"); + // The launcher hands the browser its own address form: a bare host is a + // web address, and the browser's normalisation is what says which one it + // is. A host with no path keeps the browser's own trailing slash. + expect(webuiWorkspaceLauncherUrl("example.test")).toBe("https://example.test/"); expect(webuiWorkspaceLauncherUrl(" example.test/a ")).toBe("https://example.test/a"); expect(webuiWorkspaceLauncherUrl("http://example.test/a")).toBe("http://example.test/a"); // A host with a port is a host, not a scheme. - expect(webuiWorkspaceLauncherUrl("localhost:3000")).toBe("https://localhost:3000"); + expect(webuiWorkspaceLauncherUrl("localhost:3000")).toBe("https://localhost:3000/"); // An empty box opens nothing rather than opening a blank page. expect(webuiWorkspaceLauncherUrl(" ")).toBe(""); });