Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
19 changes: 19 additions & 0 deletions packages/webui/src/client/platform/browser-dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
/**
* Creates a drawing surface.
*
Expand Down Expand Up @@ -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");
Expand Down
34 changes: 27 additions & 7 deletions packages/webui/test/browser/architecture-acceptance.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,27 +21,47 @@ 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);
});
}

test("V19/V35 an older roster permits an unverified attempt and permission denial stays a call refusal", async ({ page }) => {
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 }) => {
Expand Down
10 changes: 6 additions & 4 deletions packages/webui/test/browser/browser-workspace.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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();

Expand Down
22 changes: 18 additions & 4 deletions packages/webui/test/browser/fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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
Expand Down Expand Up @@ -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() {
Expand Down
8 changes: 5 additions & 3 deletions packages/webui/test/browser/native-runtime.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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;
Expand Down
52 changes: 27 additions & 25 deletions packages/webui/test/browser/terminal-cross-page.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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];
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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
Expand All @@ -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);
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
11 changes: 6 additions & 5 deletions packages/webui/test/unit/browser-web-tab-toolbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"');
});

Expand Down
Loading
Loading