mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
* feat(app): follow system vertical bars and fold divisions Keep custom navigation controls outside system content areas and use active division regions for temporary panel allocation. Normal sidebar preferences resume when the division disappears. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): show window actions in the active portal scope The bottom-sheet provider creates its own portal context. Register the window action host within that context so symbols reach the root host. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): hide workspace bar actions behind compact panels Compact panels keep the workspace route focused while covering its content. Hide its portal actions until the workspace panel is active again. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): continue the sidebar surface across divisions Keep the fold free of controls while painting it as part of the sidebar, with the shared divider at the workspace edge. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): omit tab count beside the full tab rail The full tab rail already exposes navigation. Use the same compact/full header selection for the vertical action list. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): preserve folded navigation and bar action lifetime Use resolved sidebar visibility for desktop activity, and keep bar controls mounted while compact panels cover them so pending menu work survives. Carry circular button chrome inside the portal and exercise the rendered boundary in Chromium. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): own script actions across header placement changes Keep one script action instance on the workspace screen so pending restarts survive removal of either menu. Pass activity through the bar portal so covered menus close their overlays without taking workflow state with them. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * test(app): exercise script ownership through the workspace Replace synthetic ownership fixtures with a real workspace, script menu and isolated terminal worker. Hold terminal exit until the compact header is removed so moving pending state back into the menu fails the regression. Exercise portal activity with production menu and header controls. Co-authored-by: Elsa Granger <zeyugao@outlook.com> --------- Co-authored-by: Elsa Granger <zeyugao@outlook.com>
240 lines
9.1 KiB
TypeScript
240 lines
9.1 KiB
TypeScript
import type { Page, TestInfo } from "@playwright/test";
|
|
import { test, expect } from "../support/fixtures";
|
|
import { pauseWorkspaceTerminalWorker } from "../support/helpers/workspace-setup";
|
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
|
import { openAgentRoute } from "../support/helpers/mock-agent";
|
|
import { getServerId } from "../support/helpers/server-id";
|
|
import { expectOpenTabCount } from "../support/helpers/sheet";
|
|
import {
|
|
closeCompactWorkspaceMenu,
|
|
goBackInMenu,
|
|
menuRow,
|
|
openCompactWorkspaceMenu,
|
|
openRunningScriptPage,
|
|
openScriptsPage,
|
|
runScriptFromScriptsPage,
|
|
scriptRow,
|
|
scriptsMenuRow,
|
|
} from "../support/helpers/compact-workspace-scripts";
|
|
|
|
const labels = {
|
|
long: {
|
|
title: "workspace-with-a-very-long-release-and-investigation-name",
|
|
project: "project-with-a-very-long-customer-integration-name",
|
|
},
|
|
short: { title: "main", project: "Paseo" },
|
|
};
|
|
|
|
async function openNamedWorkspace(page: Page, names: typeof labels.long) {
|
|
const workspace = await seedWorkspace({ repoPrefix: "header-width-", title: names.title });
|
|
try {
|
|
await workspace.client.renameProject(workspace.projectId, names.project);
|
|
const agent = await workspace.client.createAgent({
|
|
provider: "mock",
|
|
cwd: workspace.repoPath,
|
|
workspaceId: workspace.workspaceId,
|
|
title: "Header layout check",
|
|
modeId: "load-test",
|
|
model: "e2e-fast-stream",
|
|
});
|
|
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
|
|
await expect(page.getByTestId("workspace-header-title")).toHaveText(names.title);
|
|
await expect(page.getByTestId("workspace-header-subtitle")).toHaveText(names.project);
|
|
await expect(
|
|
page.getByTestId(`host-badge-${getServerId()}`).filter({ visible: true }),
|
|
).toHaveCount(1);
|
|
return workspace;
|
|
} catch (error) {
|
|
await workspace.cleanup();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function expectHeaderContentsFit(page: Page, info: TestInfo, long: boolean) {
|
|
const menu = page.getByTestId("menu-button").filter({ visible: true });
|
|
const title = page.getByTestId("workspace-header-title");
|
|
const project = page.getByTestId("workspace-header-subtitle");
|
|
const badge = page.getByTestId(`host-badge-${getServerId()}`).filter({ visible: true });
|
|
const trigger = page.getByRole("button", { name: /Switch tabs/ });
|
|
const measure = async () => ({
|
|
menu: await menu.boundingBox(),
|
|
title: await title.boundingBox(),
|
|
project: await project.boundingBox(),
|
|
badge: await badge.boundingBox(),
|
|
icon: await badge.locator("svg").boundingBox(),
|
|
trigger: await trigger.boundingBox(),
|
|
titleTruncated: await title.evaluate((el) => el.scrollWidth > el.clientWidth),
|
|
projectTruncated: await project.evaluate((el) => el.scrollWidth > el.clientWidth),
|
|
});
|
|
await page.screenshot({ path: info.outputPath("header.png") });
|
|
await expect(async () => {
|
|
const bounds = await measure();
|
|
expect(bounds.menu).not.toBeNull();
|
|
expect(bounds.title).not.toBeNull();
|
|
expect(bounds.project).not.toBeNull();
|
|
expect(bounds.badge).not.toBeNull();
|
|
expect(bounds.icon).not.toBeNull();
|
|
expect(bounds.trigger).not.toBeNull();
|
|
// The labels sit between the menu button and the actions without overlapping either.
|
|
expect(bounds.title!.x).toBeGreaterThan(bounds.menu!.x + bounds.menu!.width);
|
|
expect(bounds.project!.x).toBe(bounds.title!.x);
|
|
for (const box of [bounds.title!, bounds.project!, bounds.badge!, bounds.icon!]) {
|
|
expect(box.x + box.width).toBeLessThan(bounds.trigger!.x);
|
|
}
|
|
expect(bounds.project!.width).toBeGreaterThan(20);
|
|
// Long labels truncate instead of squeezing the host icon.
|
|
expect(bounds.icon!.width).toBe(bounds.icon!.height);
|
|
expect(bounds.badge!.x).toBeGreaterThan(bounds.project!.x + bounds.project!.width);
|
|
expect(bounds.titleTruncated).toBe(long);
|
|
expect(bounds.projectTruncated).toBe(long);
|
|
}).toPass({ timeout: 5_000 });
|
|
await info.attach("header-bounds", {
|
|
body: JSON.stringify(await measure(), null, 2),
|
|
contentType: "application/json",
|
|
});
|
|
}
|
|
|
|
for (const width of [320, 390]) {
|
|
for (const kind of ["long", "short"] as const) {
|
|
test(`compact header contains ${kind} labels at ${width}px`, async ({ page }, info) => {
|
|
await page.setViewportSize({ width, height: 844 });
|
|
const workspace = await openNamedWorkspace(page, labels[kind]);
|
|
try {
|
|
await expectHeaderContentsFit(page, info, kind === "long");
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
const KEEP_ALIVE = 'node -e "setInterval(() => {}, 1000)"';
|
|
const WEB_SERVER =
|
|
"node -e \"require('http').createServer((q, r) => r.end('ok')).listen(process.env.PASEO_PORT)\"";
|
|
|
|
async function openWorkspaceWithScripts(page: Page) {
|
|
const workspace = await seedWorkspace({
|
|
repoPrefix: "compact-scripts-",
|
|
repo: {
|
|
paseoConfig: {
|
|
scripts: {
|
|
lint: { type: "script", command: KEEP_ALIVE },
|
|
web: { type: "service", command: WEB_SERVER },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
try {
|
|
const agent = await workspace.client.createAgent({
|
|
provider: "mock",
|
|
cwd: workspace.repoPath,
|
|
workspaceId: workspace.workspaceId,
|
|
title: "Scripts menu check",
|
|
modeId: "load-test",
|
|
model: "e2e-fast-stream",
|
|
});
|
|
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
|
|
return workspace;
|
|
} catch (error) {
|
|
await workspace.cleanup();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
test("compact header menu runs, shows, and stops workspace scripts", async ({ page }) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
const workspace = await openWorkspaceWithScripts(page);
|
|
try {
|
|
await expectOpenTabCount(page, 1);
|
|
|
|
await test.step("nothing running: the Scripts row has no value", async () => {
|
|
await openCompactWorkspaceMenu(page);
|
|
await expect(scriptsMenuRow(page)).toHaveAccessibleName("Scripts");
|
|
await closeCompactWorkspaceMenu(page);
|
|
});
|
|
|
|
await test.step("a stopped script runs from the Scripts page into a terminal tab", async () => {
|
|
await runScriptFromScriptsPage(page, "lint");
|
|
await expectOpenTabCount(page, 2);
|
|
await openCompactWorkspaceMenu(page);
|
|
await expect(scriptsMenuRow(page)).toHaveAccessibleName(/^Scripts\s*lint$/);
|
|
await closeCompactWorkspaceMenu(page);
|
|
});
|
|
|
|
await test.step("two running scripts are counted on the root row", async () => {
|
|
await runScriptFromScriptsPage(page, "web");
|
|
await expectOpenTabCount(page, 3);
|
|
await openCompactWorkspaceMenu(page);
|
|
await expect(scriptsMenuRow(page)).toHaveAccessibleName(/^Scripts\s*2 running$/);
|
|
await closeCompactWorkspaceMenu(page);
|
|
});
|
|
|
|
await test.step("stopping a script keeps its page open in the stopped state", async () => {
|
|
await openRunningScriptPage(page, "web");
|
|
await expect(menuRow(page, "View service")).toBeVisible();
|
|
await menuRow(page, "Stop").click();
|
|
await expect(menuRow(page, "Run")).toBeVisible({ timeout: 15_000 });
|
|
await expect(menuRow(page, "Stop")).toHaveCount(0);
|
|
|
|
await goBackInMenu(page);
|
|
await expect(scriptRow(page, "web")).toHaveAccessibleName(/^web\s*exit \d+$/);
|
|
await expect(scriptRow(page, "lint")).toHaveAccessibleName(/^lint\s*Running$/);
|
|
await closeCompactWorkspaceMenu(page);
|
|
});
|
|
|
|
await test.step("the root row names the one script still running", async () => {
|
|
await openScriptsPage(page);
|
|
await goBackInMenu(page);
|
|
await expect(scriptsMenuRow(page)).toHaveAccessibleName(/^Scripts\s*lint$/);
|
|
});
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
|
|
async function restartScriptAcrossHeaderRemoval(page: Page, terminalId: string) {
|
|
await openRunningScriptPage(page, "lint");
|
|
const resumeWorker = await pauseWorkspaceTerminalWorker(terminalId);
|
|
try {
|
|
await menuRow(page, "Restart").click();
|
|
await page.setViewportSize({ width: 1280, height: 844 });
|
|
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveCount(0);
|
|
await expect(page.getByTestId("workspace-scripts-button")).toBeVisible();
|
|
} finally {
|
|
resumeWorker();
|
|
}
|
|
}
|
|
|
|
async function expectScriptTerminalReplaced(
|
|
workspace: Awaited<ReturnType<typeof openWorkspaceWithScripts>>,
|
|
previousId: string,
|
|
) {
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const after = await workspace.client.listTerminals(undefined, undefined, {
|
|
workspaceId: workspace.workspaceId,
|
|
});
|
|
return after.terminals.filter((terminal) => terminal.id !== previousId).length;
|
|
},
|
|
{ timeout: 15_000 },
|
|
)
|
|
.toBe(1);
|
|
}
|
|
|
|
test("pending script restart survives removal of the compact header", async ({ page }) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
const workspace = await openWorkspaceWithScripts(page);
|
|
try {
|
|
await runScriptFromScriptsPage(page, "lint");
|
|
const before = await workspace.client.listTerminals(undefined, undefined, {
|
|
workspaceId: workspace.workspaceId,
|
|
});
|
|
expect(before.terminals).toHaveLength(1);
|
|
await restartScriptAcrossHeaderRemoval(page, before.terminals[0]!.id);
|
|
await expectScriptTerminalReplaced(workspace, before.terminals[0]!.id);
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|