mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
chore(chat): merge main while preserving stacked find features
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
+55
-1
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,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" />
|
||||
+34
@@ -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
Reference in New Issue
Block a user