diff --git a/tests/e2e/task-reload.spec.ts b/tests/e2e/task-reload.spec.ts index 68f02b332f..15aca94515 100644 --- a/tests/e2e/task-reload.spec.ts +++ b/tests/e2e/task-reload.spec.ts @@ -42,3 +42,31 @@ test("saved task content survives same-URL navigation and reload with a controll await fixture.restore(); } }); + +test("an interrupted app bundle offers a retry that restores the saved task", async ({ page, request }) => { + const fixture = await setup(request); + const title = "Recover interrupted startup"; + const comment = "This saved comment must survive a startup failure."; + try { + const issue = await json(await request.post(`/api/companies/${fixture.company.id}/issues`, { + data: { title, status: "backlog" }, + })); + await json(await request.post(`/api/issues/${issue.id}/comments`, { data: { body: comment } })); + const route = `/${fixture.company.issuePrefix}/issues/${issue.identifier}`; + // Fail the shipped module before React (and its error boundary) can start. + await page.route("**/assets/*.js", intercepted => intercepted.abort()); + await page.goto(route); + await expect(page.getByRole("heading", { name: "Paperclip couldn’t start" })).toBeVisible(); + expect(await page.locator("#root").evaluate(root => root.childElementCount)).toBe(0); + await page.unroute("**/assets/*.js"); + await page.getByRole("button", { name: "Reload page" }).click(); + await expect(page.getByRole("heading", { name: title, exact: true })).toBeVisible(); + await expect(page.getByText(comment, { exact: true })).toBeVisible(); + await expect(page.getByTestId("task-chat-composer-input")).toBeVisible(); + await expect(page.locator("#paperclip-startup")).toBeHidden(); + expect(new URL(page.url()).pathname).toBe(route); + expect(await json(await request.get(`/api/issues/${issue.id}/runs`))).toEqual([]); + } finally { + await fixture.restore(); + } +}); diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index 00c1ffc3d2..ffe53d181b 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -1002,6 +1002,21 @@ standard `tests/e2e/playwright.config.ts`; no provider or Daytona credentials ar needed. Browser-support tests separately exercise blank-root/pending-module failure evidence, so a future blank page is distinguishable from a loaded task. +The HTML entry also supplies recovery before React mounts: a failed module shows +`Reload page`; a startup with no rendered root for 30 seconds offers the same +manual retry. Late successful startup removes the notice. It never reloads +automatically, and the notice lives outside `#root`, so it cannot satisfy an +app-readiness assertion. The saved-task regression interrupts the built bundle, +clicks retry, and verifies the original task, persisted comment, and composer. +`pnpm test:e2e:runner:browser-support` also tests failed and stalled imports, +evaluation errors, service-worker-controlled retry, repeated offline retries, and +cleanup after startup. The worker returns a static, uncached HTML retry screen +when a navigation fails offline; it never embeds or caches task content. + +These fault-injection tests prove recovery from interrupted startup. They do not +establish the cause of the historical intermittent Vite module-graph stall; +ordinary 304 responses and successful reruns alone are not evidence of that cause. + ### Grok branch qualification on EC2 The trusted default-branch workflow can run the explicit `grok-qualification` diff --git a/tests/runner-e2e/browser-bootstrap-recovery.spec.ts b/tests/runner-e2e/browser-bootstrap-recovery.spec.ts new file mode 100644 index 0000000000..7e79b9e5dd --- /dev/null +++ b/tests/runner-e2e/browser-bootstrap-recovery.spec.ts @@ -0,0 +1,135 @@ +import { createServer, type ServerResponse } from "node:http"; +import { readFileSync } from "node:fs"; +import { expect, test } from "@playwright/test"; + +// Exercise the actual HTML entry independently of React, the app server, and +// providers. A React error boundary cannot handle a failed module import. +const html = readFileSync(new URL("../../ui/index.html", import.meta.url), "utf8"); +const worker = readFileSync(new URL("../../ui/public/sw.js", import.meta.url), "utf8"); + +test.use({ serviceWorkers: "allow" }); + +test.describe("browser bootstrap recovery", () => { + let mode: "ready" | "failed" | "pending" | "throws"; + let pending: ServerResponse[]; + let baseURL: string; + let server: ReturnType; + const readyModule = 'document.getElementById("root").innerHTML = "
Task ready
";'; + + test.beforeEach(async () => { + mode = "ready"; + pending = []; + server = createServer((request, response) => { + const pathname = new URL(request.url!, "http://localhost").pathname; + if (pathname === "/sw.js") { + response.writeHead(200, { "Content-Type": "text/javascript" }); + response.end(worker); + } else if (pathname === "/src/main.tsx") { + response.writeHead(200, { "Content-Type": "text/javascript" }); + response.end('import "/dependency.js";'); + } else if (pathname === "/dependency.js") { + if (mode === "pending") { + pending.push(response); + return; + } + response.writeHead(mode === "failed" ? 503 : 200, { + "Content-Type": "text/javascript", "Cache-Control": "no-store", + }); + response.end(mode === "throws" ? 'throw new Error("startup fixture failure")' : readyModule); + } else if (pathname === "/tasks/reload") { + response.writeHead(200, { "Content-Type": "text/html" }); + response.end(html); + } else { + response.writeHead(404); + response.end(); + } + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + baseURL = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + }); + + test.afterEach(async () => { + for (const response of pending) response.destroy(); + server.closeAllConnections(); + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + }); + + for (const controlled of [false, true]) { + test(`failed dependency offers a working retry (${controlled ? "service worker controlled" : "first visit"})`, async ({ page }) => { + if (controlled) { + await page.goto(`${baseURL}/tasks/reload`); + await page.evaluate(async () => { + await navigator.serviceWorker.register("/sw.js"); + await navigator.serviceWorker.ready; + }); + await page.waitForFunction(() => Boolean(navigator.serviceWorker.controller)); + } + mode = "failed"; + await page.goto(`${baseURL}/tasks/reload`); + await expect(page.getByRole("heading", { name: "Paperclip couldn’t start" })).toBeVisible(); + expect(await page.locator("#root").evaluate(root => root.childElementCount)).toBe(0); + mode = "ready"; + await page.getByRole("button", { name: "Reload page" }).click(); + await expect(page.getByRole("main")).toHaveText("Task ready"); + await expect(page.locator("#paperclip-startup")).toBeHidden(); + await expect(page).toHaveURL(`${baseURL}/tasks/reload`); + }); + } + + test("offline retries keep a recovery action until the connection returns", async ({ page, context }) => { + await page.goto(`${baseURL}/tasks/reload`); + await page.evaluate(async () => { + await navigator.serviceWorker.register("/sw.js"); + await navigator.serviceWorker.ready; + }); + await page.waitForFunction(() => Boolean(navigator.serviceWorker.controller)); + mode = "failed"; + await page.reload(); + await expect(page.getByRole("heading", { name: "Paperclip couldn’t start" })).toBeVisible(); + await context.setOffline(true); + try { + for (let attempt = 0; attempt < 2; attempt += 1) { + await page.getByRole("button", { name: "Reload page" }).click(); + await expect(page.getByRole("heading", { name: "Paperclip is offline" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Reload page" })).toBeVisible(); + } + } finally { + await context.setOffline(false); + } + mode = "ready"; + await page.getByRole("button", { name: "Reload page" }).click(); + await expect(page.getByRole("main")).toHaveText("Task ready"); + await expect(page).toHaveURL(`${baseURL}/tasks/reload`); + }); + + test("a stalled import shows recovery and dismisses it when startup eventually succeeds", async ({ page }) => { + mode = "pending"; + await page.clock.install(); + await page.goto(`${baseURL}/tasks/reload`, { waitUntil: "commit" }); + await expect.poll(() => pending.length).toBe(1); + await page.clock.runFor(30_000); + await expect(page.getByRole("heading", { name: "Paperclip is taking longer to load" })).toBeVisible(); + // Late success must recover in place, without a reload or losing the route. + pending[0]!.writeHead(200, { "Content-Type": "text/javascript" }); + pending[0]!.end(readyModule); + await expect(page.getByRole("main")).toHaveText("Task ready"); + await expect(page.locator("#paperclip-startup")).toBeHidden(); + await expect(page).toHaveURL(`${baseURL}/tasks/reload`); + }); + + test("module evaluation errors before React mounts show recovery", async ({ page }) => { + mode = "throws"; + await page.goto(`${baseURL}/tasks/reload`); + await expect(page.getByRole("heading", { name: "Paperclip couldn’t start" })).toBeVisible(); + }); + + test("completed startup disables the timer and error handlers", async ({ page }) => { + await page.clock.install(); + await page.goto(`${baseURL}/tasks/reload`); + await expect(page.getByRole("main")).toHaveText("Task ready"); + await page.evaluate(() => window.dispatchEvent(new ErrorEvent("error", { message: "later unrelated error" }))); + await page.clock.runFor(60_000); + await expect(page.locator("#paperclip-startup")).toBeHidden(); + await expect(page.getByRole("main")).toHaveText("Task ready"); + }); +}); diff --git a/tests/runner-e2e/playwright-support.config.ts b/tests/runner-e2e/playwright-support.config.ts index 32dd9e6b29..f017c31641 100644 --- a/tests/runner-e2e/playwright-support.config.ts +++ b/tests/runner-e2e/playwright-support.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from "@playwright/test"; /** Browser-only harness regressions: no Paperclip instance or provider credentials. */ export default defineConfig({ testDir: ".", - testMatch: ["screenshot-readiness.spec.ts", "lost-send.spec.ts", "chat-restart.spec.ts", "settings-toggle.spec.ts", "browser-bootstrap-diagnostics.spec.ts"], + testMatch: ["screenshot-readiness.spec.ts", "lost-send.spec.ts", "chat-restart.spec.ts", "settings-toggle.spec.ts", "browser-bootstrap-diagnostics.spec.ts", "browser-bootstrap-recovery.spec.ts"], workers: 1, retries: 0, timeout: 10_000, diff --git a/ui/index.html b/ui/index.html index 8c2cbaa6f5..8306265b06 100644 --- a/ui/index.html +++ b/ui/index.html @@ -44,6 +44,68 @@
+ + + diff --git a/ui/public/sw.js b/ui/public/sw.js index c5f43d34ae..a30c891a71 100644 --- a/ui/public/sw.js +++ b/ui/public/sw.js @@ -10,6 +10,21 @@ const CACHE_NAME = `paperclip-public-assets-${BUILD_ID}`; const privateRequests = new Set(); const privateCacheControl = /(?:^|,)\s*(?:no-store|private)(?:\s*(?:,|=)|\s*$)/i; +// Static recovery only: never cache or embed authenticated page content here. +function offlineNavigationResponse() { + return new Response(` + + +Paperclip is offline +

Paperclip is offline

+

Check your connection, then reload this page to try again.

+ +
`, { + status: 503, + headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" }, + }); +} + async function evictRequest(request) { await Promise.all((await caches.keys()).map(async (key) => { const cache = await caches.open(key); @@ -72,7 +87,7 @@ self.addEventListener("fetch", (event) => { }) .catch(async () => { if (privateRequests.has(request.url)) return Response.error(); - if (!publicAsset) return request.mode === "navigate" ? new Response("Offline", { status: 503 }) : Response.error(); + if (!publicAsset) return request.mode === "navigate" ? offlineNavigationResponse() : Response.error(); // Restrict lookup to this policy's cache; old arbitrary-response caches // must not become fallback candidates if activation cleanup fails. try { diff --git a/ui/src/lib/sw-offline-fallback.test.ts b/ui/src/lib/sw-offline-fallback.test.ts index 917e7658cc..40e8c64ba1 100644 --- a/ui/src/lib/sw-offline-fallback.test.ts +++ b/ui/src/lib/sw-offline-fallback.test.ts @@ -57,7 +57,7 @@ async function respondTo( } describe("sw.js offline fallback", () => { - it("serves the Offline response for a failed navigation with an empty cache", async () => { + it("serves an uncached retry page for a failed navigation with an empty cache", async () => { const listener = loadServiceWorkerFetchListener({ fetch: () => Promise.reject(new TypeError("network down")), cachesMatch: async () => undefined, @@ -73,7 +73,12 @@ describe("sw.js offline fallback", () => { // fails the navigation with "Failed to convert value to 'Response'". expect(response).toBeInstanceOf(Response); expect(response!.status).toBe(503); - expect(await response!.text()).toBe("Offline"); + expect(response!.headers.get("content-type")).toBe("text/html; charset=utf-8"); + expect(response!.headers.get("cache-control")).toBe("no-store"); + const body = await response!.text(); + expect(body).toContain("Paperclip is offline"); + expect(body).toContain("Reload page"); + expect(body).not.toContain("app shell"); }); it("does not replay a legacy cached shell for a failed navigation", async () => { @@ -90,7 +95,12 @@ describe("sw.js offline fallback", () => { }); expect(response!.status).toBe(503); - expect(await response!.text()).toBe("Offline"); + expect(response!.headers.get("content-type")).toBe("text/html; charset=utf-8"); + expect(response!.headers.get("cache-control")).toBe("no-store"); + const body = await response!.text(); + expect(body).toContain("Paperclip is offline"); + expect(body).toContain("Reload page"); + expect(body).not.toContain("app shell"); }); it("returns a network-error Response for a failed asset with no cache entry", async () => {