mirror of
https://github.com/getpaseo/paseo.git
synced 2026-08-14 12:23:16 +00:00
* test(app/e2e): cover sessions-screen empty state (Cluster G7) Adds one E2E test — opens Sessions on a fresh workspace with no agents and asserts the "No sessions yet" placeholder renders. Uses `withWorkspace` fixture (no agent seeding) so the empty branch runs for the first time. Also exports `expectSessionsEmptyState` helper from archive-tab helpers for reuse in future session-related specs. * fix(app/e2e): run sessions-empty test before archive-tab agents are created The sessions screen shows global agent history for the daemon. Running the empty-state test last meant the reconciliation tests had already created 6 agents, so "No sessions yet" never rendered. Moving the describe block first ensures it runs on a clean daemon (workers:1, fullyParallel:false, archive-tab.spec.ts is first alphabetically). * refactor(app/e2e): guard sessions-empty ordering + dedup selector constant Addresses reviewer feedback on the ordering fragility: - Add NOTE comment above Sessions screen empty state describe explaining why it must remain first in the file (daemon history is global; the reconciliation tests below call createIdleAgent which would pollute it). - Add a fast-fail guard in expectSessionsEmptyState: asserts 0 agent rows with a 5s timeout so a future maintainer sees an immediately actionable failure message rather than a mysterious "No sessions yet" timeout. - Extract AGENT_ROW_SELECTOR constant to eliminate the duplicated [data-testid^="agent-row-"] string shared by getSessionRowByTitle and the new guard. * fix(app/e2e): move sessions-empty to 00-prefixed file to survive new specs agent-stream-ui.spec.ts (merged in #743) sorts before archive-tab.spec.ts and creates agents, breaking the empty-state test. Any future a*-*.spec.ts has the same risk. Fix: move the test to 00-sessions-empty.spec.ts — digit prefix sorts before all alpha-named specs, making the ordering constraint explicit at the filesystem level. Also adds a beforeAll daemon probe that fails fast with a clear message if any pre-existing agents are found, covering both ordering violations and stale daemon state from a previous run. Supporting changes: - Add fetchAgentHistory to ArchiveTabDaemonClient interface (typed as Array<{ id: string }> — enough for the count check) - Remove the test and NOTE comment from archive-tab.spec.ts - Update expectSessionsEmptyState guard comment to reference the new file
324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { expect, type Page } from "@playwright/test";
|
|
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
|
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
|
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
|
|
import {
|
|
buildHostAgentDetailRoute,
|
|
buildHostSessionsRoute,
|
|
buildHostWorkspaceRoute,
|
|
} from "@/utils/host-routes";
|
|
|
|
export interface ArchiveTabAgent {
|
|
id: string;
|
|
title: string;
|
|
cwd: string;
|
|
}
|
|
|
|
interface ArchiveTabDaemonClient {
|
|
connect(): Promise<void>;
|
|
close(): Promise<void>;
|
|
createAgent(options: {
|
|
provider: string;
|
|
model: string;
|
|
thinkingOptionId?: string;
|
|
modeId: string;
|
|
cwd: string;
|
|
title: string;
|
|
initialPrompt?: string;
|
|
}): Promise<{ id: string }>;
|
|
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
|
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
|
|
waitForAgentUpsert(
|
|
agentId: string,
|
|
predicate: (snapshot: { status: string }) => boolean,
|
|
timeout?: number,
|
|
): Promise<{ status: string }>;
|
|
fetchAgentHistory(options?: {
|
|
page?: { limit: number };
|
|
}): Promise<{ entries: Array<{ id: string }> }>;
|
|
}
|
|
|
|
function getDaemonPort(): string {
|
|
const daemonPort = process.env.E2E_DAEMON_PORT;
|
|
if (!daemonPort) {
|
|
throw new Error("E2E_DAEMON_PORT is not set.");
|
|
}
|
|
if (daemonPort === "6767") {
|
|
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
|
|
}
|
|
return daemonPort;
|
|
}
|
|
|
|
function getServerId(): string {
|
|
const serverId = process.env.E2E_SERVER_ID;
|
|
if (!serverId) {
|
|
throw new Error("E2E_SERVER_ID is not set.");
|
|
}
|
|
return serverId;
|
|
}
|
|
|
|
function getDaemonWsUrl(): string {
|
|
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
|
|
}
|
|
|
|
function buildSeededStoragePayload() {
|
|
const nowIso = new Date().toISOString();
|
|
return {
|
|
daemon: buildSeededHost({
|
|
serverId: getServerId(),
|
|
endpoint: `127.0.0.1:${getDaemonPort()}`,
|
|
nowIso,
|
|
}),
|
|
preferences: buildCreateAgentPreferences(getServerId()),
|
|
};
|
|
}
|
|
|
|
interface ArchiveTabDaemonClientConfig {
|
|
url: string;
|
|
clientId: string;
|
|
clientType: "cli";
|
|
webSocketFactory?: NodeWebSocketFactory;
|
|
}
|
|
|
|
async function loadDaemonClientConstructor(): Promise<
|
|
new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient
|
|
> {
|
|
const repoRoot = path.resolve(__dirname, "../../../../");
|
|
const moduleUrl = pathToFileURL(
|
|
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
|
).href;
|
|
const mod = (await import(moduleUrl)) as {
|
|
DaemonClient: new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient;
|
|
};
|
|
return mod.DaemonClient;
|
|
}
|
|
|
|
export async function connectArchiveTabDaemonClient(): Promise<ArchiveTabDaemonClient> {
|
|
const DaemonClient = await loadDaemonClientConstructor();
|
|
const webSocketFactory = createNodeWebSocketFactory();
|
|
const client = new DaemonClient({
|
|
url: getDaemonWsUrl(),
|
|
clientId: `app-e2e-archive-tab-${randomUUID()}`,
|
|
clientType: "cli",
|
|
webSocketFactory,
|
|
});
|
|
await client.connect();
|
|
return client;
|
|
}
|
|
|
|
export async function createIdleAgent(
|
|
client: ArchiveTabDaemonClient,
|
|
input: { cwd: string; title: string },
|
|
): Promise<ArchiveTabAgent> {
|
|
const created = await client.createAgent({
|
|
provider: "opencode",
|
|
model: "opencode/gpt-5-nano",
|
|
modeId: "bypassPermissions",
|
|
cwd: input.cwd,
|
|
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,
|
|
};
|
|
}
|
|
|
|
export async function archiveAgentFromDaemon(
|
|
client: ArchiveTabDaemonClient,
|
|
agentId: string,
|
|
): Promise<void> {
|
|
await client.archiveAgent(agentId);
|
|
}
|
|
|
|
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.cwd));
|
|
|
|
// 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 expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), {
|
|
timeout: 30_000,
|
|
});
|
|
await expect(page.getByText("Sessions", { 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 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: if session rows appear, a prior spec polluted the shared daemon — see 00-sessions-empty.spec.ts.
|
|
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);
|
|
}
|