chore(chat): merge main while preserving stacked find features

This commit is contained in:
Mohamed Boudra
2026-09-15 16:19:18 +02:00
794 changed files with 36260 additions and 17173 deletions
@@ -37,7 +37,7 @@ import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-
import { gotoAppShell, openSettings, selectModel } from "../support/helpers/app";
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
import {
expectResumeOverflowFallsBackToOneTail,
expectOneResumeCheckWithoutTail,
rememberTimelineRequestCounts,
} from "../support/helpers/timeline-resume";
import {
@@ -466,25 +466,29 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
}
await expect(targetDeckEntry).toHaveCount(0);
await subscriptions.waitForSubscribedAgents([
evictionAgents[WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES - 1]!.agentId,
target.agentId,
...evictionAgents.map((agent) => agent.agentId),
]);
gate.setAgentStreamSuppressed(false);
await target.client.waitForFinish(target.agentId, 30_000);
const requestsBeforeReturn = rememberTimelineRequestCounts(gate);
const requestsBeforeReturn = rememberTimelineRequestCounts(gate, target.agentId);
await waitForWorkspaceInSidebar(page, {
serverId: getServerId(),
workspaceId: target.workspaceId,
});
await openAgentRoute(page, target);
await expectComposerVisible(page);
await subscriptions.waitForSubscribedAgents([target.agentId]);
await subscriptions.waitForSubscribedAgents([
target.agentId,
...evictionAgents.map((agent) => agent.agentId),
]);
const response = page.getByText("(end of synthetic stream)", { exact: true }).last();
await expect(promptRow).toBeVisible();
await expect(response).toBeVisible();
await expectRenderedBefore(promptRow, response);
expectResumeOverflowFallsBackToOneTail(gate, requestsBeforeReturn);
expectOneResumeCheckWithoutTail(gate, requestsBeforeReturn);
} finally {
gate.setAgentStreamSuppressed(false);
gate.restore();
@@ -868,7 +872,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 +893,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 +926,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 {
@@ -9,7 +9,7 @@ import {
seedModelProvider,
} from "../support/helpers/agent-profiles";
import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { captureWorkspaceAgentRequest } from "../support/helpers/creation";
import {
openGlobalNewWorkspaceComposer,
selectNewWorkspaceProject,
@@ -22,53 +22,6 @@ const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
const MODELESS_PROVIDER = "modeless-profile-e2e";
const MODELESS_MODEL = "pi-profile-model";
type WebSocketMessage = string | Buffer;
interface CreateAgentRequestMessage {
type: "create_agent_request";
config?: {
provider?: unknown;
modeId?: unknown;
featureValues?: unknown;
};
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(rawMessage) as { type?: unknown; message?: unknown };
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
? (envelope.message as Record<string, unknown>)
: null;
} catch {
return null;
}
}
async function recordAndBlockCreateAgentRequest(page: Page): Promise<{
waitForRequest(): Promise<CreateAgentRequestMessage>;
}> {
let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null;
const request = new Promise<CreateAgentRequestMessage>((resolve) => {
resolveRequest = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request") {
resolveRequest?.(sessionMessage as unknown as CreateAgentRequestMessage);
return;
}
server.send(message);
});
server.onMessage((message) => ws.send(message));
});
return { waitForRequest: () => request };
}
async function seedPoisonedModelessPreference(page: Page): Promise<void> {
await page.addInitScript(
({ preferencesKey, provider, model }) => {
@@ -143,7 +96,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
],
});
await seedPoisonedModelessPreference(page);
const createAgentRecorder = await recordAndBlockCreateAgentRequest(page);
const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: true });
try {
await gotoAppShell(page);
@@ -206,7 +159,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
},
],
});
const createAgentRecorder = await recordAndBlockCreateAgentRequest(page);
const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: true });
try {
await gotoAppShell(page);
@@ -16,6 +16,17 @@ import {
waitForScrollableChat,
} from "../support/helpers/agent-bottom-anchor";
import { delayCreatedAgentInitialTailResponse } from "../support/helpers/agent-timeline-gate";
import {
collapseSettledWord,
installMarkdownRootObserver,
mountObservedSyntheticBlock,
readMarkdownRootEvidence,
replaceParagraph,
replaceParagraphKeepingFadingWord,
restartFadingWord,
resumeFadingWord,
settleMarkdownRootObserver,
} from "../support/helpers/markdown-root-stability";
import { selectModel } from "../support/helpers/app";
import { clickNewChat } from "../support/helpers/launcher";
import { expectComposerVisible, startRunningMockAgent } from "../support/helpers/composer";
@@ -63,6 +74,55 @@ test.describe("Agent stream UI", () => {
}
});
test("the Markdown root observer tells designed fade churn from replacements", async ({
page,
}) => {
await test.step("a word re-created with its start time resumes its fade", async () => {
await mountObservedSyntheticBlock(page);
await resumeFadingWord(page);
expect(await readMarkdownRootEvidence(page)).toEqual({
replacedElements: 0,
restartedFades: 0,
});
});
await test.step("a word re-created with a new start time is a restarted fade", async () => {
await mountObservedSyntheticBlock(page);
await restartFadingWord(page);
expect(await readMarkdownRootEvidence(page)).toEqual({
replacedElements: 0,
restartedFades: 1,
});
});
await test.step("a settled word collapsing into text is not a replacement", async () => {
await mountObservedSyntheticBlock(page);
await collapseSettledWord(page);
expect(await readMarkdownRootEvidence(page)).toEqual({
replacedElements: 0,
restartedFades: 0,
});
});
await test.step("a re-created paragraph is a replaced element", async () => {
await mountObservedSyntheticBlock(page);
await replaceParagraph(page);
expect(await readMarkdownRootEvidence(page)).toEqual({
replacedElements: 1,
restartedFades: 0,
});
});
await test.step("a re-created paragraph is still a replacement when it keeps its fading word", async () => {
await mountObservedSyntheticBlock(page);
await replaceParagraphKeepingFadingWord(page);
expect(await readMarkdownRootEvidence(page)).toEqual({
replacedElements: 1,
restartedFades: 0,
});
});
});
test("keeps the active Markdown root mounted across streamed text updates", async ({
page,
}, testInfo) => {
@@ -87,51 +147,20 @@ test.describe("Agent stream UI", () => {
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 page.evaluate(installMarkdownRootObserver, 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 settled = await page.evaluate(settleMarkdownRootObserver);
const rootState = await page.evaluate((root) => {
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,
};
return { connected: root.isConnected, sameRoot: block?.firstElementChild === root };
}, markdownRoot);
const evidence = { ...settled, ...rootState };
await testInfo.attach("markdown-root-stability", {
body: JSON.stringify(evidence, null, 2),
@@ -140,9 +169,13 @@ test.describe("Agent stream UI", () => {
expect(evidence.connected).toBe(true);
expect(evidence.sameRoot).toBe(true);
expect(
evidence.removedNodes,
evidence.replacedElements,
`Streaming Markdown replaced mounted descendants: ${JSON.stringify(evidence)}`,
).toBe(0);
expect(
evidence.restartedFades,
`Streaming Markdown re-created a fading word with a new start time: ${JSON.stringify(evidence)}`,
).toBe(0);
} finally {
await agent.cleanup();
}
@@ -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,
}) => {
@@ -181,7 +134,8 @@ test.describe("Agent timeline pagination", () => {
test("finishes loading an older page while live output continues", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 40 });
// The live turn streams for thirty minutes, so it is still running at every assertion.
const agent = await seedLongMockAgentTimeline({ turns: 40, liveTurns: "thirty-minute-stream" });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
@@ -193,8 +147,11 @@ test.describe("Agent timeline pagination", () => {
agent.agentId,
(snapshot) => snapshot.status === "running",
);
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await expect(timeline.getByText("walking through").first()).toBeAttached();
history.releasePage(1);
await expect(timeline.getByText(agent.newestOlderPagePrompt, { exact: true })).toBeAttached();
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 +162,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 +171,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();
}
@@ -84,6 +84,7 @@ test.describe("Assistant fork menu", () => {
await forkInFlightTurnToNewTab(page);
await expectChatHistoryAttachment(page);
expect(await forkAttachment.waitForText()).toContain(visibleBeforeFork);
await expect(page.getByRole("button", { name: "Menu backdrop", exact: true })).toHaveCount(0);
await page.getByRole("button", { name: sourceAgentTitle }).click();
await expectLiveAssistantText(page, visibleAfterFork);
+179 -89
View File
@@ -876,6 +876,32 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
).toContainText("Scroll long lines");
});
test("compact Changes jumps to a file from the changed-files sheet", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true });
await useUnwrappedDiffLines(page);
const explorer = await openCompactChanges(page, workspace);
await openChangedFilesOverview(page);
await test.step("reopening the overview expands folders again", async () => {
await expectOverviewReopensExpanded(page, "zz-folder", "changed.ts");
});
await test.step("jumping to a nested file closes the overview and scrolls to its diff", async () => {
await jumpToOverviewFile(page, explorer, "src/zz-folder/nested/changed.ts");
});
});
test("Jump to file stays out of the desktop diff and of an empty comparison", async ({ page }) => {
const committed = await createWorkspaceWithCommittedDiff();
await openWorkspaceChangesSurface(page, committed, 90_000);
await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0);
const explorer = await openCompactChanges(page, committed);
await expect(explorer.getByRole("button", { name: "Jump to file" })).toBeVisible();
await selectEmptyUncommittedComparison(page, explorer);
await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0);
});
test("canvas diff stays sharp while its workspace pane is resized", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
@@ -954,11 +980,21 @@ test("canvas diff stays sharp while its workspace pane is resized", async ({ pag
.toBe(true);
});
test("changes diff applies code size changes to gutter and code typography", async ({ page }) => {
test("changes diff waits for configured fonts, then applies code typography changes", async ({
page,
}) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useCodeFont(page, 12);
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await holdBrowserFontLoads(page);
await navigateToWorkspaceChanges(page, workspace);
await test.step("commit no geometry before the configured fonts are ready", async () => {
await expect(page.getByTestId("git-diff-canvas")).toBeVisible();
await expect(page.getByTestId("diff-file-0-body")).toHaveCount(0);
await releaseBrowserFontLoads(page);
await expectExpandedMountedTabDiff(page);
});
const before = await readDiffTypographyGeometry(page);
await changeCodeTypographyFromSettings(page, {
@@ -977,63 +1013,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,36 +1147,36 @@ 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 ({
context,
test("selects, preserves, copies, and dismisses diff text without opening a review", async ({
page,
}) => {
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await useUnwrappedDiffLines(page);
await openSelectionWorkspaceChanges(page, workspace);
await openCopyableSelectionDiff(page, "ABCDEFGHIJ");
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");
await test.step("copy the selected characters with the keyboard", async () => {
await copyDiffSelectionWithKeyboard(page);
await expectClipboardText(page, "CDEFGH");
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
});
await test.step("copy the preserved selection after resizing", async () => {
await resizeDiffViewportHeight(page, 960);
await copyFromChangedLineMenu(page, "Copy");
await expectClipboardText(page, "CDEFGH");
});
await test.step("copy the complete source line", async () => {
await copyFromChangedLineMenu(page, "Copy line");
await expectClipboardText(page, "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 +1190,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,14 +1384,17 @@ async function setOpenChangesPresentation(
}
}
/** Holds every font load in the document until released; later loads pass through. */
async function holdBrowserFontLoads(page: Page): Promise<void> {
await page.addInitScript(() => {
const fontSet = document.fonts;
const originalLoad = fontSet.load.bind(fontSet);
const pending: Array<() => void> = [];
let released = false;
Object.defineProperty(fontSet, "load", {
configurable: true,
value(font: string, text?: string) {
if (released) return originalLoad(font, text);
return new Promise<FontFace[]>((resolve, reject) => {
pending.push(() => {
originalLoad(font, text).then(resolve, reject);
@@ -1410,6 +1404,7 @@ async function holdBrowserFontLoads(page: Page): Promise<void> {
});
Object.assign(window, {
__releasePaseoDiffFontLoads() {
released = true;
for (const release of pending.splice(0)) release();
},
});
@@ -1659,12 +1654,72 @@ async function createWorkspaceWithStickyTransitionDiff(): Promise<DirtyWorkspace
}
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
await navigateToWorkspaceChanges(page, workspace);
await expectExpandedMountedTabDiff(page);
}
async function navigateToWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page);
await page.getByTestId("workspace-explorer-toggle").first().click();
await openChangesInVisibleExplorer(page);
await expectExpandedMountedTabDiff(page);
}
/** The Explorer overlay a phone-sized viewport shows, with its Changes tab selected. */
async function openCompactChanges(page: Page, workspace: DirtyWorkspace): Promise<Locator> {
await page.setViewportSize({ width: 390, height: 844 });
// Reload at the compact size: panel selection starts at the center on a cold
// mount, independently of any desktop sidebar used earlier in the test.
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await page.reload();
await page.getByTestId("workspace-explorer-toggle").first().click();
const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
await expect(changesTab).toBeVisible({ timeout: 30_000 });
await changesTab.click();
const explorer = page.getByTestId("explorer-content-area").filter({ visible: true });
await expect(explorer.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
return explorer;
}
async function openChangedFilesOverview(page: Page): Promise<void> {
const jumpToFile = page.getByRole("button", { name: "Jump to file" });
await expect(jumpToFile).toBeVisible();
await jumpToFile.click();
await expect(page.getByTestId("changes-jump-to-file-sheet")).toContainText("Jump to file");
}
async function expectOverviewReopensExpanded(
page: Page,
folderName: string,
childFileName: string,
): Promise<void> {
const sheet = page.getByTestId("changes-jump-to-file-sheet");
const tree = changesTree(page);
const folder = tree
.getByRole("button")
.filter({ has: page.getByText(folderName, { exact: true }) });
const child = tree.getByText(childFileName, { exact: true });
await expect(folder).toBeVisible();
await expect(child).toBeVisible();
await folder.click();
await expect(child).toHaveCount(0);
await sheet.getByRole("button", { name: "Close", exact: true }).click();
await openChangedFilesOverview(page);
await expect(child).toBeVisible();
}
async function jumpToOverviewFile(page: Page, explorer: Locator, filePath: string): Promise<void> {
const sheet = page.getByTestId("changes-jump-to-file-sheet");
await changesTree(page).getByText(path.basename(filePath), { exact: true }).click();
await expect(sheet).toHaveCount(0);
await expect(diffHeaderForPath(explorer, filePath)).toBeInViewport();
}
async function selectEmptyUncommittedComparison(page: Page, explorer: Locator): Promise<void> {
await explorer.getByRole("button", { name: "Diff mode", exact: true }).click();
await page.getByRole("menuitem", { name: "Uncommitted", exact: true }).click();
await expect(explorer.getByText("No changes to display", { exact: true })).toBeVisible();
}
async function openWorkspaceChangesSurface(
@@ -1820,6 +1875,41 @@ async function clickFirstChangedLine(page: Page): Promise<void> {
await page.mouse.click(bodyBounds.x + 120, bodyBounds.y + lineHeight * 1.5);
}
async function openCopyableSelectionDiff(page: Page, content: string): Promise<void> {
const workspace = await createWorkspaceWithExactSelectionDiff(content);
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await configureDiffPresentation(page, { layout: "unified", wrapLines: false });
await openSelectionWorkspaceChanges(page, workspace);
}
async function copyDiffSelectionWithKeyboard(page: Page): Promise<void> {
await page.evaluate(() => navigator.clipboard.writeText(""));
await page.keyboard.press("ControlOrMeta+C");
}
async function copyFromChangedLineMenu(page: Page, action: "Copy" | "Copy line"): Promise<void> {
await page.evaluate(() => navigator.clipboard.writeText(""));
await rightClickFirstChangedLine(page);
const item = page.getByRole("menuitem", { name: action, exact: true });
await expect(item).toBeEnabled();
await item.click();
}
async function expectClipboardText(page: Page, text: string): Promise<void> {
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(text);
}
async function resizeDiffViewportHeight(page: Page, height: number): Promise<void> {
const canvas = page.getByTestId("git-diff-canvas");
const previousViewport = page.viewportSize()!;
const previousHeight = await canvas.evaluate((element) => element.getBoundingClientRect().height);
await page.setViewportSize({ width: previousViewport.width, height });
await expect(canvas).toHaveCSS(
"height",
`${previousHeight + height - previousViewport.height}px`,
);
}
async function rightClickFirstChangedLine(page: Page, fileIndex = 0): Promise<void> {
const body = page.getByTestId(`diff-file-${fileIndex}-body`);
const canvas = page.getByTestId("git-diff-canvas");
@@ -1,5 +1,5 @@
import { expect, test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
@@ -12,6 +12,7 @@ test("Changes shortcut reveals the Changes tree in Explorer", async ({ page }) =
await page.setViewportSize({ width: 1400, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await page.keyboard.press(CHANGES_SHORTCUT);
@@ -20,6 +20,8 @@ import {
pointAtChatOutlineRowEdge,
pressEnterOnFocusedPrompt,
splitCurrentPanelRight,
withStreamingMarkdownOutline,
expectReadingStreamedMarkdown,
} from "../support/helpers/chat-outline";
import {
expectTimelineAtMaximumScrollWithPromptVisible,
@@ -41,6 +43,31 @@ const WIDE_VIEWPORT = { width: 1440, height: 900 };
const LOADED_TURNS = 16;
test.describe("desktop chat outline", () => {
test("keeps the prompt marked while reading split Markdown blocks and after completion", async ({
page,
}) => {
test.setTimeout(120_000);
const prompt = "Explain in several paragraphs.";
await withStreamingMarkdownOutline(async (agent) => {
await agent.client.sendAgentMessage(agent.agentId, "Earlier prompt.");
await agent.client.waitForFinish(agent.agentId, 30_000);
await page.setViewportSize(WIDE_VIEWPORT);
await openAgentTimeline(page, agent);
await agent.client.sendAgentMessage(agent.agentId, prompt);
await expectChatOutlinePrompts(page, 2);
await expectReadingStreamedMarkdown(page, prompt);
await expectActiveChatOutlinePrompt(page, 2);
await agent.client.waitForFinish(agent.agentId, 30_000);
await expectActiveChatOutlinePrompt(page, 2);
await page.reload({ waitUntil: "domcontentloaded" });
await expectActiveChatOutlinePrompt(page, 2);
await clickChatOutlineRowEdge(page, 2);
await expectTimelinePromptLandedBelowTop(page, prompt);
});
});
test("indexes unloaded prompts and jumps with one bounded merged page", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
@@ -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,
}) => {
@@ -9,6 +9,7 @@ import { expectWorkspaceTabVisible, openSessions } from "../support/helpers/arch
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { getServerId } from "../support/helpers/server-id";
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
import { expectMobileAgentSidebarVisible } from "../support/helpers/sidebar";
const TEST_COMMANDS = [
{
@@ -671,7 +672,7 @@ test.describe("Composer autocomplete", () => {
});
await page.getByRole("button", { name: "Open menu" }).click();
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 });
await expectMobileAgentSidebarVisible(page);
const popoverBox = await popover.boundingBox();
expect(popoverBox).not.toBeNull();
@@ -70,6 +70,7 @@ test.describe("Composer control density across tab switches", () => {
});
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
await clickNewChat(page);
const draftTabs = page
.locator('[data-testid^="workspace-tab-draft"]')
@@ -1,4 +1,4 @@
import { rm, writeFile } from "node:fs/promises";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -30,10 +30,13 @@ async function seedChangedAgent(repoPrefix: string) {
const workspace = await seedMockAgentWorkspace({
repoPrefix,
title: "Composer diff stat",
repo: { withRemote: true },
repo: {
withRemote: true,
// Exclude the local remote before the daemon starts observing this checkout.
files: [{ path: ".gitignore", content: "/remote.git/\n" }],
},
});
try {
await rm(path.join(workspace.cwd, "remote.git"), { recursive: true });
await writeFile(
path.join(workspace.cwd, "README.md"),
"# Temp Repo\nexport const one = 1;\nexport const two = 2;\n",
@@ -0,0 +1,130 @@
import type { createCreationScenario } from "../support/helpers/creation";
import { expect } from "../support/fixtures";
import { test } from "../support/creation-fixtures";
for (const isolation of ["local", "worktree"] as const) {
test(`repeated Create clicks before a render create only one ${isolation} workspace`, async ({
creation,
}) => {
await creation.openWorkspaceForm(isolation);
await creation.submitRepeatedly("Create");
await creation.expectOneCreatedWorkspace();
});
}
test("repeated Send clicks before a render create only one agent", async ({ creation }) => {
await creation.openAgentDraft();
await creation.submitRepeatedly("Send message", "Start exactly one agent for this prompt.");
await creation.expectPromptVisible();
await creation.expectAgentCount(1);
});
test("retrying the app's agent creation returns the same agent for every attempt", async ({
creation,
agentRetries,
}) => {
await creation.openAgentDraft();
await creation.submitPrompt("Start exactly one agent for this prompt.");
expect(new Set(await agentRetries.completedAgentIds()).size).toBe(1);
await creation.expectAgentCount(1);
});
test("the first prompt still names an agent created with a receipt", async ({ creation }) => {
await creation.openAgentDraft();
await creation.submitPrompt("Name this new agent from its first prompt.");
await creation.expectPromptVisible();
await creation.expectAgentTitle("Name this new agent from its first prompt.");
});
test("reconnecting after a lost creation response recovers the agent and initial message", async ({
creation,
promptRetry,
}) => {
await creation.openAgentDraft();
promptRetry.holdAcknowledgement();
await creation.submitPrompt("Deliver this initial prompt once.");
await promptRetry.waitForDeliveredPrompt();
await promptRetry.disconnectAndReconnect();
await promptRetry.expectSameAgentAndMessage();
await creation.expectAgentCount(1);
});
test("a remounted draft reconciles its original creation after reconnect", async ({
creation,
promptRetry,
}) => {
await creation.openAgentDraft();
promptRetry.holdAcknowledgement();
await creation.submitPrompt("Deliver this initial prompt once.");
await promptRetry.waitForDeliveredPrompt();
await creation.evictAndReturnToDraft();
await creation.expectPromptVisible("Deliver this initial prompt once.");
await promptRetry.disconnectAndReconnect();
await promptRetry.expectSameAgentAndMessage();
await creation.expectAgentCount(1);
});
test("repeated new-workspace prompt submissions create one workspace and one agent", async ({
creation,
}) => {
await creation.openWorkspaceForm("local");
await creation.submitRepeatedly("Create", "Start one agent in one new workspace.");
await creation.expectPromptVisible();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
});
test("separate drafts can intentionally create two agents in the same workspace", async ({
creation,
delayedCreation,
}) => {
await creation.openAgentDraft();
await creation.submitPrompt("Start the first agent.");
await creation.expectPromptVisible("Start the first agent.");
await delayedCreation.waitForDelayedCreatedStatus();
await creation.startAnotherDraft();
await creation.submitPrompt("Start the second agent.");
await creation.expectPromptVisible("Start the second agent.");
await delayedCreation.waitForDelayedCreatedStatus();
delayedCreation.release();
await creation.expectAgentCount(2);
});
test("new workspace navigation and optimistic prompt precede agent initialization", async ({
creation,
startup,
delayedCreation,
}) => {
await creation.openWorkspaceForm("worktree");
await creation.submitPrompt("Show this prompt while the agent is starting.", "Create");
await creation.expectAgentStillStarting();
delayedCreation.expectSingleWorkspaceIntent();
delayedCreation.release();
await startup.release();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
});
for (const scenario of [
{ suffix: "", prepare: async () => {} },
{
suffix: " after remount",
prepare: async (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
creation.evictAndReturnToDraft(),
},
]) {
test(`retrying failed agent initialization preserves its workspace${scenario.suffix}`, async ({
creation,
startup,
}) => {
await creation.openWorkspaceForm("local");
await creation.submitPrompt("Retry this workspace and agent together.", "Create");
await creation.expectAgentStillStarting();
await startup.fail();
await creation.expectStartupFailure();
await creation.expectOneCreatedWorkspace();
await scenario.prepare(creation);
await creation.submitPrompt("Retry this workspace and agent together.");
await creation.expectAgentCount(1);
});
}
@@ -0,0 +1,27 @@
import { randomUUID } from "node:crypto";
import { expect, test } from "../support/fixtures";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import {
openDaemonOverview,
restartDaemonInSettings,
expectDaemonRestartComplete,
readWorkerPid,
} from "../support/helpers/daemon-lifecycle";
test("settings restart waits for a replacement worker while retaining the supervisor", async ({
page,
}, testInfo) => {
const daemon = await startIsolatedHostDaemon(randomUUID());
try {
const supervisor = daemon.getPid();
const previousWorker = await readWorkerPid(daemon);
await openDaemonOverview(page, daemon);
await restartDaemonInSettings(page);
await expectDaemonRestartComplete(page);
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
expect(daemon.getPid()).toBe(supervisor);
await page.screenshot({ path: testInfo.outputPath("restart-confirmed.png"), fullPage: true });
} finally {
await daemon.close();
}
});
@@ -1,6 +1,10 @@
import { expect, test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { gotoWorkspace, pressDirectNewTabShortcut } from "../support/helpers/launcher";
import {
clickNewChat,
gotoWorkspace,
pressDirectNewTabShortcut,
} from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
@@ -11,6 +15,7 @@ test.describe("Direct terminal shortcut pane placement", () => {
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
const focusedPaneChild = page
@@ -1,7 +1,7 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { gotoWorkspace } from "../support/helpers/launcher";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
@@ -43,6 +43,7 @@ test.describe("New tab keyboard launcher", () => {
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
const launcher = visibleNewTabPanel(page);
@@ -7,7 +7,7 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { seedCorruptedWorkspaceLayout } from "../support/helpers/workspace-layout";
import {
openChangesTreePanel,
waitForWorkspaceTabsVisible,
@@ -93,132 +93,221 @@ async function dragChipTo(
await page.mouse.up();
}
function collectConsoleErrors(page: Page): string[] {
const errors: string[] = [];
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
page.on("pageerror", (error) => errors.push(`pageerror: ${String(error)}`));
return errors;
}
test.describe("explorer pane tab placement", () => {
test("agents open in the main pane and New-tab splits keep the app usable", async ({
page,
withWorkspace,
e2eWorkerClient,
}, testInfo) => {
const consoleErrors: string[] = [];
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("pageerror", (error) => consoleErrors.push(`pageerror: ${String(error)}`));
const consoleErrors = collectConsoleErrors(page);
const workspace = await seedWorkspace({ repoPrefix: "explorer-pane-placement-" });
const workspace = await withWorkspace({ prefix: "explorer-pane-placement-" });
let agentId = "";
try {
await test.step("empty workspace seeds a draft in the main pane", async () => {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await expect(draftTabChip(page).first()).toBeVisible({ timeout: 30_000 });
await test.step("choose Agent from the empty workspace launcher", async () => {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await openAgentDraftFromLauncher(page);
await expect(draftTabChip(page).first()).toBeVisible({ timeout: 30_000 });
});
await test.step("select Changes in Explorer", async () => {
await selectExplorerChanges(page);
});
await test.step("an agent appearing now opens in the main pane, not the explorer pane", async () => {
const agent = await e2eWorkerClient.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Explorer Placement Agent",
modeId: "load-test",
model: "ten-second-stream",
// Keep the agent streaming through the drag so pane contents are live,
// matching the reporter's "spinners were still going" environment.
initialPrompt: "stream please",
});
agentId = agent.id;
await expect(agentTabChip(page, agentId).first()).toBeVisible({ timeout: 30_000 });
await expect(
mainTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
).toBeVisible();
await expect(
explorerTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
).toHaveCount(0);
// The background open must not change the selected Explorer view.
await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
});
await test.step("close the draft; the agent is the main pane's only tab", async () => {
await closeSeededDraftInMainPane(page);
await expect(mainTabRow(page).first().locator('[data-testid^="workspace-tab-"]')).toHaveCount(
1,
);
await testInfo.attach("before-drag", {
body: await page.screenshot(),
contentType: "image/png",
});
});
await test.step("split the agent out of the main pane, leaving it empty", async () => {
const chip = agentTabChip(page, agentId).first();
const mainBox = await paneBoxContainingChip(page, chip);
// Land inside the left 15% edge band of the main pane content.
await dragChipTo(page, chip, {
x: mainBox.x + mainBox.width * 0.06,
y: mainBox.y + mainBox.height * 0.6,
});
await testInfo.attach("after-split", {
body: await page.screenshot(),
contentType: "image/png",
});
// The split must have taken: agent pane + New main. Explorer keeps its own rail.
await expect(visible(page, "workspace-tabs-row")).toHaveCount(2, { timeout: 10_000 });
await expect(explorerTabRow(page)).toHaveCount(1);
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
});
await test.step("app must stay interactive after the split", async () => {
// JS main thread alive?
const pong = await page.evaluate("1 + 1");
expect(pong).toBe(2);
// Pointer interaction alive? Selecting the agent chip must still work.
const chip = agentTabChip(page, agentId).first();
await chip.click({ position: { x: 12, y: 13 }, timeout: 5_000 });
await expect(chip).toHaveAttribute("aria-selected", "true", { timeout: 5_000 });
// Clicking inside the New tab pane (focuses it) must not lock anything. Aim
// above the vertically centred launcher, which would open a tab instead.
const emptyPane = newTabPaneChild(page);
await emptyPane.click({ position: { x: 40, y: 120 }, timeout: 5_000 });
// A second drag must work: center-drop the agent tab back into the New pane.
const target = await emptyPaneBox(page);
await dragChipTo(page, chip, {
x: target.x + target.width / 2,
y: target.y + target.height / 2,
});
await testInfo.attach("after-center-drop", {
body: await page.screenshot(),
contentType: "image/png",
});
await test.step("select Changes in Explorer", async () => {
await selectExplorerChanges(page);
// The agent chip must have replaced New in that pane and stay clickable.
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(0, {
timeout: 10_000,
});
await agentTabChip(page, agentId)
.first()
.click({ position: { x: 12, y: 13 } });
await test.step("an agent appearing now opens in the main pane, not the explorer pane", async () => {
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Explorer Placement Agent",
modeId: "load-test",
model: "ten-second-stream",
// Keep the agent streaming through the drag so pane contents are live,
// matching the reporter's "spinners were still going" environment.
initialPrompt: "stream please",
});
agentId = agent.id;
await expect(agentTabChip(page, agentId).first()).toBeVisible({ timeout: 30_000 });
await expect(
mainTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
).toBeVisible();
await expect(
explorerTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
).toHaveCount(0);
// The background open must not change the selected Explorer view.
await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
// The explorer toggle must still respond.
await visible(page, "workspace-explorer-toggle").first().click({ timeout: 5_000 });
await testInfo.attach("after-interactions", {
body: await page.screenshot(),
contentType: "image/png",
});
});
await test.step("close the draft; the agent is the main pane's only tab", async () => {
await closeSeededDraftInMainPane(page);
await expect(
mainTabRow(page).first().locator('[data-testid^="workspace-tab-"]'),
).toHaveCount(1);
await testInfo.attach("before-drag", {
body: await page.screenshot(),
contentType: "image/png",
});
});
await test.step("split the agent out of the main pane, leaving it empty", async () => {
const chip = agentTabChip(page, agentId).first();
const mainBox = await paneBoxContainingChip(page, chip);
// Land inside the left 15% edge band of the main pane content.
await dragChipTo(page, chip, {
x: mainBox.x + mainBox.width * 0.06,
y: mainBox.y + mainBox.height * 0.6,
});
await testInfo.attach("after-split", {
body: await page.screenshot(),
contentType: "image/png",
});
// The split must have taken: agent pane + New main. Explorer keeps its own rail.
await expect(visible(page, "workspace-tabs-row")).toHaveCount(2, { timeout: 10_000 });
await expect(explorerTabRow(page)).toHaveCount(1);
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
});
await test.step("app must stay interactive after the split", async () => {
// JS main thread alive?
const pong = await page.evaluate("1 + 1");
expect(pong).toBe(2);
// Pointer interaction alive? Selecting the agent chip must still work.
const chip = agentTabChip(page, agentId).first();
await chip.click({ position: { x: 12, y: 13 }, timeout: 5_000 });
await expect(chip).toHaveAttribute("aria-selected", "true", { timeout: 5_000 });
// Clicking inside the New tab pane (focuses it) must not lock anything. Aim
// above the vertically centred launcher, which would open a tab instead.
const emptyPane = newTabPaneChild(page);
await emptyPane.click({ position: { x: 40, y: 120 }, timeout: 5_000 });
// A second drag must work: center-drop the agent tab back into the New pane.
const target = await emptyPaneBox(page);
await dragChipTo(page, chip, {
x: target.x + target.width / 2,
y: target.y + target.height / 2,
});
await testInfo.attach("after-center-drop", {
body: await page.screenshot(),
contentType: "image/png",
});
// The agent chip must have replaced New in that pane and stay clickable.
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(0, {
timeout: 10_000,
});
await agentTabChip(page, agentId)
.first()
.click({ position: { x: 12, y: 13 } });
// The explorer toggle must still respond.
await visible(page, "workspace-explorer-toggle").first().click({ timeout: 5_000 });
await testInfo.attach("after-interactions", {
body: await page.screenshot(),
contentType: "image/png",
});
});
await testInfo.attach("console-errors", {
body: JSON.stringify(consoleErrors, null, 2),
contentType: "application/json",
});
} finally {
await workspace.cleanup();
}
await testInfo.attach("console-errors", {
body: JSON.stringify(consoleErrors, null, 2),
contentType: "application/json",
});
});
});
async function closeOnlyDraft(page: Page): Promise<void> {
await draftTabChip(page).hover();
await page.getByRole("button", { name: "Close", exact: true }).click();
}
async function moveOnlyDraftIntoRightSplit(page: Page): Promise<void> {
await page.getByRole("button", { name: "More actions", exact: true }).click();
await page.getByRole("menuitem", { name: "Split pane right", exact: true }).click();
const target = await emptyPaneBox(page);
await dragChipTo(page, draftTabChip(page), {
x: target.x + target.width / 2,
y: target.y + target.height / 2,
});
await expect(visible(page, "workspace-tabs-row")).toHaveCount(1);
}
async function expectNewLauncher(page: Page): Promise<void> {
await expect(
page.getByTestId("workspace-new-tab-panel").getByRole("button", { name: "Agent", exact: true }),
).toBeVisible();
await expect(draftTabChip(page)).toHaveCount(0);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
await expect(page.getByText("Paseo ran into a problem.", { exact: true })).toHaveCount(0);
}
async function openAgentDraftFromLauncher(page: Page): Promise<void> {
await expectNewLauncher(page);
await page
.getByTestId("workspace-new-tab-panel")
.getByRole("button", { name: "Agent", exact: true })
.click();
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
}
// Explorer cannot replace the ordinary workspace canvas, including on restore.
test("closing the last split-born tab with hidden Explorer keeps a usable workspace", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "last-pane-hidden-explorer-" });
await gotoWorkspace(page, workspace.workspaceId);
await openAgentDraftFromLauncher(page);
await closeOnlyDraft(page);
await expectNewLauncher(page);
await openAgentDraftFromLauncher(page);
await moveOnlyDraftIntoRightSplit(page);
await closeOnlyDraft(page);
await expectNewLauncher(page);
await page.reload();
await expectNewLauncher(page);
});
test("visible Explorer does not replace the last ordinary workspace pane", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "last-pane-visible-explorer-" });
await gotoWorkspace(page, workspace.workspaceId);
await page.getByRole("button", { name: "Open Explorer sidebar", exact: true }).click();
await openAgentDraftFromLauncher(page);
await moveOnlyDraftIntoRightSplit(page);
await closeOnlyDraft(page);
await expectNewLauncher(page);
await page.reload();
await expectNewLauncher(page);
});
test("reloading a saved hidden generated Explorer recovers a usable workspace", async ({
page,
withWorkspace,
}) => {
const otherWorkspace = await withWorkspace({ prefix: "uncorrupted-explorer-" });
await gotoWorkspace(page, otherWorkspace.workspaceId);
await openAgentDraftFromLauncher(page);
const workspace = await withWorkspace({ prefix: "saved-hidden-explorer-" });
await gotoWorkspace(page, workspace.workspaceId);
await expectNewLauncher(page);
await seedCorruptedWorkspaceLayout(page, workspace.workspaceId);
await page.reload();
await expectNewLauncher(page);
await gotoWorkspace(page, otherWorkspace.workspaceId);
await expect(draftTabChip(page)).toHaveCount(1);
});
@@ -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(() => {});
}
});
});
// ═══════════════════════════════════════════════════════════════════════════
@@ -1,4 +1,7 @@
import { test } from "../support/fixtures";
import path from "node:path";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { test, expect, type Page } from "../support/fixtures";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
import {
closeModelPicker,
expectModelSearchEmptyState,
@@ -15,7 +18,7 @@ import {
expectSearchResultsVirtualized,
} from "../support/helpers/agent-profiles";
import { expectComposerVisible } from "../support/helpers/composer";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { clickNewChat, clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
const MOCK_PROVIDER_LABEL = "Mock Load Test";
@@ -158,3 +161,111 @@ test.describe("Cross-provider model search", () => {
}
});
});
async function setRuntimeCatalog(client: DaemonClient, count: number, cwd: string) {
await client.patchDaemonConfig({
providers: {
gemini: {
extends: "acp",
label: "Catalog provider",
enabled: true,
command: [
process.execPath,
path.resolve(__dirname, "../support/fixtures/catalog-acp.cjs"),
String(count),
],
},
},
});
await expect
.poll(
async () =>
(await client.getProvidersSnapshot({ cwd })).entries.find(
(entry) => entry.provider === "gemini",
)?.status,
{ timeout: 30_000 },
)
.toBe("ready");
}
async function reloadSavedDraft(page: Page) {
await page.evaluate(() =>
localStorage.setItem(
"@paseo:e2e-disable-default-seed-once",
localStorage.getItem("@paseo:e2e-seed-nonce")!,
),
);
await page.reload();
await expectComposerVisible(page);
}
async function expectOneCatalogChoice(page: Page, label: string) {
await openModelPicker(page);
await page.getByRole("button", { name: "Back", exact: true }).click();
await searchAllModels(page, label);
await expect(page.getByTestId("model-row-gemini-gemini-3.5-flash")).toHaveCount(1);
await closeModelPicker(page);
}
async function configureModelOverride(client: DaemonClient, cwd: string) {
await client.patchDaemonConfig({
providers: {
gemini: {
additionalModels: [{ id: "gemini-3.5-flash", label: "Configured model", isDefault: true }],
},
},
});
await expect
.poll(
async () =>
(await client.getProvidersSnapshot({ cwd: cwd })).entries.find(
(entry) => entry.provider === "gemini",
)?.models,
{ timeout: 30_000 },
)
.toMatchObject([{ id: "gemini-3.5-flash", label: "Configured model", isDefault: true }]);
}
const catalogTest = test.extend<{ catalogClient: DaemonClient }>({
catalogClient: async ({ e2eWorker }, provide) => {
void e2eWorker;
const client = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "catalog-models" });
try {
await provide(client);
} finally {
try {
await client.patchDaemonConfig({ removeProviders: ["gemini"] });
} finally {
await client.close();
}
}
},
});
catalogTest(
"New Agent and saved drafts stay usable with repeated runtime model rows",
async ({ page, withWorkspace, catalogClient: client }, testInfo) => {
const workspace = await withWorkspace({ prefix: "catalog-models-" });
await test.step("open a draft with a one-row runtime catalog", async () => {
await setRuntimeCatalog(client, 1, workspace.repoPath);
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
await expectComposerVisible(page);
});
await test.step("open New Agent after the provider publishes repeated model rows", async () => {
await clickNewTerminal(page);
await setRuntimeCatalog(client, 2, workspace.repoPath);
await clickNewChat(page);
await expectComposerVisible(page);
await expectOneCatalogChoice(page, "Gemini 3.5 Flash");
await page.screenshot({ path: testInfo.outputPath("duplicate-catalog-draft.png") });
});
await test.step("reopen the saved draft with the same catalog", async () => {
await reloadSavedDraft(page);
await expectOneCatalogChoice(page, "Gemini 3.5 Flash");
});
await test.step("retain configured model overrides when runtime IDs repeat", async () => {
await configureModelOverride(client, workspace.repoPath);
await reloadSavedDraft(page);
await expectOneCatalogChoice(page, "Configured model");
});
},
);
@@ -1,9 +1,10 @@
import { expect, test, type Page } from "../support/fixtures";
import { expect, type Page } from "../support/fixtures";
import { test } from "../support/creation-fixtures";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
import type { FormPreferences } from "@/create-agent-preferences/preferences";
import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { captureWorkspaceAgentRequest } from "../support/helpers/creation";
import { openAgentRoute } from "../support/helpers/mock-agent";
import {
openGlobalNewWorkspaceComposer,
@@ -17,40 +18,6 @@ import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
type WebSocketMessage = string | Buffer;
interface CreateAgentRequestMessage {
type: "create_agent_request";
config?: {
provider?: unknown;
modeId?: unknown;
};
}
function parseWebSocketJson(message: WebSocketMessage): unknown {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(rawMessage);
} catch {
return null;
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseWebSocketJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
return null;
}
if (typeof maybeEnvelope.message !== "object") {
return null;
}
return maybeEnvelope.message as Record<string, unknown>;
}
async function seedCodexDefaultPermissionPreferences(page: Page, cwd: string): Promise<string> {
const client = await connectDaemonClient<DaemonClient>({
clientIdPrefix: "codex-mode-preferences",
@@ -142,44 +109,15 @@ async function expectThinkingOptionsFit(page: Page): Promise<void> {
await expect(page.getByTestId("combobox-desktop-container")).toHaveCount(0, { timeout: 5_000 });
}
async function recordAndBlockCreateAgentRequests(page: Page): Promise<{
waitForCreateAgentRequest(): Promise<CreateAgentRequestMessage>;
}> {
let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null;
const createAgentSeen = new Promise<CreateAgentRequestMessage>((resolve) => {
resolveRequest = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request") {
resolveRequest?.(sessionMessage as unknown as CreateAgentRequestMessage);
return;
}
server.send(message);
});
server.onMessage((message) => {
ws.send(message);
});
});
return {
waitForCreateAgentRequest: () => createAgentSeen,
};
}
test.describe("New workspace Codex mode preferences", () => {
test.describe.configure({ timeout: 240_000 });
test("keeps Full Access as the global Codex mode after the workspace draft auto-submit handoff", async ({
page,
startup,
}) => {
const seeded = await seedWorkspace({ repoPrefix: "codex-mode-preferences-" });
const createAgentRecorder = await recordAndBlockCreateAgentRequests(page);
const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: false });
try {
await seedCodexDefaultPermissionPreferences(page, seeded.repoPath);
await gotoAppShell(page);
@@ -200,8 +138,12 @@ test.describe("New workspace Codex mode preferences", () => {
).toBeVisible();
await submitNewWorkspacePrompt(page, "Keep Codex full access selected globally.");
const createAgentRequest = await createAgentRecorder.waitForCreateAgentRequest();
const createAgentRequest = await createAgentRecorder.waitForRequest();
await expect(page).toHaveURL(/\/workspace\//);
await expect(
page.getByText("Keep Codex full access selected globally.", { exact: true }).first(),
).toBeVisible();
expect(createAgentRequest.config).toMatchObject({
provider: "codex",
modeId: "full-access",
@@ -209,6 +151,8 @@ test.describe("New workspace Codex mode preferences", () => {
await expect
.poll(() => readCodexModePreference(page), { timeout: 10_000 })
.toBe("full-access");
await startup.fail();
await expect(page.getByText(/Creation startup failed for test/).first()).toBeVisible();
} finally {
await seeded.cleanup();
}
@@ -0,0 +1,26 @@
import { test } from "../support/fixtures";
import { verifyDelayedWorkspaceCreation } from "../support/helpers/new-workspace-navigation";
test.describe("Delayed workspace creation", () => {
test.describe.configure({ timeout: 120_000 });
// 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");
});
});
@@ -1,5 +1,12 @@
import { randomUUID } from "node:crypto";
import { metroTest as test } from "../support/fixtures";
import { submitMessage, expectComposerEditable } from "../support/helpers/composer";
import {
focusTerminalSurface,
typeInTerminal,
getTerminalBufferText,
} from "../support/helpers/terminal-perf";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { metroTest as test, expect } from "../support/fixtures";
import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import type { MockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -17,6 +24,19 @@ test("does not repeat an assistant block when the current app paginates a publis
test.setTimeout(120_000);
const serverId = `srv_old_pagination_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
const daemon = await startIsolatedHostDaemon(serverId, { publishedVersion: "0.2.5" });
const observationSockets = new Set<unknown>();
page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => {
if (typeof payload !== "string") return;
const frame = JSON.parse(payload);
if (
frame.type === "session" &&
frame.message.type === "fetch_agents_request" &&
frame.message.subscribe
)
observationSockets.add(socket);
});
});
const workspace = await seedWorkspace({
repoPrefix: "timeline-old-daemon-pagination-",
port: daemon.port,
@@ -79,6 +99,40 @@ test("does not repeat an assistant block when the current app paginates a publis
history.expectRepeatedEntries();
await history.expectOwnedTextRendered("Now I have a clearer picture.");
expect(observationSockets.size).toBe(1);
await test.step("send a live turn after reconnecting the old daemon", async () => {
await daemon.restart();
await expect.poll(() => observationSockets.size, { timeout: 30_000 }).toBe(2);
await expectComposerEditable(page);
await submitMessage(
page,
"compatibility-after-reconnect: emit 1 coalesced agent stream updates",
);
await expectTimelinePromptVisible(
page,
"compatibility-after-reconnect: emit 1 coalesced agent stream updates",
);
// The browser and the seed client reconnect independently after the restart.
await agent.client.connect();
await agent.client.waitForFinish(agent.agentId, 20_000);
await page.screenshot({ path: test.info().outputPath("old-daemon-live-reconnect.png") });
});
await test.step("open a terminal and receive its output", async () => {
const terminal = await agent.client.createTerminal(agent.cwd, "Compatibility terminal");
expect(terminal.error).toBeNull();
const terminalId = terminal.terminal!.id;
const route = buildHostWorkspaceRoute(serverId, workspace.workspaceId);
await page.goto(`${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`);
await focusTerminalSurface(page);
await typeInTerminal(page, "printf 'compatibility-%s\\n' terminal-output\n");
await expect
.poll(() => getTerminalBufferText(page), { timeout: 15_000 })
.toContain("compatibility-terminal-output");
await page.screenshot({ path: test.info().outputPath("old-daemon-terminal.png") });
await agent.client.killTerminal(terminalId);
});
} finally {
await agent.cleanup();
await daemon.close();
@@ -1,4 +1,5 @@
import { mkdtempSync, realpathSync } from "node:fs";
import type { Page, TestInfo } from "@playwright/test";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "../support/fixtures";
@@ -18,7 +19,7 @@ const scenarios = [
},
{
provider: "codex" as const,
providerConfig: { featureValues: { plan_mode: true } },
providerConfig: { model: "gpt-5.6-sol", featureValues: { plan_mode: true } },
planPrompt:
"Produce a concise implementation plan with exactly these steps: Inspect permission steering; Implement permission steering; Verify permission steering. Do not implement it.",
planText: "Inspect permission steering",
@@ -27,57 +28,151 @@ const scenarios = [
},
];
test.describe("composer steer supersedes plan approval", () => {
for (const scenario of scenarios) {
test(`${scenario.provider} keeps the rejected plan in the timeline`, async ({
async function readConversationOrder(page: Page, prompt: string, reply: string) {
const plan = page.getByTestId("timeline-plan-card");
const question = page.getByTestId("user-message").filter({ hasText: prompt });
const answer = page.getByTestId("assistant-message").filter({ hasText: reply });
const boxes = await Promise.all([
plan.boundingBox(),
question.boundingBox(),
answer.boundingBox(),
]);
return boxes
.map((box, index) => {
if (!box) throw new Error("Expected the plan, follow-up, and answer to be rendered");
return { role: ["plan", "question", "answer"][index], y: box.y };
})
.sort((a, b) => a.y - b.y)
.map((item) => item.role);
}
async function togglePlan(
page: Page,
testId: string,
name: string,
expanded: boolean,
): Promise<void> {
const toggle = page.getByTestId(testId).getByRole("button", { name, exact: true });
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", String(expanded));
}
async function expectRejectedPlanCollapsed(page: Page): Promise<void> {
await expect(
page
.getByTestId("timeline-plan-card")
.getByRole("button", { name: "Rejected plan", exact: true }),
).toHaveAttribute("aria-expanded", "false");
}
async function reopenConversation(page: Page, reply: string): Promise<void> {
await page.reload();
await expect(page.getByTestId("timeline-plan-card")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("assistant-message").filter({ hasText: reply })).toBeVisible();
}
type PlanScenario = (typeof scenarios)[number];
interface PlanSteer {
page: Page;
scenario: PlanScenario;
testInfo: TestInfo;
}
async function withPlanSteer(context: PlanSteer, journey: () => Promise<void>): Promise<void> {
const { page, scenario } = context;
const cwd = realpathSync(
mkdtempSync(path.join(tmpdir(), `paseo-permission-steer-${scenario.provider}-`)),
);
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
}, testInfo) => {
test.setTimeout(420_000);
const cwd = realpathSync(
mkdtempSync(path.join(tmpdir(), `paseo-permission-steer-${scenario.provider}-`)),
);
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: scenario.provider,
cwd,
mode: "full-access",
providerConfig: scenario.providerConfig,
});
await submitMessage(page, scenario.planPrompt);
await waitForPermissionPrompt(page, 180_000);
const pendingPlan = page.getByTestId("permission-plan-card");
await expect(pendingPlan).toContainText(scenario.planText);
const pendingScreenshot = testInfo.outputPath(`${scenario.provider}-pending-plan.png`);
await page.screenshot({ path: pendingScreenshot });
await testInfo.attach(`${scenario.provider} pending plan`, {
path: pendingScreenshot,
contentType: "image/png",
});
await submitMessage(page, scenario.steerPrompt);
await expect(pendingPlan).toHaveCount(0, { timeout: 30_000 });
const rejectedPlan = page.getByTestId("timeline-plan-card");
await expect(rejectedPlan).toBeVisible({ timeout: 30_000 });
await expect(rejectedPlan).toContainText(scenario.planText);
await expect(
page.getByTestId("assistant-message").filter({ hasText: scenario.reply }),
).toBeVisible({
timeout: 180_000,
});
const rejectedScreenshot = testInfo.outputPath(`${scenario.provider}-rejected-plan.png`);
await page.screenshot({ path: rejectedScreenshot });
await testInfo.attach(`${scenario.provider} rejected plan`, {
path: rejectedScreenshot,
contentType: "image/png",
});
} finally {
await cleanupRewindFlow({ handle, cwd });
}
provider: scenario.provider,
cwd,
mode: "full-access",
providerConfig: scenario.providerConfig,
});
await journey();
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
}
async function reviewCollapsibleProposal({ page, scenario, testInfo }: PlanSteer): Promise<void> {
await submitMessage(page, scenario.planPrompt);
await waitForPermissionPrompt(page, 180_000);
const pendingPlan = page.getByTestId("permission-plan-card");
await expect(pendingPlan).toContainText(scenario.planText);
await togglePlan(page, "permission-plan-card", "Plan", false);
await expect(page.getByTestId("permission-request-deny")).toBeVisible();
await togglePlan(page, "permission-plan-card", "Plan", true);
const pendingScreenshot = testInfo.outputPath(`${scenario.provider}-pending-plan.png`);
await page.screenshot({ path: pendingScreenshot });
await testInfo.attach(`${scenario.provider} pending plan`, {
path: pendingScreenshot,
contentType: "image/png",
});
}
async function rejectWithFollowUp({ page, scenario, testInfo }: PlanSteer): Promise<void> {
await submitMessage(page, scenario.steerPrompt);
await expect(page.getByTestId("permission-plan-card")).toHaveCount(0, { timeout: 30_000 });
const rejectedPlan = page.getByTestId("timeline-plan-card");
await expect(rejectedPlan).toBeVisible({ timeout: 30_000 });
await expectRejectedPlanCollapsed(page);
await expect(
page.getByTestId("assistant-message").filter({ hasText: scenario.reply }),
).toBeVisible({
timeout: 180_000,
});
const rejectedScreenshot = testInfo.outputPath(`${scenario.provider}-rejected-plan.png`);
await page.screenshot({ path: rejectedScreenshot });
await testInfo.attach(`${scenario.provider} rejected plan`, {
path: rejectedScreenshot,
contentType: "image/png",
});
await expect(
page.getByTestId("user-message").filter({ hasText: scenario.steerPrompt }),
).toBeVisible();
expect
.soft(await readConversationOrder(page, scenario.steerPrompt, scenario.reply))
.toEqual(["plan", "question", "answer"]);
}
async function rereadRejectedPlan({ page, scenario, testInfo }: PlanSteer): Promise<void> {
const rejectedPlan = page.getByTestId("timeline-plan-card");
await togglePlan(page, "timeline-plan-card", "Rejected plan", true);
await expect(rejectedPlan).toContainText(scenario.planText);
await rejectedPlan.screenshot({
path: testInfo.outputPath(`${scenario.provider}-expanded-rejected-plan.png`),
});
await togglePlan(page, "timeline-plan-card", "Rejected plan", false);
}
async function restoreConversationOrder({ page, scenario }: PlanSteer): Promise<void> {
await reopenConversation(page, scenario.reply);
await expectRejectedPlanCollapsed(page);
expect(await readConversationOrder(page, scenario.steerPrompt, scenario.reply)).toEqual([
"plan",
"question",
"answer",
]);
}
for (const scenario of scenarios) {
test(`${scenario.provider} keeps the rejected plan in the timeline`, async ({
page,
}, testInfo) => {
test.setTimeout(420_000);
const context = { page, scenario, testInfo };
await withPlanSteer(context, async () => {
await test.step("Review a collapsible proposal", () => reviewCollapsibleProposal(context));
await test.step("Reject with a follow-up below the plan", () => rejectWithFollowUp(context));
await test.step("Reopen the rejected plan", () => rereadRejectedPlan(context));
await test.step("Keep conversation order after reload", () =>
restoreConversationOrder(context));
});
});
}
@@ -0,0 +1,164 @@
import { mkdtempSync, readFileSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Page, TestInfo } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { submitMessage } from "../support/helpers/composer";
import {
allowPermission,
denyPermission,
waitForPermissionPrompt,
} from "../support/helpers/permissions";
import {
assertComposerIdle,
cleanupRewindFlow,
launchAgent,
type AgentHandle,
} from "../support/helpers/rewind-flow";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
const PLAN_PROMPT =
"Propose a concise plan to append one line Hello to README.md. Present it for approval using ExitPlanMode. Do not ask questions. Only implement after approval. If the plan is rejected, reply PLAN_REJECTED and stop without proposing another plan. After implementing an approved plan, reply PLAN_IMPLEMENTED.";
async function requestPlan(page: Page, prompt: string): Promise<void> {
await submitMessage(page, prompt);
await waitForPermissionPrompt(page, 180_000);
await expect(page.getByTestId("permission-plan-card")).toContainText("README.md");
}
function planWithOutcome(page: Page, label: string) {
return page
.getByTestId("timeline-plan-card")
.filter({ has: page.getByRole("button", { name: label, exact: true }) });
}
async function expectPlanOutcome(page: Page, label: string, expanded: boolean): Promise<void> {
const card = planWithOutcome(page, label);
await expect(card).toHaveCount(1);
await expect(card.getByRole("button", { name: label, exact: true })).toHaveAttribute(
"aria-expanded",
String(expanded),
);
}
async function toggleRecordedPlan(page: Page, label: string, expanded: boolean): Promise<void> {
await planWithOutcome(page, label).getByRole("button", { name: label, exact: true }).click();
await expectPlanOutcome(page, label, expanded);
}
async function expectClaudeReply(page: Page, marker: string): Promise<void> {
await expect(page.getByTestId("assistant-message").filter({ hasText: marker })).toBeVisible({
timeout: 180_000,
});
await assertComposerIdle({ page });
}
async function restartIsolatedDaemon(): Promise<void> {
const port = Number(getE2EDaemonPort());
expect([6767, 6768]).not.toContain(port);
const client = await connectDaemonClient<{
connect(): Promise<void>;
close(): Promise<void>;
getDaemonStatus(): Promise<{ pid: number }>;
restartServer(reason: string): Promise<unknown>;
}>({ port, clientIdPrefix: "plan-history-restart" });
try {
const before = (await client.getDaemonStatus()).pid;
await client.restartServer("Verify plan history replay in the isolated Playwright daemon");
await expect
.poll(
async () => {
try {
return (await client.getDaemonStatus()).pid;
} catch {
return before;
}
},
{ timeout: 60_000 },
)
.not.toBe(before);
} finally {
await client.close();
}
}
async function expectRecordedPlans(page: Page): Promise<void> {
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(2, { timeout: 60_000 });
await expectPlanOutcome(page, "Rejected plan", false);
await expectPlanOutcome(page, "Approved plan", true);
}
interface PlanReview {
page: Page;
cwd: string;
testInfo: TestInfo;
}
async function withPlanReview(
page: Page,
testInfo: TestInfo,
review: (context: PlanReview) => Promise<void>,
): Promise<void> {
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-plan-lifecycle-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: "claude",
cwd,
mode: "full-access",
providerConfig: { model: "haiku", modeId: "plan" },
});
await review({ page, cwd, testInfo });
} finally {
await cleanupRewindFlow({ handle, cwd });
}
}
async function rejectPlanWithoutImplementing({ page, cwd, testInfo }: PlanReview): Promise<void> {
await requestPlan(page, PLAN_PROMPT);
await denyPermission(page);
await expectClaudeReply(page, "PLAN_REJECTED");
await expectPlanOutcome(page, "Rejected plan", false);
await toggleRecordedPlan(page, "Rejected plan", true);
await expect(planWithOutcome(page, "Rejected plan")).toContainText("README.md");
await page.screenshot({ path: testInfo.outputPath("button-rejected-plan-expanded.png") });
await toggleRecordedPlan(page, "Rejected plan", false);
expect(readFileSync(path.join(cwd, "README.md"), "utf8")).not.toContain("Hello");
}
async function approveFreshPlanAndImplement({ page, cwd, testInfo }: PlanReview): Promise<void> {
await requestPlan(page, `Propose the plan again for a fresh approval. ${PLAN_PROMPT}`);
await allowPermission(page);
await expectClaudeReply(page, "PLAN_IMPLEMENTED");
expect(readFileSync(path.join(cwd, "README.md"), "utf8")).toContain("Hello");
await expectRecordedPlans(page);
await toggleRecordedPlan(page, "Approved plan", false);
await toggleRecordedPlan(page, "Approved plan", true);
await page.screenshot({ path: testInfo.outputPath("approved-plan.png") });
}
async function restoreReadablePlanHistory({ page, testInfo }: PlanReview): Promise<void> {
await restartIsolatedDaemon();
await page.reload();
await expectRecordedPlans(page);
await toggleRecordedPlan(page, "Rejected plan", true);
await expect(planWithOutcome(page, "Rejected plan")).toContainText("README.md");
await toggleRecordedPlan(page, "Rejected plan", false);
await page.screenshot({ path: testInfo.outputPath("restored-plans.png") });
}
test("Claude button rejection and approval preserve collapsible plans after daemon restart", async ({
page,
}, testInfo) => {
test.setTimeout(420_000);
await withPlanReview(page, testInfo, async (review) => {
await test.step("Reject a plan without implementing it", () =>
rejectPlanWithoutImplementing(review));
await test.step("Approve and implement a fresh plan", () =>
approveFreshPlanAndImplement(review));
await test.step("Restore readable plans after daemon restart", () =>
restoreReadablePlanHistory(review));
});
});
@@ -3,9 +3,10 @@ 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();
@@ -14,6 +14,8 @@ async function openExample(page: Page, example: string) {
async function closeExample(page: Page) {
await page.getByRole("button", { name: "Close", exact: true }).last().click();
// The launcher is visible behind a closing sheet; wait for dismissal before reopening.
await expect(page.getByText(/^Modal example:/)).toHaveCount(0);
await expect(page.getByRole("button", { name: "Open Form", exact: true })).toBeVisible();
}
@@ -0,0 +1,35 @@
import { test } from "../support/fixtures";
import {
withTimelinePlugin,
requestPluginTimeline,
interactWithStreamingCard,
expectWholeCompletedCard,
expectBothConsecutiveTools,
} from "../support/helpers/plugin-timeline";
for (const width of [1100, 390]) {
test(`assistant plugin receives the whole streaming message at width ${width}`, async ({
page,
}, info) => {
await page.setViewportSize({ width, height: 900 });
await withTimelinePlugin(page, info, "assistant", async (agent) => {
await requestPluginTimeline(agent);
await interactWithStreamingCard(page);
await agent.client.waitForFinish(agent.agentId, 30_000);
await expectWholeCompletedCard(page);
// Local state is checked during growth; the viewport remounts on the history handoff.
await page.reload({ waitUntil: "domcontentloaded" });
await expectWholeCompletedCard(page);
});
});
}
test("Overview preserves both consecutive tool plugin cards", async ({ page }, info) => {
await withTimelinePlugin(page, info, "tools", async (agent) => {
await requestPluginTimeline(agent);
await agent.client.waitForFinish(agent.agentId, 30_000);
await expectBothConsecutiveTools(page);
await page.reload({ waitUntil: "domcontentloaded" });
await expectBothConsecutiveTools(page);
});
});
@@ -67,7 +67,7 @@ function contributeClient(client) {
pills.get(agentId)?.();
pills.delete(agentId);
};
const unsubscribe = client.paseo.agents.subscribe((update) => {
const apply = (update) => {
if (update.kind === "remove") {
remove(update.agentId);
return;
@@ -94,9 +94,20 @@ function contributeClient(client) {
},
});
pills.set(agent.id, () => pill.remove());
});
};
const lifetime = new AbortController();
void client.paseo.agents.list({ subscribe: {}, signal: lifetime.signal }).then(({ subscription }) => {
subscription.subscribe({
snapshot({ entries }) {
for (const removePill of pills.values()) removePill();
pills.clear();
for (const { agent } of entries) apply({ kind: "upsert", agent });
},
update(message) { if (message.type === "agent_update") apply(message.payload); },
});
}).catch((error) => { if (!lifetime.signal.aborted) console.error(error); });
return () => {
unsubscribe();
lifetime.abort();
for (const removePill of pills.values()) removePill();
pills.clear();
};
@@ -344,7 +355,9 @@ test.describe("plugin workspace panels and Command Center", () => {
await page.goto(buildAgentRoute(primary.workspaceId, agent.id));
await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 });
await expect(page.getByRole("button", { name: "Open composer review" })).toHaveCount(0);
// Pressing removed the pill from that page only. The reloaded page evaluates
// the plugin again, and its agents snapshot contributes the pill afresh.
await expect(page.getByRole("button", { name: "Open composer review" })).toBeVisible();
await openCompactSidebar(page);
// The sidebar's Search row dismisses the compact sidebar on its way to the
// command center, so nothing has to close it after the command runs.
@@ -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);
});
});
@@ -0,0 +1,102 @@
import { createServer, type Server } from "node:http";
import { expect, test, type Page, type TestInfo } from "@playwright/test";
let server: Server;
let appUrl: string;
test.beforeAll(async () => {
// Compile this separate fixture entrypoint in setup, like the main app warmup.
// Cold Metro compilation must not consume the recovery interaction's timeout.
test.setTimeout(120_000);
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) throw new Error("E2E_METRO_PORT is required");
const bundle = `http://localhost:${metroPort}/packages/app/e2e/support/recovery-app.bundle?platform=web&dev=true&minify=false&hot=false&lazy=true&transform.engine=hermes&transform.routerRoot=src%2Fapp`;
const compiled = await fetch(bundle, { signal: AbortSignal.timeout(120_000) });
if (!compiled.ok) throw new Error(`Recovery fixture compilation failed: HTTP ${compiled.status}`);
await compiled.arrayBuffer();
server = createServer((_request, response) => {
response.setHeader("Content-Type", "text/html");
response.end(
`<!doctype html><html><head><meta name="viewport" content="width=device-width, initial-scale=1"><style>html,body,#root{height:100%;margin:0}#root{display:flex}</style></head><body><div id="root"></div><script src="${bundle}"></script></body></html>`,
);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Fixture server did not start");
appUrl = `http://127.0.0.1:${address.port}`;
});
test.afterAll(async () => {
await new Promise<void>((resolve, reject) =>
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
}),
);
});
async function openBrokenStartup(page: Page) {
await page.addInitScript(() => {
localStorage.setItem(
"workspace-layout-state",
JSON.stringify({ state: { layoutByWorkspace: {} }, version: 1 }),
);
localStorage.setItem(
"paseo:last-workspace-route-selection",
JSON.stringify({ serverId: "fixture-host", workspaceId: "broken" }),
);
});
await page.goto(appUrl);
await expect(page.getByText("Paseo ran into a problem.", { exact: true })).toBeVisible();
}
async function readSavedWorkspaceState(page: Page) {
return page.evaluate(() => ({
layout: localStorage.getItem("workspace-layout-state"),
selection: localStorage.getItem("paseo:last-workspace-route-selection"),
}));
}
async function reloadToPicker(page: Page) {
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect(page).toHaveURL(`${appUrl}/open-project`);
await expect(page.getByText("Project picker", { exact: true })).toBeVisible();
await expect(page.getByText("Paseo ran into a problem.", { exact: true })).toHaveCount(0);
}
async function openHealthyWorkspace(page: Page) {
await page.getByRole("link", { name: "Open healthy workspace" }).click();
await expect(page.getByText("Healthy workspace", { exact: true })).toBeVisible();
}
async function breakCurrentWorkspace(page: Page) {
await page.getByRole("button", { name: "Break this workspace" }).click();
await expect(page.getByText("Paseo ran into a problem.", { exact: true })).toBeVisible();
}
async function captureRecoveryScreen(page: Page, testInfo: TestInfo, name: string) {
const path = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path });
await testInfo.attach(name, { path, contentType: "image/png" });
}
test("Reload escapes a startup redirect and supports another recovery", async ({
page,
}, testInfo) => {
await openBrokenStartup(page);
const saved = await readSavedWorkspaceState(page);
await captureRecoveryScreen(page, testInfo, "error-screen");
await reloadToPicker(page);
expect(await readSavedWorkspaceState(page)).toEqual(saved);
await captureRecoveryScreen(page, testInfo, "recovered-picker");
await openHealthyWorkspace(page);
await breakCurrentWorkspace(page);
await reloadToPicker(page);
});
test.describe("compact error recovery", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("Reload remains reachable and returns to the picker", async ({ page }, testInfo) => {
await openBrokenStartup(page);
await captureRecoveryScreen(page, testInfo, "compact-error-screen");
await reloadToPicker(page);
await captureRecoveryScreen(page, testInfo, "compact-recovered-picker");
});
});
@@ -1,12 +1,11 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
addFakeScheduleHostAndReload,
buildFakeScheduleHostWorkspace,
FAKE_HOST_MODEL_ID,
FAKE_HOST_MODEL_LABEL,
installFakeScheduleHost,
} from "../support/helpers/schedule-fake-host";
addScheduleHostAndReload,
createScheduleHost,
SECONDARY_MODEL_ID,
SECONDARY_MODEL_LABEL,
} from "../support/helpers/schedule-host";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { expectSettled, expectStableHeight } from "../support/helpers/settled";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
@@ -32,7 +31,11 @@ interface ScheduleSeedClient {
scheduleDelete(input: { id: string }): Promise<{ error: string | null }>;
}
async function seedMockSchedule(workspace: SeededWorkspace, name: string): Promise<string> {
async function seedMockSchedule(
workspace: SeededWorkspace,
name: string,
model = "ten-second-stream",
): Promise<string> {
const client = workspace.client as unknown as ScheduleSeedClient;
const result = await client.scheduleCreate({
prompt: "Say hello from the scheduled agent.",
@@ -43,7 +46,7 @@ async function seedMockSchedule(workspace: SeededWorkspace, name: string): Promi
config: {
provider: "mock",
cwd: workspace.repoPath,
model: "ten-second-stream",
model,
modeId: "load-test",
title: name,
},
@@ -66,42 +69,6 @@ async function deleteSeededSchedule(workspace: SeededWorkspace, id: string): Pro
.catch(ignoreScheduleDeleteError);
}
type FakeScheduleHostSchedule = NonNullable<
Parameters<typeof installFakeScheduleHost>[0]["schedules"]
>[number];
function buildFakeHostSchedule(input: {
id: string;
name: string;
cwd: string;
}): FakeScheduleHostSchedule {
const now = "2026-07-01T00:00:00.000Z";
return {
id: input.id,
name: input.name,
prompt: "Run on the secondary host.",
cadence: { type: "cron", expression: "0 9 * * *" },
target: {
type: "new-agent",
config: {
provider: "mock",
cwd: input.cwd,
model: FAKE_HOST_MODEL_ID,
modeId: "load-test",
title: input.name,
},
},
status: "active",
createdAt: now,
updatedAt: now,
nextRunAt: now,
lastRunAt: null,
pausedAt: null,
expiresAt: null,
maxRuns: null,
};
}
test.describe("Schedules", () => {
const cleanupTasks: Array<() => Promise<void>> = [];
@@ -154,34 +121,23 @@ test.describe("Schedules", () => {
test("edit form hydrates a non-default host schedule after reload", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "schedule-host-b-hydration-" });
cleanupTasks.push(() => workspace.cleanup());
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
const scheduleId = "fake-host-schedule";
const scheduleName = "Secondary host schedule";
await installFakeScheduleHost({
page,
port: fakePort,
serverId: fakeHost.serverId,
workspace: fakeHost.workspace,
project: fakeHost,
schedules: [
buildFakeHostSchedule({
id: scheduleId,
name: scheduleName,
cwd: String(fakeHost.workspace.workspaceDirectory),
}),
],
});
const secondary = await createScheduleHost();
cleanupTasks.push(() => secondary.cleanup());
const scheduleId = await seedMockSchedule(
secondary,
"Secondary host schedule",
SECONDARY_MODEL_ID,
);
cleanupTasks.push(() => deleteSeededSchedule(secondary, scheduleId));
await gotoAppShell(page);
await waitForSidebarHydration(page);
await page.goto(buildSchedulesRoute());
await addFakeScheduleHostAndReload({
await addScheduleHostAndReload({
page,
serverId: fakeHost.serverId,
label: "Fake host",
port: fakePort,
serverId: secondary.serverId,
label: secondary.label,
port: secondary.port,
});
await page.reload();
@@ -197,14 +153,14 @@ test.describe("Schedules", () => {
const modelTrigger = page.getByTestId("schedule-model-trigger");
const modeTrigger = page.getByTestId("schedule-mode-trigger");
await expect(hostTrigger).toContainText("Fake host", { timeout: 30_000 });
await expect(hostTrigger).toContainText(secondary.label, { timeout: 30_000 });
await expect(hostTrigger).toBeDisabled();
await expectSettled(hostTrigger);
await expect(projectTrigger).toContainText(fakeHost.projectDisplayName, { timeout: 30_000 });
await expect(projectTrigger).toContainText(secondary.projectDisplayName, { timeout: 30_000 });
await expectSettled(projectTrigger);
await expect(modelTrigger).toContainText(FAKE_HOST_MODEL_LABEL, { timeout: 30_000 });
await expect(modelTrigger).toContainText(SECONDARY_MODEL_LABEL, { timeout: 30_000 });
await expectSettled(modelTrigger);
await expect(modeTrigger).toContainText("Load test", { timeout: 30_000 });
await expect(modeTrigger).toContainText("Load Test", { timeout: 30_000 });
await expectSettled(modeTrigger);
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
@@ -1,10 +1,6 @@
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
addFakeScheduleHostAndReload,
buildFakeScheduleHostWorkspace,
installFakeScheduleHost,
} from "../support/helpers/schedule-fake-host";
import { addScheduleHostAndReload, createScheduleHost } from "../support/helpers/schedule-host";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
@@ -242,20 +238,13 @@ test.describe("Schedules project target", () => {
cadenceExpression: "0 9 * * *",
});
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
await installFakeScheduleHost({
const secondary = await createScheduleHost();
cleanupTasks.push(() => secondary.cleanup());
await addScheduleHostAndReload({
page,
port: fakePort,
serverId: fakeHost.serverId,
workspace: fakeHost.workspace,
project: fakeHost,
});
await addFakeScheduleHostAndReload({
page,
serverId: fakeHost.serverId,
label: "Fake host",
port: fakePort,
serverId: secondary.serverId,
label: secondary.label,
port: secondary.port,
});
const hostFilterTrigger = page.getByTestId("schedules-host-filter-trigger");
@@ -274,25 +263,17 @@ test.describe("Schedules project target", () => {
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-host-model-" });
cleanupTasks.push(() => workspace.cleanup());
const serverId = getServerId();
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
await installFakeScheduleHost({
page,
port: fakePort,
serverId: fakeHost.serverId,
workspace: fakeHost.workspace,
project: fakeHost,
});
const secondary = await createScheduleHost();
cleanupTasks.push(() => secondary.cleanup());
await gotoAppShell(page);
await waitForSidebarHydration(page);
await page.goto(buildSchedulesRoute());
await addFakeScheduleHostAndReload({
await addScheduleHostAndReload({
page,
serverId: fakeHost.serverId,
label: "Fake host",
port: fakePort,
serverId: secondary.serverId,
label: secondary.label,
port: secondary.port,
});
await expect(page.getByTestId("schedules-empty")).toBeVisible({ timeout: 30_000 });
await page.getByTestId("schedules-empty-new").click();
@@ -334,8 +315,8 @@ test.describe("Schedules project target", () => {
await expectSettled(modeTrigger);
await hostTrigger.click();
await page.getByTestId(`schedule-host-option-${fakeHost.serverId}`).click();
await expect(hostTrigger).toContainText("Fake host");
await page.getByTestId(`schedule-host-option-${secondary.serverId}`).click();
await expect(hostTrigger).toContainText(secondary.label);
await expect(projectTrigger).toContainText(/select project/i);
await expect(modelTrigger).toHaveCount(0);
await expect(thinkingTrigger).toHaveCount(0);
@@ -345,9 +326,9 @@ test.describe("Schedules project target", () => {
await projectTrigger.click();
await page
.getByTestId(`schedule-project-option-${projectEquivalenceViewKey(fakeHost.projectKey)}`)
.getByTestId(`schedule-project-option-${projectEquivalenceViewKey(secondary.projectKey)}`)
.click();
await expect(projectTrigger).toContainText(fakeHost.projectDisplayName);
await expect(projectTrigger).toContainText(secondary.projectDisplayName);
await expectSettled(projectTrigger);
await expect(modelTrigger).toContainText(/select model/i);
await expectSettled(modelTrigger);
@@ -355,7 +336,7 @@ test.describe("Schedules project target", () => {
await expect(modeTrigger).toHaveCount(0);
await page.getByLabel("Schedule name").fill(`Cross host model ${Date.now()}`);
await page.getByLabel("Prompt").fill("Run on the fake host project.");
await page.getByLabel("Prompt").fill("Run on the secondary host project.");
await expect(page.getByRole("button", { name: "Create schedule" })).toBeDisabled();
});
@@ -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,6 +1,6 @@
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "../support/fixtures";
import { createIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
import { waitForTabBar, expectAgentTabActive } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
@@ -104,12 +104,12 @@ test.describe("Settings toggle tab regression", () => {
const workspace = await seedWorkspace({ repoPrefix: "settings-toggle-tab-" });
try {
const firstAgent = await createIdleAgent(workspace.client, {
const firstAgent = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `settings-toggle-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(workspace.client, {
const secondAgent = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `settings-toggle-b-${Date.now()}`,
@@ -165,12 +165,12 @@ test.describe("Settings toggle tab regression", () => {
const workspace = await seedWorkspace({ repoPrefix: "settings-composer-height-" });
try {
const firstAgent = await createIdleAgent(workspace.client, {
const firstAgent = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `settings-composer-height-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(workspace.client, {
const secondAgent = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `settings-composer-height-b-${Date.now()}`,
@@ -225,12 +225,12 @@ test.describe("Settings toggle tab regression", () => {
const workspace = await seedWorkspace({ repoPrefix: "agent-route-refresh-" });
try {
const firstAgent = await createIdleAgent(workspace.client, {
const firstAgent = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `agent-route-refresh-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(workspace.client, {
const secondAgent = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `agent-route-refresh-b-${Date.now()}`,
@@ -7,7 +7,6 @@ import { getServerId } from "../support/helpers/server-id";
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs";
// Model B sidebar shape: every project — git or non-git, single- or
// multi-workspace — renders as the same expandable parent, the deepest sidebar
@@ -96,8 +95,9 @@ test.describe("Model B sidebar shape", () => {
try {
// Open the workspace and materialize both an agent tab and a terminal tab.
await gotoWorkspace(page, mock.workspaceId);
const agentTabs = await getVisibleWorkspaceAgentTabIds(page);
expect(agentTabs).toContain(`workspace-tab-agent_${mock.agentId}`);
await expect(
page.getByTestId(`workspace-tab-agent_${mock.agentId}`).filter({ visible: true }),
).toBeVisible();
await clickNewTerminal(page);
await expect(
@@ -0,0 +1,153 @@
import { rm, writeFile } from "node:fs/promises";
import path from "node:path";
import type { Locator, Page, TestInfo } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
closeSidebarDisplayPreferences,
openMobileAgentSidebar,
openSidebarDisplayPage,
selectSidebarStatusGrouping,
} from "../support/helpers/sidebar";
const TITLE = "Linux desktop sandbox launch support";
async function seedChangedWorkspace() {
const workspace = await seedWorkspace({
repoPrefix: "sidebar-title-",
title: TITLE,
repo: { withRemote: true },
});
try {
await rm(path.join(workspace.repoPath, "remote.git"), { recursive: true });
await workspace.client.renameProject(workspace.projectId, "Paseo");
await writeFile(path.join(workspace.repoPath, "README.md"), "Changed line\n".repeat(12345));
await workspace.client.checkoutRefresh(workspace.repoPath);
await expect
.poll(async () => {
const { entries } = await workspace.client.fetchWorkspaces();
return entries.find((entry) => entry.id === workspace.workspaceId)?.diffStat?.additions;
})
.toBe(12345);
return workspace;
} catch (error) {
await workspace.cleanup();
throw error;
}
}
function workspaceRow(page: Page) {
return page.getByRole("button", { name: new RegExp(TITLE) }).filter({ visible: true });
}
async function titleWidth(row: Locator) {
return row
.getByText(TITLE, { exact: true })
.evaluate((element) => element.getBoundingClientRect().width);
}
async function toggleTrailing(page: Page, label: "Diff stats" | "Last activity", touch = false) {
await openSidebarDisplayPage(page, "sidebar-display-show");
await page.getByRole("menuitem", { name: label, exact: true }).click();
if (touch) {
await page
.getByRole("button", { name: "Bottom sheet backdrop", exact: true })
.click({ position: { x: 5, y: 5 } });
await expect(
page.getByRole("button", { name: "Bottom sheet backdrop", exact: true }),
).toHaveCount(0);
} else {
await closeSidebarDisplayPreferences(page);
}
await page.mouse.move(0, 0);
}
async function expectTouchTitleWidth(page: Page) {
const row = workspaceRow(page);
const withDiff = await titleWidth(row);
await toggleTrailing(page, "Diff stats", true);
const withoutStats = await titleWidth(row);
expect(withDiff).toBeCloseTo(withoutStats, 0);
await toggleTrailing(page, "Last activity", true);
expect(await titleWidth(row)).toBeCloseTo(withoutStats, 0);
await toggleTrailing(page, "Diff stats", true);
}
let workspace: SeededWorkspace;
test.beforeEach(async () => {
workspace = await seedChangedWorkspace();
});
test.afterEach(async () => {
await workspace?.cleanup();
});
async function openTouchWorkspaceList(page: Page, testInfo: TestInfo) {
await page.setViewportSize({ width: 390, height: 844 });
await page.emulateMedia({ colorScheme: "dark" });
await gotoAppShell(page);
await openMobileAgentSidebar(page);
await expect(workspaceRow(page)).toBeVisible();
await page.mouse.move(0, 0);
await page.screenshot({ path: testInfo.outputPath("sidebar.png") });
}
async function pinWorkspaceForLayout(page: Page) {
await workspace.client.setWorkspacePinned(workspace.workspaceId, true);
await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible();
}
async function moveWorkspaceToStatusGrouping(page: Page) {
await workspace.client.setWorkspacePinned(workspace.workspaceId, false);
await selectSidebarStatusGrouping(page);
}
async function openDesktopWorkspaceList(page: Page) {
await gotoAppShell(page);
const row = workspaceRow(page);
await expect(row).toBeVisible();
await page.mouse.move(0, 0);
await expect(row.getByText("+12.3k", { exact: true })).toBeVisible();
return titleWidth(row);
}
async function expectHoverKeepsTitleWidth(page: Page, width: number, testInfo: TestInfo) {
const row = workspaceRow(page);
await row.hover();
await expect(row.getByLabel("Workspace actions", { exact: true })).toBeVisible();
expect(await titleWidth(row)).toBeCloseTo(width, 0);
await page.screenshot({ path: testInfo.outputPath("desktop-hover.png") });
}
async function expectShortcutHintsKeepTitleWidth(page: Page, width: number, testInfo: TestInfo) {
const row = workspaceRow(page);
await page.keyboard.down("Alt");
await expect(row.getByText("1", { exact: true })).toBeVisible();
expect(await titleWidth(row)).toBeCloseTo(width, 0);
await page.screenshot({ path: testInfo.outputPath("desktop-shortcuts.png") });
await page.keyboard.up("Alt");
await expect(row.getByText("1", { exact: true })).toHaveCount(0);
}
async function expectDisablingStatsExpandsTitle(page: Page, width: number) {
await toggleTrailing(page, "Diff stats");
expect(await titleWidth(workspaceRow(page))).toBeGreaterThan(width);
}
test("touch titles use the space occupied by hidden stats in every grouping", async ({
page,
}, testInfo) => {
await openTouchWorkspaceList(page, testInfo);
await expectTouchTitleWidth(page);
await pinWorkspaceForLayout(page);
await expectTouchTitleWidth(page);
await moveWorkspaceToStatusGrouping(page);
await expectTouchTitleWidth(page);
});
test("desktop hover and shortcut hints preserve title width", async ({ page }, testInfo) => {
const width = await openDesktopWorkspaceList(page);
await expectHoverKeepsTitleWidth(page, width, testInfo);
await expectShortcutHintsKeepTitleWidth(page, width, testInfo);
await expectDisablingStatsExpandsTitle(page, width);
});
@@ -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);
});
});
@@ -0,0 +1,21 @@
import { test } from "../support/fixtures";
import {
expectFinishedMarkdown,
expectReloadedMarkdown,
expectUnfinishedBold,
expectUnfinishedLink,
requestStreamingMarkdown,
withStreamingMarkdown,
} from "../support/helpers/streaming-markdown";
test("formats unfinished Markdown while streaming and preserves the completed rendering", async ({
page,
}, testInfo) => {
await withStreamingMarkdown(page, testInfo, async (agent) => {
await requestStreamingMarkdown(agent);
await expectUnfinishedBold(page);
await expectUnfinishedLink(page, agent, testInfo);
await expectFinishedMarkdown(page, agent, testInfo);
await expectReloadedMarkdown(page);
});
});
@@ -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();
}
});
});
@@ -20,6 +20,7 @@ import {
interface ViewedTimelineScenario {
client: SeedDaemonClient;
workspaceId: string;
repoPath: string;
firstAgentId: string;
secondAgentId: string;
cleanup(): Promise<void>;
@@ -45,14 +46,21 @@ async function seedViewedTimelineScenario(
return {
client: workspace.client,
workspaceId: workspace.workspaceId,
repoPath: workspace.repoPath,
firstAgentId: firstAgent.id,
secondAgentId: secondAgent.id,
cleanup: workspace.cleanup,
};
}
async function openAgent(page: Page, scenario: ViewedTimelineScenario, agentId: string) {
await page.goto(buildHostAgentDetailRoute(getServerId(), agentId, scenario.workspaceId));
async function openAgent(
page: Page,
scenario: ViewedTimelineScenario,
agentId: string,
options: { recordRenders?: boolean } = {},
) {
const route = buildHostAgentDetailRoute(getServerId(), agentId, scenario.workspaceId);
await page.goto(options.recordRenders ? `${route}&renderProfile=1` : route);
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
);
@@ -63,6 +71,26 @@ async function selectAgent(page: Page, title: string) {
await page.getByRole("button", { name: title, exact: true }).click();
}
async function expectForkFailureWithoutOverlappingStatus(page: Page) {
await page.getByRole("button", { name: "Fork chat from here" }).last().click();
await page.getByRole("menuitem", { name: "Fork in a new tab", exact: true }).click();
await expect(
page.getByRole("alert").filter({ hasText: "Transport not connected" }),
).toBeVisible();
await expectReconnectingToastGone(page, { timeout: 100 });
await expectReconnectingToastVisible(page);
}
async function countChatCommits(page: Page, agentId: string) {
return page.evaluate(
(id) =>
(globalThis.__PASEO_RENDER_PROFILE__ ?? []).filter(
(sample) => sample.id === `AgentStreamSection:${id}`,
).length,
agentId,
);
}
async function enableMoveTabShortcut(page: Page) {
await page.addInitScript(() => {
Object.defineProperty(navigator, "platform", { get: () => "MacIntel" });
@@ -97,7 +125,59 @@ async function expectAgentConsistentlyIdle(page: Page, title: string): Promise<v
await expectTurnCopyButton(page);
}
async function withViewedTimelineScenario(
run: (scenario: ViewedTimelineScenario) => Promise<void>,
) {
const scenario = await seedViewedTimelineScenario();
try {
await run(scenario);
} finally {
await scenario.cleanup();
}
}
async function openSevenChats(page: Page, scenario: ViewedTimelineScenario) {
const subscriptions = observeTimelineSubscriptions(page);
const additional = [];
for (let index = 0; index < 5; index += 1) {
additional.push(
await scenario.client.createAgent({
provider: "mock",
cwd: scenario.repoPath,
workspaceId: scenario.workspaceId,
title: `Additional chat ${index + 1}`,
modeId: "load-test",
model: "ten-second-stream",
}),
);
}
await openAgent(page, scenario, scenario.firstAgentId);
await selectAgent(page, "Second viewed chat");
for (let index = 0; index < additional.length; index += 1) {
await selectAgent(page, `Additional chat ${index + 1}`);
}
await subscriptions.waitForSubscribedAgents([
scenario.firstAgentId,
scenario.secondAgentId,
...additional.map((agent) => agent.id),
]);
}
async function expectCurrentChatWithoutCatchUp(page: Page, message: string) {
await expect(page.getByText(message, { exact: true })).toBeVisible();
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0);
}
test.describe("Viewed agent timelines", () => {
test("open chats stay current after switching beyond five agents", async ({ page }) => {
await withViewedTimelineScenario(async (scenario) => {
await openSevenChats(page, scenario);
await commitMessage(scenario, scenario.firstAgentId, "Current after seven open chats.");
await selectAgent(page, "First viewed chat");
await expectCurrentChatWithoutCatchUp(page, "Current after seven open chats.");
});
});
test("a turn that finishes while hidden reopens with consistently idle chrome", async ({
page,
}) => {
@@ -119,14 +199,18 @@ test.describe("Viewed agent timelines", () => {
}
});
test("a hidden hot chat stays current", async ({ page }) => {
test("a hidden retained chat stays current without rendering", async ({ page }) => {
test.setTimeout(60_000);
const subscriptions = observeTimelineSubscriptions(page);
const scenario = await seedViewedTimelineScenario();
try {
await openAgent(page, scenario, scenario.firstAgentId);
await openAgent(page, scenario, scenario.firstAgentId, { recordRenders: true });
const composer = page.getByRole("textbox", { name: "Message agent..." });
await composer.fill("Unsent draft survives hidden streaming");
await selectAgent(page, "Second viewed chat");
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId, scenario.secondAgentId]);
const hiddenCommits = await countChatCommits(page, scenario.firstAgentId);
expect(hiddenCommits).toBeGreaterThan(0);
await commitMessage(
scenario,
scenario.firstAgentId,
@@ -135,11 +219,15 @@ test.describe("Viewed agent timelines", () => {
await expect(
page.getByText("Committed while the first chat is hidden.", { exact: true }),
).toHaveCount(0);
expect(await countChatCommits(page, scenario.firstAgentId)).toBe(hiddenCommits);
await selectAgent(page, "First viewed chat");
await expect(
page.getByText("Committed while the first chat is hidden.", { exact: true }),
).toBeVisible();
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
await expect(composer).toHaveValue("Unsent draft survives hidden streaming");
await composer.fill("Draft edited after returning");
await expect(composer).toHaveValue("Draft edited after returning");
} finally {
await scenario.cleanup();
}
@@ -170,28 +258,46 @@ test.describe("Viewed agent timelines", () => {
}
});
test("a visible chat catches up after reconnecting", async ({ page }) => {
test("a visible chat reports updating until reconnect catch-up completes", async ({
page,
}, testInfo) => {
const gate = await installDaemonWebSocketGate(page);
const scenario = await seedViewedTimelineScenario();
try {
await commitMessage(scenario, scenario.firstAgentId, "Visible before the connection drops.");
await openAgent(page, scenario, scenario.firstAgentId);
const previousMessage = page.getByText("Visible before the connection drops.", {
exact: true,
});
await expect(previousMessage).toBeVisible();
await expect(page.getByRole("button", { name: "First viewed chat" })).toHaveAttribute(
"aria-selected",
"true",
);
await gate.drop();
await gate.waitForBlockedConnection();
await expectReconnectingToastVisible(page);
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCSS("opacity", "1");
await page.screenshot({ path: testInfo.outputPath("reconnecting-chat.png") });
await expectForkFailureWithoutOverlappingStatus(page);
await commitMessage(scenario, scenario.firstAgentId, "Committed while the chat reconnects.");
await expect(
page.getByText("Committed while the chat reconnects.", { exact: true }),
).toHaveCount(0);
// Hold the first authoritative catch-up response so the assertion observes
// the reconnect boundary instead of racing a socket that has not reopened yet.
gate.holdNextServerMessage("fetch_agent_timeline_response");
// Hold the visible response to observe the connected-but-updating state.
gate.holdTimelineResponses(scenario.firstAgentId);
gate.restore();
await gate.waitForHeldServerMessage("fetch_agent_timeline_response");
gate.releaseHeldServerMessage("fetch_agent_timeline_response");
await gate.waitForHeldTimelineResponse();
await expectReconnectingToastGone(page);
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toBeVisible();
await expect(
page.getByTestId("app-toast-message").filter({ hasText: "Updating messages" }),
).toBeVisible();
await expect(previousMessage).toBeVisible();
await expect(page.getByTestId("agent-updating-toast")).toHaveCSS("opacity", "1");
await page.screenshot({ path: testInfo.outputPath("updating-chat.png") });
gate.releaseHeldTimelineResponses();
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0);
const recoveredMessage = page.getByText("Committed while the chat reconnects.", {
exact: true,
});
@@ -0,0 +1,14 @@
import { cancelAgent } from "../support/helpers/composer";
import {
test,
expectContinuousWordFade,
expectSettledSelectableText,
} from "../support/helpers/word-stream-fade";
test("released words fade left to right and settle into selectable text", async ({
streamingReply,
}, testInfo) => {
await expectContinuousWordFade(streamingReply, testInfo);
await cancelAgent(streamingReply);
await expectSettledSelectableText(streamingReply);
});
@@ -11,6 +11,7 @@ import {
expectWorkspaceTabVisible,
openWorkspaceWithAgents,
} from "../support/helpers/archive-tab";
import { clickNewChat } from "../support/helpers/launcher";
import { expectComposerVisible } from "../support/helpers/composer";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { seedWorkspace } from "../support/helpers/seed-client";
@@ -132,13 +133,14 @@ test.describe("Workspace navigation regression", () => {
await expect(page.getByText("Add a project", { exact: true })).toHaveCount(0);
});
test("keeps one replacement draft after returning from settings and closing the last tab", async ({
test("shows the New launcher after returning from settings and closing the last agent draft tab", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "workspace-settings-back-tab-" });
await workspace.navigateTo();
await clickNewChat(page);
await expect.poll(() => getVisibleDraftTabCount(page), { timeout: 30_000 }).toBe(1);
await openSettings(page);
@@ -148,7 +150,12 @@ test.describe("Workspace navigation regression", () => {
await closeFirstVisibleDraftTab(page);
await expect.poll(() => getVisibleDraftTabCount(page), { timeout: 30_000 }).toBe(1);
await expect.poll(() => getVisibleDraftTabCount(page), { timeout: 30_000 }).toBe(0);
await expect(
page
.getByTestId("workspace-new-tab-panel")
.getByRole("button", { name: "Agent", exact: true }),
).toBeVisible();
});
test("keeps the workspace rendered while reconnecting to the host", async ({ page }) => {
@@ -178,6 +185,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();
@@ -87,8 +87,7 @@ test.describe("Workspace setup streaming", () => {
try {
await seedProjectForWorkspaceSetup(client, repo.path);
// Wait for setup completion via daemon (setup snapshots are per-session,
// so the browser session won't receive progress events).
// The setup client owns progress before creating the workspace.
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
@@ -0,0 +1,63 @@
import { test } from "../support/helpers/codex-workspace-restore";
test.use({
e2eDaemonConfig: { version: 1, daemon: { mcp: { enabled: true } } },
trace: "on",
video: "on",
});
test.describe.configure({ timeout: 600_000 });
test("a fresh client restores an archived Codex agent across a reload without agent cache", async ({
codexRestore,
}) => {
await test.step("Create, finish, and archive Codex before this browser connects", async () => {
await codexRestore.createWorktreeWithCodex();
await codexRestore.archiveBeforeBrowserConnects();
});
await test.step("Connect the fresh browser, open History, and restore the workspace", async () => {
await codexRestore.connectFreshBrowser();
await codexRestore.openArchivedWorkspaceFromHistory();
await codexRestore.restoreWorkspace();
await codexRestore.expectArchivedAgentSelectedWithHistory();
});
await test.step("Reload without cached agent data and keep the selected archived agent", async () => {
await codexRestore.reloadWithoutAgentCache();
await codexRestore.waitForWorkspaceHydration();
await codexRestore.expectArchivedAgentSelectedWithHistory();
});
await test.step("Unarchive the agent and keep its idle composer after another cache-empty reload", async () => {
await codexRestore.unarchiveAgent();
await codexRestore.reloadWithoutAgentCache();
await codexRestore.expectIdleAgentWithVisibleComposer();
});
});
test("restore an archived worktree, then unarchive its completed Codex agent", async ({
codexRestore,
}) => {
await test.step("1. Create a managed worktree with a real Codex agent through Paseo MCP", async () => {
await codexRestore.createWorktreeWithCodex();
});
await test.step("2. Wait for Codex to finish and show its reply", async () => {
await codexRestore.waitForCompletedReply();
});
await test.step("3. Archive the workspace from its sidebar menu", async () => {
await codexRestore.archiveWorkspaceFromSidebar();
});
await test.step("4. Assert workspace and agent are archived and the worktree is removed", async () => {
await codexRestore.expectWorkspaceAndAgentArchived();
});
await test.step("5. Open the archived workspace from History", async () => {
await codexRestore.openArchivedWorkspaceFromHistory();
});
await test.step("6. Restore the workspace", async () => {
await codexRestore.restoreWorkspace();
await codexRestore.expectWorktreeRestoredWithArchivedAgentSelected();
});
await test.step("7. Click Unarchive on the agent", async () => {
await codexRestore.unarchiveAgent();
});
await test.step("8. Expect an idle agent with a visible editable composer", async () => {
await codexRestore.expectIdleAgentWithVisibleComposer(60_000);
});
});
@@ -205,7 +205,7 @@ test.describe("Worktree restore", () => {
"restore-success",
);
await worktreeClient.fetchWorkspaces({
subscribe: { subscriptionId: `restore-secondary-${randomUUID()}` },
subscribe: {},
});
let updateTimeout: ReturnType<typeof setTimeout> | null = null;
let unsubscribeSecondaryWorkspaceUpdate = () => {};
@@ -265,7 +265,7 @@ test.describe("Worktree restore", () => {
).toHaveText(switchedBranch, { timeout: 30_000 });
});
test("recovers the selected agent with its workspace and later rescues another archived agent", async ({
test("restores the workspace before explicitly unarchiving each selected agent", async ({
page,
}) => {
const { agents, worktree } = await createArchivedMissingWorktree("restore-agents", {
@@ -292,9 +292,16 @@ test.describe("Worktree restore", () => {
await expect(
page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect(
page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(),
).toHaveAttribute("aria-selected", "true");
expect(await fetchAgentArchivedAt(client, firstAgent.id)).not.toBeNull();
expect(await fetchAgentArchivedAt(client, secondAgent.id)).not.toBeNull();
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
await expect
.poll(() => fetchAgentArchivedAt(client, firstAgent.id), { timeout: 30_000 })
.toBeNull();
expect(await fetchAgentArchivedAt(client, secondAgent.id)).not.toBeNull();
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
timeout: 30_000,
});
@@ -0,0 +1,96 @@
// Run against a native dev app loaded from this checkout:
// node packages/app/e2e/mobile/clipboard-image.mjs <metro-port> <app-id>
// Exercises real native attachment persistence; Node's fetch(data:) hides this regression.
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { setTimeout as delay } from "node:timers/promises";
import { WebSocket } from "ws";
const [, , metroPort, appId] = process.argv;
assert(metroPort && appId, "Pass the Metro port and native app ID");
const targets = await fetch(`http://127.0.0.1:${Number(metroPort)}/json/list`).then((r) =>
r.json(),
);
const target = targets.find((candidate) => candidate.appId === appId);
assert(target, `Missing Hermes target for ${appId}`);
const png = await readFile(new URL("../../assets/images/icon.png", import.meta.url));
const base64 = png.toString("base64");
const socket = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((resolve, reject) => {
socket.once("open", resolve);
socket.once("error", reject);
});
let nextId = 0;
function evaluate(expression) {
return new Promise((resolve, reject) => {
const id = ++nextId;
const timeout = setTimeout(() => {
socket.off("message", onMessage);
reject(new Error("Hermes evaluation timed out"));
}, 10000);
function onMessage(message) {
const response = JSON.parse(message.toString());
if (response.id !== id) return;
clearTimeout(timeout);
socket.off("message", onMessage);
if (response.error || response.result.exceptionDetails) {
reject(new Error(JSON.stringify(response.error ?? response.result.exceptionDetails)));
} else {
resolve(response.result.result.value);
}
}
socket.on("message", onMessage);
socket.send(
JSON.stringify({
id,
method: "Runtime.evaluate",
params: { expression, returnByValue: true },
}),
);
});
}
async function waitForNativeClipboardImage() {
const deadline = Date.now() + 15000;
do {
const result = await evaluate("globalThis.__clipboardImageTest");
if (result) return result;
await delay(100);
} while (Date.now() < deadline);
assert.fail("Native image persistence timed out");
}
try {
// Metro's debugger module table locates the same public service used by the composer.
// Hermes does not await promises through Runtime.evaluate, so collect the async result.
await evaluate(`(() => {
const entry = Array.from(__r.getModules().entries()).find(
([, module]) => module.verboseName === "src/attachments/service.ts"
);
if (!entry) throw new Error("Open the composer before running this test");
const service = __r(entry[0]);
globalThis.__clipboardImageTest = null;
service.persistAttachmentFromDataUrl({
dataUrl: ${JSON.stringify(`data:image/png;base64,${base64}`)},
fileName: "clipboard.png"
}).then(attachment => {
return Promise.all([
service.encodeAttachmentsForSend([attachment]),
service.resolveAttachmentPreviewUrl(attachment)
]).then(([encoded, preview]) => ({ attachment, encoded, preview }))
.finally(() => service.deleteAttachments([attachment]));
}).then(result => { globalThis.__clipboardImageTest = result; })
.catch(error => { globalThis.__clipboardImageTest = { error: String(error) }; });
})()`);
const result = await waitForNativeClipboardImage();
assert.equal(result.error, undefined, result.error);
assert.equal(result.attachment.storageType, "native-file");
assert.equal(result.attachment.byteSize, png.length);
assert.equal(result.attachment.fileName, "clipboard.png");
assert.match(result.preview, /^file:\/\//);
assert.deepEqual(result.encoded, [{ data: base64, mimeType: "image/png" }]);
console.log(`PASS: native clipboard PNG persisted, previewed, and encoded (${png.length} bytes)`);
} finally {
await evaluate("delete globalThis.__clipboardImageTest").finally(() => socket.close());
}
@@ -275,6 +275,11 @@ node "${ASSERT}" xml-above-y \
"${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \
"Add attachment" \
"$(read_ime_top)"
display_density="$(adb shell wm density | awk '/density:/ { density = $NF } END { print density }')"
node "${ASSERT}" xml-composer-contained \
"${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \
"$(read_ime_top)" \
"${display_density}"
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "${MESSAGE}" --settle
open_gboard "${input_x}" "${input_y}"
@@ -337,4 +342,24 @@ node "${ASSERT}" same-region \
1400 \
430
ad press 'id="menu-button"' --settle
ad press 'id="sidebar-global-new-workspace"' --settle
ad wait 'id="workspace-create-submit"' 10000
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "${LONG_MESSAGE}" --settle
snapshot_json "${ARTIFACTS_DIR}/new-workspace-long-draft.json"
read -r new_input_x new_input_y new_input_height < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-long-draft.json" "editable"
)
# The editor shrinks when the keyboard opens. Its bottom stays anchored, while
# its old center can move into the setup fields after the height constraint.
new_input_y="$((new_input_y + new_input_height / 2 - 8))"
open_gboard "${new_input_x}" "${new_input_y}"
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml"
capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.png"
node "${ASSERT}" xml-composer-contained \
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \
"$(read_ime_top)" \
"${display_density}"
echo "Composer keyboard invariants passed"
@@ -4,11 +4,44 @@ import sharp from "sharp";
const [, , command, ...args] = process.argv;
if (command === "rect") {
if (command === "xml-composer-contained") {
const [snapshotPath, imeTopArgument, densityArgument] = args;
const snapshot = await fs.readFile(snapshotPath, "utf8");
const boundsFor = (id) => {
const node = snapshot.match(new RegExp(`<node[^>]*resource-id="${id}"[^>]*>`));
if (!node) throw new Error(`Missing node: ${id}`);
const bounds = node[0].match(/bounds="\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]"/);
if (!bounds) throw new Error(`Missing bounds: ${id}`);
return { top: Number(bounds[2]), bottom: Number(bounds[4]) };
};
const viewport = boundsFor("composer-viewport");
const content = boundsFor("composer-viewport-content");
const composer = boundsFor("message-input-root");
const controls = boundsFor("message-input-attach-button");
const clearance = (5 * Number(densityArgument)) / 160;
const minimumTop = viewport.top + clearance;
// Android rounds layout units to physical pixels; allow one pixel of rounding.
if (content.top < minimumTop - 1 || composer.top < minimumTop - 1) {
throw new Error(
`Composer overlaps header: content=${content.top}, editor=${composer.top}, minimum=${minimumTop}`,
);
}
if (composer.bottom > Number(imeTopArgument) || controls.bottom > Number(imeTopArgument)) {
throw new Error(
`Composer extends behind keyboard: editor=${composer.bottom}, controls=${controls.bottom}, keyboard=${imeTopArgument}`,
);
}
process.stdout.write(
`Composer contained: top=${composer.top}, header=${viewport.top}, bottom=${composer.bottom}, keyboard=${imeTopArgument}\n`,
);
} else if (command === "rect") {
const [snapshotPath, identifier] = args;
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
const node = snapshot.data.nodes.find(
(candidate) => candidate.identifier === identifier || candidate.label === identifier,
(candidate) =>
candidate.identifier === identifier ||
candidate.label === identifier ||
(identifier === "editable" && candidate.type.endsWith("EditText")),
);
if (!node) throw new Error(`Missing node: ${identifier}`);
const { x, y, width, height } = node.rect;
+47 -2
View File
@@ -1,4 +1,6 @@
# Native sheet gestures
# Native modal and sheet gestures
## Compact sheet gestures
Use an Android emulator at 1080×1920, density 420, with the checkout's native app bundle loaded.
Connect the app to an isolated daemon, enable plugins, and install `plugin-examples/modal-ui` there.
@@ -22,7 +24,7 @@ its parent without selecting a model or submitting a prompt. It does not assert
These scripts live outside the default mobile suite because they require the installed example and
an explicit connected host. See [mobile testing](../../../../../docs/mobile-testing.md) for device setup.
## Recorded verification
### Recorded verification
Android API 35, 1080×1920 at density 420, development binary 0.7.2 with this checkout's JavaScript.
Plugin tests used an isolated daemon. Recordings show the same body drag
@@ -45,3 +47,46 @@ Root typecheck, lint and format: passed
The model form's catalog stayed on “Loading…” in the debug app even though the isolated daemon's
provider API returned models. Its sheet dismissal/reopen was exercised; populated model selection
was not. iOS and Electron were not exercised.
## Tablet model selection
`model-tablet.android.ad` exercises the New workspace popover. `model-tablet-agent.android.ad`
exercises the modal used by an existing agent's narrow composer on a wide device. Both drill into
a populated provider, select a real model, assert the updated trigger, reopen, and dismiss with
Android Back. New workspace also pans beyond the short list viewport and flings back without
selecting a row or dismissing the popover. They do not submit a prompt.
Use an English-language Android tablet at 1600×2560, density 320 (800dp), with this checkout's
JavaScript and a connected isolated daemon. On a Pixel Tablet AVD, rotate to portrait explicitly;
its natural landscape orientation letterboxes this portrait-locked app into the compact layout:
```sh
adb -s "$ANDROID_SERIAL" shell settings put system accelerometer_rotation 0
adb -s "$ANDROID_SERIAL" shell settings put system user_rotation 1
adb -s "$ANDROID_SERIAL" shell wm density 320
```
Prepare an idle agent through the daemon's public `createAgent` API with a real provider/model and
no initial prompt. Set `MODEL_QA_AGENT_ID` and `MODEL_QA_AGENT_TITLE` to that agent. In New workspace,
remember a model first (the phone layout works on the broken baseline), then close the picker.
Choose a model visible on the provider's first page and set its catalog ID and displayed label.
Set `MODEL_QA_LAST_MODEL_ID` to its initially offscreen last model; use a short overflowing catalog
(six rows during this verification) for the coordinate-based pan/fling regression:
```sh
agent-device test packages/app/e2e/mobile/modal-sheet/model-tablet.android.ad \
packages/app/e2e/mobile/modal-sheet/model-tablet-agent.android.ad \
--serial "$ANDROID_SERIAL" --metro-port "$MODEL_QA_METRO_PORT" \
--env FORM_URL="paseo://new?serverId=${SHEET_QA_SERVER_ID}" \
--env AGENT_URL="paseo://h/${SHEET_QA_SERVER_ID}/agent/${MODEL_QA_AGENT_ID}" \
--env AGENT_TITLE="$MODEL_QA_AGENT_TITLE" \
--env PROVIDER_ID=codex --env MODEL_ID="$MODEL_QA_MODEL_ID" \
--env MODEL_LABEL="$MODEL_QA_MODEL_LABEL" --env LAST_MODEL_ID="$MODEL_QA_LAST_MODEL_ID" \
--artifacts-dir /tmp/paseo-model-tablet-qa
```
The existing-agent journey needs the sidebar pinned so its composer stays narrow. Start each run with no modal open;
a failed provider tap leaves its modal open, so dismiss it before the next run. These host-dependent
journeys remain outside the default mobile suite, alongside the compact sheet journeys above.
Keep generated screenshots, recordings, and run logs outside the repository.
@@ -0,0 +1,23 @@
# Idle existing agent in a narrow composer on a wide Android device (AdaptiveModalSheet).
# AGENT_URL=paseo://h/<serverId>/agent/<agentId>; AGENT_TITLE identifies the loaded route.
context platform=android timeout=60000 retries=0
env APP_ID=sh.paseo.debug
env PROVIDER_ID=codex
open "${APP_ID}" "${AGENT_URL}"
wait "label=\"${AGENT_TITLE}\"" 20000
wait "id=\"combined-model-selector\"" 10000
press "id=\"combined-model-selector\""
wait "id=\"model-provider-${PROVIDER_ID}\"" 10000
press "id=\"model-provider-${PROVIDER_ID}\""
wait "id=\"compact-model-list\"" 3000
press "id=\"model-row-${PROVIDER_ID}-${MODEL_ID}\""
wait "id=\"combined-model-selector\" label=\"Select model (${MODEL_LABEL})\"" 3000
press "id=\"combined-model-selector\""
wait "id=\"model-provider-${PROVIDER_ID}\"" 10000
press "id=\"model-provider-${PROVIDER_ID}\""
wait "id=\"model-row-${PROVIDER_ID}-${MODEL_ID}\"" 3000
back
wait "id=\"combined-model-selector\" label=\"Select model (${MODEL_LABEL})\"" 3000
close
@@ -0,0 +1,30 @@
# Connected host with a selected model, 1600x2560 at density 320. See README.md.
# FORM_URL=paseo://new?serverId=<serverId>; MODEL_ID and MODEL_LABEL name a real catalog model.
context platform=android timeout=60000 retries=0
env APP_ID=sh.paseo.debug
env PROVIDER_ID=codex
open "${APP_ID}" "${FORM_URL}"
wait "label=\"Workspace project\"" 20000
press "id=\"combined-model-selector\""
wait "id=\"combobox-desktop-container\"" 10000
# A remembered model opens its provider's list. Return to the provider page first.
press "label=\"Back\""
wait "id=\"model-provider-${PROVIDER_ID}\"" 10000
press "id=\"model-provider-${PROVIDER_ID}\""
wait "id=\"compact-model-list\"" 3000
press "id=\"model-row-${PROVIDER_ID}-${MODEL_ID}\""
wait "id=\"combined-model-selector\" label=\"Select model (${MODEL_LABEL})\"" 3000
# A drag that leaves the short viewport must scroll without selecting or dismissing.
press "id=\"combined-model-selector\""
wait "id=\"model-row-${PROVIDER_ID}-${MODEL_ID}\"" 3000
gesture pan 1000 1850 0 -700 600
wait "id=\"compact-model-list\"" 3000
wait "id=\"model-row-${PROVIDER_ID}-${LAST_MODEL_ID}\"" 3000
gesture fling down 1000 1750 500
wait "id=\"model-row-${PROVIDER_ID}-${MODEL_ID}\"" 3000
# Android Back dismisses the whole popover without changing the remembered selection.
back
wait "id=\"combined-model-selector\" label=\"Select model (${MODEL_LABEL})\"" 3000
close
@@ -0,0 +1,95 @@
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";
import { test as base } from "./fixtures";
import {
createCreationScenario,
createPromptRetryScenario,
retryNextAgentCreation,
} from "./helpers/creation";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
export const test = base.extend<{
startup: { release(): Promise<void>; fail(): Promise<void> };
creation: Awaited<ReturnType<typeof createCreationScenario>>;
promptRetry: ReturnType<typeof createPromptRetryScenario>;
agentRetries: Awaited<ReturnType<typeof retryNextAgentCreation>>;
delayedCreation: Awaited<ReturnType<typeof delayBrowserAgentCreatedStatus>>;
}>({
startup: async ({ page }, provide) => {
void page;
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"),
`
import { readFile, rm } from "node:fs/promises";
export default function contribute(server) {
return server.before("agent.session_open", async ({ request }) => {
if (request.reason !== "create") return request;
const gate = ${JSON.stringify(gate)};
let command = await readFile(gate, "utf8").catch(() => "release");
while (command === "hold") {
await new Promise(resolve => setTimeout(resolve, 20));
command = await readFile(gate, "utf8").catch(() => "release");
}
if (command === "fail") {
await rm(gate, { force: true });
throw new Error("Creation startup failed for test");
}
return request;
});
}
`,
);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(plugin.directory);
await provide({
release: () => rm(gate, { force: true }),
// Replacing the command must not expose a truncated, empty file to the hook.
fail: () => rename(failure, gate),
});
} finally {
await rm(gate, { force: true });
await client.removePlugin("lifecycle-logger");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await plugin.cleanup();
await client.close();
}
},
creation: async ({ page }, provide) => {
const creation = await createCreationScenario(page);
try {
await provide(creation);
} finally {
await creation.cleanup();
}
},
promptRetry: async ({ page }, provide) => {
const gate = await installDaemonWebSocketGate(page);
try {
await provide(createPromptRetryScenario(page, gate));
} finally {
gate.restore();
}
},
agentRetries: async ({ page }, provide) => {
await provide(await retryNextAgentCreation(page));
},
delayedCreation: async ({ page }, provide) => {
const delayed = await delayBrowserAgentCreatedStatus(page);
try {
await provide(delayed);
} finally {
delayed.release();
}
},
});
@@ -0,0 +1,18 @@
const readline = require("node:readline");
const model = { modelId: "gemini-3.5-flash", name: "Gemini 3.5 Flash" };
readline.createInterface({ input: process.stdin }).on("line", (line) => {
const request = JSON.parse(line);
if (request.id === undefined) return;
let result = {};
if (request.method === "initialize")
result = { protocolVersion: 1, agentCapabilities: {}, authMethods: [] };
if (request.method === "session/new")
result = {
sessionId: "catalog-diagnostic",
models: {
currentModelId: model.modelId,
availableModels: Array.from({ length: Number(process.argv[2]) }, () => model),
},
};
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }) + "\n");
});
+4 -27
View File
@@ -1,3 +1,5 @@
import { warmMetro } from "./metro-warmup.mjs";
export { warmMetro } from "./metro-warmup.mjs";
import { spawn, type ChildProcess } from "node:child_process";
import { killProcessTree } from "./helpers/spawn-node";
import { existsSync } from "node:fs";
@@ -16,7 +18,8 @@ export interface WaitForServerOptions {
type ServerProbe = (host: string, port: number) => Promise<void>;
const RESERVED_LOCAL_PORTS = new Set([
6767, // Developer daemon.
6767, // Installed daemon.
6768, // Developer daemon.
61680, // OpenCode's default local server.
]);
@@ -122,32 +125,6 @@ export async function waitForMetro(port: number, options: WaitForServerOptions):
await waitForServer(port, options, probeMetro);
}
export async function warmMetro(port: number): Promise<void> {
const origin = `http://127.0.0.1:${port}`;
const documentResponse = await fetch(origin, { signal: AbortSignal.timeout(120_000) });
if (!documentResponse.ok) {
throw new Error(`Metro document warmup failed with HTTP ${documentResponse.status}`);
}
const document = await documentResponse.text();
const scriptSources = [...document.matchAll(/<script[^>]+src=["']([^"']+)["']/g)].map(
(match) => match[1],
);
if (scriptSources.length === 0) {
throw new Error("Metro document warmup found no scripts to compile");
}
for (const source of scriptSources) {
const scriptUrl = new URL(source, origin);
if (scriptUrl.origin !== origin) continue;
const response = await fetch(scriptUrl, { signal: AbortSignal.timeout(120_000) });
if (!response.ok) {
throw new Error(
`Metro bundle warmup failed for ${scriptUrl.pathname}: HTTP ${response.status}`,
);
}
await response.arrayBuffer();
}
}
function startMetro(port: number, buffer: ReturnType<typeof createLineBuffer>): ChildProcess {
const appDir = path.resolve(__dirname, "../..");
const expoCli = require.resolve("expo/bin/cli");
@@ -1,4 +1,5 @@
import type { Page } from "@playwright/test";
import { loadSessionMessageReaders } from "./new-workspace";
import { daemonWsRoutePattern, wsRoutePatternForPort } from "./daemon-port";
type WebSocketMessage = string | Buffer;
@@ -38,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,
@@ -292,11 +217,14 @@ export async function holdRewindCompletion(
}
if (
!released &&
sessionMessage?.type === "agent.rewind.response" &&
(sessionMessage?.type === "agent.rewind.response" ||
sessionMessage?.type === "agent.timeline.replacement") &&
payload?.agentId === agentId
) {
// The replacement also signals completion and removes the rewound row/menu.
// Hold it with the response so the pending-state assertion observes a pending rewind.
delayedForwards.push(() => ws.send(message));
resolveDelayedResponse?.();
if (sessionMessage.type === "agent.rewind.response") resolveDelayedResponse?.();
return;
}
ws.send(message);
@@ -321,6 +249,7 @@ export async function holdRewindCompletion(
export async function delayCreatedAgentInitialTailResponse(
page: Page,
): Promise<CreatedAgentTimelineGate> {
const frames = await loadSessionMessageReaders();
let createdAgentId: string | null = null;
let releaseRequested = false;
let delayedResponseSeen = false;
@@ -350,19 +279,24 @@ export async function delayCreatedAgentInitialTailResponse(
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
if (sessionMessage?.type === "status" && payload?.status === "agent_created") {
const agentId = payload.agentId;
if (typeof agentId === "string") {
createdAgentId = agentId;
resolveCreatedAgent?.(agentId);
}
const sessionMessage = frames.server(message);
let createdId: unknown;
if (sessionMessage?.type === "agent.create.response") {
createdId = sessionMessage.payload.agent?.id;
} else if (
sessionMessage?.type === "status" &&
sessionMessage.payload.status === "agent_created"
) {
createdId = sessionMessage.payload.agentId;
}
if (typeof createdId === "string") {
createdAgentId = createdId;
resolveCreatedAgent?.(createdId);
}
if (sessionMessage?.type === "fetch_agent_timeline_response") {
const agentId = payload?.agentId;
const direction = payload?.direction;
const agentId = sessionMessage.payload.agentId;
const direction = sessionMessage.payload.direction;
if (
!delayedResponseSeen &&
typeof agentId === "string" &&
@@ -599,3 +533,71 @@ async function delayAgentTimelineResponse(
waitForDelayedResponse: () => delayedResponse,
};
}
/** Holds real incoming frames so intermediate rendering assertions do not race the producer. */
export async function holdAssistantStream(page: Page, agentId: string) {
const pending: Array<{ forward(): void; text: string }> = [];
let holding = false;
let released = false;
let forwardedText = "";
let notifyFrame: (() => void) | undefined;
let notifyInitialTimeline!: () => void;
const initialTimeline = new Promise<void>((resolve) => {
notifyInitialTimeline = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
const session = getSessionMessage(message);
const payload = session ? getPayload(session) : null;
if (session?.type === "fetch_agent_timeline_response" && payload?.agentId === agentId) {
notifyInitialTimeline();
}
const event = payload?.event as
| { type?: string; item?: { type?: string; text?: string } }
| undefined;
const text =
session?.type === "agent_stream" &&
payload?.agentId === agentId &&
event?.type === "timeline" &&
event.item?.type === "assistant_message"
? (event.item.text ?? "")
: "";
if (text) holding = true;
if (holding && !released) {
pending.push({ forward: () => ws.send(message), text });
notifyFrame?.();
} else {
ws.send(message);
}
});
});
return {
waitForInitialTimeline: () => initialTimeline,
async showThrough(prefix: string): Promise<void> {
while (forwardedText.length < prefix.length) {
const frame = pending.shift();
if (!frame) {
await new Promise<void>((resolve) => {
notifyFrame = resolve;
});
continue;
}
forwardedText += frame.text;
frame.forward();
}
if (forwardedText !== prefix) {
throw new Error(
`Stream did not stop at ${JSON.stringify(prefix)}: ${JSON.stringify(forwardedText)}`,
);
}
},
release() {
released = true;
for (const frame of pending.splice(0)) frame.forward();
},
};
}
@@ -186,9 +186,15 @@ export async function userPagesUntilAssistantImageRenders(
}
const previous = await rememberTimelineViewport(page);
await userScrollsTimelineToHistoryStart(page);
// Scrolling can bring an already loaded image into the virtualized window
// without increasing content height. Its rendering also satisfies this step.
await expect
.poll(async () => (await rememberTimelineViewport(page)).scrollHeight)
.toBeGreaterThan(previous.scrollHeight);
.poll(
async () =>
(await rendered.count()) > 0 ||
(await rememberTimelineViewport(page)).scrollHeight > previous.scrollHeight,
)
.toBe(true);
}
await expectAssistantImageRendered(page, image);
}
@@ -2,6 +2,41 @@ import { expect, type Locator, type Page } from "@playwright/test";
import { openSettings } from "./app";
import { openSettingsSection } from "./settings";
import { runWorkspaceActionFromCommandCenter } from "./command-center-workspace-actions";
import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
export async function withStreamingMarkdownOutline(
run: (agent: MockAgentWorkspace) => Promise<void>,
): Promise<void> {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-outline-markdown-",
title: "Streaming Markdown outline",
featureValues: {
mockStreamingAssistantResponse: Array.from(
{ length: 60 },
(_, index) => `Paragraph ${index + 1}.`,
).join("\n\n"),
mockStreamingAssistantIntervalMs: 80,
},
});
try {
await run(agent);
} finally {
await agent.cleanup();
}
}
export async function expectReadingStreamedMarkdown(page: Page, prompt: string): Promise<void> {
await expect(page.getByText("Paragraph 30.", { exact: true }).last()).toBeVisible();
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const promptRow = timeline.getByTestId("user-message").filter({ hasText: prompt });
await expect
.poll(async () => {
const [viewport, row] = await Promise.all([timeline.boundingBox(), promptRow.boundingBox()]);
return viewport && row ? row.y + row.height - viewport.y : Number.POSITIVE_INFINITY;
})
.toBeLessThan(0);
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible();
}
export function chatOutlineRail(page: Page): Locator {
return page.getByTestId("chat-outline-rail");
@@ -0,0 +1,277 @@
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { z } from "zod";
import type { TestInfo } from "@playwright/test";
import { test as base, expect, type Page } from "../fixtures";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { openSessions } from "./archive-tab";
import { getE2EDaemonPort } from "./daemon-port";
import { assertComposerIdle } from "./rewind-flow";
import { getServerId } from "./server-id";
import { archiveWorkspaceFromSidebar, expectWorkspaceAbsentFromSidebar } from "./sidebar";
import { createTempGitRepo } from "./workspace";
import { waitForSidebarHydration } from "./workspace-ui";
import type { SeedDaemonClient } from "./seed-client";
const REPLY = "CODEX_WORKTREE_RESTORE_READY";
const workspaceSchema = z.object({
workspaceId: z.string(),
projectId: z.string(),
cwd: z.string(),
isolation: z.literal("worktree"),
});
async function callPaseoTool(
mcp: Client,
name: string,
args: Record<string, unknown>,
): Promise<unknown> {
const result = await mcp.callTool({ name, arguments: args }, undefined, { timeout: 240_000 });
expect(result).not.toMatchObject({ isError: true });
return result.structuredContent;
}
async function defaultCodexModel(mcp: Client): Promise<string> {
const result = z
.object({ models: z.array(z.object({ id: z.string(), isDefault: z.boolean() })) })
.parse(await callPaseoTool(mcp, "list_models", { provider: "codex" }));
const model = result.models.find((entry) => entry.isDefault);
if (!model) throw new Error("Codex did not advertise a default model");
return model.id;
}
function recordTimelineWire(page: Page): string[] {
const wire: string[] = [];
page.on("websocket", (socket) => {
const recordFrame = (direction: string, message: string) => {
if (/agent.*timeline|fetch_agents|agent_update/.test(message)) {
wire.push(JSON.stringify({ at: Date.now(), direction, message }));
}
};
socket.on("framesent", ({ payload }) => recordFrame("sent", payload.toString()));
socket.on("framereceived", ({ payload }) => recordFrame("received", payload.toString()));
});
return wire;
}
async function createCodexRestoreJourney(page: Page, client: SeedDaemonClient, info: TestInfo) {
const repo = await createTempGitRepo("codex-worktree-restore-");
const mcp = new Client({ name: "worktree-codex-restore-playwright", version: "1.0.0" });
const wire = recordTimelineWire(page);
let workspace: z.infer<typeof workspaceSchema> | undefined;
let agentId: string | undefined;
function created() {
if (!workspace || !agentId) throw new Error("Create the Codex worktree before using it");
return { ...workspace, agentId };
}
async function expectSelected() {
await expect(page.getByTestId(`workspace-tab-agent_${created().agentId}`)).toHaveAttribute(
"aria-selected",
"true",
);
}
return {
async createWorktreeWithCodex() {
await mcp.connect(
new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${getE2EDaemonPort()}/mcp/agents`),
),
);
workspace = workspaceSchema.parse(
await callPaseoTool(mcp, "create_workspace", {
isolation: "worktree",
path: repo.path,
baseBranch: "main",
title: "Codex worktree restore reproduction",
}),
);
const agent = z.object({ agentId: z.string() }).parse(
await callPaseoTool(mcp, "create_agent", {
workspaceId: workspace.workspaceId,
provider: `codex/${await defaultCodexModel(mcp)}`,
settings: { modeId: "full-access", thinkingOptionId: "low" },
title: "Codex worktree restore reproduction",
initialPrompt: `Reply with exactly ${REPLY} and nothing else. Do not use tools.`,
background: true,
}),
);
agentId = agent.agentId;
},
async waitForCompletedReply() {
const target = created();
await page.goto(buildHostAgentDetailRoute(getServerId(), target.agentId, target.workspaceId));
await waitForSidebarHydration(page);
await expect(page.getByTestId("assistant-message")).toContainText(REPLY, {
timeout: 240_000,
});
await expect
.poll(() => client.fetchAgent({ agentId: target.agentId }))
.toMatchObject({
agent: { status: "idle", archivedAt: null },
});
await assertComposerIdle({ page });
},
async archiveBeforeBrowserConnects() {
const target = created();
await client.waitForFinish(target.agentId, 240_000);
expect((await client.archiveWorkspace(target.workspaceId)).error).toBeNull();
await expect.poll(() => existsSync(target.cwd)).toBe(false);
await expect
.poll(() => client.fetchAgent({ agentId: target.agentId }))
.toMatchObject({
agent: { archivedAt: expect.any(String) },
});
},
async connectFreshBrowser() {
await page.goto("/");
},
async archiveWorkspaceFromSidebar() {
await archiveWorkspaceFromSidebar(page, created().workspaceId);
},
async expectWorkspaceAndAgentArchived() {
const target = created();
await expectWorkspaceAbsentFromSidebar(page, target.workspaceId);
await expect
.poll(() => client.fetchAgent({ agentId: target.agentId }))
.toMatchObject({
agent: { archivedAt: expect.any(String) },
});
await expect.poll(() => existsSync(target.cwd), { timeout: 30_000 }).toBe(false);
await expect
.poll(() => client.fetchWorkspaces())
.not.toMatchObject({
entries: expect.arrayContaining([expect.objectContaining({ id: target.workspaceId })]),
});
},
async openArchivedWorkspaceFromHistory() {
await openSessions(page);
const row = page.getByTestId(`agent-row-${getServerId()}-${created().agentId}`);
await expect(row).toContainText("Archived");
await row.click();
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible();
},
async restoreWorkspace() {
await page.getByRole("button", { name: "Restore", exact: true }).click();
await expect(page.getByText("Workspace archived", { exact: true })).toHaveCount(0, {
timeout: 60_000,
});
},
async expectWorktreeRestoredWithArchivedAgentSelected() {
await expect.poll(() => existsSync(created().cwd), { timeout: 30_000 }).toBe(true);
await expectSelected();
await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible();
await info.attach("after-workspace-restore", {
body: JSON.stringify(await client.fetchAgent({ agentId: created().agentId }), null, 2),
contentType: "application/json",
});
},
async expectArchivedAgentSelectedWithHistory() {
await expectSelected();
await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible();
await expect(page.getByTestId("assistant-message")).toContainText(REPLY);
},
async reloadWithoutAgentCache() {
const url = page.url();
const cdp = await page.context().newCDPSession(page);
// Unmount first so an open connection cannot repopulate the cache while it is cleared.
await page.goto("about:blank");
await cdp.send("Storage.clearDataForOrigin", {
origin: new URL(url).origin,
storageTypes: "indexeddb,cache_storage",
});
await cdp.send("Network.clearBrowserCache");
await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
await page.goto(url);
await cdp.detach();
},
async waitForWorkspaceHydration() {
await waitForSidebarHydration(page);
},
async unarchiveAgent() {
await page.getByRole("button", { name: "Unarchive", exact: true }).click({ timeout: 30_000 });
await expect(page.getByText("This agent is archived", { exact: true })).toHaveCount(0, {
timeout: 60_000,
});
},
async expectIdleAgentWithVisibleComposer(statusTimeout?: number) {
await expect
.poll(() => client.fetchAgent({ agentId: created().agentId }), { timeout: statusTimeout })
.toMatchObject({ agent: { status: "idle", archivedAt: null } });
await expectSelected();
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
await assertComposerIdle({ page });
await info.attach("restored-agent-screen", {
body: await page.screenshot(),
contentType: "image/png",
});
await info.attach("restored-agent-accessibility", {
body: await page.locator("body").ariaSnapshot(),
contentType: "text/plain",
});
},
async dispose() {
const testFailed = info.status !== info.expectedStatus;
const errors: unknown[] = [];
try {
await info.attach("final-screen", {
body: await page.screenshot(),
contentType: "image/png",
});
await info.attach("timeline-wire", { body: wire.join("\n"), contentType: "text/plain" });
await info.attach("final-agent", {
body: JSON.stringify(agentId ? await client.fetchAgent({ agentId }) : null, null, 2),
contentType: "application/json",
});
await info.attach("daemon-log", {
body: await readFile(path.join(process.env.E2E_PASEO_HOME!, "daemon.log")),
contentType: "text/plain",
});
} catch (error) {
errors.push(error);
}
const cleanup: Array<() => Promise<unknown>> = [() => mcp.close()];
if (!testFailed || process.env.E2E_KEEP_PASEO_HOME !== "1") {
const projectId = workspace?.projectId;
if (projectId) cleanup.push(() => client.removeProject(projectId));
cleanup.push(() => repo.cleanup());
}
for (const release of cleanup) {
try {
await release();
} catch (error) {
errors.push(error);
}
}
if (errors.length > 0) {
const error = new AggregateError(errors, "Codex restore fixture teardown failed");
if (!testFailed) throw error;
// Keep the journey failure primary while reporting teardown failures in the test log.
console.error(error);
}
},
};
}
export const test = base.extend<{
codexRestore: Awaited<ReturnType<typeof createCodexRestoreJourney>>;
}>({
// This fixture owns cleanup so a failed reproduction can retain its daemon state.
projectOwnership: async ({ e2eWorkerClient }, provide) => {
void e2eWorkerClient;
await provide();
},
codexRestore: async ({ page, e2eWorkerClient }, provide, info) => {
const journey = await createCodexRestoreJourney(page, e2eWorkerClient, info);
try {
await provide(journey);
} finally {
await journey.dispose();
}
},
});
@@ -28,7 +28,7 @@ export async function openWorkspaceFromCommandCenter(
): Promise<void> {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(title);
await page.keyboard.press("Enter");
await action(panel, title).click();
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId), {
timeout: 30_000,
});
+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,
@@ -0,0 +1,278 @@
import type { WorkspaceCreateRequest } from "@getpaseo/protocol/messages";
import { expect, type Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
import { gotoAppShell } from "./app";
import { gotoWorkspace } from "./launcher";
import { fillComposerDraft } from "./composer";
import { createAgentTabFromMenu } from "./workspace-tabs";
import {
openNewWorkspaceComposer,
selectWorkspaceIsolation,
loadSessionMessageReaders,
} from "./new-workspace";
import { seedWorkspace } from "./seed-client";
import {
waitForSidebarHydration,
switchWorkspaceViaSidebar,
workspaceDeckEntryLocator,
} from "./workspace-ui";
import { getServerId } from "./server-id";
import { WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES } from "@/screens/workspace/workspace-deck-retention";
import type { installDaemonWebSocketGate } from "./daemon-websocket-gate";
/** Capture the submitted agent options; optionally stop provisioning for wire-only assertions. */
export async function captureWorkspaceAgentRequest(page: Page, options: { block: boolean }) {
const frames = await loadSessionMessageReaders();
type AgentIntent = NonNullable<WorkspaceCreateRequest["agent"]>;
let resolveRequest!: (agent: AgentIntent) => void;
const request = new Promise<AgentIntent>((resolve) => {
resolveRequest = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((frame) => {
const message = frames.client(frame);
if (message?.type === "workspace.create.request" && message.agent) {
resolveRequest(message.agent);
if (options.block) return;
}
server.send(frame);
});
server.onMessage((frame) => ws.send(frame));
});
return { waitForRequest: () => request };
}
export async function pressSubmitBeforeTheNextRender(page: Page, name: string): Promise<void> {
const create = page.getByRole("button", { name, exact: true });
await expect(create).toBeEnabled();
// Dispatch the queued clicks in one JS task, before pending state can paint.
// Exercise DOM events rather than calling the app's submit handler directly.
await create.evaluate((button) => {
for (let click = 0; click < 3; click++) {
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
}
});
}
export async function observeCreationRequests(page: Page) {
const frames = await loadSessionMessageReaders();
const pending = new Set<string>();
page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => {
const request = frames.client(payload);
if (
request?.type === "workspace.create.request" ||
request?.type === "agent.create.request"
) {
pending.add(request.requestId);
}
});
socket.on("framereceived", ({ payload }) => {
const response = frames.server(payload);
if (
response?.type === "workspace.create.response" ||
response?.type === "agent.create.response" ||
(response?.type === "status" &&
(response.payload.status === "agent_created" ||
response.payload.status === "agent_create_failed"))
) {
const requestId = response.payload.requestId;
if (typeof requestId === "string") pending.delete(requestId);
}
});
});
return {
async settled() {
await expect.poll(() => pending.size).toBe(0);
},
};
}
export async function retryNextAgentCreation(page: Page) {
const frames = await loadSessionMessageReaders();
const retryIds = new Set<string>();
const results: Array<{ status: string; agentId?: string }> = [];
let repeated = false;
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((frame) => {
const request = frames.client(frame);
if (!repeated && request?.type === "agent.create.request") {
repeated = true;
for (let attempt = 1; attempt <= 3; attempt++) {
const requestId = attempt === 1 ? request.requestId : `${request.requestId}-${attempt}`;
retryIds.add(requestId);
// Keep the app's operation key and payload; only RPC correlation changes.
server.send(JSON.stringify({ type: "session", message: { ...request, requestId } }));
}
return;
}
server.send(frame);
});
server.onMessage((frame) => {
const response = frames.server(frame);
if (
response?.type === "agent.create.response" &&
retryIds.delete(response.payload.requestId)
) {
results.push({
status: response.payload.error ? "agent_create_failed" : "agent_created",
agentId: response.payload.agent?.id,
});
}
browser.send(frame);
});
});
return {
async completedAgentIds() {
await expect.poll(() => results.length).toBe(3);
expect(results.map((result) => result.status)).toEqual([
"agent_created",
"agent_created",
"agent_created",
]);
return results.map((result) => result.agentId);
},
};
}
export async function createCreationScenario(page: Page) {
const requests = await observeCreationRequests(page);
const project = await seedWorkspace({ repoPrefix: "creation-idempotency-" });
let workspaceId = project.workspaceId;
return {
cleanup: project.cleanup,
async openWorkspaceForm(isolation: "local" | "worktree") {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, project);
await selectWorkspaceIsolation(page, isolation);
},
async openAgentDraft() {
await gotoWorkspace(page, project.workspaceId);
await createAgentTabFromMenu(page);
},
async startAnotherDraft() {
await createAgentTabFromMenu(page);
},
async submitPrompt(prompt: string, button = "Send message") {
await fillComposerDraft(page, prompt);
await page.getByRole("button", { name: button, exact: true }).click();
},
async submitRepeatedly(button: string, prompt?: string) {
if (prompt) await fillComposerDraft(page, prompt);
await pressSubmitBeforeTheNextRender(page, button);
},
async expectPromptVisible(prompt?: string) {
// Inactive agent tabs retain their timeline DOM beside the visible draft.
const rows = page.getByTestId("user-message").filter({ visible: true });
await expect(prompt ? rows.filter({ hasText: prompt }) : rows.first()).toBeVisible();
},
async expectWorkspaceReadyBeforeAgentCompletion() {
await expect(page).toHaveURL(/\/workspace\//);
await expect(page.getByTestId("user-message").first()).toBeVisible();
await expect(
page
.locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]')
.filter({ visible: true }),
).toBeVisible();
},
async expectAgentStillStarting() {
await this.expectWorkspaceReadyBeforeAgentCompletion();
expect((await project.client.fetchAgents()).entries).toHaveLength(0);
},
async expectStartupFailure() {
await expect(page.getByText(/Creation startup failed for test/).first()).toBeVisible();
},
async expectOneCreatedWorkspace() {
await expect(page).toHaveURL(/\/workspace\//);
await requests.settled();
const workspaces = (await project.client.fetchWorkspaces()).entries.filter(
(workspace) =>
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
);
expect(workspaces).toHaveLength(1);
workspaceId = workspaces[0]!.id;
},
async expectAgentCount(count: number) {
await requests.settled();
await expect
.poll(
async () =>
(await project.client.fetchAgents()).entries.filter(
({ agent }) => agent.workspaceId === workspaceId,
).length,
)
.toBe(count);
},
async expectAgentTitle(title: string) {
await expect
.poll(
async () =>
(await project.client.fetchAgents()).entries.find(
({ agent }) => agent.workspaceId === workspaceId,
)?.agent.title,
)
.toBe(title);
},
async evictAndReturnToDraft() {
const draft = page
.locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]')
.filter({ visible: true })
.first();
const draftTestId = await draft.getAttribute("data-testid");
expect(draftTestId).not.toBeNull();
for (let index = 0; index < WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES; index++) {
const result = await project.client.createWorkspace({
source: { kind: "directory", path: project.repoPath },
});
if (!result.workspace) throw new Error(result.error ?? "Failed to seed eviction workspace");
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: result.workspace.id,
});
}
await expect(workspaceDeckEntryLocator(page, getServerId(), workspaceId)).toHaveCount(0);
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId });
await page
.getByTestId(draftTestId!)
.filter({ visible: true })
.click({ position: { x: 12, y: 13 } });
},
};
}
export function createPromptRetryScenario(
page: Page,
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
) {
let firstMessage: ReturnType<typeof gate.getClientRequests>[number] | undefined;
return {
holdAcknowledgement() {
gate.holdNextServerMessage("agent.create.response");
},
async waitForDeliveredPrompt() {
await gate.waitForHeldServerMessage("agent.create.response");
firstMessage = gate.getClientRequests("agent.create.request").at(-1);
},
async disconnectAndReconnect() {
const fetches = gate.getClientRequestCount("fetch_agents_request");
await gate.drop();
gate.restore();
await expect
.poll(() => gate.getClientRequestCount("fetch_agents_request"))
.toBeGreaterThan(fetches);
},
async expectSameAgentAndMessage() {
expect(gate.getClientRequestCount("agent.create.request")).toBe(1);
await expect
.poll(() => gate.getClientRequestCount("creation.subscribe.request"))
.toBeGreaterThan(0);
expect(gate.getClientRequests("agent.create.request").at(-1)).toEqual(firstMessage);
expect(gate.getClientRequestCount("send_agent_message_request")).toBe(0);
await expect(page.getByTestId("user-message").filter({ visible: true })).toHaveCount(1);
},
};
}
@@ -64,3 +64,10 @@ function loadAppVersion(): string {
}
return packageJson.version;
}
export async function loadProtocolSchemas(): Promise<typeof import("@getpaseo/protocol/messages")> {
const moduleUrl = pathToFileURL(
path.resolve(__dirname, "../../../../../packages/protocol/dist/messages.js"),
).href;
return import(moduleUrl);
}
@@ -0,0 +1,42 @@
import { expect, type Page } from "@playwright/test";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectDaemonClient } from "./daemon-client-loader";
import { openSettings } from "./app";
import { openHostSection, openSettingsHost, seedSavedSettingsHosts } from "./settings";
import type { IsolatedHostDaemon } from "./isolated-host-daemon";
export async function openDaemonOverview(page: Page, daemon: IsolatedHostDaemon) {
await seedSavedSettingsHosts(page, [
{ serverId: daemon.serverId, label: "Lifecycle host", endpoint: `127.0.0.1:${daemon.port}` },
]);
await page.reload();
await openSettings(page);
await openSettingsHost(page, daemon.serverId);
await openHostSection(page, daemon.serverId, "host");
}
export async function restartDaemonInSettings(page: Page) {
const button = page.getByRole("button", { name: "Restart", exact: true });
await expect(button).toBeEnabled();
page.once("dialog", (dialog) => dialog.accept());
await button.click();
await expect(page.getByRole("button", { name: "Restarting...", exact: true })).toBeDisabled();
}
export async function expectDaemonRestartComplete(page: Page) {
await expect(page.getByRole("button", { name: "Restart", exact: true })).toBeEnabled({
timeout: 30_000,
});
}
export async function readWorkerPid(daemon: IsolatedHostDaemon): Promise<number> {
const client = await connectDaemonClient<DaemonClient>({
port: daemon.port,
clientIdPrefix: "lifecycle-observer",
});
try {
return (await client.getDaemonStatus()).pid;
} finally {
await client.close();
}
}
@@ -21,6 +21,7 @@ interface ClientRequest {
mode?: unknown;
path?: unknown;
agentId?: unknown;
messageId?: unknown;
text?: unknown;
activeTurnBehavior?: unknown;
}
@@ -848,7 +849,12 @@ export async function installDaemonWebSocketGate(page: Page) {
getClientRequests(type: string): ReadonlyArray<ClientRequest> {
return [...(clientRequests.get(type) ?? [])];
},
getTimelineRequestCount(direction: "tail" | "before" | "after"): number {
getTimelineRequestCount(direction: "tail" | "before" | "after", agentId?: string): number {
if (agentId) {
return (clientRequests.get("fetch_agent_timeline_request") ?? []).filter(
(request) => request.agentId === agentId && request.direction === direction,
).length;
}
return timelineRequestCounts.get(direction) ?? 0;
},
getAgentStreamItemCount(type: string): number {
@@ -23,8 +23,7 @@ export class ImportSessionFlow {
await expectMobileAgentSidebarVisible(this.page);
const button = this.page.getByTestId("sidebar-import-session");
await expect(button).toHaveAccessibleName("Import session");
await button.hover();
await expect(this.page.getByText("Import session", { exact: true })).toBeVisible();
await expect(button).toBeInViewport();
}
async openGlobally() {
await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible();
@@ -49,6 +48,9 @@ export class ImportSessionFlow {
before?: [string, string];
folders?: Array<[string, string]>;
}) {
for (const id of input.first ?? input.before ?? []) {
await expect(this.page.getByTestId(rowTestId(id))).toHaveCount(1);
}
const ids = await this.rowIds();
if (input.first) expect(ids.slice(0, input.first.length)).toEqual(input.first.map(rowTestId));
if (input.before) {
@@ -83,7 +83,7 @@ export async function startIsolatedHostDaemon(
): Promise<IsolatedHostDaemon> {
const primaryPort = Number(process.env.E2E_DAEMON_PORT ?? 0);
let port = await getAvailablePort();
while (port === 6767 || port === primaryPort) port = await getAvailablePort();
while (port === 6767 || port === 6768 || port === primaryPort) port = await getAvailablePort();
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) throw new Error("E2E_METRO_PORT is required to start an isolated host daemon");
@@ -0,0 +1,196 @@
/**
* Observes a streaming Markdown block and classifies DOM churn.
*
* The word fade wraps each arriving word in a `data-word-fade` span carrying
* the word's start time, and collapses it back into plain text once the fade
* has settled, so text nodes around the tail and settled spans come and go by
* design. When Markdown re-parses and a word's rendered offset shifts, its span
* is re-created with the same start time and the fade resumes. Two things are
* defects:
*
* - `replacedElements`: an element other than a fade span left the block, so
* Markdown re-created a mounted descendant. Text nodes alone cannot form a
* Markdown descendant.
* - `restartedFades`: a fade span was removed before it settled and no span with
* that start time was added afterwards, so the word was faded again.
*
* Both functions run inside the page through `page.evaluate`, so they must be
* self-contained: no references to module scope.
*/
import type { Page } from "@playwright/test";
export interface MarkdownRootEvidence {
addedNodes: number;
characterDataMutations: number;
removedNodes: number;
replacedElements: number;
restartedFades: number;
}
interface MarkdownRootObserverWindow {
__markdownRootSettle?: () => MarkdownRootEvidence;
}
export function installMarkdownRootObserver(block: Element): void {
const fadeDurationMs = 150;
const frameMs = 16;
const evidence: MarkdownRootEvidence = {
addedNodes: 0,
characterDataMutations: 0,
removedNodes: 0,
replacedElements: 0,
restartedFades: 0,
};
// Unsettled removals waiting for a same-start-time addition. An addition
// only resumes a removal that already happened; React removes the old span
// before placing the new one within a commit.
const pendingResumes = new Map<string, number>();
const fadeSpans = (node: Node): HTMLElement[] => {
if (!(node instanceof HTMLElement)) return [];
return node.hasAttribute("data-word-fade")
? [node]
: Array.from(node.querySelectorAll<HTMLElement>("[data-word-fade]"));
};
// The renderer collapses a word when its last letter's animation ends. That
// letter starts at startedAt + spanMs * (letters - 1) / letters.
const isSettled = (span: HTMLElement) => {
const letters = Math.max(1, span.childElementCount);
const settledAt =
Number(span.dataset.fadeStarted) +
(Number(span.dataset.fadeSpan) * (letters - 1)) / letters +
fadeDurationMs;
return Number.isFinite(settledAt) && Date.now() + frameMs >= settledAt;
};
const handle = (records: MutationRecord[]) => {
for (const record of records) {
evidence.addedNodes += record.addedNodes.length;
evidence.removedNodes += record.removedNodes.length;
if (record.type === "characterData") evidence.characterDataMutations += 1;
for (const node of record.removedNodes) {
// Any removed element other than a fade span is a replaced descendant,
// whatever it contains. Fade spans it carried away were removed too.
if (node instanceof Element && !node.hasAttribute("data-word-fade")) {
evidence.replacedElements += 1;
}
for (const span of fadeSpans(node)) {
const started = span.dataset.fadeStarted;
if (!started || isSettled(span)) continue;
pendingResumes.set(started, (pendingResumes.get(started) ?? 0) + 1);
}
}
for (const node of record.addedNodes) {
for (const span of fadeSpans(node)) {
const started = span.dataset.fadeStarted;
const pending = started ? pendingResumes.get(started) : undefined;
if (!started || pending === undefined) continue;
if (pending > 1) pendingResumes.set(started, pending - 1);
else pendingResumes.delete(started);
}
}
}
};
const observer = new MutationObserver(handle);
observer.observe(block, { characterData: true, childList: true, subtree: true });
const settle = () => {
handle(observer.takeRecords());
observer.disconnect();
let restarted = 0;
for (const count of pendingResumes.values()) restarted += count;
evidence.restartedFades = restarted;
return evidence;
};
(window as unknown as MarkdownRootObserverWindow).__markdownRootSettle = settle;
}
export function settleMarkdownRootObserver(): MarkdownRootEvidence {
const settle = (window as unknown as MarkdownRootObserverWindow).__markdownRootSettle;
if (!settle) throw new Error("installMarkdownRootObserver has not run on this page");
return settle();
}
// Synthetic streaming block: the same DOM shapes the fade renderer produces,
// driven by hand so each classification can be exercised in isolation.
const SYNTHETIC_BLOCK_ID = "markdown-root-stability-block";
export async function mountObservedSyntheticBlock(page: Page): Promise<void> {
await page.setContent(`<div id="${SYNTHETIC_BLOCK_ID}"><p>settled </p></div>`);
const block = await page.locator(`#${SYNTHETIC_BLOCK_ID}`).elementHandle();
if (!block) throw new Error("Expected the synthetic Markdown block to mount");
await page.evaluate(installMarkdownRootObserver, block);
}
type SyntheticMutation =
| "resume-fading-word"
| "restart-fading-word"
| "collapse-settled-word"
| "replace-paragraph"
| "replace-paragraph-keeping-fading-word";
async function mutateSyntheticBlock(page: Page, mutation: SyntheticMutation): Promise<void> {
await page.evaluate(
([blockId, kind]) => {
const p = document.querySelector(`#${blockId} > p`) as HTMLElement;
const fadingWord = (startedAt: number) => {
const span = document.createElement("span");
span.setAttribute("data-word-fade", "0");
span.dataset.fadeStarted = String(startedAt);
span.dataset.fadeSpan = "60";
span.textContent = "word";
return span;
};
const replaceParagraphWith = (child?: HTMLElement) => {
const next = document.createElement("p");
next.textContent = p.textContent;
if (child) next.append(child);
p.replaceWith(next);
};
// Wall-clock start times are unsettled at removal unless backdated.
const now = Date.now();
switch (kind) {
case "resume-fading-word":
p.append(fadingWord(now));
p.lastElementChild!.remove();
p.append(fadingWord(now));
return;
case "restart-fading-word":
p.append(fadingWord(now));
p.lastElementChild!.remove();
p.append(fadingWord(now + 1));
return;
case "collapse-settled-word":
p.append(fadingWord(now - 10_000));
p.lastElementChild!.remove();
p.append("word");
return;
case "replace-paragraph":
replaceParagraphWith();
return;
case "replace-paragraph-keeping-fading-word":
p.append(fadingWord(now));
replaceParagraphWith(fadingWord(now));
return;
}
},
[SYNTHETIC_BLOCK_ID, mutation] as const,
);
}
/** Remove a fading word and re-create it with the same start time. */
export const resumeFadingWord = (page: Page) => mutateSyntheticBlock(page, "resume-fading-word");
/** Remove a fading word and re-create it with a new start time. */
export const restartFadingWord = (page: Page) => mutateSyntheticBlock(page, "restart-fading-word");
/** Collapse a word whose fade already finished back into plain text. */
export const collapseSettledWord = (page: Page) =>
mutateSyntheticBlock(page, "collapse-settled-word");
/** Re-create the paragraph element with the same text. */
export const replaceParagraph = (page: Page) => mutateSyntheticBlock(page, "replace-paragraph");
/** Re-create the paragraph while carrying its fading word over with the same start time. */
export const replaceParagraphKeepingFadingWord = (page: Page) =>
mutateSyntheticBlock(page, "replace-paragraph-keeping-fading-word");
export async function readMarkdownRootEvidence(page: Page) {
const evidence = await page.evaluate(settleMarkdownRootObserver);
return { replacedElements: evidence.replacedElements, restartedFades: evidence.restartedFades };
}
@@ -0,0 +1,202 @@
import { expect, test, type Page } from "../fixtures";
import { readFile } from "node:fs/promises";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { gotoAppShell } from "./app";
import { scrollTimelineToOldestLoadedEdge } from "./timeline-pagination";
import {
archiveLocalWorkspaceFromDaemon,
archiveWorkspaceFromDaemon,
connectNewWorkspaceDaemonClient,
countWorkspaceAgents,
countWorkspaceTerminals,
delayBrowserWorkspaceCreatedResponse,
expectNewWorkspaceDraft,
fillNewWorkspaceDraft,
openNewWorkspaceComposer,
openProjectViaDaemon,
selectWorkspaceIsolation,
submitNewWorkspaceEmpty,
submitNewWorkspacePrompt,
waitForCreatedWorkspace,
} from "./new-workspace";
import { createTempGitRepo } from "./workspace";
import { expectAppRoute } from "./route-assertions";
import { getServerId } from "./server-id";
import {
expectTerminalOutputContains,
expectWorkspaceOpensWithTerminalTab,
fillTerminalPrompt,
seedTerminalProfiles,
selectLaunchOption,
submitTerminalLaunch,
type TerminalProfile,
} from "./new-workspace-launch";
import { switchWorkspaceViaSidebar, waitForSidebarHydration } from "./workspace-ui";
import { dropFileOnComposer, expectAttachmentPill } from "./composer";
const PROMPT = "Hello from the navigation guard";
const NEXT_PROMPT = "Keep this newer draft";
const CONTEXT = {
name: "context.json",
mimeType: "application/json",
buffer: Buffer.from('{"preserve":"attachment"}'),
};
const PROFILE: TerminalProfile = {
id: "e2e-nav-guard-echo",
name: "Nav Guard Echo",
command: "/bin/sh",
args: ["-c", 'echo captured: "$0"; sleep 120', "{{{prompt}}}"],
};
export async function verifyDelayedWorkspaceCreation(
page: Page,
launch: "chat" | "terminal" | "empty",
destination: "leave" | "stay" | "new-draft",
isolation: "local" | "worktree" = "local",
): Promise<void> {
const client = await connectNewWorkspaceDaemonClient();
const repo = await createTempGitRepo("workspace-focus-");
const profileSeed = await seedTerminalProfiles([PROFILE]);
const delay = await delayBrowserWorkspaceCreatedResponse(page);
const serverId = getServerId();
let localWorkspaceId: string | undefined;
let createdDirectory: string | undefined;
let newDraftRoute = "";
try {
const project = await openProjectViaDaemon(client, repo.path);
localWorkspaceId = project.workspaceId;
const knownIds = new Set((await client.fetchWorkspaces()).entries.map((entry) => entry.id));
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId });
await test.step("Submit workspace creation with the requested content", async () => {
await openNewWorkspaceComposer(page, project);
await selectWorkspaceIsolation(page, isolation);
if (launch === "chat") {
await dropFileOnComposer(page, CONTEXT);
await expectAttachmentPill(page, "composer-file-attachment-pill");
await submitNewWorkspacePrompt(page, PROMPT);
} else if (launch === "terminal") {
await selectLaunchOption(page, PROFILE.id);
await fillTerminalPrompt(page, PROMPT);
await submitTerminalLaunch(page);
} else {
await submitNewWorkspaceEmpty(page);
}
await delay.waitForCreateRequest();
});
await test.step("Choose where to work while the creation response is held", async () => {
if (destination !== "stay") {
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId });
}
if (destination === "new-draft") {
await openNewWorkspaceComposer(page, project);
await fillNewWorkspaceDraft(page, NEXT_PROMPT);
const url = new URL(page.url());
newDraftRoute = url.pathname + url.search;
}
// Recording pacing only; the response is held deterministically above.
if (process.env.E2E_RECORD_VIDEO === "1") {
await page.mouse.move(1100, 80);
await page.waitForTimeout(1500);
}
delay.release();
});
const created = await waitForCreatedWorkspace(client, knownIds);
createdDirectory = created.workspaceDirectory;
expect(created.workspaceKind).toBe(isolation === "worktree" ? "worktree" : "local_checkout");
await test.step("Completion creates exactly the requested resource and preserves focus", async () => {
await expect
.poll(() => countWorkspaceAgents(client, created.id))
.toBe(launch === "chat" ? 1 : 0);
await expect
.poll(() => countWorkspaceTerminals(client, created.id))
.toBe(launch === "terminal" ? 1 : 0);
// A negative navigation assertion needs an observation window after completion.
await page.waitForTimeout(5000);
const expectedRoute =
destination === "new-draft"
? newDraftRoute
: buildHostWorkspaceRoute(
serverId,
destination === "stay" ? created.id : project.workspaceId,
);
await expectAppRoute(page, expectedRoute);
if (destination === "new-draft") await expectNewWorkspaceDraft(page, NEXT_PROMPT);
if (launch === "chat") {
expect(delay.agentRequests).toHaveLength(1);
const upload = delay.agentRequests[0]?.attachments?.find(
(item) => item.type === "uploaded_file",
);
if (!upload) throw new Error("The create request did not contain the submitted file");
expect(await readFile(upload.path)).toEqual(CONTEXT.buffer);
const agents = await client.fetchAgents();
expect(
agents.entries.find((entry) => entry.agent.workspaceId === created.id)?.agent,
).toMatchObject({
cwd: created.workspaceDirectory,
});
expect(delay.agentRequests[0]).toMatchObject({
initialPrompt: PROMPT,
config: {
provider: "mock",
model: "ten-second-stream",
modeId: "load-test",
thinkingOptionId: "low",
cwd: repo.path,
},
attachments: [
expect.objectContaining({
fileName: CONTEXT.name,
mimeType: CONTEXT.mimeType,
size: CONTEXT.buffer.length,
}),
],
});
}
});
await test.step("Visit the new workspace and return without submitting a second draft", async () => {
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: created.id });
if (launch === "chat") {
await scrollTimelineToOldestLoadedEdge(page);
await expect(page.getByText(PROMPT, { exact: true }).first()).toBeVisible();
} else if (launch === "terminal") {
await expectWorkspaceOpensWithTerminalTab(page);
await expectTerminalOutputContains(page, `captured: ${PROMPT}`);
}
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId });
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: created.id });
await expect
.poll(() => countWorkspaceAgents(client, created.id))
.toBe(launch === "chat" ? 1 : 0);
await expect
.poll(() => countWorkspaceTerminals(client, created.id))
.toBe(launch === "terminal" ? 1 : 0);
if (launch === "chat") {
expect(delay.agentRequests).toHaveLength(1);
await openNewWorkspaceComposer(page, project);
await expectNewWorkspaceDraft(page, destination === "new-draft" ? NEXT_PROMPT : "");
}
if (process.env.E2E_RECORD_VIDEO === "1") {
await page.mouse.move(1100, 80);
await page.waitForTimeout(1500);
}
});
} catch (error) {
await page.screenshot({ path: test.info().outputPath("before-cleanup.png") });
console.error(error);
throw error;
} finally {
delay.release();
if (createdDirectory) await archiveWorkspaceFromDaemon(client, createdDirectory);
if (localWorkspaceId) await archiveLocalWorkspaceFromDaemon(client, localWorkspaceId);
await profileSeed.restore();
await client.close();
await repo.cleanup();
}
}
+169 -34
View File
@@ -1,7 +1,8 @@
import { expect, type BrowserContext, type Page } from "@playwright/test";
import type { CreateAgentRequestMessage, SessionInboundMessage } from "@getpaseo/protocol/messages";
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
import { connectDaemonClient } from "./daemon-client-loader";
import { connectDaemonClient, loadProtocolSchemas } from "./daemon-client-loader";
import { daemonWsRoutePattern } from "./daemon-port";
import { projectEquivalenceViewKey } from "./project-view-key";
import { expectWorkspaceHeader } from "./workspace-ui";
@@ -16,6 +17,7 @@ type NewWorkspaceDaemonClient = Pick<
| "connect"
| "createPaseoWorktree"
| "createWorkspace"
| "fetchAgents"
| "fetchWorkspaces"
| "getPaseoWorktreeList"
| "getDaemonConfig"
@@ -24,6 +26,7 @@ type NewWorkspaceDaemonClient = Pick<
| "enablePlugin"
| "inspectWorkspaceRecovery"
| "listProjects"
| "listTerminals"
| "on"
| "patchDaemonConfig"
| "removeProject"
@@ -532,37 +535,35 @@ function parseWebSocketJson(message: WebSocketMessage): unknown {
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseWebSocketJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
return null;
}
if (typeof maybeEnvelope.message !== "object") {
return null;
}
return maybeEnvelope.message as Record<string, unknown>;
}
function getStringField(input: Record<string, unknown>, key: string): string | null {
const value = input[key];
return typeof value === "string" ? value : null;
export async function loadSessionMessageReaders() {
// Use the same ESM module instance as the dynamically loaded daemon client.
const { WSInboundMessageSchema, WSOutboundMessageSchema } = await loadProtocolSchemas();
return {
client(message: WebSocketMessage) {
const parsed = WSInboundMessageSchema.safeParse(parseWebSocketJson(message));
return parsed.success && parsed.data.type === "session" ? parsed.data.message : null;
},
server(message: WebSocketMessage) {
const parsed = WSOutboundMessageSchema.safeParse(parseWebSocketJson(message));
return parsed.success && parsed.data.type === "session" ? parsed.data.message : null;
},
};
}
export interface AgentCreatedDelayControl {
release(): void;
waitForCreateRequest(): Promise<void>;
waitForDelayedCreatedStatus(): Promise<void>;
expectSingleWorkspaceIntent(): void;
}
export async function delayBrowserAgentCreatedStatus(
page: Page,
): Promise<AgentCreatedDelayControl> {
const frames = await loadSessionMessageReaders();
const daemonPortPattern = daemonWsRoutePattern();
const createRequestIds = new Set<string>();
const creationRequests: SessionInboundMessage[] = [];
const delayedForwards: Array<() => void> = [];
let releaseRequested = false;
let resolveCreateRequest: (() => void) | null = null;
@@ -578,26 +579,32 @@ export async function delayBrowserAgentCreatedStatus(
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request") {
const requestId = getStringField(sessionMessage, "requestId");
if (requestId) {
createRequestIds.add(requestId);
resolveCreateRequest?.();
}
const sessionMessage = frames.client(message);
if (sessionMessage?.type === "send_agent_message_request")
creationRequests.push(sessionMessage);
if (
sessionMessage?.type === "create_agent_request" ||
sessionMessage?.type === "agent.create.request" ||
sessionMessage?.type === "workspace.create.request"
) {
creationRequests.push(sessionMessage);
createRequestIds.add(sessionMessage.requestId);
resolveCreateRequest?.();
}
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload =
sessionMessage?.type === "status" && typeof sessionMessage.payload === "object"
? (sessionMessage.payload as Record<string, unknown>)
: null;
const requestId = payload ? getStringField(payload, "requestId") : null;
if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) {
const sessionMessage = frames.server(message);
const isCreated =
(sessionMessage?.type === "status" && sessionMessage.payload.status === "agent_created") ||
sessionMessage?.type === "agent.create.response" ||
sessionMessage?.type === "workspace.create.response";
if (
isCreated &&
typeof sessionMessage.payload.requestId === "string" &&
createRequestIds.has(sessionMessage.payload.requestId)
) {
resolveDelayedCreatedStatus?.();
if (releaseRequested) {
ws.send(message);
@@ -618,7 +625,135 @@ export async function delayBrowserAgentCreatedStatus(
forward();
}
},
expectSingleWorkspaceIntent() {
expect(creationRequests).toHaveLength(1);
expect(creationRequests[0]).toMatchObject({
type: "workspace.create.request",
agent: { initialPrompt: expect.any(String) },
});
},
waitForCreateRequest: () => createRequestSeen,
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
};
}
type AgentCreationIntent = Pick<
CreateAgentRequestMessage,
"config" | "initialPrompt" | "attachments"
>;
export interface WorkspaceCreatedDelayControl {
agentRequests: readonly AgentCreationIntent[];
release(): void;
waitForCreateRequest(): Promise<void>;
}
/**
* Holds workspace readiness events and responses so a test can act while the daemon is still
* running `git worktree add` — most importantly, navigate somewhere else.
*/
export async function delayBrowserWorkspaceCreatedResponse(
page: Page,
): Promise<WorkspaceCreatedDelayControl> {
const frames = await loadSessionMessageReaders();
const daemonPortPattern = daemonWsRoutePattern();
const agentRequests: AgentCreationIntent[] = [];
const creationKeys = new Set<string>();
const createRequestIds = new Set<string>();
const delayedForwards: Array<() => void> = [];
let releaseRequested = false;
let resolveCreateRequest: (() => void) | null = null;
const createRequestSeen = new Promise<void>((resolve) => {
resolveCreateRequest = resolve;
});
await page.routeWebSocket(daemonPortPattern, (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = frames.client(message);
if (sessionMessage?.type === "create_agent_request") agentRequests.push(sessionMessage);
if (sessionMessage?.type === "workspace.create.request") {
createRequestIds.add(sessionMessage.requestId);
if (sessionMessage.idempotencyKey) creationKeys.add(sessionMessage.idempotencyKey);
if (sessionMessage.agent) agentRequests.push(sessionMessage.agent);
resolveCreateRequest?.();
}
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = frames.server(message);
const isCreation =
(sessionMessage?.type === "workspace.create.response" &&
createRequestIds.has(sessionMessage.payload.requestId)) ||
(sessionMessage?.type === "workspace.create.update" &&
creationKeys.has(sessionMessage.payload.idempotencyKey));
if (isCreation && !releaseRequested) {
delayedForwards.push(() => ws.send(message));
return;
}
ws.send(message);
});
});
return {
agentRequests,
release() {
releaseRequested = true;
for (const forward of delayedForwards.splice(0)) {
forward();
}
},
waitForCreateRequest: () => createRequestSeen,
};
}
type WorkspaceEntry = Awaited<
ReturnType<NewWorkspaceDaemonClient["fetchWorkspaces"]>
>["entries"][number];
/**
* Waits for a workspace the caller did not already know about, and returns it. Tests that create a
* workspace without navigating to it have no route to assert on, so the daemon's own list is the
* signal that creation finished.
*/
export async function waitForCreatedWorkspace(
client: NewWorkspaceDaemonClient,
knownWorkspaceIds: ReadonlySet<string>,
options?: { timeout?: number },
): Promise<WorkspaceEntry> {
let created: WorkspaceEntry | undefined;
await expect
.poll(
async () => {
const payload = await client.fetchWorkspaces();
created = payload.entries.find((entry) => !knownWorkspaceIds.has(entry.id));
return created !== undefined;
},
{ timeout: options?.timeout ?? 60_000 },
)
.toBe(true);
if (!created) {
throw new Error("Workspace list reported a new workspace but it could not be read back");
}
return created;
}
export async function countWorkspaceAgents(
client: NewWorkspaceDaemonClient,
workspaceId: string,
): Promise<number> {
const payload = await client.fetchAgents({ filter: { includeArchived: true } });
return payload.entries.filter((entry) => entry.agent.workspaceId === workspaceId).length;
}
export async function countWorkspaceTerminals(
client: NewWorkspaceDaemonClient,
workspaceId: string,
): Promise<number> {
const payload = await client.listTerminals(undefined, undefined, { workspaceId });
return payload.terminals.length;
}
@@ -1,5 +1,5 @@
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import net from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -59,23 +59,37 @@ export async function startPackagedWebDaemon(input: {
const serverId = `relay-deployment-${Date.now().toString(36)}`;
const paseo = path.resolve(__dirname, "../../../../../node_modules/.bin/paseo");
const env: NodeJS.ProcessEnv = {
...process.env,
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_"))),
HOME: home,
USERPROFILE: home,
CI: "true",
NODE_ENV: "development",
PASEO_NODE_ENV: "development",
PASEO_SERVER_ID: serverId,
PASEO_RELAY_ENDPOINT: input.relayEndpoint,
PASEO_RELAY_PUBLIC_ENDPOINT: input.relayEndpoint,
PASEO_RELAY_USE_TLS: "false",
PASEO_RELAY_PUBLIC_USE_TLS: "false",
};
try {
await execFileAsync(
paseo,
["daemon", "start", "--home", home, "--port", String(port), "--relay", "--web-ui"],
{ env },
await writeFile(
path.join(home, "config.json"),
JSON.stringify({
daemon: {
listen: `127.0.0.1:${port}`,
relay: {
enabled: true,
endpoint: input.relayEndpoint,
publicEndpoint: input.relayEndpoint,
useTls: false,
publicUseTls: false,
},
},
features: {
webUi: { enabled: true },
dictation: { enabled: false },
voiceMode: { enabled: false },
},
}),
);
await execFileAsync(paseo, ["daemon", "start", "--home", home], { env });
const origin = `http://127.0.0.1:${port}`;
await waitForWebUi(origin, home);
@@ -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 };
@@ -18,7 +17,7 @@ const COMPACT = { width: 390, height: 844 };
function clientSource(workspaceId: string, agentId: string): string {
return `import React from "react";
import { Text, View, Pressable } from "react-native";
import { useWorkspace, useRpc } from "@getpaseo/plugin/client";
import { useWorkspace, useRpc, usePaseo } from "@getpaseo/plugin/client";
import { useQuery } from "@tanstack/react-query";
import { summary } from "./shared/rpc";
@@ -28,9 +27,22 @@ function StatusIcon({ size, theme }) {
function Details({ workspaceId, theme, layout, close }) {
const workspace = useWorkspace(workspaceId, (workspace) => workspace.name);
const paseo = usePaseo();
const [ownerId, setOwnerId] = React.useState("");
const [updates, setUpdates] = React.useState(0);
const [failed, setFailed] = React.useState(false);
React.useEffect(() => {
const owner = paseo.observeEvents(["project.update"]);
owner.subscribe({ snapshot(value) { setOwnerId(value.subscriptionId); }, update() { setUpdates((n) => n + 1); } });
// Deliberately leave this observation to the mounted host scope.
}, [paseo]);
const rpc = useRpc(summary);
const query = useQuery({ queryKey: ["summary"], queryFn: () => rpc({}) });
if (failed) throw new Error("Button content failed");
return <View style={{ gap: 12 }}>
<Text accessibilityLabel="Content observation" style={{ color: theme.colors.foreground }}>{ownerId}</Text>
<Text style={{ color: theme.colors.foreground }}>Observed updates: {updates}</Text>
<Pressable accessibilityRole="button" accessibilityLabel="Fail button content" onPress={() => setFailed(true)}><Text style={{ color: theme.colors.foreground }}>Fail content</Text></Pressable>
<Text style={{ color: theme.colors.foreground, fontSize: 18 }}>Deployment status</Text>
<Text style={{ color: theme.colors.foregroundMuted }}>{workspace}</Text>
<Text style={{ color: theme.colors.statusSuccess }}>{query.data?.message ?? "Loading deployment..."}</Text>
@@ -83,12 +95,19 @@ export default function contribute(client) {
command("icon-only", "Use icon-only header", () => deploy.update({ label: undefined }));
command("hide-menu", "Hide composer menu", () => pillMenu.update({ visible: false }));
command("show-menu", "Show composer menu", () => pillMenu.update({ visible: true }));
const unsubscribe = client.paseo.workspaces.ref(workspaceId).subscribe((update) => {
if (update.kind !== "upsert") return;
deploy.update({ visible: update.workspace.name !== "Compact checks" });
pillMenu.update({ visible: update.workspace.name !== "Hide composer menu" });
});
return () => { unsubscribe(); finish(); finishReview(); for (const registration of registrations) registration.remove(); };
const lifetime = new AbortController();
const applyWorkspace = (workspace) => {
if (workspace.id !== workspaceId) return;
deploy.update({ visible: workspace.name !== "Compact checks" });
pillMenu.update({ visible: workspace.name !== "Hide composer menu" });
};
void client.paseo.workspaces.list({ subscribe: {}, signal: lifetime.signal }).then(({ subscription }) => {
subscription.subscribe({
snapshot({ entries }) { for (const workspace of entries) applyWorkspace(workspace); },
update(message) { if (message.type === "workspace_update" && message.payload.kind === "upsert") applyWorkspace(message.payload.workspace); },
});
}).catch((error) => { if (!lifetime.signal.aborted) console.error(error); });
return () => { lifetime.abort(); finish(); finishReview(); for (const registration of registrations) registration.remove(); };
}`;
}
@@ -146,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) {
@@ -194,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
@@ -210,20 +223,29 @@ 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>;
openCompactSheets(): Promise<void>;
hideAndUnloadOpenButtons(): Promise<void>;
verifyObservationLifetime(): Promise<void>;
}) => Promise<void>,
) {
const released = new Set<string>();
page.on("websocket", (socket) =>
socket.on("framereceived", ({ payload }) => {
if (typeof payload !== "string") return;
const frame = JSON.parse(payload);
if (frame.message?.type === "subscription.release.response")
released.add(frame.message.payload.subscriptionId);
}),
);
const workspace = await seedWorkspace({ repoPrefix: "plugin-buttons-" });
const agent = await workspace.client.createAgent({
provider: "mock",
@@ -241,6 +263,39 @@ export async function withButtonShowcase(
await expect(page.getByRole("button", { name: "Run review", exact: true })).toBeVisible();
await run({
verifyObservationLifetime: () =>
test.step("closing and crashing content release its owner while sibling buttons and app remain live", async () => {
await press(page, "Header status");
await expectDetails(page);
const observation = page.getByLabel("Content observation", { exact: true });
await expect(observation).not.toHaveText("");
const first = (await observation.textContent())!;
await press(page, "Close deployment details");
await expect.poll(() => released.has(first)).toBe(true);
await press(page, "Composer status");
await expectDetails(page);
const second = (await observation.textContent())!;
expect(second).not.toBe(first);
await workspace.client.renameProject(workspace.projectId, "Observer control");
await expect(page.getByText("Observed updates: 1", { exact: true })).toBeVisible();
await press(page, "Fail button content");
await expect(
page.getByText("Plugin failed: Button content failed", { exact: true }),
).toBeVisible();
await expect.poll(() => released.has(second)).toBe(true);
await page.keyboard.press("Escape");
await press(page, "Header status");
await expectDetails(page);
const recovered = (await observation.textContent())!;
expect([first, second]).not.toContain(recovered);
await showcase.setTitle("Healthy app after button failure");
await expect(page.getByTestId("workspace-header-title")).toHaveText(
"Healthy app after button failure",
);
await press(page, "Close deployment details");
await expect.poll(() => released.has(recovered)).toBe(true);
await showcase.setTitle("Button showcase");
}),
openWideMenusAndPopovers: () =>
test.step("wide buttons have placement-owned labels and chevrons", async () => {
await expect(
@@ -253,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");
@@ -277,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");
@@ -288,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");
@@ -327,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 }),
@@ -346,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",
@@ -355,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,
@@ -387,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");
@@ -421,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");
@@ -460,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);
@@ -477,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 {
@@ -0,0 +1,144 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, type Page, type TestInfo } from "@playwright/test";
import { pluginRequirements } from "./plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
const PLUGIN_ID = "timeline-transform-e2e";
export const ASSISTANT_TEXT =
"Skill header\n\n# Instructions\n\nRead every paragraph and preserve the full message while this response is still streaming.";
const CLIENT_SOURCE = `import React, { useState } from "react";
import { Text, View, Pressable } from "react-native";
import { z } from "zod";
function Card({ item }) {
const [count, setCount] = useState(0);
return <View>
<Text accessibilityRole="header">{item.data.label + " " + item.data.phase}</Text>
<Text>{item.data.text}</Text>
<Pressable accessibilityRole="button" onPress={() => setCount(count + 1)}>
<Text>{"Card clicks " + count}</Text>
</Pressable>
</View>;
}
export default function contribute(client) {
for (const itemType of ["assistant_message", "tool_call"]) {
client.addTimelineTransformer({
id: itemType.replaceAll("_", "-"),
query: { itemType },
transform({ item, phase }) {
return { items: [{ type: "plugin", kind: "card", version: 1, data: {
label: item.type === "tool_call" ? "Tool " + item.name : "Assistant",
text: item.type === "tool_call" ? item.callId : item.text,
phase,
} }] };
},
});
}
client.addTimelineRenderer({
kind: "card", version: 1,
schema: z.object({ label: z.string(), text: z.string(), phase: z.enum(["streaming", "complete"]) }),
Component: Card,
});
return () => {};
}`;
export async function withTimelinePlugin(
page: Page,
info: TestInfo,
mode: "assistant" | "tools",
run: (agent: MockAgentWorkspace) => Promise<void>,
): Promise<void> {
info.setTimeout(120_000);
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ toolCallDetailLevel: "overview" }),
);
});
const directory = await mkdtemp(path.join(tmpdir(), "paseo-timeline-plugin-"));
const agent = await seedMockAgentWorkspace({
repoPrefix: "timeline-plugin-",
title: "Timeline plugin regression",
model: "ten-second-stream",
featureValues:
mode === "assistant"
? {
mockStreamingAssistantResponse: ASSISTANT_TEXT,
mockStreamingAssistantIntervalMs: 300,
}
: {},
});
const pluginClient = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await pluginClient.getDaemonConfig();
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), CLIENT_SOURCE);
await pluginClient.patchDaemonConfig({ pluginsEnabled: true });
await pluginClient.installDirectoryPlugin(directory);
await openAgentRoute(page, agent);
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible();
await run(agent);
await info.attach("timeline-plugin", {
body: await page.screenshot({ path: info.outputPath("timeline-plugin.png") }),
contentType: "image/png",
});
} catch (error) {
await info.attach("timeline-before-cleanup", {
body: await page.screenshot({ path: info.outputPath("timeline-before-cleanup.png") }),
contentType: "image/png",
});
throw error;
} finally {
await pluginClient.removePlugin(PLUGIN_ID);
await pluginClient.patchDaemonConfig({
pluginsEnabled: previous.config.pluginsEnabled ?? false,
});
await pluginClient.close();
await agent.cleanup();
await rm(directory, { recursive: true, force: true });
}
}
export async function requestPluginTimeline(agent: MockAgentWorkspace): Promise<void> {
await agent.client.sendAgentMessage(agent.agentId, "Exercise timeline transforms.");
}
export async function interactWithStreamingCard(page: Page): Promise<void> {
await expect(page.getByRole("heading", { name: "Assistant streaming", exact: true })).toHaveCount(
1,
);
await page.getByRole("button", { name: "Card clicks 0", exact: true }).click();
await expect(page.getByText(/Skill header\s+# Instructions/)).toBeVisible();
await expect(page.getByRole("heading", { name: "Assistant streaming", exact: true })).toHaveCount(
1,
);
await expect(page.getByRole("button", { name: "Card clicks 1", exact: true })).toBeVisible();
await expect(page.getByTestId("assistant-message")).toHaveCount(0);
}
export async function expectWholeCompletedCard(page: Page): Promise<void> {
await expect(page.getByRole("heading", { name: "Assistant complete", exact: true })).toHaveCount(
1,
);
await expect(page.getByText(ASSISTANT_TEXT, { exact: true })).toBeVisible();
await expect(page.getByTestId("assistant-message")).toHaveCount(0);
}
export async function expectBothConsecutiveTools(page: Page): Promise<void> {
// read and grep are consecutive source calls in the fixture. Overview used to
// send only the later call to the plugin, so the read card never existed.
const read = page.getByRole("heading", { name: "Tool read complete", exact: true }).first();
await read.scrollIntoViewIfNeeded();
await expect(read).toBeVisible();
const grep = page.getByRole("heading", { name: "Tool grep complete", exact: true }).first();
await grep.scrollIntoViewIfNeeded();
await expect(grep).toBeVisible();
}
@@ -92,7 +92,7 @@ export async function measureRelayRestartDuringStream(input: {
agentTitle: string;
}): Promise<RelayDeploymentMeasurements> {
const { page, relay, agentTitle } = input;
const toast = page.getByTestId("agent-reconnecting-toast");
const toast = page.getByRole("alert").filter({ hasText: "Reconnecting to host" });
const beforeOutage = await latestAssistantText(page);
await waitForAssistantTextToGrow(page, beforeOutage);
@@ -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 };
}
@@ -1,304 +0,0 @@
import { type Page } from "@playwright/test";
import { buildSeededHost } from "./daemon-registry";
import { wsRoutePatternForPort } from "./daemon-port";
import { type SeededWorkspace } from "./seed-client";
const REGISTRY_KEY = "@paseo:daemon-registry";
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
export const FAKE_HOST_MODEL_ID = "fake-host-model";
export const FAKE_HOST_MODEL_LABEL = "Fake host model";
export const FAKE_HOST_PROJECT_DISPLAY_NAME = "Fake host project";
type WebSocketMessage = string | Buffer;
type SessionRequest = Record<string, unknown> & { type?: string; requestId?: string };
export interface FakeScheduleHostWorkspace {
serverId: string;
projectId: string;
projectKey: string;
projectDisplayName: string;
workspace: Record<string, unknown>;
}
interface FakeScheduleSummary {
id: string;
name: string | null;
prompt: string;
cadence: { type: "cron"; expression: string };
target: {
type: "new-agent";
config: {
provider: "mock";
cwd: string;
model: string;
modeId: string;
title?: string;
};
};
status: "active";
createdAt: string;
updatedAt: string;
nextRunAt: string | null;
lastRunAt: string | null;
pausedAt: string | null;
expiresAt: string | null;
maxRuns: number | null;
}
function parseJson(message: WebSocketMessage): unknown {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(raw);
} catch {
return null;
}
}
function buildSessionMessage(type: string, payload: Record<string, unknown>) {
return JSON.stringify({
type: "session",
message: {
type,
payload,
},
});
}
function buildFakeProviderEntries(nowIso: string) {
return [
{
provider: "mock",
label: "Mock",
status: "ready",
enabled: true,
fetchedAt: nowIso,
models: [
{
provider: "mock",
id: FAKE_HOST_MODEL_ID,
label: FAKE_HOST_MODEL_LABEL,
isDefault: true,
},
],
modes: [{ id: "load-test", label: "Load test" }],
defaultModeId: "load-test",
},
];
}
function readSessionRequest(message: WebSocketMessage): SessionRequest | null {
const parsed = parseJson(message);
if (!parsed || typeof parsed !== "object") {
return null;
}
const envelope = parsed as { type?: string; message?: SessionRequest };
if (envelope.type !== "session" || !envelope.message) {
return null;
}
return envelope.message;
}
function getRequestId(request: SessionRequest): string {
return typeof request.requestId === "string" ? request.requestId : "fake-request";
}
export async function buildFakeScheduleHostWorkspace(
workspace: SeededWorkspace,
): Promise<FakeScheduleHostWorkspace> {
const workspaceList = await workspace.client.fetchWorkspaces({
filter: { projectId: workspace.projectId },
});
const baseWorkspace = workspaceList.entries.find((entry) => entry.id === workspace.workspaceId);
if (!baseWorkspace) {
throw new Error(`Failed to load seeded workspace descriptor ${workspace.workspaceId}`);
}
const projectId = `${workspace.projectId}-fake-host`;
const cwd = `${workspace.repoPath}-fake-host`;
return {
serverId: "schedule-fake-host",
projectId,
projectKey: workspace.projectKey,
projectDisplayName: FAKE_HOST_PROJECT_DISPLAY_NAME,
workspace: {
...baseWorkspace,
id: `${baseWorkspace.id}-fake-host`,
projectId,
projectDisplayName: FAKE_HOST_PROJECT_DISPLAY_NAME,
projectRootPath: cwd,
workspaceDirectory: cwd,
name: FAKE_HOST_PROJECT_DISPLAY_NAME,
project: undefined,
},
};
}
export async function installFakeScheduleHost(input: {
page: Page;
port: string;
serverId: string;
workspace: Record<string, unknown>;
project: Pick<FakeScheduleHostWorkspace, "projectId" | "projectKey" | "projectDisplayName">;
schedules?: FakeScheduleSummary[];
}): Promise<void> {
await input.page.routeWebSocket(wsRoutePatternForPort(input.port), (ws) => {
ws.onMessage((message) => {
const parsed = parseJson(message);
if (parsed && typeof parsed === "object" && (parsed as { type?: string }).type === "hello") {
ws.send(
buildSessionMessage("status", {
status: "server_info",
serverId: input.serverId,
hostname: "fake-schedule-host",
version: "0.0.0-e2e",
features: {
providersSnapshot: true,
workspaceMultiplicity: true,
projectList: true,
projectAdd: true,
projectRemove: true,
workspaceRecovery: true,
},
}),
);
return;
}
if (parsed && typeof parsed === "object" && (parsed as { type?: string }).type === "ping") {
ws.send(JSON.stringify({ type: "pong" }));
return;
}
const request = readSessionRequest(message);
if (!request) {
return;
}
const requestId = getRequestId(request);
const now = Date.now();
const nowIso = new Date(now).toISOString();
switch (request.type) {
case "ping":
ws.send(
buildSessionMessage("pong", {
requestId,
clientSentAt: typeof request.clientSentAt === "number" ? request.clientSentAt : now,
serverReceivedAt: now,
serverSentAt: now,
}),
);
return;
case "fetch_workspaces_request":
ws.send(
buildSessionMessage("fetch_workspaces_response", {
requestId,
entries: [input.workspace],
emptyProjects: [],
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
}),
);
return;
case "project.list.request":
ws.send(
buildSessionMessage("project.list.response", {
requestId,
projects: [
{
projectId: input.project.projectId,
projectKey: input.project.projectKey,
projectDisplayName: input.project.projectDisplayName,
projectRootPath: String(input.workspace.projectRootPath),
projectKind: "git",
},
],
}),
);
return;
case "fetch_agents_request":
ws.send(
buildSessionMessage("fetch_agents_response", {
requestId,
entries: [],
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
}),
);
return;
case "get_providers_snapshot_request":
ws.send(
buildSessionMessage("get_providers_snapshot_response", {
requestId,
entries: buildFakeProviderEntries(nowIso),
generatedAt: nowIso,
}),
);
return;
case "refresh_providers_snapshot_request":
ws.send(
buildSessionMessage("refresh_providers_snapshot_response", {
requestId,
acknowledged: true,
}),
);
return;
case "schedule/list":
ws.send(
buildSessionMessage("schedule/list/response", {
requestId,
schedules: input.schedules ?? [],
error: null,
}),
);
return;
}
});
});
}
export async function addFakeScheduleHostAndReload(input: {
page: Page;
serverId: string;
label: string;
port: string;
}): Promise<void> {
const host = buildSeededHost({
serverId: input.serverId,
label: input.label,
endpoint: `127.0.0.1:${input.port}`,
nowIso: new Date().toISOString(),
});
await input.page.evaluate(
({ seededHost, keys }) => {
const nonce = localStorage.getItem(keys.nonce);
if (!nonce) {
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
}
const raw = localStorage.getItem(keys.registry);
const registry: Array<{ serverId: string }> = raw ? JSON.parse(raw) : [];
const nextRegistry = registry.filter((entry) => entry.serverId !== seededHost.serverId);
nextRegistry.push(seededHost);
localStorage.setItem(keys.registry, JSON.stringify(nextRegistry));
const rawExtraHosts = localStorage.getItem(keys.extraHosts);
const extraHosts: Array<{ serverId: string }> = rawExtraHosts
? JSON.parse(rawExtraHosts)
: [];
const nextExtraHosts = extraHosts.filter((entry) => entry.serverId !== seededHost.serverId);
nextExtraHosts.push(seededHost);
localStorage.setItem(keys.extraHosts, JSON.stringify(nextExtraHosts));
},
{
seededHost: host,
keys: {
registry: REGISTRY_KEY,
nonce: SEED_NONCE_KEY,
extraHosts: EXTRA_HOSTS_KEY,
},
},
);
await input.page.reload();
}
@@ -0,0 +1,81 @@
import { type Page } from "@playwright/test";
import { buildSeededHost } from "./daemon-registry";
import { startIsolatedHostDaemon } from "./isolated-host-daemon";
import { seedWorkspace, type SeededWorkspace } from "./seed-client";
const REGISTRY_KEY = "@paseo:daemon-registry";
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
export const SECONDARY_MODEL_ID = "one-minute-stream";
export const SECONDARY_MODEL_LABEL = "One minute stream";
export async function createScheduleHost() {
const daemon = await startIsolatedHostDaemon("schedule-secondary-host");
let workspace: SeededWorkspace | undefined;
try {
workspace = await seedWorkspace({ repoPrefix: "schedule-secondary-", port: daemon.port });
const seeded = workspace;
await seeded.client.renameProject(seeded.projectId, "Secondary project");
return {
...seeded,
projectDisplayName: "Secondary project",
serverId: daemon.serverId,
port: daemon.port,
label: "Secondary host",
cleanup: async () => {
await seeded.cleanup();
await daemon.close();
},
};
} catch (error) {
await workspace?.cleanup();
await daemon.close();
throw error;
}
}
export async function addScheduleHostAndReload(input: {
page: Page;
serverId: string;
label: string;
port: number;
}): Promise<void> {
const host = buildSeededHost({
serverId: input.serverId,
label: input.label,
endpoint: `127.0.0.1:${input.port}`,
nowIso: new Date().toISOString(),
});
await input.page.evaluate(
({ seededHost, keys }) => {
const nonce = localStorage.getItem(keys.nonce);
if (!nonce) {
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
}
const raw = localStorage.getItem(keys.registry);
const registry: Array<{ serverId: string }> = raw ? JSON.parse(raw) : [];
const nextRegistry = registry.filter((entry) => entry.serverId !== seededHost.serverId);
nextRegistry.push(seededHost);
localStorage.setItem(keys.registry, JSON.stringify(nextRegistry));
const rawExtraHosts = localStorage.getItem(keys.extraHosts);
const extraHosts: Array<{ serverId: string }> = rawExtraHosts
? JSON.parse(rawExtraHosts)
: [];
const nextExtraHosts = extraHosts.filter((entry) => entry.serverId !== seededHost.serverId);
nextExtraHosts.push(seededHost);
localStorage.setItem(keys.extraHosts, JSON.stringify(nextExtraHosts));
},
{
seededHost: host,
keys: {
registry: REGISTRY_KEY,
nonce: SEED_NONCE_KEY,
extraHosts: EXTRA_HOSTS_KEY,
},
},
);
await input.page.reload();
}
@@ -0,0 +1,105 @@
import { holdAssistantStream } from "./agent-timeline-gate";
import { expect, type Page, type TestInfo } from "@playwright/test";
import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
interface StreamingMarkdownAgent extends MockAgentWorkspace {
stream: Awaited<ReturnType<typeof holdAssistantStream>>;
}
export async function withStreamingMarkdown(
page: Page,
testInfo: TestInfo,
run: (agent: StreamingMarkdownAgent) => Promise<void>,
): Promise<void> {
testInfo.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "streaming-markdown-",
title: "Streaming Markdown",
featureValues: {
mockStreamingAssistantResponse:
"**Bold text stays bold** and [Paseo docs](https://example.com/documentation). Done.",
mockStreamingAssistantIntervalMs: 400,
},
});
const stream = await holdAssistantStream(page, agent.agentId);
try {
await openAgentRoute(page, agent);
await stream.waitForInitialTimeline();
await run({ ...agent, stream });
} finally {
stream.release();
await agent.cleanup();
}
}
export async function requestStreamingMarkdown(agent: StreamingMarkdownAgent): Promise<void> {
await agent.client.sendAgentMessage(agent.agentId, "Show the formatted streaming response.");
// Exercise late assertions: the producer finishes before the browser consumes its frames.
await agent.client.waitForFinish(agent.agentId, 30_000);
// The mock splits "**Bold" into two frames. Whether they land in one store
// commit or two is up to the browser's task scheduling, and word pacing only
// releases a word once the whitespace after it has arrived, so stop after
// that whitespace: every batching then reveals "Bold" and nothing past "text".
await agent.stream.showThrough("**Bold text");
}
export async function expectUnfinishedBold(page: Page): Promise<void> {
const message = page.getByTestId("assistant-message").last();
const bold = message.locator('[data-paseo-markdown-tag="strong"]');
await expect(bold).toContainText("Bold");
await expect(message).not.toContainText("stays bold");
await expect(bold).toHaveCSS("font-weight", "500");
await expect(message).not.toContainText("*");
}
export async function expectUnfinishedLink(
page: Page,
agent: StreamingMarkdownAgent,
testInfo: TestInfo,
): Promise<void> {
const message = page.getByTestId("assistant-message").last();
await agent.stream.showThrough("**Bold text stays bold** and [Paseo docs");
// The next word includes the closing label and URL, so word pacing releases
// it only when the link is complete. Observe the first complete label word.
await expect(message).toContainText("Paseo");
await expect(message).not.toContainText("docs");
await expect(message.getByRole("link")).toHaveCount(0);
await expect(message).not.toContainText("[");
await expect(message).not.toContainText("https:");
await captureMarkdown(page, testInfo, "unfinished-link");
}
export async function expectFinishedMarkdown(
page: Page,
agent: StreamingMarkdownAgent,
testInfo: TestInfo,
): Promise<void> {
agent.stream.release();
await agent.client.waitForFinish(agent.agentId, 30_000);
await expectCompletedMarkdown(page);
await captureMarkdown(page, testInfo, "completed-markdown");
}
export async function expectReloadedMarkdown(page: Page): Promise<void> {
await page.reload({ waitUntil: "domcontentloaded" });
await expectCompletedMarkdown(page);
}
async function captureMarkdown(page: Page, testInfo: TestInfo, name: string): Promise<void> {
await testInfo.attach(name, {
body: await page.screenshot({ path: testInfo.outputPath(`${name}.png`) }),
contentType: "image/png",
});
}
async function expectCompletedMarkdown(page: Page): Promise<void> {
const message = page.getByTestId("assistant-message").last();
await expect(message).toHaveText("Bold text stays bold and Paseo docs. Done.");
await expect(
message.getByRole("link", { name: "Paseo docs" }).and(message.locator("a")),
).toHaveAttribute("href", "https://example.com/documentation");
await expect(message.locator('[data-paseo-markdown-tag="strong"]')).toHaveCSS(
"font-weight",
"500",
);
}
@@ -15,6 +15,8 @@ export { holdDaemonHydration };
interface LongTimelineAgentOptions {
turns: number;
/** Live turns stream for ten seconds unless a test needs one that outlasts it. */
liveTurns?: "ten-second-stream" | "thirty-minute-stream";
}
export interface LongTimelineAgent extends MockAgentWorkspace {
@@ -23,6 +25,8 @@ export interface LongTimelineAgent extends MockAgentWorkspace {
firstOlderPagePrompt: string;
initialTailAnchorPrompt: string;
initialTailOldestPrompt: string;
/** The newest prompt on the first older page, adjacent to the initial tail. */
newestOlderPagePrompt: string;
oldestPrompt: string;
newestPrompt: string;
}
@@ -40,12 +44,6 @@ interface TimelineViewportSnapshot {
scrollTop: number;
}
interface PersistedCanonicalTimelineRange {
epoch: string;
startSeq: number;
endSeq: number;
}
export interface TimelinePresentationSnapshot {
marker: string;
position: TimelinePromptPositionSnapshot;
@@ -57,11 +55,6 @@ interface TimelinePromptPositionSnapshot {
top: number;
}
interface OlderHistoryLoadingOperation {
animationStartTime: number | null;
marker: string;
}
interface OlderHistoryPages {
expectRequestedPages(count: number): Promise<void>;
expectSettledWithRequestedPages(count: number): Promise<void>;
@@ -82,7 +75,7 @@ export async function seedLongMockAgentTimeline(
const agent = await seedMockAgentWorkspace({
repoPrefix: "timeline-pagination-",
title: "Timeline pagination regression",
model: "ten-second-stream",
model: options.liveTurns ?? "ten-second-stream",
});
for (let index = 0; index < options.turns; index += 1) {
@@ -96,6 +89,7 @@ export async function seedLongMockAgentTimeline(
firstOlderPagePrompt: promptForTurn(Math.max(0, options.turns - 40)),
initialTailAnchorPrompt: promptForTurn(Math.max(0, options.turns - 5)),
initialTailOldestPrompt: promptForTurn(Math.max(0, options.turns - 20)),
newestOlderPagePrompt: promptForTurn(Math.max(0, options.turns - 21)),
oldestPrompt: promptForTurn(0),
newestPrompt: promptForTurn(options.turns - 1),
};
@@ -304,34 +298,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,
@@ -402,8 +368,15 @@ export async function expectStableHistoryStartGutter(page: Page): Promise<void>
export async function scrollTimelinePromptIntoView(page: Page, prompt: string): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const row = timeline.getByTestId("user-message").filter({ hasText: prompt });
await row.scrollIntoViewIfNeeded();
await expect(row).toBeVisible();
const [viewport, target] = await Promise.all([timeline.boundingBox(), row.boundingBox()]);
if (!viewport || !target) {
throw new Error(`Expected a rendered timeline prompt for ${prompt}`);
}
// Programmatic scrolling does not express the user's intent to stop following output.
await timeline.hover();
await page.mouse.wheel(0, target.y - viewport.y - (viewport.height - target.height) / 2);
await expect(row).toBeInViewport({ ratio: 1 });
await waitForTimelineGeometryToSettle(page);
await expect
.poll(async () => (await readTimelineViewport(page)).scrollTop)
.toBeGreaterThan(HISTORY_START_THRESHOLD_PX);
@@ -450,49 +423,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)
@@ -6,6 +6,7 @@ import { expectReconnectingToastGone, expectReconnectingToastVisible } from "./w
type DaemonWebSocketGate = Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
export interface TimelineRequestCounts {
agentId?: string;
after: number;
tail: number;
}
@@ -33,10 +34,14 @@ export async function commitTimelineTurnsWhileDisconnected(
};
}
export function rememberTimelineRequestCounts(gate: DaemonWebSocketGate): TimelineRequestCounts {
export function rememberTimelineRequestCounts(
gate: DaemonWebSocketGate,
agentId?: string,
): TimelineRequestCounts {
return {
after: gate.getTimelineRequestCount("after"),
tail: gate.getTimelineRequestCount("tail"),
agentId,
after: gate.getTimelineRequestCount("after", agentId),
tail: gate.getTimelineRequestCount("tail", agentId),
};
}
@@ -44,16 +49,16 @@ export function expectOneResumeCheckWithoutTail(
gate: DaemonWebSocketGate,
before: TimelineRequestCounts,
): void {
expect(gate.getTimelineRequestCount("after") - before.after).toBe(1);
expect(gate.getTimelineRequestCount("tail") - before.tail).toBe(0);
expect(gate.getTimelineRequestCount("after", before.agentId) - before.after).toBe(1);
expect(gate.getTimelineRequestCount("tail", before.agentId) - before.tail).toBe(0);
}
export function expectResumeOverflowFallsBackToOneTail(
gate: DaemonWebSocketGate,
before: TimelineRequestCounts,
): void {
expect(gate.getTimelineRequestCount("after") - before.after).toBe(1);
expect(gate.getTimelineRequestCount("tail") - before.tail).toBe(1);
expect(gate.getTimelineRequestCount("after", before.agentId) - before.after).toBe(1);
expect(gate.getTimelineRequestCount("tail", before.agentId) - before.tail).toBe(1);
}
export async function disconnectViewedTimeline(
@@ -71,6 +76,8 @@ export async function restoreViewedTimelineWithHeldResponse(
gate.holdNextServerMessage("fetch_agent_timeline_response");
gate.restore();
await gate.waitForHeldServerMessage();
gate.releaseHeldServerMessage();
await expectReconnectingToastGone(page);
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toBeVisible();
gate.releaseHeldServerMessage();
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0);
}
@@ -0,0 +1,112 @@
import { expect, type Page, type TestInfo } from "@playwright/test";
import { test as base } from "../fixtures";
import { startRunningMockAgent } from "./composer";
import { awaitAssistantMessage } from "./agent-stream";
export const test = base.extend<{ streamingReply: Page }>({
streamingReply: async ({ page }, provide) => {
const agent = await startRunningMockAgent(page, {
prefix: "word-fade-",
model: "bursty-stream",
prompt: "Stream bursty output for word fade verification.",
});
try {
await awaitAssistantMessage(page);
await provide(page);
} finally {
await agent.cleanup();
}
},
});
export async function expectContinuousWordFade(page: Page, testInfo: TestInfo): Promise<void> {
const report = await sampleWordFades(page);
await testInfo.attach("word-fade-observations", {
body: JSON.stringify(report),
contentType: "application/json",
});
expect(report.directionalFades).toBeGreaterThan(10);
expect(report.reverseFades).toBe(0);
expect(report.boundaryReversals).toBe(0);
expect(report.maxWidthDelta).toBeLessThan(0.5);
expect(report.maxActiveLetters).toBeLessThan(500);
}
export async function sampleWordFades(page: Page) {
return page.evaluate(async () => {
const started = performance.now();
let directionalFades = 0;
let reverseFades = 0;
let boundaryReversals = 0;
let maxBoundaryReversal = 0;
let maxWidthDelta = 0;
let maxActiveLetters = 0;
while (performance.now() - started < 4_000) {
const words = Array.from(document.querySelectorAll<HTMLElement>("[data-word-fade]"));
maxActiveLetters = Math.max(
maxActiveLetters,
words.reduce((sum, word) => sum + word.children.length, 0),
);
// One front across the whole line: a word's first letter is never more
// opaque than the last letter of the fading word before it on that line.
let previous: { top: number; opacity: number } | null = null;
for (const word of words) {
const letters = Array.from(word.children);
const rect = word.getBoundingClientRect();
const first = Number(getComputedStyle(letters[0]!).opacity);
const last = Number(getComputedStyle(letters.at(-1)!).opacity);
if (previous && Math.abs(previous.top - rect.top) < 1 && first > previous.opacity + 0.02) {
boundaryReversals++;
maxBoundaryReversal = Math.max(maxBoundaryReversal, first - previous.opacity);
}
previous = { top: rect.top, opacity: last };
if (letters.length < 3) continue;
if (first > last) directionalFades++;
if (last > first) reverseFades++;
if (word.getClientRects().length !== 1) continue;
// Compare shaped text with the same word without transient spans.
const plain = document.createElement("span");
plain.textContent = word.textContent;
Object.assign(plain.style, {
position: "absolute",
whiteSpace: "pre",
visibility: "hidden",
});
word.parentElement!.appendChild(plain);
maxWidthDelta = Math.max(
maxWidthDelta,
Math.abs(plain.getBoundingClientRect().width - word.getBoundingClientRect().width),
);
plain.remove();
}
await new Promise(requestAnimationFrame);
}
return {
directionalFades,
reverseFades,
boundaryReversals,
maxBoundaryReversal,
maxWidthDelta,
maxActiveLetters,
};
});
}
export async function expectSettledSelectableText(page: Page): Promise<void> {
await expect(page.locator("[data-word-fade]")).toHaveCount(0);
const result = await page
.getByTestId("assistant-message")
.first()
.evaluate((message) => {
const range = document.createRange();
range.selectNodeContents(message);
const selection = window.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
const selectedText = { expected: message.textContent, selected: selection.toString() };
selection.removeAllRanges();
return selectedText;
});
expect(result.selected.length).toBeGreaterThan(0);
expect(result.selected.replace(/\s/g, "")).toBe(result.expected!.replace(/\s/g, ""));
}
@@ -0,0 +1,63 @@
import type { Page } from "@playwright/test";
import { z } from "zod";
import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage";
import {
buildWorkspaceTabPersistenceKey,
type WorkspaceTab,
} from "../../../src/workspace-tabs/model";
import { getServerId } from "./server-id";
/** Seed the reported hidden Explorer-only layout with 2,250 saved agent draft tabs. */
export async function seedCorruptedWorkspaceLayout(page: Page, workspaceId: string): Promise<void> {
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
if (raw === null) throw new Error("Explorer fixture: workspace layout was not persisted");
const stored = z
.strictObject({
state: WorkspaceLayoutPersistedStateSchema,
version: z.number().int().nonnegative().optional(),
})
.parse(JSON.parse(raw), {
error: () => "Explorer fixture: invalid persisted workspace layout",
});
const key = buildWorkspaceTabPersistenceKey({ serverId: getServerId(), workspaceId });
if (!key || !Object.hasOwn(stored.state.layoutByWorkspace, key)) {
throw new Error(`Explorer fixture: no persisted layout for workspace ${workspaceId}`);
}
const tabs: WorkspaceTab[] = [
{ tabId: "files", target: { kind: "files" }, createdAt: 1 },
{ tabId: "changes_tree", target: { kind: "changes_tree" }, createdAt: 1 },
...Array.from(
{ length: 2250 },
(_, index): WorkspaceTab => ({
tabId: `draft_saved_${index}`,
target: { kind: "draft", draftId: `draft_saved_${index}` },
createdAt: index + 2,
}),
),
];
const corruptedLayout = {
root: {
kind: "pane",
pane: {
id: "pane_generated_report_equivalent",
hidden: true,
tabIds: tabs.map((tab) => tab.tabId),
tabs,
focusedTabId: tabs[tabs.length - 1].tabId,
},
},
focusedPaneId: null,
};
stored.state = WorkspaceLayoutPersistedStateSchema.parse({
...stored.state,
layoutByWorkspace: { ...stored.state.layoutByWorkspace, [key]: corruptedLayout },
explorerPaneIdByWorkspace: {
...stored.state.explorerPaneIdByWorkspace,
[key]: "pane_generated_report_equivalent",
},
});
await page.evaluate(
(value) => localStorage.setItem("workspace-layout-state", value),
JSON.stringify(stored),
);
}
@@ -20,6 +20,7 @@ type WorkspaceSetupDaemonClient = Pick<
| "fetchAgents"
| "fetchWorkspaces"
| "listTerminals"
| "observeEvents"
| "removeProject"
| "subscribeRawMessages"
>;
@@ -35,6 +36,13 @@ export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemo
const client = await connectDaemonClient<WorkspaceSetupDaemonClient>({
clientIdPrefix: "workspace-setup",
});
// Establish demand before a caller can launch setup. Client close releases it.
try {
await client.observeEvents(["workspace_setup_progress"]).ready;
} catch (error) {
await client.close();
throw error;
}
return withProjectOwnership(client);
}
@@ -95,19 +95,18 @@ export async function expectReconnectingToastVisible(
page: Page,
options?: { timeout?: number },
): Promise<void> {
const toast = page.getByTestId("agent-reconnecting-toast");
const toast = page.getByRole("alert").filter({ hasText: "Reconnecting to host" });
await expect(toast).toBeVisible({
timeout: options?.timeout ?? 30_000,
});
await expect(toast).toHaveText("Reconnecting");
await expect(toast.getByTestId("agent-reconnecting-status-dot")).toBeVisible();
await expect(toast).toHaveText("Reconnecting to host");
}
export async function expectReconnectingToastGone(
page: Page,
options?: { timeout?: number },
): Promise<void> {
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, {
await expect(page.getByRole("alert").filter({ hasText: "Reconnecting to host" })).toHaveCount(0, {
timeout: options?.timeout ?? 30_000,
});
}
@@ -18,12 +18,11 @@ export interface TempDirectory {
}
/**
* The temp root for E2E fixtures. On macOS we resolve symlinks (/tmp →
* /private/tmp) so fixture paths match the daemon's resolved paths; on Windows
* `/tmp` doesn't exist, so fall back to the OS temp dir.
* Honor the OS temp directory (including TMPDIR), resolving symlinks so fixture
* paths match the daemon's resolved paths.
*/
export async function resolveTempRoot(): Promise<string> {
return process.platform === "win32" ? tmpdir() : await realpath("/tmp");
return await realpath(tmpdir());
}
async function configureRemote(input: {
+26
View File
@@ -0,0 +1,26 @@
/** @param {number} port */
export async function warmMetro(port) {
const origin = `http://127.0.0.1:${port}`;
const documentResponse = await fetch(origin, { signal: AbortSignal.timeout(120_000) });
if (!documentResponse.ok) {
throw new Error(`Metro document warmup failed with HTTP ${documentResponse.status}`);
}
const document = await documentResponse.text();
const scriptSources = [...document.matchAll(/<script[^>]+src=["']([^"']+)["']/g)].map(
(match) => match[1],
);
if (scriptSources.length === 0) {
throw new Error("Metro document warmup found no scripts to compile");
}
for (const source of scriptSources) {
const scriptUrl = new URL(source, origin);
if (scriptUrl.origin !== origin) continue;
const response = await fetch(scriptUrl, { signal: AbortSignal.timeout(120_000) });
if (!response.ok) {
throw new Error(
`Metro bundle warmup failed for ${scriptUrl.pathname}: HTTP ${response.status}`,
);
}
await response.arrayBuffer();
}
}
+55
View File
@@ -0,0 +1,55 @@
// Test-only entry: the production recovery boundary and Expo router with failing routes.
import "../../src/styles/unistyles";
// oxlint-disable-next-line import/no-unassigned-import -- Match the Metro web entry runtime.
import "@expo/metro-runtime";
import { registerRootComponent } from "expo";
import { Link, Redirect, Stack, type Href } from "expo-router";
import { useCallback, useState, type ComponentProps } from "react";
import { Button, Text, View } from "react-native";
import { RootRouter } from "../../src/root-app";
const screenOptions = { headerShown: false };
function Layout() {
return <Stack screenOptions={screenOptions} />;
}
// Fixture routes are absent from Expo's generated production route types.
function Startup() {
return <Redirect href={"/broken" as Href} />;
}
function BrokenWorkspace(): never {
throw new Error("Workspace recovery fixture");
}
function Picker() {
return (
<View>
<Text>Project picker</Text>
<Link href={"/healthy" as Href}>Open healthy workspace</Link>
</View>
);
}
function HealthyWorkspace() {
const [broken, setBroken] = useState(false);
const breakWorkspace = useCallback(() => setBroken(true), []);
if (broken) return <BrokenWorkspace />;
return (
<View>
<Text>Healthy workspace</Text>
<Button title="Break this workspace" onPress={breakWorkspace} />
</View>
);
}
const screens = {
"./_layout.tsx": { default: Layout },
"./index.tsx": { default: Startup },
"./broken.tsx": { default: BrokenWorkspace },
"./open-project.tsx": { default: Picker },
"./healthy.tsx": { default: HealthyWorkspace },
};
const context: ComponentProps<typeof RootRouter>["context"] = Object.assign(
(key: string) => screens[key as keyof typeof screens],
{ keys: () => Object.keys(screens), resolve: (key: string) => key, id: "recovery-fixture" },
);
function RecoveryApp() {
return <RootRouter context={context} />;
}
registerRootComponent(RecoveryApp);
+8 -1
View File
@@ -8,4 +8,11 @@ polyfillScreenOrientation();
// Configure Unistyles before Expo Router pulls in any components using StyleSheet.
import "./src/styles/unistyles";
import "expo-router/entry";
// oxlint-disable-next-line import/no-unassigned-import -- Preserve Expo's entry side effects.
import "@expo/metro-runtime";
// oxlint-disable-next-line import/no-unassigned-import -- Preserve Expo's entry side effects.
import "expo-router/build/fast-refresh";
import { renderRootComponent } from "expo-router/build/renderRootComponent";
import { RootApp } from "./src/root-app";
renderRootComponent(RootApp);
@@ -0,0 +1,2 @@
/android/.cxx/
/android/build/
@@ -0,0 +1,35 @@
# Native diff layout experiment
Android-only Expo module for evaluating background wrapped-text layout. The normal
diff surface does not call this module; it still uses the JavaScript model and
renderer. This is a checkpoint of the layout experiment, not a replacement renderer.
The C++ core owns cell text and wrapped grapheme geometry. Kotlin/JNI supplies the
Android font manager and dispatches preparation through Expo's background queue.
It links the Skia libraries shipped by the installed React Native Skia package.
An iOS binding, renderer integration, and shipping library ownership are unfinished.
## Diagnostic interface
Access the module through `expo.modules.PaseoDiffPrototype` in a development build:
- `create()` returns a numeric handle.
- `prepare(handle, texts, widths, family, fontSize)` asynchronously builds geometry
and returns counts, copy/layout timings, and a partial geometry allocation estimate.
Text and width arrays must have matching lengths. Input is copied; it is not zero-copy.
- `read(handle, startCell, count)` synchronously returns packed numerical geometry
for at most 256 cells. This exists for parity checks, not whole-model transfer.
- `release(handle)` cancels pending work and releases ownership. Release handles
after success or failure; module destruction also cancels and releases all handles.
Each packed cell begins with its index and fragment count. Each fragment contains
UTF-16 start/end offsets, width, and grapheme count, followed by each grapheme's
UTF-16 start/end offsets and width. The caller retains source text.
The experiment accepts normalized cell inputs. It does not parse wire payloads,
construct file/header/review models, apply highlighting, or draw pixels. A renderer
integration must preserve native ownership and request bounded visible data rather
than reconstructing the entire JavaScript graph.
Grapheme property ranges are generated at build time from the installed JavaScript
dependency so segmentation uses the same data as the reference implementation.
@@ -0,0 +1,15 @@
cmake_minimum_required(VERSION 3.22)
project(paseo_diff_prototype)
set(CMAKE_CXX_STANDARD 20)
execute_process(COMMAND node "${CMAKE_CURRENT_SOURCE_DIR}/../cpp/generate-graphemes.mjs" "${CMAKE_CURRENT_BINARY_DIR}/graphemes.h" COMMAND_ERROR_IS_FATAL ANY)
add_library(paseo_diff_prototype SHARED ../cpp/layout.cpp ../cpp/jni.cpp)
target_include_directories(paseo_diff_prototype PRIVATE "${SKIA_PACKAGE}/cpp/skia" "${CMAKE_CURRENT_BINARY_DIR}")
target_compile_definitions(paseo_diff_prototype PRIVATE SK_BUILD_FOR_ANDROID)
target_compile_options(paseo_diff_prototype PRIVATE -O2 -Wall -Wextra)
target_link_options(paseo_diff_prototype PRIVATE "-Wl,-z,max-page-size=16384")
foreach(lib skparagraph skshaper skunicode_core skunicode_icu skia)
add_library(${lib} STATIC IMPORTED)
set_target_properties(${lib} PROPERTIES IMPORTED_LOCATION "${SKIA_PACKAGE}/libs/android/${ANDROID_ABI}/lib${lib}.a")
endforeach()
target_link_libraries(paseo_diff_prototype PRIVATE skparagraph skshaper skunicode_core skunicode_icu skia android log EGL GLESv2)
@@ -0,0 +1,27 @@
apply plugin: 'com.android.library'
group = 'sh.paseo.diffprototype'
version = '0.1.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useCoreDependencies()
useDefaultAndroidSdkVersions()
def skiaPackage = new File(["node", "--print", "require.resolve('@shopify/react-native-skia/package.json')"].execute(null, projectDir).text.trim()).parentFile
android {
namespace "sh.paseo.diffprototype"
defaultConfig {
externalNativeBuild {
cmake {
arguments "-DSKIA_PACKAGE=${skiaPackage.absolutePath}"
cppFlags "-std=c++20 -O2 -fexceptions -frtti"
}
}
}
externalNativeBuild {
cmake { path "CMakeLists.txt" }
}
}
@@ -0,0 +1 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,34 @@
package sh.paseo.diffprototype
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
// Experimental binding only. The layout engine has no JVM or platform dependency.
class PaseoDiffPrototypeModule : Module() {
companion object {
init { System.loadLibrary("paseo_diff_prototype") }
}
private external fun createDocument(): Double
private external fun prepareDocument(id: Double, texts: Array<String>, widths: DoubleArray, family: String, size: Double): DoubleArray
private external fun readDocument(id: Double, start: Int, count: Int): DoubleArray
private external fun releaseDocument(id: Double)
private external fun releaseAllDocuments()
override fun definition() = ModuleDefinition {
Name("PaseoDiffPrototype")
Function("create") { createDocument() }
// Expo dispatches this non-suspending AsyncFunction on its background queue.
AsyncFunction("prepare") { id: Double, texts: List<String>, widths: List<Double>, family: String, size: Double ->
val stats = prepareDocument(id, texts.toTypedArray(), widths.toDoubleArray(), family, size)
mapOf(
"cells" to stats[0], "fragments" to stats[1], "graphemes" to stats[2],
"copyMs" to stats[3], "layoutMs" to stats[4], "geometryBytes" to stats[5]
)
}
Function("read") { id: Double, start: Int, count: Int -> readDocument(id, start, count) }
Function("release") { id: Double -> releaseDocument(id) }
OnDestroy { releaseAllDocuments() }
}
}
@@ -0,0 +1,18 @@
import { writeFileSync } from "node:fs";
import { isExtendingChar } from "@marijn/find-cluster-break";
// Use the installed JS engine's Unicode property data, not a different ICU version.
const ranges = [];
let start = null;
for (let code = 0; code <= 0x110000; code++) {
const extending = code < 0x110000 && isExtendingChar(code);
if (extending && start === null) start = code;
if (!extending && start !== null) {
ranges.push(`{${start},${code}}`);
start = null;
}
}
writeFileSync(
process.argv[2],
`#pragma once\n#include <array>\n#include <utility>\ninline constexpr std::array<std::pair<int,int>,${ranges.length}> extendingRanges = {{${ranges.join(",")}}};\n`,
);
@@ -0,0 +1,136 @@
#include "layout.h"
#include <jni.h>
#include <chrono>
#include <cmath>
#include <memory>
#include <mutex>
#include <stdexcept>
#include <unordered_map>
#include "include/ports/SkFontMgr_android.h"
#include "modules/skunicode/include/SkUnicode.h"
namespace {
using Clock = std::chrono::steady_clock;
using paseo::diff::Document;
struct Job {
std::atomic<bool> cancelled{false};
std::atomic<bool> started{false};
std::shared_ptr<const Document> document;
};
std::mutex mutex;
std::unordered_map<uint64_t, std::shared_ptr<Job>> jobs;
uint64_t nextId = 0;
double elapsed(Clock::time_point start) {
return std::chrono::duration<double, std::milli>(Clock::now() - start).count();
}
std::shared_ptr<Job> findJob(double id) {
std::lock_guard lock(mutex);
const auto found = jobs.find(static_cast<uint64_t>(id));
if (found == jobs.end()) throw std::runtime_error("Unknown or released layout");
return found->second;
}
std::u16string copyString(JNIEnv* env, jstring source) {
if (!source) throw std::runtime_error("Missing text");
const jsize length = env->GetStringLength(source);
const jchar* chars = env->GetStringChars(source, nullptr);
if (!chars) throw std::runtime_error("Could not copy text");
std::u16string result(reinterpret_cast<const char16_t*>(chars), length);
env->ReleaseStringChars(source, chars);
return result;
}
jdoubleArray numbers(JNIEnv* env, const std::vector<double>& values) {
auto result = env->NewDoubleArray(values.size());
if (result) env->SetDoubleArrayRegion(result, 0, values.size(), values.data());
return result;
}
void fail(JNIEnv* env, const std::exception& error) {
env->ThrowNew(env->FindClass("java/lang/IllegalStateException"), error.what());
}
} // namespace
#define METHOD(name) Java_sh_paseo_diffprototype_PaseoDiffPrototypeModule_##name
extern "C" JNIEXPORT jdouble JNICALL METHOD(createDocument)(JNIEnv*, jobject) {
std::lock_guard lock(mutex);
const auto id = ++nextId;
jobs.emplace(id, std::make_shared<Job>());
return id;
}
extern "C" JNIEXPORT jdoubleArray JNICALL METHOD(prepareDocument)(
JNIEnv* env, jobject, jdouble id, jobjectArray texts, jdoubleArray widths,
jstring family, jdouble size) {
try {
auto job = findJob(id);
if (job->started.exchange(true)) throw std::runtime_error("Layout already started");
const auto copyStart = Clock::now();
const jsize count = env->GetArrayLength(texts);
if (count > 100000 || env->GetArrayLength(widths) != count || !std::isfinite(size) || size <= 0 || size > 100)
throw std::runtime_error("Invalid layout input");
std::vector<double> available(count);
env->GetDoubleArrayRegion(widths, 0, count, available.data());
auto document = std::make_shared<Document>();
document->cells.reserve(count);
size_t units = 0;
for (jsize i = 0; i < count; i++) {
if (job->cancelled.load()) throw std::runtime_error("Layout cancelled");
if (!std::isfinite(available[i]) || available[i] <= 0) throw std::runtime_error("Invalid width");
auto text = static_cast<jstring>(env->GetObjectArrayElement(texts, i));
auto copied = copyString(env, text);
env->DeleteLocalRef(text);
units += copied.size();
if (units > 16000000) throw std::runtime_error("Prototype input limit exceeded");
document->cells.push_back({std::move(copied), available[i], {}});
}
const auto fontFamily = SkUnicode::convertUtf16ToUtf8(copyString(env, family));
const double copyMs = elapsed(copyStart);
const auto layoutStart = Clock::now();
paseo::diff::prepare(*document, SkFontMgr_New_Android(nullptr), fontFamily.c_str(), size, job->cancelled);
const double layoutMs = elapsed(layoutStart);
{
std::lock_guard lock(mutex);
if (job->cancelled.load()) throw std::runtime_error("Layout cancelled");
job->document = document;
}
return numbers(env, {static_cast<double>(document->cells.size()),
static_cast<double>(document->fragmentCount), static_cast<double>(document->graphemeCount),
copyMs, layoutMs, static_cast<double>(document->geometryBytes)});
} catch (const std::exception& error) {
fail(env, error);
return nullptr;
}
}
extern "C" JNIEXPORT jdoubleArray JNICALL METHOD(readDocument)(
JNIEnv* env, jobject, jdouble id, jint start, jint count) {
try {
const auto job = findJob(id);
std::shared_ptr<const Document> document;
{
std::lock_guard lock(mutex);
document = job->document;
}
if (!document) throw std::runtime_error("Layout is not ready");
if (start < 0 || count < 0 || count > 256 || static_cast<size_t>(start) > document->cells.size())
throw std::runtime_error("Invalid viewport range");
return numbers(env, paseo::diff::read(*document, start, count));
} catch (const std::exception& error) {
fail(env, error);
return nullptr;
}
}
extern "C" JNIEXPORT void JNICALL METHOD(releaseDocument)(JNIEnv*, jobject, jdouble id) {
std::lock_guard lock(mutex);
const auto found = jobs.find(static_cast<uint64_t>(id));
if (found == jobs.end()) return;
found->second->cancelled.store(true);
jobs.erase(found);
}
extern "C" JNIEXPORT void JNICALL METHOD(releaseAllDocuments)(JNIEnv*, jobject) {
std::lock_guard lock(mutex);
for (auto& [id, job] : jobs) job->cancelled.store(true);
jobs.clear();
}

Some files were not shown because too many files have changed in this diff Show More