Fix History restore and diff truncation edge cases
This commit is contained in:
@@ -1,18 +1,21 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./fixtures";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { connectSeedClient } from "./helpers/seed-client";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
archiveAgentFromDaemon,
|
||||
archiveAgentFromSessions,
|
||||
clickSessionRow,
|
||||
closeWorkspaceAgentTab,
|
||||
createIdleAgent,
|
||||
expectArchivedAgentFocused,
|
||||
expectSessionRowArchived,
|
||||
expectSessionRowVisible,
|
||||
expectWorkspaceArchiveOutcome,
|
||||
expectWorkspaceTabHidden,
|
||||
fetchAgentArchivedAt,
|
||||
expectWorkspaceTabVisible,
|
||||
openSessions,
|
||||
openWorkspaceWithAgents,
|
||||
primeAdditionalPage,
|
||||
@@ -105,25 +108,31 @@ test.describe("Archive tab reconciliation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking an archived session reopens its closed tab focused", async ({ page }) => {
|
||||
test("clicking an archived session unarchives it and opens the agent", async ({ page }) => {
|
||||
const archived = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
title: `reopen-archived-${randomUUID().slice(0, 8)}`,
|
||||
title: `unarchive-archived-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const surviving = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
title: `reopen-control-${randomUUID().slice(0, 8)}`,
|
||||
title: `unarchive-control-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
|
||||
await resetSeededPageState(page);
|
||||
await openWorkspaceWithAgents(page, [archived, surviving]);
|
||||
await closeWorkspaceAgentTab(page, archived.id);
|
||||
await archiveAgentFromDaemon(client, archived.id);
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, archived.title);
|
||||
|
||||
await clickSessionRow(page, archived.title);
|
||||
|
||||
await expectArchivedAgentFocused(page, archived.id);
|
||||
await expect
|
||||
.poll(() => fetchAgentArchivedAt(client, archived.id), { timeout: 30_000 })
|
||||
.toBeNull();
|
||||
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectWorkspaceTabVisible(page, archived.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { spawn, type ChildProcess, execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import net from "node:net";
|
||||
@@ -154,6 +154,67 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readSupervisorPidLock(home: string): Promise<number | null> {
|
||||
try {
|
||||
const content = await readFile(path.join(home, "paseo.pid"), "utf8");
|
||||
const parsed = JSON.parse(content) as { pid?: unknown };
|
||||
return typeof parsed.pid === "number" ? parsed.pid : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopProcessByPid(pid: number): Promise<void> {
|
||||
if (!isProcessRunning(pid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const deadline = Date.now() + 5000;
|
||||
while (Date.now() < deadline) {
|
||||
if (!isProcessRunning(pid)) {
|
||||
return;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
if (isProcessRunning(pid)) {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function stopCurrentDaemonFromPidLock(): Promise<void> {
|
||||
if (!paseoHome) {
|
||||
return;
|
||||
}
|
||||
if (process.env.E2E_DAEMON_PORT === "6767") {
|
||||
throw new Error("Refusing to clean up daemon PID lock for developer daemon port 6767.");
|
||||
}
|
||||
|
||||
const pid = await readSupervisorPidLock(paseoHome);
|
||||
if (pid === null) {
|
||||
return;
|
||||
}
|
||||
await stopProcessByPid(pid);
|
||||
}
|
||||
|
||||
function summarizeOpenAiErrorBody(body: string): string {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) {
|
||||
@@ -665,6 +726,7 @@ async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
|
||||
stopProcess(metroProcess),
|
||||
stopProcess(relayProcess),
|
||||
]);
|
||||
await stopCurrentDaemonFromPidLock();
|
||||
daemonProcess = null;
|
||||
metroProcess = null;
|
||||
relayProcess = null;
|
||||
|
||||
@@ -93,6 +93,22 @@ export async function archiveAgentFromDaemon(
|
||||
await client.archiveAgent(agentId);
|
||||
}
|
||||
|
||||
export async function fetchAgentArchivedAt(
|
||||
client: {
|
||||
fetchAgent(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 function getWorktreeRestoreFeature(client: {
|
||||
getLastServerInfoMessage(): { features?: { worktreeRestore?: boolean } | null } | null;
|
||||
}): boolean {
|
||||
return client.getLastServerInfoMessage()?.features?.worktreeRestore === true;
|
||||
}
|
||||
|
||||
export async function primeAdditionalPage(page: Page): Promise<void> {
|
||||
const seedNonce = randomUUID();
|
||||
const { daemon, preferences } = buildSeededStoragePayload();
|
||||
@@ -214,7 +230,7 @@ export async function openSessions(page: Page): Promise<void> {
|
||||
await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByText("Agent history", { exact: true }).last()).toBeVisible({
|
||||
await expect(page.getByText("History", { exact: true }).last()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
@@ -233,6 +249,12 @@ export async function expectSessionRowArchived(page: Page, title: string): Promi
|
||||
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 });
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
|
||||
/**
|
||||
* Restarts the isolated E2E daemon against the SAME PASEO_HOME and SAME port so
|
||||
* persisted state reloads and existing clients can reconnect. This exercises the
|
||||
* post-restart rehydration path (the daemon rebuilding workspace/agent links
|
||||
* from disk), which is where the worktree-branch regression lives.
|
||||
*
|
||||
* The daemon is owned by Playwright's `globalSetup`, which keeps its child
|
||||
* handle in module scope we can't reach from a spec. Instead we drive it the
|
||||
* same way an operator would: read the supervisor PID from
|
||||
* `$PASEO_HOME/paseo.pid`, SIGTERM it (the supervisor forwards the signal to its
|
||||
* worker and releases the lock), wait for the port to free, then re-spawn the
|
||||
* supervisor with the identical environment globalSetup used. The relay and
|
||||
* Metro processes are untouched, so we reuse their already-published ports.
|
||||
*
|
||||
* This NEVER targets the developer daemon: the port comes from
|
||||
* `getE2EDaemonPort()`, which refuses 6767, and PASEO_HOME is the isolated E2E
|
||||
* home globalSetup created.
|
||||
*/
|
||||
|
||||
function getEnvOrThrow(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`${name} is not set (expected from Playwright globalSetup).`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function readSupervisorPid(paseoHome: string): Promise<number> {
|
||||
const pidPath = path.join(paseoHome, "paseo.pid");
|
||||
const content = await readFile(pidPath, "utf8");
|
||||
const parsed = JSON.parse(content) as { pid?: unknown };
|
||||
if (typeof parsed.pid !== "number") {
|
||||
throw new Error(`Malformed PID lock at ${pidPath}: ${content}`);
|
||||
}
|
||||
return parsed.pid;
|
||||
}
|
||||
|
||||
function isPidRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isPortListening(port: number, host = "127.0.0.1"): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.connect(port, host, () => {
|
||||
socket.end();
|
||||
resolve(true);
|
||||
});
|
||||
socket.setTimeout(1000, () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
socket.on("error", () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitUntil(
|
||||
predicate: () => Promise<boolean> | boolean,
|
||||
options: { timeoutMs: number; label: string },
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + options.timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await predicate()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`Timed out after ${options.timeoutMs}ms waiting for ${options.label}.`);
|
||||
}
|
||||
|
||||
function spawnSupervisor(args: {
|
||||
paseoHome: string;
|
||||
port: string;
|
||||
relayPort: string;
|
||||
metroPort: string;
|
||||
editorRecordPath: string;
|
||||
}): ChildProcess {
|
||||
const serverDir = path.resolve(__dirname, "../../../..", "packages/server");
|
||||
// Run the supervisor through the resolved tsx CLI under the current node
|
||||
// binary. Spawning the `node_modules/.bin/tsx` shim directly is unreliable
|
||||
// inside the Playwright worker (the shim is a .mjs symlink, not an executable),
|
||||
// so resolve the CLI module and load it with node.
|
||||
const tsxCli = createRequire(path.join(serverDir, "package.json")).resolve("tsx/cli");
|
||||
|
||||
const child = spawn(process.execPath, [tsxCli, "scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_HOME: args.paseoHome,
|
||||
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
PASEO_LISTEN: `0.0.0.0:${args.port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
child.stdout?.on("data", (data: Buffer) => {
|
||||
for (const line of data.toString().split("\n")) {
|
||||
if (line.trim()) console.log(`[daemon:restart] ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
child.stderr?.on("data", (data: Buffer) => {
|
||||
for (const line of data.toString().split("\n")) {
|
||||
if (line.trim()) console.error(`[daemon:restart] ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Detach our handles so the spawned supervisor outlives this spec process and
|
||||
// is reaped by globalSetup's cleanup (the original process tree), not us.
|
||||
child.unref();
|
||||
return child;
|
||||
}
|
||||
|
||||
export async function restartTestDaemon(): Promise<void> {
|
||||
const port = getE2EDaemonPort();
|
||||
const paseoHome = getEnvOrThrow("E2E_PASEO_HOME");
|
||||
const relayPort = getEnvOrThrow("E2E_RELAY_PORT");
|
||||
const metroPort = getEnvOrThrow("E2E_METRO_PORT");
|
||||
const editorRecordPath =
|
||||
process.env.E2E_EDITOR_RECORD_PATH ?? path.join(paseoHome, "editor-open-records.jsonl");
|
||||
|
||||
const pid = await readSupervisorPid(paseoHome);
|
||||
process.kill(pid, "SIGTERM");
|
||||
|
||||
await waitUntil(() => !isPidRunning(pid), {
|
||||
timeoutMs: 15_000,
|
||||
label: `supervisor PID ${pid} to exit`,
|
||||
});
|
||||
await waitUntil(async () => !(await isPortListening(Number(port))), {
|
||||
timeoutMs: 15_000,
|
||||
label: `port ${port} to free`,
|
||||
});
|
||||
|
||||
spawnSupervisor({ paseoHome, port, relayPort, metroPort, editorRecordPath });
|
||||
|
||||
await waitUntil(async () => isPortListening(Number(port)), {
|
||||
timeoutMs: 30_000,
|
||||
label: `restarted daemon to listen on port ${port}`,
|
||||
});
|
||||
}
|
||||
@@ -103,8 +103,12 @@ export async function openProjectViaDaemon(
|
||||
export async function archiveWorkspaceFromDaemon(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
workspaceDirectory: string,
|
||||
options?: { scope?: "workspace" | "worktree" },
|
||||
): Promise<void> {
|
||||
const payload = await client.archivePaseoWorktree({ worktreePath: workspaceDirectory });
|
||||
const payload = await client.archivePaseoWorktree({
|
||||
worktreePath: workspaceDirectory,
|
||||
...(options?.scope !== undefined ? { scope: options.scope } : {}),
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
|
||||
@@ -121,6 +121,12 @@ export interface SeedDaemonClient {
|
||||
timeout?: number,
|
||||
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
|
||||
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
||||
fetchAgent(
|
||||
agentId: string,
|
||||
): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>;
|
||||
getLastServerInfoMessage(): {
|
||||
features?: { worktreeRestore?: boolean } | null;
|
||||
} | null;
|
||||
fetchAgentHistory(options?: {
|
||||
page?: { limit: number };
|
||||
}): Promise<{ entries: Array<{ id: string }> }>;
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { createIdleAgent, expectSessionRowArchived, openSessions } from "./helpers/archive-tab";
|
||||
import { restartTestDaemon } from "./helpers/daemon-restart";
|
||||
import {
|
||||
archiveWorkspaceFromDaemon,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
createWorktreeViaDaemon,
|
||||
openProjectViaDaemon,
|
||||
} from "./helpers/new-workspace";
|
||||
import { connectSeedClient } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
test.describe("Worktree restore after daemon restart", () => {
|
||||
let client: Awaited<ReturnType<typeof connectSeedClient>>;
|
||||
let worktreeClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
|
||||
test.describe.configure({ retries: 0, timeout: 180_000 });
|
||||
|
||||
test.beforeEach(async () => {
|
||||
client = await connectSeedClient();
|
||||
worktreeClient = await connectNewWorkspaceDaemonClient();
|
||||
tempRepo = await createTempGitRepo("wt-restart-");
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
for (const directory of createdWorktreeDirectories) {
|
||||
await archiveWorkspaceFromDaemon(worktreeClient, directory).catch(() => undefined);
|
||||
}
|
||||
createdWorktreeDirectories.clear();
|
||||
await client?.close().catch(() => undefined);
|
||||
await worktreeClient?.close().catch(() => undefined);
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("after archiving a worktree and restarting the daemon, History shows the worktree branch (not main) before any restore", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
// A paseo worktree is cut on its own branch named after the slug, and the
|
||||
// worktree workspace is displayed under the same name. These are the values
|
||||
// the History table cells must show after restore — never "main".
|
||||
const worktreeSlug = `restart-restore-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
||||
const worktree = await createWorktreeViaDaemon(worktreeClient, {
|
||||
cwd: tempRepo.path,
|
||||
slug: worktreeSlug,
|
||||
});
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: worktree.workspaceDirectory,
|
||||
title: `restart-restore-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
|
||||
// Archive through the default production path (no scope): the worktree dir is deleted.
|
||||
await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory);
|
||||
await expect
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
|
||||
// Bounce the isolated test daemon on the SAME home and port so it rebuilds
|
||||
// all workspace/agent links from persisted state. Then reconnect both clients.
|
||||
await client.close().catch(() => undefined);
|
||||
await worktreeClient.close().catch(() => undefined);
|
||||
await restartTestDaemon();
|
||||
client = await connectSeedClient();
|
||||
worktreeClient = await connectNewWorkspaceDaemonClient();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, agent.title);
|
||||
|
||||
// KEY ASSERTION: reproduce the screenshot state. Right after the daemon
|
||||
// restart, with NO restore and NO row click, the rendered History table cells
|
||||
// (fed by each agent row's projectPlacement via fetch_agent_history) must read
|
||||
// the worktree branch and the worktree workspace name — never "main".
|
||||
const branchCell = page.getByTestId(`agent-row-branch-${serverId}-${agent.id}`);
|
||||
const workspaceCell = page.getByTestId(`agent-row-workspace-${serverId}-${agent.id}`);
|
||||
|
||||
await expect(branchCell).toBeVisible({ timeout: 60_000 });
|
||||
await expect(branchCell).toHaveText(worktreeSlug, { timeout: 60_000 });
|
||||
await expect(workspaceCell).toHaveText(worktree.workspaceName, { timeout: 60_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
archiveAgentFromDaemon,
|
||||
createIdleAgent,
|
||||
expectSessionRowArchived,
|
||||
fetchAgentArchivedAt,
|
||||
openSessions,
|
||||
} from "./helpers/archive-tab";
|
||||
import {
|
||||
archiveWorkspaceFromDaemon,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
createWorktreeViaDaemon,
|
||||
openProjectViaDaemon,
|
||||
} from "./helpers/new-workspace";
|
||||
import { connectSeedClient } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./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>();
|
||||
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test.beforeEach(async () => {
|
||||
client = await connectSeedClient();
|
||||
worktreeClient = await connectNewWorkspaceDaemonClient();
|
||||
tempRepo = await createTempGitRepo("wt-restore-");
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
for (const directory of createdWorktreeDirectories) {
|
||||
await archiveWorkspaceFromDaemon(worktreeClient, directory).catch(() => undefined);
|
||||
}
|
||||
createdWorktreeDirectories.clear();
|
||||
await client?.close().catch(() => undefined);
|
||||
await worktreeClient?.close().catch(() => undefined);
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("archiving an agent, then clicking it in History unarchives it in place (worktree dir untouched)", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
||||
const worktree = await createWorktreeViaDaemon(worktreeClient, {
|
||||
cwd: tempRepo.path,
|
||||
slug: `restore-inplace-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: worktree.workspaceDirectory,
|
||||
title: `restore-inplace-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
|
||||
await archiveAgentFromDaemon(client, agent.id);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, agent.title);
|
||||
|
||||
await page.getByTestId(`agent-row-${serverId}-${agent.id}`).click();
|
||||
|
||||
await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull();
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
|
||||
// The History list is a cached react-query snapshot, so the cleared Archived
|
||||
// badge only renders after a cold refetch. Reload to remount the query fresh.
|
||||
await page.reload();
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
const row = page
|
||||
.locator('[data-testid^="agent-row-"]')
|
||||
.filter({ hasText: agent.title })
|
||||
.first();
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row).not.toContainText("Archived", { timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("archiving a worktree (dir deleted), then clicking its agent in History recreates the worktree", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
||||
const worktree = await createWorktreeViaDaemon(worktreeClient, {
|
||||
cwd: tempRepo.path,
|
||||
slug: `restore-recreate-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: worktree.workspaceDirectory,
|
||||
title: `restore-recreate-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
|
||||
// Archive through the default production path the sidebar uses (no explicit
|
||||
// scope). With the restore prune fix, this default path frees the kept branch
|
||||
// so the daemon can re-check-out the worktree on restore.
|
||||
await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory);
|
||||
await expect
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, agent.title);
|
||||
|
||||
await page.getByTestId(`agent-row-${serverId}-${agent.id}`).click();
|
||||
|
||||
await expect
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(true);
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
|
||||
await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user