From fb96537e28c4c7bfced4939909cb334392320868 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 4 Nov 2026 16:11:02 -0800 Subject: [PATCH] test(ui): verify disconnect recovery through browser outages (#18832) ## Thinking Path > - Paperclip lets people manage agent work through the board. > - Deploys can interrupt reads and writes while people use the board. > - Component tests cannot prove the browser keeps task content and saved drafts through a reload. > - The read and cache changes need a regression suite against the built app. > - This pull request drives those journeys through temporary API failures. > - CI discovers the new suite through the existing e2e shard plan. ## Linked Issues or Issue Description **What happened?** Outage recovery lacked one browser suite covering retained content, write recovery, cached reloads, access errors and sign-out clearing together. **Expected behavior** Keep loaded task content visible through a 20-second API outage. Show one connection banner and its recovery state. Save comments once and retain document edits. Restore approved reads after a reload. Keep real error responses readable and clear query records on sign-out. **Steps to reproduce** Open a task or settings. Return 503 with `tenant_app_unavailable` from browser API requests for 20 seconds. Type a comment, edit a document, or reload. Restore the API and compare the visible result with the saved server state. Repeat with real 403/404 statuses and sign-out. Stacked on Refs #18825, which depends on Refs #18822. Related durable-write foundation: Refs #17960. ## What Changed - Add `disconnect-resilience.spec.ts` with ten browser cases for comments, a queued comment across reload, document autosave, cached settings/sidebar/task reads, legacy Inbox reloads, 403 and 404 responses, sign-out clearing, restored-draft conflict detection, and first-socket outage control. - Assert one connection banner, readable recovery, no transient error boxes or raw codes, retained task text, one saved comment, and the saved document after reload. - Use the real local server and isolated embedded PostgreSQL. Intercept browser API traffic for outages. Control the logout/session response for the sign-out test. Create no agents or model runs. - Register realtime interception before navigation. Track both ends of current sockets, close them at outage start, and keep reconnects unavailable through the HTTP outage deadline. Preserve normal close forwarding and close tracked pairs during teardown. The regression captures the first browser socket without waiting for history or open, then checks that the outage closes it. - Prepare complete server-derived experimental settings and workspace preferences before the legacy Inbox navigation. Its route callbacks serve per-test snapshots across reloads, avoiding reads from disposed route.fetch responses. - Document the direct test command. The existing CI e2e shards already discover this file exactly once; no workflow or package script is added. ## Verification - Current head `228c1b68d30` is merge-ready: all 54 check runs completed (52 successful, one skipped, one neutral), the legacy Snyk status is successful, and both required gates pass. The single failed-jobs debug rerun in run `41752978283` passed on attempt 2, including Runner package job `126328280650` and `verify` job `126332119116`. Greptile Apex reviewed this exact head at 5/5 with zero unresolved threads; the branch has no merge conflicts. The three-PR stack is awaiting operator merge approval. - Original attempt 1 of current-head CI run `41752978283` had 46 successful jobs, one skipped and two failed: Runner package tests and the downstream `verify` gate. GitHub reports lost communication with the Runner host; the test step was cancelled, its log returns HTTP 404, and no Runner diagnostic artifact is available. The underlying test outcome and disconnection cause are unknown. One failed-jobs retry with debug logging was accepted after verifying the unchanged current head and passed on attempt 2. No source change was made; earlier passing CI and local tests do not certify this incomplete Runner result. Current-head CI now passes; the earlier disconnection cause remains unknown. - Preceding head `6531caccf62` reached merge-ready: all 54 check runs completed (52 successful, one skipped, one neutral), the legacy Snyk status was successful, and both required gates passed in run `41749512329`. Greptile reviewed that exact head at 5/5 with zero unresolved threads. Current head `228c1b68d30` carries later account-preference and bulk-selection fixes and now has its own passing current-head CI. - All 23 built-UI outage/write/loading cases pass in 4.5 minutes on exact current head `228c1b68d30`: ten outage, six durable-write and seven loading cases. The run used bundled Chromium, a fresh local-trusted server and isolated embedded PostgreSQL on macOS arm64/Node 25.6.1. Owned temporary server and database stopped. - Current integrated head `228c1b68d30` contains cache revision `fe4f682c879` and read head `09d1db29dea`. The exact application source passes all 1,198 focused tests across twenty-eight files, UI typecheck and both guards. Seventeen new cases cover restored-account settings, owned crown cleanup and pending bulk selections; sixteen fail the previous source and one preserves denial compatibility. All three browser specs pass standalone strict checking. All 23 current-head browser cases pass in 4.5 minutes. - Application revision `9c1a10f25d9` passed all 1,137 focused tests across twenty-one files, UI typecheck and both guards. Exact preceding application/spec head `4fec693ec94` passed all 23 built-UI outage/write/loading cases in 4.6 minutes and standalone strict checking of all three specs. It used bundled Chromium, fresh isolated PostgreSQL and local-trusted auth on macOS arm64/Node 25.6.1. Owned test processes stopped. - Preceding integrated head `870aa709657`, containing cache `cbb891dd37d`, passes all 23 built-UI outage/write/loading cases in 4.6 minutes: ten outage cases, six durable-write cases and seven loading cases. It used bundled Chromium, the built UI, a fresh local-trusted server and isolated embedded PostgreSQL on macOS arm64/Node 25.6.1. Owned test processes stopped. - The earlier head `763fe431b74` passes the affected legacy Inbox outage/reload case (34.3 seconds; 50.8 seconds including startup) and strict three-spec checking after the response-snapshot fix. The preceding head `973a12d77f4` passed all 23 built-UI outage/write/loading cases in 4.6 minutes: ten outage cases, six durable-write cases and seven loading cases. Its first-socket regression fails the previous helper because that socket stays open. The run used bundled Chromium, the built UI, fresh isolated PostgreSQL and local-trusted auth on macOS arm64/Node 25.6.1. That earlier isolated callback revision was verified with its affected case; the later application/spec head `4fec693ec94` also passes the full suite. Owned test servers and databases stopped. - The current base gives known skills/app errors priority over sibling loading. All 60 affected tests and both application UI typechecks passed. Its twelve new regressions failed the preceding source. All 47 recovery-worker tests and seven related Runner fixture variants also passed during the historical CI audit; those focused checks do not claim a full current server/Runner suite. - All 22 outage/write/loading cases passed in 4.7 minutes on preceding integrated head `b7deaaee5da`, including direct cache revision `97c0bc3966f`. The run used the built UI, bundled Chromium and a fresh local-trusted server with isolated embedded PostgreSQL on macOS arm64/Node 25.6.1. It covered all nine outage, six durable-write and seven loading cases. The owned temporary server and database stopped. - All 1,038 focused tests passed on that direct cache revision, with UI typecheck and guards. Six regressions failed the previous source: online retries could trigger a hidden-tab worker reload, and sign-out failed without crypto.randomUUID. Both modes and stamp states now pass. Standalone strict checking passes for all three browser specs. - The restored cached-A/draft-B/server-C test asserts zero replay PATCHes and retains the conflict. The queued-comment case holds fresh session verification after general API recovery and asserts zero POSTs until release. Denial, logout and cached-navigation cases require saved records. Sign-out seeds several company records to exercise native Storage key reordering. Healthy navigation waits for conversation-history readiness. - Earlier runs exposed real cached-header, storage cleanup and replay defects in the base. Those failures and corrected fixture assumptions remain in the discussion. A prior run passed 21/22 while real sockets remained available during simulated HTTP failures. A diagnostic trace established the contradiction. The first repair gated new sockets, but review identified the first pre-existing socket still escaping. This revision intercepts before navigation and closes existing pairs too. The new regression fails the preceding helper, and all 23 current cases pass without weaker assertions. Shared-memory exhaustion prevented two earlier diagnostic startups; only unused segments from completed owned test databases were removed. - All six separate cross-organization link/upload variants passed on an earlier integrated base. Standalone strict checking of that unrelated spec reports five existing errors in its shared fixture; this PR does not claim that check passes. - CI on older head `53d979e384c` failed the legacy Inbox case with `apiResponse.json: Response has been disposed` inside a route.fetch callback. Its other 25 cases passed and one skipped. The seven other browser shards succeeded; the aggregate only reports that child failure. The new snapshot callbacks remove those fetched-response lifetimes. Original CI evidence remains recorded in the discussion. - Greptile Apex reviewed exact current head `228c1b68d30` at 5/5 with no actionable findings and zero unresolved threads (summary updated 2026-11-04 at 01:26:14 UTC). The branch has no merge conflicts. Current-head CI now passes; the earlier disconnection cause remains unknown. Controlled local outages do not prove hosted authentication or a real Cloud restart/deploy. Commands run: ```sh pnpm exec playwright test --config tests/e2e/playwright.config.ts tests/e2e/disconnect-resilience.spec.ts --grep 'a legacy Inbox reload retains' --reporter=list pnpm exec playwright test --config tests/e2e/playwright.config.ts tests/e2e/disconnect-resilience.spec.ts tests/e2e/disconnect-write-recovery.spec.ts tests/e2e/task-loading-frame.spec.ts --reporter=list pnpm exec tsc --ignoreConfig --noEmit --strict --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck tests/e2e/disconnect-resilience.spec.ts tests/e2e/disconnect-write-recovery.spec.ts tests/e2e/task-loading-frame.spec.ts ``` ## Risks The suite adds about three minutes to one e2e shard and depends on current accessible UI controls. It uses controlled outages and logout responses. A passing local run must not be treated as real deployment acceptance. There are no production behavior, schema, dependency, or API changes in this PR. ## Model Used OpenAI `gpt-6.1-sol` through Codex, with xhigh reasoning effort, tool use, and code execution. The context window size is not exposed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for related PRs and linked them above - [x] I have described the issue following the bug report template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id - [x] All 23 built-UI cases pass on current head `228c1b68d30` - [x] All three browser specs pass standalone strict typechecking on the current head - [x] The current application source passes all 1,198 focused tests, UI typecheck and both guards - [x] I have added tests and updated relevant documentation - [x] I have considered and documented risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Paperclip Co-authored-by: Bender (Fable) Co-authored-by: Claude Fable 5.1 --- doc/DEVELOPING.md | 2 + tests/e2e/disconnect-resilience.spec.ts | 392 ++++++++++++++++++++++++ 2 files changed, 394 insertions(+) create mode 100644 tests/e2e/disconnect-resilience.spec.ts diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 59489b8f097..1e276816779 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -4567,3 +4567,5 @@ Cached agents retain only safe adapter display fields and an empty runtime confi A restored task shows its header and properties while uncached history is still loading. History is not persisted, and its normal placeholder remains until reads recover. The header stays visible across recovery. Query-cache cleanup snapshots storage keys before deletion because native storage can reorder them as records are removed. Flush cleanup affects only its owning account and yields if another account becomes active. + +Run `pnpm exec playwright test -c tests/e2e/playwright.config.ts tests/e2e/disconnect-resilience.spec.ts` for the built-UI outage regressions. The suite uses fresh isolated local data, 20-second API interceptions, and a controlled sign-out session response. It covers retained tasks, comment and document recovery, queued-comment reloads, cached settings/sidebar/task reads, legacy Inbox reloads, real error status handling and sign-out clearing across several company records. The existing CI e2e shards discover it automatically. A real hosted restart or deploy remains a separate acceptance check. diff --git a/tests/e2e/disconnect-resilience.spec.ts b/tests/e2e/disconnect-resilience.spec.ts new file mode 100644 index 00000000000..e74ea8da084 --- /dev/null +++ b/tests/e2e/disconnect-resilience.spec.ts @@ -0,0 +1,392 @@ +import { expect, test, type APIRequestContext, type APIResponse, type Page, type WebSocketRoute } from "@playwright/test"; + +test.describe.configure({ timeout: 90_000 }); +test.use({ actionTimeout: 10_000 }); + +type SocketPair = { client: WebSocketRoute; server: WebSocketRoute }; +type RealtimeControl = { until: number; sockets: Set }; +const realtime = new WeakMap(); +const outageClose = { code: 1013, reason: "Simulated outage" }; + +async function closeSockets(control: RealtimeControl) { + const sockets = [...control.sockets]; + control.sockets.clear(); + await Promise.all(sockets.flatMap(({ client, server }) => [client.close(outageClose), server.close(outageClose)])); +} + +test.beforeEach(async ({ page }) => { + const control: RealtimeControl = { until: 0, sockets: new Set() }; + realtime.set(page, control); + // Intercept before navigation, including the first connection while its + // server handshake is still pending. Reloads use the same outage deadline. + await page.routeWebSocket("**/api/companies/*/events/ws", async client => { + if (Date.now() < control.until) { await client.close(outageClose); return; } + const server = client.connectToServer(); + const pair = { client, server }; + control.sockets.add(pair); + // Custom close handlers must preserve Playwright's normal forwarding. + client.onClose((code, reason) => control.sockets.delete(pair) ? server.close({ code, reason }) : undefined); + server.onClose((code, reason) => control.sockets.delete(pair) ? client.close({ code, reason }) : undefined); + }); +}); + +test.afterEach(async ({ page }) => { + const control = realtime.get(page); + if (!control) return; + control.until = Infinity; + await closeSockets(control); + realtime.delete(page); +}); + +async function json(response: APIResponse) { + expect(response.ok(), `${response.status()}: ${await response.text()}`).toBe(true); + return response.json(); +} + +async function fixture(request: APIRequestContext) { + const company = await json(await request.post("/api/companies", { data: { name: `Disconnect recovery ${Date.now()}` } })); + const issue = await json(await request.post(`/api/companies/${company.id}/issues`, { + data: { title: "Keep this task visible", description: "Saved task context stays visible.", status: "backlog", assigneeAgentId: null, assigneeUserId: null }, + })); + return { company, issue, route: `/${company.issuePrefix}/issues/${issue.identifier}` }; +} + +async function outage(page: Page) { + const until = Date.now() + 20_000; + let failures = 0; + const control = realtime.get(page); + if (!control) throw new Error("Realtime interception must be installed before navigation"); + control.until = until; + await page.route("**/api/**", async route => { + if (Date.now() >= until) return route.fallback(); + failures += 1; + await route.fulfill({ status: 503, json: { error: "tenant_app_unavailable" } }); + }); + await closeSockets(control); + return { until, failures: () => failures }; +} + +test("an outage closes the first realtime socket before it can report a late recovery", async ({ page, request }) => { + const data = await fixture(request); + await page.addInitScript(() => { + const browser = window as typeof window & { outageSockets: WebSocket[]; initialOutageSockets: WebSocket[] }; + browser.outageSockets = []; + browser.initialOutageSockets = []; + browser.WebSocket = class extends WebSocket { + constructor(url: string | URL, protocols?: string | string[]) { + super(url, protocols); + if (new URL(url, location.href).pathname.endsWith("/events/ws")) browser.outageSockets.push(this); + } + }; + }); + try { + await page.goto(data.route); + // Do not wait for open or history readiness: catch the first connection + // as soon as the app creates it, including a pending handshake. + await expect.poll(() => page.evaluate(() => { + const browser = window as typeof window & { outageSockets: WebSocket[]; initialOutageSockets: WebSocket[] }; + browser.initialOutageSockets = browser.outageSockets.filter(socket => socket.readyState <= WebSocket.OPEN); + return browser.initialOutageSockets.length; + })).toBeGreaterThan(0); + const interruption = await outage(page); + await expect.poll(() => page.evaluate(() => (window as typeof window & { initialOutageSockets: WebSocket[] }) + .initialOutageSockets.every(socket => socket.readyState === WebSocket.CLOSED))).toBe(true); + expect(await page.evaluate(async () => (await fetch("/api/health")).status)).toBe(503); + await expect.poll(interruption.failures).toBeGreaterThan(0); + await expectReconnecting(page); + expect(Date.now()).toBeLessThan(interruption.until); + } finally { + await request.patch(`/api/companies/${data.company.id}`, { data: { status: "archived" } }); + } +}); + +async function expectReconnecting(page: Page) { + await expect(page.locator('[data-connection-status="reconnecting"]')).toHaveCount(1, { timeout: 10_000 }); + await expect(page.getByText("tenant_app_unavailable", { exact: false })).toHaveCount(0); + await expect(page.locator('p.text-destructive, span.text-destructive, div.text-destructive, [role="alert"]') + .filter({ hasNotText: /^Danger Zone$/ }).filter({ visible: true })).toHaveCount(0); +} + +async function expectCachedTask(page: Page, issue: { id: string; title: string }) { + await expect.poll(() => page.evaluate(task => Object.entries(localStorage).some(([key, value]) => { + if (!key.startsWith("paperclip:query-cache:v1:")) return false; + try { + const record = JSON.parse(value) as { state?: { queries?: Array<{ state?: { data?: { id?: string; title?: string } } }> } }; + return record.state?.queries?.some(query => query.state?.data?.id === task.id && query.state.data.title === task.title) ?? false; + } catch { return false; } + }), issue)).toBe(true); +} + +test("a comment typed during a 20-second outage posts once and loaded task content stays visible", async ({ page, request }) => { + const data = await fixture(request); + const body = "A comment retained through the disconnect"; + let successfulPosts = 0; + page.on("response", response => { + if (response.request().method() === "POST" && response.url().endsWith("/comments") + && response.request().postDataJSON()?.body === body && response.ok()) successfulPosts += 1; + }); + try { + await page.goto(data.route); + const header = page.getByTestId("issue-detail-header"); + await expect(page.getByTestId("task-chat-history-content")).toBeVisible({ timeout: 30_000 }); + await expect(header.getByRole("heading", { name: data.issue.title, exact: true })).toBeVisible(); + await expect(page.getByText(data.issue.description, { exact: true }).first()).toBeVisible(); + const interruption = await outage(page); + await page.getByTestId("task-chat-composer-input").locator('[contenteditable="true"]').fill(body); + await page.getByTestId("task-chat-composer-send").click(); + await expect.poll(interruption.failures).toBeGreaterThan(0); + await expectReconnecting(page); + await expect(header.getByRole("heading", { name: data.issue.title, exact: true })).toBeVisible(); + await expect(page.getByText(body, { exact: true }).first()).toBeVisible(); + expect((await json(await request.get(`/api/issues/${data.issue.id}/comments`))).filter((comment: { body: string }) => comment.body === body)).toHaveLength(0); + await expect(page.locator('[data-connection-status="back_online"]')).toBeVisible({ timeout: 35_000 }); + expect(Date.now()).toBeGreaterThanOrEqual(interruption.until); + await expect.poll(async () => (await json(await request.get(`/api/issues/${data.issue.id}/comments`))) + .filter((comment: { body: string }) => comment.body === body).length).toBe(1); + await expect.poll(() => successfulPosts).toBe(1); + await page.reload(); + await expect(page.getByText(body, { exact: true })).toBeVisible(); + expect((await json(await request.get(`/api/issues/${data.issue.id}`))).assigneeAgentId).toBeNull(); + } finally { + await request.patch(`/api/companies/${data.company.id}`, { data: { status: "archived" } }); + } +}); + +test("a queued comment survives an outage reload and sends once after session verification", async ({ page, request }) => { + const data = await fixture(request); + const body = "A pending comment retained across reload"; + let reloading = false, replayPosts = 0, sessionHeld = false; + let releaseSession!: () => void; + const sessionGate = new Promise(resolve => { releaseSession = resolve; }); + page.on("request", request => { + if (reloading && request.method() === "POST" && request.url().endsWith("/comments") && request.postDataJSON()?.body === body) replayPosts += 1; + }); + try { + await page.goto(data.route); + await expect(page.getByTestId("task-chat-history-content")).toBeVisible({ timeout: 30_000 }); + const interruption = await outage(page); + await page.route("**/api/auth/get-session", async route => { + if (Date.now() < interruption.until) return route.fallback(); + sessionHeld = true; + await sessionGate; + await route.continue(); + }); + await page.getByTestId("task-chat-composer-input").locator('[contenteditable="true"]').fill(body); + await page.getByTestId("task-chat-composer-send").click(); + await expectReconnecting(page); + await expect.poll(() => page.evaluate(text => Object.values(localStorage).some(value => value.includes(text)), body)).toBe(true); + reloading = true; + await page.reload(); + await expect(page.getByTestId("issue-detail-header").getByRole("heading", { name: data.issue.title, exact: true })).toBeVisible(); + await expectReconnecting(page); + expect((await json(await request.get(`/api/issues/${data.issue.id}/comments`))).filter((comment: { body: string }) => comment.body === body)).toHaveLength(0); + await expect(page.locator('[data-connection-status="back_online"]')).toBeVisible({ timeout: 35_000 }); + await expect.poll(() => sessionHeld).toBe(true); + expect(await page.evaluate(async () => (await fetch("/api/health")).ok)).toBe(true); + expect(replayPosts).toBe(0); + releaseSession(); + await expect.poll(async () => (await json(await request.get(`/api/issues/${data.issue.id}/comments`))) + .filter((comment: { body: string }) => comment.body === body).length).toBe(1); + expect(replayPosts).toBe(1); + await page.reload(); + await expect(page.getByText(body, { exact: true })).toBeVisible(); + } finally { + releaseSession(); + await request.patch(`/api/companies/${data.company.id}`, { data: { status: "archived" } }); + } +}); + +test("a document edit stays visible and saves after a 20-second outage", async ({ page, request }) => { + const data = await fixture(request); + const original = "The original document body."; + const edited = "The document edit retained during the outage."; + try { + await json(await request.put(`/api/issues/${data.issue.id}/documents/handoff`, { + data: { title: "Recovery handoff", format: "markdown", body: original, baseRevisionId: null }, + })); + await page.setViewportSize({ width: 1440, height: 960 }); + await page.addInitScript(() => localStorage.setItem("paperclip:panel-visible", "true")); + await page.goto(data.route); + await expect(page.getByTestId("task-chat-history-content")).toBeVisible({ timeout: 30_000 }); + const panel = page.locator("aside").filter({ has: page.getByRole("button", { name: "Open a new tab", exact: true }) }); + await panel.getByRole("tab", { name: "Artifacts", exact: true }) + .or(panel.getByRole("button", { name: "Artifacts", exact: true })).click(); + const document = panel.locator("#document-handoff"); + await expect(document.getByText(original, { exact: true })).toBeVisible(); + await document.getByRole("button", { name: "Document actions", exact: true }).click(); + await page.getByRole("menuitem", { name: "Edit document", exact: true }).click(); + await outage(page); + await document.locator('[contenteditable="true"]').fill(edited); + await expectReconnecting(page); + await expect(document.locator('[contenteditable="true"]')).toContainText(edited); + expect((await json(await request.get(`/api/issues/${data.issue.id}/documents/handoff`))).body).toBe(original); + await expect(page.locator('[data-connection-status="back_online"]')).toBeVisible({ timeout: 35_000 }); + await expect.poll(async () => (await json(await request.get(`/api/issues/${data.issue.id}/documents/handoff`))).body).toBe(edited); + await page.reload(); + await expect(page.getByText(edited, { exact: true }).first()).toBeVisible(); + } finally { + await request.patch(`/api/companies/${data.company.id}`, { data: { status: "archived" } }); + } +}); + +test("a reload during a 20-second outage restores settings, sidebar and a recent task", async ({ page, request }) => { + const data = await fixture(request); + try { + await page.goto(data.route); + await expect(page.getByTestId("task-chat-history-content")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("issue-detail-header").getByRole("heading", { name: data.issue.title, exact: true })).toBeVisible(); + await expectCachedTask(page, data.issue); + await page.getByRole("button", { name: "Open account menu", exact: true }).click(); + await page.getByRole("link", { name: "Settings", exact: true }).click(); + await expect(page.getByRole("heading", { name: "General", exact: true })).toBeVisible(); + await expect(page.locator('input[type="text"]').first()).toHaveValue(data.company.name); + await expect.poll(() => page.evaluate(() => Object.keys(localStorage).some(key => key.startsWith("paperclip:query-cache:v1:") && key.endsWith(":root")))).toBe(true); + await outage(page); + await page.reload(); + await expect(page.getByRole("heading", { name: "General", exact: true })).toBeVisible(); + await expect(page.locator('input[type="text"]').first()).toHaveValue(data.company.name); + await expectReconnecting(page); + await page.goBack(); + await expect(page.getByRole("link").filter({ hasText: data.issue.title }).first()).toBeVisible(); + await expect(page.getByTestId("issue-detail-header").getByRole("heading", { name: data.issue.title, exact: true })).toBeVisible(); + await expectReconnecting(page); + await expect(page.getByText(data.issue.description, { exact: true }).first()).toBeVisible(); + await expect(page.locator('[data-connection-status="back_online"]')).toBeVisible({ timeout: 35_000 }); + } finally { + await request.patch(`/api/companies/${data.company.id}`, { data: { status: "archived" } }); + } +}); + +test("a legacy Inbox reload retains its saved task rows during a 20-second outage", async ({ page, request }) => { + const data = await fixture(request); + try { + const session = await json(await request.get("/api/auth/get-session")); + await json(await request.patch(`/api/issues/${data.issue.id}`, { data: { status: "todo", assigneeUserId: session.user.id } })); + // Serve complete server-derived settings throughout reloads without + // retaining route.fetch responses that can be disposed during teardown. + const [experimental, preferences] = await Promise.all([ + request.get("/api/instance/settings/experimental").then(json), + request.get("/api/workspace-preferences/me").then(json), + ]); + await page.route("**/api/instance/settings/experimental", route => route.fulfill({ + json: { ...experimental, enableClassicTaskInterface: true, enableStreamlinedUi: false }, + })); + await page.route("**/api/workspace-preferences/me", route => route.fulfill({ + json: { ...preferences, coordinatorWorkspaceEnabled: false }, + })); + await page.goto(`/${data.company.issuePrefix}/inbox/all`); + const row = page.locator("[data-inbox-item]").filter({ hasText: data.issue.title }); + await expect(row).toBeVisible({ timeout: 30_000 }); + await expect.poll(() => page.evaluate(id => Object.entries(localStorage).some(([key, value]) => { + if (!key.startsWith("paperclip:query-cache:v1:")) return false; + try { + return JSON.parse(value).state?.queries?.some((query: { queryKey: unknown[]; state: { data?: Array<{ id: string }> } }) => + query.queryKey.includes("compact") && Array.isArray(query.state.data) && query.state.data.some(task => task.id === id)) ?? false; + } catch { return false; } + }), data.issue.id)).toBe(true); + await outage(page); + await page.reload(); + await expectReconnecting(page); + await expect(row).toBeVisible(); + await expect(page.getByRole("status", { name: "Loading tasks" })).toHaveCount(0); + await expect(page.locator('[data-connection-status="back_online"]')).toBeVisible({ timeout: 35_000 }); + await expect(row).toBeVisible(); + } finally { + await request.patch(`/api/companies/${data.company.id}`, { data: { status: "archived" } }); + } +}); + +for (const status of [403, 404]) test(`a genuine ${status} replaces cached task content with readable copy`, async ({ page, request }) => { + const data = await fixture(request); + try { + await page.goto(data.route); + await expect(page.getByTestId("task-chat-history-content")).toBeVisible({ timeout: 30_000 }); + await expectCachedTask(page, data.issue); + await page.route("**/api/issues/*?view=core", route => route.fulfill({ status, json: { error: status === 403 ? "forbidden" : "not_found" } })); + await page.reload(); + await expect(page.getByText(status === 403 ? "You don’t have permission to do that." : "This item doesn’t exist or was moved.", { exact: true })).toBeVisible(); + await expect(page.getByTestId("issue-detail-header")).toHaveCount(0); + await expect(page.locator("[data-connection-status]")).toHaveCount(0); + } finally { + await request.patch(`/api/companies/${data.company.id}`, { data: { status: "archived" } }); + } +}); + +test("signing out clears saved query data before a later outage reload", async ({ page, request }) => { + const data = await fixture(request); + let signedOut = false; + const additionalCompanies: Array<{ id: string }> = []; + try { + // Exercise native Storage deletion with multiple company records; a + // single-company run can conceal key reordering during removal. + for (let i = 0; i < 6; i += 1) additionalCompanies.push(await json(await request.post("/api/companies", { data: { name: `Sign-out cache scope ${i}` } }))); + await page.goto(data.route); + await expect(page.getByTestId("task-chat-history-content")).toBeVisible({ timeout: 30_000 }); + await expectCachedTask(page, data.issue); + await expect.poll(() => page.evaluate(() => Object.keys(localStorage).filter(key => + key.startsWith("paperclip:query-cache:v1:") && !key.endsWith(":active")).length)).toBeGreaterThanOrEqual(8); + await page.getByRole("button", { name: "Open account menu", exact: true }).click(); + await page.getByRole("link", { name: "Settings", exact: true }).click(); + await expect(page.getByRole("heading", { name: "General", exact: true })).toBeVisible(); + await outage(page); + await page.route("**/api/auth/get-session", route => signedOut ? route.fulfill({ status: 200, json: null }) : route.fallback()); + await page.route("**/api/auth/sign-out", route => { + signedOut = true; + return route.fulfill({ status: 200, json: { success: true } }); + }); + page.once("dialog", dialog => dialog.accept()); + await page.getByRole("button", { name: "Sign out", exact: true }).click(); + await expect.poll(() => signedOut).toBe(true); + await expect.poll(() => page.evaluate(() => Object.keys(localStorage).filter(key => key.startsWith("paperclip:query-cache:v1:")))).toEqual([]); + await page.goto(data.route); + await expect(page.getByTestId("issue-detail-header")).toHaveCount(0); + expect(await page.evaluate(() => Object.keys(localStorage).filter(key => key.startsWith("paperclip:query-cache:v1:")))).toEqual([]); + } finally { + for (const company of additionalCompanies) await request.patch(`/api/companies/${company.id}`, { data: { status: "archived" } }); + await request.patch(`/api/companies/${data.company.id}`, { data: { status: "archived" } }); + } +}); + + +test("a restored title draft cannot overwrite an edit made after its cached base", async ({ page, request }) => { + const data = await fixture(request); + const draft = "My restored title B"; + const newer = "A newer server title C"; + let releaseReads!: () => void; + const gate = new Promise(resolve => { releaseReads = resolve; }); + let replayWrites = 0; + page.on("request", incoming => { + if (incoming.method() === "PATCH" && incoming.postDataJSON()?.title === draft) replayWrites += 1; + }); + try { + await page.goto(data.route); + await expect(page.getByTestId("task-chat-history-content")).toBeVisible({ timeout: 30_000 }); + await expectCachedTask(page, data.issue); + const session = await json(await request.get("/api/auth/get-session")); + const owner = session.user.id as string; + const key = `paperclip:inline-draft:v2:${owner}:issue:${data.issue.id}:title`; + await page.evaluate(({ key, draft, base }) => localStorage.setItem(key, JSON.stringify({ version: 1, draft, base })), { key, draft, base: data.issue.title }); + const paths = new Set([data.issue.id, data.issue.identifier].map(ref => `/api/issues/${ref}`.toLowerCase())); + await page.route("**/api/issues/**", async route => { + if (route.request().method() === "GET" && paths.has(new URL(route.request().url()).pathname.toLowerCase())) await gate; + await route.fallback(); + }); + await json(await request.patch(`/api/issues/${data.issue.id}`, { data: { title: newer } })); + const verifiedSession = page.waitForResponse(response => new URL(response.url()).pathname === "/api/auth/get-session" && response.ok()); + await page.reload(); + await verifiedSession; + const editor = page.getByTestId("issue-detail-header").locator("textarea"); + await expect(editor).toHaveValue(draft, { timeout: 30_000 }); + expect(replayWrites).toBe(0); + releaseReads(); + await expect(editor).toHaveAttribute("aria-invalid", "true", { timeout: 30_000 }); + await expect(editor).toHaveValue(draft); + expect((await json(await request.get(`/api/issues/${data.issue.id}`))).title).toBe(newer); + expect(await page.evaluate(key => localStorage.getItem(key), key)).toContain(draft); + expect(replayWrites).toBe(0); + } finally { + releaseReads(); + await page.unroute("**/api/issues/**"); + await request.patch(`/api/companies/${data.company.id}`, { data: { status: "archived" } }); + } +});