| 1 | // Run: tsx src/__tests__/send-failed.test.ts |
| 2 | |
| 3 | import { readFileSync } from "node:fs"; |
| 4 | import { dirname, resolve } from "node:path"; |
| 5 | import { fileURLToPath } from "node:url"; |
| 6 | import { acceptsRuntimeEventEpoch, initialState, normalizeTurnSubmit, reducer, replayPendingPromptsForActiveTab, runtimeReadyForSubmit } from "../lib/useController"; |
| 7 | import { continueDelivery } from "../lib/deliveryContinue"; |
| 8 | import { |
| 9 | activateGoalAndSubmit, |
| 10 | activateGoalAndSubmitOnTab, |
| 11 | } from "../lib/goalSubmit"; |
| 12 | import type { WireEvent } from "../lib/types"; |
| 13 | |
| 14 | let passed = 0; |
| 15 | let failed = 0; |
| 16 | |
| 17 | function eq(a: unknown, b: unknown, label: string) { |
| 18 | if (a === b) { |
| 19 | process.stdout.write(` PASS ${label}\n`); |
| 20 | passed += 1; |
| 21 | } else { |
| 22 | process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`); |
| 23 | failed += 1; |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | console.log("\nsend failure feedback"); |
| 28 | |
| 29 | { |
| 30 | const calls: string[] = []; |
| 31 | await activateGoalAndSubmit({ |
| 32 | displayText: "List the existing notes", |
| 33 | submitText: "/ui-ux-pro-max List the existing notes", |
| 34 | structured: { |
| 35 | display: "/ui-ux-pro-max List the existing notes", |
| 36 | input: "List the existing notes", |
| 37 | invocations: [{ name: "ui-ux-pro-max", kind: "skill", offset: 0 }], |
| 38 | }, |
| 39 | applyGoal: async (goal) => { |
| 40 | calls.push(`goal:${goal}`); |
| 41 | }, |
| 42 | send: async (display, submit, structured) => { |
| 43 | calls.push(`send:${display}:${submit}:${structured?.invocations[0]?.name ?? ""}`); |
| 44 | }, |
| 45 | }); |
| 46 | eq(calls.join("|"), "goal:List the existing notes|send:List the existing notes:/ui-ux-pro-max List the existing notes:ui-ux-pro-max", "initial Goal activates before structured Skill submission"); |
| 47 | } |
| 48 | |
| 49 | { |
| 50 | // Bridge failure must abort structured Skill submit: there is no `/goal` fallback. |
| 51 | const calls: string[] = []; |
| 52 | let threw = false; |
| 53 | try { |
| 54 | await activateGoalAndSubmit({ |
| 55 | displayText: "Ship the feature", |
| 56 | submitText: "/ui-ux-pro-max Ship the feature", |
| 57 | structured: { |
| 58 | display: "/ui-ux-pro-max Ship the feature", |
| 59 | input: "Ship the feature", |
| 60 | invocations: [{ name: "ui-ux-pro-max", kind: "skill", offset: 0 }], |
| 61 | }, |
| 62 | applyGoal: async (goal) => { |
| 63 | calls.push(`goal:${goal}`); |
| 64 | throw new Error("SetGoalForTab: tab closed"); |
| 65 | }, |
| 66 | send: async (display, submit, structured) => { |
| 67 | calls.push(`send:${display}:${submit}:${structured?.invocations[0]?.name ?? ""}`); |
| 68 | }, |
| 69 | }); |
| 70 | } catch (error) { |
| 71 | threw = error instanceof Error && error.message === "SetGoalForTab: tab closed"; |
| 72 | } |
| 73 | eq(threw, true, "Goal activation bridge failure propagates"); |
| 74 | eq(calls.join("|"), "goal:Ship the feature", "failed Goal activation does not submit the structured Skill"); |
| 75 | } |
| 76 | |
| 77 | { |
| 78 | // Tab-scoped helper captures source tab and workbench target once; callbacks |
| 79 | // receive both even if a surrounding "active tab" concept changes mid-flight. |
| 80 | const calls: string[] = []; |
| 81 | let releaseSubmit!: () => void; |
| 82 | const submitGate = new Promise<void>((resolve) => { |
| 83 | releaseSubmit = resolve; |
| 84 | }); |
| 85 | let activeTab = "tab-a"; |
| 86 | const pending = activateGoalAndSubmitOnTab({ |
| 87 | tabId: "tab-a", |
| 88 | displayText: "Cross-tab safe goal", |
| 89 | submitText: "/ui-ux-pro-max Cross-tab safe goal", |
| 90 | structured: { |
| 91 | display: "/ui-ux-pro-max Cross-tab safe goal", |
| 92 | input: "Cross-tab safe goal", |
| 93 | invocations: [{ name: "ui-ux-pro-max", kind: "skill", offset: 0 }], |
| 94 | }, |
| 95 | sendToTab: async (tabId, goal, display, submit, structured) => { |
| 96 | await submitGate; |
| 97 | calls.push( |
| 98 | `send:${tabId}:${goal}:${display}:${submit}:${structured?.invocations[0]?.name ?? ""}:active=${activeTab}`, |
| 99 | ); |
| 100 | }, |
| 101 | }); |
| 102 | activeTab = "tab-b"; |
| 103 | calls.push("switched-to-tab-b"); |
| 104 | releaseSubmit(); |
| 105 | await pending; |
| 106 | eq( |
| 107 | calls.join("|"), |
| 108 | "switched-to-tab-b|send:tab-a:Cross-tab safe goal:Cross-tab safe goal:/ui-ux-pro-max Cross-tab safe goal:ui-ux-pro-max:active=tab-b", |
| 109 | "activateGoalAndSubmitOnTab keeps Goal and Skill on the captured source tab", |
| 110 | ); |
| 111 | } |
| 112 | |
| 113 | eq(runtimeReadyForSubmit({ label: "", ready: false, eventChannel: "", cwd: "", runtime: { phase: "starting", epoch: "e1" } }), false, "starting runtime cannot submit"); |
| 114 | eq(runtimeReadyForSubmit({ label: "", ready: false, eventChannel: "", cwd: "", runtime: { phase: "lease_blocked", epoch: "e1" } }), false, "lease-blocked runtime cannot submit"); |
| 115 | eq(runtimeReadyForSubmit({ label: "", ready: false, eventChannel: "", cwd: "", runtime: { phase: "failed", epoch: "e1" } }), false, "failed runtime cannot submit"); |
| 116 | eq(runtimeReadyForSubmit({ label: "", ready: true, eventChannel: "", cwd: "", runtime: { phase: "ready", epoch: "e1" } }), true, "ready runtime can submit"); |
| 117 | eq(normalizeTurnSubmit(" visible prompt ", " provider prompt ").submit, "provider prompt", "submit normalization trims provider input"); |
| 118 | let rejectedVisibleOnlySubmit = false; |
| 119 | try { |
| 120 | normalizeTurnSubmit("visible prompt", " "); |
| 121 | } catch { |
| 122 | rejectedVisibleOnlySubmit = true; |
| 123 | } |
| 124 | eq(rejectedVisibleOnlySubmit, true, "visible display text cannot start an empty provider turn"); |
| 125 | eq(acceptsRuntimeEventEpoch("e2", "e1"), false, "old runtime epoch is rejected"); |
| 126 | eq(acceptsRuntimeEventEpoch("e2", "e2"), true, "current runtime epoch is accepted"); |
| 127 | eq(acceptsRuntimeEventEpoch(undefined, "e1"), true, "first runtime epoch can establish the fence"); |
| 128 | eq(acceptsRuntimeEventEpoch("e2", undefined), true, "legacy events remain compatible"); |
| 129 | |
| 130 | const sent = reducer({ ...initialState }, { type: "user", text: "hello", seq: 0 }); |
| 131 | eq(sent.items.length, 1, "submit appends the user bubble immediately"); |
| 132 | eq(sent.items[0].kind === "user" && sent.items[0].text, "hello", "bubble carries the submitted text"); |
| 133 | eq(sent.running, true, "submit marks the turn running"); |
| 134 | eq(sent.pendingUser, "hello", "submit tracks the optimistic bubble"); |
| 135 | |
| 136 | const hiddenSubmit = reducer({ ...initialState }, { type: "user", text: "display prompt", submitText: "hidden context\ndisplay prompt", seq: 0 }); |
| 137 | eq( |
| 138 | hiddenSubmit.items[0].kind === "user" && hiddenSubmit.items[0].submitText, |
| 139 | "hidden context\ndisplay prompt", |
| 140 | "optimistic user bubble preserves submit-only context", |
| 141 | ); |
| 142 | |
| 143 | const confirmed = reducer(sent, { type: "event", e: { kind: "text", text: "hi" } as WireEvent }); |
| 144 | eq(confirmed.items.filter((it) => it.kind === "user").length, 1, "first backend event confirms without duplicating"); |
| 145 | eq(confirmed.pendingUser, undefined, "confirmation clears the pending marker"); |
| 146 | |
| 147 | const memoryCitationMessage = { |
| 148 | kind: "message", |
| 149 | memoryCitations: [{ kind: "memory_reference", source: "MEMORY.md", note: "reasonix workflow" }], |
| 150 | } as WireEvent; |
| 151 | const started = reducer(sent, { type: "event", e: { kind: "turn_started" } as WireEvent }); |
| 152 | const citationOnlyFinal = reducer(started, { type: "event", e: memoryCitationMessage }); |
| 153 | eq(citationOnlyFinal.items.length, 1, "memory citations alone do not leave an empty assistant bubble"); |
| 154 | eq(citationOnlyFinal.items.some((it) => it.kind === "assistant"), false, "memory citations alone stay hidden from the transcript"); |
| 155 | const textThenCitationFinal = reducer(reducer(started, { type: "event", e: { kind: "text", text: "done" } as WireEvent }), { type: "event", e: memoryCitationMessage }); |
| 156 | const citedAssistant = textThenCitationFinal.items.find((it) => it.kind === "assistant"); |
| 157 | eq(citedAssistant?.kind === "assistant" && citedAssistant.text, "done", "memory citations preserve existing assistant text"); |
| 158 | eq(citedAssistant?.kind === "assistant" && citedAssistant.memoryCitations?.length, 1, "memory citations attach to real assistant content"); |
| 159 | |
| 160 | const failedState = reducer(sent, { type: "send_failed", error: "Send failed: bridge unavailable" }); |
| 161 | const failedBubble = failedState.items.find((it) => it.kind === "user"); |
| 162 | eq(failedBubble?.kind === "user" && failedBubble.failed, true, "send_failed marks the bubble failed"); |
| 163 | const notice = failedState.items[failedState.items.length - 1]; |
| 164 | eq(notice.kind, "notice", "send_failed appends a notice"); |
| 165 | eq(notice.kind === "notice" && notice.level, "warn", "the notice is a warning"); |
| 166 | eq(failedState.running, false, "send_failed stops the running indicator"); |
| 167 | eq(failedState.pendingUser, undefined, "send_failed clears the pending marker"); |
| 168 | |
| 169 | const readinessStarted = reducer(sent, { type: "event", e: { kind: "turn_started" } as WireEvent }); |
| 170 | const readinessState = reducer(readinessStarted, { |
| 171 | type: "event", |
| 172 | e: { |
| 173 | kind: "turn_done", |
| 174 | outcome: "final_readiness", |
| 175 | err: "final-answer readiness failed 3 times: missing verification", |
| 176 | readiness: { attempts: 3, missing: ["verification", "review"] }, |
| 177 | } as WireEvent, |
| 178 | }); |
| 179 | const readinessNotice = readinessState.items[readinessState.items.length - 1]; |
| 180 | eq(readinessNotice.kind, "notice", "final readiness appends a notice"); |
| 181 | eq(readinessNotice.kind === "notice" && readinessNotice.level, "info", "final readiness uses informational severity"); |
| 182 | eq(readinessNotice.kind === "notice" && readinessNotice.variant, "delivery", "final readiness uses the delivery status treatment"); |
| 183 | eq(readinessNotice.kind === "notice" && readinessNotice.title, "Delivery checks are not complete", "final readiness uses localized product copy"); |
| 184 | eq(readinessNotice.kind === "notice" && readinessNotice.detail, "Still needed: verification, change review", "structured requirements produce localized detail"); |
| 185 | eq(readinessNotice.kind === "notice" && readinessNotice.action, "continue_delivery", "final readiness offers a recovery action"); |
| 186 | const readinessUser = readinessState.items.find((it) => it.kind === "user"); |
| 187 | eq(readinessUser?.kind === "user" && Boolean(readinessUser.failed), false, "final readiness does not mark the delivered user message as failed"); |
| 188 | eq(readinessState.running, false, "an unclicked continue-check action does not keep the turn running"); |
| 189 | eq(readinessState.pendingPrompt, false, "an unclicked continue-check action does not create a pending prompt"); |
| 190 | |
| 191 | const recovering = reducer(readinessState, { type: "user", text: "Continue checks", seq: readinessState.seq, deliveryRecovery: true }); |
| 192 | const recovered = reducer(recovering, { type: "event", e: { kind: "turn_done" } as WireEvent }); |
| 193 | eq(recovered.items.some((it) => it.kind === "notice" && it.variant === "delivery"), false, "successful explicit recovery removes the stale delivery card"); |
| 194 | |
| 195 | const ordinaryTurnError = reducer(readinessStarted, { |
| 196 | type: "event", |
| 197 | e: { kind: "turn_done", err: "provider failed" } as WireEvent, |
| 198 | }); |
| 199 | const ordinaryTurnNotice = ordinaryTurnError.items[ordinaryTurnError.items.length - 1]; |
| 200 | eq(ordinaryTurnNotice.kind === "notice" && ordinaryTurnNotice.level, "warn", "ordinary turn errors remain warnings"); |
| 201 | eq(ordinaryTurnNotice.kind === "notice" && ordinaryTurnNotice.text, "provider failed", "ordinary turn errors keep their diagnostic text"); |
| 202 | |
| 203 | const recoveryPaused = reducer(readinessStarted, { |
| 204 | type: "event", |
| 205 | e: { |
| 206 | kind: "turn_done", |
| 207 | outcome: "recovery_paused", |
| 208 | err: "Automatic retries paused. Reasonix stopped repeated attempts and kept completed work. Send \"continue\" to start a fresh attempt, or add instructions to change direction.", |
| 209 | } as WireEvent, |
| 210 | }); |
| 211 | const recoveryNotice = recoveryPaused.items[recoveryPaused.items.length - 1]; |
| 212 | eq(recoveryNotice.kind === "notice" && recoveryNotice.level, "info", "recovery_paused uses informational severity"); |
| 213 | eq(recoveryNotice.kind === "notice" && Boolean(recoveryNotice.title), true, "recovery_paused shows a product title"); |
| 214 | eq( |
| 215 | recoveryNotice.kind === "notice" && recoveryNotice.text, |
| 216 | "Reasonix stopped repeated attempts and kept completed work. Send “Continue” to start a fresh attempt, or add instructions to change direction.", |
| 217 | "recovery_paused uses the localized product copy", |
| 218 | ); |
| 219 | eq( |
| 220 | recoveryNotice.kind === "notice" && Boolean(recoveryNotice.detail), |
| 221 | false, |
| 222 | "recovery_paused does not repeat the backend English fallback as localized detail", |
| 223 | ); |
| 224 | const recoveryUser = recoveryPaused.items.find((it) => it.kind === "user"); |
| 225 | eq(recoveryUser?.kind === "user" && Boolean(recoveryUser.failed), false, "recovery_paused does not mark the user message as failed"); |
| 226 | eq(recoveryPaused.running, false, "recovery_paused frees the composer"); |
| 227 | |
| 228 | const shellSent = reducer({ ...initialState }, { type: "user", text: "!ls", seq: 0 }); |
| 229 | const shellFailed = reducer(shellSent, { type: "send_failed", error: "Command failed: workspace is still starting" }); |
| 230 | const shellNotice = shellFailed.items[shellFailed.items.length - 1]; |
| 231 | eq(shellNotice.kind, "notice", "rejected shell command appends a visible notice"); |
| 232 | eq(shellNotice.kind === "notice" && shellNotice.text.includes("workspace is still starting"), true, "shell rejection notice includes the backend error"); |
| 233 | |
| 234 | const lateFailure = reducer(confirmed, { type: "send_failed", error: "Send failed: late" }); |
| 235 | eq(lateFailure, confirmed, "send_failed after backend confirmation is a no-op"); |
| 236 | |
| 237 | const beforeMcpReady = { ...initialState }; |
| 238 | const mcpReady = reducer(beforeMcpReady, { type: "event", e: { kind: "mcp_surface_ready" } as WireEvent }); |
| 239 | eq(mcpReady, beforeMcpReady, "mcp_surface_ready is accepted as a deliberate no-op"); |
| 240 | const pendingMcpReady = reducer(sent, { type: "event", e: { kind: "mcp_surface_ready" } as WireEvent }); |
| 241 | eq(pendingMcpReady, sent, "mcp_surface_ready does not confirm a pending submit"); |
| 242 | const failedAfterMcpReady = reducer(pendingMcpReady, { type: "send_failed", error: "Send failed: bridge unavailable" }); |
| 243 | const failedAfterMcpReadyBubble = failedAfterMcpReady.items.find((it) => it.kind === "user"); |
| 244 | eq( |
| 245 | failedAfterMcpReadyBubble?.kind === "user" && failedAfterMcpReadyBubble.failed, |
| 246 | true, |
| 247 | "send_failed still marks a pending submit after mcp readiness", |
| 248 | ); |
| 249 | |
| 250 | const here = dirname(fileURLToPath(import.meta.url)); |
| 251 | const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8"); |
| 252 | const typesSource = readFileSync(resolve(here, "../lib/types.ts"), "utf8"); |
| 253 | const controllerSource = readFileSync(resolve(here, "../lib/useController.ts"), "utf8"); |
| 254 | eq(typesSource.includes('"mcp_surface_ready"'), true, "TypeScript EventKind declares mcp_surface_ready"); |
| 255 | eq(controllerSource.includes('e.kind === "mcp_surface_ready"'), true, "reducer handles mcp_surface_ready before optimistic confirmation"); |
| 256 | eq( |
| 257 | /if \(allow\) \{\s*await applyCollaborationMode\("normal"\);\s*resolvePlanDecision\(state\.approval!\.id, "start_execution"\);/.test(appSource), |
| 258 | true, |
| 259 | "plan approval clears the remembered plan restore intent and records start execution explicitly", |
| 260 | ); |
| 261 | eq( |
| 262 | /onExitPlan=\{async \(\) => \{\s*await applyCollaborationMode\("normal"\);\s*resolvePlanDecision\(state\.approval!\.id, "exit_plan"\);\s*\}\}/.test(appSource), |
| 263 | true, |
| 264 | "exit-without-executing switches to Normal before recording the explicit plan exit", |
| 265 | ); |
| 266 | eq( |
| 267 | /onRevisePlan=\{\(text\) => \{[\s\S]{0,260}resolvePlanDecision\(state\.approval!\.id, "revise_plan"\);/.test(appSource), |
| 268 | true, |
| 269 | "plan revision records a distinct revise decision", |
| 270 | ); |
| 271 | eq( |
| 272 | !/exit_plan_mode[\s\S]{0,240}rememberUserIntent:\s*false/.test(appSource), |
| 273 | true, |
| 274 | "plan approval must not preserve stale plan restore intent", |
| 275 | ); |
| 276 | eq( |
| 277 | !appSource.includes("rememberUserIntent"), |
| 278 | true, |
| 279 | "collaboration mode changes always reconcile the remembered plan restore intent", |
| 280 | ); |
| 281 | eq( |
| 282 | appSource.includes("runtimeTransitionTabsRef.current.has(tabId)"), |
| 283 | true, |
| 284 | "runtime profile transitions reject rapid duplicate switches for one tab", |
| 285 | ); |
| 286 | eq( |
| 287 | appSource.includes("delete pending.tokenMode") && appSource.includes("tokenMode: previous"), |
| 288 | true, |
| 289 | "failed runtime profile transitions roll back the optimistic token mode", |
| 290 | ); |
| 291 | eq( |
| 292 | appSource.includes("!state.backendActivationPending &&") && appSource.includes("!runtimeTransitioning"), |
| 293 | true, |
| 294 | "runtime profile transitions keep submit behind the controller-ready gate", |
| 295 | ); |
| 296 | eq( |
| 297 | appSource.includes("activateGoalAndSubmitOnTab({") && |
| 298 | appSource.includes("tabId: sourceTabId") && |
| 299 | appSource.includes("goal: nextGoal") && |
| 300 | appSource.includes("collaborationMode: controllerComposerProfileCollaborationMode(composerProfile)") && |
| 301 | appSource.includes("toolApprovalMode,"), |
| 302 | true, |
| 303 | "initial Goal activation captures the submission tab", |
| 304 | ); |
| 305 | eq( |
| 306 | appSource.includes("setControllerGoalForTab(tabId, trimmed)") && appSource.includes("clearControllerGoalForTab(tabId)"), |
| 307 | true, |
| 308 | "tab-scoped Goal activation updates the matching controller", |
| 309 | ); |
| 310 | eq( |
| 311 | /await \(trimmed \? setControllerGoalForTab\(tabId, trimmed\) : clearControllerGoalForTab\(tabId\)\);\s*patchActivatedGoalForTab\(tabId, trimmed\)/.test(appSource), |
| 312 | true, |
| 313 | "local Goal profile is patched only after backend activation succeeds", |
| 314 | ); |
| 315 | eq( |
| 316 | controllerSource.includes("await app.SetGoalForTab(tabId, goal)") && !/SetGoalForTab\(tabId, goal\)\.catch\(\(\) => \{\}\)/.test(controllerSource), |
| 317 | true, |
| 318 | "SetGoalForTab activation failures propagate to callers", |
| 319 | ); |
| 320 | eq( |
| 321 | controllerSource.includes("await app.ClearGoalForTab(tabId)") && !/ClearGoalForTab\(tabId\)\.catch\(\(\) => \{\}\)/.test(controllerSource), |
| 322 | true, |
| 323 | "ClearGoalForTab failures also propagate to callers", |
| 324 | ); |
| 325 | eq( |
| 326 | /await continueDelivery\(\{[\s\S]{0,240}goal: state\.meta\?\.goal,[\s\S]{0,240}resumeGoal: resumeControllerGoalForTab,/.test(appSource), |
| 327 | true, |
| 328 | "delivery recovery routes through continueDelivery with the backend Goal state", |
| 329 | ); |
| 330 | eq( |
| 331 | controllerSource.includes("app.SubmitInitialGoalToTab(") && |
| 332 | appSource.includes("patchActivatedGoalForTab(sourceTabId, trimmed)"), |
| 333 | true, |
| 334 | "the first Goal turn uses the atomic target-scoped backend contract", |
| 335 | ); |
| 336 | |
| 337 | const unsent = reducer(sent, { type: "unsend" }); |
| 338 | eq(unsent.pendingUser, undefined, "unsend clears the pending marker"); |
| 339 | eq(unsent.discardTurn, true, "unsend discards the in-flight turn"); |
| 340 | |
| 341 | const planApprovalFirst = reducer( |
| 342 | { ...initialState }, |
| 343 | { type: "event", e: { kind: "approval_request", approval: { id: "plan-1", tool: "exit_plan_mode", subject: "Approve plan" } } as WireEvent }, |
| 344 | ); |
| 345 | const planTurnDoneAfter = reducer(planApprovalFirst, { type: "event", e: { kind: "turn_done" } as WireEvent }); |
| 346 | eq( |
| 347 | planTurnDoneAfter.approval?.id, |
| 348 | "plan-1", |
| 349 | "turn_done preserves out-of-order plan approval", |
| 350 | ); |
| 351 | eq(planTurnDoneAfter.running, true, "preserved plan approval keeps the tab running"); |
| 352 | eq(planTurnDoneAfter.pendingPrompt, true, "preserved plan approval keeps the prompt gate active"); |
| 353 | |
| 354 | let replayCalls = 0; |
| 355 | replayPendingPromptsForActiveTab(undefined, () => { |
| 356 | replayCalls += 1; |
| 357 | return Promise.resolve(); |
| 358 | }); |
| 359 | eq(replayCalls, 0, "no active tab does not replay pending prompts"); |
| 360 | |
| 361 | replayPendingPromptsForActiveTab("tab-a", () => { |
| 362 | replayCalls += 1; |
| 363 | return Promise.resolve(); |
| 364 | }); |
| 365 | eq(replayCalls, 1, "active tab switch replays pending prompts"); |
| 366 | |
| 367 | replayPendingPromptsForActiveTab("tab-b", () => { |
| 368 | replayCalls += 1; |
| 369 | return Promise.reject(new Error("bridge unavailable")); |
| 370 | }); |
| 371 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 372 | eq(replayCalls, 2, "replay bridge failures are swallowed by the tab-switch effect"); |
| 373 | |
| 374 | console.log("\ndelivery recovery continuation"); |
| 375 | |
| 376 | interface ContinueCalls { |
| 377 | resumes: string[]; |
| 378 | sends: string[]; |
| 379 | } |
| 380 | |
| 381 | async function runContinueDelivery(opts: { |
| 382 | goal: string | undefined; |
| 383 | resumed?: boolean; |
| 384 | ready?: boolean; |
| 385 | tabId?: string | null; |
| 386 | tabAfterResume?: string; |
| 387 | }): Promise<ContinueCalls> { |
| 388 | const calls: ContinueCalls = { resumes: [], sends: [] }; |
| 389 | await continueDelivery({ |
| 390 | tabId: opts.tabId === undefined ? "tab-a" : opts.tabId, |
| 391 | ready: opts.ready ?? true, |
| 392 | goal: opts.goal, |
| 393 | activeTabId: () => opts.tabAfterResume ?? "tab-a", |
| 394 | resumeGoal: (tabId) => { |
| 395 | calls.resumes.push(tabId); |
| 396 | return Promise.resolve(opts.resumed ?? true); |
| 397 | }, |
| 398 | send: (tabId) => { |
| 399 | calls.sends.push(tabId); |
| 400 | return Promise.resolve(); |
| 401 | }, |
| 402 | }); |
| 403 | return calls; |
| 404 | } |
| 405 | |
| 406 | const noGoal = await runContinueDelivery({ goal: undefined }); |
| 407 | eq(noGoal.resumes.length, 0, "delivery recovery without a Goal skips the resume call"); |
| 408 | eq(noGoal.sends.join(","), "tab-a", "delivery recovery without a Goal submits the continuation directly"); |
| 409 | |
| 410 | const blankGoal = await runContinueDelivery({ goal: " " }); |
| 411 | eq(blankGoal.resumes.length, 0, "delivery recovery treats a blank Goal as absent"); |
| 412 | eq(blankGoal.sends.join(","), "tab-a", "delivery recovery with a blank Goal still submits the continuation"); |
| 413 | |
| 414 | const goalResumed = await runContinueDelivery({ goal: "ship it", resumed: true }); |
| 415 | eq(goalResumed.resumes.join(","), "tab-a", "delivery recovery with a Goal resumes it first"); |
| 416 | eq(goalResumed.sends.join(","), "tab-a", "delivery recovery submits after the Goal resumes"); |
| 417 | |
| 418 | const goalRefused = await runContinueDelivery({ goal: "ship it", resumed: false }); |
| 419 | eq(goalRefused.resumes.join(","), "tab-a", "an unresumable Goal is still offered the resume"); |
| 420 | eq(goalRefused.sends.length, 0, "an unresumable (completed) Goal does not submit the continuation"); |
| 421 | |
| 422 | const tabSwitched = await runContinueDelivery({ goal: "ship it", resumed: true, tabAfterResume: "tab-b" }); |
| 423 | eq(tabSwitched.sends.length, 0, "a tab switch during resume drops the continuation"); |
| 424 | |
| 425 | const notReady = await runContinueDelivery({ goal: undefined, ready: false }); |
| 426 | eq(notReady.sends.length, 0, "delivery recovery waits for controller readiness"); |
| 427 | |
| 428 | const noTab = await runContinueDelivery({ goal: undefined, tabId: null }); |
| 429 | eq(noTab.sends.length, 0, "delivery recovery without an active tab is a no-op"); |
| 430 | |
| 431 | console.log(`\n${passed} passed, ${failed} failed`); |
| 432 | if (failed > 0) process.exit(1); |
| 433 |