* fix(app): validate persisted client state Persisted replica data could satisfy TypeScript through an assertion while missing fields required by renderers. Treat every AsyncStorage value as untrusted input, clear values that fail strict schemas, and version the replica DTO so incompatible caches cannot hydrate. * fix(app): preserve validated legacy state * fix(app): preserve validated preferences Preserve supported legacy location fields and every shipped theme while keeping unknown persisted fields rejected. Align browser test seeds with the current preference shape so strict validation exercises the intended provider. * fix(app): migrate legacy agent preferences Released clients stored host/location fields and per-provider thinkingOptionId together. Migrate those exact strict bytes into current preferences without admitting unknown fields. * fix(app): preserve drafts with legacy reviews Accept the exact strict shape released for workspace review attachments so migration can discard that obsolete attachment without clearing every persisted draft.
290 lines
10 KiB
TypeScript
290 lines
10 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { expect, type Page } from "@playwright/test";
|
|
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
|
import { getE2EDaemonPort } from "./daemon-port";
|
|
import { getServerId } from "./server-id";
|
|
import { expectAppRoute } from "./route-assertions";
|
|
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
|
|
import {
|
|
buildHostAgentDetailRoute,
|
|
buildHostWorkspaceRoute,
|
|
buildSessionsRoute,
|
|
} from "@/utils/host-routes";
|
|
|
|
export interface ArchiveTabAgent {
|
|
id: string;
|
|
title: string;
|
|
cwd: string;
|
|
workspaceId: string;
|
|
}
|
|
|
|
function buildSeededStoragePayload() {
|
|
const nowIso = new Date().toISOString();
|
|
return {
|
|
daemon: buildSeededHost({
|
|
serverId: getServerId(),
|
|
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
|
nowIso,
|
|
}),
|
|
preferences: buildCreateAgentPreferences(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The slice of a daemon client `createIdleAgent` needs: spawn an agent and await
|
|
* its idle upsert. The shared seed client satisfies it, so a spec can seed an
|
|
* idle agent from the same client it uses for everything else.
|
|
*/
|
|
export interface IdleAgentSeedClient {
|
|
createAgent(options: {
|
|
provider: string;
|
|
model: string;
|
|
modeId: string;
|
|
featureValues?: Record<string, unknown>;
|
|
cwd: string;
|
|
workspaceId: string;
|
|
title: string;
|
|
}): Promise<{ id: string }>;
|
|
waitForAgentUpsert(
|
|
agentId: string,
|
|
predicate: (snapshot: { status: string }) => boolean,
|
|
timeout?: number,
|
|
): Promise<{ status: string }>;
|
|
}
|
|
|
|
export async function createIdleAgent(
|
|
client: IdleAgentSeedClient,
|
|
input: { cwd: string; workspaceId: string; title: string },
|
|
): Promise<ArchiveTabAgent> {
|
|
const created = await client.createAgent({
|
|
provider: "opencode",
|
|
model: "opencode/gpt-5-nano",
|
|
// OpenCode has no "bypassPermissions" mode (that's Claude's). Use build with
|
|
// auto_accept for unattended full access — mode validation now rejects modes
|
|
// the provider doesn't define.
|
|
modeId: "build",
|
|
featureValues: { auto_accept: true },
|
|
cwd: input.cwd,
|
|
workspaceId: input.workspaceId,
|
|
title: input.title,
|
|
});
|
|
const snapshot = await client.waitForAgentUpsert(
|
|
created.id,
|
|
(agent) => agent.status === "idle",
|
|
30_000,
|
|
);
|
|
if (snapshot.status !== "idle") {
|
|
throw new Error(`Expected agent ${created.id} to become idle, got ${snapshot.status}.`);
|
|
}
|
|
return {
|
|
id: created.id,
|
|
title: input.title,
|
|
cwd: input.cwd,
|
|
workspaceId: input.workspaceId,
|
|
};
|
|
}
|
|
|
|
export async function archiveAgentFromDaemon(
|
|
client: { archiveAgent(agentId: string): Promise<{ archivedAt: string }> },
|
|
agentId: string,
|
|
): Promise<void> {
|
|
await client.archiveAgent(agentId);
|
|
}
|
|
|
|
export async function fetchAgentArchivedAt(
|
|
client: {
|
|
fetchAgent(options: {
|
|
agentId: string;
|
|
}): Promise<{ agent: { archivedAt?: string | null } } | null>;
|
|
},
|
|
agentId: string,
|
|
): Promise<string | null> {
|
|
const result = await client.fetchAgent({ agentId });
|
|
return result?.agent.archivedAt ?? null;
|
|
}
|
|
|
|
export async function primeAdditionalPage(page: Page): Promise<void> {
|
|
const seedNonce = randomUUID();
|
|
const { daemon, preferences } = buildSeededStoragePayload();
|
|
|
|
await page.route(/:(6767)\b/, (route) => route.abort());
|
|
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
|
|
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
|
});
|
|
await page.addInitScript(
|
|
({ daemon: seededDaemon, preferences: seededPreferences, seedNonce: nonce }) => {
|
|
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
|
|
const disableValue = localStorage.getItem(disableOnceKey);
|
|
if (disableValue) {
|
|
localStorage.removeItem(disableOnceKey);
|
|
if (disableValue === nonce) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
localStorage.setItem("@paseo:e2e", "1");
|
|
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
|
|
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
|
|
localStorage.removeItem("@paseo:settings");
|
|
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
|
|
},
|
|
{ daemon, preferences, seedNonce },
|
|
);
|
|
await page.goto("/");
|
|
}
|
|
|
|
export async function resetSeededPageState(page: Page): Promise<void> {
|
|
const { daemon, preferences } = buildSeededStoragePayload();
|
|
await page.goto("/");
|
|
await page.evaluate(
|
|
({ daemon: seededDaemon, preferences: seededPreferences }) => {
|
|
localStorage.clear();
|
|
localStorage.setItem("@paseo:e2e", "1");
|
|
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
|
|
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
|
|
localStorage.removeItem("@paseo:settings");
|
|
},
|
|
{ daemon, preferences },
|
|
);
|
|
await page.goto("/");
|
|
}
|
|
|
|
export async function openWorkspaceWithAgents(
|
|
page: Page,
|
|
agents: [ArchiveTabAgent, ArchiveTabAgent],
|
|
): Promise<void> {
|
|
const serverId = getServerId();
|
|
for (const agent of agents) {
|
|
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
|
|
|
|
// The workspace layout consumes `?open=agent:xxx`, returns null during the effect,
|
|
// then replaces the URL with the clean workspace route after preparing the tab.
|
|
// On CI, Expo Router's rootNavigationState may take time to initialize,
|
|
// so we allow a generous timeout here (matching terminal-perf pattern).
|
|
await page.waitForURL(
|
|
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
|
{ timeout: 60_000 },
|
|
);
|
|
|
|
await waitForWorkspaceTabsVisible(page);
|
|
await expectWorkspaceTabVisible(page, agent.id);
|
|
}
|
|
}
|
|
|
|
export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise<void> {
|
|
await expect(
|
|
page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).first(),
|
|
).toBeVisible({ timeout: 30_000 });
|
|
}
|
|
|
|
export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise<void> {
|
|
await expect(
|
|
page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }),
|
|
).toHaveCount(0, {
|
|
timeout: 30_000,
|
|
});
|
|
}
|
|
|
|
export async function expectWorkspaceArchiveOutcome(
|
|
page: Page,
|
|
input: { archivedAgentId: string; survivingAgentId: string },
|
|
): Promise<void> {
|
|
await expectWorkspaceTabHidden(page, input.archivedAgentId);
|
|
await expectWorkspaceTabVisible(page, input.survivingAgentId);
|
|
}
|
|
|
|
export async function closeWorkspaceAgentTab(page: Page, agentId: string): Promise<void> {
|
|
const closeButton = page.getByTestId(`workspace-agent-close-${agentId}`).filter({
|
|
visible: true,
|
|
});
|
|
await expect(closeButton.first()).toBeVisible({ timeout: 30_000 });
|
|
await closeButton.first().click();
|
|
await expectWorkspaceTabHidden(page, agentId);
|
|
}
|
|
|
|
export async function expectArchivedAgentFocused(page: Page, agentId: string): Promise<void> {
|
|
await expectWorkspaceTabVisible(page, agentId);
|
|
await expect(
|
|
page.getByText("This agent is archived").filter({ visible: true }).first(),
|
|
).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
}
|
|
|
|
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
|
|
const serverId = getServerId();
|
|
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
|
|
await waitForWorkspaceTabsVisible(page);
|
|
}
|
|
|
|
export async function openSessions(page: Page): Promise<void> {
|
|
const sessionsButton = page.getByTestId("sidebar-sessions");
|
|
await expect(sessionsButton).toBeVisible({ timeout: 30_000 });
|
|
await sessionsButton.click();
|
|
await expectAppRoute(page, buildSessionsRoute(), { timeout: 30_000 });
|
|
await expect(page.getByText("History", { exact: true }).last()).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
}
|
|
|
|
const AGENT_ROW_SELECTOR = '[data-testid^="agent-row-"]';
|
|
|
|
function getSessionRowByTitle(page: Page, title: string) {
|
|
return page.locator(AGENT_ROW_SELECTOR).filter({ hasText: title }).first();
|
|
}
|
|
|
|
export async function expectSessionRowVisible(page: Page, title: string): Promise<void> {
|
|
await expect(getSessionRowByTitle(page, title)).toBeVisible({ timeout: 30_000 });
|
|
}
|
|
|
|
export async function expectSessionRowArchived(page: Page, title: string): Promise<void> {
|
|
await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 });
|
|
}
|
|
|
|
export async function expectSessionRowNotArchived(page: Page, title: string): Promise<void> {
|
|
await expect(getSessionRowByTitle(page, title)).not.toContainText("Archived", {
|
|
timeout: 30_000,
|
|
});
|
|
}
|
|
|
|
export async function clickSessionRow(page: Page, title: string): Promise<void> {
|
|
const row = getSessionRowByTitle(page, title);
|
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
await row.click();
|
|
}
|
|
|
|
export async function expectSessionsEmptyState(page: Page): Promise<void> {
|
|
// Guard: the sessions-empty spec owns a pristine daemon, so this helper only
|
|
// needs to distinguish an empty result from the expected seeded rows.
|
|
await expect(page.locator(AGENT_ROW_SELECTOR)).toHaveCount(0, { timeout: 5_000 });
|
|
await expect(page.getByText("No sessions yet")).toBeVisible({ timeout: 30_000 });
|
|
}
|
|
|
|
export async function archiveAgentFromSessions(
|
|
page: Page,
|
|
input: { agentId: string; title: string },
|
|
): Promise<void> {
|
|
const row = getSessionRowByTitle(page, input.title);
|
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
const box = await row.boundingBox();
|
|
if (!box) {
|
|
throw new Error(`Could not read bounding box for session row ${input.agentId}.`);
|
|
}
|
|
|
|
// Long-press the row. Idle agents are archived immediately (no modal).
|
|
// Running/initializing agents show a confirmation modal instead.
|
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
|
await page.mouse.down();
|
|
await page.waitForTimeout(900);
|
|
await page.mouse.up();
|
|
|
|
// If a confirmation modal appears (running agent), click the archive button.
|
|
const archiveButton = page.getByTestId("agent-action-archive").first();
|
|
const modalVisible = await archiveButton.isVisible().catch(() => false);
|
|
if (modalVisible) {
|
|
await archiveButton.click();
|
|
}
|
|
|
|
await expectSessionRowArchived(page, input.title);
|
|
}
|