Skip to content

Commit 01a2189

Browse files
committed
Merge branch 'webui' into feat/webui-settings-shortcuts
The webui baseline's own gates are green again after #266, so this branch is brought up to it before its own checks run.
2 parents edcec43 + b456b9e commit 01a2189

14 files changed

Lines changed: 157 additions & 72 deletions

‎packages/webui/src/client/features/workspace/ui/WorkspacePanels.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -525,7 +525,12 @@ export function WebuiWorkspacePanel({ state, dispatch, sessionId, workspaceDir,
525525
terminal.open(host);
526526
let disposed = false;
527527
let initialFitFrame: number | undefined;
528-
void document.fonts.ready.then(() => {
528+
// The grid is measured once the fonts have landed: a measurement taken
529+
// against fallback glyphs is a measurement of the wrong grid. The wait
530+
// comes from the browser platform, because a panel that reached for
531+
// `document` itself would be a panel that could only be placed against a
532+
// browser.
533+
void dom.whenFontsReady().then(() => {
529534
if (disposed) return;
530535
initialFitFrame = requestAnimationFrame(() => {
531536
if (disposed) return;
@@ -570,7 +575,7 @@ export function WebuiWorkspacePanel({ state, dispatch, sessionId, workspaceDir,
570575
const input = terminal.onData((data) => { if (terminalOwner?.canWrite) void terminalOwner.write(terminalId, data); });
571576
let disposed = false;
572577
let initialFitFrame: number | undefined;
573-
void document.fonts.ready.then(() => {
578+
void dom.whenFontsReady().then(() => {
574579
if (disposed) return;
575580
initialFitFrame = requestAnimationFrame(() => {
576581
if (disposed) return;

‎packages/webui/src/client/platform/browser-dom.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,20 @@ export interface WebuiBrowserDom {
101101
* palette.
102102
*/
103103
readonly readCssVariable: (name: string, fallback: string) => string;
104+
/**
105+
* Resolves once the document's fonts have loaded.
106+
*
107+
* A terminal measures its grid after the fonts land: a measurement taken
108+
* against fallback glyphs is a measurement of the wrong grid, and the shell
109+
* that follows it resizes to a size the terminal never had. The wait
110+
* belongs to the platform for the same reason the resize listener does — a
111+
* component that reaches for `document` directly is a component that cannot
112+
* be placed against anything but a browser.
113+
*
114+
* A platform with no document resolves at once: there are no fonts to wait
115+
* for, so the caller measures the grid it has.
116+
*/
117+
readonly whenFontsReady: () => Promise<void>;
104118
/**
105119
* Creates a drawing surface.
106120
*
@@ -223,6 +237,11 @@ export function createWebuiBrowserDom(
223237
.trim();
224238
return value || fallback;
225239
},
240+
whenFontsReady: () => {
241+
const fonts = documentObject?.fonts;
242+
if (!fonts) return Promise.resolve();
243+
return fonts.ready.then(() => undefined);
244+
},
226245
createCanvas: (width, height) => {
227246
if (!documentObject) return null;
228247
const canvas = documentObject.createElement("canvas");

‎packages/webui/test/browser/architecture-acceptance.spec.mjs‎

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,27 +21,47 @@ for (const state of ["absent", "not-ready"]) {
2121
}, state);
2222
await openApp(page);
2323
await openTerminal(page);
24-
await expect(page.getByRole("button", { name: "新建终端", exact: true })).toBeDisabled();
24+
25+
// The terminal action is off: the panel says why it is off, and it opens
26+
// no terminal and watches none.
2527
await expect(page.getByTestId("workspace-panel")).toContainText(state === "absent" ? "未提供终端" : "终端服务尚未就绪");
28+
await expect.poll(() => page.evaluate(() => window.__fixture.terminals().length)).toBe(0);
29+
expect(await page.evaluate(() => window.__fixture.watchedTerminalIds())).toEqual([]);
30+
31+
// Only that action is off: the rest of the client keeps working.
2632
await expect(page.getByPlaceholder("输入消息…(输入 / 唤起命令)")).toBeEnabled();
33+
34+
// The service comes back. The client re-reads the handshake, so the
35+
// readiness refreshes and the panel stops saying the terminal is missing.
2736
await page.evaluate(() => {
2837
window.__fixture.setResponse("version", { version: "fixture", protocolVersion: 1, capabilities: { terminal: "supported" } });
2938
window.__fixture.dropAll();
3039
});
31-
await expect(page.getByRole("button", { name: "新建终端", exact: true })).toBeEnabled();
40+
await expect(page.getByTestId("workspace-panel")).not.toContainText(state === "absent" ? "未提供终端" : "终端服务尚未就绪", { timeout: 15_000 });
41+
42+
// And the action works again: the terminal opens for the tab that asked
43+
// for it, and the panel watches it.
44+
await page.getByRole("button", { name: "关闭面板" }).click();
45+
await page.getByTestId("workspace-panel-controls").getByRole("button", { name: "浏览器" }).click();
46+
await expect.poll(() => page.evaluate(() => window.__fixture.terminals().length)).toBe(1);
47+
await expect.poll(() => page.evaluate(() => window.__fixture.watchedTerminalIds().length)).toBe(1);
3248
});
3349
}
3450

3551
test("V19/V35 an older roster permits an unverified attempt and permission denial stays a call refusal", async ({ page }) => {
3652
await configureFixture(page, () => window.__fixture.refuseOperation("createTerminal", "permission_denied", "Synthetic terminal permission denied"));
3753
await openApp(page);
3854
await openTerminal(page);
39-
const create = page.getByRole("button", { name: "新建终端", exact: true });
40-
await expect(create).toBeEnabled();
41-
await create.click();
55+
56+
// An older service declares nothing, so the attempt is still permitted:
57+
// the panel does not claim the terminal is missing, and the create goes out.
58+
await expect(page.getByTestId("workspace-panel")).not.toContainText("未提供终端", { timeout: 15_000 });
59+
await expect.poll(() => page.evaluate(() => window.__fixture.requests.some((request) => request.operation === "createTerminal"))).toBe(true);
60+
61+
// The refusal stays a call refusal: the panel reports it and nothing else
62+
// stops working.
4263
await expect(page.getByTestId("workspace-panel")).toContainText("Synthetic terminal permission denied");
43-
await expect(create).toBeEnabled();
44-
await expect(page.getByTestId("workspace-panel")).not.toContainText("未提供终端");
64+
await expect(page.getByPlaceholder("输入消息…(输入 / 唤起命令)")).toBeEnabled();
4565
});
4666

4767
test("V29 protocol incompatibility is a startup failure before business ingress", async ({ page }) => {

‎packages/webui/test/browser/browser-workspace.spec.mjs‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ test("the browser control shows its browser glyph and opens a new tab page by de
5151
await openApp(page);
5252

5353
const browserButton = page.getByTestId("workspace-panel-controls").getByRole("button", { name: "浏览器" });
54-
await expect(browserButton).toHaveAttribute("title", "浏览器");
54+
// The control is an icon, so it carries its name in `aria-label` rather
55+
// than in a tooltip title.
56+
await expect(browserButton).toHaveAttribute("aria-label", "浏览器");
5557
// The control's glyph is the tabler browser icon. It used to be a globe
5658
// drawn as an SVG circle, and the library draws this one as paths, so the
5759
// 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
107109

108110
// The window: a toolbar with the url in an address bar, and the page.
109111
await expect(page.getByTestId("browser-toolbar")).toBeVisible();
110-
await expect(page.getByTestId("browser-toolbar-url")).toHaveText("https://example.test/");
112+
await expect(page.getByTestId("browser-toolbar-url")).toHaveValue("https://example.test/");
111113
await expect(page.getByTestId("browser-panel-frame")).toBeVisible();
112114

113115
// 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",
249251

250252
// Choosing one brings its page to the front, and the address bar names it.
251253
await page.getByRole("tab", { name: "Another Page" }).click();
252-
await expect(page.getByTestId("browser-toolbar-url")).toHaveText("https://another.test/");
254+
await expect(page.getByTestId("browser-toolbar-url")).toHaveValue("https://another.test/");
253255
await expect(page.getByTestId("browser-panel-frame")).toBeVisible();
254256
});
255257

@@ -288,7 +290,7 @@ test("a gesture names the page in front, not the page the Agent is on", async ({
288290
await openApp(page);
289291
await openPanel(page);
290292
await page.getByRole("tab", { name: "Person page" }).click();
291-
await expect(page.getByTestId("browser-toolbar-url")).toHaveText("https://person.test/");
293+
await expect(page.getByTestId("browser-toolbar-url")).toHaveValue("https://person.test/");
292294

293295
await page.getByTestId("browser-panel-frame").click();
294296

‎packages/webui/test/browser/fixture.mjs‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -393,8 +393,11 @@ export function installFixtureTransport() {
393393
// would make every terminal vanish with the page that opened it, and the
394394
// cross-page assertion would then be asserting the fixture rather than the
395395
// client. The runtime's own shape is reproduced exactly — `{terminalId,
396-
// status, output}` per `server/operation/handlers/terminal.ts` — because a
397-
// roster that answered `{}` would only be testing the client's tolerance.
396+
// status, output, workspaceTabId}` per `server/operation/handlers/terminal.ts`
397+
// — because a roster that answered `{}` would only be testing the client's
398+
// tolerance. The `workspaceTabId` is the tab the terminal was opened for:
399+
// the client binds a terminal to the workspace tab that asked for it, and
400+
// reads that binding back out of the roster.
398401
if (operation === "createTerminal") {
399402
const sequence = Number(window.localStorage.getItem(terminalSequenceKey) ?? "0") + 1;
400403
window.localStorage.setItem(terminalSequenceKey, String(sequence));
@@ -406,12 +409,18 @@ export function installFixtureTransport() {
406409
// The synthetic process returns a banner so the client can distinguish
407410
// an opened terminal from an empty xterm host.
408411
output: `synthetic shell ${terminalId}\r\n$ `,
412+
...(typeof body?.workspaceTabId === "string" ? { workspaceTabId: body.workspaceTabId } : {}),
409413
});
410414
writeTerminals(terminals);
411415
return { terminalId, status: "running" };
412416
}
413417
if (operation === "listTerminals")
414-
return readTerminals().map(({ terminalId, status, output }) => ({ terminalId, status, output }));
418+
return readTerminals().map(({ terminalId, status, output, workspaceTabId }) => ({
419+
terminalId,
420+
status,
421+
output,
422+
...(typeof workspaceTabId === "string" ? { workspaceTabId } : {}),
423+
}));
415424
if (operation === "writeTerminal") {
416425
// The echo is what makes a written byte observable in the browser: the
417426
// client sends it, and only a process that answers with it can prove the
@@ -823,7 +832,12 @@ export function installFixtureTransport() {
823832
},
824833
/** The PTY processes the runtime currently holds. */
825834
terminals() {
826-
return readTerminals().map(({ terminalId, status, output }) => ({ terminalId, status, output }));
835+
return readTerminals().map(({ terminalId, status, output, workspaceTabId }) => ({
836+
terminalId,
837+
status,
838+
output,
839+
...(typeof workspaceTabId === "string" ? { workspaceTabId } : {}),
840+
}));
827841
},
828842
/** Which terminals have at least one live watcher attached right now. */
829843
watchedTerminalIds() {

‎packages/webui/test/browser/native-runtime.spec.mjs‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ test("V04/V06: native PTYs survive another page, reload and a full five-slot ros
7171
await expect(page.locator(`[data-webui-session-link="${created.session.sessionId}"]`)).toHaveAttribute("data-webui-session-active", "true")
7272
.catch((error) => { throw new Error(`${error.message}\n${JSON.stringify(wire)}`); });
7373
await openTerminal(page);
74-
await page.getByRole("button", { name: "新建终端" }).click();
74+
// The terminal tab asked the runtime for a terminal, and the runtime made
75+
// one: there is no separate "create" gesture any more.
7576
await expect.poll(async () => (await rpc(page, "listTerminals")).length).toBe(1)
7677
.catch(async (error) => { throw new Error(`${error.message}\n${await page.locator("body").innerText()}`); });
7778
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
101102
const latePage = await context.newPage();
102103
await latePage.goto(url);
103104
await expect(latePage.locator(`[data-webui-session-link="${created.session.sessionId}"]`)).toHaveAttribute("data-webui-session-active", "true");
104-
await openTerminal(latePage);
105105
const spawned = once(child, "message");
106-
await latePage.getByRole("button", { name: "新建终端" }).click();
106+
// The terminal tab asks for its terminal as soon as it is open, so the hold
107+
// has to be armed before the tab is.
108+
await openTerminal(latePage);
107109
expect((await spawned)[0]).toEqual({ created: true });
108110
await latePage.close();
109111
const released = once(child, "message"); child.send("release-create"); await released;

‎packages/webui/test/browser/terminal-cross-page.spec.mjs‎

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66
// was no longer listed or watchable anywhere. The Terminal owner now holds that
77
// truth, and process lifetime belongs to `disposeProcess` / `disposeAll`.
88
//
9+
// A terminal is opened for a workspace tab, and the runtime records that
10+
// binding: the tab that asked for the PTY is the tab that finds it again in
11+
// the roster. Two pages that open the same terminal tab therefore meet the
12+
// same process, and the panel that goes away releases its watch without
13+
// ending it.
14+
//
915
// A unit test can prove the owner keeps a record, but only the built client can
1016
// prove what the row is actually about: that a PTY created while session A was
1117
// on screen is still listed, still watched and still producing output after the
@@ -32,20 +38,14 @@ const openTerminalTab = async (page) => {
3238
await expect(page.getByTestId("workspace-panel")).toHaveAttribute("data-active-tab", "terminal");
3339
};
3440

35-
const createTerminal = async (page) => {
36-
await page.getByRole("button", { name: "新建终端" }).click();
37-
await expect
38-
.poll(() => page.evaluate(() => window.__fixture.terminals().length))
39-
.toBeGreaterThan(0);
40-
};
41-
4241
test("a PTY created on one page is listed and watched from another", async ({ page }) => {
4342
await configureFixture(page, () => undefined);
4443
await openApp(page);
4544

4645
await openTerminalTab(page);
47-
await createTerminal(page);
48-
46+
// The tab asked the runtime for a terminal, and the runtime made one: the
47+
// process belongs to the runtime, not to the tab that opened it.
48+
await expect.poll(() => page.evaluate(() => window.__fixture.terminals().length)).toBe(1);
4949
const created = await page.evaluate(() => window.__fixture.terminals());
5050
expect(created).toHaveLength(1);
5151
const { terminalId } = created[0];
@@ -61,7 +61,9 @@ test("a PTY created on one page is listed and watched from another", async ({ pa
6161
await configureFixture(pageB, () => undefined);
6262
await openApp(pageB);
6363
await openTerminalTab(pageB);
64-
await expect(pageB.getByTestId("workspace-panel")).toContainText("#1");
64+
// The catalogue the runtime answers is the runtime's: it still holds the PTY
65+
// the first page opened, and the second page adopts that process rather than
66+
// asking for another one.
6567
await expect
6668
.poll(() => pageB.evaluate(() => window.__fixture.watchedTerminalIds()))
6769
.toContain(terminalId);
@@ -84,7 +86,7 @@ test("a PTY created on one page is listed and watched from another", async ({ pa
8486
expect(await page.evaluate(() => window.__fixture.terminals()[0]?.status)).toBe("running");
8587

8688
// The roster holds exactly the PTY created before the round trip. A fixture
87-
// or an owner that re-created on attach would show two tabs here.
89+
// or an owner that re-created on attach would show two rows here.
8890
const final = await page.evaluate(() => window.__fixture.terminals());
8991
expect(final).toHaveLength(1);
9092
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
9799
expect(delivered).toBe(1);
98100
});
99101

100-
test("a watch is released without ending the process, and a disposed PTY does not come back", async ({
102+
test("a watch is released without ending the process, and an ended PTY does not come back", async ({
101103
page,
102104
}) => {
103105
await configureFixture(page, () => undefined);
104106
await openApp(page);
105107

106108
await openTerminalTab(page);
107-
await createTerminal(page);
108-
109+
await expect.poll(() => page.evaluate(() => window.__fixture.terminals().length)).toBe(1);
109110
const { terminalId } = (await page.evaluate(() => window.__fixture.terminals()))[0];
110111

111112
// 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
119120
expect(stillRunning).toHaveLength(1);
120121
expect(stillRunning[0].status).toBe("running");
121122

122-
// An explicit close is the other half: it ends the process, and the roster
123-
// loses the row rather than reporting it as exited-but-present.
124-
await openTerminalTab(page);
125-
await page.locator(".webui-terminal-actions").getByRole("button", { name: "关闭" }).click();
126-
127-
await expect
128-
.poll(() => page.evaluate(() => window.__fixture.terminals().length))
129-
.toBe(0);
130-
await expect
131-
.poll(() => page.evaluate(() => window.__fixture.watchedTerminalIds()))
132-
.not.toContain(terminalId);
123+
// The other half: the process ends on the runtime's side. The roster keeps
124+
// the row, so the client still knows that tab's terminal, and it must not
125+
// answer the ending by opening another process for the same tab.
126+
const creates = () => page.evaluate(() => window.__fixture.requests.filter(
127+
(request) => request.operation === "createTerminal",
128+
).length);
129+
const before = await creates();
130+
expect(await page.evaluate((id) => window.__fixture.exitTerminal(id), terminalId)).toBe(true);
131+
await page.getByTestId("workspace-panel-controls").getByRole("button", { name: "浏览器" }).click();
132+
await expect(page.getByTestId("workspace-panel")).toHaveAttribute("data-active-tab", "terminal");
133+
await expect.poll(() => page.evaluate(() => window.__fixture.terminals()[0]?.status)).toBe("exited");
134+
expect(await creates()).toBe(before);
133135
});
4.01 KB
Loading

‎packages/webui/test/integration/workspace-browser-tab-acceptance.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -530,7 +530,10 @@ describe.skipIf(!available)("the acceptance of the workspace browser tab model",
530530
const state = reduceWorkspacePanelState(panel(), { type: "open-new-tab" });
531531
const tabId = state.activeTabId!;
532532
const url = webuiWorkspaceLauncherUrl("example.test");
533-
expect(url).toBe("https://example.test");
533+
// The launcher hands the browser the same canonical address the
534+
// address bar uses, so a bare host arrives with the root path the
535+
// URL parser gives it.
536+
expect(url).toBe("https://example.test/");
534537

535538
const entry = handler("openBrowserWebTab");
536539
const opened = (await entry.handle(

‎packages/webui/test/unit/browser-web-tab-toolbar.test.tsx‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -136,11 +136,12 @@ describe("a web tab's toolbar", () => {
136136
});
137137

138138
it("is carried by the browser business's host, over the tab's target", () => {
139-
// The toolbar is rendered by the host the strip mounts, and the strip hands
140-
// it the tab's own target and url — so the row and the frame describe the
141-
// same page rather than a pair that has drifted.
142-
expect(WORKSPACE_PANELS_SOURCE).toContain("canGoBack={activeTab.content.canGoBack}");
143-
expect(WORKSPACE_PANELS_SOURCE).toContain("canGoForward={activeTab.content.canGoForward}");
139+
// The toolbar is rendered by the host the panel mounts, and the panel hands
140+
// it one tab's own target and url — so the row and the frame describe the
141+
// same page rather than a pair that has drifted. That tab is the one the
142+
// panel kept mounted, which is the one whose frame the reader is looking at.
143+
expect(WORKSPACE_PANELS_SOURCE).toContain("canGoBack={browserTab.content.canGoBack}");
144+
expect(WORKSPACE_PANELS_SOURCE).toContain("canGoForward={browserTab.content.canGoForward}");
144145
expect(WORKSPACE_PANELS_SOURCE).toContain('type: "return-web-tab-to-launcher"');
145146
});
146147

0 commit comments

Comments
 (0)