fix(ui): preserve worktree mutation errors (#106196)

This commit is contained in:
Peter Steinberger
2026-07-13 02:24:33 -07:00
committed by GitHub
parent 4f287dd740
commit 95d9457465
3 changed files with 148 additions and 7 deletions
+81
View File
@@ -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();
}
});
});
+59 -1
View File
@@ -18,7 +18,7 @@ type WorktreesPageTestElement = HTMLElement & {
createBranches: string[];
updateComplete: Promise<boolean>;
requestUpdate: () => void;
load: () => Promise<void>;
load: (options?: { preserveError?: boolean }) => Promise<void>;
loadCreateBranches: () => void;
createWorktree: () => Promise<void>;
removeWorktree: (record: WorktreeRecord) => Promise<void>;
@@ -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<unknown>();
const request = vi.fn((method: string) => {
+8 -6
View File
@@ -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<WorktreesListResult>("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 });
}
}
}