| 1 | // Run: tsx src/__tests__/navigation-surface-transition.test.ts |
| 2 | |
| 3 | import { readFileSync } from "node:fs"; |
| 4 | import { navigateWorkspace } from "../app-runtime/navigationOwner"; |
| 5 | import { |
| 6 | advanceSurfacePaintCommit, |
| 7 | beginNavigationSurfaceState, |
| 8 | createNavigationSurfaceTicket, |
| 9 | guardBackendNavigationResult, |
| 10 | markNavigationTargetMasked, |
| 11 | matchesNavigationSurfaceTicket, |
| 12 | settleNavigationSurfaceIntent, |
| 13 | settleNavigationSurfaceState, |
| 14 | } from "../lib/navigationSurfaceTransition"; |
| 15 | |
| 16 | let passed = 0; |
| 17 | let failed = 0; |
| 18 | |
| 19 | function ok(value: boolean, label: string) { |
| 20 | process.stdout.write(` ${value ? "PASS" : "FAIL"} ${label}\n`); |
| 21 | if (value) passed += 1; |
| 22 | else failed += 1; |
| 23 | } |
| 24 | |
| 25 | console.log("\nnavigation surface transition"); |
| 26 | |
| 27 | let active: number | null = 1; |
| 28 | active = 2; // B supersedes A before A completes. |
| 29 | active = 3; // C supersedes queued B. |
| 30 | active = settleNavigationSurfaceIntent(active, 1); |
| 31 | ok(active === 3, "A completion cannot release C's mask"); |
| 32 | active = settleNavigationSurfaceIntent(active, 2); |
| 33 | ok(active === 3, "coalesced B completion cannot release C's mask"); |
| 34 | active = settleNavigationSurfaceIntent(active, 3); |
| 35 | ok(active === null, "the latest completion releases its own mask"); |
| 36 | |
| 37 | let surface = beginNavigationSurfaceState(9); |
| 38 | surface = markNavigationTargetMasked(surface, 8); |
| 39 | ok(surface?.phase === "source-retained", "a stale request cannot replace the retained source"); |
| 40 | surface = markNavigationTargetMasked(surface, 9, "tab-target"); |
| 41 | ok(surface?.phase === "target-masked", "the latest request mounts its target under the mask"); |
| 42 | surface = settleNavigationSurfaceState(surface, 8); |
| 43 | ok(surface?.intent === 9, "a stale paint terminal cannot release the latest mask"); |
| 44 | surface = settleNavigationSurfaceState(surface, 9); |
| 45 | ok(surface === null, "the matching paint terminal releases the mask"); |
| 46 | |
| 47 | const ticketA1 = createNavigationSurfaceTicket(20, "tab-a", "session-a:1"); |
| 48 | const ticketB = createNavigationSurfaceTicket(21, "tab-b", "session-b:1"); |
| 49 | const ticketA2 = createNavigationSurfaceTicket(22, "tab-a", "session-a:1"); |
| 50 | ok(matchesNavigationSurfaceTicket(ticketA1, ticketA1.token, 20, "tab-a", "session-a:1"), "paint acknowledgement matches the complete ticket"); |
| 51 | ok(!matchesNavigationSurfaceTicket(ticketB, ticketA1.token, 21, "tab-b", "session-b:1"), "an old paint token cannot commit B"); |
| 52 | ok(!matchesNavigationSurfaceTicket(ticketA2, ticketA1.token, 22, "tab-a", "session-a:1"), "A → B → A cannot revive A's old paint token"); |
| 53 | ok(!matchesNavigationSurfaceTicket(ticketA1, ticketA1.token, 20, "tab-a", "session-a:2"), "same-tab replacement session rejects the old ticket"); |
| 54 | |
| 55 | let paint = advanceSurfacePaintCommit({ attempts: 0, stableFrames: 0 }, { |
| 56 | rendered: true, placementReady: true, geometryReady: true, geometryKey: "755:1200:445", |
| 57 | }); |
| 58 | ok(paint.outcome === undefined, "one paint frame cannot expose the target"); |
| 59 | paint = advanceSurfacePaintCommit(paint.progress, { |
| 60 | rendered: true, placementReady: true, geometryReady: true, geometryKey: "859:1200:341", |
| 61 | }); |
| 62 | ok(paint.outcome === undefined, "a changed viewport geometry restarts the stability gate"); |
| 63 | paint = advanceSurfacePaintCommit(paint.progress, { |
| 64 | rendered: true, placementReady: true, geometryReady: true, geometryKey: "859:1200:341", |
| 65 | }); |
| 66 | ok(paint.outcome === "ready", "two stable geometry frames commit the target"); |
| 67 | let stalled = { attempts: 0, stableFrames: 0 }; |
| 68 | let recoveryRequests = 0; |
| 69 | let degraded: string | undefined; |
| 70 | for (let frame = 0; frame < 180; frame += 1) { |
| 71 | const decision = advanceSurfacePaintCommit(stalled, { |
| 72 | rendered: true, placementReady: false, geometryReady: false, geometryKey: "755:0:0", |
| 73 | }); |
| 74 | stalled = decision.progress; |
| 75 | if (decision.requestRecovery) recoveryRequests += 1; |
| 76 | degraded = decision.outcome; |
| 77 | } |
| 78 | ok(recoveryRequests === 2, "stalled placement receives two bounded recovery opportunities"); |
| 79 | ok(degraded === "degraded", "the second failed placement terminates without permanent loading"); |
| 80 | |
| 81 | let reasserted = ""; |
| 82 | const currentAccepted = await guardBackendNavigationResult({ |
| 83 | intent: 4, |
| 84 | targetTabId: "tab-current", |
| 85 | kind: "tab.reveal-background", |
| 86 | isIntentCurrent: (intent) => intent === 4, |
| 87 | reassert: async (kind, tabId) => { reasserted = `${kind}:${tabId}`; }, |
| 88 | }); |
| 89 | ok(currentAccepted, "the current backend navigation result is accepted"); |
| 90 | ok(reasserted === "", "the current backend navigation result does not reassert"); |
| 91 | |
| 92 | let releaseReassert!: () => void; |
| 93 | const reassertGate = new Promise<void>((resolve) => { releaseReassert = resolve; }); |
| 94 | let staleReassertStarted = false; |
| 95 | const staleAcceptedPromise = guardBackendNavigationResult({ |
| 96 | intent: 4, |
| 97 | targetTabId: "tab-stale", |
| 98 | kind: "tab.reveal-background", |
| 99 | isIntentCurrent: (intent) => intent === 5, |
| 100 | reassert: async (kind, tabId) => { |
| 101 | staleReassertStarted = true; |
| 102 | reasserted = `${kind}:${tabId}`; |
| 103 | await reassertGate; |
| 104 | }, |
| 105 | }); |
| 106 | await Promise.resolve(); |
| 107 | ok(staleReassertStarted, "a stale backend-activating result starts visible-tab reassertion"); |
| 108 | releaseReassert(); |
| 109 | ok(await staleAcceptedPromise === false, "a stale backend-activating result is rejected after reassertion"); |
| 110 | ok(reasserted === "tab.reveal-background:tab-stale", "stale reassertion receives the mutating target identity"); |
| 111 | |
| 112 | const appSource = readFileSync(new URL("../AppRuntime.tsx", import.meta.url), "utf8"); |
| 113 | const chatPaneSource = readFileSync(new URL("../app-shell/ChatPaneRegion.tsx", import.meta.url), "utf8"); |
| 114 | const appViewSource = readFileSync(new URL("../app-shell/AppRuntimeView.tsx", import.meta.url), "utf8"); |
| 115 | const sessionCompositionSource = readFileSync(new URL("../app-runtime/useAppSessionComposition.ts", import.meta.url), "utf8"); |
| 116 | const surfaceHookSource = readFileSync(new URL("../lib/useNavigationSurface.ts", import.meta.url), "utf8"); |
| 117 | const tabBarSource = readFileSync(new URL("../app-runtime/useTabBarCommands.ts", import.meta.url), "utf8"); |
| 118 | const stylesSource = readFileSync(new URL("../styles.css", import.meta.url), "utf8"); |
| 119 | ok(surfaceHookSource.includes("flushSync(() => {"), "navigation masking commits synchronously before the bridge await"); |
| 120 | ok(surfaceHookSource.includes("setPreserved(rendered?.items.length ? rendered : null)"), "the last stable transcript is retained during navigation"); |
| 121 | ok(sessionCompositionSource.includes("visibleTranscriptItems,") && appViewSource.includes("items: session.transcript.visibleTranscriptItems"), "the visible transcript is decoupled from the hydrating target"); |
| 122 | ok(chatPaneSource.includes("transcript-navigation-overlay"), "remote navigation retains its blocking transcript overlay"); |
| 123 | ok(/const presentationTransitioning = runtimeTransitioning && core\.remoteSurfaceActive;/.test(appViewSource) |
| 124 | && appViewSource.includes("transitioning={presentationTransitioning}"), |
| 125 | "local navigation bypasses the blocking overlay while remote navigation keeps the existing behavior"); |
| 126 | ok(/\.transcript-navigation-overlay\s*\{[\s\S]*?background:\s*var\(--chat-bg, var\(--bg\)\)/.test(stylesSource), "the remote navigation overlay remains opaque while target rows settle"); |
| 127 | ok(chatPaneSource.includes("live={transitioning ? undefined : state.live}"), "App removes source live output during navigation"); |
| 128 | ok(!appSource.includes("hidden={composerSurfaceHidden || undefined}"), "navigation no longer collapses the composer footprint"); |
| 129 | // Masked Composer/Todo/rewind layout is exercised through the mounted production |
| 130 | // DecisionFooterRegion in decision-footer-lifecycle.test.tsx, not App source text. |
| 131 | ok(/\.footer--navigation-hidden\s*\{[\s\S]*?visibility:\s*hidden;[\s\S]*?pointer-events:\s*none;/.test(stylesSource), "masked target footer cannot paint or receive input"); |
| 132 | ok(appViewSource.includes('style={core.surface.surface?.phase === "source-retained"') && sessionCompositionSource.includes("const visibleDecisionSurface = decisionSurface"), "target-masked paint uses the target footer geometry"); |
| 133 | ok((tabBarSource.match(/guardBackendNavigationResult\(\{/g) ?? []).length === 2, "both Reveal paths guard stale backend activation results"); |
| 134 | ok(surfaceHookSource.includes("navigation.paint-ready"), "surface settlement is diagnosed only from target paint readiness"); |
| 135 | |
| 136 | let currentWorkspaceIntent = 30; |
| 137 | let releaseWorkspace!: (picked: string) => void; |
| 138 | const workspaceResult = new Promise<string>((resolve) => { releaseWorkspace = resolve; }); |
| 139 | const workspaceCalls: string[] = []; |
| 140 | const staleWorkspace = navigateWorkspace("/workspace-a", { |
| 141 | claimIntent: () => currentWorkspaceIntent, |
| 142 | beginSurface: (intent) => workspaceCalls.push(`begin:${intent}`), |
| 143 | isIntentCurrent: (intent) => intent === currentWorkspaceIntent, |
| 144 | pickWorkspace: async () => "", |
| 145 | switchWorkspace: async (path, intent) => { |
| 146 | workspaceCalls.push(`switch:${intent}:${path}`); |
| 147 | return workspaceResult; |
| 148 | }, |
| 149 | markProjectChanged: (updater) => { updater(0); workspaceCalls.push("changed"); }, |
| 150 | refreshTabsAfterMutation: async (latest) => { workspaceCalls.push(`refresh:${latest() ? "current" : "stale"}`); }, |
| 151 | maskTarget: (intent) => workspaceCalls.push(`mask:${intent}`), |
| 152 | }); |
| 153 | currentWorkspaceIntent = 31; |
| 154 | releaseWorkspace("/workspace-a"); |
| 155 | ok(await staleWorkspace === "/workspace-a", "source workspace data may finish after a newer navigation"); |
| 156 | ok(!workspaceCalls.includes("changed") && !workspaceCalls.some((call) => call.startsWith("refresh:")), "stale workspace completion cannot mutate current UI"); |
| 157 | ok(workspaceCalls[workspaceCalls.length - 1] === "mask:30", "old workspace finally addresses only its own surface intent"); |
| 158 | |
| 159 | console.log(`\n${passed} passed, ${failed} failed`); |
| 160 | if (failed > 0) process.exit(1); |
| 161 |