diff --git a/ui/src/e2e/worktrees.e2e.test.ts b/ui/src/e2e/worktrees.e2e.test.ts new file mode 100644 index 000000000000..127153394053 --- /dev/null +++ b/ui/src/e2e/worktrees.e2e.test.ts @@ -0,0 +1,81 @@ +// Control UI tests cover Worktrees mutation failures through the rendered settings page. +import { chromium, type Browser } from "playwright"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + canRunPlaywrightChromium, + installMockGateway, + resolvePlaywrightChromiumExecutablePath, + startControlUiE2eServer, + type ControlUiE2eServer, +} from "../test-helpers/control-ui-e2e.ts"; + +const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); +const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); +const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1"; +const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip; + +let browser: Browser; +let server: ControlUiE2eServer; + +const restorableWorktree = { + baseRef: "main", + branch: "openclaw/test", + createdAt: 1, + id: "worktree-1", + lastActiveAt: 2, + name: "restorable-test", + ownerKind: "manual", + path: "/tmp/repo/.worktrees/restorable-test", + removedAt: 3, + repoFingerprint: "0123456789abcdef", + repoRoot: "/tmp/repo", + snapshotRef: "refs/openclaw/worktree-snapshots/test", +}; + +describeControlUiE2e("Control UI Worktrees mocked Gateway E2E", () => { + beforeAll(async () => { + if (!chromiumAvailable) { + throw new Error( + `Playwright Chromium is not installed or cannot start at ${chromiumExecutablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`, + ); + } + server = await startControlUiE2eServer(); + browser = await chromium.launch({ executablePath: chromiumExecutablePath }); + }); + + afterAll(async () => { + await browser?.close(); + await server?.close(); + }); + + it("keeps a restore failure visible after the automatic list refresh succeeds", async () => { + const context = await browser.newContext(); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + deferredMethods: ["worktrees.restore"], + methodResponses: { + "worktrees.list": { worktrees: [restorableWorktree] }, + }, + }); + + try { + const response = await page.goto(`${server.baseUrl}settings/worktrees`); + expect(response?.status()).toBe(200); + await page.getByRole("button", { name: "Restore" }).click(); + await gateway.waitForRequest("worktrees.restore"); + await gateway.rejectDeferred("worktrees.restore", { + message: "source repository is unavailable", + }); + + await expect + .poll(async () => (await gateway.getRequests("worktrees.list")).length) + .toBeGreaterThanOrEqual(2); + await expect(page.locator(".callout.danger").textContent()).resolves.toContain( + "source repository is unavailable", + ); + await expect(page.getByRole("button", { name: "Restore" }).count()).resolves.toBe(1); + } finally { + await context.close(); + } + }); +}); diff --git a/ui/src/pages/worktrees/worktrees-page.test.ts b/ui/src/pages/worktrees/worktrees-page.test.ts index 23c64d119951..35f47c89e5fc 100644 --- a/ui/src/pages/worktrees/worktrees-page.test.ts +++ b/ui/src/pages/worktrees/worktrees-page.test.ts @@ -18,7 +18,7 @@ type WorktreesPageTestElement = HTMLElement & { createBranches: string[]; updateComplete: Promise; requestUpdate: () => void; - load: () => Promise; + load: (options?: { preserveError?: boolean }) => Promise; loadCreateBranches: () => void; createWorktree: () => Promise; removeWorktree: (record: WorktreeRecord) => Promise; @@ -311,6 +311,64 @@ describe("WorktreesPage lifecycle", () => { expect(page.busyId).toBeNull(); }); + it("keeps a restore error after the reconciliation refresh succeeds", async () => { + const record = worktree(); + let listRequests = 0; + const request = vi.fn((method: string) => { + if (method === "worktrees.list") { + listRequests += 1; + return Promise.resolve({ worktrees: [record] }); + } + if (method === "worktrees.restore") { + return Promise.reject(new Error("restore failed")); + } + return Promise.resolve({}); + }); + const page = document.createElement("openclaw-worktrees-page") as WorktreesPageTestElement; + page.context = contextWithGateway( + gatewayWithClient({ request } as unknown as GatewayBrowserClient), + ); + document.body.append(page); + await vi.waitFor(() => expect(listRequests).toBe(1)); + await vi.waitFor(() => expect(page.loading).toBe(false)); + + await page.restore(record); + + expect(listRequests).toBe(2); + expect(page.error).toBe("Error: restore failed"); + expect(page.busyId).toBeNull(); + }); + + it("replaces a mutation error when the reconciliation refresh also fails", async () => { + const record = worktree(); + let listRequests = 0; + const request = vi.fn((method: string) => { + if (method === "worktrees.list") { + listRequests += 1; + return listRequests === 1 + ? Promise.resolve({ worktrees: [record] }) + : Promise.reject(new Error("list failed")); + } + if (method === "worktrees.restore") { + return Promise.reject(new Error("restore failed")); + } + return Promise.resolve({}); + }); + const page = document.createElement("openclaw-worktrees-page") as WorktreesPageTestElement; + page.context = contextWithGateway( + gatewayWithClient({ request } as unknown as GatewayBrowserClient), + ); + document.body.append(page); + await vi.waitFor(() => expect(listRequests).toBe(1)); + await vi.waitFor(() => expect(page.loading).toBe(false)); + + await page.restore(record); + + expect(listRequests).toBe(2); + expect(page.error).toBe("Error: list failed"); + expect(page.busyId).toBeNull(); + }); + it("clears pending create state across a same-client reconnect", async () => { const pendingCreate = deferred(); const request = vi.fn((method: string) => { diff --git a/ui/src/pages/worktrees/worktrees-page.ts b/ui/src/pages/worktrees/worktrees-page.ts index 01e4479e739e..d24a5c7335dc 100644 --- a/ui/src/pages/worktrees/worktrees-page.ts +++ b/ui/src/pages/worktrees/worktrees-page.ts @@ -144,14 +144,16 @@ class WorktreesPage extends OpenClawLightDomElement { return this.loading || this.busyId !== null || this.creating; } - private async load() { + private async load(options: { preserveError?: boolean } = {}) { const client = this.client; if (!client || !this.gatewayConnected || this.operationPending) { return; } const generation = ++this.loadGeneration; this.loading = true; - this.error = null; + if (!options.preserveError) { + this.error = null; + } try { const result = await client.request("worktrees.list", {}); if (generation === this.loadGeneration && client === this.client) { @@ -212,7 +214,7 @@ class WorktreesPage extends OpenClawLightDomElement { } finally { if (this.isOperationScopeCurrent(scope)) { this.busyId = null; - await this.load(); + await this.load({ preserveError: true }); } } } @@ -233,7 +235,7 @@ class WorktreesPage extends OpenClawLightDomElement { } finally { if (this.isOperationScopeCurrent(scope)) { this.busyId = null; - await this.load(); + await this.load({ preserveError: true }); } } } @@ -254,7 +256,7 @@ class WorktreesPage extends OpenClawLightDomElement { } finally { if (this.isOperationScopeCurrent(scope)) { this.loading = false; - await this.load(); + await this.load({ preserveError: true }); } } } @@ -325,7 +327,7 @@ class WorktreesPage extends OpenClawLightDomElement { } finally { if (this.isOperationScopeCurrent(scope)) { this.creating = false; - await this.load(); + await this.load({ preserveError: true }); } } }