Files
paseo/packages/app/e2e/browser/worktree-restore.spec.ts
T
Mohamed BoudraandGitHub 5a30affd7f Skip unrelated required checks without losing their names (#2542)
* fix(ci): skip unrelated required checks cleanly

Matrix jobs had to start no-op runners to preserve interpolated required-check names. Static named jobs let GitHub report genuine skipped conclusions while keeping those names stable.

* fix(ci): gate changes to install patching script

* fix(ci): route cross-package test contracts narrowly

* fix(ci): keep routed contracts merge-blocking

* fix(server): exclude nested test utilities from builds

* fix(ci): cover app-owned native and browser domains

* refactor(ci): align tests with runtime domains

Own browser specs, desktop specs, and Electron-only app code by stable directories so PR routing does not depend on filename conventions. Keep desktop integration coverage inside the required desktop check.

* fix(ci): keep routing and desktop suites isolated

The routing regression must run before dependency installation, and desktop Vitest must not collect Playwright-owned specs.

* fix(ci): isolate desktop e2e runtime

* perf(test): isolate Playwright workers

* perf(test): parallelize Playwright within specs

* fix(test): keep Playwright file scheduling

* fix(test): share dynamic Metro fixture

* fix(test): preserve own-host restart fixtures

* fix(ci): preserve routed contracts after rebase

* Stabilize Playwright wheel-scroll assertions
2026-08-02 00:56:05 +08:00

383 lines
15 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { rename } from "node:fs/promises";
import type { Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
expectWorkspaceBranch,
openChangesPanel,
switchBranchFromChangesPanel,
} from "../support/helpers/branch-switcher";
import {
createIdleAgent,
expectSessionRowNotArchived,
fetchAgentArchivedAt,
openSessions,
} from "../support/helpers/archive-tab";
import {
archiveWorkspaceFromDaemon,
archiveLocalWorkspaceFromDaemon,
connectNewWorkspaceDaemonClient,
createWorktreeViaDaemon,
openProjectViaDaemon,
} from "../support/helpers/new-workspace";
import { connectSeedClient } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { createTempGitRepo } from "../support/helpers/workspace";
import {
waitForSidebarHydration,
waitForWorkspaceInSidebar,
} from "../support/helpers/workspace-ui";
test.describe("Worktree restore", () => {
let client: Awaited<ReturnType<typeof connectSeedClient>>;
let worktreeClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
const createdProjectIds = new Set<string>();
test.describe.configure({ timeout: 120_000 });
test.beforeEach(async () => {
client = await connectSeedClient();
worktreeClient = await connectNewWorkspaceDaemonClient();
tempRepo = await createTempGitRepo("wt-restore-");
});
async function createArchivedMissingWorktree(
prefix: string,
options: { agentCount?: number; keepAgentsArchived?: boolean } = {},
) {
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
createdProjectIds.add(project.projectKey);
const worktree = await createWorktreeViaDaemon(worktreeClient, {
cwd: tempRepo.path,
slug: `${prefix}-${randomUUID().slice(0, 8)}`,
});
createdProjectIds.add(worktree.projectKey);
createdWorktreeDirectories.add(worktree.workspaceDirectory);
const agents = await Promise.all(
Array.from({ length: options.agentCount ?? 1 }, () =>
createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
workspaceId: worktree.workspaceId,
title: `${prefix}-${randomUUID().slice(0, 8)}`,
}),
),
);
const agent = agents[0];
if (!agent) {
throw new Error("Expected at least one archived-worktree agent");
}
await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory);
await expect
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
.toBe(false);
// Match the remote cloud-race record: workspace archived and absent, while
// the surviving closed agent record is not agent-archived. Refresh now owns
// only agent lifecycle, so its expected cwd failure cannot recover the workspace.
if (options.keepAgentsArchived) {
for (const archivedAgent of agents) {
await expect
.poll(() => fetchAgentArchivedAt(client, archivedAgent.id), { timeout: 30_000 })
.not.toBeNull();
}
} else {
await client.refreshAgent(agent.id).catch(() => undefined);
await expect
.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 })
.toBeNull();
}
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
return { agent, agents, worktree };
}
async function openArchivedWorkspaceFromHistory(page: Page, prefix: string) {
const seeded = await createArchivedMissingWorktree(prefix);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openSessions(page);
await expectSessionRowNotArchived(page, seeded.agent.title);
await page.getByTestId(`agent-row-${getServerId()}-${seeded.agent.id}`).click();
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("workspace-recovery-action")).toHaveText("Restore");
return seeded;
}
async function openArchivedAgentBeforeWorkspaceHydration(page: Page, prefix: string) {
const seeded = await createArchivedMissingWorktree(prefix);
const workspaceRoute = buildHostWorkspaceRoute(getServerId(), seeded.worktree.workspaceId);
const openAgent = encodeURIComponent(`agent:${seeded.agent.id}`);
await page.goto(`${workspaceRoute}?open=${openAgent}`);
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("workspace-recovery-action")).toHaveText("Restore");
return seeded;
}
test.afterEach(async () => {
for (const directory of createdWorktreeDirectories) {
await archiveWorkspaceFromDaemon(worktreeClient, directory).catch(() => undefined);
}
createdWorktreeDirectories.clear();
for (const projectId of createdProjectIds) {
await worktreeClient.removeProject(projectId).catch(() => undefined);
}
createdProjectIds.clear();
await client?.close().catch(() => undefined);
await worktreeClient?.close().catch(() => undefined);
await tempRepo?.cleanup().catch(() => undefined);
});
test("opening an active History agent navigates without restoring or unarchiving", async ({
page,
}) => {
const serverId = getServerId();
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
createdProjectIds.add(project.projectKey);
const worktree = await createWorktreeViaDaemon(worktreeClient, {
cwd: tempRepo.path,
slug: `restore-inplace-${randomUUID().slice(0, 8)}`,
});
createdProjectIds.add(worktree.projectKey);
createdWorktreeDirectories.add(worktree.workspaceDirectory);
const agent = await createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
workspaceId: worktree.workspaceId,
title: `restore-inplace-${randomUUID().slice(0, 8)}`,
});
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull();
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openSessions(page);
await expectSessionRowNotArchived(page, agent.title);
await page.getByTestId(`agent-row-${serverId}-${agent.id}`).click();
await expect(
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0);
expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull();
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
await openSessions(page);
await expectSessionRowNotArchived(page, agent.title);
await page.getByTestId(`agent-row-${serverId}-${agent.id}`).click();
await expect(
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect(
page.getByTestId(`workspace-deck-entry-${serverId}:${worktree.workspaceId}`),
).toHaveCount(1);
expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull();
});
test("opening a recoverable archived workspace shows an explicit Restore action without mutating it", async ({
page,
}) => {
const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-ready");
expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull();
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
await expect(
worktreeClient.inspectWorkspaceRecovery(worktree.workspaceId),
).resolves.toMatchObject({ kind: "recoverable", action: "restore" });
});
test("explicit Restore shows loading and opens the recreated workspace", async ({ page }) => {
const { agent, worktree } = await openArchivedAgentBeforeWorkspaceHydration(
page,
"restore-success",
);
await worktreeClient.fetchWorkspaces({
subscribe: { subscriptionId: `restore-secondary-${randomUUID()}` },
});
let updateTimeout: ReturnType<typeof setTimeout> | null = null;
let unsubscribeSecondaryWorkspaceUpdate = () => {};
const secondaryWorkspaceUpdate = new Promise<void>((resolve, reject) => {
updateTimeout = setTimeout(
() => reject(new Error("Secondary client did not receive the restored workspace")),
30_000,
);
unsubscribeSecondaryWorkspaceUpdate = worktreeClient.on("workspace_update", (message) => {
if (
message.payload.kind === "upsert" &&
message.payload.workspace.id === worktree.workspaceId
) {
resolve();
}
});
});
try {
await page.getByTestId("workspace-recovery-action").click();
await expect(page.getByText("Restoring workspace", { exact: true })).toBeVisible();
await expect
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
.toBe(true);
await secondaryWorkspaceUpdate;
await waitForWorkspaceInSidebar(page, {
serverId: getServerId(),
workspaceId: worktree.workspaceId,
});
await expect(
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("workspace-recovery-action")).toHaveCount(0);
expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull();
} finally {
unsubscribeSecondaryWorkspaceUpdate();
if (updateTimeout) {
clearTimeout(updateTimeout);
}
}
const switchedBranch = `restored-live-${randomUUID().slice(0, 8)}`;
execFileSync("git", ["branch", switchedBranch], {
cwd: tempRepo.path,
stdio: "pipe",
});
await openChangesPanel(page);
await expectWorkspaceBranch(page, worktree.workspaceName);
await switchBranchFromChangesPanel(page, {
from: worktree.workspaceName,
to: switchedBranch,
});
await expectWorkspaceBranch(page, switchedBranch);
await expect(
page.getByTestId("workspace-header-title").filter({ visible: true }).first(),
).toHaveText(switchedBranch, { timeout: 30_000 });
});
test("recovers the selected agent with its workspace and later rescues another archived agent", async ({
page,
}) => {
const { agents, worktree } = await createArchivedMissingWorktree("restore-agents", {
agentCount: 2,
keepAgentsArchived: true,
});
const [firstAgent, secondAgent] = agents;
if (!firstAgent || !secondAgent) {
throw new Error("Expected two archived agents");
}
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openSessions(page);
await page.getByTestId(`agent-row-${getServerId()}-${firstAgent.id}`).click();
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({
timeout: 30_000,
});
await page.getByTestId("workspace-recovery-action").click();
await expect
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
.toBe(true);
await expect(
page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect
.poll(() => fetchAgentArchivedAt(client, firstAgent.id), { timeout: 30_000 })
.toBeNull();
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
timeout: 30_000,
});
await openSessions(page);
await page.getByTestId(`agent-row-${getServerId()}-${secondAgent.id}`).click();
await expect(
page.getByTestId(`workspace-tab-agent_${secondAgent.id}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("button", { name: "Unarchive" })).toBeVisible({
timeout: 30_000,
});
await page.getByRole("button", { name: "Unarchive" }).click();
await expect
.poll(() => fetchAgentArchivedAt(client, secondAgent.id), { timeout: 30_000 })
.toBeNull();
});
test("restore failure stays visible and permits a successful retry", async ({ page }) => {
const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry");
const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`;
await rename(tempRepo.path, displacedProjectPath);
try {
await page.getByTestId("workspace-recovery-action").click();
await expect(page.getByTestId("workspace-recovery-error")).toHaveText(
"The source repository needed to restore this worktree no longer exists.",
);
await expect(page.getByTestId("workspace-recovery-action")).toHaveText("Retry");
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
} finally {
await rename(displacedProjectPath, tempRepo.path);
}
await page.getByTestId("workspace-recovery-action").click();
await expect
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
.toBe(true);
await waitForWorkspaceInSidebar(page, {
serverId: getServerId(),
workspaceId: worktree.workspaceId,
});
await expect(
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
});
test("an unrecoverable missing workspace shows no misleading recovery action", async ({
page,
}) => {
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
createdProjectIds.add(project.projectKey);
const agent = await createIdleAgent(client, {
cwd: project.workspaceDirectory,
workspaceId: project.workspaceId,
title: `unrecoverable-${randomUUID().slice(0, 8)}`,
});
await archiveLocalWorkspaceFromDaemon(worktreeClient, project.workspaceId);
await client.refreshAgent(agent.id).catch(() => undefined);
await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull();
const displacedProjectPath = `${tempRepo.path}-missing`;
await rename(tempRepo.path, displacedProjectPath);
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openSessions(page);
await expectSessionRowNotArchived(page, agent.title);
await page.getByTestId(`agent-row-${getServerId()}-${agent.id}`).click();
await expect(page.getByText("Workspace unavailable", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(
page.getByText(
"The archived workspace directory no longer exists and cannot be recreated.",
{ exact: true },
),
).toBeVisible();
await expect(page.getByTestId("workspace-recovery-action")).toHaveCount(0);
} finally {
await rename(displacedProjectPath, tempRepo.path);
}
});
});