diff --git a/.agents/inbox/2026-10-28-async-errors-must-retain-account-ownership.md b/.agents/inbox/2026-10-28-async-errors-must-retain-account-ownership.md new file mode 100644 index 00000000..20090fe5 --- /dev/null +++ b/.agents/inbox/2026-10-28-async-errors-must-retain-account-ownership.md @@ -0,0 +1,46 @@ +# Async errors must retain account ownership instead of relabeling newer state + +Date: 2026-10-28 UTC. Independent review of layout candidate +`c4df01e1567af10b6c4b0dc9d666ad946c1e0b75` found a Settings disclosure race. +The existing receipt-ordering lesson covered database writes; this incident +concerns the owner of already loaded UI state on an asynchronous error path. + +## Evidence + +The retained owned probe and unit regression ran with Node 22.18.0: + +```sh +node /tmp/graphdone-resolution-20261027/layout-settings-review-negative/probe.cjs +npx vitest run src/hooks/__tests__/useLayoutRecovery.test.ts --root packages/web +``` + +A starts a discard while its account read is held. B signs in and B's pending +graph list loads. A's discard rejects after the held read returns. The old catch +spreads the newest state, including B's scopes, and overwrites its owner with A. +Returning to A before its next read resolves printed: + +```text +visibleToA [{"graphId":"b-private-graph","count":1}] +error The signed-in account changed. No further layout changes were discarded. +``` + +`layout-settings-negative-unit.log` retains the failing hook test. After the +correction, `layout-settings-corrected-unit.log` reports 43 passed across seven +focused suites, including the new delayed account-switch regression. + +## Cause and corrective rule + +An owner label is provenance, not a presentation field to replace on failure. +Associate operations with the current owner scope identity, ignore stale +completion/error paths, and retain prior scopes only if their recorded owner +matches. Scope busy state too, so a previous owner's completion cannot clear a +new owner's pending operation. Number refresh requests to ignore older reads. +Test errors after a replacement account's data has loaded, and inspect the +first render after returning before the fresh read completes. + +## Limits + +The probe and unit test use a controlled account store. They prove the UI +interleaving, not hosted authorization or backend isolation. Existing real +browser account-switch and deleted-graph regressions remain required, as do +renewed exact-head review and strict CI before integration. diff --git a/.agents/inbox/2026-10-28-freeze-index-for-strict-runs.md b/.agents/inbox/2026-10-28-freeze-index-for-strict-runs.md new file mode 100644 index 00000000..74a2698c --- /dev/null +++ b/.agents/inbox/2026-10-28-freeze-index-for-strict-runs.md @@ -0,0 +1,47 @@ +# Freeze the index as well as runtime source during strict test runs + +Date: 2026-10-28 UTC. Existing mobile touch evidence and the unified runner +already require matching source fingerprints. A layout recovery run exposed a +workflow mistake: staging the prepared implementation while the gate was active +changed the repository fingerprint even though the running renderer's bytes +were unchanged. + +## Evidence + +The owned run used: + +```sh +TEST_URL=http://localhost:28818 QC_BROWSER_PORT=29818 xvfb-run --auto-servernum \ + node tests/run-unified.mjs --sequence layout-recovery \ + --out /tmp/graphdone-resolution-20261027/layout-recovery-strict-final +``` + +During execution, `git add` staged the implementation, tests and documentation. +The 2026-10-28 result was: + +```text +layout-recovery: passed, 9 passed, 0 failed, 0 warned, 0 skipped (247.5s) +source-integrity: failed +The tested repository changed during this run; rerun against a stable source tree. +overall: FAILED, 9 passed, 1 failed, 0 warned, 0 skipped +``` + +The report remains retained. It is not counted as an accepted run. The existing +runner detected the mutation without a new implementation or weaker threshold. + +## Cause and corrective rule + +The source freeze includes repository/index state, not only files imported by +the running browser. Finish edits and staging, commit the candidate, then start +the exact-head gates. During a gate, keep that worktree's source, documentation, +index and runtime fixed. Read-only review and work in other owned worktrees can +continue. Wait for exit and inspect the entire report, including integrity and +execution records, before editing or crediting its case count. + +## Limits + +This incident establishes a provenance failure, not a failed browser behavior. +It also does not authorize ignoring source-integrity when only staging appears +to have changed. A subsequent complete, frozen-source run is required. Normal +ignored test outputs and owned external evidence remain separate from tracked +source; do not change the harness exclusions to hide an implementation mutation. diff --git a/.agents/inbox/2026-10-28-persisted-layout-receipts-do-not-authorize-replay.md b/.agents/inbox/2026-10-28-persisted-layout-receipts-do-not-authorize-replay.md new file mode 100644 index 00000000..ba1df45d --- /dev/null +++ b/.agents/inbox/2026-10-28-persisted-layout-receipts-do-not-authorize-replay.md @@ -0,0 +1,117 @@ +# A persisted layout receipt does not by itself authorize replay + +Date: 2026-10-28 UTC. Issue #181. Existing guidance distinguishes the old in-tab +queue from crash recovery; the first persistence prototype exposed additional +ordering boundaries during independent review. The corrections and regression controls now accompany this lesson. Final +exact-head review and CI remain the integration gate; preparatory local results +are not a release or hosted acceptance record. + +## Evidence + +The prototype is based on `525ca4e`, with its tracked patch and relevant source +hashes retained as `layout-reviewed-prototype.patch` and +`layout-reviewed-prototype-sha256.txt` in the owned dated evidence directory. +Twenty-three focused tests passed, but they did not cover the following cases. + +On 2026-10-28, Node 22.18 ran the retained owned probes: + +```sh +node /tmp/graphdone-resolution-20261027/layout-review-probes/probe.mjs +node /tmp/graphdone-resolution-20261027/layout-review-probes/discard-race.mjs +``` + +Sanitized output in `layout-review-negative.log`: + +- Another tab saved `x=20` and cleared its receipt; the old tab then replayed + `x=10` and reported no pending work. +- A server accepted `x=30`, but local receipt cleanup failed. The prototype + reported `acknowledged=false`; retry sent the position again, replacing a + newer server value of `99` in the probe. +- A quota failure left one memory-only position. Discard tried to persist that + unwanted position first and failed, leaving it pending. +- Holding discard's commit and calling flush sent the position being discarded. + +Source review also found that asynchronous recovery could arrive after D3 +initialization without changing live coordinates, and that mapping pending +positions into a fresh work-item array on every camera render invalidated LOD +projection/label memoization. Both require actual browser regressions. + +A second review reproduced a subtler variant after the first seven ordering +regressions passed. A quota-failed, memory-only edit could persist itself on +reconnect after another tab had already saved and cleared a newer receipt: + +```text +oldAfterQuota {"pending":1,"local":0} +beforeOldReconnect {"server":20,"receipts":[]} +afterOldReconnect {"server":10,"sent":[["new",20],["old",10]],"pending":0} +``` + +The matching bundled probe is retained at +`layout-review-probes/corrected-prototype/volatile-stale.mjs`; the new failing +unit assertion is retained in `layout-volatile-negative-unit.log`. A real +IndexedDB/two-tab browser regression now covers the same ordering boundary. + +The held-IndexedDB graph-switch regression also failed before the simulation +scope correction: after A→B, a synthetic pagehide raised **Layout needs +attention** because A's still-live simulation tried to queue through B. Graph +filters prevented a cross-graph server write, but did not establish correct +renderer ownership. `layout-switch-negative.log` retains that failed run. + +The pending-layout LOD browser regression passed with stable memo inputs. +Deliberately recreating the source array during every camera render made it +fail after pan: labels moved between slots, including a 96px row change. The +corrected renderer was then restored byte-for-byte; the negative control remains +in `layout-lod-identity-negative.log`. + +## Cause and corrective rule + +Revision comparison during deletion protects newer receipts from old cleanup; +it does not establish eligibility to send an old receipt. Coordinate current +receipt eligibility, sending and acknowledgement across tabs. Preserve known +server acceptance separately from local cleanup, so cleanup retry cannot +silently become another network write. Serialize discard and flush, and do not +require a failed write to succeed before abandoning memory-only changes. + +Persistence must also preserve eligibility. Compare the staged batch's original +observed graph generation inside the same account transaction that persists it. +Do not silently rebase a failed staged edit after reading a newer generation. +Retain generation fences after ACK/discard; a bounded graph-fence map falls back +to an eviction watermark advanced to the account counter only when an old entry +is evicted. Ordinary unrelated graph saves must not advance that fallback. Validate +safe integers and fail at corruption/overflow. The first fallback used the +current counter and unnecessarily rejected an unrelated graph’s first save; +`layout-fence-unrelated-negative.log` retains that failure. This deliberately rejects some +concurrent independent edits rather than guessing how to merge coordinates. + +Hydrate layout recovery before initializing the graph, or explicitly apply an +eligible recovered revision to the live renderer. Keep semantic source identity +stable during camera changes. Durability status must reflect the actual boundary +completed, including the absence of a successful initial storage read. Stop the +previous owner/graph simulation during layout cleanup and validate its scope at +each persistence boundary; hiding its SVG does not stop its callbacks. + +## Limits and remaining work + +The probes use an owned account-store double; they are evidence of the ordering +failures. The subsequent strict browser run passes eight cases against the owned +API/Neo4j stack, with real IndexedDB and an isolated browser process group killed +by SIGKILL. It includes delayed hydration, reload before ACK, restart, quota and +two-tab ordering, account isolation, deleted/revoked access and Settings discard, +accepted-save cleanup, and unsupported capabilities. See the +[layout recovery record](../../docs/quality/LAYOUT_RECOVERY_2026-10-28.md) for +commands, the pending-layout LOD gate, retained fixture failures and limitations. + +The first process-loss run failed strict profile removal with ENOTEMPTY. Killing +only the browser parent did not bound all profile writers. The fixture now owns +an isolated process group, kills that exact group and awaits the child exit +before reopening/removing its profile. No broad process matching or removal +retry was substituted for ownership. The next run caught a fixture viewport +mistake: the restarted browser used its default width and the desktop selector +was correctly hidden. The fixture now explicitly restores its intended viewport. +Both failures remain retained; neither was an application durability pass. + +Issue #181 excludes server-side multi-actor conflict resolution. A crash after +server acceptance but before durable receipt deletion leaves an ambiguous +at-least-once boundary without a server idempotency/revision contract. Do not +claim exactly-once replay or protection from later writes on another device. +Known acceptance in the live process still requires truthful cleanup-only retry. diff --git a/.agents/issues/INDEX.md b/.agents/issues/INDEX.md index 2620fd35..6ed0ed7a 100644 --- a/.agents/issues/INDEX.md +++ b/.agents/issues/INDEX.md @@ -2,6 +2,9 @@ | Symptom | Canonical lesson and regression | | --- | --- | +| All browser cases pass but unified source-integrity fails after staging | [Freeze the index as well as source](../inbox/2026-10-28-freeze-index-for-strict-runs.md); commit before the gate, leave repository content and index unchanged | +| Switching accounts briefly exposes another account’s pending graph IDs after a delayed error | [Async errors must retain account ownership](../inbox/2026-10-28-async-errors-must-retain-account-ownership.md); `useLayoutRecovery.test.ts` delayed A-discard → B-load → A-return regression | +| A persisted layout replays after another tab saved, cleanup failure resends accepted work, or discard races recovery | [Persisted receipts do not authorize replay](../inbox/2026-10-28-persisted-layout-receipts-do-not-authorize-replay.md); `recoverablePositionQueue.test.ts`, `layoutPersistence.test.ts`, `layout-recovery.spec.ts`; retained stale/volatile replay and hydration-switch negatives | | A tool upgrade's negative test exits before the intended failing task executes, or a hoisted renderer path changes | [Require executed consumer evidence](../inbox/2026-10-27-tool-upgrades-need-executed-consumer-evidence.md); installed Turbo and Mermaid contracts | | Pruned runtime starts failing imports, retains dev tools or loses auth across container replacement | [Installed consumer and persistence proof](../inbox/2026-10-27-runtime-images-need-installed-consumer-proof.md); `tests/runtime/run-image.mjs` | | A toolchain upgrade changes test counts, loses matcher types or drops unimported coverage | [Preserve discovery and consumer behavior](../inbox/2026-10-27-toolchain-upgrades-must-preserve-test-contracts.md); [migration evidence](../../docs/quality/TOOLCHAIN_MIGRATION_2026-10-27.md) | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94ab3775..7fafbb7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -194,6 +194,11 @@ jobs: env: TEST_URL: http://localhost:3127 CI: true + - name: Durable layout recovery acceptance + run: xvfb-run --auto-servernum node tests/run-unified.mjs --sequence layout-recovery --out test-artifacts/ci-layout-recovery + env: + TEST_URL: http://localhost:3127 + CI: true - name: Route compatibility acceptance run: xvfb-run --auto-servernum node tests/run-unified.mjs --sequence router-navigation --out test-artifacts/ci-router-navigation env: diff --git a/docs/quality/DURABILITY_REVIEW_2026-10-23.md b/docs/quality/DURABILITY_REVIEW_2026-10-23.md index 37278c37..01760830 100644 --- a/docs/quality/DURABILITY_REVIEW_2026-10-23.md +++ b/docs/quality/DURABILITY_REVIEW_2026-10-23.md @@ -19,3 +19,12 @@ The provider retains at most 16 graph/account scopes and 10,000 distinct pending ## Limits Pending layouts are tab-memory state, not a persistent outbox: a forced close, crash, or browser ignoring the unload warning can still lose them. While in List or another route, the queue waits for the original graph to reopen; the user-facing retry message says so. Already-issued requests can complete under their original account, but later requests cannot use a replacement account's credentials. These tests do not establish browser rendering, live-service health, cross-device conflict resolution, or backend atomic deletion protection; those require separate integration/release checks. + + +## 2026-10-28 follow-up + +The historical memory-only limit above is addressed by issue #181's +[network-backed layout recovery implementation and evidence](LAYOUT_RECOVERY_2026-10-28.md). +It reuses the existing account IndexedDB store, adds account/graph/node receipts +and bounded local generation fences, and keeps offline-sync durability separate. +The original navigation, owner/token and deletion regressions remain required. diff --git a/docs/quality/LAYOUT_RECOVERY_2026-10-28.md b/docs/quality/LAYOUT_RECOVERY_2026-10-28.md new file mode 100644 index 00000000..23697c5e --- /dev/null +++ b/docs/quality/LAYOUT_RECOVERY_2026-10-28.md @@ -0,0 +1,139 @@ +# Pending layout recovery across reload and browser process loss + +Issue #181 extends the existing navigation-safe position queue. The underlying +Core source starts at reviewed `525ca4e`; integrated dev `780a759` has the same +complete tree. Local preparatory results below used the isolated working tree, +not unchanged `525ca4e`. The containing PR's exact head, independent review and +final CI determine integration eligibility. + +## Supported behavior + +In network-backed mode, dragging queues the newest coordinates per owner, +graph and node. The existing `graphdone-offline` account store commits the +receipt in a strict IndexedDB transaction before the queue sends it. The UI +separately reports positions retained only in this tab, positions committed on +this device, pending sends, and server acceptance. A known server acceptance +followed by failed receipt cleanup remains accepted; retry performs cleanup +without sending the coordinates again. + +The queue hydrates before graph initialization. A delayed or failed initial +storage read cannot render an older server layout and silently ignore recovered +coordinates. Switching owner/graph stops the previous simulation and checks its +scope at save callbacks. Pending position snapshots and their work-item overlay +retain identity across camera renders, preserving LOD projection and label +placement memoization. + +Leaving Graph for Work list detaches the callback but retains the queue. Reload +or browser restart recovers committed receipts when the same account and graph +return. Each actual mutation still checks the current owner, exact bearer +snapshot and graph filter. A rejected or empty mutation result retains work. +Logout and another account cannot replay the original account's positions. + +Settings → Pending layouts lists this account's pending graph scopes, including +graphs that are no longer accessible or have been deleted. Discard requires an +explicit confirmation there. In the active graph, Retry and Discard unsent +layout operate on its scoped queue. Discard never needs to persist a memory-only +edit first, and reconnect cannot send while its discard is in progress. +Already-issued server requests cannot be recalled by discarding local work. + +In `VITE_OFFLINE_SYNC=true` mode, the existing local project mutation and sync +engine remain the authority. Successful local mutation means saved on this +device, while upload/conflict status remains in the existing sync UI. This +change adds no second layout outbox in that mode. Network receipts and their +generation fences are excluded from portable offline backups. + +## Ordering, retention and limits + +- A fresh receipt check, send and cleanup run under a Web Lock keyed by owner, + graph and node. Missing Web Locks are explicit and prevent uncoordinated + network replay. Missing/unusable IndexedDB or quota failures also remain + explicit; no durable or server-saved status is invented. +- Persistence compares the batch's original observed graph generation inside + its account transaction. A stale volatile edit cannot write itself back after + another tab saves. Conflicting edits remain in the tab with a visible message; + discard and reload to adopt the newer layout. Reads never silently rebase an + already-staged edit. This conservatively rejects simultaneous edits on + different nodes in the same graph too. +- ACK removes only the matching receipt revision. Explicit durable discard also + advances its graph fence. Account counters and at most 16 recent graph fences + survive the last receipt's removal. An eviction advances a retained watermark to the new account counter; + absent graphs fall back to that watermark, preventing reuse of an older + generation. Unrelated graph saves without eviction leave that fallback stable. Eviction can cause + a conservative conflict after unrelated graph activity. Invalid or exhausted + safe-integer counters fail explicitly. +- Persistent pending work is bounded to 16 graphs and 10,000 positions per + account. The provider retains at most 16 account/graph scopes and the existing + per-scope queue bound. Capacity rejection preserves earlier work. There is no + automatic receipt expiry; ACK or explicit discard removes it. +- Only IDs, finite bounded coordinates, timestamps and revision metadata are + stored by this feature. It stores no bearer/session credentials. It does not + encrypt browser data or survive clearing/eviction of the browser's storage. +- SIGKILL acceptance proves recovery after this owned browser process group + terminates, following a completed IndexedDB transaction. It does not prove + recovery after power loss, hardware failure or an operating-system storage + failure. Edits still labelled only-in-tab can be lost on termination. +- A crash or lost response after the server has applied a write but before its + receipt is removed is an ambiguous at-least-once boundary. In-flight requests + may also finish after a browser dies. Server idempotency/revision ordering and + multi-device conflict resolution are outside #181; this is not exactly-once + replay or a guarantee against later writes by another actor. + +## Regression evidence + +The dated, owned local evidence directory is +`/tmp/graphdone-resolution-20261027`. It is not a published artifact or a +substitute for the repository regressions and PR checks. + +| Boundary | Retained negative/control and corrective evidence | +| --- | --- | +| Provider loss | Original provider-replacement test failed with zero recovered sends; corrected provider recovers the newest coordinates. | +| Receipt and discard ordering | Seven ordering cases failed before correction. Tests cover stale durable replay, accepted-save cleanup, quota-only discard, pending discard versus flush, same-item serialization, unsupported locks and stable snapshot identity. | +| Volatile stale replay | Matching bundled probe changed server `20 → 10`; the added regression failed before generation CAS. The actual two-tab/quota browser test now preserves the newer real server save. | +| Renderer scope | Held-IDB A→B switch plus pagehide raised erroneous layout attention before simulation ownership cleanup. The corrected browser test passes with no wrong-scope receipt. | +| Process ownership | First process-loss run failed profile cleanup; owned process-group termination fixes the writer lifetime. A second failed fixture assertion exposed the missing restart viewport, then explicit viewport restoration fixed it. | +| Fence isolation | A fresh graph incorrectly conflicted after an unrelated graph saved. `layout-fence-unrelated-negative.log` retains the failure. Advancing the fallback only on actual eviction preserves independent graph edits while retaining stale/evicted fence rejection. | +| Pending-layout LOD | Reintroducing a fresh item-array identity moves overview labels between slots during a real pan (including a 96px row change). `layout-lod-identity-negative.log` retains the expected failure. The corrected memoized source passes the same camera/label/zero-write assertions. | +| LOD fixture setup | First pending-layout probe passed camera translation instead of graph coordinates to the minimap hook. The corrected probe then wheeled over the minimap after panning. Both failures are retained; the final fixture targets an overview card explicitly. | + +The strict eight-case browser run in `layout-recovery-strict-first/report.json` +passed 8, failed 0, warned 0 and skipped 0, with exit 0, no timeout or evidence +errors. It uses real owned API/Neo4j data and IndexedDB: two real drags/reload +before ACK, delayed hydration, owned process restart, graph switching, actual +two-tab quota ordering, account isolation, revoked writes and deleted nodes/ +graphs, Settings discard, accepted-response cleanup, and unavailable storage/ +coordination. The pending-layout LOD case passes separately, and its deliberate identity-regression negative fails on changed label slots. All nine cases subsequently passed, but that run was rejected by the source-integrity gate because staging changed the repository fingerprint during execution. It is retained as failed evidence, not final acceptance; a committed, frozen-tree run is required. + +The local full coverage invocation passed 739 tests; 26 existing database +integration cases remain explicitly separate, not counted as passes. All 193 +harness tests passed. Build, types and lint passed; lint retains the existing +warning inventory and build retains its chunk-size warning. These numbers do +not establish comprehensive statement/branch coverage, hosted D1 parity or +release readiness. + +Run the browser gate against an owned Core stack, with explicit Neo4j fixture +credentials (the access-revocation test changes only its newly created graph): + +```sh +TEST_URL=http://localhost:3127 xvfb-run --auto-servernum \ + node tests/run-unified.mjs --sequence layout-recovery --out test-artifacts/layout-recovery +``` + +The sequence enforces one worker, zero retries and failure-only trace retention; +CI runs it in the existing Smoke Gate. Its fixture registers graph IDs at +creation, restores any temporary ownership change, deletes edges before nodes, +and verifies graph removal. The process-loss case owns and removes only its +temporary browser profile. Local Chrome and CI Chromium are separate browser +builds; neither is evidence for all engines, real phones or hosted D1. + +The frozen `c4df01e1567af10b6c4b0dc9d666ad946c1e0b75` run passed all nine +browser cases in 266.9 seconds, with exit 0, no timeout/evidence errors, and clean +matching start/end fingerprints. Its full unit invocation passed 740 tests +(with the same 26 gated database skips), and 193 harness tests passed. Build, +types and lint passed. Independent review then found a separate delayed Settings +discard/account-switch race; that candidate was not cleared for integration. +The new hook regression failed before correction and all 43 focused tests pass +after it. See the [account-label lesson](../../.agents/inbox/2026-10-28-async-errors-must-retain-account-ownership.md). +The containing corrected commit still requires renewed exact-head gates. + +The existing smoke Graph→List→Graph/ACK/reload case, auth-link token checks, +offline project transfer, semantic LOD and motion gates remain required. diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 54e33205..3b92e8c2 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -2,6 +2,7 @@ import { skipBackgroundPoll } from '../lib/hostedUsage'; import { QuickEditSurface } from './QuickEditSurface'; import React, { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from 'react'; import { SemanticGraphOverview } from './SemanticGraphOverview'; +import { LayoutSaveStatus } from './LayoutSaveStatus'; import ReactDOM from 'react-dom/client'; import { createPortal } from 'react-dom'; import * as d3 from 'd3'; @@ -773,9 +774,13 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const positionSaveQueue = usePositionSaveQueue( position => saveNodePosition(position.id, position.x, position.y), - message => showError('Layout could not be saved', message || 'Keep this tab open and return to this graph after reconnecting to retry your pending positions.'), + message => showError('Layout needs attention', message || 'Check the layout save status before closing this tab. Return to this graph after reconnecting to retry pending positions.'), currentUser && currentGraph ? { ownerId: currentUser.id, graphId: currentGraph.id } : null, ); + const layoutScope = currentUser && currentGraph ? JSON.stringify([currentUser.id, currentGraph.id]) : null; + const activeLayoutScopeRef = useRef(layoutScope); + activeLayoutScopeRef.current = layoutScope; + const simulationLayoutScopeRef = useRef(null); const positionSaveQueueRef = useRef(positionSaveQueue); positionSaveQueueRef.current = positionSaveQueue; @@ -786,7 +791,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // pin to. Called debounced when the sim settles and on page hide. const persistAllPositions = useCallback(() => { const sim = simulationRef.current; - if (!sim) return; + if (!sim || simulationLayoutScopeRef.current !== activeLayoutScopeRef.current) return; const moved = (sim.nodes() as any[]).filter((n: any) => typeof n.x === 'number' && typeof n.y === 'number' && (Math.abs((n.positionX ?? 0) - n.x) > 1 || Math.abs((n.positionY ?? 0) - n.y) > 1) @@ -1105,8 +1110,18 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // Remove unused handleEdgeClick to fix TypeScript warning - const workItems: WorkItem[] = workItemsData?.workItems || []; - + const layoutRecoveryReady = !currentUser || !currentGraph || !!positionSaveQueue.state?.recovered || !!positionSaveQueue.error; + const pendingPositions = positionSaveQueue.state?.positions; + const workItems = useMemo(() => { + if (!layoutRecoveryReady) return []; + const saved: WorkItem[] = workItemsData?.workItems || []; + if (!pendingPositions?.size) return saved; + return saved.map(item => { + const pending = pendingPositions.get(item.id); + return pending ? { ...item, positionX: pending.x, positionY: pending.y } : item; + }); + }, [workItemsData?.workItems, pendingPositions, layoutRecoveryReady]); + // Text refresh utility function const refreshTextVisibility = useCallback(() => { if (!d3 || !containerRef.current) return; @@ -1338,6 +1353,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const stopActiveSimulation = useCallback(() => { simulationRef.current?.on('tick', null).on('end.persist', null).alphaTarget(0).stop(); simulationRef.current = null; + simulationLayoutScopeRef.current = null; delete (window as any).__graphPerf; }, []); @@ -2032,6 +2048,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // Simple 2D force simulation const simulation = d3.forceSimulation(visibleNodes as any); + simulationLayoutScopeRef.current = activeLayoutScopeRef.current; simulationRef.current = simulation; // Store reference for resize handling // RESTORE PRESERVED POSITIONS to prevent nodes jumping to origin @@ -2397,6 +2414,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i d.y = d.fy; }) .on('end', (event, d: any) => { + if (simulationRef.current !== simulation || simulationLayoutScopeRef.current !== activeLayoutScopeRef.current) return; // Check if this is edge creation end if (mousedownNodeRef.current && mousedownNodeRef.current.id !== d.id) { const sourceId = mousedownNodeRef.current.id; @@ -4278,7 +4296,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // Store simulation reference for resize handling const simulationRef = useRef | null>(null); - useEffect(() => () => stopActiveSimulation(), [stopActiveSimulation]); + useLayoutEffect(() => () => stopActiveSimulation(), [layoutScope, stopActiveSimulation]); const zoomBehaviorRef = useRef | null>(null); const [semanticOverviewActive, setSemanticOverviewActive] = useState(false); const handleOverviewActive = useCallback((active: boolean) => { @@ -5265,13 +5283,14 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // Set empty state overlay when no graph is selected const showNoGraphMessage = !currentGraph; - if (loading || edgesLoading) { + if (loading || edgesLoading || !layoutRecoveryReady) { return (
Loading graph data...
-
Connecting to database...
+ +
{layoutRecoveryReady ? 'Connecting to database...' : 'Recovering saved layout...'}
); @@ -5508,6 +5527,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i return (
+ (); + if (import.meta.env.VITE_OFFLINE_SYNC === 'true') return null; + return ( +
+

Pending layouts

+

Return to an accessible graph to retry its pending layout. You can discard local changes here if a graph was deleted or you no longer have access. Discarding does not undo positions already saved to the server.

+ {recovery.error &&

{recovery.error}

} + {recovery.loading &&

Checking this device for pending layouts…

} + {!recovery.loading && !recovery.scopes.length && !recovery.error &&

No pending layouts for this account on this device.

} +
    + {recovery.scopes.map(scope =>
  • +

    {availableGraphs.find(graph => graph.id === scope.graphId)?.name || 'Unavailable graph'}

    +

    {scope.graphId}

    +

    {scope.count} pending position{scope.count === 1 ? '' : 's'}

    + {confirm === scope.graphId ?
    + + +
    : } +
  • )} +
+ + ); +} diff --git a/packages/web/src/components/LayoutSaveStatus.tsx b/packages/web/src/components/LayoutSaveStatus.tsx new file mode 100644 index 00000000..f84cf127 --- /dev/null +++ b/packages/web/src/components/LayoutSaveStatus.tsx @@ -0,0 +1,24 @@ +import type { usePositionSaveQueue } from '../hooks/usePositionSaveQueue'; + +export function LayoutSaveStatus({ queue }: { queue: ReturnType }) { + const { state, error } = queue; + if (!error && (!state || !(state.pending || state.acknowledged || state.storageError || state.cleanupError || state.sendError))) return null; + return ( +
+ {error &&

{error}

} + {state && <> + {state.pending > 0 &&

{state.local} saved on this device; {state.pending - state.local} pending only in this tab.

} + {state.unsent > 0 &&

Waiting to {state.networkBacked ? 'send' : 'save'} {state.unsent} layout change{state.unsent === 1 ? '' : 's'}.

} + {state.cleanupPending > 0 &&

The server accepted {state.cleanupPending} layout change{state.cleanupPending === 1 ? '' : 's'}. Device receipt cleanup is pending.

} + {state.pending === 0 && state.acknowledged &&

{state.networkBacked ? 'Layout saved to the server.' : 'Layout saved on this device. Check sync status for upload progress.'}

} + {state.storageError &&

Layout recovery is unavailable: {state.storageError}{state.pending > state.local ? ' Keep this tab open.' : ''}

} + {state.cleanupError &&

{state.cleanupError}

} + {state.sendError &&

{state.sendError}

} + {(state.pending > 0 || state.storageError) &&
+ + {state.unsent > 0 && } +
} + } +
+ ); +} diff --git a/packages/web/src/components/__tests__/LayoutSaveStatus.test.tsx b/packages/web/src/components/__tests__/LayoutSaveStatus.test.tsx new file mode 100644 index 00000000..bbc38fd8 --- /dev/null +++ b/packages/web/src/components/__tests__/LayoutSaveStatus.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { LayoutSaveStatus } from '../LayoutSaveStatus'; +import type { usePositionSaveQueue } from '../../hooks/usePositionSaveQueue'; + +type Queue = ReturnType; +const state: NonNullable = { + ready: true, recovered: true, pending: 0, unsent: 0, cleanupPending: 0, local: 0, + storageError: '', cleanupError: '', sendError: '', acknowledged: false, networkBacked: true, positions: new Map(), +}; +const queue = (values: Partial): Queue => ({ + state: { ...state, ...values }, queue: vi.fn(), flush: vi.fn(), discard: vi.fn(), error: undefined, saving: false, +}); + +describe('layout save status', () => { + it('does not claim server acceptance when recovery storage failed before any save', () => { + render(); + expect(screen.getByRole('status')).toHaveTextContent('Layout recovery is unavailable'); + expect(screen.getByRole('status')).not.toHaveTextContent('Layout saved to the server'); + expect(screen.getByRole('button', { name: 'Retry layout save' })).toBeEnabled(); + }); + + it('offers cleanup alone for accepted changes and never describes them as unsent', () => { + render(); + expect(screen.getByRole('status')).toHaveTextContent('The server accepted 1 layout change'); + expect(screen.getByRole('status')).not.toHaveTextContent('Waiting to send'); + expect(screen.getByRole('button', { name: 'Retry receipt cleanup' })).toBeEnabled(); + expect(screen.queryByRole('button', { name: 'Discard unsent layout' })).toBeNull(); + }); + + it('distinguishes volatile edits and durable receipts while allowing explicit discard', () => { + render(); + expect(screen.getByRole('status')).toHaveTextContent('1 saved on this device; 1 pending only in this tab'); + expect(screen.getByRole('status')).toHaveTextContent('Waiting to send 2 layout changes'); + expect(screen.getByRole('button', { name: 'Discard unsent layout' })).toBeEnabled(); + }); +}); diff --git a/packages/web/src/hooks/__tests__/useDurablePositionSaveQueue.test.ts b/packages/web/src/hooks/__tests__/useDurablePositionSaveQueue.test.ts new file mode 100644 index 00000000..99a8b2e5 --- /dev/null +++ b/packages/web/src/hooks/__tests__/useDurablePositionSaveQueue.test.ts @@ -0,0 +1,51 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PositionSaveProvider, usePositionSaveQueue } from '../usePositionSaveQueue'; +import { setSession } from '../../lib/authStorage'; +import { emptyAccount, type Account } from '../../lib/offline/model'; + +const fixture = vi.hoisted(() => ({ accounts: new Map() })); +vi.mock('../../lib/offline/store', () => ({ + readAccount: async (owner: string) => structuredClone(fixture.accounts.get(owner) || emptyAccount()), + editAccount: async (owner: string, edit: (account: Account) => unknown) => { + const account = structuredClone(fixture.accounts.get(owner) || emptyAccount()); + const result = edit(account); + fixture.accounts.set(owner, account); + return result; + }, +})); + +const scope = { ownerId: 'durable-owner', graphId: 'durable-graph' }; +const token = (id: string) => `header.${btoa(JSON.stringify({ userId: id }))}.signature`; +afterEach(() => vi.unstubAllGlobals()); +beforeEach(() => { + const locks = new Map>(); + vi.stubGlobal('navigator', { locks: { + request: (name: string, _options: unknown, action: () => Promise) => { + const operation = (locks.get(name) || Promise.resolve()).catch(() => undefined).then(action); + locks.set(name, operation.catch(() => undefined)); + return operation; + }, + } }); + fixture.accounts.clear(); + localStorage.clear(); + sessionStorage.clear(); + setSession(token(scope.ownerId), { id: scope.ownerId }, true); +}); + +describe('durable layout recovery', () => { + it('recovers the newest unsent position after the entire provider is replaced', async () => { + const offline = vi.fn().mockRejectedValue(new Error('owned network outage')); + const first = renderHook(() => usePositionSaveQueue(offline, vi.fn(), scope), { wrapper: PositionSaveProvider }); + await waitFor(() => expect(first.result.current.state?.recovered).toBe(true)); + act(() => { first.result.current.queue('task', 1, 2); first.result.current.queue('task', 9, 10); }); + await act(() => first.result.current.flush()); + first.unmount(); + const online = vi.fn().mockResolvedValue(undefined); + const second = renderHook(() => usePositionSaveQueue(online, vi.fn(), scope), { wrapper: PositionSaveProvider }); + await act(() => second.result.current.flush()); + expect(online).toHaveBeenCalledExactlyOnceWith({ id: 'task', x: 9, y: 10 }); + expect(JSON.stringify([...fixture.accounts.values()])).not.toMatch(/authToken|signature|header\./); + second.unmount(); + }); +}); diff --git a/packages/web/src/hooks/__tests__/useLayoutRecovery.test.ts b/packages/web/src/hooks/__tests__/useLayoutRecovery.test.ts new file mode 100644 index 00000000..e4d97380 --- /dev/null +++ b/packages/web/src/hooks/__tests__/useLayoutRecovery.test.ts @@ -0,0 +1,97 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PositionSaveProvider, useLayoutRecovery } from '../usePositionSaveQueue'; +import { clearSession, setSession } from '../../lib/authStorage'; +import { emptyAccount, type Account } from '../../lib/offline/model'; + +const fixture = vi.hoisted(() => ({ accounts: new Map(), pause: undefined as Promise | undefined, ownerPauses: new Map>() })); +vi.mock('../../lib/offline/store', () => ({ + channel: null, + readAccount: async (owner: string) => { + await (fixture.ownerPauses.get(owner) || fixture.pause); + return structuredClone(fixture.accounts.get(owner) || emptyAccount()); + }, + editAccount: async (owner: string, edit: (account: Account) => unknown) => { + const account = structuredClone(fixture.accounts.get(owner) || emptyAccount()); + const result = edit(account); + fixture.accounts.set(owner, account); + return result; + }, +})); + +const token = (id: string) => `header.${btoa(JSON.stringify({ userId: id }))}.signature`; +beforeEach(() => { + fixture.pause = undefined; + fixture.accounts.clear(); + fixture.ownerPauses.clear(); + localStorage.clear(); + sessionStorage.clear(); + setSession(token('owner-a'), { id: 'owner-a' }, true); + fixture.accounts.set('owner-a', { ...emptyAccount(), layoutOutbox: { version: 1, graphs: { + '"missing-graph"': { '"task"': { id: 'task', x: 10, y: 20, updatedAt: 1000, revision: 'pending' } }, + } } }); + vi.stubGlobal('navigator', { locks: { request: (_name: string, _options: unknown, action: () => Promise) => action() } }); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe('account layout recovery settings', () => { + it('loads and discards an inaccessible graph without a graph attachment or network send', async () => { + const { result } = renderHook(() => useLayoutRecovery('owner-a'), { wrapper: PositionSaveProvider }); + await waitFor(() => expect(result.current.scopes).toEqual([{ graphId: 'missing-graph', count: 1 }])); + await act(() => result.current.discard('missing-graph')); + await waitFor(() => expect(result.current.scopes).toEqual([])); + expect(fixture.accounts.get('owner-a')?.layoutOutbox).toBeUndefined(); + expect(fixture.accounts.get('owner-a')?.layoutFences?.counter).toBe(1); + }); + + it('does not show another account receipts or accept a mismatched token and cached user', async () => { + setSession(token('owner-b'), { id: 'owner-b' }, true); + const view = renderHook(({ owner }) => useLayoutRecovery(owner), { initialProps: { owner: 'owner-b' }, wrapper: PositionSaveProvider }); + await waitFor(() => expect(view.result.current.loading).toBe(false)); + expect(view.result.current.scopes).toEqual([]); + act(() => setSession(token('owner-b'), { id: 'owner-a' }, true)); + view.rerender({ owner: 'owner-a' }); + expect(view.result.current.scopes).toEqual([]); + expect(view.result.current.error).toMatch(/Sign in again/); + await act(() => view.result.current.discard('missing-graph')); + expect(fixture.accounts.get('owner-a')?.layoutOutbox).toBeDefined(); + }); + + + it('does not relabel a replacement account scopes after a delayed discard rejects', async () => { + fixture.accounts.set('owner-b', { ...emptyAccount(), layoutOutbox: { version: 1, graphs: { + '"b-private-graph"': { '"b-task"': { id: 'b-task', x: 1, y: 2, updatedAt: 1000, revision: 'b-pending' } }, + } } }); + const view = renderHook(({ owner }) => useLayoutRecovery(owner), { initialProps: { owner: 'owner-a' }, wrapper: PositionSaveProvider }); + await waitFor(() => expect(view.result.current.scopes).toEqual([{ graphId: 'missing-graph', count: 1 }])); + let release!: () => void; + fixture.ownerPauses.set('owner-a', new Promise(resolve => { release = resolve; })); + let discard!: Promise; + act(() => { discard = view.result.current.discard('missing-graph'); }); + act(() => setSession(token('owner-b'), { id: 'owner-b' }, true)); + view.rerender({ owner: 'owner-b' }); + await waitFor(() => expect(view.result.current.scopes).toEqual([{ graphId: 'b-private-graph', count: 1 }])); + await act(async () => { release(); await discard; }); + expect(view.result.current.scopes).toEqual([{ graphId: 'b-private-graph', count: 1 }]); + fixture.ownerPauses.set('owner-a', new Promise(() => undefined)); + act(() => setSession(token('owner-a'), { id: 'owner-a' }, true)); + view.rerender({ owner: 'owner-a' }); + expect(view.result.current.scopes).toEqual([]); + expect(view.result.current.loading).toBe(true); + expect(fixture.accounts.get('owner-a')?.layoutOutbox).toBeDefined(); + expect(fixture.accounts.get('owner-b')?.layoutOutbox).toBeDefined(); + view.unmount(); + }); + + it('stops a pending discard when logout occurs before recovery finishes', async () => { + const { result } = renderHook(() => useLayoutRecovery('owner-a'), { wrapper: PositionSaveProvider }); + await waitFor(() => expect(result.current.scopes).toHaveLength(1)); + let release!: () => void; + fixture.pause = new Promise(resolve => { release = resolve; }); + let discard!: Promise; + act(() => { discard = result.current.discard('missing-graph'); }); + act(() => clearSession()); + await act(async () => { release(); await discard; }); + expect(fixture.accounts.get('owner-a')?.layoutOutbox).toBeDefined(); + }); +}); diff --git a/packages/web/src/hooks/__tests__/usePositionSaveQueue.test.ts b/packages/web/src/hooks/__tests__/usePositionSaveQueue.test.ts index b8c3098b..a96e7fb8 100644 --- a/packages/web/src/hooks/__tests__/usePositionSaveQueue.test.ts +++ b/packages/web/src/hooks/__tests__/usePositionSaveQueue.test.ts @@ -1,9 +1,21 @@ import { createElement, StrictMode } from 'react'; import { act, render, renderHook, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { PositionSaveProvider, usePositionSaveQueue, type PositionSaveScope } from '../usePositionSaveQueue'; import { clearSession, setSession } from '../../lib/authStorage'; import type { PositionSave } from '../../lib/positionSaveQueue'; +import { emptyAccount, type Account } from '../../lib/offline/model'; + +const storage = vi.hoisted(() => new Map()); +vi.mock('../../lib/offline/store', () => ({ + readAccount: async (owner: string) => structuredClone(storage.get(owner) || emptyAccount()), + editAccount: async (owner: string, edit: (account: Account) => unknown) => { + const account = structuredClone(storage.get(owner) || emptyAccount()); + const result = edit(account); + storage.set(owner, account); + return result; + }, +})); const token = (id: string) => `header.${btoa(JSON.stringify({ userId: id }))}.signature`; const scope = { ownerId: 'owner-a', graphId: 'graph-a' }; @@ -29,7 +41,17 @@ function leaving() { return event.defaultPrevented; } +afterEach(() => vi.unstubAllGlobals()); beforeEach(() => { + const locks = new Map>(); + vi.stubGlobal('navigator', { locks: { + request: (name: string, _options: unknown, action: () => Promise) => { + const operation = (locks.get(name) || Promise.resolve()).catch(() => undefined).then(action); + locks.set(name, operation.catch(() => undefined)); + return operation; + }, + } }); + storage.clear(); localStorage.clear(); sessionStorage.clear(); setSession(token('owner-a'), { id: 'owner-a' }, true); @@ -39,6 +61,7 @@ describe('position saving in the graph', () => { it('retains failed positions and the unload warning across Graph → List → Graph navigation', async () => { const save = vi.fn().mockRejectedValue(new Error('Connection lost')); const view = navigation(save); + await waitFor(() => expect(view.queue.state?.recovered).toBe(true)); view.queue.queue('task', 12, 34); await act(() => view.queue.flush()); view.go(false); @@ -57,6 +80,7 @@ describe('position saving in the graph', () => { const save = vi.fn().mockRejectedValue(new Error('Connection lost')); const onError = vi.fn(); const { result } = renderHook(() => usePositionSaveQueue(save, onError, scope), { wrapper: PositionSaveProvider }); + await waitFor(() => expect(result.current.state?.recovered).toBe(true)); result.current.queue('task', 12, 34); await act(() => result.current.flush()); expect(onError).toHaveBeenCalledTimes(1); @@ -77,8 +101,10 @@ describe('position saving in the graph', () => { let reject!: (error: Error) => void; const save = vi.fn().mockImplementationOnce(() => new Promise((_, fail) => { reject = fail; })).mockResolvedValue(undefined); const view = navigation(save); + await waitFor(() => expect(view.queue.state?.recovered).toBe(true)); view.queue.queue('task', 1, 2); const saving = view.queue.flush(); + await waitFor(() => expect(save).toHaveBeenCalledTimes(1)); view.go(false); expect(leaving()).toBe(true); view.go(true); @@ -99,9 +125,11 @@ describe('position saving in the graph', () => { } const tree = (graphId: 'graph-a' | 'graph-b') => createElement(PositionSaveProvider, { children: createElement(Graph, { graphId }) }); const view = render(tree('graph-a')); + await waitFor(() => expect(queue.state?.recovered).toBe(true)); queue.queue('same-task', 1, 2); await act(() => queue.flush()); view.rerender(tree('graph-b')); + await waitFor(() => expect(queue.state?.recovered).toBe(true)); queue.queue('same-task', 9, 10); await act(() => queue.flush()); expect(saves['graph-b']).toHaveBeenCalledTimes(1); @@ -117,9 +145,10 @@ describe('position saving in the graph', () => { const resolvers: Array<() => void> = []; const save = vi.fn().mockImplementation(() => new Promise(done => { resolvers.push(done); })); const view = navigation(save); + await waitFor(() => expect(view.queue.state?.recovered).toBe(true)); for (let index = 0; index < 5; index++) view.queue.queue(`task-${index}`, index, index); const saving = view.queue.flush(); - expect(save).toHaveBeenCalledTimes(4); + await waitFor(() => expect(save).toHaveBeenCalledTimes(4)); act(() => { clearSession(); }); view.go(false); act(() => { setSession(token('owner-b'), { id: 'owner-b' }, true); }); @@ -142,6 +171,7 @@ describe('position saving in the graph', () => { it('keeps the original owner’s failed layout isolated until the same account and graph return', async () => { const save = vi.fn().mockRejectedValue(new Error('offline')); const view = navigation(save); + await waitFor(() => expect(view.queue.state?.recovered).toBe(true)); view.queue.queue('task', 1, 2); await act(() => view.queue.flush()); act(() => { clearSession(); window.dispatchEvent(new Event('online')); }); @@ -169,6 +199,7 @@ describe('position saving in the graph', () => { const view = navigation(save, onError); for (let index = 0; index < 16; index++) { view.go(true, { ...scope, graphId: `graph-${index}` }); + await waitFor(() => expect(view.queue.state?.recovered).toBe(true)); expect(view.queue.queue('task', index, index)).toBe(true); await act(() => view.queue.flush()); } @@ -185,6 +216,7 @@ describe('position saving in the graph', () => { const save = vi.fn().mockResolvedValue(undefined); const wrapper = ({ children }: { children: React.ReactNode }) => createElement(StrictMode, {}, createElement(PositionSaveProvider, { children })); const { result, unmount } = renderHook(() => usePositionSaveQueue(save, vi.fn(), scope), { wrapper }); + await waitFor(() => expect(result.current.state?.recovered).toBe(true)); result.current.queue('task', 1, 2); await act(() => result.current.flush()); expect(save).toHaveBeenCalledTimes(1); diff --git a/packages/web/src/hooks/usePositionSaveQueue.ts b/packages/web/src/hooks/usePositionSaveQueue.ts index 79079f66..9edee386 100644 --- a/packages/web/src/hooks/usePositionSaveQueue.ts +++ b/packages/web/src/hooks/usePositionSaveQueue.ts @@ -1,17 +1,32 @@ -import { createContext, createElement, useCallback, useContext, useEffect, useMemo, useRef, type ReactNode } from 'react'; -import { createPositionSaveQueue, type PositionSave } from '../lib/positionSaveQueue'; +import { createContext, createElement, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'; +import { type PositionSave } from '../lib/positionSaveQueue'; +import { createRecoverablePositionQueue } from '../lib/recoverablePositionQueue'; import { getSessionOwnerId } from '../lib/authStorage'; +import { readLayoutScopes } from '../lib/layoutPersistence'; +import { channel } from '../lib/offline/store'; export interface PositionSaveScope { ownerId: string; graphId: string; } type Attachment = { save: (position: PositionSave) => Promise; onError: (message?: string) => void }; type Entry = PositionSaveScope & { - queue: ReturnType; + queue: ReturnType; attachment?: Attachment; reportedFailure: boolean; }; function createRegistry() { const entries = new Map(); + const listeners = new Set<() => void>(); + let version = 0; + let notificationPending = false; + const changed = () => { + if (notificationPending) return; + notificationPending = true; + queueMicrotask(() => { + notificationPending = false; + version++; + listeners.forEach(listener => listener()); + }); + }; const get = (scope: PositionSaveScope) => { const key = JSON.stringify([scope.ownerId, scope.graphId]); let entry = entries.get(key); @@ -25,8 +40,10 @@ function createRegistry() { const next: Entry = { ...scope, reportedFailure: false, - queue: createPositionSaveQueue({ - maxPending: 10000, + queue: createRecoverablePositionQueue({ + ...scope, + networkBacked: import.meta.env.VITE_OFFLINE_SYNC !== 'true', + onChange: changed, save: async position => { if (getSessionOwnerId() !== next.ownerId || !next.attachment) throw new Error('Layout saving is waiting for its graph and signed-in owner.'); await next.attachment.save(position); @@ -43,20 +60,92 @@ function createRegistry() { try { await entry.queue.flush(); entry.reportedFailure = false; - } catch { + } catch (error) { if (entry.attachment && getSessionOwnerId() === entry.ownerId && !entry.reportedFailure) { entry.reportedFailure = true; - entry.attachment.onError(); + entry.attachment.onError(error instanceof Error ? error.message : undefined); } } }; return { get, flush, + entries: () => [...entries.values()], + discardScope: async (scope: PositionSaveScope) => { + if (getSessionOwnerId() !== scope.ownerId) throw new Error('Sign in as the layout owner before discarding changes.'); + const existing = entries.get(JSON.stringify([scope.ownerId, scope.graphId])); + const recovered = createRecoverablePositionQueue({ ...scope, networkBacked: true, onChange() {}, save: async () => { throw new Error('Settings cannot send layout changes.'); } }); + await recovered.initialized; + if (!recovered.state.recovered) throw new Error(recovered.state.storageError || 'Device layout recovery is unavailable.'); + if (getSessionOwnerId() !== scope.ownerId) throw new Error('The signed-in account changed. No further layout changes were discarded.'); + if (existing) await existing.queue.discard(); + if (getSessionOwnerId() !== scope.ownerId) throw new Error('The signed-in account changed. No further layout changes were discarded.'); + await recovered.discard(); + changed(); + }, + peek: (key: string | null) => key ? entries.get(key) : undefined, + subscribe: (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; }, + snapshot: () => version, reconnect: () => { for (const entry of entries.values()) void flush(entry); }, hasPending: () => [...entries.values()].some(entry => entry.queue.size || entry.queue.saving), }; } +export function useLayoutRecovery(requestedOwnerId?: string) { + const ownerId = getSessionOwnerId() === requestedOwnerId ? requestedOwnerId : undefined; + const registry = useContext(PositionSaveContext); + if (!registry) throw new Error('PositionSaveProvider is required.'); + const version = useSyncExternalStore(registry.subscribe, registry.snapshot); + const [saved, setSaved] = useState<{ ownerId?: string; scopes: Awaited>; error: string }>({ scopes: [], error: '' }); + const active = useRef({ ownerId }); + if (active.current.ownerId !== ownerId) active.current = { ownerId }; + const scope = active.current; + const [busyScope, setBusyScope] = useState(); + const busy = busyScope === scope; + const isCurrent = () => active.current === scope && getSessionOwnerId() === ownerId; + useEffect(() => { + if (!ownerId || import.meta.env.VITE_OFFLINE_SYNC === 'true') return; + let cancelled = false; + let request = 0; + const refresh = async () => { + const currentRequest = ++request; + try { + const scopes = await readLayoutScopes(ownerId); + if (!cancelled && currentRequest === request && active.current === scope && getSessionOwnerId() === ownerId) setSaved({ ownerId, scopes, error: '' }); + } catch (error) { + if (!cancelled && currentRequest === request && active.current === scope && getSessionOwnerId() === ownerId) setSaved({ ownerId, scopes: [], error: error instanceof Error ? error.message : 'Device layout storage is unavailable.' }); + } + }; + void refresh(); + window.addEventListener('graphdone-local-change', refresh); + channel?.addEventListener('message', refresh); + return () => { + cancelled = true; + window.removeEventListener('graphdone-local-change', refresh); + channel?.removeEventListener('message', refresh); + }; + }, [ownerId, registry, version, scope]); + const scopes = new Map>(); + if (saved.ownerId === ownerId) for (const scope of saved.scopes) scopes.set(scope.graphId, new Set(scope.positions.map(position => position.id))); + for (const entry of registry.entries().filter(entry => entry.ownerId === ownerId && entry.queue.size)) { + const ids = scopes.get(entry.graphId) || new Set(); + for (const id of entry.queue.state.positions.keys()) ids.add(id); + scopes.set(entry.graphId, ids); + } + const discard = async (graphId: string) => { + if (!ownerId || busy) return; + setBusyScope(scope); + try { + await registry.discardScope({ ownerId, graphId }); + if (!isCurrent()) return; + const scopes = await readLayoutScopes(ownerId); + if (isCurrent()) setSaved({ ownerId, scopes, error: '' }); + } catch (error) { + if (isCurrent()) setSaved(previous => ({ ownerId, scopes: previous.ownerId === ownerId ? previous.scopes : [], error: error instanceof Error ? error.message : 'The pending layout could not be discarded.' })); + } finally { setBusyScope(previous => previous === scope ? undefined : previous); } + }; + return { scopes: [...scopes].map(([graphId, ids]) => ({ graphId, count: ids.size })), loading: !!ownerId && saved.ownerId !== ownerId, error: requestedOwnerId && !ownerId ? 'Sign in again to inspect this account’s pending layouts.' : saved.ownerId === ownerId ? saved.error : '', busy, discard }; +} + const PositionSaveContext = createContext | null>(null); export function PositionSaveProvider({ children }: { children: ReactNode }) { @@ -89,6 +178,7 @@ export function usePositionSaveQueue( ) { const registry = useContext(PositionSaveContext); if (!registry) throw new Error('PositionSaveProvider is required.'); + useSyncExternalStore(registry.subscribe, registry.snapshot); const key = scope ? JSON.stringify([scope.ownerId, scope.graphId]) : null; const latest = useRef({ save, onError, key }); latest.current = { save, onError, key }; @@ -100,12 +190,19 @@ export function usePositionSaveQueue( onError: message => { if (latest.current.key === key) latest.current.onError(message); }, }), [key]); const active = useRef(); + const [scopeError, setScopeError] = useState<{ key: string | null; message: string }>(); useEffect(() => { if (!scope) return; let entry: Entry; try { entry = registry.get(scope); } - catch (error) { attachment.onError((error as Error).message); return; } + catch (error) { + const message = (error as Error).message; + setScopeError({ key, message }); + attachment.onError(message); + return; + } + setScopeError(undefined); active.current = entry; entry.attachment = attachment; void registry.flush(entry); @@ -129,5 +226,13 @@ export function usePositionSaveQueue( if (entry && entry.attachment === attachment && latest.current.key === key) await registry.flush(entry); }, [registry, attachment, key]); - return { queue, flush }; + const discard = useCallback(async () => { + const entry = active.current; + if (!entry || entry.attachment !== attachment || latest.current.key !== key || getSessionOwnerId() !== entry.ownerId) return; + try { await entry.queue.discard(); entry.reportedFailure = false; } + catch (error) { attachment.onError((error as Error).message); } + }, [attachment, key]); + + const entry = registry.peek(key); + return { queue, flush, discard, error: scopeError?.key === key ? scopeError.message : undefined, state: entry?.queue.state, saving: entry?.queue.saving ?? false }; } diff --git a/packages/web/src/lib/__tests__/layoutPersistence.test.ts b/packages/web/src/lib/__tests__/layoutPersistence.test.ts new file mode 100644 index 00000000..b5e8d9a7 --- /dev/null +++ b/packages/web/src/lib/__tests__/layoutPersistence.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { emptyAccount, type Account } from '../offline/model'; +import { acknowledgeLayout, MAX_LAYOUT_POSITIONS, persistLayout as persistWithGeneration, readLayout, readLayoutSnapshot } from '../layoutPersistence'; +import type { LayoutReceipt } from '../positionSaveQueue'; + +const fixture = vi.hoisted(() => ({ accounts: new Map(), fail: false })); +vi.mock('../offline/store', () => ({ + readAccount: async (owner: string) => structuredClone(fixture.accounts.get(owner) || emptyAccount()), + editAccount: async (owner: string, edit: (account: Account) => unknown) => { + const account = structuredClone(fixture.accounts.get(owner) || emptyAccount()); + const result = edit(account); + if (fixture.fail) throw new DOMException('Owned quota failure', 'QuotaExceededError'); + fixture.accounts.set(owner, account); + return result; + }, +})); + +const persistLayout = async (owner: string, graph: string, positions: LayoutReceipt[]) => persistWithGeneration(owner, graph, positions, (await readLayoutSnapshot(owner, graph)).generation); +const position = (id = 'task', revision = 'first', x = 1): LayoutReceipt => ({ id, revision, x, y: 2, updatedAt: 1000 }); +beforeEach(() => { fixture.accounts.clear(); fixture.fail = false; }); + +describe('account-scoped layout receipts', () => { + it('does not conflict with an unrelated graph before any fence eviction', async () => { + const unopened = await readLayoutSnapshot('owner', 'first-graph'); + await persistLayout('owner', 'second-graph', [position()]); + await acknowledgeLayout('owner', 'second-graph', [position()]); + await expect(persistWithGeneration('owner', 'first-graph', [position()], unopened.generation)).resolves.toBeGreaterThan(0); + }); + + it('retains its generation after acknowledgement and rejects a stale staged commit', async () => { + const before = await readLayoutSnapshot('owner', 'graph'); + const value = position(); + const committed = await persistWithGeneration('owner', 'graph', [value], before.generation); + await acknowledgeLayout('owner', 'graph', [value]); + expect(await readLayoutSnapshot('owner', 'graph')).toEqual({ generation: committed, positions: [] }); + await expect(persistWithGeneration('owner', 'graph', [position('task', 'stale', 99)], before.generation)).rejects.toThrow('Another tab'); + expect(await readLayout('owner', 'graph')).toEqual([]); + }); + + it('keeps a monotonic fallback when bounded graph fences are evicted', async () => { + await persistLayout('owner', 'old-graph', [position()]); + const previous = (await readLayoutSnapshot('owner', 'old-graph')).generation; + await acknowledgeLayout('owner', 'old-graph', [position()]); + for (let index = 0; index < 20; index++) { + await persistLayout('owner', `new-${index}`, [position()]); + await acknowledgeLayout('owner', `new-${index}`, [position()]); + } + expect(Object.keys(fixture.accounts.get('owner')!.layoutFences!.graphs)).toHaveLength(16); + expect((await readLayoutSnapshot('owner', 'old-graph')).generation).toBeGreaterThan(previous); + await expect(persistWithGeneration('owner', 'old-graph', [position('task', 'stale')], previous)).rejects.toThrow('Another tab'); + }); + + it('advances the fence on explicit discard but not on a stale acknowledgement', async () => { + await persistLayout('owner', 'graph', [position()]); + const before = (await readLayoutSnapshot('owner', 'graph')).generation; + await acknowledgeLayout('owner', 'graph', [position('task', 'wrong')], true); + expect((await readLayoutSnapshot('owner', 'graph')).generation).toBe(before); + await acknowledgeLayout('owner', 'graph', [position()], true); + expect((await readLayoutSnapshot('owner', 'graph')).generation).toBeGreaterThan(before); + await expect(persistWithGeneration('owner', 'graph', [position('task', 'stale')], before)).rejects.toThrow('Another tab'); + }); + + it('fails explicitly on corrupt or exhausted generation counters', async () => { + for (const counter of [-1, NaN, Number.MAX_SAFE_INTEGER + 1]) { + fixture.accounts.set('owner', { ...emptyAccount(), layoutFences: { version: 1, counter, floor: 0, graphs: {} } }); + await expect(readLayoutSnapshot('owner', 'graph')).rejects.toThrow('revision is invalid'); + } + const account = { ...emptyAccount(), layoutFences: { version: 1 as const, counter: Number.MAX_SAFE_INTEGER, floor: Number.MAX_SAFE_INTEGER, graphs: {} } }; + fixture.accounts.set('owner', account); + await expect(persistWithGeneration('owner', 'graph', [position()], Number.MAX_SAFE_INTEGER)).rejects.toThrow('revision limit'); + expect(fixture.accounts.get('owner')).toEqual(account); + }); + + it('does not let a late acknowledgement erase a newer persisted position', async () => { + const older = position(), newer = position('task', 'second', 9); + await persistLayout('owner', 'graph', [older]); + await persistLayout('owner', 'graph', [newer]); + await acknowledgeLayout('owner', 'graph', [older]); + expect(await readLayout('owner', 'graph')).toEqual([newer]); + await acknowledgeLayout('owner', 'graph', [newer]); + expect(await readLayout('owner', 'graph')).toEqual([]); + expect(fixture.accounts.get('owner')?.layoutOutbox).toBeUndefined(); + }); + + it('isolates identical item IDs by account and graph, including special object keys', async () => { + await persistLayout('owner-a', '__proto__', [position('__proto__')]); + await persistLayout('owner-a', 'other', [position('__proto__', 'other', 4)]); + await persistLayout('owner-b', '__proto__', [position('__proto__', 'account-b', 8)]); + await acknowledgeLayout('owner-a', '__proto__', [position('__proto__')]); + expect(await readLayout('owner-a', '__proto__')).toEqual([]); + expect((await readLayout('owner-a', 'other'))[0].x).toBe(4); + expect((await readLayout('owner-b', '__proto__'))[0].x).toBe(8); + expect(({} as Record).revision).toBeUndefined(); + }); + + it('retains existing data after a failed transaction or capacity rejection', async () => { + await persistLayout('owner', 'graph', [position()]); + fixture.fail = true; + await expect(persistLayout('owner', 'graph', [position('task', 'new', 9)])).rejects.toThrow('Owned quota failure'); + expect(await readLayout('owner', 'graph')).toEqual([position()]); + fixture.fail = false; + const full = Array.from({ length: MAX_LAYOUT_POSITIONS }, (_, index) => position(`task-${index}`)); + await persistLayout('full-owner', 'graph', full); + await expect(persistLayout('full-owner', 'other', [position()])).rejects.toThrow('10,000'); + expect(await readLayout('full-owner', 'graph')).toHaveLength(MAX_LAYOUT_POSITIONS); + expect(await readLayout('full-owner', 'other')).toEqual([]); + }); + + it('rejects invalid coordinates and unsupported saved formats without erasing evidence', async () => { + await expect(persistLayout('owner', 'graph', [{ ...position(), x: Infinity }])).rejects.toThrow('invalid'); + const unsupported = { ...emptyAccount(), layoutOutbox: { version: 99, graphs: {} } } as unknown as Account; + fixture.accounts.set('owner', unsupported); + await expect(readLayout('owner', 'graph')).rejects.toThrow('unsupported'); + await expect(persistLayout('owner', 'graph', [position()])).rejects.toThrow('unsupported'); + expect(fixture.accounts.get('owner')).toEqual(unsupported); + }); + + it('persists only coordinate receipts and preserves existing offline projects', async () => { + const account = emptyAccount(); + account.list = [{ id: 'existing', name: 'Existing offline project' }]; + fixture.accounts.set('owner', account); + const extra = { ...position(), authToken: 'must-not-be-stored' }; + await persistLayout('owner', 'graph', [extra]); + expect(fixture.accounts.get('owner')?.list).toEqual(account.list); + expect(JSON.stringify(fixture.accounts.get('owner'))).not.toContain('authToken'); + }); +}); diff --git a/packages/web/src/lib/__tests__/recoverablePositionQueue.test.ts b/packages/web/src/lib/__tests__/recoverablePositionQueue.test.ts new file mode 100644 index 00000000..e26246f2 --- /dev/null +++ b/packages/web/src/lib/__tests__/recoverablePositionQueue.test.ts @@ -0,0 +1,174 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { waitFor } from '@testing-library/react'; +import { emptyAccount, type Account } from '../offline/model'; +import { createRecoverablePositionQueue } from '../recoverablePositionQueue'; +import type { PositionSave } from '../positionSaveQueue'; +import { readLayout } from '../layoutPersistence'; + +const fixture = vi.hoisted(() => ({ accounts: new Map(), fail: false, transaction: Promise.resolve(), beforeCommit: undefined as (() => Promise) | undefined })); +vi.mock('../offline/store', () => ({ + readAccount: async (owner: string) => { + await fixture.transaction; + return structuredClone(fixture.accounts.get(owner) || emptyAccount()); + }, + editAccount: (owner: string, edit: (account: Account) => unknown) => { + const operation = fixture.transaction.then(async () => { + const account = structuredClone(fixture.accounts.get(owner) || emptyAccount()); + const result = edit(account); + await fixture.beforeCommit?.(); + if (fixture.fail) throw new DOMException('Owned device write failure', 'QuotaExceededError'); + fixture.accounts.set(owner, account); + return result; + }); + fixture.transaction = operation.then(() => undefined, () => undefined); + return operation; + }, +})); + +const deferred = () => { + let resolve!: () => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +}; +const make = async (save = vi.fn().mockResolvedValue(undefined)) => { + const queue = createRecoverablePositionQueue({ ownerId: 'owner', graphId: 'graph', networkBacked: true, save, onChange() {} }); + await waitFor(() => expect(queue.state.recovered).toBe(true)); + return queue; +}; +beforeEach(() => { + fixture.accounts.clear(); + fixture.fail = false; + fixture.transaction = Promise.resolve(); + fixture.beforeCommit = undefined; + const locks = new Map>(); + vi.stubGlobal('navigator', { locks: { + request: (name: string, _options: unknown, action: () => Promise) => { + const operation = (locks.get(name) || Promise.resolve()).catch(() => undefined).then(action); + locks.set(name, operation.catch(() => undefined)); + return operation; + }, + } }); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe('recoverable layout ordering', () => { + it('does not let a volatile quota-failed edit establish fresh eligibility after another tab saved', async () => { + let server = 0; + const oldSave = vi.fn(async ({ x }: PositionSave) => { server = x; }); + const old = await make(oldSave); + fixture.fail = true; + old.queue('task', 10, 2); + await expect(old.flush()).rejects.toThrow('Owned device write failure'); + fixture.fail = false; + const fresh = await make(vi.fn(async ({ x }: PositionSave) => { server = x; })); + fresh.queue('task', 20, 2); + await fresh.flush(); + expect(server).toBe(20); + await expect(old.flush()).rejects.toThrow(/another tab|conflict/i); + expect(server).toBe(20); + expect(oldSave).not.toHaveBeenCalled(); + expect(old.state).toMatchObject({ pending: 1, local: 0 }); + expect(old.state.positions.get('task')?.x).toBe(10); + }); + + it('does not replay an older tab receipt after another tab saves a newer position', async () => { + let offline = true; + let server = 0; + const oldSave = vi.fn(async ({ x }: PositionSave) => { if (offline) throw Error('owned outage'); server = x; }); + const old = await make(oldSave); + old.queue('task', 10, 2); + await expect(old.flush()).rejects.toThrow('owned outage'); + const fresh = await make(vi.fn(async ({ x }: PositionSave) => { server = x; })); + fresh.queue('task', 20, 2); + await fresh.flush(); + expect(await readLayout('owner', 'graph')).toEqual([]); + offline = false; + await old.flush(); + expect(server).toBe(20); + expect(oldSave).toHaveBeenCalledTimes(1); + expect(old.state.pending).toBe(0); + }); + + it('retries receipt cleanup without sending an already accepted position again', async () => { + let server = 0; + const save = vi.fn(async ({ x }: PositionSave) => { server = x; fixture.fail = true; }); + const queue = await make(save); + queue.queue('task', 30, 2); + await expect(queue.flush()).rejects.toThrow('Owned device write failure'); + expect(queue.state).toMatchObject({ acknowledged: true, cleanupPending: 1 }); + server = 99; + fixture.fail = false; + await queue.flush(); + expect(save).toHaveBeenCalledTimes(1); + expect(server).toBe(99); + expect(queue.state).toMatchObject({ pending: 0, cleanupPending: 0 }); + }); + + it('can discard a memory-only position when device writes cannot succeed', async () => { + fixture.fail = true; + const save = vi.fn().mockResolvedValue(undefined); + const queue = await make(save); + queue.queue('task', 1, 2); + await expect(queue.flush()).rejects.toThrow('Owned device write failure'); + expect(queue.state).toMatchObject({ pending: 1, local: 0 }); + await queue.discard(); + expect(queue.state.pending).toBe(0); + expect(save).not.toHaveBeenCalled(); + expect(await readLayout('owner', 'graph')).toEqual([]); + }); + + it('does not let reconnect flush send a position while its discard transaction is pending', async () => { + const save = vi.fn().mockResolvedValue(undefined); + const queue = await make(save); + queue.queue('task', 1, 2); + await waitFor(() => expect(queue.saving).toBe(false)); + const entered = deferred(), release = deferred(); + fixture.beforeCommit = async () => { fixture.beforeCommit = undefined; entered.resolve(); await release.promise; }; + const discarding = queue.discard(); + await entered.promise; + const flushing = queue.flush(); + await new Promise(resolve => setTimeout(resolve, 0)); + const sentDuringDiscard = save.mock.calls.length; + release.resolve(); + await Promise.all([discarding, flushing]); + expect(sentDuringDiscard).toBe(0); + expect(save).not.toHaveBeenCalled(); + expect(queue.state.pending).toBe(0); + }); + + it('serializes sends for the same item across queues while allowing the newest receipt to finish last', async () => { + const entered = deferred(), release = deferred(); + const sends: number[] = []; + const first = await make(vi.fn(async ({ x }: PositionSave) => { sends.push(x); entered.resolve(); await release.promise; })); + first.queue('task', 1, 2); + const sending = first.flush(); + await entered.promise; + const second = await make(vi.fn(async ({ x }: PositionSave) => { sends.push(x); })); + second.queue('task', 9, 10); + const following = second.flush(); + await new Promise(resolve => setTimeout(resolve, 0)); + const beforeRelease = [...sends]; + release.resolve(); + await Promise.all([sending, following]); + expect(beforeRelease).toEqual([1]); + expect(sends).toEqual([1, 9]); + }); + + it('reports unsupported coordination without sending a durable receipt', async () => { + vi.stubGlobal('navigator', {}); + const save = vi.fn().mockResolvedValue(undefined); + const queue = await make(save); + queue.queue('task', 1, 2); + await expect(queue.flush()).rejects.toThrow(/coordinate|lock|browser/i); + expect(save).not.toHaveBeenCalled(); + expect(queue.state).toMatchObject({ pending: 1, local: 1 }); + }); + + it('keeps pending position identity stable when only persistence status changes', async () => { + const queue = await make(); + queue.queue('task', 1, 2); + const positions = queue.state.positions; + await waitFor(() => expect(queue.saving).toBe(false)); + expect(queue.state.positions).toBe(positions); + }); +}); diff --git a/packages/web/src/lib/layoutPersistence.ts b/packages/web/src/lib/layoutPersistence.ts new file mode 100644 index 00000000..67b67077 --- /dev/null +++ b/packages/web/src/lib/layoutPersistence.ts @@ -0,0 +1,118 @@ +import { editAccount, readAccount } from './offline/store'; +import type { Account } from './offline/model'; +import type { LayoutReceipt } from './positionSaveQueue'; + +export const MAX_LAYOUT_GRAPHS = 16; +export const MAX_LAYOUT_POSITIONS = 10000; + +const key = (id: string) => { + if (typeof id !== 'string' || !id || id.length > 256) throw new Error('The layout owner, graph or item identifier is invalid.'); + return JSON.stringify(id); +}; + +const receipt = (value: LayoutReceipt): LayoutReceipt => { + key(value?.id); + if (![value.x, value.y].every(n => Number.isFinite(n) && Math.abs(n) <= 1e9) + || typeof value.revision !== 'string' || !value.revision || value.revision.length > 128 + || !Number.isFinite(value.updatedAt) || value.updatedAt < 0) throw new Error('A saved layout is invalid. Its data has been retained for recovery.'); + return { id: value.id, x: value.x, y: value.y, revision: value.revision, updatedAt: value.updatedAt }; +}; + +const outbox = (account: Account) => { + const value = account.layoutOutbox; + if (!value) return { version: 1 as const, graphs: {} as Record> }; + if (value.version !== 1 || !value.graphs || typeof value.graphs !== 'object' || Array.isArray(value.graphs)) throw new Error('This saved layout format is unsupported. Its data has been retained for recovery.'); + let count = 0; + for (const [graphKey, positions] of Object.entries(value.graphs)) { + if (key(JSON.parse(graphKey)) !== graphKey || !positions || typeof positions !== 'object' || Array.isArray(positions)) throw new Error('The saved layout scope is invalid. Its data has been retained for recovery.'); + for (const [nodeKey, position] of Object.entries(positions)) { + if (key(receipt(position).id) !== nodeKey) throw new Error('The saved layout item does not match its scope.'); + count++; + } + } + if (Object.keys(value.graphs).length > MAX_LAYOUT_GRAPHS || count > MAX_LAYOUT_POSITIONS) throw new Error('Saved layouts exceed the supported recovery limit. Their data has been retained.'); + return value; +}; + +const fences = (account: Account) => { + const value = account.layoutFences; + if (!value) return { version: 1 as const, counter: 0, floor: 0, graphs: {} as Record }; + if (value.version !== 1 || !Number.isSafeInteger(value.counter) || value.counter < 0 || !Number.isSafeInteger(value.floor) || value.floor < 0 || value.floor > value.counter || !value.graphs || typeof value.graphs !== 'object' || Array.isArray(value.graphs) || Object.keys(value.graphs).length > MAX_LAYOUT_GRAPHS) throw new Error('The saved layout revision is invalid. Its data has been retained for recovery.'); + for (const [graphKey, generation] of Object.entries(value.graphs)) { + if (key(JSON.parse(graphKey)) !== graphKey || !Number.isSafeInteger(generation) || generation < 1 || generation > value.counter) throw new Error('The saved layout revision is invalid. Its data has been retained for recovery.'); + } + return value; +}; + +const generation = (account: Account, graphKey: string) => { + const value = fences(account); + return value.graphs[graphKey] ?? value.floor; +}; + +const advance = (account: Account, graphKey: string) => { + const value = fences(account); + if (value.counter >= Number.MAX_SAFE_INTEGER) throw new Error('The saved layout revision limit was reached. Pending data has been retained.'); + const counter = value.counter + 1; + const entries = Object.entries({ ...value.graphs, [graphKey]: counter }).sort((a, b) => b[1] - a[1]); + const graphs = Object.fromEntries(entries.slice(0, MAX_LAYOUT_GRAPHS)); + const floor = entries.length > MAX_LAYOUT_GRAPHS ? counter : value.floor; + account.layoutFences = { version: 1, counter, floor, graphs }; + return counter; +}; + +export async function readLayoutSnapshot(ownerId: string, graphId: string) { + key(ownerId); + const account = await readAccount(ownerId); + const graphKey = key(graphId); + return { generation: generation(account, graphKey), positions: Object.values(outbox(account).graphs[graphKey] || {}).map(receipt) }; +} + +export async function readLayout(ownerId: string, graphId: string): Promise { + return (await readLayoutSnapshot(ownerId, graphId)).positions; +} + +export async function readLayoutScopes(ownerId: string) { + key(ownerId); + const account = await readAccount(ownerId); + fences(account); + return Object.entries(outbox(account).graphs).map(([graphKey, positions]) => ({ graphId: JSON.parse(graphKey) as string, positions: Object.values(positions).map(receipt) })); +} + +export async function persistLayout(ownerId: string, graphId: string, positions: LayoutReceipt[], expectedGeneration: number): Promise { + key(ownerId); + const graphKey = key(graphId); + const values = positions.map(receipt); + if (!Number.isSafeInteger(expectedGeneration) || expectedGeneration < 0) throw new Error('The expected layout revision is invalid.'); + return editAccount(ownerId, account => { + if (generation(account, graphKey) !== expectedGeneration) throw new Error('Another tab changed this layout. Your unsent changes remain in this tab. Discard them and reload to use the newer layout.'); + if (!values.length) return expectedGeneration; + const value = outbox(account); + const graph = { ...value.graphs[graphKey] }; + for (const position of values) graph[key(position.id)] = position; + const graphs = { ...value.graphs, [graphKey]: graph }; + const total = Object.values(graphs).reduce((sum, items) => sum + Object.keys(items).length, 0); + if (Object.keys(graphs).length > MAX_LAYOUT_GRAPHS || total > MAX_LAYOUT_POSITIONS) throw new Error('This account has reached 16 pending graphs or 10,000 pending positions. Reconnect or discard an earlier pending layout before saving more on this device.'); + account.layoutOutbox = { version: 1, graphs }; + return advance(account, graphKey); + }); +} + +export async function acknowledgeLayout(ownerId: string, graphId: string, positions: LayoutReceipt[], discard = false): Promise { + key(ownerId); + const graphKey = key(graphId); + const values = positions.map(receipt); + await editAccount(ownerId, account => { + const value = outbox(account); + const graph = value.graphs[graphKey]; + if (!graph) return; + let removed = false; + for (const position of values) { + const nodeKey = key(position.id); + if (graph[nodeKey]?.revision === position.revision) { delete graph[nodeKey]; removed = true; } + } + if (!Object.keys(graph).length) delete value.graphs[graphKey]; + if (!Object.keys(value.graphs).length) delete account.layoutOutbox; + else account.layoutOutbox = value; + if (removed && discard) advance(account, graphKey); + }); +} diff --git a/packages/web/src/lib/offline/engine.ts b/packages/web/src/lib/offline/engine.ts index 0adfadf3..44593cde 100644 --- a/packages/web/src/lib/offline/engine.ts +++ b/packages/web/src/lib/offline/engine.ts @@ -132,7 +132,7 @@ export async function resolveConflict(id:string,choice:'local'|'cloud') { export async function prepareOffline() {const list=await listProjects(true);for(const p of list){const owner=ownerId();if(!owner||!await canConnect(owner))break;await getProject(p.id);}} export async function exportOffline() { const owner=ownerId();if(!owner)return; - const account=await readAccount(owner);delete account.user; + const account=await readAccount(owner);delete account.user;delete account.layoutOutbox;delete account.layoutFences; const blob=new Blob([JSON.stringify({format:'graphdone-offline-v1',exportedAt:new Date().toISOString(),account},null,2)],{type:'application/json'}); const url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='graphdone-offline-backup.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),10000); } diff --git a/packages/web/src/lib/offline/model.ts b/packages/web/src/lib/offline/model.ts index 092a0c35..ff99ab8d 100644 --- a/packages/web/src/lib/offline/model.ts +++ b/packages/web/src/lib/offline/model.ts @@ -1,9 +1,11 @@ +import type { LayoutReceipt } from '../positionSaveQueue'; + export type Row = {id:string; [key:string]:any}; export type Snapshot = {project:Row; nodes:Row[]; edges:Row[]; notes:Row[]}; export type Change = {table:'nodes'|'edges'|'notes'; id:string; action:'put'|'delete'; value?:Row}; export type PendingRequest = {requestId:string;projectId:string;version:number;attempts?:number;name?:string;deleteProject?:boolean;changes:Change[];sent:Snapshot|null}; export type Project = {base:Snapshot|null;local:Snapshot|null;request?:PendingRequest;conflicts?:string[];remote?:Snapshot|null;checkedAt:number}; -export type Account = {user?:Record;projects:Record;list:Row[];listedAt:number;retryAt:number;error:string;blocked?:'auth'|'validation'}; +export type Account = {user?:Record;projects:Record;list:Row[];listedAt:number;retryAt:number;error:string;blocked?:'auth'|'validation';layoutFences?:{version:1;counter:number;floor:number;graphs:Record};layoutOutbox?:{version:1;graphs:Record>}}; export const emptyAccount = ():Account=>({projects:{},list:[],listedAt:0,retryAt:0,error:''}); export const same=(a:any,b:any):boolean=>JSON.stringify(a)===JSON.stringify(b); const editable:Record={nodes:['type','title','description','status','priority','position_x','position_y','due_date','tags','metadata'],edges:['source_id','target_id','type','weight'],notes:['work_item_id','body']}; diff --git a/packages/web/src/lib/positionSaveQueue.ts b/packages/web/src/lib/positionSaveQueue.ts index b5d32b44..6599366e 100644 --- a/packages/web/src/lib/positionSaveQueue.ts +++ b/packages/web/src/lib/positionSaveQueue.ts @@ -8,7 +8,8 @@ * at most `concurrency` writes at a time instead of 500 in parallel. The pure * pieces are unit-tested with an injected `save`. */ -export interface PositionSave { id: string; x: number; y: number; } +export interface PositionSave { id: string; x: number; y: number; revision?: string; } +export interface LayoutReceipt extends PositionSave { revision: string; updatedAt: number; } /** Run `worker` over `items` with at most `limit` in flight; resolve when all done. */ export async function drainWithLimit( @@ -36,26 +37,30 @@ export function createPositionSaveQueue(opts: { const retainedIds = new Set(); const concurrency = opts.concurrency ?? 4; let flushing: Promise | undefined; + let generation = 0; /** Queue/overwrite a node's pending position (last write wins — coalesced). */ - const queue = (id: string, x: number, y: number): void => { + const queue = (id: string, x: number, y: number, revision?: string): void => { if (!retainedIds.has(id) && retainedIds.size >= (opts.maxPending ?? Infinity)) { throw new Error('Too many pending layout changes. Reconnect and save before moving more tasks.'); } retainedIds.add(id); - pending.set(id, { id, x, y }); + pending.set(id, { id, x, y, ...(revision ? { revision } : {}) }); }; const drain = async (): Promise => { while (pending.size > 0) { const batch = [...pending.values()]; + const batchGeneration = generation; pending.clear(); const failures: unknown[] = []; await drainWithLimit(batch, concurrency, async (position) => { + if (generation !== batchGeneration) return; try { await opts.save(position); - if (!pending.has(position.id)) retainedIds.delete(position.id); + if (generation === batchGeneration && !pending.has(position.id)) retainedIds.delete(position.id); } catch (error) { + if (generation !== batchGeneration) return; if (!pending.has(position.id)) pending.set(position.id, position); failures.push(error); } @@ -74,6 +79,7 @@ export function createPositionSaveQueue(opts: { return { queue, flush, + discard: () => { generation++; pending.clear(); retainedIds.clear(); }, get size() { return pending.size; }, get saving() { return flushing !== undefined; }, }; diff --git a/packages/web/src/lib/recoverablePositionQueue.ts b/packages/web/src/lib/recoverablePositionQueue.ts new file mode 100644 index 00000000..3e16638c --- /dev/null +++ b/packages/web/src/lib/recoverablePositionQueue.ts @@ -0,0 +1,224 @@ +import { createPositionSaveQueue, type LayoutReceipt, type PositionSave } from './positionSaveQueue'; +import { acknowledgeLayout, persistLayout, readLayout, readLayoutSnapshot } from './layoutPersistence'; + +export function createRecoverablePositionQueue(options: { + ownerId: string; + graphId: string; + networkBacked: boolean; + save: (position: PositionSave) => Promise; + onChange: () => void; +}) { + const positions = new Map(); + const durable = new Map(); + const staged = new Map(); + const accepted = new Map(); + let storageError = ''; + let cleanupError = ''; + let sendError = ''; + let loaded = false; + let recovered = !options.networkBacked; + let persisting: Promise | undefined; + let discarding = false; + let discardPromise: Promise | undefined; + let acknowledged = false; + let observedGeneration = 0; + let positionsDirty = true; + let positionSnapshot: ReadonlyMap = new Map(); + const notify = options.onChange; + const plain = ({ id, x, y }: PositionSave) => ({ id, x, y }); + const forget = (position: LayoutReceipt) => { + if (positions.get(position.id)?.revision === position.revision) { positions.delete(position.id); positionsDirty = true; } + if (durable.get(position.id) === position.revision) durable.delete(position.id); + accepted.delete(position.revision); + notify(); + }; + const lock = async (id: string, action: () => Promise): Promise => { + if (!navigator.locks?.request) throw new Error('This browser cannot coordinate safe layout recovery between tabs. Positions remain on this device; use a browser with Web Locks support.'); + return navigator.locks.request(JSON.stringify(['graphdone-layout-v1', options.ownerId, options.graphId, id]), { mode: 'exclusive' }, action); + }; + const cleanup = async (position: LayoutReceipt) => { + try { + const current = (await readLayout(options.ownerId, options.graphId)).find(item => item.id === position.id); + if (current?.revision === position.revision) await acknowledgeLayout(options.ownerId, options.graphId, [position]); + forget(position); + cleanupError = ''; + } catch (error) { + cleanupError = 'The server accepted this layout, but this device could not clear its receipt. Retry will only clear that receipt.'; + notify(); + throw error; + } + }; + const queue = createPositionSaveQueue({ + maxPending: 10000, + save: async position => { + if (options.networkBacked) await persist(); + if (positions.get(position.id)?.revision !== position.revision) return; + if (!options.networkBacked) { + await options.save(plain(position)); + const current = positions.get(position.id); + if (current && current.revision === position.revision) forget(current); + acknowledged = true; + notify(); + return; + } + await lock(position.id, async () => { + const current = positions.get(position.id); + if (!current || current.revision !== position.revision) return; + if (accepted.has(current.revision)) { await cleanup(current); return; } + const eligible = (await readLayout(options.ownerId, options.graphId)).find(item => item.id === position.id); + if (eligible?.revision !== position.revision) { forget(current); return; } + if (positions.get(position.id)?.revision !== position.revision) return; + await options.save(plain(position)); + accepted.set(current.revision, current); + acknowledged = true; + notify(); + await cleanup(current); + }); + }, + }); + + const ready = (async () => { + try { + if (options.networkBacked) { + const snapshot = await readLayoutSnapshot(options.ownerId, options.graphId); + observedGeneration = snapshot.generation; + for (const position of snapshot.positions) { + if (positions.has(position.id)) continue; + queue.queue(position.id, position.x, position.y, position.revision); + positions.set(position.id, position); + positionsDirty = true; + durable.set(position.id, position.revision); + } + } + recovered = true; + } catch (error) { + storageError = error instanceof Error ? error.message : 'Device storage is unavailable.'; + } finally { + loaded = true; + notify(); + } + })(); + + const persist = (): Promise => { + if (persisting) return persisting; + if (!options.networkBacked) return Promise.resolve(); + persisting = (async () => { + await ready; + if (!recovered) { + const snapshot = await readLayoutSnapshot(options.ownerId, options.graphId); + observedGeneration = snapshot.generation; + recovered = true; + for (const position of snapshot.positions) { + if (!positions.has(position.id)) { + queue.queue(position.id, position.x, position.y, position.revision); + positions.set(position.id, position); + positionsDirty = true; + durable.set(position.id, position.revision); + } + } + } + while (staged.size) { + const batch = [...staged.values()]; + staged.clear(); + try { + observedGeneration = await persistLayout(options.ownerId, options.graphId, batch, observedGeneration); + for (const position of batch) durable.set(position.id, position.revision); + } catch (error) { + for (const position of batch) if (!staged.has(position.id)) staged.set(position.id, position); + throw error; + } + } + storageError = ''; + })().catch(error => { + storageError = error instanceof Error ? error.message : 'Device storage is unavailable.'; + throw error; + }).finally(() => { persisting = undefined; notify(); }); + return persisting; + }; + + return { + initialized: ready, + queue: (id: string, x: number, y: number) => { + if (!recovered) throw new Error('Wait for layout recovery before moving tasks.'); + if (discarding) throw new Error('Wait for the pending layout discard to finish.'); + if (!id || !Number.isFinite(x) || !Number.isFinite(y) || Math.abs(x) > 1e9 || Math.abs(y) > 1e9) throw new Error('This layout position is invalid.'); + const position = { id, x, y, revision: crypto.randomUUID(), updatedAt: Date.now() }; + queue.queue(id, x, y, position.revision); + positions.set(id, position); + positionsDirty = true; + if (options.networkBacked) staged.set(id, position); + acknowledged = false; + notify(); + if (options.networkBacked) void persist().catch(() => undefined); + }, + flush: async () => { + try { + if (discardPromise) return await discardPromise; + await ready; + if (discardPromise) return await discardPromise; + if (options.networkBacked) await persist(); + if (discardPromise) return await discardPromise; + if (options.networkBacked) for (const position of [...accepted.values()]) await lock(position.id, () => cleanup(position)); + if (discardPromise) return await discardPromise; + const flushing = queue.flush(); + notify(); + await flushing; + sendError = ''; + } catch (error) { + if (!storageError && !cleanupError) sendError = error instanceof Error ? error.message : 'Layout sending failed. Retry when connected.'; + throw error; + } finally { notify(); } + }, + discard: () => { + if (discardPromise) return discardPromise; + if (queue.saving) return Promise.reject(new Error('Wait for the current save to finish before discarding unsent positions.')); + discarding = true; + notify(); + discardPromise = (async () => { + let recoveryError = ''; + await ready; + if (persisting) await persisting.catch(() => undefined); + if (options.networkBacked) { + for (const position of [...positions.values()]) { + if (!durable.has(position.id)) continue; + const remove = async () => { + const stored = (await readLayout(options.ownerId, options.graphId)).find(item => item.id === position.id); + if (stored && (stored.revision === position.revision || stored.revision === durable.get(position.id))) { + await acknowledgeLayout(options.ownerId, options.graphId, [stored], true); + } + }; + if (typeof navigator.locks?.request === 'function') await lock(position.id, remove); + else await remove(); + } + } + if (options.networkBacked) { + try { observedGeneration = (await readLayoutSnapshot(options.ownerId, options.graphId)).generation; recovered = true; } + catch (error) { + if (durable.size) throw error; + recovered = false; + recoveryError = error instanceof Error ? error.message : 'Device storage is unavailable.'; + } + } + queue.discard(); + positions.clear(); + positionsDirty = true; + durable.clear(); + staged.clear(); + accepted.clear(); + storageError = recoveryError; + cleanupError = ''; + sendError = ''; + acknowledged = false; + })().finally(() => { discarding = false; discardPromise = undefined; notify(); }); + return discardPromise; + }, + get size() { return positions.size; }, + get saving() { return queue.saving || persisting !== undefined || discarding; }, + get state() { + if (positionsDirty) { positionSnapshot = new Map(positions); positionsDirty = false; } + const local = [...positions.values()].filter(position => durable.get(position.id) === position.revision).length; + const unsent = [...positions.values()].filter(position => !accepted.has(position.revision)).length; + return { ready: loaded, recovered, pending: positions.size, unsent, cleanupPending: accepted.size, local, storageError, cleanupError, sendError, acknowledged, networkBacked: options.networkBacked, positions: positionSnapshot }; + }, + }; +} diff --git a/packages/web/src/pages/Settings.tsx b/packages/web/src/pages/Settings.tsx index 7585f227..6c4552b2 100644 --- a/packages/web/src/pages/Settings.tsx +++ b/packages/web/src/pages/Settings.tsx @@ -1,6 +1,7 @@ import {FeedbackButton} from '../components/Feedback'; import React from 'react'; import { HostedUsage } from '../components/HostedUsage'; +import { LayoutRecoverySettings } from '../components/LayoutRecoverySettings'; import { Settings as SettingsIcon } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; import { CustomDropdown } from '../components/CustomDropdown'; @@ -42,6 +43,7 @@ export function Settings() {
+ {/* Performance (ADAPT-6) */}

Performance

diff --git a/tests/e2e/graph/layout-recovery.spec.ts b/tests/e2e/graph/layout-recovery.spec.ts new file mode 100644 index 00000000..677115b4 --- /dev/null +++ b/tests/e2e/graph/layout-recovery.spec.ts @@ -0,0 +1,521 @@ +import { test as base, expect, chromium, type Page, type Route, type Browser, type BrowserContext } from '@playwright/test'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import neo4j from 'neo4j-driver'; +import { login, logout, TEST_USERS, getBaseURL } from '../../lib/auth'; +import { gql } from '../../lib/graphRequest'; +import { deleteGraphDeep } from '../../lib/seedGraph'; +import { ownedGraphIdsFixture, type OwnedGraphs } from '../../lib/ownedGraphIdsFixture'; + +const test = base.extend<{ ownedGraphIds: OwnedGraphs }>({ ownedGraphIds: [ownedGraphIdsFixture, { timeout: 60000 }] }); +test.use({ viewport: { width: 1440, height: 900 }, actionTimeout: 10000 }); +const rounded = (position: { x: number; y: number }) => ({ x: +position.x.toFixed(3), y: +position.y.toFixed(3) }); + +async function openGraph(page: Page, graphId: string, size = 2) { + await page.evaluate(id => { + localStorage.setItem('currentGraphId', id); + localStorage.setItem('graphdone:viewMode', 'graph'); + }, graphId); + await page.goto(getBaseURL()); + await expect(page.locator('.graph-container svg .node')).toHaveCount(size); +} + +async function readPosition(page: Page, nodeId: string) { + return page.locator('.graph-container svg .node').evaluateAll((elements, id) => { + const data = elements.map(element => (element as Element & { __data__?: { id: string; x: number; y: number } }).__data__).find(item => item?.id === id); + return data ? { x: data.x, y: data.y } : null; + }, nodeId); +} + +async function readSaved(page: Page, graphId: string, nodeId: string) { + const result = await gql(page, 'query($graph:ID!){workItems(where:{graph:{id:$graph}}){id positionX positionY}}', { graph: graphId }); + const node = result.workItems.find((item: { id: string }) => item.id === nodeId); + expect(node).toBeDefined(); + return { x: node.positionX as number, y: node.positionY as number }; +} + +async function readReceipt(page: Page, graphId: string, nodeId: string) { + const owner = (await gql(page, '{me{id}}')).me.id as string; + return page.evaluate(async ({ owner, graphId, nodeId }) => { + const account = await new Promise((resolve, reject) => { + const open = indexedDB.open('graphdone-offline', 1); + open.onerror = () => reject(open.error); + open.onsuccess = () => { + const database = open.result; + const request = database.transaction('accounts').objectStore('accounts').get(owner); + request.onerror = () => { database.close(); reject(request.error); }; + request.onsuccess = () => { database.close(); resolve(request.result); }; + }; + }); + const receipt = account?.layoutOutbox?.graphs?.[JSON.stringify(graphId)]?.[JSON.stringify(nodeId)]; + return { receipt, credentialFound: /authToken|Bearer |sessionToken/.test(JSON.stringify(account?.layoutOutbox || {})) }; + }, { owner, graphId, nodeId }); +} + +async function drag(page: Page, nodeId: string) { + const card = page.locator('.graph-container svg .node').filter({ has: page.locator(`[data-node-id="${nodeId}"]`) }).locator('.node-bg'); + await expect(card).toBeInViewport({ ratio: 1 }); + let previous: { x: number; y: number } | null = null; + let stable = 0; + await expect.poll(async () => { + const box = await card.boundingBox(); + if (!box) return 0; + stable = previous && Math.hypot(box.x - previous.x, box.y - previous.y) < 1 ? stable + 1 : 0; + previous = box; + return stable; + }, { timeout: 10000, intervals: [150] }).toBeGreaterThanOrEqual(3); + const box = (await card.boundingBox())!; + const center = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + await page.mouse.move(center.x, center.y); + await page.mouse.down(); + await page.mouse.move(center.x + 75, center.y + 45, { steps: 12 }); + await page.mouse.up(); + const position = await readPosition(page, nodeId); + expect(position).not.toBeNull(); + return position!; +} + +function failLayout(graphId: string, nodeId: string, attempts: Array<{ x: number; y: number }>) { + return async (route: Route) => { + const body = route.request().method() === 'POST' ? route.request().postDataJSON() : null; + const update = body?.variables?.update; + if (body?.operationName !== 'UpdateWorkItems' || body?.variables?.where?.id !== nodeId || body?.variables?.where?.graph?.id !== graphId || Object.keys(update || {}).sort().join(',') !== 'positionX,positionY') { + await route.continue(); + return; + } + attempts.push({ x: update.positionX, y: update.positionY }); + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ errors: [{ message: 'Owned layout recovery outage' }] }) }); + }; +} + +async function holdLayoutReads(context: BrowserContext) { + const gate = await context.newPage(); + await gate.goto(new URL('/health', getBaseURL()).href); + await gate.evaluate(async () => new Promise((resolve, reject) => { + const open = indexedDB.open('graphdone-offline', 1); + open.onerror = () => reject(open.error); + open.onsuccess = () => { + const database = open.result; + const transaction = database.transaction('accounts', 'readwrite'); + const store = transaction.objectStore('accounts'); + const deadline = Date.now() + 20000; + (window as any).__holdLayoutRead = true; + const next = () => { + const request = store.get('owned-hydration-gate'); + request.onsuccess = () => { + resolve(); + if ((window as any).__holdLayoutRead && Date.now() < deadline) next(); + }; + }; + transaction.oncomplete = () => { database.close(); (window as any).__layoutGateDone = true; }; + transaction.onabort = () => { database.close(); reject(transaction.error); }; + next(); + }; + })); + return async () => { + await gate.evaluate(() => { (window as any).__holdLayoutRead = false; }); + await expect.poll(() => gate.evaluate(() => !!(window as any).__layoutGateDone)).toBe(true); + await gate.close(); + }; +} + +test('latest real drag survives reload before acknowledgement and delayed IndexedDB hydration @layout-recovery', async ({ page, context, ownedGraphIds }, info) => { + test.setTimeout(90000); + await login(page, TEST_USERS.ADMIN); + const graph = await ownedGraphIds.seed({ size: 2, spacing: 600, edgeFactor: 0.5, namePrefix: 'Durable reload' }); + await openGraph(page, graph.graphId); + const nodeId = graph.nodeIds[0]; + const initial = await readSaved(page, graph.graphId, nodeId); + const attempts: Array<{ x: number; y: number }> = []; + const route = failLayout(graph.graphId, nodeId, attempts); + await page.route('**/api/graphql', route); + const first = await drag(page, nodeId); + await expect.poll(() => attempts.length).toBe(1); + const latest = await drag(page, nodeId); + expect(Math.hypot(latest.x - first.x, latest.y - first.y)).toBeGreaterThan(10); + await expect.poll(() => attempts.length).toBe(2); + await expect(page.getByTestId('layout-save-status')).toContainText('1 saved on this device; 0 pending only in this tab'); + const durable = await readReceipt(page, graph.graphId, nodeId); + expect(rounded(durable.receipt)).toEqual(rounded(latest)); + expect(durable.credentialFound).toBe(false); + expect(await readSaved(page, graph.graphId, nodeId)).toEqual(initial); + const releaseReads = await holdLayoutReads(context); + try { + const graphResponse = page.waitForResponse(response => response.url().includes('/graphql') && response.request().postData()?.includes('GetWorkItems') === true); + await page.reload(); + await graphResponse; + await expect(page.getByText('Recovering saved layout...', { exact: true })).toBeVisible(); + await expect(page.locator('.graph-container svg .node')).toHaveCount(0); + } finally { + await releaseReads(); + } + await expect(page.locator('.graph-container svg .node')).toHaveCount(2); + await expect.poll(async () => { const value = await readPosition(page, nodeId); return value && rounded(value); }).toEqual(rounded(latest)); + expect(await readSaved(page, graph.graphId, nodeId)).toEqual(initial); + await expect(page.getByTestId('layout-save-status')).toContainText('Waiting to send 1 layout change'); + await page.unroute('**/api/graphql', route); + await page.getByRole('button', { name: 'Retry layout save' }).click(); + await expect.poll(async () => rounded(await readSaved(page, graph.graphId, nodeId))).toEqual(rounded(latest)); + await expect(page.getByTestId('layout-save-status')).toHaveText('Layout saved to the server.'); + expect((await readReceipt(page, graph.graphId, nodeId)).receipt).toBeUndefined(); + await page.reload(); + await expect.poll(async () => { const value = await readPosition(page, nodeId); return value && rounded(value); }).toEqual(rounded(latest)); + await info.attach('layout-reload.json', { contentType: 'application/json', body: JSON.stringify({ graphId: graph.graphId, nodeId, initial, first, latest, attempts, independentReadback: await readSaved(page, graph.graphId, nodeId) }) }); +}); + +async function startOwnedBrowser(profile: string) { + await rm(join(profile, 'DevToolsActivePort'), { force: true }); + const executable = process.env.QC_BROWSER_PORT ? '/opt/google/chrome/chrome' : chromium.executablePath(); + const child = spawn(executable, ['--headless=new', '--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check', '--remote-debugging-address=127.0.0.1', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { stdio: 'ignore', detached: true }); + let browser: Browser | undefined; + try { + let port = ''; + await expect.poll(async () => { + expect(child.exitCode).toBeNull(); + try { port = (await readFile(join(profile, 'DevToolsActivePort'), 'utf8')).split('\n')[0]; return /^\d+$/.test(port); } + catch { return false; } + }, { timeout: 10000 }).toBe(true); + browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`); + return { process: child, browser, page: browser.contexts()[0].pages()[0] }; + } catch (error) { + await stopOwnedBrowser(child, browser); + throw error; + } +} + +async function killOwnedBrowser(child: ChildProcess) { + const exited = child.exitCode === null && child.signalCode === null ? once(child, 'exit') : Promise.resolve(); + try { process.kill(-child.pid!, 'SIGKILL'); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error; } + await exited; +} + +async function stopOwnedBrowser(child: ChildProcess, browser?: Browser) { + await killOwnedBrowser(child); + await browser?.close().catch(() => undefined); +} + +test('a killed owned browser process recovers committed layout receipts from its retained profile @layout-recovery', async ({ page, ownedGraphIds }, info) => { + test.setTimeout(90000); + await login(page, TEST_USERS.ADMIN); + const graph = await ownedGraphIds.seed({ size: 2, spacing: 600, edgeFactor: 0.5, namePrefix: 'Durable process restart' }); + const nodeId = graph.nodeIds[0]; + const initial = await readSaved(page, graph.graphId, nodeId); + const profile = await mkdtemp(join(tmpdir(), 'graphdone-layout-owned-profile-')); + let owned: Awaited> | undefined; + let unloads = 0; + try { + owned = await startOwnedBrowser(profile); + await owned.page.setViewportSize({ width: 1440, height: 900 }); + await login(owned.page, TEST_USERS.ADMIN); + await openGraph(owned.page, graph.graphId); + const attempts: Array<{ x: number; y: number }> = []; + await owned.page.route('**/api/graphql', failLayout(graph.graphId, nodeId, attempts)); + const first = await drag(owned.page, nodeId); + await expect.poll(() => attempts.length).toBe(1); + const latest = await drag(owned.page, nodeId); + await expect.poll(() => attempts.length).toBe(2); + await expect(owned.page.getByTestId('layout-save-status')).toContainText('1 saved on this device; 0 pending only in this tab'); + expect(rounded((await readReceipt(owned.page, graph.graphId, nodeId)).receipt)).toEqual(rounded(latest)); + expect(await readSaved(page, graph.graphId, nodeId)).toEqual(initial); + await owned.page.exposeFunction('ownedUnloadObserved', () => { unloads++; }); + await owned.page.evaluate(() => addEventListener('beforeunload', () => { void (window as any).ownedUnloadObserved(); })); + await killOwnedBrowser(owned.process); + expect(owned.process.signalCode).toBe('SIGKILL'); + expect(unloads).toBe(0); + owned = await startOwnedBrowser(profile); + await owned.page.setViewportSize({ width: 1440, height: 900 }); + await owned.page.goto(getBaseURL()); + await expect(owned.page.getByTestId('graph-selector')).toBeVisible(); + await expect.poll(async () => rounded(await readSaved(page, graph.graphId, nodeId))).toEqual(rounded(latest)); + await expect.poll(async () => { const value = await readPosition(owned!.page, nodeId); return value && rounded(value); }).toEqual(rounded(latest)); + expect((await readReceipt(owned.page, graph.graphId, nodeId)).receipt).toBeUndefined(); + await owned.page.reload(); + await expect.poll(async () => { const value = await readPosition(owned!.page, nodeId); return value && rounded(value); }).toEqual(rounded(latest)); + await info.attach('layout-process-restart.json', { contentType: 'application/json', body: JSON.stringify({ graphId: graph.graphId, nodeId, initial, first, latest, attempts, signal: 'SIGKILL', unloads, independentReadback: await readSaved(page, graph.graphId, nodeId) }) }); + } finally { + if (owned) await stopOwnedBrowser(owned.process, owned.browser); + await rm(profile, { recursive: true, force: true }); + } +}); + + +test('graph switch stops the previous simulation before delayed layout hydration @layout-recovery', async ({ page, context, ownedGraphIds }) => { + test.setTimeout(90000); + await login(page, TEST_USERS.ADMIN); + const first = await ownedGraphIds.seed({ size: 2, spacing: 600, edgeFactor: 0.5, namePrefix: 'Hydration source' }); + const second = await ownedGraphIds.seed({ size: 2, spacing: 600, edgeFactor: 0.5, namePrefix: 'Hydration destination' }); + const graphs = (await gql(page, '{graphs{id name}}')).graphs as Array<{id: string; name: string}>; + await openGraph(page, first.graphId); + await expect.poll(() => page.evaluate(() => (window as any).__layoutMetrics?.()?.atRest)).toBe(true); + await page.evaluate(() => { + const node = document.querySelector('.graph-container svg .node') as Element & {__data__: {x: number}}; + node.__data__.x += 30; + }); + const releaseReads = await holdLayoutReads(context); + try { + await page.getByTestId('graph-selector').click(); + await page.getByTestId('graph-selector-dropdown').getByText(graphs.find(graph => graph.id === second.graphId)!.name, {exact: true}).click(); + await page.keyboard.press('Escape'); + await expect(page.getByText('Recovering saved layout...', {exact: true})).toBeVisible(); + await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pagehide'))); + await expect(page.getByText('Layout needs attention', {exact: true})).toHaveCount(0); + } finally { await releaseReads(); } + await expect(page.locator('.graph-container svg .node')).toHaveCount(2); + expect((await readReceipt(page, second.graphId, first.nodeIds[0])).receipt).toBeUndefined(); + expect((await readReceipt(page, second.graphId, first.nodeIds[1])).receipt).toBeUndefined(); + await expect(page.getByTestId('layout-save-status')).toHaveCount(0); +}); + +test('quota-failed stale work cannot overwrite a newer real tab save @layout-recovery', async ({ page, context, ownedGraphIds }) => { + test.setTimeout(90000); + await login(page, TEST_USERS.ADMIN); + const graph = await ownedGraphIds.seed({ size: 2, spacing: 600, edgeFactor: 0.5, namePrefix: 'Volatile tab conflict' }); + const nodeId = graph.nodeIds[0]; + await openGraph(page, graph.graphId); + await page.evaluate(({ graphId, nodeId }) => { + const original = IDBObjectStore.prototype.put; + (window as any).__failOwnedLayout = true; + IDBObjectStore.prototype.put = function(value, ...args) { + if ((window as any).__failOwnedLayout && this.name === 'accounts' && value?.layoutOutbox?.graphs?.[JSON.stringify(graphId)]?.[JSON.stringify(nodeId)]) throw new DOMException('Owned layout quota failure', 'QuotaExceededError'); + return original.call(this, value, ...args); + }; + }, { graphId: graph.graphId, nodeId }); + await drag(page, nodeId); + await expect(page.getByTestId('layout-save-status')).toContainText('0 saved on this device; 1 pending only in this tab'); + await expect(page.getByTestId('layout-save-status')).toContainText('Layout recovery is unavailable'); + const other = await context.newPage(); + try { + await other.goto(getBaseURL()); + await openGraph(other, graph.graphId); + const newest = await drag(other, nodeId); + await expect(other.getByTestId('layout-save-status')).toHaveText('Layout saved to the server.'); + await page.evaluate(() => { (window as any).__failOwnedLayout = false; }); + await page.getByRole('button', { name: 'Retry layout save' }).click(); + await expect(page.getByTestId('layout-save-status')).toContainText('Another tab changed this layout'); + expect(rounded(await readSaved(page, graph.graphId, nodeId))).toEqual(rounded(newest)); + await page.getByRole('button', { name: 'Discard unsent layout' }).click(); + await expect(page.getByTestId('layout-save-status')).toHaveCount(0); + await page.reload(); + await expect.poll(async () => { const value = await readPosition(page, nodeId); return value && rounded(value); }).toEqual(rounded(newest)); + } finally { await other.close(); } +}); + +test('logout and another account do not replay or expose the previous owner pending layout @layout-recovery', async ({ page, browser, ownedGraphIds }) => { + test.setTimeout(120000); + await login(page, TEST_USERS.ADMIN); + const graph = await ownedGraphIds.seed({ size: 2, spacing: 600, edgeFactor: 0.5, namePrefix: 'Account isolated recovery' }); + const nodeId = graph.nodeIds[0]; + const initial = await readSaved(page, graph.graphId, nodeId); + const isolated = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const editor = await isolated.newPage(); + try { + await login(editor, TEST_USERS.ADMIN); + await openGraph(editor, graph.graphId); + const attempts: Array<{ x: number; y: number }> = []; + const route = failLayout(graph.graphId, nodeId, attempts); + await editor.route('**/api/graphql', route); + const latest = await drag(editor, nodeId); + await expect(editor.getByTestId('layout-save-status')).toContainText('1 saved on this device; 0 pending only in this tab'); + await logout(editor); + expect(await editor.evaluate(() => localStorage.getItem('authToken'))).toBeNull(); + await editor.unroute('**/api/graphql', route); + await editor.evaluate(() => window.dispatchEvent(new Event('online'))); + expect(await readSaved(page, graph.graphId, nodeId)).toEqual(initial); + await login(editor, TEST_USERS.VIEWER, { forceReauth: true, retries: 1 }); + await openGraph(editor, graph.graphId); + expect(await readSaved(page, graph.graphId, nodeId)).toEqual(initial); + await editor.goto(new URL('/settings', getBaseURL()).href); + await expect(editor.getByTestId('layout-recovery-settings')).toContainText('No pending layouts for this account on this device'); + await expect(editor.getByTestId(`layout-recovery-${graph.graphId}`)).toHaveCount(0); + await logout(editor); + await login(editor, TEST_USERS.ADMIN, { forceReauth: true, retries: 1 }); + await openGraph(editor, graph.graphId); + await expect.poll(async () => rounded(await readSaved(page, graph.graphId, nodeId))).toEqual(rounded(latest)); + expect((await readReceipt(editor, graph.graphId, nodeId)).receipt).toBeUndefined(); + } finally { await isolated.close(); } +}); + +test('revoked graph writes and deleted graphs retain scoped recovery until explicit Settings discard @layout-recovery', async ({ page, ownedGraphIds }) => { + test.setTimeout(90000); + if (!process.env.NEO4J_URI || !process.env.NEO4J_USER || !process.env.NEO4J_PASSWORD) throw new Error('Layout access acceptance requires explicit owned Neo4j fixture credentials.'); + await login(page, TEST_USERS.ADMIN); + const graph = await ownedGraphIds.seed({ size: 2, spacing: 600, edgeFactor: 0.5, namePrefix: 'Revoked layout recovery' }); + const nodeId = graph.nodeIds[0]; + const owner = (await gql(page, '{me{id}}')).me.id as string; + const initial = await readSaved(page, graph.graphId, nodeId); + const revoked = `owned-layout-revoked-${graph.graphId}`; + const driver = neo4j.driver(process.env.NEO4J_URI, neo4j.auth.basic(process.env.NEO4J_USER, process.env.NEO4J_PASSWORD)); + const session = driver.session(); + try { + await openGraph(page, graph.graphId); + const attempts: Array<{ x: number; y: number }> = []; + const route = failLayout(graph.graphId, nodeId, attempts); + await page.route('**/api/graphql', route); + await drag(page, nodeId); + await expect(page.getByTestId('layout-save-status')).toContainText('1 saved on this device; 0 pending only in this tab'); + const changed = await session.run('MATCH (g:Graph {id:$id,createdBy:$owner}) SET g.createdBy=$revoked RETURN g.id AS id', { id: graph.graphId, owner, revoked }); + expect(changed.records.map(record => record.get('id'))).toEqual([graph.graphId]); + await page.unroute('**/api/graphql', route); + await page.reload(); + await expect(page.getByTestId('layout-save-status')).toContainText('The task position was not saved'); + await expect(page.getByTestId('layout-save-status')).toContainText('Waiting to send 1 layout change'); + expect(await readSaved(page, graph.graphId, nodeId)).toEqual(initial); + expect((await readReceipt(page, graph.graphId, nodeId)).receipt).toBeDefined(); + const restored = await session.run('MATCH (g:Graph {id:$id,createdBy:$revoked}) SET g.createdBy=$owner RETURN g.id AS id', { id: graph.graphId, owner, revoked }); + expect(restored.records.map(record => record.get('id'))).toEqual([graph.graphId]); + await gql(page, 'mutation($id:ID!){deleteEdges(where:{OR:[{source:{id:$id}},{target:{id:$id}}]}){nodesDeleted}}', { id: nodeId }); + await gql(page, 'mutation($id:ID!,$graph:ID!){deleteWorkItems(where:{id:$id,graph:{id:$graph}}){nodesDeleted}}', { id: nodeId, graph: graph.graphId }); + await page.getByRole('button', { name: 'Retry layout save' }).click(); + await expect(page.getByTestId('layout-save-status')).toContainText('The task position was not saved'); + expect((await readReceipt(page, graph.graphId, nodeId)).receipt).toBeDefined(); + expect((await gql(page, 'query($id:ID!){workItems(where:{id:$id}){id}}', { id: nodeId })).workItems).toEqual([]); + await deleteGraphDeep(page, graph.graphId, { strict: true }); + await page.goto(new URL('/settings', getBaseURL()).href); + await page.reload(); + const recovery = page.getByTestId(`layout-recovery-${graph.graphId}`); + await expect(recovery).toContainText('Unavailable graph'); + await expect(recovery).toContainText('1 pending position'); + await recovery.getByRole('button', { name: 'Discard pending layout' }).click(); + await recovery.getByRole('button', { name: 'Confirm discard' }).click(); + await expect(recovery).toHaveCount(0); + expect((await readReceipt(page, graph.graphId, nodeId)).receipt).toBeUndefined(); + expect((await gql(page, 'query($id:ID!){graphs(where:{id:$id}){id}}', { id: graph.graphId })).graphs).toEqual([]); + } finally { + await session.run('MATCH (g:Graph {id:$id,createdBy:$revoked}) SET g.createdBy=$owner', { id: graph.graphId, owner, revoked }); + await session.close(); + await driver.close(); + } +}); + +test('retry after real server acceptance only clears a failed local receipt @layout-recovery', async ({ page, ownedGraphIds }) => { + test.setTimeout(90000); + await login(page, TEST_USERS.ADMIN); + const graph = await ownedGraphIds.seed({ size: 2, spacing: 600, edgeFactor: 0.5, namePrefix: 'Accepted receipt cleanup' }); + const nodeId = graph.nodeIds[0]; + await openGraph(page, graph.graphId); + await page.evaluate(({ graphId, nodeId }) => { + const original = IDBObjectStore.prototype.put; + (window as any).__failOwnedCleanup = false; + IDBObjectStore.prototype.put = function(value, ...args) { + if ((window as any).__failOwnedCleanup && this.name === 'accounts' && !value?.layoutOutbox?.graphs?.[JSON.stringify(graphId)]?.[JSON.stringify(nodeId)]) throw new DOMException('Owned receipt cleanup failure', 'QuotaExceededError'); + return original.call(this, value, ...args); + }; + }, { graphId: graph.graphId, nodeId }); + let sends = 0; + await page.route('**/api/graphql', async route => { + const body = route.request().method() === 'POST' ? route.request().postDataJSON() : null; + if (body?.operationName !== 'UpdateWorkItems' || body?.variables?.where?.id !== nodeId || body?.variables?.where?.graph?.id !== graph.graphId || Object.keys(body?.variables?.update || {}).sort().join(',') !== 'positionX,positionY') { await route.continue(); return; } + sends++; + const response = await route.fetch(); + expect((await response.json()).data.updateWorkItems.workItems.map((item: {id: string}) => item.id)).toContain(nodeId); + await page.evaluate(() => { (window as any).__failOwnedCleanup = true; }); + await route.fulfill({ response }); + }); + const dragged = await drag(page, nodeId); + await expect(page.getByTestId('layout-save-status')).toContainText('The server accepted 1 layout change'); + await expect(page.getByRole('button', { name: 'Retry receipt cleanup' })).toBeEnabled(); + expect(rounded(await readSaved(page, graph.graphId, nodeId))).toEqual(rounded(dragged)); + const later = { x: dragged.x + 30, y: dragged.y + 30 }; + await gql(page, 'mutation($id:ID!,$graph:ID!,$x:Float!,$y:Float!){updateWorkItems(where:{id:$id,graph:{id:$graph}},update:{positionX:$x,positionY:$y}){workItems{id}}}', { id: nodeId, graph: graph.graphId, ...later }); + await page.evaluate(() => { (window as any).__failOwnedCleanup = false; }); + await page.getByRole('button', { name: 'Retry receipt cleanup' }).click(); + await expect(page.getByTestId('layout-save-status')).toHaveText('Layout saved to the server.'); + expect(sends).toBe(1); + expect(rounded(await readSaved(page, graph.graphId, nodeId))).toEqual(rounded(later)); + expect((await readReceipt(page, graph.graphId, nodeId)).receipt).toBeUndefined(); +}); + +test('unavailable storage and missing tab coordination remain explicit without false saved status @layout-recovery', async ({ page, ownedGraphIds }) => { + test.setTimeout(90000); + await login(page, TEST_USERS.ADMIN); + const graph = await ownedGraphIds.seed({ size: 2, spacing: 600, edgeFactor: 0.5, namePrefix: 'Unavailable recovery capabilities' }); + const nodeId = graph.nodeIds[0]; + const initial = await readSaved(page, graph.graphId, nodeId); + await page.addInitScript(() => { + const original = IDBObjectStore.prototype.get; + (window as any).__denyOwnedLayoutRead = true; + IDBObjectStore.prototype.get = function(key) { + if ((window as any).__denyOwnedLayoutRead && this.name === 'accounts') throw new DOMException('Owned storage read unavailable', 'SecurityError'); + return original.call(this, key); + }; + }); + await page.evaluate(id => { localStorage.setItem('currentGraphId', id); localStorage.setItem('graphdone:viewMode', 'graph'); }, graph.graphId); + await page.reload(); + await expect(page.getByTestId('layout-save-status')).toContainText('Layout recovery is unavailable'); + await expect(page.getByTestId('layout-save-status')).not.toContainText('Layout saved to the server'); + await expect(page.locator('.graph-container svg .node')).toHaveCount(0); + await page.evaluate(() => { (window as any).__denyOwnedLayoutRead = false; }); + await page.getByRole('button', { name: 'Retry layout save' }).click(); + await expect(page.locator('.graph-container svg .node')).toHaveCount(2); + await page.evaluate(() => Object.defineProperty(navigator, 'locks', { configurable: true, value: undefined })); + await drag(page, nodeId); + await expect(page.getByTestId('layout-save-status')).toContainText('cannot coordinate safe layout recovery between tabs'); + await expect(page.getByTestId('layout-save-status')).toContainText('1 saved on this device; 0 pending only in this tab'); + expect(await readSaved(page, graph.graphId, nodeId)).toEqual(initial); + await page.getByRole('button', { name: 'Discard unsent layout' }).click(); + await expect(page.getByTestId('layout-save-status')).toHaveCount(0); + expect((await readReceipt(page, graph.graphId, nodeId)).receipt).toBeUndefined(); +}); + + +test('pending layout keeps semantic overview labels fixed during pan and zoom @layout-recovery', async ({ page, ownedGraphIds }) => { + test.setTimeout(120000); + await login(page, TEST_USERS.ADMIN); + const graph = await ownedGraphIds.seed({ size: 40, spacing: 600, batchSize: 20, namePrefix: 'Pending layout LOD', metadataForIndex: index => ({ lod: { groupPath: [`Subsystem ${index % 8}`] } }) }); + const nodeId = graph.nodeIds[24]; + await openGraph(page, graph.graphId, 40); + await page.getByLabel('Graph detail', {exact: true}).selectOption('items'); + const svg = page.locator('.graph-container > svg'); + const initial = await readSaved(page, graph.graphId, nodeId); + const zoom = async (scale: number) => { + await page.evaluate(({position, scale}) => { + (window as any).miniMapZoom(position.x, position.y, scale); + }, {position: initial, scale}); + await expect.poll(() => svg.evaluate(element => (element as any).__zoom.k)).toBe(scale); + }; + await zoom(1.2); + const attempts: Array<{x: number; y: number}> = []; + await page.route('**/api/graphql', failLayout(graph.graphId, nodeId, attempts)); + const latest = await drag(page, nodeId); + await expect(page.getByTestId('layout-save-status')).toContainText('1 saved on this device; 0 pending only in this tab'); + await zoom(0.25); + await page.getByLabel('Graph detail', {exact: true}).selectOption('overview'); + const cards = page.locator('[data-lod-group]'); + await expect.poll(() => cards.count()).toBeGreaterThan(1); + const geometry = () => cards.evaluateAll(elements => elements.map(element => { + const rect = element.getBoundingClientRect(); + return {id: element.getAttribute('data-lod-group'), x: rect.x, y: rect.y, w: rect.width, h: rect.height}; + })); + const before = await geometry(); + const camera = () => svg.evaluate(element => { const transform = (element as any).__zoom; return { x: transform.x, y: transform.y, k: transform.k }; }); + const beforeCamera = await camera(); + const box = (await svg.boundingBox())!; + const empty = await svg.evaluate(element => { + const rect = element.getBoundingClientRect(); + for (const x of [0.8, 0.6, 0.4]) for (const y of [0.8, 0.65, 0.5]) { + const point = { x: rect.x + rect.width * x, y: rect.y + rect.height * y }; + const hit = document.elementFromPoint(point.x, point.y); + if (hit && element.contains(hit) && !hit.closest('[data-lod-group], [data-lod-controls]')) return point; + } + return null; + }); + expect(empty, 'a visible SVG pan surface is available').not.toBeNull(); + await page.mouse.move(empty!.x, empty!.y); + await page.mouse.down(); + await page.mouse.move(empty!.x + Math.min(90, box.width / 10), empty!.y - 35, {steps: 12}); + await page.mouse.up(); + await expect.poll(camera).not.toEqual(beforeCamera); + expect(await geometry()).toEqual(before); + const beforeWheel = await camera(); + await cards.first().hover(); + await page.mouse.wheel(0, -120); + await expect.poll(async () => (await camera()).k).toBeGreaterThan(beforeWheel.k); + expect(await geometry()).toEqual(before); + expect(attempts).toHaveLength(1); + expect(rounded((await readReceipt(page, graph.graphId, nodeId)).receipt)).toEqual(rounded(latest)); + expect(await readSaved(page, graph.graphId, nodeId)).toEqual(initial); +}); diff --git a/tests/e2e/smoke/user-smoke.spec.ts b/tests/e2e/smoke/user-smoke.spec.ts index 91c006e1..82b4c60a 100644 --- a/tests/e2e/smoke/user-smoke.spec.ts +++ b/tests/e2e/smoke/user-smoke.spec.ts @@ -472,7 +472,7 @@ test.describe('user smoke: the app works from a user point of view @smoke', () = await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))); expect(attempts).toHaveLength(1); releaseFailure(); - await expect(page.getByText('Layout could not be saved', { exact: true })).toBeVisible(); + await expect(page.getByText('Layout needs attention', { exact: true })).toBeVisible(); await page.getByLabel('Workspace view', { exact: true }).selectOption('cards'); await expect(page.locator('.graph-container svg')).toHaveCount(0); await expect(page.getByLabel('Search work items', { exact: true })).toBeVisible(); diff --git a/tests/lib/runner/unified-cli.test.mjs b/tests/lib/runner/unified-cli.test.mjs index a16b7892..5e9f3c69 100644 --- a/tests/lib/runner/unified-cli.test.mjs +++ b/tests/lib/runner/unified-cli.test.mjs @@ -46,7 +46,7 @@ test('disposable Git fixture commits do not spawn background maintenance', t => assert.deepEqual(events.filter(event => event.event === 'child_start' && event.argv?.includes('maintenance')), []); }); -for (const sequence of ['new-user', 'semantic-lod', 'router-navigation', 'mobile', 'perf-budgets']) { +for (const sequence of ['new-user', 'semantic-lod', 'layout-recovery', 'router-navigation', 'mobile', 'perf-budgets']) { test(`CLI forwards failure-only tracing without retries for ${sequence}`, t => { const { dir, run } = fixture(t); const report = { suites: [{ specs: [{ title: 'fixture', ok: true, @@ -60,8 +60,8 @@ for (const sequence of ['new-user', 'semantic-lod', 'router-navigation', 'mobile assert.ok(!args.includes('tests/e2e/graph/semantic-lod.spec.ts')); assert.ok(!args.includes('tests/e2e/auth/router-navigation.spec.ts')); } - if (sequence === 'semantic-lod' || sequence === 'router-navigation') { - const spec = sequence === 'semantic-lod' ? 'tests/e2e/graph/semantic-lod.spec.ts' : 'tests/e2e/auth/router-navigation.spec.ts'; + if (sequence === 'semantic-lod' || sequence === 'layout-recovery' || sequence === 'router-navigation') { + const spec = sequence === 'semantic-lod' ? 'tests/e2e/graph/semantic-lod.spec.ts' : sequence === 'layout-recovery' ? 'tests/e2e/graph/layout-recovery.spec.ts' : 'tests/e2e/auth/router-navigation.spec.ts'; assert.ok(args.includes(spec)); assert.equal(SEQUENCES[sequence].blocking, true); const workflow = readFileSync(join(root, '.github/workflows/ci.yml'), 'utf8'); diff --git a/tests/sequences/unified.config.mjs b/tests/sequences/unified.config.mjs index 6f72a0cc..99c73faf 100644 --- a/tests/sequences/unified.config.mjs +++ b/tests/sequences/unified.config.mjs @@ -32,6 +32,9 @@ export const SEQUENCES = { 'semantic-lod': { adapter: 'playwright', title: 'Semantic LOD acceptance', args: [ 'tests/e2e/graph/semantic-lod.spec.ts', `--project=${PW}`, '--headed', '--workers=1', '--retries=0', '--trace=retain-on-failure', ], blocking: true }, + 'layout-recovery': { adapter: 'playwright', title: 'Durable layout recovery acceptance', args: [ + 'tests/e2e/graph/layout-recovery.spec.ts', `--project=${PW}`, '--headed', '--workers=1', '--retries=0', '--trace=retain-on-failure', + ], blocking: true }, 'router-navigation': { adapter: 'playwright', title: 'Route compatibility acceptance', args: [ 'tests/e2e/auth/router-navigation.spec.ts', `--project=${PW}`, '--headed', '--workers=1', '--retries=0', '--trace=retain-on-failure', ], blocking: true }, @@ -56,8 +59,8 @@ export const SEQUENCES = { export const PROFILES = { smoke: ['unit-web', 'smoke', 'e2e-auth'], - pr: ['unit-web', 'smoke', 'e2e-auth', 'e2e-graph', 'semantic-lod', 'router-navigation', 'mobile', 'perf-budgets'], - full: ['unit-web', 'smoke', 'e2e-auth', 'e2e-graph', 'semantic-lod', 'router-navigation', 'mobile', 'perf-budgets', 'diagnostics', 'showcase', 'matrix', 'vlm', 'perf-scale', 'cloud-audit'], + pr: ['unit-web', 'smoke', 'e2e-auth', 'e2e-graph', 'semantic-lod', 'layout-recovery', 'router-navigation', 'mobile', 'perf-budgets'], + full: ['unit-web', 'smoke', 'e2e-auth', 'e2e-graph', 'semantic-lod', 'layout-recovery', 'router-navigation', 'mobile', 'perf-budgets', 'diagnostics', 'showcase', 'matrix', 'vlm', 'perf-scale', 'cloud-audit'], report: ['diagnostics', 'showcase', 'matrix', 'vlm', 'perf-scale', 'cloud-audit'], live: ['live-journey'], };