test(ci): consolidate journeys and remove repeated startup work

Run user journeys with shared setup, retain one Hub CLI contract, and weight the existing Playwright shards using observed costs. Remove implementation-only and ad-hoc measurement checks. Await root-watch closure and publish creation failure commands atomically so teardown and failure injection have completion boundaries.
This commit is contained in:
Mohamed Boudra
2026-09-15 00:51:10 +02:00
parent 24cf660161
commit a0d81c4b52
34 changed files with 514 additions and 2305 deletions
+3
View File
@@ -358,6 +358,9 @@ jobs:
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }}
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
# Weight file groups by observed duration; raw test counts overload
# early agent/Changes specs. Required check names remain unchanged.
PWTEST_SHARD_WEIGHTS: "111:170:137:160"
- name: Upload test artifacts
uses: actions/upload-artifact@v4
@@ -868,7 +868,7 @@ test.describe("Agent message submission", () => {
}
});
test("keeps one canonical prompt when the provider echoes before accepting", async ({
test("keeps canonical prompts through early echoes, reload, and later turns", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({
@@ -889,6 +889,20 @@ test.describe("Agent message submission", () => {
await expect(submittedPrompt).toHaveCount(1);
await expect(submittedPrompt).toHaveAttribute("aria-busy", "false");
await expectVisibleAgentSurfacesIdle(page);
for (const [index, nextPrompt] of [
"emit 1 coalesced agent stream updates for the second turn.",
"emit 1 coalesced agent stream updates for the third turn.",
].entries()) {
await submitMessage(page, nextPrompt);
await expect(page.getByText(nextPrompt, { exact: true })).toBeVisible();
await expectVisibleAgentSurfacesIdle(page);
await expectComposerEditable(page);
await expect(page.getByTestId("user-message")).toHaveCount(index + 2);
}
await fillComposerDraft(page, "Keep this unsent draft.");
await composerLocator(page).blur();
await expect(page.getByTestId("user-message")).toHaveCount(3);
await expectComposerEditable(page);
} finally {
await workspace.cleanup();
}
@@ -908,16 +922,12 @@ test.describe("Agent message submission", () => {
await submitMessage(page, prompt);
const submittedPrompt = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(submittedPrompt).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
const marker = `late-provider-identity-${Date.now()}`;
await submittedPrompt.evaluate((element, value) => {
element.dataset.lateProviderIdentity = value;
}, marker);
await submittedPrompt.hover();
await expect(submittedPrompt.getByTestId("rewind-menu-trigger")).toBeVisible({
timeout: 5_000,
});
await expect(page.locator(`[data-late-provider-identity="${marker}"]`)).toBeVisible();
await expect(submittedPrompt).toHaveCount(1);
await page.getByRole("button", { name: "Stop agent", exact: true }).click();
await expectVisibleAgentSurfacesIdle(page);
} finally {
@@ -63,91 +63,6 @@ test.describe("Agent stream UI", () => {
}
});
test("keeps the active Markdown root mounted across streamed text updates", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const agent = await startRunningMockAgent(page, {
prefix: "stream-markdown-root-",
model: "one-minute-stream",
prompt: "Stream for Markdown root stability test.",
});
try {
const assistantMessage = page.getByTestId("assistant-message").last();
await expect(assistantMessage).toContainText("walking through", { timeout: 30_000 });
const activeBlock = assistantMessage.locator(":scope > *").last();
const initialText = (await activeBlock.textContent()) ?? "";
const activeBlockHandle = await activeBlock.elementHandle();
if (!activeBlockHandle) {
throw new Error("Expected the active assistant message to contain a block");
}
const markdownRoot = await activeBlock.locator(":scope > *").first().elementHandle();
if (!markdownRoot) {
throw new Error("Expected the active assistant block to contain a Markdown root");
}
await page.evaluate((block) => {
const evidence = {
addedNodes: 0,
characterDataMutations: 0,
removedNodes: 0,
};
const observer = new MutationObserver((records) => {
for (const record of records) {
evidence.addedNodes += record.addedNodes.length;
evidence.removedNodes += record.removedNodes.length;
if (record.type === "characterData") {
evidence.characterDataMutations += 1;
}
}
});
observer.observe(block, { characterData: true, childList: true, subtree: true });
Object.assign(window, {
__markdownRootEvidence: evidence,
__markdownRootObserver: observer,
});
}, activeBlockHandle);
await expect
.poll(async () => ((await activeBlock.textContent()) ?? "").length)
.toBeGreaterThan(initialText.length + 80);
const evidence = await page.evaluate((root) => {
const state = window as typeof window & {
__markdownRootEvidence?: {
addedNodes: number;
characterDataMutations: number;
removedNodes: number;
};
__markdownRootObserver?: MutationObserver;
};
state.__markdownRootObserver?.disconnect();
const messages = document.querySelectorAll('[data-testid="assistant-message"]');
const message = messages.item(messages.length - 1);
const block = message?.lastElementChild;
return {
...state.__markdownRootEvidence,
connected: root.isConnected,
sameRoot: block?.firstElementChild === root,
};
}, markdownRoot);
await testInfo.attach("markdown-root-stability", {
body: JSON.stringify(evidence, null, 2),
contentType: "application/json",
});
expect(evidence.connected).toBe(true);
expect(evidence.sameRoot).toBe(true);
expect(
evidence.removedNodes,
`Streaming Markdown replaced mounted descendants: ${JSON.stringify(evidence)}`,
).toBe(0);
} finally {
await agent.cleanup();
}
});
test("keeps the viewport fixed after the user scrolls away during a stream", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
@@ -1,6 +1,5 @@
import { expect, test } from "../support/fixtures";
import {
expectSameOlderHistoryLoadingOperation,
expectStableHistoryStartGutter,
expectTimelineAtHistoryStart,
expectTimelinePromptCentered,
@@ -12,7 +11,6 @@ import {
holdOlderHistoryPages,
makeLoadedTimelineFitViewport,
openAgentTimeline,
rememberOlderHistoryLoadingOperation,
rememberTimelineViewport,
rememberTimelinePromptPosition,
reloadAgentTimelineFromPersistedReplica,
@@ -26,42 +24,10 @@ import {
expectTimelineViewportAnchoredAfterPrepend,
userNavigatesTimelineToHistoryStartWithKeyboard,
userScrollsTimelineToHistoryStart,
waitForPersistedCanonicalTimelineRange,
} from "../support/helpers/timeline-pagination";
import { trackAgentTimelineRequests } from "../support/helpers/agent-timeline-gate";
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
test.describe("Agent timeline pagination", () => {
test("resumes a persisted canonical timeline after its exact end", async ({ page }) => {
const prompt = "timeline persisted range resumes without tail replay";
const agent = await seedMockAgentWorkspace({
repoPrefix: "timeline-persisted-range-",
title: "Persisted timeline range",
initialPrompt: prompt,
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, prompt);
const range = await waitForPersistedCanonicalTimelineRange(page, agent.agentId);
const tracker = await trackAgentTimelineRequests(page, agent.agentId);
await page.reload();
await expectTimelinePromptVisible(page, prompt);
const request = await tracker.nextRequest();
expect(request).toEqual({
direction: "after",
cursor: { epoch: range.epoch, seq: range.endSeq },
});
await tracker.waitForResponse();
expect(tracker.requests().some((entry) => entry.direction === "tail")).toBe(false);
} finally {
await agent.cleanup();
}
});
test("keeps a fixed history-start gutter before, during, and after pagination", async ({
test("keeps the history-start gutter and visible position stable through the final page", async ({
page,
}) => {
const agent = await seedLongMockAgentTimeline({ turns: 40 });
@@ -73,8 +39,10 @@ test.describe("Agent timeline pagination", () => {
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
await expectStableHistoryStartGutter(page);
const viewport = await rememberTimelineViewport(page);
history.releasePage(1);
await expectTimelineViewportAnchoredAfterPrepend(page, viewport);
await history.expectSettledWithRequestedPages(1);
await expectStableHistoryStartGutter(page);
} finally {
@@ -96,7 +64,9 @@ test.describe("Agent timeline pagination", () => {
await userScrollsTimelineToHistoryStart(page);
await expectTimelinePromptVisible(page, agent.initialTailOldestPrompt);
await history.expectRequestedPages(1);
const viewport = await rememberTimelineViewport(page);
history.releasePage(1);
await expectTimelineViewportAnchoredAfterPrepend(page, viewport);
await history.expectSettledWithRequestedPages(1);
await userScrollsTimelineToHistoryStart(page);
@@ -137,23 +107,6 @@ test.describe("Agent timeline pagination", () => {
}
});
test("keeps the visible timeline position anchored while prepending a page", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const viewport = await rememberTimelineViewport(page);
history.releasePage(1);
await expectTimelineViewportAnchoredAfterPrepend(page, viewport);
} finally {
await agent.cleanup();
}
});
test("keeps visible history anchored when live output grows during a prepend", async ({
page,
}) => {
@@ -195,6 +148,8 @@ test.describe("Agent timeline pagination", () => {
);
history.releasePage(1);
await userScrollsTimelineToHistoryStart(page);
await expectTimelinePromptVisible(page, agent.oldestPrompt);
await expect(page.getByTestId("load-older-history-spinner")).toBeHidden({ timeout: 5_000 });
const running = await agent.client.fetchAgents({ scope: "active" });
expect(running.entries.find((entry) => entry.agent.id === agent.agentId)?.agent.status).toBe(
@@ -205,28 +160,7 @@ test.describe("Agent timeline pagination", () => {
}
});
test("keeps the visible timeline anchored when the final page finishes", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 40 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const position = await rememberTimelinePromptPosition(page, agent.initialTailOldestPrompt);
history.releasePage(1);
await expectTimelinePromptPositionPreserved(page, position);
await history.expectSettledWithRequestedPages(1);
} finally {
await agent.cleanup();
}
});
test("continues one loading operation while older pages still leave history start exposed", async ({
page,
}) => {
test("keeps loading while older pages still leave history start exposed", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
@@ -235,12 +169,12 @@ test.describe("Agent timeline pagination", () => {
await openAgentTimeline(page, agent);
await scrollTimelineToOldestLoadedEdge(page);
await history.expectRequestedPages(1);
const loading = await rememberOlderHistoryLoadingOperation(page);
await expect(page.getByTestId("load-older-history-spinner")).toBeVisible();
history.releasePage(1);
await history.expectRequestedPages(2);
await expectTimelineAtHistoryStart(page);
await expectSameOlderHistoryLoadingOperation(page, loading);
await expect(page.getByTestId("load-older-history-spinner")).toBeVisible();
} finally {
await agent.cleanup();
}
+42 -113
View File
@@ -977,63 +977,31 @@ test("changes diff applies code size changes to gutter and code typography", asy
expect(after.canvasPixels).not.toEqual(before.canvasPixels);
});
test("canvas diff does not commit geometry before configured fonts are ready", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await holdBrowserFontLoads(page);
await useUnwrappedDiffLines(page);
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page);
await openChangesPanel(page);
await expect(page.getByTestId("git-diff-canvas")).toBeVisible();
await expect(page.getByTestId("diff-file-0-body")).toHaveCount(0);
await releaseBrowserFontLoads(page);
await expectExpandedMountedTabDiff(page);
});
test("canvas diff creates, edits, and deletes an inline review without DOM code rows", async ({
test("creates, cancels, edits, and deletes a review while keeping Changes focused", async ({
page,
}) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await startReviewOnFirstChangedLine(page);
await cancelInlineReview(page);
await startReviewOnFirstChangedLine(page);
await saveInlineReview(page, "Please keep this branch explicit");
await editInlineReview(page, "Please keep this branch named explicitly");
await deleteInlineReview(page);
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
});
test("autofocusing an inline review keeps the Changes tab focused", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const changesTab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true });
const focusedBackground = await changesTab.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
await startReviewOnFirstChangedLine(page);
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
await expect
.poll(() => changesTab.evaluate((element) => getComputedStyle(element).backgroundColor))
.toBe(focusedBackground);
});
test("inline reviews keep the browser text context menu", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await startReviewOnFirstChangedLine(page);
await page.getByTestId("inline-review-editor-input").click({ button: "right" });
await expect(page.getByTestId("diff-source-context-menu")).toHaveCount(0);
await test.step("start a focused review with the browser text menu", async () => {
await startReviewOnFirstChangedLine(page);
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
await expect(changesTab).toHaveCSS("background-color", focusedBackground);
await page.getByTestId("inline-review-editor-input").click({ button: "right" });
await expect(page.getByTestId("diff-source-context-menu")).toHaveCount(0);
await cancelInlineReview(page);
});
await test.step("save, edit, and delete the review", async () => {
await startReviewOnFirstChangedLine(page);
await saveInlineReview(page, "Please keep this branch explicit");
await editInlineReview(page, "Please keep this branch named explicitly");
await deleteInlineReview(page);
});
});
test("split canvas creates a review on the changed side and keeps it in that column", async ({
@@ -1143,7 +1111,7 @@ test("the whole reviewable row reveals the gutter affordance and uses a text cur
await expect(page.getByTestId("git-diff-scroll")).toHaveCSS("cursor", "text");
});
test("canvas diff copies a dragged character selection without opening a review", async ({
test("selects, copies, and dismisses diff text without opening a review", async ({
context,
page,
}) => {
@@ -1152,27 +1120,32 @@ test("canvas diff copies a dragged character selection without opening a review"
await useUnwrappedDiffLines(page);
await openSelectionWorkspaceChanges(page, workspace);
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
await page.keyboard.press("ControlOrMeta+C");
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("CDEFGH");
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
});
test("canvas diff context menu copies selections and source lines", async ({ context, page }) => {
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await useUnwrappedDiffLines(page);
await openSelectionWorkspaceChanges(page, workspace);
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
await rightClickFirstChangedLine(page);
await page.getByTestId("diff-source-copy-selection").click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("CDEFGH");
await rightClickFirstChangedLine(page);
await page.getByTestId("diff-source-copy-line").click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("ABCDEFGHIJ");
const readClipboard = () => page.evaluate(() => navigator.clipboard.readText());
await test.step("copy the selected characters with the keyboard", async () => {
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
await page.keyboard.press("ControlOrMeta+C");
await expect.poll(readClipboard).toBe("CDEFGH");
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
});
await test.step("copy the selection and the whole line from the context menu", async () => {
await page.evaluate(() => navigator.clipboard.writeText(""));
await rightClickFirstChangedLine(page);
await page.getByTestId("diff-source-copy-selection").click();
await expect.poll(readClipboard).toBe("CDEFGH");
await rightClickFirstChangedLine(page);
await page.getByTestId("diff-source-copy-line").click();
await expect.poll(readClipboard).toBe("ABCDEFGHIJ");
});
await test.step("dismiss a new selection without starting a review", async () => {
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
await clickFirstChangedLine(page);
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
await rightClickFirstChangedLine(page);
await expect(page.getByTestId("diff-source-copy-selection")).toBeDisabled();
await page.keyboard.press("Escape");
await clickFirstChangedLine(page);
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
});
});
test("canvas diff clears a selection when collapsing an earlier file", async ({ page }) => {
@@ -1186,19 +1159,6 @@ test("canvas diff clears a selection when collapsing an earlier file", async ({
await expect(page.getByTestId("diff-source-copy-selection")).toBeDisabled();
});
test("clicking the canvas dismisses a selection without opening a review", async ({ page }) => {
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
await useUnwrappedDiffLines(page);
await openSelectionWorkspaceChanges(page, workspace);
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
await clickFirstChangedLine(page);
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
await clickFirstChangedLine(page);
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
});
test("canvas diff replaces a selection with forward and backward drags", async ({
context,
page,
@@ -1393,37 +1353,6 @@ async function setOpenChangesPresentation(
}
}
async function holdBrowserFontLoads(page: Page): Promise<void> {
await page.addInitScript(() => {
const fontSet = document.fonts;
const originalLoad = fontSet.load.bind(fontSet);
const pending: Array<() => void> = [];
Object.defineProperty(fontSet, "load", {
configurable: true,
value(font: string, text?: string) {
return new Promise<FontFace[]>((resolve, reject) => {
pending.push(() => {
originalLoad(font, text).then(resolve, reject);
});
});
},
});
Object.assign(window, {
__releasePaseoDiffFontLoads() {
for (const release of pending.splice(0)) release();
},
});
});
}
async function releaseBrowserFontLoads(page: Page): Promise<void> {
await page.evaluate(() => {
(
window as typeof window & { __releasePaseoDiffFontLoads: () => void }
).__releasePaseoDiffFontLoads();
});
}
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
const canvas = page.getByTestId("git-diff-canvas");
await expect
@@ -1,33 +0,0 @@
import { randomUUID } from "node:crypto";
import { test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { createIdleAgent } from "../support/helpers/archive-tab";
import { openCommandCenter } from "../support/helpers/command-center";
import { expectAgentTabActive } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
test.describe("Command center host labels", () => {
test.describe.configure({ timeout: 180_000 });
test("selecting an agent result opens its workspace tab", async ({ page }) => {
const seeded = await seedWorkspace({ repoPrefix: "command-center-agent-navigation-" });
const title = `cc-navigation-${randomUUID().slice(0, 8)}`;
try {
const agent = await createIdleAgent(seeded.client, {
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title,
});
await gotoAppShell(page);
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(title);
await page.keyboard.press("Enter");
await expectAgentTabActive(page, agent.id);
} finally {
await seeded.cleanup();
}
});
});
@@ -3,6 +3,7 @@ import { expect } from "@playwright/test";
import { test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { createIdleAgent } from "../support/helpers/archive-tab";
import { expectAgentTabActive } from "../support/helpers/launcher";
import { openCommandCenter } from "../support/helpers/command-center";
import { addOfflineHostAndReload } from "../support/helpers/hosts";
import { expectAppRoute } from "../support/helpers/route-assertions";
@@ -109,6 +110,13 @@ test.describe("Command center workspaces", () => {
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId), {
timeout: 30_000,
});
await test.step("find an agent and open its workspace tab", async () => {
const agentPanel = await openCommandCenter(page);
await agentPanel.getByTestId("command-center-input").fill(AGENT_TITLE);
await page.keyboard.press("Enter");
await expectAgentTabActive(page, agent.id);
});
} finally {
await seeded.cleanup();
}
@@ -52,19 +52,6 @@ const TEST_JSON = {
};
test.describe("Composer attachments", () => {
test("Plus menu shows image and GitHub options", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-plus-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await openAttachmentMenu(page);
await expect(page.getByTestId("message-input-attachment-menu-item-image")).toBeVisible();
await expect(page.getByTestId("message-input-attachment-menu-item-github")).toBeVisible();
});
test("compact Plus menu aligns attachment rows with its sheet title", async ({
page,
withWorkspace,
@@ -101,59 +88,27 @@ test.describe("Composer attachments", () => {
).toBeVisible({ timeout: 10_000 });
});
test("GitHub issue attachment pill visible after search and selection", async ({ page }) => {
test("attaches an issue and a pull request from the repository picker", async ({ page }) => {
test.setTimeout(120_000);
if (!hasGithubAuth()) {
test.skip(true, "GitHub auth not available in this environment");
}
const ghRepo = await createTempGithubRepo({
category: "attach-issue",
issues: [{ title: "fix: attach-issue-unique-alpha" }],
prs: [{ title: "feat: attach-issue-dummy-pr", state: "open" }],
category: "attachments",
issues: [{ title: "fix: attachment issue" }],
prs: [{ title: "feat: attachment pull request", state: "open" }],
});
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
try {
await clickNewChat(page);
await expectComposerVisible(page);
await selectGithubOption(
page,
"attach-issue-unique-alpha",
`issue:${ghRepo.issues[0].number}`,
);
await expectGithubAttachmentPill(page, {
number: ghRepo.issues[0].number,
title: ghRepo.issues[0].title,
});
} finally {
await handle.cleanup();
await ghRepo.cleanup();
}
});
test("GitHub PR attachment pill visible after search and selection", async ({ page }) => {
test.setTimeout(120_000);
if (!hasGithubAuth()) {
test.skip(true, "GitHub auth not available in this environment");
}
const ghRepo = await createTempGithubRepo({
category: "attach-pr",
prs: [{ title: "feat: attach-pr-unique-beta", state: "open" }],
});
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
try {
await clickNewChat(page);
await expectComposerVisible(page);
await selectGithubOption(page, "attach-pr-unique-beta", `pr:${ghRepo.prs[0].number}`);
await expectGithubAttachmentPill(page, {
number: ghRepo.prs[0].number,
title: ghRepo.prs[0].title,
});
// Choose from the repository list. Newly created repositories are not
// necessarily available in GitHub's separate text-search index yet.
await selectGithubOption(page, "", `issue:${ghRepo.issues[0].number}`);
await expectGithubAttachmentPill(page, ghRepo.issues[0]);
await selectGithubOption(page, "", `change_request:${ghRepo.prs[0].number}`);
await expectGithubAttachmentPill(page, ghRepo.prs[0]);
await expectGithubAttachmentPill(page, ghRepo.issues[0]);
} finally {
await handle.cleanup();
await ghRepo.cleanup();
@@ -168,7 +123,7 @@ test.describe("Composer attachments", () => {
// needed before this can be exercised end-to-end.
});
test("image lightbox opens on pill click and closes on Escape", async ({
test("attaches, previews, and removes an image before dropping a file", async ({
page,
withWorkspace,
}) => {
@@ -178,38 +133,25 @@ test.describe("Composer attachments", () => {
await clickNewChat(page);
await expectComposerVisible(page);
await test.step("open the available attachment choices", async () => {
await openAttachmentMenu(page);
await expect(page.getByTestId("message-input-attachment-menu-item-image")).toBeVisible();
await expect(page.getByTestId("message-input-attachment-menu-item-github")).toBeVisible();
await page.keyboard.press("Escape");
});
await attachImageFromMenu(page, TEST_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await openImageLightbox(page);
await closeImageLightbox(page);
});
test("image attachment pill renders after file is selected", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-pill-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await attachImageFromMenu(page, TEST_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
});
test("dropped JSON file renders as a file attachment in active chat", async ({
page,
withWorkspace,
}) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-drop-json-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await dropFileOnComposer(page, TEST_JSON);
await expectAttachmentPill(page, "composer-file-attachment-pill");
await test.step("remove the image and attach a dropped file", async () => {
await removeAttachmentPill(page, "composer-image-attachment-pill", "Remove image attachment");
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
await dropFileOnComposer(page, TEST_JSON);
await expectAttachmentPill(page, "composer-file-attachment-pill");
});
});
test("dropped JSON file renders as a file attachment in New Workspace", async ({ page }) => {
@@ -238,23 +180,6 @@ test.describe("Composer attachments", () => {
}
});
test("clicking the X on an image pill removes it", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-remove-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await attachImageFromMenu(page, TEST_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await removeAttachmentPill(page, "composer-image-attachment-pill", "Remove image attachment");
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0, {
timeout: 5_000,
});
});
test("submitting while agent is running queues the message and clears the draft", async ({
page,
}) => {
@@ -111,21 +111,6 @@ test.afterEach(async () => {
await workspace?.cleanup();
});
test("file explorer rows share the workspace title opacity treatment", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
const row = page
.getByTestId("file-explorer-tree-scroll")
.getByTestId(/^file-explorer-row-\d+$/)
.first();
const name = row.getByTestId(/-name$/);
await expect(row).toHaveCSS("opacity", "1");
await expect(name).toHaveCSS("opacity", "0.76");
await row.hover();
await expect(name).toHaveCSS("opacity", "1");
});
test("creates, renames, copies, and deletes entries through the file explorer", async ({
context,
page,
@@ -82,121 +82,66 @@ const test = base.extend<{ twoHostSidebar: TwoHostSidebar }>({
},
});
test.describe("Host appearance", () => {
test.describe.configure({ timeout: 180_000 });
test.describe.configure({ timeout: 180_000 });
test("renaming a host renames its sidebar badge", async ({ page, twoHostSidebar }) => {
const badge = {
serverId: twoHostSidebar.secondaryServerId,
workspaceId: twoHostSidebar.secondaryWorkspaceId,
};
test("customizes a host badge and preserves its appearance after reload", async ({
page,
twoHostSidebar,
}) => {
const badge = {
serverId: twoHostSidebar.secondaryServerId,
workspaceId: twoHostSidebar.secondaryWorkspaceId,
};
const hostName = "Developer MacBook Pro.local";
await test.step("rename the host and use its available sidebar width", async () => {
await expectHostBadgeName(page, { ...badge, hostName: SECONDARY_HOST_LABEL });
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
await renameHostFromSettings(page, "Build Box");
await leaveHostAppearanceSettings(page);
await expectHostBadgeName(page, { ...badge, hostName: "Build Box" });
});
test("host names use the available metadata width", async ({ page, twoHostSidebar }) => {
const hostName = "Developer MacBook Pro.local";
const workspaceKey = `${twoHostSidebar.secondaryServerId}:${twoHostSidebar.secondaryWorkspaceId}`;
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
await openHostAppearanceSettings(page, badge.serverId);
await renameHostFromSettings(page, hostName);
await leaveHostAppearanceSettings(page);
await expectHostBadgeName(page, { ...badge, hostName });
const row = page.getByTestId(`sidebar-workspace-row-${workspaceKey}`);
const badge = row.getByTestId(`host-badge-${twoHostSidebar.secondaryServerId}`);
await expect(badge).toHaveText(hostName);
const [badgeBox, rowBox] = await Promise.all([badge.boundingBox(), row.boundingBox()]);
const row = page.getByTestId(`sidebar-workspace-row-${badge.serverId}:${badge.workspaceId}`);
const hostBadge = row.getByTestId(`host-badge-${badge.serverId}`);
const [badgeBox, rowBox] = await Promise.all([hostBadge.boundingBox(), row.boundingBox()]);
expect(badgeBox).not.toBeNull();
expect(rowBox).not.toBeNull();
expect(badgeBox!.width).toBeGreaterThan(96);
expect(badgeBox!.x + badgeBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width);
const labelWidths = await badge.getByText(hostName, { exact: true }).evaluate((label) => ({
clientWidth: label.clientWidth,
scrollWidth: label.scrollWidth,
const widths = await hostBadge.getByText(hostName, { exact: true }).evaluate((label) => ({
client: label.clientWidth,
content: label.scrollWidth,
}));
expect(labelWidths.scrollWidth).toBeLessThanOrEqual(labelWidths.clientWidth);
expect(widths.content).toBeLessThanOrEqual(widths.client);
});
test("picking a color identifies the badge", async ({ page, twoHostSidebar }) => {
const badge = {
serverId: twoHostSidebar.secondaryServerId,
workspaceId: twoHostSidebar.secondaryWorkspaceId,
hostName: SECONDARY_HOST_LABEL,
};
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
await test.step("preview a color and apply it to the sidebar badge", async () => {
await openHostAppearanceSettings(page, badge.serverId);
await chooseHostColor(page, "Teal");
await expectHostAppearancePreview(page, { serverId: badge.serverId, hostName, color: "teal" });
await leaveHostAppearanceSettings(page);
await expectHostBadgeTinted(page, { ...badge, color: "teal" });
await expectHostBadgeTinted(page, { ...badge, hostName, color: "teal" });
});
test("icon only keeps the badge and drops the name", async ({ page, twoHostSidebar }) => {
const badge = {
serverId: twoHostSidebar.secondaryServerId,
workspaceId: twoHostSidebar.secondaryWorkspaceId,
hostName: SECONDARY_HOST_LABEL,
};
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
await test.step("keep the icon-only preference through reload", async () => {
await openHostAppearanceSettings(page, badge.serverId);
await chooseHostBadgeDisplay(page, "Icon only");
await leaveHostAppearanceSettings(page);
await expectHostBadgeIconOnly(page, badge);
await expectHostBadgeIconOnly(page, { ...badge, hostName });
await reloadPreservingHostRegistry(page);
await waitForSidebarHydration(page);
await expectHostBadgeIconOnly(page, { ...badge, hostName });
});
test("hiding one host leaves the other host's badge visible", async ({
page,
twoHostSidebar,
}) => {
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
await test.step("hide this badge without hiding the other host", async () => {
await openHostAppearanceSettings(page, badge.serverId);
await chooseHostBadgeDisplay(page, "Hidden");
await leaveHostAppearanceSettings(page);
await expectNoHostBadge(page, {
serverId: twoHostSidebar.secondaryServerId,
workspaceId: twoHostSidebar.secondaryWorkspaceId,
hostName: SECONDARY_HOST_LABEL,
});
await expectNoHostBadge(page, { ...badge, hostName });
await expectHostBadgeName(page, {
serverId: twoHostSidebar.primaryServerId,
workspaceId: twoHostSidebar.primaryWorkspaceId,
hostName: PRIMARY_HOST_LABEL,
});
});
test("name, color, and display survive a reload", async ({ page, twoHostSidebar }) => {
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
await renameHostFromSettings(page, "Build Box");
await chooseHostColor(page, "Amber");
await chooseHostBadgeDisplay(page, "Icon only");
await leaveHostAppearanceSettings(page);
await reloadPreservingHostRegistry(page);
await waitForSidebarHydration(page);
await expectHostBadgeIconOnly(page, {
serverId: twoHostSidebar.secondaryServerId,
workspaceId: twoHostSidebar.secondaryWorkspaceId,
hostName: "Build Box",
});
});
test("the settings preview shows the badge as configured", async ({ page, twoHostSidebar }) => {
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
await renameHostFromSettings(page, "Build Box");
await chooseHostColor(page, "Emerald");
await expectHostAppearancePreview(page, {
serverId: twoHostSidebar.secondaryServerId,
hostName: "Build Box",
color: "emerald",
});
});
});
+30 -145
View File
@@ -13,17 +13,13 @@ import {
pressNewTabShortcut,
pressDirectNewTabShortcut,
getTabTestIds,
waitForTabWithTitle,
measureTileTransition,
sampleTabsDuringTransition,
expectTabTitleFits,
terminalSurfaceLocator,
} from "../support/helpers/launcher";
import { expectComposerVisible, composerLocator } from "../support/helpers/composer";
import {
expectTerminalSurfaceVisible,
setupDeterministicPrompt,
} from "../support/helpers/terminal-perf";
import { expectTerminalSurfaceVisible } from "../support/helpers/terminal-perf";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
expectTerminalOutputContains,
@@ -43,7 +39,7 @@ const EMPTY_PROMPT_PROFILE: TerminalProfile = {
id: "e2e-empty-prompt",
name: "Empty Prompt",
command: "/bin/sh",
args: ["-c", 'echo prompt-args: "$#"; sleep 10', "profile-name", "{{{prompt}}}"],
args: ["-c", 'echo prompt-args: "$#"; exec cat', "profile-name", "{{{prompt}}}"],
};
async function tabTestIds(tabs: Locator): Promise<(string | null)[]> {
@@ -126,47 +122,40 @@ test.describe("Tab creation", () => {
await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(newTabCountBefore);
});
test("Cmd+T creates a New tab without creating an agent", async ({ page }) => {
test("opens the menu, then creates independent New tabs without creating agents", async ({
page,
}) => {
await gotoWorkspace(page, workspace.workspaceId);
const countBefore = await countTabsOfKind(page, "draft");
await openNewTabMenuWithShortcut(page);
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore);
await expect(
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
).toBeVisible();
});
test("clicking + opens its menu without creating a New tab", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
const countBefore = await countTabsOfKind(page, "new_tab");
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
await expect(
page.getByTestId("workspace-new-tab-menu").filter({ visible: true }),
).toBeVisible();
await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(countBefore);
});
test("opening two New tabs creates two independent tab identities", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
await pressNewTabShortcut(page);
const newTabs = page
.locator('[data-testid^="workspace-tab-tab_"]')
.filter({ hasText: "New tab" });
await expect(newTabs.first()).toBeVisible();
const countBefore = await newTabs.count();
const draftCount = await countTabsOfKind(page, "draft");
await pressNewTabShortcut(page);
await expect(newTabs).toHaveCount(countBefore + 1);
const firstIds = await tabTestIds(newTabs);
await pressNewTabShortcut(page);
await expect(newTabs).toHaveCount(countBefore + 2);
const ids = await tabTestIds(newTabs);
expect(new Set(ids).size).toBe(ids.length);
expect(ids.filter((id) => !firstIds.includes(id))).toHaveLength(1);
await test.step("opening the plus menu leaves the current tabs intact", async () => {
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
await expect(
page.getByTestId("workspace-new-tab-menu").filter({ visible: true }),
).toBeVisible();
await expect(newTabs).toHaveCount(countBefore);
await page.keyboard.press("Escape");
});
await test.step("two shortcuts open two independent launchers", async () => {
await pressNewTabShortcut(page);
await expect(newTabs).toHaveCount(countBefore + 1);
const firstIds = await tabTestIds(newTabs);
await pressNewTabShortcut(page);
await expect(newTabs).toHaveCount(countBefore + 2);
const ids = await tabTestIds(newTabs);
expect(new Set(ids).size).toBe(ids.length);
expect(ids).toEqual(expect.arrayContaining(firstIds));
expect(await countTabsOfKind(page, "draft")).toBe(draftCount);
await expect(
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
).toBeVisible();
});
});
test("New tab exposes shortcuts and supports arrow navigation after refocus", async ({
@@ -267,110 +256,6 @@ test.describe("Tab creation", () => {
await assertNewChatTileVisible(page);
await assertNewTabMenuTriggerVisible(page);
});
test("never shades the New tab button", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
const tabRow = page.getByTestId("workspace-tabs-row").filter({ visible: true }).first();
const scrollArea = tabRow.getByTestId("workspace-tabs-scroll");
const newTabButtonInScroll = await scrollArea.getByTestId("workspace-new-tab-button").count();
const scrollShades = await tabRow
.locator('[data-testid^="workspace-tabs-scroll-shade-"]')
.count();
expect(newTabButtonInScroll === 0 || scrollShades === 0).toBe(true);
await expect(tabRow.getByTestId("workspace-new-tab-button")).toBeVisible();
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Terminal Title Tests
// ═══════════════════════════════════════════════════════════════════════════
test.describe("Terminal title propagation", () => {
// OSC title escape sequence propagation is inherently flaky — the terminal
// must process the sequence, emit a title change event, and the tab bar
// must re-render before the assertion deadline. Allow retries.
test.describe.configure({ retries: 2 });
test.skip("terminal tab title updates from OSC title escape sequence", async ({ page }) => {
test.setTimeout(60_000);
const result = await workspace.client.createTerminal(
workspace.repoPath,
"title-test",
undefined,
{
workspaceId: workspace.workspaceId,
},
);
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
const terminalId = result.terminal.id;
try {
// Navigate to workspace and open a terminal
await gotoWorkspace(page, workspace.workspaceId);
await clickNewTerminal(page);
await expectTerminalSurfaceVisible(page);
await terminalSurfaceLocator(page).click();
await setupDeterministicPrompt(page);
// Send OSC 0 (set window title) escape sequence
const testTitle = `E2E-Title-${Date.now()}`;
await terminalSurfaceLocator(page).pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, {
delay: 0,
});
// Wait for the tab to reflect the new title
await waitForTabWithTitle(page, testTitle, 15_000);
} finally {
await workspace.client.killTerminal(terminalId).catch(() => {});
}
});
test.skip("title debouncing coalesces rapid changes", async ({ page }) => {
test.setTimeout(60_000);
const result = await workspace.client.createTerminal(
workspace.repoPath,
"debounce-test",
undefined,
{
workspaceId: workspace.workspaceId,
},
);
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
const terminalId = result.terminal.id;
try {
await gotoWorkspace(page, workspace.workspaceId);
await clickNewTerminal(page);
await expectTerminalSurfaceVisible(page);
await terminalSurfaceLocator(page).click();
await setupDeterministicPrompt(page);
// Fire many rapid title changes — only the last should stick
const finalTitle = `Final-${Date.now()}`;
for (let i = 0; i < 5; i++) {
await terminalSurfaceLocator(page).pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, {
delay: 0,
});
}
await terminalSurfaceLocator(page).pressSequentially(
`printf '\\033]0;${finalTitle}\\007'\n`,
{ delay: 0 },
);
// The tab should eventually settle on the final title
await waitForTabWithTitle(page, finalTitle, 15_000);
} finally {
await workspace.client.killTerminal(terminalId).catch(() => {});
}
});
});
// ═══════════════════════════════════════════════════════════════════════════
@@ -4,17 +4,21 @@ import { verifyDelayedWorkspaceCreation } from "../support/helpers/new-workspace
test.describe("Delayed workspace creation", () => {
test.describe.configure({ timeout: 120_000 });
for (const launch of ["chat", "terminal", "empty"] as const) {
test(`${launch}: keeps the workspace chosen during creation`, async ({ page }) => {
await verifyDelayedWorkspaceCreation(page, launch, "leave");
});
test(`worktree ${launch}: keeps the workspace chosen during creation`, async ({ page }) => {
await verifyDelayedWorkspaceCreation(page, launch, "leave", "worktree");
});
test(`${launch}: opens the created workspace when the user stays`, async ({ page }) => {
await verifyDelayedWorkspaceCreation(page, launch, "stay");
});
}
// Exercise each submit handler's navigation guard. Checkout isolation shares
// those handlers; ordinary successful launches live in the creation journeys.
test("chat worktree creation keeps the workspace chosen while it was pending", async ({
page,
}) => {
await verifyDelayedWorkspaceCreation(page, "chat", "leave", "worktree");
});
test("terminal creation keeps the workspace chosen while it was pending", async ({ page }) => {
await verifyDelayedWorkspaceCreation(page, "terminal", "leave");
});
test("empty creation keeps the workspace chosen while it was pending", async ({ page }) => {
await verifyDelayedWorkspaceCreation(page, "empty", "leave");
});
test("preserves a newer draft opened while creation is pending", async ({ page }) => {
await verifyDelayedWorkspaceCreation(page, "chat", "new-draft");
@@ -3,19 +3,13 @@ import { withButtonShowcase } from "../support/helpers/plugin-buttons";
test("plugin header and composer buttons share actions, menus, content, and updates", async ({
page,
}, testInfo) => {
}) => {
test.setTimeout(180_000);
await withButtonShowcase(page, testInfo, async (buttons) => {
await withButtonShowcase(page, async (buttons) => {
await buttons.verifyObservationLifetime();
await buttons.openWideMenusAndPopovers();
await buttons.runAndUpdateActions();
await buttons.openCompactSheets();
await buttons.hideAndUnloadOpenButtons();
});
});
test("plugin button observations follow mounted content and error lifetimes", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
await withButtonShowcase(page, testInfo, (buttons) => buttons.verifyObservationLifetime());
});
@@ -1,50 +0,0 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test } from "@playwright/test";
import {
beginReplicaCacheObservation,
finishReplicaCacheObservation,
installReplicaCacheMeasurement,
measureReplicaCacheRestore,
type ReplicaCacheMeasurementReport,
} from "../support/helpers/replica-cache-measurement";
const RUN_MEASUREMENT = process.env.PASEO_REPLICA_CACHE_MEASUREMENT === "1";
const TARGET_URL = process.env.PASEO_REPLICA_CACHE_MEASUREMENT_URL;
const REPORT_PATH = process.env.PASEO_REPLICA_CACHE_MEASUREMENT_REPORT;
const OBSERVATION_MS = Number(process.env.PASEO_REPLICA_CACHE_MEASUREMENT_MS ?? 60_000);
const HYDRATED_SELECTOR =
process.env.PASEO_REPLICA_CACHE_HYDRATED_SELECTOR ??
'[data-testid="agent-chat-scroll"], [data-testid^="sidebar-workspace-row-"]';
const measurementDescribe = RUN_MEASUREMENT ? test.describe : test.describe.skip;
measurementDescribe("Replica cache live measurement", () => {
test("observes persistence and cached restore without mutating the daemon", async ({
page,
}, testInfo) => {
test.setTimeout(OBSERVATION_MS + 90_000);
expect(TARGET_URL, "PASEO_REPLICA_CACHE_MEASUREMENT_URL must be a full app URL").toBeTruthy();
expect(REPORT_PATH, "PASEO_REPLICA_CACHE_MEASUREMENT_REPORT must be set").toBeTruthy();
await installReplicaCacheMeasurement(page);
await page.goto(TARGET_URL!, { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle").catch(() => undefined);
const observation = await beginReplicaCacheObservation(page);
await page.waitForTimeout(OBSERVATION_MS);
const observed = await finishReplicaCacheObservation(page, observation, TARGET_URL!);
const restoreMs = await measureReplicaCacheRestore(page, TARGET_URL!, HYDRATED_SELECTOR);
const report: ReplicaCacheMeasurementReport = { ...observed, restoreMs };
await mkdir(path.dirname(REPORT_PATH!), { recursive: true });
await writeFile(REPORT_PATH!, `${JSON.stringify(report, null, 2)}\n`);
await testInfo.attach("replica-cache-measurement", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
console.log(`[measurement] Replica cache: ${JSON.stringify(report)}`);
expect(report.observationMs).toBeGreaterThanOrEqual(OBSERVATION_MS);
});
});
@@ -21,52 +21,47 @@ import {
} from "../support/helpers/settings";
test.describe("Settings host page", () => {
test("connections section shows the seeded connection endpoint", async ({ page }) => {
test("visits host settings and opens the label editor", async ({ page }) => {
const serverId = getServerId();
const port = getE2EDaemonPort();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await test.step("connections section shows the seeded connection endpoint", async () => {
await expectSettingsHeader(page, "Connections");
await expectHostConnectionsCard(page, port);
});
await test.step("agents section shows the inject MCP toggle", async () => {
await openHostSection(page, serverId, "agents");
await expectSettingsHeader(page, "Agents");
await expectHostInjectMcpCard(page);
});
await test.step("providers section shows the providers card", async () => {
await expectHostProvidersCard(page, serverId);
await expectSettingsHeader(page, "Providers");
});
await test.step("host section shows the host label and restart/remove action cards", async () => {
await openHostSection(page, serverId, "host");
await expectSettingsHeader(page, "Overview");
await expectHostLabelDisplayed(page);
await expectHostActionCards(page, serverId);
});
await test.step("clicking the label pencil reveals the inline editor", async () => {
await openHostSection(page, serverId, "host");
await expectSettingsHeader(page, "Connections");
await expectHostConnectionsCard(page, port);
});
await expectHostLabelDisplayed(page);
await clickEditHostLabel(page);
await expectHostLabelEditMode(page, TEST_HOST_LABEL);
await page.keyboard.press("Escape");
});
await test.step("host section does not render daemon lifecycle controls for a remote daemon", async () => {
await openHostSection(page, serverId, "host");
test("agents section shows the inject MCP toggle", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "agents");
await expectSettingsHeader(page, "Agents");
await expectHostInjectMcpCard(page);
});
test("providers section shows the providers card", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await expectHostProvidersCard(page, serverId);
await expectSettingsHeader(page, "Providers");
});
test("host section shows the host label and restart/remove action cards", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "host");
await expectSettingsHeader(page, "Overview");
await expectHostLabelDisplayed(page);
await expectHostActionCards(page, serverId);
await expectHostNoDaemonLifecycleRow(page);
});
await test.step("settings sidebar exposes the flat App and Host section rows", async () => {
await expectRetiredSidebarSectionsAbsent(page);
});
});
test("a failed remote daemon update remains visible in the host UI", async ({
@@ -94,40 +89,6 @@ test.describe("Settings host page", () => {
await expect(updateButton).toBeEnabled();
});
test("clicking the label pencil reveals the inline editor", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "host");
await expectHostLabelDisplayed(page);
await clickEditHostLabel(page);
await expectHostLabelEditMode(page, TEST_HOST_LABEL);
});
test("host section does not render daemon lifecycle controls for a remote daemon", async ({
page,
}) => {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "host");
// TODO: add a local-daemon fixture for positive daemon lifecycle coverage.
await expectHostNoDaemonLifecycleRow(page);
});
test("settings sidebar exposes the flat App and Host section rows", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await expectRetiredSidebarSectionsAbsent(page);
});
test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({
page,
}) => {
@@ -72,6 +72,9 @@ test.describe("Settings sidebar navigation", () => {
await openSettingsSection(page, "appearance");
await expectSettingsHeader(page, "Appearance");
await expectAppearanceContent(page);
await clickSettingsBackToWorkspace(page);
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
});
test("/h/[serverId]/settings redirects to the host connections section", async ({ page }) => {
@@ -79,19 +82,13 @@ test.describe("Settings sidebar navigation", () => {
await verifyLegacyHostSettingsRedirect(page);
});
test("the + Add host button opens the add-host method modal", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await openAddHostFlow(page);
await expectAddHostMethodOptions(page);
});
test("direct connection advanced URI round-trips SSL and password into the form", async ({
page,
}) => {
await gotoAppShell(page);
await openSettings(page);
await openAddHostFlow(page);
await expectAddHostMethodOptions(page);
await selectHostConnectionType(page, "direct");
await toggleHostAdvanced(page);
@@ -115,20 +112,6 @@ test.describe("Settings sidebar navigation", () => {
await expectDirectHostUriHidden(page);
});
test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await clickSettingsBackToWorkspace(page);
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
});
test("pressing Escape closes settings", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await page.keyboard.press("Escape");
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
});
test("Escape lets settings dropdowns and modals close before leaving settings", async ({
page,
}) => {
@@ -155,66 +138,45 @@ test.describe("Settings sidebar navigation", () => {
await expect(page.getByText("Add connection", { exact: true })).toHaveCount(0);
await expect(page).toHaveURL(/\/settings(\/|$)/);
});
await page.keyboard.press("Escape");
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
});
});
test.describe("Settings — compact master-detail", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("/settings renders only the sidebar list (no section content)", async ({ page }) => {
test("opens compact app and host details and returns through the settings list", async ({
page,
}) => {
await gotoAppShell(page);
await openCompactSettings(page, buildOpenProjectRoute());
await expectSettingsSidebarSections(page, ["general", "diagnostics", "about"]);
await expectCompactSettingsList(page);
await expectSettingsBackButton(page);
await goBackInSettings(page);
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
});
await test.step("open app details and return to the list", async () => {
await openSettingsSection(page, "diagnostics");
await expectAppRoute(page, buildSettingsSectionRoute("diagnostics"));
await expectDiagnosticsContent(page);
await expectSettingsSidebarHidden(page);
await expectSettingsBackButton(page);
await goBackInSettings(page);
await expectCompactSettingsList(page);
});
test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => {
await gotoAppShell(page);
await openCompactSettings(page, buildOpenProjectRoute());
await openSettingsSection(page, "diagnostics");
await expectAppRoute(page, buildSettingsSectionRoute("diagnostics"));
await expectDiagnosticsContent(page);
await expectSettingsSidebarHidden(page);
await expectSettingsBackButton(page);
});
test("back from a section detail returns to the /settings list", async ({ page }) => {
await gotoAppShell(page);
await openCompactSettings(page, buildOpenProjectRoute());
await openSettingsSection(page, "about");
await expectAppRoute(page, buildSettingsSectionRoute("about"));
await goBackInSettings(page);
await expectCompactSettingsList(page);
await expectSettingsBackButton(page);
});
test("tapping a host section row pushes /settings/hosts/[serverId]/connections", async ({
page,
}) => {
await gotoAppShell(page);
await openCompactSettings(page, buildOpenProjectRoute());
await openCompactSettingsHost(page);
await expectSettingsBackButton(page);
await expectSettingsSidebarHidden(page);
});
test("back from a host detail returns to the /settings list", async ({ page }) => {
await gotoAppShell(page);
await openCompactSettings(page, buildOpenProjectRoute());
await openCompactSettingsHost(page);
await goBackInSettings(page);
await expectAppRoute(page, buildSettingsRoute());
await expectSettingsSidebarVisible(page);
await test.step("open host details and return through the list", async () => {
await openCompactSettingsHost(page);
await expectSettingsBackButton(page);
await expectSettingsSidebarHidden(page);
await goBackInSettings(page);
await expectAppRoute(page, buildSettingsRoute());
await expectSettingsSidebarVisible(page);
await expectSettingsBackButton(page);
await goBackInSettings(page);
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
});
});
test("host picker settings opens Overview and backs through the settings list", async ({
@@ -1,543 +0,0 @@
import { Buffer } from "node:buffer";
import type { CDPSession, Page, TestInfo } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
type WireDirection = "sent" | "received";
type WirePhase = "startup" | "workspace_clicks";
interface ParsedWireMessage {
type: string | null;
requestId: string | null;
entryCount: number | null;
hasMore: boolean | null;
providerEntries: ProviderSnapshotWireEntry[] | null;
}
type WireFrameRecord = ParsedWireMessage & {
phase: WirePhase;
direction: WireDirection;
bytes: number;
};
interface ProviderSnapshotWireEntry {
provider: string;
status: string | null;
modelCount: number;
modeCount: number;
bytes: number;
}
interface WebSocketFrameEvent {
requestId: string;
response: {
opcode: number;
payloadData: string;
};
}
interface WireSummary {
totalFrames: number;
totalBytes: number;
byDirection: Record<WireDirection, { frames: number; bytes: number }>;
byPhase: Record<
WirePhase,
{
frames: number;
bytes: number;
byType: Array<{ type: string; frames: number; bytes: number }>;
rpcCounts: Array<{ requestType: string; count: number }>;
rpcs: Array<{ requestType: string; requestId: string; responseType: string | null }>;
}
>;
fetchPages: Array<{
phase: WirePhase;
type: string;
requestId: string | null;
entries: number | null;
hasMore: boolean | null;
bytes: number;
}>;
clickedWorkspaces: Array<{ testId: string; frames: number; bytes: number }>;
providerSnapshots: Array<{
phase: WirePhase;
type: string;
requestId: string | null;
totalModels: number;
totalModes: number;
bytes: number;
providers: ProviderSnapshotWireEntry[];
}>;
providerSnapshotTotals: Array<{
phase: WirePhase;
provider: string;
frames: number;
bytes: number;
maxModels: number;
maxModes: number;
statuses: string[];
}>;
fork: {
sourceHome: string | null;
targetHome: string | null;
copiedFiles: number | null;
copiedBytes: number | null;
};
}
function extractWorkspaceTestIds(elements: Element[]): string[] {
const result: string[] = [];
for (const element of elements.slice(0, 3)) {
const value = element.getAttribute("data-testid");
if (value) {
result.push(value);
}
}
return result;
}
class WireMonitor {
private phase: WirePhase = "startup";
private session: CDPSession | null = null;
readonly records: WireFrameRecord[] = [];
async start(page: Page): Promise<void> {
this.session = await page.context().newCDPSession(page);
await this.session.send("Network.enable");
this.session.on("Network.webSocketFrameSent", (event: WebSocketFrameEvent) => {
this.record("sent", event);
});
this.session.on("Network.webSocketFrameReceived", (event: WebSocketFrameEvent) => {
this.record("received", event);
});
}
setPhase(phase: WirePhase): void {
this.phase = phase;
}
hasCompletedStartupFetches(): boolean {
return (
this.records.some(
(record) =>
record.direction === "received" &&
record.type === "fetch_agents_response" &&
record.hasMore === false,
) &&
this.records.some(
(record) =>
record.direction === "received" &&
record.type === "fetch_workspaces_response" &&
record.hasMore === false,
)
);
}
summarize(clickedWorkspaces: WireSummary["clickedWorkspaces"]): WireSummary {
return {
totalFrames: this.records.length,
totalBytes: sumBytes(this.records),
byDirection: {
sent: summarizeDirection(this.records, "sent"),
received: summarizeDirection(this.records, "received"),
},
byPhase: {
startup: summarizePhase(this.records, "startup"),
workspace_clicks: summarizePhase(this.records, "workspace_clicks"),
},
fetchPages: this.records
.filter(
(record) =>
record.direction === "received" &&
(record.type === "fetch_agents_response" ||
record.type === "fetch_workspaces_response"),
)
.map((record) => ({
phase: record.phase,
type: record.type ?? "unknown",
requestId: record.requestId,
entries: record.entryCount,
hasMore: record.hasMore,
bytes: record.bytes,
})),
clickedWorkspaces,
providerSnapshots: this.records
.filter((record) => record.direction === "received" && record.providerEntries)
.map((record) => ({
phase: record.phase,
type: record.type ?? "unknown",
requestId: record.requestId,
totalModels: sumProviderModels(record.providerEntries ?? []),
totalModes: sumProviderModes(record.providerEntries ?? []),
bytes: record.bytes,
providers: record.providerEntries ?? [],
})),
providerSnapshotTotals: summarizeProviderSnapshots(this.records),
fork: {
sourceHome: process.env.E2E_FORK_SOURCE_PASEO_HOME ?? null,
targetHome: process.env.E2E_FORK_TARGET_PASEO_HOME ?? null,
copiedFiles: parseOptionalNumber(process.env.E2E_FORK_COPIED_FILES),
copiedBytes: parseOptionalNumber(process.env.E2E_FORK_COPIED_BYTES),
},
};
}
private record(direction: WireDirection, event: WebSocketFrameEvent): void {
if (event.response.opcode !== 1) {
this.records.push({
phase: this.phase,
direction,
bytes: Buffer.byteLength(event.response.payloadData),
type: `opcode:${event.response.opcode}`,
requestId: null,
entryCount: null,
hasMore: null,
providerEntries: null,
});
return;
}
this.records.push({
phase: this.phase,
direction,
bytes: Buffer.byteLength(event.response.payloadData, "utf8"),
...parseWireMessage(event.response.payloadData),
});
}
}
function parseOptionalNumber(value: string | undefined): number | null {
if (!value) {
return null;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function parseWireMessage(payloadData: string): ParsedWireMessage {
try {
const parsed = JSON.parse(payloadData) as unknown;
if (!parsed || typeof parsed !== "object") {
return emptyParsedWireMessage();
}
const envelope = parsed as {
type?: unknown;
message?: unknown;
};
const unwrapped =
envelope.type === "session" && envelope.message && typeof envelope.message === "object"
? envelope.message
: parsed;
const message = unwrapped as {
type?: unknown;
requestId?: unknown;
payload?: {
requestId?: unknown;
entries?: unknown;
agents?: unknown;
pageInfo?: { hasMore?: unknown };
};
};
const entries = readMessageEntries(message.payload);
const hasMore = readMessageHasMore(message.payload);
const messageType = typeof message.type === "string" ? message.type : null;
const providerEntries = isProviderSnapshotMessage(messageType)
? parseProviderSnapshotEntries(message.payload)
: null;
return {
type: messageType,
requestId: readMessageRequestId(message),
entryCount: entries ? entries.length : null,
hasMore,
providerEntries,
};
} catch {
return emptyParsedWireMessage();
}
}
function readMessageRequestId(message: {
requestId?: unknown;
payload?: { requestId?: unknown };
}): string | null {
if (typeof message.requestId === "string") {
return message.requestId;
}
if (typeof message.payload?.requestId === "string") {
return message.payload.requestId;
}
return null;
}
function isProviderSnapshotMessage(messageType: string | null): boolean {
return (
messageType === "get_providers_snapshot_response" || messageType === "providers_snapshot_update"
);
}
function readMessageEntries(
payload:
| {
entries?: unknown;
agents?: unknown;
}
| undefined,
): unknown[] | null {
if (Array.isArray(payload?.entries)) {
return payload.entries;
}
if (Array.isArray(payload?.agents)) {
return payload.agents;
}
return null;
}
function readMessageHasMore(
payload: { pageInfo?: { hasMore?: unknown } } | undefined,
): boolean | null {
return typeof payload?.pageInfo?.hasMore === "boolean" ? payload.pageInfo.hasMore : null;
}
function emptyParsedWireMessage(): ParsedWireMessage {
return {
type: null,
requestId: null,
entryCount: null,
hasMore: null,
providerEntries: null,
};
}
function parseProviderSnapshotEntries(payload: unknown): ProviderSnapshotWireEntry[] | null {
if (!payload || typeof payload !== "object") {
return null;
}
const entries = (payload as { entries?: unknown }).entries;
if (!Array.isArray(entries)) {
return null;
}
const providerEntries: ProviderSnapshotWireEntry[] = [];
for (const entry of entries) {
if (!entry || typeof entry !== "object") {
continue;
}
const provider = (entry as { provider?: unknown }).provider;
if (typeof provider !== "string") {
continue;
}
const models = (entry as { models?: unknown }).models;
const modes = (entry as { modes?: unknown }).modes;
const status = (entry as { status?: unknown }).status;
providerEntries.push({
provider,
status: typeof status === "string" ? status : null,
modelCount: Array.isArray(models) ? models.length : 0,
modeCount: Array.isArray(modes) ? modes.length : 0,
bytes: Buffer.byteLength(JSON.stringify(entry), "utf8"),
});
}
return providerEntries.length > 0 ? providerEntries : null;
}
function sumBytes(records: WireFrameRecord[]): number {
return records.reduce((sum, record) => sum + record.bytes, 0);
}
function summarizeDirection(records: WireFrameRecord[], direction: WireDirection) {
const selected = records.filter((record) => record.direction === direction);
return {
frames: selected.length,
bytes: sumBytes(selected),
};
}
function summarizePhase(records: WireFrameRecord[], phase: WirePhase) {
const selected = records.filter((record) => record.phase === phase);
return {
frames: selected.length,
bytes: sumBytes(selected),
byType: summarizeByType(selected),
rpcCounts: summarizeRpcCounts(selected),
rpcs: summarizeRpcs(selected),
};
}
function summarizeByType(records: WireFrameRecord[]): Array<{
type: string;
frames: number;
bytes: number;
}> {
const byType = new Map<string, { frames: number; bytes: number }>();
for (const record of records) {
const type = record.type ?? "unknown";
const current = byType.get(type) ?? { frames: 0, bytes: 0 };
current.frames += 1;
current.bytes += record.bytes;
byType.set(type, current);
}
return [...byType.entries()]
.map(([type, value]) => Object.assign({ type }, value))
.sort((left, right) => right.bytes - left.bytes);
}
function summarizeRpcs(records: WireFrameRecord[]): WireSummary["byPhase"][WirePhase]["rpcs"] {
const responsesByRequestId = new Map<string, string>();
for (const record of records) {
if (record.direction !== "received" || !record.requestId) {
continue;
}
responsesByRequestId.set(record.requestId, record.type ?? "unknown");
}
return records
.filter((record) => record.direction === "sent" && record.requestId && record.type)
.map((record) => ({
requestType: record.type ?? "unknown",
requestId: record.requestId ?? "",
responseType: responsesByRequestId.get(record.requestId ?? "") ?? null,
}));
}
function summarizeRpcCounts(
records: WireFrameRecord[],
): Array<{ requestType: string; count: number }> {
const counts = new Map<string, number>();
for (const record of records) {
if (record.direction !== "sent" || !record.type || !record.requestId) {
continue;
}
counts.set(record.type, (counts.get(record.type) ?? 0) + 1);
}
return [...counts.entries()]
.map(([requestType, count]) => ({ requestType, count }))
.sort(
(left, right) =>
right.count - left.count || left.requestType.localeCompare(right.requestType),
);
}
function sumProviderModels(entries: ProviderSnapshotWireEntry[]): number {
return entries.reduce((sum, entry) => sum + entry.modelCount, 0);
}
function sumProviderModes(entries: ProviderSnapshotWireEntry[]): number {
return entries.reduce((sum, entry) => sum + entry.modeCount, 0);
}
function summarizeProviderSnapshots(
records: WireFrameRecord[],
): WireSummary["providerSnapshotTotals"] {
const byProvider = new Map<
string,
{
phase: WirePhase;
provider: string;
frames: number;
bytes: number;
maxModels: number;
maxModes: number;
statuses: Set<string>;
}
>();
for (const record of records) {
if (record.direction !== "received" || !record.providerEntries) {
continue;
}
for (const entry of record.providerEntries) {
const key = `${record.phase}:${entry.provider}`;
const current = byProvider.get(key) ?? {
phase: record.phase,
provider: entry.provider,
frames: 0,
bytes: 0,
maxModels: 0,
maxModes: 0,
statuses: new Set<string>(),
};
current.frames += 1;
current.bytes += entry.bytes;
current.maxModels = Math.max(current.maxModels, entry.modelCount);
current.maxModes = Math.max(current.maxModes, entry.modeCount);
if (entry.status) {
current.statuses.add(entry.status);
}
byProvider.set(key, current);
}
}
return [...byProvider.values()]
.map((entry) => ({
phase: entry.phase,
provider: entry.provider,
frames: entry.frames,
bytes: entry.bytes,
maxModels: entry.maxModels,
maxModes: entry.maxModes,
statuses: [...entry.statuses].sort(),
}))
.sort((left, right) => right.bytes - left.bytes);
}
async function attachSummary(testInfo: TestInfo, summary: WireSummary): Promise<void> {
await testInfo.attach("startup-wire-metrics.json", {
body: JSON.stringify(summary, null, 2),
contentType: "application/json",
});
}
test.describe("ad hoc startup wire metrics", () => {
test.skip(
process.env.E2E_WIRE_METRICS !== "1",
"Set E2E_WIRE_METRICS=1 to run this ad hoc measurement.",
);
test("measures startup hydration and workspace navigation websocket traffic", async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
const monitor = new WireMonitor();
await monitor.start(page);
await gotoAppShell(page);
await waitForSidebarHydration(page, 120_000);
await expect.poll(() => monitor.hasCompletedStartupFetches(), { timeout: 120_000 }).toBe(true);
await page.waitForTimeout(1_000);
const workspaceTestIds = await page
.locator('[data-testid^="sidebar-workspace-row-"]:visible')
.evaluateAll(extractWorkspaceTestIds);
monitor.setPhase("workspace_clicks");
const clickedWorkspaces: WireSummary["clickedWorkspaces"] = [];
for (const testId of workspaceTestIds) {
const row = page.getByTestId(testId);
if (!(await row.isVisible().catch(() => false))) {
continue;
}
const beforeFrames = monitor.records.length;
const beforeBytes = sumBytes(monitor.records);
await row.click();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
await page.waitForTimeout(1_000);
clickedWorkspaces.push({
testId,
frames: monitor.records.length - beforeFrames,
bytes: sumBytes(monitor.records) - beforeBytes,
});
}
const summary = monitor.summarize(clickedWorkspaces);
await attachSummary(testInfo, summary);
console.log("PASEO_STARTUP_WIRE_METRICS_BEGIN");
console.log(JSON.stringify(summary, null, 2));
console.log("PASEO_STARTUP_WIRE_METRICS_END");
expect(summary.byPhase.startup.byType.length).toBeGreaterThan(0);
expect(clickedWorkspaces.length).toBeGreaterThan(0);
});
});
@@ -1,63 +0,0 @@
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
composerLocator,
expectComposerEditable,
expectComposerVisible,
fillComposerDraft,
submitMessage,
} from "../support/helpers/composer";
async function expectUserMessageCount(page: Page, expected: number): Promise<void> {
await expect(page.getByTestId("user-message")).toHaveCount(expected, { timeout: 15_000 });
}
async function expectIdleComposer(page: Page): Promise<void> {
await expectComposerEditable(page);
await expect(page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, {
timeout: 15_000,
});
}
async function expectNoLoadingRegressionAfterIdle(page: Page): Promise<void> {
await expectIdleComposer(page);
await page.waitForTimeout(1_000);
await expectIdleComposer(page);
}
test.describe("User message UI contract", () => {
test("dedupes mock provider user_message echoes across multi-turn sends", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "user-message-contract-e2e-",
title: "User message contract e2e",
});
const prompts = [
"emit 1 coalesced agent stream updates for user message contract turn one.",
"emit 1 coalesced agent stream updates for user message contract turn two.",
"emit 1 coalesced agent stream updates for user message contract turn three.",
];
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
for (let index = 0; index < prompts.length; index += 1) {
const prompt = prompts[index]!;
await submitMessage(page, prompt);
await expect(page.getByText(prompt, { exact: true })).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("stress-update-0", { exact: true }).first()).toBeVisible({
timeout: 15_000,
});
await expectUserMessageCount(page, index + 1);
await expectNoLoadingRegressionAfterIdle(page);
}
await fillComposerDraft(page, "append");
await composerLocator(page).evaluate((element) => element.blur());
await expectUserMessageCount(page, 3);
await expectIdleComposer(page);
} finally {
await session.cleanup();
}
});
});
@@ -178,6 +178,9 @@ test.describe("Workspace navigation regression", () => {
});
await waitForWorkspaceTabsVisible(page);
await expectWorkspaceTabVisible(page, agent.id);
// The reconnect toast belongs to the visible agent panel, which mounts
// after the tab strip. Drop the connection only after that panel is ready.
await expectComposerVisible(page);
await daemonGate.drop();
await daemonGate.waitForBlockedConnection();
@@ -1,4 +1,4 @@
import { writeFile, rm } from "node:fs/promises";
import { writeFile, rm, rename } from "node:fs/promises";
import path from "node:path";
import { copyPluginExample } from "./helpers/plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./helpers/new-workspace";
@@ -23,9 +23,11 @@ export const test = base.extend<{
const client = await connectNewWorkspaceDaemonClient();
const plugin = await copyPluginExample("lifecycle-logger");
const gate = path.join(plugin.directory, "startup.txt");
const failure = path.join(plugin.directory, "startup-failure.txt");
const previous = await client.getDaemonConfig();
try {
await writeFile(gate, "hold");
await writeFile(failure, "fail");
await writeFile(
path.join(plugin.directory, "index.server.ts"),
`
@@ -52,7 +54,8 @@ export default function contribute(server) {
await client.installDirectoryPlugin(plugin.directory);
await provide({
release: () => rm(gate, { force: true }),
fail: () => writeFile(gate, "fail"),
// Replacing the command must not expose a truncated, empty file to the hook.
fail: () => rename(failure, gate),
});
} finally {
await rm(gate, { force: true });
@@ -39,82 +39,6 @@ export interface PromptJumpRequestTracker {
requests(): Array<{ cursorSeq: number | null; limit: number | null; mergeWindow: boolean }>;
}
export interface TimelineRequestTracker {
nextRequest(): Promise<{
direction: string | null;
cursor: { epoch: string; seq: number } | null;
}>;
requests(): Array<{
direction: string | null;
cursor: { epoch: string; seq: number } | null;
}>;
waitForResponse(): Promise<void>;
}
export async function trackAgentTimelineRequests(
page: Page,
agentId: string,
): Promise<TimelineRequestTracker> {
type Request = ReturnType<TimelineRequestTracker["requests"]>[number];
const seen: Request[] = [];
const waiters: Array<(request: Request) => void> = [];
let responseSeen = false;
let resolveResponse: (() => void) | null = null;
const response = new Promise<void>((resolve) => {
resolveResponse = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (
sessionMessage?.type === "fetch_agent_timeline_request" &&
sessionMessage.agentId === agentId
) {
const rawCursor = sessionMessage.cursor;
const cursor =
rawCursor &&
typeof rawCursor === "object" &&
typeof (rawCursor as { epoch?: unknown }).epoch === "string" &&
typeof (rawCursor as { seq?: unknown }).seq === "number"
? {
epoch: (rawCursor as { epoch: string }).epoch,
seq: (rawCursor as { seq: number }).seq,
}
: null;
const request = {
direction: typeof sessionMessage.direction === "string" ? sessionMessage.direction : null,
cursor,
};
seen.push(request);
waiters.shift()?.(request);
}
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
if (
sessionMessage?.type === "fetch_agent_timeline_response" &&
payload?.agentId === agentId
) {
responseSeen = true;
resolveResponse?.();
}
ws.send(message);
});
});
return {
nextRequest() {
const request = seen[0];
if (request) return Promise.resolve(request);
return new Promise((resolve) => waiters.push(resolve));
},
requests: () => [...seen],
waitForResponse: () => (responseSeen ? Promise.resolve() : response),
};
}
export async function trackPromptJumpRequests(
page: Page,
agentId: string,
+2 -2
View File
@@ -166,7 +166,7 @@ export async function expectGithubAttachmentPill(
page: Page,
input: { number: number; title: string },
): Promise<void> {
const pill = page.getByTestId("composer-github-attachment-pill").first();
const pill = page.getByTestId("composer-github-attachment-pill").filter({ hasText: input.title });
await expect(pill).toBeVisible({ timeout: 10_000 });
await expect(pill).toContainText(`#${input.number}`);
await expect(pill).toContainText(input.title);
@@ -188,7 +188,7 @@ export async function openGithubPickerFromMenu(page: Page): Promise<void> {
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 5_000 });
}
/** Open picker, type a query, wait for the matching option by id (e.g. "issue:3", "pr:1"), and click it. */
/** Open picker, type a query, wait for the matching option by id (e.g. "issue:3", "change_request:1"), and click it. */
export async function selectGithubOption(
page: Page,
searchTerm: string,
@@ -1,7 +1,7 @@
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Locator, TestInfo } from "@playwright/test";
import type { Locator } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { buildAgentRoute } from "./mock-agent";
@@ -9,7 +9,6 @@ import { openCommandCenter } from "./command-center";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { seedWorkspace } from "./seed-client";
import { pluginRequirements } from "./plugin-fixture";
import { waitForSettledPosition } from "./sheet-layout";
const PLUGIN_ID = "button-showcase";
const WIDE = { width: 1440, height: 900 };
@@ -166,17 +165,11 @@ async function command(page: Page, title: string) {
await expect(panel).not.toBeVisible();
}
async function capture(
async function expectSurfaceVisible(
page: Page,
testInfo: TestInfo,
name: string,
subject: Locator = page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
) {
await expect(subject).toBeInViewport();
await waitForSettledPosition(subject);
const file = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: file, animations: "disabled" });
await testInfo.attach(name, { path: file, contentType: "image/png" });
}
async function expectDetails(page: Page) {
@@ -214,7 +207,7 @@ async function holdButton(page: Page, button: Locator) {
await page.mouse.down();
}
async function openCompactOverflowWithHeaderChrome(page: Page, testInfo: TestInfo) {
async function openCompactOverflowWithHeaderChrome(page: Page) {
const hamburger = page.getByRole("button", { name: "Open menu", exact: true });
const action = page.getByRole("button", { name: "Deploy application", exact: true });
const overflow = page
@@ -230,13 +223,12 @@ async function openCompactOverflowWithHeaderChrome(page: Page, testInfo: TestInf
await expect(action).toHaveCSS("background-color", highlight);
await holdButton(page, overflow);
await expect(overflow).toHaveCSS("background-color", highlight);
await capture(page, testInfo, "12c-compact-overflow-pressed");
await expectSurfaceVisible(page);
await page.mouse.up();
}
export async function withButtonShowcase(
page: Page,
testInfo: TestInfo,
run: (buttons: {
openWideMenusAndPopovers(): Promise<void>;
runAndUpdateActions(): Promise<void>;
@@ -300,15 +292,9 @@ export async function withButtonShowcase(
await expect(page.getByTestId("workspace-header-title")).toHaveText(
"Healthy app after button failure",
);
await showcase.disable();
await press(page, "Close deployment details");
await expect.poll(() => released.has(recovered)).toBe(true);
await expect(page.getByRole("button", { name: "Run review", exact: true })).toHaveCount(
0,
);
await showcase.setTitle("Healthy app after plugin unload");
await expect(page.getByTestId("workspace-header-title")).toHaveText(
"Healthy app after plugin unload",
);
await showcase.setTitle("Button showcase");
}),
openWideMenusAndPopovers: () =>
test.step("wide buttons have placement-owned labels and chevrons", async () => {
@@ -322,23 +308,19 @@ export async function withButtonShowcase(
.getByRole("button", { name: "Composer checks", exact: true })
.getByTestId("plugin-button-chevron"),
).toHaveCount(0);
await capture(page, testInfo, "01-wide-buttons");
await expectSurfaceVisible(page);
await press(page, "Header checks");
await expect(
page.getByRole("menuitem", { name: "Run checks", exact: true }),
).toBeVisible();
await capture(
await expectSurfaceVisible(
page,
testInfo,
"02-header-menu",
page.getByRole("menuitem", { name: "Run checks", exact: true }),
);
await choose(page, "Environment");
await expect(page.getByRole("menuitem", { name: "Staging", exact: true })).toBeVisible();
await capture(
await expectSurfaceVisible(
page,
testInfo,
"03-header-submenu",
page.getByRole("menuitem", { name: "Staging", exact: true }),
);
await choose(page, "Staging");
@@ -346,10 +328,8 @@ export async function withButtonShowcase(
await expect(
page.getByRole("menuitem", { name: "Production locked", exact: true }),
).toBeDisabled();
await capture(
await expectSurfaceVisible(
page,
testInfo,
"03b-wide-overflow",
page.getByRole("menuitem", { name: "Production locked", exact: true }),
);
await choose(page, "Open deployment logs");
@@ -357,35 +337,27 @@ export async function withButtonShowcase(
await press(page, "Close deployment details");
await press(page, "Header status");
await expectDetails(page);
await capture(
await expectSurfaceVisible(
page,
testInfo,
"04-header-popover",
page.getByRole("button", { name: "Close deployment details", exact: true }),
);
await press(page, "Close deployment details");
await press(page, "Composer checks");
await capture(
await expectSurfaceVisible(
page,
testInfo,
"05-composer-menu",
page.getByRole("menuitem", { name: "Run checks", exact: true }),
);
await choose(page, "Deployment details");
await expectDetails(page);
await capture(
await expectSurfaceVisible(
page,
testInfo,
"06-composer-menu-content",
page.getByRole("button", { name: "Close deployment details", exact: true }),
);
await press(page, "Close deployment details");
await press(page, "Composer status");
await expectDetails(page);
await capture(
await expectSurfaceVisible(
page,
testInfo,
"07-composer-popover",
page.getByRole("button", { name: "Close deployment details", exact: true }),
);
await press(page, "Close deployment details");
@@ -396,17 +368,15 @@ export async function withButtonShowcase(
await expect(
page.getByText("Deployment failed. Try again.", { exact: true }),
).toBeVisible();
await capture(
await expectSurfaceVisible(
page,
testInfo,
"08-action-error",
page.getByText("Deployment failed. Try again.", { exact: true }),
);
await press(page, "Deploy application");
await expect(
page.getByRole("button", { name: "Deploy application", exact: true }),
).toBeDisabled();
await capture(page, testInfo, "09-action-pending");
await expectSurfaceVisible(page);
await command(page, "Finish deployment");
await expect(
page.getByRole("button", { name: "Deploy application", exact: true }),
@@ -415,7 +385,7 @@ export async function withButtonShowcase(
await expect(
page.getByRole("button", { name: "Run review", exact: true }),
).toBeDisabled();
await capture(page, testInfo, "09b-composer-pending");
await expectSurfaceVisible(page);
await command(page, "Finish review");
await expect(page.getByRole("button", { name: "Run review", exact: true })).toContainText(
"Reviewed",
@@ -424,10 +394,10 @@ export async function withButtonShowcase(
await expect(
page.getByRole("button", { name: "Run review", exact: true }),
).toBeDisabled();
await capture(page, testInfo, "10-disabled-buttons");
await expectSurfaceVisible(page);
await command(page, "Enable example buttons");
await command(page, "Use icon-only header");
await capture(page, testInfo, "11-icon-only-header");
await expectSurfaceVisible(page);
await command(page, "Hide review button");
await expect(page.getByRole("button", { name: "Run review", exact: true })).toHaveCount(
0,
@@ -456,30 +426,24 @@ export async function withButtonShowcase(
"Button showcase with a long workspace title",
);
await expectHeaderTextTruncated(page);
await capture(page, testInfo, "12-compact-buttons");
await openCompactOverflowWithHeaderChrome(page, testInfo);
await expectSurfaceVisible(page);
await openCompactOverflowWithHeaderChrome(page);
await expect(
page.getByRole("menuitem", { name: "Production locked", exact: true }),
).toBeDisabled();
await capture(
await expectSurfaceVisible(
page,
testInfo,
"13-compact-header-overflow",
page.getByRole("menuitem", { name: "Production locked", exact: true }),
);
await choose(page, "Header checks");
await capture(
await expectSurfaceVisible(
page,
testInfo,
"14-compact-header-menu",
page.getByRole("menuitem", { name: "Run checks", exact: true }),
);
await choose(page, "Deployment details");
await expectDetails(page);
await capture(
await expectSurfaceVisible(
page,
testInfo,
"15-compact-header-content",
page.getByRole("button", { name: "Close deployment details", exact: true }),
);
await press(page, "Close deployment details");
@@ -490,28 +454,22 @@ export async function withButtonShowcase(
.getByRole("button", { name: "Header checks", exact: true })
.getByTestId("plugin-button-chevron"),
).toHaveCount(0);
await capture(
await expectSurfaceVisible(
page,
testInfo,
"15b-compact-header-direct-menu",
page.getByRole("menuitem", { name: "Run checks", exact: true }),
);
await choose(page, "Run checks");
await showcase.setTitle("Button showcase");
await press(page, "Composer status");
await expectDetails(page);
await capture(
await expectSurfaceVisible(
page,
testInfo,
"16-compact-composer-popover",
page.getByRole("button", { name: "Close deployment details", exact: true }),
);
await press(page, "Close deployment details");
await press(page, "Composer checks");
await capture(
await expectSurfaceVisible(
page,
testInfo,
"17-compact-composer-menu",
page.getByRole("menuitem", { name: "Run checks", exact: true }),
);
await choose(page, "Run checks");
@@ -529,16 +487,18 @@ export async function withButtonShowcase(
).toHaveCount(0);
await showcase.setTitle("Button showcase");
await page.emulateMedia({ colorScheme: "dark" });
await capture(page, testInfo, "18-dark-buttons");
await expectSurfaceVisible(page);
await press(page, "Header status");
await expectDetails(page);
await capture(
await expectSurfaceVisible(
page,
testInfo,
"19-dark-popover",
page.getByRole("button", { name: "Close deployment details", exact: true }),
);
const observation = page.getByLabel("Content observation", { exact: true });
await expect(observation).not.toHaveText("");
const owner = (await observation.textContent())!;
await showcase.disable();
await expect.poll(() => released.has(owner)).toBe(true);
await expect(
page.getByRole("button", { name: "Header status", exact: true }),
).toHaveCount(0);
@@ -546,6 +506,10 @@ export async function withButtonShowcase(
0,
);
await expect(page.getByText("All systems operational", { exact: true })).toHaveCount(0);
await showcase.setTitle("Healthy app after plugin unload");
await expect(page.getByTestId("workspace-header-title")).toHaveText(
"Healthy app after plugin unload",
);
}),
});
} finally {
@@ -1,312 +0,0 @@
import type { CDPSession, Page } from "@playwright/test";
const REPLICA_CACHE_DATABASE = "paseo-replica-row-store";
const REPLICA_CACHE_ROWS_STORE = "rows";
const PERSIST_FRAME_NAMES = [
"captureSessions",
"captureHost",
"queueReplicaChanges",
"apply",
"safeParse",
"stringify",
"setItem",
"measuredPut",
] as const;
const PERSIST_STACK_ANCHORS = new Set([
"captureSessions",
"captureHost",
"queueReplicaChanges",
"persist",
"flushPending",
"measuredPut",
]);
export interface ReplicaCacheMeasurementReport {
targetUrl: string;
observationMs: number;
persistCpuMs: number;
persistCpuFrames: Record<string, number>;
flushes: number;
bytesWritten: {
total: number;
perFlush: number[];
};
redundantWrites: number;
storageDurationMs: number;
longTasks: {
count: number;
totalDurationMs: number;
maximumDurationMs: number;
durationsMs: number[];
};
restoreMs: number | null;
}
interface BrowserMeasurementState {
flushes: Array<{ bytes: number; durationMs: number }>;
longTasks: number[];
redundantWrites: number;
}
interface ProfileNode {
id: number;
callFrame: { functionName: string; url: string };
parent?: number;
}
interface SamplingProfile {
nodes: ProfileNode[];
samples?: number[];
timeDeltas?: number[];
}
declare global {
interface Window {
__replicaCacheMeasurement?: BrowserMeasurementState;
__replicaCacheRestoreReadStartedAt?: number;
}
}
export async function installReplicaCacheMeasurement(page: Page): Promise<void> {
await page.addInitScript(
({ databaseName, storeName }) => {
const encoder = new TextEncoder();
const transactionWrites = new WeakMap<
IDBTransaction,
Array<
| { type: "put"; key: string; value: string; bytes: number; startedAt: number }
| { type: "delete"; key: string; bytes: 0; startedAt: number }
>
>();
const lastValues = new Map<string, string>();
const resolveStorageKey = (
store: IDBObjectStore,
value: unknown,
explicitKey: IDBValidKey | undefined,
): unknown => {
if (explicitKey !== undefined) return explicitKey;
if (value === null || typeof value !== "object") return null;
if (Array.isArray(store.keyPath)) {
return store.keyPath.map((part) => Reflect.get(value, part));
}
return typeof store.keyPath === "string" ? Reflect.get(value, store.keyPath) : null;
};
window.__replicaCacheMeasurement = {
flushes: [],
longTasks: [],
redundantWrites: 0,
};
const observeTransaction = (transaction: IDBTransaction) => {
let writes = transactionWrites.get(transaction);
if (writes) return writes;
writes = [];
transactionWrites.set(transaction, writes);
transaction.addEventListener("complete", () => {
const state = window.__replicaCacheMeasurement;
if (!state || writes!.length === 0) return;
let bytes = 0;
let startedAt = Number.POSITIVE_INFINITY;
for (const write of writes!) {
bytes += write.bytes;
startedAt = Math.min(startedAt, write.startedAt);
if (write.type === "put") {
if (lastValues.get(write.key) === write.value) state.redundantWrites += 1;
lastValues.set(write.key, write.value);
} else {
lastValues.delete(write.key);
}
}
state.flushes.push({ bytes, durationMs: performance.now() - startedAt });
});
return writes;
};
const originalPut = IDBObjectStore.prototype.put;
IDBObjectStore.prototype.put = function measuredPut(value: unknown, key?: IDBValidKey) {
const request =
key === undefined ? originalPut.call(this, value) : originalPut.call(this, value, key);
if (this.transaction.db.name !== databaseName || this.name !== storeName) return request;
const rowPayload =
value && typeof value === "object" ? Reflect.get(value, "payload") : undefined;
let serialized = JSON.stringify(value);
if (typeof value === "string") serialized = value;
if (typeof rowPayload === "string") serialized = rowPayload;
const storageKey = JSON.stringify(resolveStorageKey(this, value, key));
observeTransaction(this.transaction).push({
type: "put",
key: `${this.name}:${storageKey}`,
value: serialized,
bytes: encoder.encode(serialized).byteLength,
startedAt: performance.now(),
});
return request;
};
const originalDelete = IDBObjectStore.prototype.delete;
IDBObjectStore.prototype.delete = function measuredDelete(query: IDBValidKey | IDBKeyRange) {
const request = originalDelete.call(this, query);
if (this.transaction.db.name !== databaseName || this.name !== storeName) return request;
observeTransaction(this.transaction).push({
type: "delete",
key: `${this.name}:${JSON.stringify(query)}`,
bytes: 0,
startedAt: performance.now(),
});
return request;
};
const markRestoreRead = (store: IDBObjectStore) => {
if (
store.transaction.db.name === databaseName &&
store.name === storeName &&
store.transaction.mode === "readonly" &&
window.__replicaCacheRestoreReadStartedAt === undefined
) {
window.__replicaCacheRestoreReadStartedAt = performance.now();
}
};
const originalGet = IDBObjectStore.prototype.get;
IDBObjectStore.prototype.get = function measuredGet(query: IDBValidKey | IDBKeyRange) {
markRestoreRead(this);
return originalGet.call(this, query);
};
const originalGetAll = IDBObjectStore.prototype.getAll;
IDBObjectStore.prototype.getAll = function measuredGetAll(
query?: IDBValidKey | IDBKeyRange | null,
count?: number,
) {
markRestoreRead(this);
return originalGetAll.call(this, query, count);
};
const originalOpenCursor = IDBObjectStore.prototype.openCursor;
IDBObjectStore.prototype.openCursor = function measuredOpenCursor(
query?: IDBValidKey | IDBKeyRange | null,
direction?: IDBCursorDirection,
) {
markRestoreRead(this);
return originalOpenCursor.call(this, query, direction);
};
if (typeof PerformanceObserver !== "undefined") {
const observer = new PerformanceObserver((entries) => {
const state = window.__replicaCacheMeasurement;
if (!state) return;
for (const entry of entries.getEntries()) state.longTasks.push(entry.duration);
});
try {
observer.observe({ type: "longtask", buffered: true });
} catch {
// Chromium exposes Long Tasks; leave an empty list on runtimes that do not.
}
}
},
{ databaseName: REPLICA_CACHE_DATABASE, storeName: REPLICA_CACHE_ROWS_STORE },
);
}
export async function beginReplicaCacheObservation(
page: Page,
): Promise<{ cdp: CDPSession; startedAt: number }> {
await page.evaluate(() => {
const state = window.__replicaCacheMeasurement;
if (!state) throw new Error("Replica cache measurement is not installed");
state.flushes = [];
state.longTasks = [];
state.redundantWrites = 0;
});
const cdp = await page.context().newCDPSession(page);
await cdp.send("Profiler.enable");
await cdp.send("Profiler.setSamplingInterval", { interval: 100 });
await cdp.send("Profiler.start");
return { cdp, startedAt: Date.now() };
}
export async function finishReplicaCacheObservation(
page: Page,
observation: { cdp: CDPSession; startedAt: number },
targetUrl: string,
): Promise<Omit<ReplicaCacheMeasurementReport, "restoreMs">> {
const result = (await observation.cdp.send("Profiler.stop")) as { profile: SamplingProfile };
await observation.cdp.detach();
const browser = await page.evaluate(() => {
const state = window.__replicaCacheMeasurement;
if (!state) throw new Error("Replica cache measurement is not installed");
return state;
});
const cpu = summarizePersistCpu(result.profile);
const bytesPerFlush = browser.flushes.map((flush) => flush.bytes);
const longTaskDurations = browser.longTasks;
return {
targetUrl,
observationMs: Date.now() - observation.startedAt,
persistCpuMs: cpu.totalMs,
persistCpuFrames: cpu.byFrame,
flushes: browser.flushes.length,
bytesWritten: {
total: bytesPerFlush.reduce((sum, bytes) => sum + bytes, 0),
perFlush: bytesPerFlush,
},
redundantWrites: browser.redundantWrites,
storageDurationMs: browser.flushes.reduce((sum, flush) => sum + flush.durationMs, 0),
longTasks: {
count: longTaskDurations.length,
totalDurationMs: longTaskDurations.reduce((sum, duration) => sum + duration, 0),
maximumDurationMs: Math.max(0, ...longTaskDurations),
durationsMs: longTaskDurations,
},
};
}
export async function measureReplicaCacheRestore(
page: Page,
targetUrl: string,
hydratedSelector: string,
): Promise<number | null> {
await page.routeWebSocket(/\/ws(?:\?|$)/, async (socket) => {
await socket.close({ code: 1000, reason: "Hold reconnect until cached replica paints" });
});
await page.goto(targetUrl, { waitUntil: "domcontentloaded" });
const restoredAt = await page.waitForFunction(
(selector) => {
const startedAt = window.__replicaCacheRestoreReadStartedAt;
if (startedAt === undefined) return null;
const element = document.querySelector(selector);
if (!element || !(element instanceof HTMLElement) || element.offsetParent === null)
return null;
return performance.now();
},
hydratedSelector,
{ timeout: 30_000 },
);
const restoredAtMs = await restoredAt.jsonValue();
const startedAtMs = await page.evaluate(() => window.__replicaCacheRestoreReadStartedAt ?? null);
return typeof restoredAtMs === "number" && startedAtMs !== null
? restoredAtMs - startedAtMs
: null;
}
function summarizePersistCpu(profile: SamplingProfile): {
totalMs: number;
byFrame: Record<string, number>;
} {
const nodes = new Map(profile.nodes.map((node) => [node.id, node]));
const byFrame: Record<string, number> = {};
let totalMicroseconds = 0;
for (const [index, sampleId] of (profile.samples ?? []).entries()) {
const delta = profile.timeDeltas?.[index] ?? 0;
const stackFunctions = new Set<string>();
let node = nodes.get(sampleId);
while (node) {
stackFunctions.add(node.callFrame.functionName);
node = node.parent === undefined ? undefined : nodes.get(node.parent);
}
if (![...stackFunctions].some((name) => PERSIST_STACK_ANCHORS.has(name))) continue;
const matched = PERSIST_FRAME_NAMES.filter((name) => stackFunctions.has(name));
if (matched.length === 0) continue;
totalMicroseconds += delta;
for (const pattern of matched) byFrame[pattern] = (byFrame[pattern] ?? 0) + delta / 1_000;
}
return { totalMs: totalMicroseconds / 1_000, byFrame };
}
@@ -40,12 +40,6 @@ interface TimelineViewportSnapshot {
scrollTop: number;
}
interface PersistedCanonicalTimelineRange {
epoch: string;
startSeq: number;
endSeq: number;
}
export interface TimelinePresentationSnapshot {
marker: string;
position: TimelinePromptPositionSnapshot;
@@ -57,11 +51,6 @@ interface TimelinePromptPositionSnapshot {
top: number;
}
interface OlderHistoryLoadingOperation {
animationStartTime: number | null;
marker: string;
}
interface OlderHistoryPages {
expectRequestedPages(count: number): Promise<void>;
expectSettledWithRequestedPages(count: number): Promise<void>;
@@ -304,34 +293,6 @@ export async function reloadAgentTimelineFromPersistedReplica(
await expectTimelinePromptVisible(page, agent.newestPrompt);
}
export async function waitForPersistedCanonicalTimelineRange(
page: Page,
agentId: string,
): Promise<PersistedCanonicalTimelineRange> {
const readRange = async () => {
const cache = await readReplicaCache(page);
if (cache?.version !== 6) return null;
const range = cache.hosts
?.flatMap((host) => host.timelines)
.find((timeline) => timeline.agentId === agentId)?.range;
if (
typeof range?.epoch !== "string" ||
typeof range.startSeq !== "number" ||
typeof range.endSeq !== "number"
) {
return null;
}
return { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq };
};
await expect.poll(readRange).not.toBeNull();
const range = await readRange();
if (!range) {
throw new Error(`Persisted canonical timeline range is missing for ${agentId}`);
}
return range;
}
export async function holdNextOlderTimelinePage(
page: Page,
agent: LongTimelineAgent,
@@ -457,49 +418,6 @@ export async function expectTimelineViewportAnchoredAfterPrepend(
expect(Math.abs(contentGrowth - scrollAdjustment)).toBeLessThanOrEqual(2);
}
export async function rememberOlderHistoryLoadingOperation(
page: Page,
): Promise<OlderHistoryLoadingOperation> {
const slot = page.getByTestId("load-older-history-spinner");
await expect(slot).toBeVisible();
const marker = `older-history-loading-${Date.now()}`;
await slot.evaluate((element, operationMarker) => {
const candidates = [element, ...Array.from(element.querySelectorAll("*"))];
const animated = candidates.find(
(candidate) => getComputedStyle(candidate).animationName !== "none",
);
if (!(animated instanceof HTMLElement)) {
throw new Error("Expected the older-history loader to contain an animated element");
}
animated.dataset.olderHistoryLoadingOperation = operationMarker;
}, marker);
const animated = page.locator(`[data-older-history-loading-operation="${marker}"]`);
await expect
.poll(async () => {
const startTime = await animated.evaluate((element) => element.getAnimations()[0]?.startTime);
return typeof startTime === "number" ? startTime : null;
})
.not.toBeNull();
const animationStartTime = await animated.evaluate((element) => {
const startTime = element.getAnimations()[0]?.startTime;
return typeof startTime === "number" ? startTime : null;
});
return { animationStartTime, marker };
}
export async function expectSameOlderHistoryLoadingOperation(
page: Page,
operation: OlderHistoryLoadingOperation,
): Promise<void> {
const animated = page.locator(`[data-older-history-loading-operation="${operation.marker}"]`);
await expect(animated).toBeVisible();
const animationStartTime = await animated.evaluate((element) => {
const startTime = element.getAnimations()[0]?.startTime;
return typeof startTime === "number" ? startTime : null;
});
expect(animationStartTime).toBe(operation.animationStartTime);
}
export async function expectTimelineAtHistoryStart(page: Page): Promise<void> {
await expect
.poll(async () => (await readTimelineViewport(page)).scrollTop)
@@ -110,6 +110,29 @@ test("unassigning a shortcut leaves it inert until it is reset", async ({ page }
await page.keyboard.press("Shift+?");
await expect(dialog).not.toBeVisible({ timeout: 5_000 });
await test.step("show the cleared shortcut in help, then bind new keys", async () => {
const help = await openCheatSheet(page);
const row = help.getByTestId(`shortcut-help-row-${SHORTCUTS_ROW}`);
await expect(row.getByText("Not set", { exact: true })).toBeVisible();
await expect(help.getByText("?", { exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
await openSettings(page);
await openSettingsSection(page, "shortcuts");
await openRowMenu(page);
await bind.click();
await page.keyboard.press("Alt+Shift+K");
await page.getByText("Done", { exact: true }).click();
await expect(page.getByText("⌥⇧K", { exact: true })).toBeVisible();
const reboundHelp = await openCheatSheet(page);
const reboundRow = reboundHelp.getByTestId(`shortcut-help-row-${SHORTCUTS_ROW}`);
await expect(reboundRow.getByText("⌥⇧K", { exact: true })).toBeVisible();
await expect(reboundRow.getByText("?", { exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
await openSettings(page);
await openSettingsSection(page, "shortcuts");
});
await openRowMenu(page);
await page.getByTestId(`shortcut-reset-${SHORTCUTS_ROW}`).click();
await expect(notSet).toHaveCount(0);
@@ -123,46 +146,3 @@ test("unassigning a shortcut leaves it inert until it is reset", async ({ page }
await page.keyboard.press("Escape");
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
});
test("an unassigned shortcut lists no keys in the shortcuts cheat sheet", async ({ page }) => {
await openShortcutsSettings(page);
await openRowMenu(page);
await page.getByTestId(`shortcut-clear-${SHORTCUTS_ROW}`).click();
await expect(page.getByText("Not set", { exact: true })).toBeVisible();
const dialog = await openCheatSheet(page);
// Addressed by testID rather than by filtering on its own text: an anchored
// `hasText` matches only the innermost node holding exactly that label, so a
// row-scoped assertion about anything *else* in the row finds nothing.
const row = dialog.getByTestId(`shortcut-help-row-${SHORTCUTS_ROW}`);
await expect(row).toBeVisible();
// No badge pill, blank or otherwise, for a shortcut with no keys.
await expect(dialog.getByText("?", { exact: true })).toHaveCount(0);
// It says so, rather than leaving a silent gap where the keys were. Same words
// the settings row uses for the same state.
await expect(row.getByText("Not set", { exact: true })).toBeVisible();
});
test("a rebound shortcut lists its new keys in the shortcuts cheat sheet", async ({ page }) => {
await openShortcutsSettings(page);
const defaultKeys = page.getByText("?", { exact: true });
await expect(defaultKeys).toBeVisible();
await page.getByTestId(`shortcut-actions-${SHORTCUTS_ROW}`).click();
await page.getByTestId(`shortcut-bind-${SHORTCUTS_ROW}`).click();
await page.keyboard.press("Alt+Shift+K");
await page.getByText("Done", { exact: true }).click();
const reboundKeys = page.getByText("⌥⇧K", { exact: true });
await expect(reboundKeys).toBeVisible();
await expect(defaultKeys).toHaveCount(0);
const dialog = await openCheatSheet(page);
const row = dialog.getByTestId(`shortcut-help-row-${SHORTCUTS_ROW}`);
await expect(row.getByText("⌥⇧K", { exact: true })).toBeVisible();
// The whole point: the cheat sheet stops advertising the shipped default.
await expect(row.getByText("?", { exact: true })).toHaveCount(0);
});
+29 -117
View File
@@ -49,17 +49,6 @@ test.describe("Desktop updates", () => {
await expect(page.getByTestId("host-page-update-button")).toBeDisabled();
});
test("update banner appears in the sidebar when an app update is available", async ({ page }) => {
await installDesktopRuntime(page, {
serverId: getServerId(),
updateAvailable: true,
latestVersion: "1.2.3",
});
await gotoAppShell(page);
await expectUpdateBanner(page, "1.2.3");
});
test("clicking install shows the installing state on the callout", async ({ page }) => {
await installDesktopRuntime(page, {
serverId: getServerId(),
@@ -105,9 +94,7 @@ test.describe("Desktop updates", () => {
});
test.describe("Desktop daemon management", () => {
test("disabling built-in daemon management shows confirm dialog with correct copy", async ({
page,
}) => {
test("cancelling the management confirmation preserves the enabled daemon", async ({ page }) => {
const serverId = getServerId();
await installDesktopRuntime(page, {
serverId,
@@ -124,22 +111,6 @@ test.describe("Desktop daemon management", () => {
await expectDaemonManagementEnabled(page);
});
test("cancelling the confirm dialog leaves the daemon management toggle on", async ({ page }) => {
const serverId = getServerId();
await installDesktopRuntime(page, {
serverId,
manageBuiltInDaemon: true,
ownedByDesktop: true,
confirmShouldAccept: false,
});
await gotoAppShell(page);
await openDesktopSettings(page, serverId);
await expectDaemonManagementEnabled(page);
await toggleDaemonManagement(page, "disable");
await expectDaemonManagementEnabled(page);
});
test("confirming the dialog disables built-in daemon management", async ({ page }) => {
const serverId = getServerId();
await installDesktopRuntime(page, {
@@ -176,93 +147,34 @@ test.describe("Desktop daemon management", () => {
await expectDaemonStatusLogPath(page, realState.logPath);
});
test("stopping and restarting the daemon updates the PID", async ({ page }) => {
const serverId = getServerId();
const realState = await loadRealDaemonState();
await installDesktopRuntime(page, {
serverId,
manageBuiltInDaemon: true,
ownedByDesktop: true,
daemonPid: realState.pid,
daemonVersion: realState.version,
daemonLogPath: realState.logPath,
confirmShouldAccept: true,
});
await gotoAppShell(page);
await openDesktopSettings(page, serverId);
await expectDaemonStatusPid(page, realState.pid);
await toggleDaemonManagement(page, "disable");
await expectDaemonManagementDisabled(page);
await expectDaemonStatusPid(page, null);
await toggleDaemonManagement(page, "enable");
await expectDaemonManagementEnabled(page);
const newPid = realState.pid !== null ? realState.pid + 1000 : 11000;
await expectDaemonStatusPid(page, newPid);
});
test("pausing management preserves an attached legacy desktop-managed daemon", async ({
page,
}) => {
const serverId = getServerId();
const realState = await loadRealDaemonState();
await installDesktopRuntime(page, {
serverId,
manageBuiltInDaemon: true,
ownedByDesktop: false,
daemonPid: realState.pid,
confirmShouldAccept: true,
});
await gotoAppShell(page);
await openDesktopSettings(page, serverId);
const dialog = await interceptDaemonManagementConfirmDialog(page);
expect(dialog).toEqual({
title: "Pause built-in daemon",
message: "Pause automatic daemon management? The attached daemon will keep running.",
});
await expectDaemonManagementDisabled(page);
await expectDaemonStatusPid(page, realState.pid);
await toggleDaemonManagement(page, "enable");
await expectDaemonManagementEnabled(page);
await expectDaemonStatusPid(page, realState.pid);
});
for (const ownedByDesktop of [false, true]) {
for (const confirmShouldAccept of [false, true]) {
test(`${confirmShouldAccept ? "confirming" : "cancelling"} Stop identifies the ${ownedByDesktop ? "owned" : "attached"} daemon`, async ({
page,
}) => {
const serverId = getServerId();
const realState = await loadRealDaemonState();
const daemonHome = process.env.E2E_PASEO_HOME!;
await installDesktopRuntime(page, {
serverId,
daemonPid: realState.pid,
daemonHome,
ownedByDesktop,
confirmShouldAccept,
});
await gotoAppShell(page);
await openDesktopSettings(page, serverId);
const dialog = await interceptDaemonStopConfirmDialog(page);
expect(dialog).toEqual({
title: "Stop local daemon?",
message: [
ownedByDesktop
? "This daemon was launched by this Desktop session."
: "This daemon was not launched by this Desktop session.",
`Home: ${daemonHome}`,
`Supervisor PID: ${realState.pid}`,
"Running agent work will be interrupted.",
].join("\n"),
});
await expectDaemonStatusPid(page, confirmShouldAccept ? null : realState.pid);
for (const confirmShouldAccept of [false, true]) {
test(`${confirmShouldAccept ? "confirming" : "cancelling"} Stop identifies the owned daemon`, async ({
page,
}) => {
const serverId = getServerId();
const realState = await loadRealDaemonState();
const daemonHome = process.env.E2E_PASEO_HOME!;
await installDesktopRuntime(page, {
serverId,
daemonPid: realState.pid,
daemonHome,
ownedByDesktop: true,
confirmShouldAccept,
});
}
await gotoAppShell(page);
await openDesktopSettings(page, serverId);
const dialog = await interceptDaemonStopConfirmDialog(page);
expect(dialog).toEqual({
title: "Stop local daemon?",
message: [
"This daemon was launched by this Desktop session.",
`Home: ${daemonHome}`,
`Supervisor PID: ${realState.pid}`,
"Running agent work will be interrupted.",
].join("\n"),
});
await expectDaemonStatusPid(page, confirmShouldAccept ? null : realState.pid);
});
}
});
@@ -2,7 +2,6 @@ import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { mkdtempSync, rmSync } from "node:fs";
import { createServer, type ServerResponse } from "node:http";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { Writable } from "node:stream";
@@ -367,7 +366,7 @@ describe("OpenCodeServerManager managed process ledger", () => {
describe.runIf(process.platform === "win32")(
"OpenCodeServerManager Windows OpenCode npm install",
() => {
test("starts the helper server from opencode.exe instead of the npm opencode.cmd shim", async () => {
test("resolves the Windows npm shim to its native helper executable", async () => {
const detectedOpenCode = await findExecutable("opencode");
expect(detectedOpenCode, "Windows CI must install opencode-ai before server tests").not.toBe(
null,
@@ -376,27 +375,21 @@ describe.runIf(process.platform === "win32")(
const tempDir = mkdtempSync(path.join(os.tmpdir(), "opencode-real-windows-"));
const opencodeHomeDir = path.join(tempDir, "opencode-home");
const managedProcesses = new FakeManagedProcesses();
const runtime = new FakeOpenCodeServerRuntime([4604], { autoAnnounce: true });
const manager = new OpenCodeServerManager({
logger: createTestLogger(),
managedProcesses,
managedProcesses: runtime.managedProcesses,
resolveHomeDir: () => opencodeHomeDir,
portAllocator: runtime.allocatePort,
spawnServerProcess: runtime.spawnServerProcess,
terminateProcess: runtime.terminateProcess,
});
let acquiredPort: number | null = null;
try {
const acquisition = await manager.acquireDedicated({
OPENCODE_AUTH_CONTENT: "{}",
OPENCODE_DISABLE_AUTOUPDATE: "1",
OPENCODE_DISABLE_AUTOCOMPACT: "1",
OPENCODE_DISABLE_MODELS_FETCH: "1",
OPENCODE_DISABLE_PROJECT_CONFIG: "1",
OPENCODE_PURE: "1",
OPENCODE_TEST_HOME: path.join(tempDir, "test-home"),
});
acquiredPort = acquisition.server.port;
const records = await managedProcesses.list();
// Resolve the actual npm installation. Generation lifecycle coverage above
// owns process readiness; this regression owns the Windows launch command.
const acquisition = await manager.acquireCurrent();
const records = await runtime.managedProcesses.list();
expect(records).toHaveLength(1);
const record = records[0]!;
expect(path.extname(record.command).toLowerCase()).toBe(".exe");
@@ -404,15 +397,13 @@ describe.runIf(process.platform === "win32")(
path.normalize("node_modules/opencode-ai/bin/opencode.exe").toLowerCase(),
);
expect(record.command.toLowerCase()).not.toBe(detectedOpenCode!.toLowerCase());
expect(record.args).toEqual(["serve", "--port", String(acquiredPort)]);
expect(record.args).toEqual(["serve", "--port", String(acquisition.server.port)]);
await acquisition.release();
} finally {
await manager.shutdown().catch(() => undefined);
if (acquiredPort !== null) {
await waitForClosedPort(acquiredPort, 5_000);
}
rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
await manager.shutdown();
rmSync(tempDir, { recursive: true, force: true });
}
}, 60_000);
});
},
);
@@ -610,37 +601,3 @@ class FakeManagedProcesses implements ManagedProcessRegistry {
};
}
}
async function waitForClosedPort(port: number, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!(await canConnectToPort(port))) {
return;
}
await sleep(100);
}
throw new Error(`OpenCode helper server still accepts connections on port ${port}`);
}
function canConnectToPort(port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = net.createConnection({ host: "127.0.0.1", port });
let settled = false;
const settle = (connected: boolean) => {
if (settled) {
return;
}
settled = true;
socket.destroy();
resolve(connected);
};
socket.once("connect", () => settle(true));
socket.once("error", () => settle(false));
socket.setTimeout(500, () => settle(false));
});
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+1 -1
View File
@@ -1779,7 +1779,7 @@ export async function createPaseoDaemon(
await pluginRuntime.stopAllPlugins();
unsubscribePluginProviders();
await hubRelationships.stop();
workspaceReconciliation.dispose();
await workspaceReconciliation.dispose();
scriptHealthMonitor.stop();
// Freeze both ingress and registration before taking the agent closure snapshot.
wsServer?.prepareForShutdown();
@@ -10,13 +10,21 @@ afterEach(async () => {
test("the Hub CLI connects, reports status, and disconnects through the daemon", async () => {
relationship = await HubRelationshipHarness.start();
const connected = await relationship.beginConnect().result;
const connected = await relationship.runCli([
"hub",
"connect",
"https://hub.test",
"--api-key",
"hub-contract-api-key:ceremony-token",
"--permission",
"hub.execute",
]);
relationship.connectLatestSocket();
const status = await relationship.status();
const status = await relationship.runCli(["hub", "status"]);
const enrollment = relationship.enrollmentAttempts()[0];
const secret = relationship.relationshipFile()?.credential?.secret;
const disconnected = await relationship.disconnect();
const disconnected = await relationship.runCli(["hub", "disconnect"]);
expect(connected.state).toBe("connecting");
expect(status.state).toBe("connected");
@@ -297,7 +297,7 @@ describe("Hub relationship", () => {
version: 2,
relationship: { permissions: ["hub.execute"] },
});
expect(await relationship.status()).toMatchObject({ permissions: "hub.execute" });
expect(await relationship.status()).toMatchObject({ permissions: ["hub.execute"] });
});
test.each([
@@ -419,7 +419,7 @@ describe("Hub relationship", () => {
expect(disconnected).toMatchObject({
state: "not_connected",
hub: null,
hubOrigin: null,
warning: expect.stringContaining("server-side revocation may remain pending"),
});
expect(reconnected.state).toBe("connecting");
@@ -483,14 +483,14 @@ describe("Hub relationship", () => {
const disconnected = await relationship.disconnect(true);
expect(disconnected).toMatchObject({ state: "not_connected", hub: null });
expect(disconnected).toMatchObject({ state: "not_connected", hubOrigin: null });
expect(relationship.revocationAttempts()).toBe(0);
expect(relationship.relationshipFile()).toBeNull();
relationship.completeEnrollment();
await connecting.result;
expect(relationship.socketAttempts()).toBe(0);
expect(await relationship.status()).toMatchObject({ state: "not_connected", hub: null });
expect(await relationship.status()).toMatchObject({ state: "not_connected", hubOrigin: null });
});
test("daemon restart reconnects the same durable relationship", async () => {
@@ -811,9 +811,9 @@ describe("Hub relationship", () => {
expect(status).toMatchObject({
state: "revoked",
daemonId: enrollment.daemonId,
hub: "https://hub.test",
permissions: "hub.execute",
error: reason,
hubOrigin: "https://hub.test",
permissions: ["hub.execute"],
lastError: reason,
});
expect(persisted?.state).toBe("revoked");
expect(persisted?.relationship).toMatchObject({
@@ -848,10 +848,10 @@ describe("Hub relationship", () => {
expect(disconnected).toMatchObject({
state: "not_connected",
hub: null,
hubOrigin: null,
warning: expect.stringContaining("server-side revocation may remain pending"),
});
expect(await relationship.status()).toMatchObject({ state: "not_connected", hub: null });
expect(await relationship.status()).toMatchObject({ state: "not_connected", hubOrigin: null });
expect(relationship.revocationAttempts()).toBe(1);
expect(relationship.socketAttempts()).toBe(1);
expect(relationship.pendingRelationshipRetries()).toBe(0);
@@ -864,10 +864,14 @@ describe("Hub relationship", () => {
const disconnected = await relationship.disconnect();
expect(disconnected).toMatchObject({ state: "not_connected", hub: null, error: null });
expect(disconnected).toMatchObject({
state: "not_connected",
hubOrigin: null,
lastError: null,
});
expect(disconnected).not.toHaveProperty("warning");
expect(relationship.revocationAttempts()).toBe(1);
expect(await relationship.status()).toMatchObject({ state: "not_connected", error: null });
expect(await relationship.status()).toMatchObject({ state: "not_connected", lastError: null });
});
test("force disconnect removes local authority without notifying the Hub", async () => {
@@ -876,7 +880,7 @@ describe("Hub relationship", () => {
const forced = await relationship.disconnect(true);
expect(forced).toMatchObject({ state: "not_connected", hub: null });
expect(forced).toMatchObject({ state: "not_connected", hubOrigin: null });
expect(forced).not.toHaveProperty("warning");
expect(relationship.revocationAttempts()).toBe(0);
expect(relationship.loggableValues(forced)).not.toContain(
@@ -895,7 +899,7 @@ describe("Hub relationship", () => {
await relationship.startStoppedDaemon();
expect(await relationship.status()).toMatchObject({ state: "not_connected", hub: null });
expect(await relationship.status()).toMatchObject({ state: "not_connected", hubOrigin: null });
expect(relationship.relationshipFile()).toBeNull();
expect(relationship.revocationAttempts()).toBe(0);
expect(relationship.pendingRelationshipRetries()).toBe(0);
@@ -425,7 +425,7 @@ class InMemoryHubRelationships implements HubRelationshipRemote {
}
}
interface CliProcess {
interface RelationshipOperation {
result: Promise<Record<string, unknown>>;
}
@@ -454,7 +454,7 @@ export class HubRelationshipHarness {
private host = "";
private readonly logs: string[] = [];
private readonly providerPrompts: AgentPromptInput[] = [];
private readonly cliProcesses = new Set<Promise<unknown>>();
private readonly operations = new Set<Promise<unknown>>();
private readonly claimedCliSockets = new Set<WebSocket>();
private readonly promptsToFail = new Set<string>();
private failNextSessionClose = false;
@@ -514,37 +514,44 @@ export class HubRelationshipHarness {
this.remote.holdEnrollment();
}
beginConnect(token = "ceremony-token", hubUrl = HUB_ORIGIN, execute = true): CliProcess {
beginConnect(
token = "ceremony-token",
hubUrl = HUB_ORIGIN,
execute = true,
): RelationshipOperation {
return {
result: this.runCli([
"hub",
"connect",
hubUrl,
"--api-key",
`hub-contract-api-key:${token}`,
...(execute ? ["--permission", "hub.execute"] : []),
]),
result: this.withClient(
async (client) =>
(await client.connectHub(hubUrl, token, execute ? ["hub.execute"] : [])).status,
),
};
}
async status(): Promise<Record<string, unknown>> {
return this.runCli(["hub", "status"]);
return this.withClient(async (client) => (await client.getHubStatus()).status);
}
async disconnect(force = false): Promise<Record<string, unknown>> {
return this.runCli(["hub", "disconnect", ...(force ? ["--force"] : [])]);
return this.withClient(async (client) => {
const { status, warning } = await client.disconnectHub(force);
return { ...status, ...(warning ? { warning } : {}) };
});
}
async grantPermission(permission: string): Promise<Record<string, unknown>> {
return this.runCli(["hub", "permissions", "grant", permission]);
return this.withClient(
async (client) => (await client.updateHubPermissions({ grant: [permission] })).status,
);
}
async revokePermission(permission: string): Promise<Record<string, unknown>> {
return this.runCli(["hub", "permissions", "revoke", permission]);
return this.withClient(
async (client) => (await client.updateHubPermissions({ revoke: [permission] })).status,
);
}
beginDisconnect(force = false): CliProcess {
return { result: this.runCli(["hub", "disconnect", ...(force ? ["--force"] : [])]) };
beginDisconnect(force = false): RelationshipOperation {
return { result: this.disconnect(force) };
}
async relationshipStateBecomes(expected: string | null): Promise<void> {
@@ -1372,7 +1379,7 @@ export class HubRelationshipHarness {
async close(): Promise<void> {
await this.stopDaemon();
await Promise.allSettled(this.cliProcesses);
await Promise.allSettled(this.operations);
await Promise.all(
[...this.claimedCliSockets].map((socket) => this.closeClaimedCliSocket(socket)),
);
@@ -1433,8 +1440,21 @@ export class HubRelationshipHarness {
this.daemon = null;
}
private runCli(args: string[]): Promise<Record<string, unknown>> {
return this.trackCli(this.executeCli(args));
private withClient<T>(action: (client: DaemonClient) => Promise<T>): Promise<T> {
return this.trackOperation(
(async () => {
const client = await this.trustedClient();
try {
return await action(client);
} finally {
await client.close();
}
})(),
);
}
runCli(args: string[]): Promise<Record<string, unknown>> {
return this.trackOperation(this.executeCli(args));
}
private async executeCli(args: string[]): Promise<Record<string, unknown>> {
@@ -1458,11 +1478,11 @@ export class HubRelationshipHarness {
return parsed as Record<string, unknown>;
}
private trackCli<T>(process: Promise<T>): Promise<T> {
this.cliProcesses.add(process);
private trackOperation<T>(process: Promise<T>): Promise<T> {
this.operations.add(process);
void process.then(
() => this.cliProcesses.delete(process),
() => this.cliProcesses.delete(process),
() => this.operations.delete(process),
() => this.operations.delete(process),
);
return process;
}
@@ -1,4 +1,4 @@
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, watch as watchPath } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
@@ -30,6 +30,52 @@ afterEach(() => {
for (const target of cleanupPaths.splice(0)) rmSync(target, { recursive: true, force: true });
});
test("disposal releases a real project root watch before its directory is removed", async () => {
const root = mkdtempSync(path.join(tmpdir(), "project-watch-close-"));
cleanupPaths.push(root);
const logger = createTestLogger();
const projects = new FileBackedProjectRegistry(path.join(root, "projects.json"), logger);
const workspaces = new FileBackedWorkspaceRegistry(path.join(root, "workspaces.json"), logger);
await projects.initialize();
await workspaces.initialize();
await projects.upsert(
createPersistedProjectRecord({
projectId: "project-watch-close",
rootPath: root,
kind: "non_git",
displayName: "root watch",
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
}),
);
let released = false;
const service = new WorkspaceReconciliationService({
projectRegistry: projects,
workspaceRegistry: workspaces,
logger,
watchProjectRoot: (rootPath, options, onChange, onError) => {
const watcher = watchPath(rootPath, options, onChange);
watcher.on("error", onError);
const closed = new Promise<void>((resolve) =>
watcher.once("close", () => {
released = true;
resolve();
}),
);
return {
close: () => {
watcher.close();
return closed;
},
};
},
});
await service.start();
await service.dispose();
expect(released).toBe(true);
rmSync(root, { recursive: true });
});
interface ProjectSpec {
id: string;
root: string;
@@ -188,7 +234,11 @@ class ObservedPlacements {
const watch = { rootPath, onChange, onError, closed: false };
this.watches.push(watch);
this.lifecycleEvents.push(`watch installed:${path.relative(this.home, rootPath)}`);
return { close: () => (watch.closed = true) };
return {
close: async () => {
watch.closed = true;
},
};
};
this.service = new WorkspaceReconciliationService({
projectRegistry: this.projects,
@@ -277,8 +327,8 @@ class ObservedPlacements {
await this.clock.advanceBy(elapsedMs);
}
dispose(): void {
this.service.dispose();
async dispose(): Promise<void> {
await this.service.dispose();
}
watchedRoots(): string[] {
@@ -407,7 +457,7 @@ describe("observed workspace placement", () => {
"registry mutation resolved:project-new",
]);
expect(observed.gitReads).toBe(0);
observed.dispose();
await observed.dispose();
});
test("deduplicates active root watches and tears them down on archive and remove", async () => {
@@ -434,7 +484,7 @@ describe("observed workspace placement", () => {
{ kind: "remove", projectId: "project-duplicate" },
{ kind: "remove", projectId: "project-remove" },
]);
observed.dispose();
await observed.dispose();
});
test("filters unrelated files and coalesces Git change bursts", async () => {
@@ -450,7 +500,7 @@ describe("observed workspace placement", () => {
observed.change("repo", null);
await observed.advanceBy(DEBOUNCE_MS);
expect(observed.gitReads).toBe(1);
observed.dispose();
await observed.dispose();
});
test("recovers errored and temporarily unavailable watchers on the periodic pass", async () => {
@@ -468,7 +518,7 @@ describe("observed workspace placement", () => {
expect(observed.watchedRoots()).toEqual(["errored", "unavailable"]);
expect(observed.closedRoots()).toEqual(["errored"]);
observed.dispose();
await observed.dispose();
});
test("archives missing workspace directories on the periodic pass", async () => {
@@ -482,7 +532,7 @@ describe("observed workspace placement", () => {
expect((await observed.placement("workspace-one"))?.archivedAt).toEqual(expect.any(String));
expect(observed.workspaceBatches).toEqual([["workspace-one"]]);
observed.dispose();
await observed.dispose();
});
test("preserves a periodic full pass queued behind metadata reconciliation", async () => {
@@ -504,7 +554,7 @@ describe("observed workspace placement", () => {
expect((await observed.placement("workspace-one"))?.archivedAt).toEqual(expect.any(String));
expect(observed.workspaceBatches).toEqual([["workspace-one"]]);
observed.dispose();
await observed.dispose();
});
test("contains a failed reconciliation and converges on the next change", async () => {
@@ -526,7 +576,7 @@ describe("observed workspace placement", () => {
branch: "main",
displayName: "Durable workspace-one",
});
observed.dispose();
await observed.dispose();
});
test("deduplicates direct placement and project-derived workspace fanout", async () => {
@@ -549,7 +599,7 @@ describe("observed workspace placement", () => {
expect(observed.projectUpdates).toHaveLength(1);
expect(observed.workspaceBatches).toEqual([["workspace-one", "workspace-two"]]);
expect(new Set(observed.workspaceBatches[0]).size).toBe(2);
observed.dispose();
await observed.dispose();
});
test("disposal suppresses in-flight mutations and reconciliation fanout", async () => {
@@ -558,7 +608,7 @@ describe("observed workspace placement", () => {
const registryRead = mutation.holdNextRegistryRead();
const adding = mutation.add({ id: "project-late", root: "late" });
await registryRead.started;
mutation.dispose();
await mutation.dispose();
registryRead.release();
await adding;
expect(mutation.projectUpdates).toEqual([]);
@@ -573,7 +623,7 @@ describe("observed workspace placement", () => {
reconciliation.change("repo", ".git");
const advancing = reconciliation.advanceBy(DEBOUNCE_MS);
await workspaceRead.started;
reconciliation.dispose();
await reconciliation.dispose();
workspaceRead.release();
await advancing;
@@ -25,7 +25,7 @@ export type ProjectUpdate =
| { kind: "remove"; projectId: string };
interface ProjectRootWatcher {
close(): void;
close(): Promise<void>;
}
export interface ProjectRootWatch {
@@ -58,7 +58,13 @@ const systemClock: ReconciliationClock = {
const watchProjectRoot: ProjectRootWatch = (rootPath, options, onChange, onError) => {
const watcher = watchPath(rootPath, options, onChange);
watcher.on("error", onError);
return watcher;
const closed = new Promise<void>((resolve) => watcher.once("close", resolve));
return {
close() {
watcher.close();
return closed;
},
};
};
export type ReconciliationChange =
@@ -178,14 +184,15 @@ export class WorkspaceReconciliationService {
this.rescanTimer.unref?.();
}
dispose(): void {
async dispose(): Promise<void> {
this.disposed = true;
this.unsubscribeRegistry?.();
this.unsubscribeRegistry = null;
if (this.rescanTimer) this.clock.clearInterval(this.rescanTimer);
if (this.debounceTimer) this.clock.clearTimeout(this.debounceTimer);
for (const { watcher } of this.watchers) watcher.close();
const closing = this.watchers.map(({ watcher }) => watcher.close());
this.watchers.length = 0;
await Promise.all(closing);
}
/** Reconciles mutable Git facts only; never archives missing records. */
@@ -413,7 +420,7 @@ export class WorkspaceReconciliationService {
areEquivalentPaths(project.rootPath, target.rootPath),
);
if (stillActive) continue;
target.watcher.close();
void target.watcher.close();
this.watchers.splice(index, 1);
}
@@ -433,7 +440,7 @@ export class WorkspaceReconciliationService {
}
},
(error) => {
watcher.close();
void watcher.close();
const index = this.watchers.findIndex((target) => target.watcher === watcher);
if (index >= 0) this.watchers.splice(index, 1);
this.logger.warn(