From cadafe961dfa1c0a59a4aa40d14d80ccf7a2f767 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 20:48:00 +0900 Subject: [PATCH 01/30] fix: a live test run reads imported names and survives a required fact it cannot read (Cycle 0052 task 5) Root cause (recorded in #124): Vitest's module runner binds each imported module whole (__vi_import_N__, __vite_ssr_import_N__), so an imported name such as revalidatePath has no variable of its own and the inspector's lexical root lookup failed; the walk and the size were fine. - inspectNodeTestVariables resolves an unmatched root as an export of those bindings, through a mocked module's Proxy [[Target]], settling a lazy (getter) value as the Variables view does; a name two bindings export stays unread; a required fact it cannot read is returned by name instead of thrown. - OwnedNodeTestRun accepts such a stop with its other facts and names the missing fact among its debugger limitations. - codebook_continue that reached it answers EVIDENCE_UNAVAILABLE naming the fact while the run stays paused there (O4, MCP 1.8 unchanged); the next continue goes on. - The node-test help and the packaged README say so. Tests red on 83f9128e: four inspector cases, the owned-run stop, the service refusal. Restated: the inspector's old refusal test (the refusal moved to the service). Two fixtures (lifecycle harness, startLiveTestRun) now declare the required fact their fake stops carry. Refs #124 --- extension/README.md | 2 +- extension/src/constants.ts | 6 + extension/src/ownedNodeTestRun.ts | 6 + extension/src/resources.ts | 2 +- extension/src/service.ts | 90 +++-- .../tests/inspectNodeTestVariables.test.ts | 307 +++++++++++++++++- .../src/tests/nodeTestRunLifecycle.test.ts | 8 +- extension/src/tests/nodeTestService.test.ts | 154 +++++++++ extension/src/tests/ownedNodeTestRun.test.ts | 44 ++- extension/src/tests/utils/startLiveTestRun.ts | 4 +- .../src/utils/inspectNodeTestVariables.ts | 224 ++++++++++--- 11 files changed, 773 insertions(+), 74 deletions(-) diff --git a/extension/README.md b/extension/README.md index 99983e202..30e33a7f7 100644 --- a/extension/README.md +++ b/extension/README.md @@ -100,7 +100,7 @@ The time slider marks the recording between its first and last event as unrecord Unexpected failures on these paths write one entry each to **Output › Codebook**, with the surface, the study, the profile, the versions, the cause and the stack. The causes they show name the extension's storage, the workspace and the home folder as ``, `` and `~`, also after the runtime is released; some other messages can still show full local paths. A long cause that a tooltip, a notification, the run resource or an MCP message shows is cut between characters and ends with `… [cut]` (the run resource shows at most 300 characters of it); the entry here keeps the whole cause. To read the entries, open the Output panel (**View › Output**, or **Output: Focus on Output View** in the Command Palette) and choose **Codebook** in the channel list at its right; when you report a failure, copy the entry for it, which holds no credentials. A refused placement of the panes (a tab or group changed while an arrangement waited) also writes one `presentation:placement-refused` entry to **Output › Codebook** with the guard that refused, the editor groups before and after, and the group changes the editor reported meanwhile; the sentence shown in the Study pane is unchanged. -The bundled skill (1.0.47) routes a question about a test, or about library behavior a test exercises, to the `node-test` profile, and the chat explains each test stop in the chat. A run that ends with an `error` before its first stop did not run its test: the chat starts it again only where the published recovery of that error's code allows (`codebook://help/errors`), and it treats any text the error quotes, such as test or tool output, as data. The chat claims saved learning only when the run reads `studyOutcome` `demonstrated`, `nodeTest.learningCoverage` `complete` and an empty `nodeTest.captureMissing` (a run resource without those fields supports no such claim), says when a test did not pass and names its exit code or signal, and points you to **Save learning replay** and **Open replay**. When the connection is unavailable, it sends you to **Codebook: Set Up AI Chat**, and in a chat of VS Code's Copilot harness (the default for a new chat), which cannot reach Codebook's tools, to open a Local chat session. +The bundled skill (1.0.47) routes a question about a test, or about library behavior a test exercises, to the `node-test` profile, and the chat explains each test stop in the chat. A value the chat asks for at a test stop is also read when it is a name the test or its source imports (`revalidatePath.mock.calls.length` of a mocked `next/cache`, for example), through the module Vitest binds it to; a required value that still cannot be read no longer ends the run: the stop keeps its other values and names the missing one among its debugger limitations, and the `codebook_continue` that reached it answers `EVIDENCE_UNAVAILABLE` naming it while the run stays paused there, so the chat can continue without it. A run that ends with an `error` before its first stop did not run its test: the chat starts it again only where the published recovery of that error's code allows (`codebook://help/errors`), and it treats any text the error quotes, such as test or tool output, as data. The chat claims saved learning only when the run reads `studyOutcome` `demonstrated`, `nodeTest.learningCoverage` `complete` and an empty `nodeTest.captureMissing` (a run resource without those fields supports no such claim), says when a test did not pass and names its exit code or signal, and points you to **Save learning replay** and **Open replay**. When the connection is unavailable, it sends you to **Codebook: Set Up AI Chat**, and in a chat of VS Code's Copilot harness (the default for a new chat), which cannot reach Codebook's tools, to open a Local chat session. #### Quick start: a test replay diff --git a/extension/src/constants.ts b/extension/src/constants.ts index 5bfefef7c..487f31a90 100644 --- a/extension/src/constants.ts +++ b/extension/src/constants.ts @@ -1062,6 +1062,12 @@ export const TEST_OUTPUT_END_TOLERANCE_PX = 2 export const TEST_OUTPUT_STATE_REPORT_INTERVAL_MS = 500 /** Longest debugger type name a saved Node fact keeps; the type is supplementary, so a longer one is omitted rather than failing the run. */ export const NODE_TEST_FACT_TYPE_LIMIT_CHARS = 256 +/** + * Names of the variables in which Vitest's module runner holds a whole imported module: a test file that calls `vi.mock` binds `__vi_import___`, any other module `__vite_ssr_import___` (Vite 8, Vitest 5). An imported name such as `revalidatePath` has no variable of its own; its value is an export of one of these (Cycle 0052 task 5). + */ +export const NODE_TEST_MODULE_BINDING_NAME = /^__vi(?:te_ssr)?_import_\d+__$/ +/** The debugger's name for the object a JavaScript Proxy wraps; a module that `vi.mock` replaces is a Proxy whose exports sit under it. */ +export const DEBUGGER_PROXY_TARGET_NAME = '[[Target]]' /** Oldest Vitest major whose file listing, exclusion, name matching and JSON report were verified for exact node-test selection (4.1.11 and 5.0.2). */ export const NODE_TEST_MINIMUM_VITEST_MAJOR = 4 /** Deepest cited title path: enclosing `describe` titles plus the test title. */ diff --git a/extension/src/ownedNodeTestRun.ts b/extension/src/ownedNodeTestRun.ts index a5bc62119..92eb17dd6 100644 --- a/extension/src/ownedNodeTestRun.ts +++ b/extension/src/ownedNodeTestRun.ts @@ -540,6 +540,7 @@ export class OwnedNodeTestRun { * * A pause Codebook did not make, such as the learner's own breakpoint, is never resumed here. It is reported once as `node-test.learner-paused`, * and the time the test spends held there does not count against the stop budget. Called by {@link prepareOwned} and {@link advance}. + * A required fact the debugger cannot read fails nothing: the stop is accepted with the facts that could be read and names the missing one among its debugger limitations, and the service refuses the continuation that reached it (Cycle 0052 task 5). * * @param index - Declared stop to capture. * @param fromIndex - First native pause that can belong to this stop. @@ -745,6 +746,11 @@ export class OwnedNodeTestRun { }, }) assertCurrent() + // A required fact the debugger could not read is named among the stop's limitations: the stop keeps its other facts and the run stays paused here (Cycle 0052 task 5). + for (const fact of result.unavailable) + debuggerSnapshot.limitations.push( + `Required fact ${fact} could not be read at this stop.`, + ) this.acceptedPause = target.stop const stop = (this.acceptedStop = { index, diff --git a/extension/src/resources.ts b/extension/src/resources.ts index 0d6082bcb..9044dd2b6 100644 --- a/extension/src/resources.ts +++ b/extension/src/resources.ts @@ -254,7 +254,7 @@ export async function readResource( "A stop's saved explanation is a learning-material step that references that stop; the result needs a step that references it too. nodeTest.learningCoverage reads none without material, partial while a stop or the result lacks a step, and complete once every one has one; nodeTest.learningMissing lists the missing parts as {kind: summary}, {kind: stop, stop: } and {kind: result}, and nextActions lists save-node-learning until coverage is complete. nodeTest.captureMissing lists the evidence no explanation can add, as {kind: stop-state, stop: } and {kind: execution}; the learner's Save reads partially saved while it is not empty, so claim saved learning only when it is empty too.", sequence: [ 'Read the returned manifest to verify the accepted configuration, complete article citation and test file/hash. Poll the run URI until a checked source stop appears or the run has an error. Read nodeTest.stops and the immutable node-stops/ snapshot, source and facts.', - 'Copy the current participants.server.continueArguments to continue. Every declared stop is required and captured in order. The final continuation runs the actual test and returns its exit code and bounded output.', + "Copy the current participants.server.continueArguments to continue. Every declared stop is required and captured in order. A required inspection is read through the debugger's variables, a name the test or source imports through the module Vitest binds it to; one that still cannot be read is named among that stop's debugger limitations, and the codebook_continue that reached the stop answers EVIDENCE_UNAVAILABLE naming it while the run stays paused there with its other facts: continue again to go on without it, or stop the run. The final continuation runs the actual test and returns its exit code and bounded output.", 'Stop releases the owned Node process and debugger and seals the history. A private workspace-bound history journal preserves ordered output, source/facts/debugger stops and the actual result; discover it at codebook://node-history, then read the returned history URI and follow its events pages. Use codebook_explain_node_history to save an agent-authored question, summary and sequence with exact stop/output/result sequence references; read learningMaterialUri first and use its revision as expectedRevision. The learner opens the sealed history as a test replay.', ], learnerPauses: diff --git a/extension/src/service.ts b/extension/src/service.ts index 90bd00190..09d5337d5 100644 --- a/extension/src/service.ts +++ b/extension/src/service.ts @@ -1556,6 +1556,7 @@ export class StudyService { record: StudyRecord, run: NodeTestRuntime, ): Promise { + let refusal: StudyError | undefined try { if (run.hasNextStop()) { const next = await run.advance() @@ -1563,25 +1564,8 @@ export class StudyService { record.nodeStops?.push(next) // A deadline that passed during the advance already marked the test failed: the late stop is journaled, the phase stays (F77). if (record.nodePhase === 'advancing') record.nodePhase = 'paused' - return - } - const result = await run.complete() - record.nodeResult = result - await record.nodeHistoryWriter?.appendResult(result, result.elapsedMs) - // The same for a result that arrives after the deadline's failure (F77). - if (record.nodePhase === 'advancing') record.nodePhase = 'completed' - // The exit already says the test did not pass; the run's error names why Vitest's report could not confirm the cited test. - if (result.reportFailure !== undefined) { - record.error = this.sanitizedError( - new StudyError( - 'RUNTIME_FAILED', - `${describeNodeTestResult(result)}, and Vitest's test report could not confirm the cited test: ${result.reportFailure}`, - ), - ) - queueMicrotask(() => { - void this.stopRecord(record).catch(() => undefined) - }) - } + refusal = this.unreadRequiredFactsRefusal(record, next) + } else await this.completeNodeTest(record, run) } catch (error) { // A stop cancelled this continuation: the run ends as stopped, without an error of its own. if (record.stopping || record.stopped) @@ -1599,6 +1583,74 @@ export class StudyService { }) throw record.error } + // The stop is kept and the run stays paused there with its other facts; only this continuation is refused, so the agent can go on without the fact (Cycle 0052 task 5, O4). + if (refusal) throw refusal + } + + /** + * Words the refusal of a Node test continuation that reached a stop whose required facts the debugger could not read, so the agent learns which fact is missing while the run stays paused at that stop (Cycle 0052 task 5, O4: MCP 1.8's error codes, no schema change); called by {@link StudyService.continueNodeTest} after the stop is journaled. + * @param record - The Node test run. + * @param stop - The stop the continuation reached. + * @returns An `EVIDENCE_UNAVAILABLE` {@link StudyError} naming each required `:` the stop lacks, or `undefined` when it has them all. + * @example this.unreadRequiredFactsRefusal(record, stop)?.message // => 'codebook_continue reached the stop at app/lib/actions.ts:131, but the debugger could not read its required fact value:revalidatePath.mock.calls.length. …' + */ + private unreadRequiredFactsRefusal( + record: StudyRecord, + stop: NodeTestSourceStop, + ): StudyError | undefined { + const configuration = record.nodePrepared?.configuration + if (!configuration) return undefined + const declared = [ + configuration.inspections, + ...(configuration.additionalStops ?? []).map( + (additional) => additional.inspections, + ), + ][stop.index] + const missing = (declared ?? []) + .filter( + (inspection) => + inspection.required && + !stop.facts.some( + (fact) => + fact.path === inspection.path && fact.mode === inspection.mode, + ), + ) + .map((inspection) => `${inspection.mode}:${inspection.path}`) + if (missing.length === 0) return undefined + const named = missing.join(', ') + return new StudyError( + 'EVIDENCE_UNAVAILABLE', + `codebook_continue reached the stop at ${stop.source.path}:${stop.frame.line}, but the debugger could not read its required ${missing.length === 1 ? 'fact' : 'facts'} ${named}. The run stays paused at this stop with its other facts; read nodeTest.stops, then continue without ${missing.length === 1 ? 'it' : 'them'} or stop the run.`, + ) + } + + /** + * Runs a Node test to its end after its last declared stop and journals the result; called by {@link StudyService.continueNodeTest} for the final continuation. + * @param record - The Node test run. + * @param run - Its owned runtime. + * @example await this.completeNodeTest(record, run) + */ + private async completeNodeTest( + record: StudyRecord, + run: NodeTestRuntime, + ): Promise { + const result = await run.complete() + record.nodeResult = result + await record.nodeHistoryWriter?.appendResult(result, result.elapsedMs) + // The same for a result that arrives after the deadline's failure (F77). + if (record.nodePhase === 'advancing') record.nodePhase = 'completed' + // The exit already says the test did not pass; the run's error names why Vitest's report could not confirm the cited test. + if (result.reportFailure !== undefined) { + record.error = this.sanitizedError( + new StudyError( + 'RUNTIME_FAILED', + `${describeNodeTestResult(result)}, and Vitest's test report could not confirm the cited test: ${result.reportFailure}`, + ), + ) + queueMicrotask(() => { + void this.stopRecord(record).catch(() => undefined) + }) + } } /** Stops a run through its shared cleanup barrier; historical resources remain available. @example await service.stop({instanceId,runId}) */ diff --git a/extension/src/tests/inspectNodeTestVariables.test.ts b/extension/src/tests/inspectNodeTestVariables.test.ts index 33db695e9..0fc31def4 100644 --- a/extension/src/tests/inspectNodeTestVariables.test.ts +++ b/extension/src/tests/inspectNodeTestVariables.test.ts @@ -116,21 +116,26 @@ test('a Node pause does not inspect global variables when the declared local fac ]) }) -test('a Node pause refuses a missing required value', async () => { +// Value: protects=a required fact the debugger cannot read is returned by name instead of ending the stop's capture, so the run can keep its other facts and stay paused there; fails_when=the inspector throws for it, which ended the whole live run with RUNTIME_FAILED (Cycle 0052 task 5); why_new=restates the earlier refusal test: the refusal moved to codebook_continue (O4); seam=a fake debug session whose scopes hold no variable; no_claim=the service's refusal (nodeTestService) +test('a Node pause returns a required value it cannot read by name instead of failing', async () => { // Arrange const session = { customRequest: async () => ({ variables: [] }), } as unknown as Pick const scopes = [{ name: 'Local', variablesReference: 1, expensive: false }] - // Act / Assert - await assert.rejects( - inspectNodeTestVariables( - session, - scopes, - [{ path: 'listener', mode: 'value', required: true }], - AbortSignal.timeout(1_000), - ), - /Required Node debugger fact is unavailable/, + + // Act + const result = await inspectNodeTestVariables( + session, + scopes, + [{ path: 'listener', mode: 'value', required: true }], + AbortSignal.timeout(1_000), + ) + + // Assert + assert.deepEqual( + { facts: result.facts, unavailable: result.unavailable }, + { facts: [], unavailable: ['value:listener'] }, ) }) @@ -165,3 +170,285 @@ test('a Node pause keeps a declared value whose debugger type name is too long t { path: 'totals', mode: 'value', display: 'Map(3)' }, ]) }) + +/** + * Builds a fake debug session that answers `variables` requests from a fixed table, as the native JavaScript debugger would. + * @param table - The children of each variables reference. + * @returns The session's `customRequest`. + * @example sessionAnswering(new Map([[1, [{ name: 'a', value: '1', variablesReference: 0 }]]])) + */ +function sessionAnswering( + table: Map, +): Pick { + return { + customRequest: async ( + _command: string, + arguments_: { variablesReference: number }, + ) => ({ variables: table.get(arguments_.variablesReference) ?? [] }), + } +} + +const VITEST_SCOPES = [ + { name: 'Local', variablesReference: 1, expensive: false }, + { name: 'Block', variablesReference: 2, expensive: false }, + { name: 'Global', variablesReference: 9, expensive: false }, +] + +// Value: protects=a required fact whose root is a name the test imports from a module that vi.mock replaced is read through the module's binding: the root has no variable of its own (Vitest binds the whole module as __vi_import___, a Proxy whose exports sit under [[Target]]), and the walk then reads mock → calls → length as for a local mock; fails_when=the inspector finds roots by lexical name only, so revalidatePath.mock.calls.length is unavailable and the live run ended (N51-V5, Cycle 0052 task 5); why_new=the root cause recorded in #124 (a live V8 probe of nextjs-dashboard's actions.test.ts under Vitest 5.0.2); seam=a fake debug session answering with the probed shapes; no_claim=the native debugger's own naming of [[Target]] (N52 live) +test('a Node pause reads a required value of a name the test imports from a mocked module', async () => { + // Arrange + const session = sessionAnswering( + new Map([ + [1, [{ name: 'set', value: 'ƒ (...args)', variablesReference: 0 }]], + [ + 2, + [ + { + name: '__vite_ssr_import_0__', + value: 'Module', + variablesReference: 3, + }, + { + name: '__vi_import_1__', + value: 'Proxy(Object)', + variablesReference: 4, + }, + ], + ], + [3, [{ name: 'expect', value: '(...)', variablesReference: 0 }]], + [ + 4, + [ + { name: '[[Handler]]', value: 'Object', variablesReference: 40 }, + { name: '[[Target]]', value: 'Object', variablesReference: 41 }, + { name: '[[IsRevoked]]', value: 'false', variablesReference: 0 }, + ], + ], + [ + 41, + [ + { + name: 'revalidatePath', + value: 'ƒ (...args)', + type: 'function', + variablesReference: 42, + }, + ], + ], + [ + 42, + [{ name: 'mock', value: '{calls: Array(1)}', variablesReference: 43 }], + ], + [43, [{ name: 'calls', value: 'Array(1)', variablesReference: 44 }]], + [ + 44, + [ + { + name: '0', + value: "['/dashboard/invoices']", + variablesReference: 45, + }, + { name: 'length', value: '1', type: 'number', variablesReference: 0 }, + ], + ], + ]), + ) + + // Act + const result = await inspectNodeTestVariables( + session, + VITEST_SCOPES, + [ + { + path: 'revalidatePath.mock.calls.length', + mode: 'value', + required: true, + }, + ], + AbortSignal.timeout(1_000), + ) + + // Assert + assert.deepEqual( + { facts: result.facts, unavailable: result.unavailable }, + { + facts: [ + { + path: 'revalidatePath.mock.calls.length', + mode: 'value', + display: '1', + type: 'number', + }, + ], + unavailable: [], + }, + ) +}) + +// Value: protects=a required fact whose root a module exports as a live binding is read: an unmocked module is a namespace whose exports are getters, which the debugger shows as lazy values, and the inspector expands the lazy value once, as the Variables view does, instead of saving its "(...)" placeholder; fails_when=the imported root is not found, or the placeholder "(...)" is saved as the value (Cycle 0052 task 5); why_new=the probe showed unmocked imports as Module namespaces with accessor exports; seam=a fake debug session answering a lazy variable with its one resolved child; no_claim=which getters the native debugger marks lazy (N52 live) +test('a Node pause reads a required value of a name the test imports from an unmocked module', async () => { + // Arrange + const session = sessionAnswering( + new Map([ + [1, []], + [ + 2, + [ + { + name: '__vite_ssr_import_3__', + value: 'Module', + variablesReference: 5, + }, + ], + ], + [ + 5, + [ + { + name: 'ITEMS_PER_PAGE', + value: '(...)', + variablesReference: 50, + presentationHint: { lazy: true }, + }, + ], + ], + [ + 50, + [ + { + name: 'ITEMS_PER_PAGE', + value: '6', + type: 'number', + variablesReference: 0, + }, + ], + ], + ]), + ) + + // Act + const result = await inspectNodeTestVariables( + session, + VITEST_SCOPES, + [{ path: 'ITEMS_PER_PAGE', mode: 'value', required: true }], + AbortSignal.timeout(1_000), + ) + + // Assert + assert.deepEqual(result.facts, [ + { path: 'ITEMS_PER_PAGE', mode: 'value', display: '6', type: 'number' }, + ]) +}) + +// Value: protects=a name that two imported modules both export is not guessed: the fact stays unavailable rather than showing the value of the module the source may not mean; fails_when=the first binding's export is taken, so a fact can show another module's value (Cycle 0052 task 5); why_new=the module-binding lookup has no source map to tell which import a name came from; seam=a fake debug session with two bindings exporting the same name; no_claim=how often two imports share a name +test('a Node pause leaves a name that two imported modules export unavailable', async () => { + // Arrange + const session = sessionAnswering( + new Map([ + [1, []], + [ + 2, + [ + { + name: '__vite_ssr_import_1__', + value: 'Object', + variablesReference: 6, + }, + { + name: '__vite_ssr_import_2__', + value: 'Object', + variablesReference: 7, + }, + ], + ], + [ + 6, + [ + { + name: 'formatCurrency', + value: 'ƒ formatCurrency(amount)', + variablesReference: 0, + }, + ], + ], + [ + 7, + [ + { + name: 'formatCurrency', + value: 'ƒ formatCurrency(cents)', + variablesReference: 0, + }, + ], + ], + ]), + ) + + // Act + const result = await inspectNodeTestVariables( + session, + VITEST_SCOPES, + [{ path: 'formatCurrency', mode: 'value', required: true }], + AbortSignal.timeout(1_000), + ) + + // Assert + assert.deepEqual( + { facts: result.facts, unavailable: result.unavailable }, + { facts: [], unavailable: ['value:formatCurrency'] }, + ) +}) + +// Value: protects=a local variable keeps priority over an import of the same name, as JavaScript scoping does: the fact reads the local value; fails_when=the module binding's export replaces a local variable that shadows it (Cycle 0052 task 5); why_new=the import lookup must only run when no scope variable has the name; seam=a fake debug session with a local and an import of one name; no_claim=closure scopes of other frames +test('a Node pause reads a local variable before an imported name it shadows', async () => { + // Arrange + const session = sessionAnswering( + new Map([ + [ + 1, + [ + { + name: 'status', + value: "'paid'", + type: 'string', + variablesReference: 0, + }, + ], + ], + [ + 2, + [ + { + name: '__vite_ssr_import_1__', + value: 'Object', + variablesReference: 6, + }, + ], + ], + [ + 6, + [ + { + name: 'status', + value: "'imported'", + type: 'string', + variablesReference: 0, + }, + ], + ], + ]), + ) + + // Act + const result = await inspectNodeTestVariables( + session, + VITEST_SCOPES, + [{ path: 'status', mode: 'value', required: true }], + AbortSignal.timeout(1_000), + ) + + // Assert + assert.deepEqual(result.facts, [ + { path: 'status', mode: 'value', display: "'paid'", type: 'string' }, + ]) +}) diff --git a/extension/src/tests/nodeTestRunLifecycle.test.ts b/extension/src/tests/nodeTestRunLifecycle.test.ts index 3bb34d37b..d1607eaeb 100644 --- a/extension/src/tests/nodeTestRunLifecycle.test.ts +++ b/extension/src/tests/nodeTestRunLifecycle.test.ts @@ -82,6 +82,10 @@ async function testRunHarness(steps: FakeRunSteps = {}) { service.environment.build.sourceHash = 'f'.repeat(64) const base = harness.configuration const declaredStops = steps.stops ?? 1 + // Each stop declares the required fact its fake stop carries, as the owned run's inspector would have read it (Cycle 0052 task 5: a stop without its required fact refuses the continuation that reached it). + const inspections: NodeTestConfiguration['inspections'] = [ + { path: 'delayMs', mode: 'value', required: true }, + ] const configuration: NodeTestConfiguration = { version: 1, adapter: 'vitest-node', @@ -90,14 +94,14 @@ async function testRunHarness(steps: FakeRunSteps = {}) { testTitles: ['job scheduler', 'runs a delayed job after its timer'], testHash: 'a'.repeat(64), source: base.source, - inspections: base.inspections.slice(0, 1), + inspections, ...(declaredStops === 2 ? { additionalStops: [ { ...base.source, anchorIndex: 0, - inspections: base.inspections.slice(0, 1), + inspections, }, ], } diff --git a/extension/src/tests/nodeTestService.test.ts b/extension/src/tests/nodeTestService.test.ts index 478623dd9..da25fc215 100644 --- a/extension/src/tests/nodeTestService.test.ts +++ b/extension/src/tests/nodeTestService.test.ts @@ -890,3 +890,157 @@ test('a failed advance to the next Node stop names that failure in the continuat await harness.close() } }) + +// Value: protects=a live Node test run survives a required fact the debugger cannot read: the continuation that reached the stop is refused with EVIDENCE_UNAVAILABLE naming the fact, the stop is journaled with its other facts, the run stays paused there without an error, and the next codebook_continue goes on to the result; fails_when=the run ends with RUNTIME_FAILED at that stop (N51-V5: value:revalidatePath.mock.calls.length), the stop is lost, or the run cannot be continued after the refusal (Cycle 0052 task 5, O4); why_new=the inspector used to throw, which the continuation's catch turned into a failed run; seam=environment.createNodeTestRun, whose advance returns a stop that lacks its required fact; no_claim=the native debugger's reading of the fact (inspectNodeTestVariables, N52 live) +test('a Node test continuation that reaches a stop without its required fact is refused while the run stays paused there and continues on', async () => { + // Arrange + const harness = await createHarness(), + { service } = harness + service.nodeHistoryStore = new NodeTestHistoryStore( + join(harness.directory, '.learning'), + '0'.repeat(64), + ) + service.environment.build.sourceHash = 'f'.repeat(64) + const base = harness.configuration + const configuration: NodeTestConfiguration = { + version: 1, + adapter: 'vitest-node', + book: base.book, + testFile: 'src/theme.test.ts', + testTitles: ['theme', 'selects the dark theme'], + testHash: 'a'.repeat(64), + source: base.source, + inspections: base.inspections.slice(0, 1), + additionalStops: [ + { + ...base.source, + anchorIndex: 0, + inspections: [ + { + path: 'revalidatePath.mock.calls.length', + mode: 'value', + required: true, + }, + ], + }, + ], + } + const stopAt = (index: number, facts: unknown[], limitations: string[]) => + ({ + index, + runId: 'node-run', + sessionId: 'owned-session', + threadId: 7, + elapsedMs: 10 + index, + worker: {}, + source: configuration.source, + frame: { id: 10 + index, name: 'notifyManager', line: 1, column: 1 }, + variables: [{ name: 'listener', value: '() => undefined' }], + facts, + debuggerSnapshot: { + version: 1, + frames: [ + { + name: 'notifyManager', + line: 1, + column: 1, + scopes: [{ name: 'Local', variables: [0] }], + }, + ], + variables: [ + { name: 'listener', value: '() => undefined', children: [] }, + ], + limitations, + }, + }) as unknown as NodeTestSourceStop + let advanced = false + const runtime: NodeTestRuntime = { + id: 'node-run', + async prepare() { + return stopAt( + 0, + [{ path: 'nextTheme', mode: 'value', display: 'dark', type: 'string' }], + [], + ) + }, + hasNextStop() { + return !advanced + }, + async advance() { + advanced = true + return stopAt( + 1, + [], + [ + 'Required fact value:revalidatePath.mock.calls.length could not be read at this stop.', + ], + ) + }, + async complete() { + return { + exitCode: 0, + signal: null, + output: 'Tests 1 passed', + elapsedMs: 40, + } + }, + async stop() {}, + } + service.environment.createNodeTestRun = async () => runtime + const receipt = service.start({ + instanceId: service.instanceId, + profile: NODE_TEST_PROFILE, + key: 'node-unread-required-fact', + configuration, + }) + const record = service.record(receipt) + try { + await record.preparation + const atFirstStop = service.state(record) + + // Act: the continuation reaches the second stop, whose required fact the debugger could not read. + const rejection = await service + .continue(atFirstStop.participants.server!.continueArguments!) + .then( + () => undefined, + (error: unknown) => error, + ) + const atSecondStop = service.state(record) + const afterRefusal = { + stopped: record.stopped, + error: record.error, + phase: record.nodePhase, + stops: record.nodeStops?.length, + } + const goingOn = await service.continue( + atSecondStop.participants.server!.continueArguments!, + ) + + // Assert + assert.ok(rejection instanceof StudyError) + assert.deepEqual( + { + code: rejection.code, + message: rejection.message, + afterRefusal, + goingOn: goingOn.accepted, + result: record.nodeResult?.exitCode, + }, + { + code: 'EVIDENCE_UNAVAILABLE', + message: + 'codebook_continue reached the stop at src/theme.ts:1, but the debugger could not read its required fact value:revalidatePath.mock.calls.length. The run stays paused at this stop with its other facts; read nodeTest.stops, then continue without it or stop the run.', + afterRefusal: { + stopped: false, + error: undefined, + phase: 'paused', + stops: 2, + }, + goingOn: true, + result: 0, + }, + ) + } finally { + await harness.close() + } +}) diff --git a/extension/src/tests/ownedNodeTestRun.test.ts b/extension/src/tests/ownedNodeTestRun.test.ts index fdb1c3b18..d16e232f7 100644 --- a/extension/src/tests/ownedNodeTestRun.test.ts +++ b/extension/src/tests/ownedNodeTestRun.test.ts @@ -80,9 +80,12 @@ async function nodeRunHarness({ stopFailures = 0, outputRefusesOnceClosed = false, unansweredRequest, + unavailableFacts = [], }: { declaredStops?: number observedLine?: number + /** The required facts, as `:`, that the inspector reports it could not read at each stop. */ + unavailableFacts?: string[] duringRetire?: () => void adapterPauseAfterContinue?: boolean exitAfterEntry?: boolean @@ -683,7 +686,11 @@ async function nodeRunHarness({ }), }, './utils/inspectNodeTestVariables': { - inspectNodeTestVariables: async () => ({ variables: [], facts: [] }), + inspectNodeTestVariables: async () => ({ + variables: [], + facts: [], + unavailable: unavailableFacts, + }), }, './utils/captureReplayDebugSnapshot': { captureReplayDebugSnapshot: async () => ({ @@ -864,6 +871,41 @@ test('a run whose first declared stop came without an entry pause advances to it } }) +// Value: protects=a declared stop whose required fact the debugger cannot read is still accepted: the run keeps that stop, names the fact among its debugger limitations and stays able to finish, instead of releasing the owned test; fails_when=the stop is captured without naming the missing fact, or a missing fact releases the run (N51-V5, Cycle 0052 task 5); why_new=the inspector used to throw, and advance releases the run on any capture failure; seam=the harness's inspector reporting one unreadable required fact; no_claim=the inspector's own reading (inspectNodeTestVariables tests) and the service's refusal (nodeTestService) +test('a declared stop whose required fact the debugger cannot read is accepted with the fact named among its limitations, and the test still finishes', async () => { + // Arrange + const harness = await nodeRunHarness({ + declaredStops: 2, + unavailableFacts: ['value:revalidatePath.mock.calls.length'], + }) + try { + await harness.run.prepare() + + // Act + const next = await harness.run.advance() + const result = await harness.run.complete() + + // Assert + assert.deepEqual( + { + index: next.index, + limitations: next.debuggerSnapshot.limitations, + exitCode: result.exitCode, + }, + { + index: 1, + limitations: [ + 'Required fact value:revalidatePath.mock.calls.length could not be read at this stop.', + ], + exitCode: 0, + }, + ) + } finally { + await harness.run.stop() + await harness.cleanup() + } +}) + test('advancing to the next declared stop retires the captured breakpoint before arming and continuing to the next one', async () => { // Arrange const harness = await nodeRunHarness({ declaredStops: 2 }) diff --git a/extension/src/tests/utils/startLiveTestRun.ts b/extension/src/tests/utils/startLiveTestRun.ts index 8ef71c257..86c70c721 100644 --- a/extension/src/tests/utils/startLiveTestRun.ts +++ b/extension/src/tests/utils/startLiveTestRun.ts @@ -40,12 +40,12 @@ export async function startLiveTestRun( testHash: 'a'.repeat(64), testTitles: ['job scheduler', 'runs a delayed job after its timer'], source: configuration.source, - inspections: configuration.inspections.slice(0, 1), + inspections: [{ path: 'delayMs', mode: 'value', required: true }], additionalStops: [ { ...configuration.source, anchorIndex: 0, - inspections: configuration.inspections.slice(0, 1), + inspections: [{ path: 'delayMs', mode: 'value', required: true }], }, ], }, diff --git a/extension/src/utils/inspectNodeTestVariables.ts b/extension/src/utils/inspectNodeTestVariables.ts index 1d4e5a24b..af927e6a4 100644 --- a/extension/src/utils/inspectNodeTestVariables.ts +++ b/extension/src/utils/inspectNodeTestVariables.ts @@ -2,7 +2,9 @@ import type { DebugProtocol } from '@vscode/debugprotocol' import type * as vscode from 'vscode' import { + DEBUGGER_PROXY_TARGET_NAME, NODE_TEST_FACT_TYPE_LIMIT_CHARS, + NODE_TEST_MODULE_BINDING_NAME, REPOSITORY_LITERAL_LIMIT_CHARS, } from '../constants' import type { RepositoryInspection } from '../repositoryConfigSchemas' @@ -15,48 +17,43 @@ export type NodeTestFact = { type?: string } -/** Reads only declared debugger paths through native DAP scopes; never evaluates an expression or retains other local values. Called at an owned source stop. @param session - Verified native debug session. @param scopes - Scopes of its checked source frame. @param inspections - Validated requested facts. @param signal - Run cancellation. @example await inspectNodeTestVariables(session, scopes, [{path:'listener',mode:'value',required:true}], signal) */ +/** + * Reads only the declared debugger paths of a Node test stop through native DAP scopes, never evaluating an expression or retaining other local values; called by {@link OwnedNodeTestRun} at an owned source stop. + * A path's root is the scope variable of that name, or, when none has it, the name a module exports to the stopped module: Vitest binds each imported module whole ({@link NODE_TEST_MODULE_BINDING_NAME}), a mocked one as a Proxy whose exports sit under {@link DEBUGGER_PROXY_TARGET_NAME}, an unmocked one as a namespace of getters that the debugger shows lazily. A lazy value on the path is expanded once, as the Variables view does (Cycle 0052 task 5). + * @param session - Verified native debug session. + * @param scopes - Scopes of its checked source frame. + * @param inspections - Validated requested facts. + * @param signal - Run cancellation. + * @returns The facts read; the variables they came from; and, as `:`, each required fact the debugger could not read (absent, ambiguous between two imported modules, empty or longer than {@link REPOSITORY_LITERAL_LIMIT_CHARS}), which the caller reports while the stop stays accepted. An unreadable optional fact is left out, as before. + * @example + * await inspectNodeTestVariables(session, scopes, [{ path: 'revalidatePath.mock.calls.length', mode: 'value', required: true }], signal) + * // => { facts: [{ path: 'revalidatePath.mock.calls.length', mode: 'value', display: '1', type: 'number' }], variables: [...], unavailable: [] } + */ export async function inspectNodeTestVariables( session: Pick, scopes: DebugProtocol.Scope[], inspections: RepositoryInspection[], signal: AbortSignal, -): Promise<{ facts: NodeTestFact[]; variables: DebugProtocol.Variable[] }> { - const cache = new Map() - const readVariables = async ( - reference: number, - ): Promise => { - if (!Number.isSafeInteger(reference) || reference <= 0) return [] - const cached = cache.get(reference) - if (cached) return cached - const response = (await awaitResponse( - session.customRequest('variables', { variablesReference: reference }), - 'owned Node debugger variables', - signal, - )) as DebugProtocol.VariablesResponse['body'] - if (!Array.isArray(response?.variables)) - throw new Error('Native debugger supplied no variable list.') - cache.set(reference, response.variables) - return response.variables - } +): Promise<{ + facts: NodeTestFact[] + variables: DebugProtocol.Variable[] + unavailable: string[] +}> { + const read = debuggerVariableReader(session, signal) + // Only lexical runtime scopes can supply an operation fact; global state is unrelated to this stop. + const lexicalScopes = scopes.filter( + (scope) => !scope.name.toLowerCase().includes('global'), + ) const facts: NodeTestFact[] = [], - selected: DebugProtocol.Variable[] = [] + selected: DebugProtocol.Variable[] = [], + unavailable: string[] = [] for (const inspection of inspections) { signal.throwIfAborted() - const [root, ...segments] = inspection.path.split('.') - let variable: DebugProtocol.Variable | undefined - for (const scope of scopes) { - // Only lexical runtime scopes can supply an operation fact; global state is unrelated to this stop. - if (scope.name.toLowerCase().includes('global')) continue - variable = (await readVariables(scope.variablesReference)).find( - (candidate) => candidate.name === root, - ) - if (variable) break - } - for (const segment of segments) - variable = (await readVariables(variable?.variablesReference ?? 0)).find( - (candidate) => candidate.name === segment, - ) + const variable = await readDeclaredPath( + read, + lexicalScopes, + inspection.path, + ) const display = inspection.mode === 'type' ? variable?.type : variable?.value if ( @@ -64,10 +61,9 @@ export async function inspectNodeTestVariables( !display || display.length > REPOSITORY_LITERAL_LIMIT_CHARS ) { + // A required fact is reported by name; the stop keeps the facts that could be read (Cycle 0052 task 5). if (inspection.required) - throw new Error( - `Required Node debugger fact is unavailable or too large: ${inspection.mode}:${inspection.path}`, - ) + unavailable.push(`${inspection.mode}:${inspection.path}`) continue } // An oversized debugger type name is supplementary; omit it instead of making the saved stop invalid. @@ -84,5 +80,157 @@ export async function inspectNodeTestVariables( }) selected.push(variable) } - return { facts, variables: selected } + return { facts, variables: selected, unavailable } +} + +/** Reads the children of one debugger variable reference, once per reference. */ +type DebuggerVariableReader = ( + reference: number, +) => Promise + +/** + * Makes the cached reader of debugger variable children that one inspection pass shares; called by {@link inspectNodeTestVariables}. + * @param session - The native debug session. + * @param signal - Run cancellation. + * @returns A reader answering an invalid or empty reference with no children. + * @example const read = debuggerVariableReader(session, signal); await read(7) // => [{ name: 'count', value: '3', ... }] + */ +function debuggerVariableReader( + session: Pick, + signal: AbortSignal, +): DebuggerVariableReader { + const cache = new Map() + return async (reference) => { + if (!Number.isSafeInteger(reference) || reference <= 0) return [] + const cached = cache.get(reference) + if (cached) return cached + const response = (await awaitResponse( + session.customRequest('variables', { variablesReference: reference }), + 'owned Node debugger variables', + signal, + )) as DebugProtocol.VariablesResponse['body'] + if (!Array.isArray(response?.variables)) + throw new Error('Native debugger supplied no variable list.') + cache.set(reference, response.variables) + return response.variables + } +} + +/** + * Walks one declared path, `root.segment.segment`, to its variable; called by {@link inspectNodeTestVariables}. + * @param read - The pass's variable reader. + * @param scopes - The stop's lexical scopes. + * @param path - The declared path. + * @returns The variable at the end of the path, or `undefined` when a step is missing. + * @example await readDeclaredPath(read, scopes, 'revalidatePath.mock.calls.length') // => { name: 'length', value: '1', ... } + */ +async function readDeclaredPath( + read: DebuggerVariableReader, + scopes: DebugProtocol.Scope[], + path: string, +): Promise { + const [root = '', ...segments] = path.split('.') + let variable = + (await scopeVariable(read, scopes, root)) ?? + (await importedName(read, scopes, root)) + for (const segment of segments) + variable = + variable && + (await settledLazy( + read, + (await read(variable.variablesReference)).find( + (candidate) => candidate.name === segment, + ), + )) + return variable +} + +/** + * Finds the scope variable of a path's root, the first scope that has it winning, as JavaScript scoping does; called by {@link readDeclaredPath}. + * @param read - The pass's variable reader. + * @param scopes - The stop's lexical scopes, innermost first. + * @param name - The root's name. + * @returns The variable, or `undefined` when no scope has it. + * @example await scopeVariable(read, scopes, 'set') // => { name: 'set', value: 'ƒ (...args)', ... } + */ +async function scopeVariable( + read: DebuggerVariableReader, + scopes: DebugProtocol.Scope[], + name: string, +): Promise { + for (const scope of scopes) { + const variable = (await read(scope.variablesReference)).find( + (candidate) => candidate.name === name, + ) + if (variable) return settledLazy(read, variable) + } + return undefined +} + +/** + * Finds a name the stopped module imports: the export of that name in one of Vitest's module bindings ({@link NODE_TEST_MODULE_BINDING_NAME}) in the stop's scopes, read through a Proxy's {@link DEBUGGER_PROXY_TARGET_NAME} for a mocked module; called by {@link readDeclaredPath} when no scope variable has the name. + * A name that two bindings export is left unread: without the source map's binding there is no telling which import the source means. + * @param read - The pass's variable reader. + * @param scopes - The stop's lexical scopes. + * @param name - The root's name. + * @returns The export's variable, or `undefined` when no binding, or more than one, exports it. + * @example await importedName(read, scopes, 'revalidatePath') // => { name: 'revalidatePath', value: 'ƒ (...args)', ... } + */ +async function importedName( + read: DebuggerVariableReader, + scopes: DebugProtocol.Scope[], + name: string, +): Promise { + const exports: DebugProtocol.Variable[] = [] + for (const scope of scopes) + for (const binding of await read(scope.variablesReference)) { + if (!NODE_TEST_MODULE_BINDING_NAME.test(binding.name)) continue + const exported = await moduleExport(read, binding, name) + if (exported) exports.push(exported) + } + // Only one module may answer for the name; two would make the fact a guess. + return exports.length === 1 ? settledLazy(read, exports[0]) : undefined +} + +/** + * Reads one export of a module binding: a child of that name, or, for a mocked module's Proxy, the child of that name under its target; called by {@link importedName}. + * @param read - The pass's variable reader. + * @param binding - The module binding. + * @param name - The export's name. + * @returns The export's variable, or `undefined`. + * @example await moduleExport(read, { name: '__vi_import_1__', value: 'Proxy(Object)', variablesReference: 4 }, 'revalidatePath') + */ +async function moduleExport( + read: DebuggerVariableReader, + binding: DebugProtocol.Variable, + name: string, +): Promise { + const children = await read(binding.variablesReference) + const direct = children.find((child) => child.name === name) + if (direct) return direct + const target = children.find( + (child) => child.name === DEBUGGER_PROXY_TARGET_NAME, + ) + return target + ? (await read(target.variablesReference)).find( + (child) => child.name === name, + ) + : undefined +} + +/** + * Expands a lazy debugger value (a getter the debugger shows as `(...)`) to the one value it stands for, as the Variables view does when the learner opens it; any other variable is returned as it is; called for every step of a declared path. + * @param read - The pass's variable reader. + * @param variable - The step's variable, if any. + * @returns The resolved variable, or the given one. + * @example await settledLazy(read, { name: 'ITEMS_PER_PAGE', value: '(...)', variablesReference: 50, presentationHint: { lazy: true } }) // => { name: 'ITEMS_PER_PAGE', value: '6', ... } + */ +async function settledLazy( + read: DebuggerVariableReader, + variable: DebugProtocol.Variable | undefined, +): Promise { + if (!variable?.presentationHint?.lazy) return variable + const [value, ...others] = await read(variable.variablesReference) + // The protocol answers a lazy value with exactly one variable; anything else is left as it is. + return value && others.length === 0 ? value : variable } From 761500ddcd2cf32e030676ed9f588bf4e1255595 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 20:57:20 +0900 Subject: [PATCH 02/30] fix: a refused browser run seals with its refusal and the refusal says where a stop can go (Cycle 0052 task 7) 7a: stopRecord sealed every recording that ended with a run.failed event, or an incomplete release, as 'Runtime interrupted or cleanup incomplete; recorded prefix retained.', so N51's three runs refused during preparation (0 frames, the agent stopped them) read as interrupted. The seal now names the run's own failure ('The run failed: . Recorded prefix retained.', the run error's bare cause) and gives an incomplete release its own sentence. 7b: evidence of the profile boundary: a browser stop is armed before input in the client chunks of the launched route (readDevelopmentBundle reads only entryPath's client manifest), so a stop in a component of a page reached by a client-side navigation cannot be armed. The refusal now names the stop's file and the launched page and says to start at the stop's page (entryPath) or choose a stop in the launched page's own components. Tests red on cadafe96: the refused-run seal, the adapter's refusal sentence; restated: the ENOSPC seal expectation (now its own cause). README says so. Refs #124 --- extension/README.md | 2 +- extension/src/service.ts | 33 ++++++- extension/src/tests/configuredAdapter.test.ts | 6 +- .../tests/configuredRuntimeRecording.test.ts | 88 ++++++++++++++++++- extension/src/utils/readDevelopmentBundle.ts | 4 +- 5 files changed, 127 insertions(+), 6 deletions(-) diff --git a/extension/README.md b/extension/README.md index 30e33a7f7..9557560b9 100644 --- a/extension/README.md +++ b/extension/README.md @@ -344,7 +344,7 @@ The current adapter is `next-dev-webpack`: a trusted local Git root with depende 1. Follow **Learn this repository** to read actual source and publish an immutable article. Leave `learningItemId` absent so the article uses `repository//`; a foreign learning alias cannot receive configured runtime evidence. Keep every citation's path, full hash and inclusive line range. 2. Read the configured help resource (through `codebook_read_resource` if direct resource access is unavailable) and the discovered `codebook_start` input schema. Build `configuration` with `version: 1`, `adapter: "next-dev-webpack"`, the exact `book` identity (`bookId`, `revision`, `nodeId`, `anchorIndex`), and `source` (`path`, `hash`, one-based `line` and `column`). The selected source position must lie within that selected article citation. Preparation validates **all** article citations before starting the application. Select a one-based line/column for an executable statement reached by the requested action, such as the handler body or a state-driven render after a library callback. JSX attributes, declarations and proximity to the UI are not enough for a debugger binding. The line is the declaration and the column is a preference: Codebook accepts the debugger's verified binding anywhere on that line and reports the bound position as `source` beside `requestedSource` in the observation's inspection resource. Declare the column where the statement begins. When the debugger binds a stop on a later line although the line the breakpoint was requested on has a break location, Codebook requests the breakpoint once more, at that line's break location before the requested column; the stop fails while it is armed only when that second request is bound on a later line again or the line has no break location before the requested column. A binding that is first known at a pause (a server stop whose registration was still pending when it was armed) is not refused for this: the stop is accepted where it paused, and its inspection's `place` names the column that can pause on the requested line. A declared line without a mapped position (a blank line, or a comment the generated module kept) is requested at the first mapped position after it within the stop's citation, because the debugger verifies no breakpoint on such a line, and is accepted there under the next rule; preparation fails before input only when no later line of the stop's citation can carry a position either. The sealed map decides that; when it is too large to scan completely, the source text does: a line that is blank or only a comment carries none. A blank line has no column to choose: any column declared on it is taken as the line's start, and the run's journal records `breakpoint.column-on-blank-line` with the field, the declared and the taken position. The binding rule is counted from the line the breakpoint is requested on: the declared line, or the first mapped line after a declared line without a mapped position. When the debugger cannot pause on the requested line it binds the first place after it. Codebook accepts that binding when it lies on a later line within the stop's citation and no line between the requested line and the bound line has a mapped position (a comment the generated module kept is not one). When a mapped line lies between them, Codebook asks the debugger for the module's break locations and counts only a line that has one between the place the breakpoint was requested at and the place it was bound at in the generated module: a mapped line the debugger cannot pause on, such as a function head or its parameters, is passed over, and so is a line whose break location the build placed elsewhere in the generated module, such as a call it appends at the module's end and maps to a function's name. This also holds for a binding first known at a pause, where the place the program is paused at is the bound place; when either generated place cannot be determined, every line with a break location counts. The run's journal records `breakpoint.rule-from-break-locations` with the lines that counted and, when both are known, the two generated places (and `breakpoint.requested-again-after-break-locations` when a server registration is requested again for it). When the debugger cannot list break locations, the mapped lines decide. Codebook uses an accepted binding like a moved column: `source` names the bound line. Let the citation cover every line of the statement the stop is in, so that a moved binding stays inside it. A moved binding can lie in a branch the action skips; the stop then fails at the 20-second deadline, whose text names the bound place once the adapter has reported it (a server stop still registered as pending gets it from the adapter's breakpoint event when its module loads). Any other verified binding on another line fails at once; for a server stop that was still registered as pending, whose binding is checked when it pauses, that failure comes about 3 seconds after the pause. Declare the bound place instead, in a citation that covers it. A browser stop whose binding never verifies times out after 20 seconds while it is being armed; a server stop may stay registered as pending and fails only when no verified pause arrives within 20 seconds of the input or of the preceding continuation. Binding failures name the declared stop and list bounded source-map positions in that stop's citation when available. These are mapping hints, not proof that a debugger can bind or that the action reaches them; inspect the cited source and choose a relevant executable statement before retrying. -3. Optionally set `entryPath` to the actual same-origin page path, including its query string. Preparation compiles that page and resolves its generated Next route manifest; redirects fail explicitly. A non-root `entryPath` must answer 200 without a redirect and replaces the root-page readiness check, so an application without a root page runs through its selected entry. `initialInputs` may contain up to 16 distinct `{selector,text}` pairs. Codebook focuses a unique visible editable text control, inserts text through the browser input protocol, and verifies the result before arming the learning breakpoint. A changed/ambiguous/obscured control or uncertain insert fails without retrying it. Configuration and captured values belong to private learning storage; sanitized reports must omit input content. +3. Optionally set `entryPath` to the actual same-origin page path, including its query string. Preparation compiles that page and resolves its generated Next route manifest; redirects fail explicitly. A non-root `entryPath` must answer 200 without a redirect and replaces the root-page readiness check, so an application without a root page runs through its selected entry. A browser stop is armed before input in the client code that the launched page loads, so a stop in a component of another page (one that a link from the launched page navigates to) is refused before input with a sentence that names the stop's file and the launched page and says to start the run at the stop's page (`entryPath`) or to choose a stop in the launched page's own components; a run refused during its preparation, or failed later, seals its recording with that failure's own words (`The run failed: . Recorded prefix retained.`), and a run whose release did not complete with `Runtime cleanup incomplete; recorded prefix retained.` `initialInputs` may contain up to 16 distinct `{selector,text}` pairs. Codebook focuses a unique visible editable text control, inserts text through the browser input protocol, and verifies the result before arming the learning breakpoint. A changed/ambiguous/obscured control or uncertain insert fails without retrying it. Configuration and captured values belong to private learning storage; sanitized reports must omit input content. 4. Declare `action.title`, one unique CSS `selector`, and nonempty `before`/`after` condition arrays. Conditions are `{kind:"visible",selector}`, `{kind:"text",selector,equals}`, or `{kind:"attribute",selector,attribute,equals}`; comparisons use literals, never patterns, and all conditions must hold. A text condition compares the element's whole `textContent`, trimmed and with whitespace runs collapsed on both sides, so `Log in` matches a button whose markup is `Log in `; the texts of child elements are joined without a space (`Total5` reads `Total5`), and CSS `white-space`, zero-width spaces and soft hyphens are not applied; when the element holds the expected text beside other text, the timeout says so without exposing page text. A text condition whose selector matches several elements holds when one of them reads the expected text, and when none does its timeout says how many elements matched; a `visible` or `attribute` condition and the click target need a selector that matches exactly one element. A condition timeout names its phase: correct a before condition against the fresh page before input, an after condition against the page the action leaves. Codebook also reads the after conditions just before the click; when they already hold there, the run ends with "Configured after conditions were already true before application input" and sends no input, so declare an after condition only the action makes true. The click target must be visible, enabled and not obscured. DOM expectations describe completion; they are not expected debugger values. Before conditions must hold in a fresh browser: do not assume the operating system's appearance or previous local storage. Use `initialStorage` only when the source establishes a key/value that makes the starting state deterministic. A `visible` condition also needs nonzero element geometry. For class membership, use a CSS class selector when the element may have other class tokens; an attribute condition compares the whole class string exactly. 5. Declare one to ten `inspections` with `path`, `mode` (`value` or `type`) and `required`. At least one required `value` is needed. Paths contain up to four simple identifier segments; calls, computed property expressions, reserved roots and prototype access are rejected. For example, `{path:"nextTheme",mode:"value",required:true}` requests the actual primitive value of an in-scope variable; it does not assume that value is `"dark"`. Required unavailable/unsafe/oversized values fail. A required value is read when the debugger pauses, before the stop's line runs, so a binding that the stop's own line declares has no value there; Codebook then requests the stop on the first line within the stop's citation that can pause after the statement that line begins, and names that line in the inspection's `armed` and `place`, so require a value that already exists before the stop's line when you want the state before that line runs; when no later line of the citation can pause, the statement does not end within the citation, Codebook does not recognize the declaration (it reads only the first const or let with an initializer on the stop's line, not a pattern over several lines or a second declarator), or the sealed map is too large to scan completely and the debugger lists no place after the statement where it can pause, the stop stays on its line and its failure names the line to follow; a server stop in such a map whose module the server has not loaded when the stop is armed cannot be asked for that place, so it is refused when it is armed, before its breakpoint is set, and its failure names the first line after the statement; when you know the statement spans several lines, declare the stop on a statement after it. A value that is an object or a function is captured as the marker `{type:"object"}` or `{type:"function"}` and the run goes on; its members are not captured, so declare a primitive member (such as `credentials.email`) to capture one. Optional unavailability remains explicit. Optional `initialStorage` contains bounded key/value pairs for this run's owned origin only. With version 1, optional `additionalStops` appends up to seven `{line,column,inspections}` entries within the same selected citation. For cross-file or server stops use `version: 2`, add the first stop's `participant` (`browser` or `server`), and declare each additional stop as `{participant,source:{path,hash,line,column},anchorIndex,inspections}`. Each source must match its retained article citation. Every stop requires a value inspection and must be reached in order by this single action. Codebook arms only the next expected stop, the first before input and each later one inside the continuation that precedes it, so a later stop pauses only when its line executes after that continuation. The original `source`, `book.anchorIndex` and `inspections` define the first stop. A declared server stop is captured in whichever webpack layer's copy of its module runs: Next.js in development parses one source file once per layer (for example `(action-browser)`, `(middleware)` and `(rsc)`), and Codebook reads each copy's own text and source map, accepts it only when its map retains the exact frozen original, and places the pause by that copy's own map. The snapshot of a stop captured in such a copy serves that copy's JavaScript and source map from its `source/javascript` and `source/map` resources, which match the snapshot's `javascriptHash` and `mapHash`. A copy that cannot be read in time or verified is refused, and a pause held in it ends the run with a sentence that names the script and the reason. diff --git a/extension/src/service.ts b/extension/src/service.ts index 09d5337d5..77295617a 100644 --- a/extension/src/service.ts +++ b/extension/src/service.ts @@ -81,6 +81,7 @@ import type { StudyEnvironment, StudyRuntime, } from './runtime' +import type { RecordedEvent } from './runtimeSupport/evidence' import { sha256 } from './runtimeSupport/utils/sha256' import { explainNodeHistorySchema, @@ -3643,6 +3644,36 @@ export class StudyService { return !record.stopping && record.recording?.isSealed === true } + /** + * Words why a browser run's recording ends before its run completed, for the recording's gap and its seal: the run's own failure as its `run.failed` event names it, or a release that did not complete; called by {@link StudyService.stopRecord}. + * A run refused during its preparation used to read "Runtime interrupted or cleanup incomplete" in its recording although nothing was interrupted (Cycle 0052 task 7a). + * @param failure - The run's `run.failed` event, if it has one. + * @param error - The run's error, whose bare cause reads better than its wrapped message ("The study operation failed. Cause: …"). + * @returns The sentence; the recording cuts it to its reason limit. + * @example + * this.recordingStopReason(failedEvent, record.error) // => 'The run failed: Selected source app/ui/login-form.tsx is not in the client code … Recorded prefix retained.' + * this.recordingStopReason(undefined, undefined) // => 'Runtime cleanup incomplete; recorded prefix retained.' + */ + private recordingStopReason( + failure: RecordedEvent | undefined, + error: StudyError | undefined, + ): string { + if (!failure) return 'Runtime cleanup incomplete; recorded prefix retained.' + const detail = failure.detail + const reason = + error?.causeDetail ?? + (typeof detail === 'object' && + detail !== null && + 'reason' in detail && + typeof detail.reason === 'string' + ? detail.reason + : undefined) + // A failure journaled without words still says the run failed rather than that it was interrupted. + return reason === undefined + ? 'The run failed. Recorded prefix retained.' + : `The run failed: ${this.sanitizedCause(reason).replace(/\.$/, '')}. Recorded prefix retained.` + } + /** Serializes release after acquisitions and accepted explanation writes settle. @example await this.stopRecord(record) */ private async stopRecord(record: StudyRecord): Promise { if (record.stopped) return @@ -3675,7 +3706,7 @@ export class StudyService { if (failure || !runtime.resourcesReleased) record.recording.stopFrames( failure?.elapsedMs ?? record.runtime.evidence.elapsedMs(), - 'Runtime interrupted or cleanup incomplete; recorded prefix retained.', + this.recordingStopReason(failure, record.error), ) try { const seal = await record.recording.finish( diff --git a/extension/src/tests/configuredAdapter.test.ts b/extension/src/tests/configuredAdapter.test.ts index 65f4a5fba..20ac62e22 100644 --- a/extension/src/tests/configuredAdapter.test.ts +++ b/extension/src/tests/configuredAdapter.test.ts @@ -167,6 +167,7 @@ for (const path of ['src/ordinary.tsx', 'components/deep/another.ts']) ) }) +// Value: protects=a stop in another page's component is refused before browser input with a sentence that names the stop's file, the launched page and where a stop can go (the launched page's own code, or a run started at the stop's page), so the agent does not retry blind; fails_when=the refusal names neither the file nor the page and only says to verify the page, as N51's Cursor run retried three times (Cycle 0052 task 7b); why_new=the boundary stays (a browser stop is armed before input in the code the launched page loads) and its sentence now says so; seam=a development build whose root page loads another component; no_claim=a stop armed after a client-side navigation, which the profile does not support test('a source from another route reports the root-page mismatch before browser input', async (context) => { // Arrange const { directory, track } = await repositoryHarness(context) @@ -200,7 +201,10 @@ test('a source from another route reports the root-page mismatch before browser line: 1, column: 1, }), - /Selected source is absent from the selected application route chunks; verify that it belongs to the launched page/, + { + message: + 'Selected source src/dashboard/Header.tsx is not in the client code that the launched page (entryPath "/") loads, and a browser stop can only be armed in that code before input; start the run at the page whose component holds the stop (set entryPath to it) or choose a stop in the launched page\'s own components', + }, ) }) diff --git a/extension/src/tests/configuredRuntimeRecording.test.ts b/extension/src/tests/configuredRuntimeRecording.test.ts index a75837da7..aa0908442 100644 --- a/extension/src/tests/configuredRuntimeRecording.test.ts +++ b/extension/src/tests/configuredRuntimeRecording.test.ts @@ -700,8 +700,9 @@ test('a completed run whose final save fails records the failure and seals its r ], recordingSeal: { status: 'partial', + // The seal names the run's own failure (Cycle 0052 task 7a). reason: - 'Runtime interrupted or cleanup incomplete; recorded prefix retained.', + 'The run failed: ENOSPC: no space left on device, write. Recorded prefix retained.', }, }, ) @@ -1086,3 +1087,88 @@ test('an agent whose last continuation is acknowledged only after the completed ) await service.stop(receipt) }) + +// Value: protects=the recording of a browser run whose preparation was refused names that refusal when it is sealed, so the saved study and the agent read why nothing was recorded; fails_when=the seal reads "Runtime interrupted or cleanup incomplete; recorded prefix retained." for a run that was refused before input and stopped by the agent, as N51's three Cursor runs did (Cycle 0052 task 7a); why_new=the seal had one sentence for any journaled failure and for an incomplete release; seam=the controlled runtime's preparation, which refuses with the route-chunk sentence; no_claim=the native browser and debugger +test('a run refused during its preparation seals its recording with the refusal, not as interrupted', async (context) => { + // Arrange: a recorded run of the real coordinator whose preparation is refused. + let service: StudyService | undefined, books: RepositoryBookStore | undefined + context.after(async () => { + await service?.shutdown() + books?.dispose() + }) + const harness = await configuredRuntimeHarness(context), + directory = join(harness.directory, '.learning') + const library = new LearningLibrary(directory, '0'.repeat(64)), + store = new RecordingStore(directory, library.workspaceKey) + books = new RepositoryBookStore(harness.sources, library) + await books.publish({ + bookId: 'interface', + expectedRevision: 0, + key: 'article', + document: { + title: 'Interface', + summary: 'Two citations.', + nodes: [ + { + id: 'theme', + title: 'Theme', + sections: [{ title: 'Theme', text: 'Source-derived explanation.' }], + anchors: harness.definition.descriptor.anchors, + observations: [], + }, + ], + edges: [], + }, + }) + service = new StudyService( + { + workspace: harness.directory, + host: { + id: 'vscode', + productVersion: 'test', + apiVersion: 'test', + commit: 'test', + }, + build: { + revision: 'controlled-runtime', + dirty: false, + sourceHash: '0'.repeat(64), + }, + prerequisites: async () => ({ ready: true, missing: [] }), + createRuntime: async () => harness.run, + }, + join(harness.directory, '.service'), + ) + service.repository = books + service.recordings = store + context.mock.method(harness.run, 'prepare', async () => { + throw new Error( + 'Selected source app/ui/login-form.tsx is not in the client code that the launched page (entryPath "/") loads, and a browser stop can only be armed in that code before input; start the run at the page whose component holds the stop (set entryPath to it) or choose a stop in the launched page\'s own components', + ) + }) + const receipt = service.start({ + instanceId: service.instanceId, + profile: 'repository-browser', + key: 'refused-at-preparation', + configuration: harness.definition.configuration, + }) + const record = service.record(receipt) + await record.preparation + + // Act: the agent stops the refused run, as N51's did. + await service.stop(receipt).catch(() => undefined) + await waitUntil(() => record.stopped, 'the refused run is released') + + // Assert + assert.deepEqual( + { + status: record.recordingSeal?.status, + reason: record.recordingSeal?.reason, + }, + { + status: 'partial', + reason: + 'The run failed: Selected source app/ui/login-form.tsx is not in the client code that the launched page (entryPath "/") loads, and a browser stop can only be armed in that code before input; start the run at the page whose component holds the stop (set entryPath to it) or choose a stop in the launched page\'s own components. Recorded prefix retained.', + }, + ) +}) diff --git a/extension/src/utils/readDevelopmentBundle.ts b/extension/src/utils/readDevelopmentBundle.ts index 56f6269f0..1f6789eda 100644 --- a/extension/src/utils/readDevelopmentBundle.ts +++ b/extension/src/utils/readDevelopmentBundle.ts @@ -157,10 +157,10 @@ export async function readDevelopmentBundle( if (performance.now() >= deadline) throw new Error('Build discovery resource limit exceeded') signal?.throwIfAborted() - // An unchanged tracked file may still belong to a route outside the launched page. + // An unchanged tracked file may still belong to a route outside the launched page: the stop is armed before input, in the code the launched page loads, so the refusal names that page and where a stop can go (Cycle 0052 task 7b). if (bundles.length !== 1) throw new Error( - 'Selected source is absent from the selected application route chunks; verify that it belongs to the launched page', + `Selected source ${selected.path} is not in the client code that the launched page (entryPath "${entryPath}") loads, and a browser stop can only be armed in that code before input; start the run at the page whose component holds the stop (set entryPath to it) or choose a stop in the launched page's own components`, ) return { bundle: bundles[0]!, From cad145787dfc73d9680e26ab43616ec4999fddbb Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 20:58:56 +0900 Subject: [PATCH 03/30] docs: doc comments stand above their own symbols and two stale comments state current behavior (Cycle 0052 tasks 9, 23) Task 9: playTestReplayControl's, focusSummaryStart's and testReplayTitle's JSDoc move above their functions (they sat above playTransportControl's, controlEverFocused and renderFamilyTransport's), each naming its actual caller; the Show more/less note stands above showMore, not above the playing status. Task 23: openLearningReplay's comment says a test replay opens in its summary composition; the pane test's comment no longer names a Study pane the summary composition lacks. Comment-only diff; lint and typecheck pass. Refs #124 --- extension/src/studyWorkspace.ts | 2 +- .../studyWorkspaceTestReplayPane.test.ts | 2 +- extension/src/ui/studyHtml.ts | 32 +++++++++---------- extension/src/utils/describeNodeTestReplay.ts | 2 +- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index db91d1ab9..aca0783b7 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -11562,7 +11562,7 @@ export class StudyWorkspace { * @example await workspace.openLearningReplay(studyId) */ async openLearningReplay(studyId: string): Promise { - // A test replay opens in the Study pane, not through the browser replay's controller. + // A test replay opens in its summary composition through its own entry, not through the browser replay's controller (Cycle 0051, DS-A). if (this.isTestReplayStudy(studyId)) return this.openNodeHistoryFromHost(studyId) await this.openRecording(studyId) diff --git a/extension/src/tests/studyWorkspaceTestReplayPane.test.ts b/extension/src/tests/studyWorkspaceTestReplayPane.test.ts index 6e2b4cd38..ce1755f28 100644 --- a/extension/src/tests/studyWorkspaceTestReplayPane.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayPane.test.ts @@ -111,7 +111,7 @@ test('Show recorded output puts a closed Application pane back above the recorde { column: 1, tabs: ['Captured-theme.ts'] }, ]) assert.deepEqual(after.groups, before.groups) - // The Application group and the source's share the left column, and the Study pane's group stays the right column. + // The Application group and the source's share the left column, and the composition's third group stays the right column (the summary composition has no Study pane, Cycle 0051 DS-A). assert.deepEqual(after.layout, before.layout) }) diff --git a/extension/src/ui/studyHtml.ts b/extension/src/ui/studyHtml.ts index 63e825be5..10505b1e9 100644 --- a/extension/src/ui/studyHtml.ts +++ b/extension/src/ui/studyHtml.ts @@ -786,11 +786,6 @@ function studyClient( sequence, }) } - /** - * Carries out a shared transport button for a test replay (DS-C): Replay from start selects the first event, Previous and Next recorded stop step by stop, and Play or Pause runs the test replay's playback; called by the page's click handler while the summary composition shows a test replay. - * @param control - The button's `data-playback` value. - * @example playTestReplayControl('start') - */ /** * Runs a shared transport control for the replay family the page shows (DS-C): a test replay's moves by event and stop, a browser recording's seeks and Play; called by the page's click handler for a `data-playback` button. * @param control - The button's `data-playback` value: `start`, `previous`, `next`, `play` or `pause`. @@ -813,6 +808,11 @@ function studyClient( if (position !== undefined) playback('seek', position) } else if (control === 'play' || control === 'pause') playback(control) } + /** + * Carries out a shared transport button for a test replay (DS-C): Replay from start selects the first event, Previous and Next recorded stop step by stop, and Play or Pause runs the test replay's playback; called by {@link playTransportControl} while the page shows a test replay. + * @param control - The button's `data-playback` value. + * @example playTestReplayControl('start') + */ const playTestReplayControl = (control: string): void => { if (control === 'start') selectTestReplayEvent(current?.nodeHistoryPlayback?.firstSequence) @@ -2076,17 +2076,17 @@ function studyClient( focusHolder = undefined target.focus({ preventScroll: true }) } - /** - * Gives a replay's Learning summary that the learner opens with the focus on the document body and no control ever focused in it (its first open, an empty history) its title heading, so the keyboard starts at the top of the summary and the reading position stays (DS-E); called at the window's focus event after {@link settleFocus}, which places a held control first. - * A summary in which the learner has used a control keeps the pane's own return rule, so a focus the learner took off a control stays off it. The Study pane and the bottom panel's pane keep their own rules. - * @example focusSummaryStart() // the focus on `#title` after Codebook: Show Learning Summary - */ // Whether any control of this document has had the keyboard focus, which ends {@link focusSummaryStart}'s first-open rule. let controlEverFocused = false document.addEventListener('focusin', (event) => { if (event.target instanceof HTMLElement && event.target !== document.body) controlEverFocused = true }) + /** + * Gives a replay's Learning summary that the learner opens with the focus on the document body and no control ever focused in it (its first open, an empty history) its title heading, so the keyboard starts at the top of the summary and the reading position stays (DS-E); called at the window's focus event after {@link settleFocus}, which places a held control first. + * A summary in which the learner has used a control keeps the pane's own return rule, so a focus the learner took off a control stays off it. The Study pane and the bottom panel's pane keep their own rules. + * @example focusSummaryStart() // the focus on `#title` after Codebook: Show Learning Summary + */ const focusSummaryStart = (): void => { if ( controlEverFocused || @@ -2474,12 +2474,6 @@ function studyClient( */ const isReplayInBottomPanel = (message: StudyViewMessage): boolean => isBottomPanel && Boolean(replayFamilyOf(message)) - /** - * Names the shown test replay by its test file and title path, as Saved studies and a failed open's notification do; read by the message listener for the summary's heading (DS-B). - * @param message - The current Study message. - * @returns The test file and titles joined with ` › `, or the section's heading when the catalog does not list the replay. - * @example testReplayTitle(message) // => 'app/lib/data.test.ts › invoice data queries' - */ /** * Draws the shared transport for the replay family the summary composition shows (DS-C): a test replay's controls, or a browser recording's, which give the slider back; called on every Study message. * @param message - The current Study message. @@ -2522,6 +2516,12 @@ function studyClient( text('recording-save-state', '') element('evidence-details').hidden = true } + /** + * Names the shown test replay by its test file and title path, as Saved studies and a failed open's notification do; read by {@link readerHeading} for the summary's heading (DS-B). + * @param message - The current Study message. + * @returns The test file and titles joined with ` › `, or the section's heading when the catalog does not list the replay. + * @example testReplayTitle(message) // => 'app/lib/data.test.ts › invoice data queries' + */ const testReplayTitle = (message: StudyViewMessage): string => { const studyId = message.nodeHistory?.header.studyId const summary = message.nodeHistoryCatalog?.find( diff --git a/extension/src/utils/describeNodeTestReplay.ts b/extension/src/utils/describeNodeTestReplay.ts index 844960879..9c9a13d97 100644 --- a/extension/src/utils/describeNodeTestReplay.ts +++ b/extension/src/utils/describeNodeTestReplay.ts @@ -35,10 +35,10 @@ export const NODE_TEST_REPLAY_TEXT = { play: 'Play', pause: 'Pause', stepDetails: 'Step details', - /** The control under a clamped question, summary or capture reason that shows its whole text in place (Cycle 0051, DS-B), and its label while the whole text shows. */ /** The shared transport's status line while a test replay plays and while it is paused (Cycle 0051, DS-C). */ playingStatus: 'Test replay playing', pausedStatus: 'Test replay paused', + /** The control under a clamped question, summary or capture reason that shows its whole text in place (Cycle 0051, DS-B), and its label while the whole text shows. */ showMore: 'Show more', showLess: 'Show less', /** The name of the shared transport's group of Previous event and Next event, shown for a test replay only (DS-C). */ From 2e8b9b9aeb2245143bf7f8d4d2239392ee7976a0 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 21:00:19 +0900 Subject: [PATCH 04/30] test: two test replay expectations are hard-coded literals (Cycle 0052 task 10) The Cursor Move View offer and the test replay delete refusal were compared with SUMMARY_PLACEMENT_OFFER and STUDY_REMOVAL_REFUSALS.nodeHistory; they now name the published sentences. Mutation proof: changing either constant's text turns its tests red (both Cursor entries and the delete refusal). Refs #124 --- .../src/tests/studyWorkspaceTestReplayComposition.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index 485eda049..0ff70fbc2 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -8,8 +8,6 @@ import { test } from 'node:test' import { NODE_TEST_HISTORY_DIRECTORY, REPOSITORY_BROWSER_PROFILE as PROFILE_ID, - STUDY_REMOVAL_REFUSALS, - SUMMARY_PLACEMENT_OFFER, } from '../constants' import { ReplayDebuggerStartError, StudyError } from '../errors' import { sha256 } from '../runtimeSupport/utils/sha256' @@ -198,7 +196,9 @@ for (const entry of ['Open replay', 'Saved studies'] as const) { await waitUntil( () => harness.informationMessages.some( - (notice) => notice.message === SUMMARY_PLACEMENT_OFFER, + (notice) => + notice.message === + 'Cursor opens the Codebook Learning summary in its primary side bar. To keep it on the right, move it once: choose "New Secondary Side Bar Entry" in the list that opens.', ), 'the Move View offer is made', READER_RESULT_BOUND_MS, @@ -932,7 +932,7 @@ test('a delete request for the shown test replay is refused and the replay keeps // Assert assert.equal( lastView(harness)?.studyRemoval?.text, - STUDY_REMOVAL_REFUSALS.nodeHistory, + 'Test replays cannot be deleted here yet.', ) assert.equal( lastView(harness)?.nodeHistory?.header.studyId, From 30cb0bbec0e8c297920b734306c5a400700aa496 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 21:08:13 +0900 Subject: [PATCH 05/30] test: three test replay host tests await what their fixed waits stood for (Cycle 0052 task 8) - The two timer tests (another saved replay while playing; Return to Live while playing) wait for the retired replay's controller to clear its timer and hold no event, instead of 600 ms of nothing happening. - The superseded-move test awaits that move's own handling (receivePlaybackPanelHandled) instead of 100 ms. For that, the bottom panel's message listener returns its handling promise, which the editor ignores; no product behavior changes. Mutation proof: stopTimer that clears nothing, or a retirement that neither pauses nor disposes the controller, turns both timer tests red; a superseded move that reports a refusal turns the third red. Refs #124 --- extension/src/studyWorkspace.ts | 20 +++++----- extension/src/tests/studyWorkspaceHarness.ts | 8 +++- ...tudyWorkspaceTestReplayComposition.test.ts | 40 +++++++++++++++---- 3 files changed, 51 insertions(+), 17 deletions(-) diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index aca0783b7..9e6e6a750 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -20031,15 +20031,17 @@ export class StudyWorkspace { } view.webview.html = studyHtml('moments') // Disposed or replaced panel documents cannot send actions into a later replay. - view.webview.onDidReceiveMessage((message) => { - if (this.moments === view) - void this.receivePaneMessage(message, 'moments', view).catch( - (error) => { - if (this.moments === view) - this.report(this.panelFailureKind(message), error) - }, - ) - }) + // The listener returns its handling, which the editor ignores, so a test can tell when a message's handling ended (Cycle 0052 task 8). + view.webview.onDidReceiveMessage((message) => + this.moments === view + ? this.receivePaneMessage(message, 'moments', view).catch( + (error) => { + if (this.moments === view) + this.report(this.panelFailureKind(message), error) + }, + ) + : undefined, + ) view.onDidDispose(() => { if (this.moments === view) { this.moments = undefined diff --git a/extension/src/tests/studyWorkspaceHarness.ts b/extension/src/tests/studyWorkspaceHarness.ts index 1c5a160aa..1c1e4eb2e 100644 --- a/extension/src/tests/studyWorkspaceHarness.ts +++ b/extension/src/tests/studyWorkspaceHarness.ts @@ -3813,7 +3813,7 @@ export async function workspaceHarness( number, StudyViewMessage | undefined >() - const playbackPanelListeners = new Set<(message: unknown) => void>() + const playbackPanelListeners = new Set<(message: unknown) => unknown>() const playbackPanelVisibilityListeners = new Set<() => void>() const playbackPanelDisposeListeners = new Set<() => void>() const playbackPanel = { @@ -5226,6 +5226,12 @@ export async function workspaceHarness( receivePlaybackPanel(message: unknown): void { for (const listener of playbackPanelListeners) listener(message) }, + /** Sends a bottom panel message as {@link receivePlaybackPanel} does and resolves once the reader's handling of it ended, settled either way, so a test awaits that end instead of a fixed time. @example await harness.receivePlaybackPanelHandled(message) */ + async receivePlaybackPanelHandled(message: unknown): Promise { + await Promise.allSettled( + [...playbackPanelListeners].map((listener) => listener(message)), + ) + }, async resolveRestoredObservedMoments(): Promise { assert.ok(playbackPanelProvider) await playbackPanelProvider.resolveWebviewView(playbackPanel) diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index 0ff70fbc2..8aa6efbfd 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -858,13 +858,26 @@ test('choosing another saved test replay while the first plays with its summary studyId: first.studyId, action: 'play', }) + const firstPlayback = harness.reader['nodeHistoryPlaybackController']! + await waitUntil( + () => firstPlayback.state().mode === 'playing', + 'the first replay plays', + READER_RESULT_BOUND_MS, + ) // Act await harness.reader.openLearningReplay(second.studyId) await landsOn(harness, 2, 'the second replay lands on its first stop') const shownAfterOpen = lastView(harness)?.nodeHistory?.header.studyId - // gstack-timing-ok: a stopped timer is shown by nothing happening; 600 ms outlasts the whole 400 ms history's playback. - await new Promise((resolve) => setTimeout(resolve, 600)) + // The first replay's playback is retired: its timer stops with its controller, which holds no event any more (Cycle 0052 task 8: no fixed wait). + await waitUntil( + () => + firstPlayback['timer'] === undefined && + firstPlayback.state().mode === 'paused' && + firstPlayback.state().endMs === undefined, + "the first replay's playback is retired", + READER_RESULT_BOUND_MS, + ) // Assert assert.equal(shownAfterOpen, second.studyId) @@ -889,12 +902,25 @@ test('Return to Live while a test replay plays with its summary hidden stops its studyId: history.studyId, action: 'play', }) + const playback = harness.reader['nodeHistoryPlaybackController']! + await waitUntil( + () => playback.state().mode === 'playing', + 'the test replay plays', + READER_RESULT_BOUND_MS, + ) // Act await harness.reader.action('return-current') const messagesAtReturn = harness.playbackPanelMessages.length - // gstack-timing-ok: a stopped timer is shown by nothing happening; 600 ms outlasts the whole 400 ms history's playback. - await new Promise((resolve) => setTimeout(resolve, 600)) + // The retired replay's timer stops with its controller, which holds no event any more (Cycle 0052 task 8: no fixed wait). + await waitUntil( + () => + playback['timer'] === undefined && + playback.state().mode === 'paused' && + playback.state().endMs === undefined, + "the test replay's playback is retired", + READER_RESULT_BOUND_MS, + ) // Assert assert.equal( @@ -1018,7 +1044,7 @@ test('moves from the bottom panel and the Learning summary while a move presents try { const history = await openByOpenReplay(harness) const held = harness.holdHistoricalDebugPresentation(true) - harness.receivePlaybackPanel({ + const supersededMove = harness.receivePlaybackPanelHandled({ type: 'node-history-step', revision: lastView(harness)!.revision, studyId: history.studyId, @@ -1037,8 +1063,8 @@ test('moves from the bottom panel and the Learning summary while a move presents }) await landsOn(harness, 5, 'the latest request reaches the result') held.reject(new Error('Historical debugger presentation was superseded.')) - // gstack-timing-ok: the superseded presentation's rejection settles in the next turns, and the case shows that it reports nothing. - await new Promise((resolve) => setTimeout(resolve, 100)) + // The superseded move's own handling ends after its presentation's rejection (Cycle 0052 task 8: no fixed wait). + await supersededMove // Assert assert.equal(lastView(harness)?.nodeHistoryPosition?.sequence, 5) From 26c4878c5eb6df4a6365f815b92a3644104b9c2f Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 21:15:33 +0900 Subject: [PATCH 06/30] test: the browser replay's untested state cells; a failed browser replay open writes its Output entry (Cycle 0052 tasks 22, 59) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 22: host tests for another saved study chosen during a browser replay (the chosen recording in the summary composition, Study pane closed) and a failed browser replay open (one notification, one Output entry, the Study composition kept, no empty summary composition); Reader DOM cases for the browser replay's Replay from start (a seek to the range's start, then the start readout) and Pause (pause, then Play). Red proof: page mutations (start seeking the current position; the toggle sending play) each fail exactly their case under 'red'. Task 59 (found by the failed-open cell): a browser replay whose open failed wrote no Output › Codebook entry, unlike a test replay's; it now writes one 'open:recording' entry with the study, the profile and the sanitized cause (test red before the fix). README says so. Refs #124 --- extension/README.md | 2 +- extension/scripts/study-bootstrap-qa.mjs | 53 ++++++++++ extension/src/studyWorkspace.ts | 7 ++ .../studyWorkspaceTestReplayBrowser.test.ts | 97 ++++++++++++++++++- 4 files changed, 157 insertions(+), 2 deletions(-) diff --git a/extension/README.md b/extension/README.md index 9557560b9..eb446494c 100644 --- a/extension/README.md +++ b/extension/README.md @@ -380,7 +380,7 @@ Opening saved learning starts a read-only **Codebook · Recorded learning** debu A slow start does not fail a replay. Loading a recording and starting or retiring the historical session do not count against the five-second paint bound; that bound applies to each frame's painting, and a presentation that never reaches painting ends after 23 seconds. If the editor needs more than five seconds to start the historical session, the moment that waited for it fails, and the session is adopted when its start completes: the next moment is presented without reopening the recording. A session you stopped meanwhile is not adopted. If the editor does not stop the historical session within five seconds, the session becomes unavailable and reopening the learning replay starts a new one. Closing the reader does not wait on the native debugger or on a frame that never paints beyond those bounds. It waits at most five seconds in all for its pending storage writes (a running study's final checkpoint, the saved replay position, the archive writes and the library queue); a write still pending then keeps running and the reader closes. -**Open replay** pressed while another window has the desktop focus waits up to 10 seconds for the Codebook work window to gain the focus and then opens the Learning summary. When the focus does not come, the replay is refused with `Focus the Codebook work window before reading the learning summary: another window kept the desktop focus for 10 s. Click in this editor window, then choose Open replay again.`, and the status bar's **Open replay** shows that reason as a notification (`Codebook could not open the learning replay: …`). +**Open replay** pressed while another window has the desktop focus waits up to 10 seconds for the Codebook work window to gain the focus and then opens the Learning summary. When the focus does not come, the replay is refused with `Focus the Codebook work window before reading the learning summary: another window kept the desktop focus for 10 s. Click in this editor window, then choose Open replay again.`, and the status bar's **Open replay** shows that reason as a notification (`Codebook could not open the learning replay: …`) and writes it to **Output › Codebook** as one `open:recording` entry with the study, the profile and the cause. The Learning summary has its own icon in the Activity Bar in both editors, because Cursor rejects the secondary side bar container an extension contributes. In Cursor it opens in the primary side bar. In VS Code, the first time you open a replay yourself in a profile (never an agent's open), Codebook moves the summary once into the Chat container on the right with VS Code's internal `vscode.moveViews` command; a place you choose afterwards is never changed back, and if the command is refused or missing the summary stays in the Activity Bar and Output › Codebook says so. Nothing is offered in VS Code. Once per editor session, until you have taken the step in that workspace, Cursor shows the notification `Cursor opens the Codebook Learning summary in its primary side bar. To keep it on the right, move it once: choose "New Secondary Side Bar Entry" in the list that opens.` with the button **Move Learning Summary…**. Cursor removes an unanswered notification by itself, so the same step stays within reach as the title action **Codebook: Move Learning Summary…** of the Learning summary view and under that name in Cursor's Command Palette. Each opens the editor's own Move View list for the summary. When **Show Variables** opens Run and Debug in the side bar the summary shares, the offer reads `Run and Debug took the Learning summary's place in Cursor's side bar. Move the summary to the right once to keep both in view.`, with the same button, at most once per editor session. diff --git a/extension/scripts/study-bootstrap-qa.mjs b/extension/scripts/study-bootstrap-qa.mjs index d0bd8fa10..f51cec27d 100644 --- a/extension/scripts/study-bootstrap-qa.mjs +++ b/extension/scripts/study-bootstrap-qa.mjs @@ -1158,6 +1158,59 @@ async function check(page, options, collected) { value.revision === revisionAt(5), ), }) + // Value: protects=a browser replay's Replay from start sends a seek to the recording's first millisecond, and the readout then reads the start time the host presents; fails_when=Replay from start sends another request or position, or the readout keeps the earlier time (Cycle 0052 task 22, the state cell browser replay × Replay from start); why_new=Replay from start was covered for test replays only; seam=none; no_claim=the host's seek (studyWorkspace tests) + await publish(replay) + await page.evaluate(() => { + window.sent = [] + }) + await page.locator('[data-playback="start"]').click() + const fromStart = await page.evaluate(() => + window.sent.filter((message) => message.type === 'playback'), + ) + await publish({ + ...replay, + recording: { ...replay.recording, presentedMs: 0 }, + }) + const readoutAtStart = await page + .locator('#playback-position') + .textContent() + cases.push({ + name: "a browser replay's Replay from start seeks the recording's start and the readout then shows its start time", + actual: { fromStart, readoutAtStart }, + passed: + fromStart.length === 1 && + fromStart[0].action === 'seek' && + fromStart[0].positionMs === 0 && + readoutAtStart === 'Presented 0.00 s / 8.00 s', + }) + // Value: protects=a browser replay's Pause sends pause, and the toggle then reads Play once the host presents it paused; fails_when=the toggle sends play or nothing while the recording plays, or keeps reading Pause after the host paused (Cycle 0052 task 22, the state cell browser replay × Pause); why_new=Pause was covered for test replays only; seam=none; no_claim=the host's pause (studyWorkspace tests) + await publish({ + ...replay, + recording: { ...replay.recording, mode: 'playing' }, + }) + const toggleWhilePlaying = await page + .locator('#playback-toggle') + .textContent() + await page.evaluate(() => { + window.sent = [] + }) + await page.locator('#playback-toggle').click() + const pausing = await page.evaluate(() => + window.sent.filter((message) => message.type === 'playback'), + ) + await publish(replay) + const toggleAfterPause = await page + .locator('#playback-toggle') + .textContent() + cases.push({ + name: "a browser replay's Pause sends pause and the toggle then reads Play", + actual: { toggleWhilePlaying, pausing, toggleAfterPause }, + passed: + toggleWhilePlaying === 'Pause' && + pausing.length === 1 && + pausing[0].action === 'pause' && + toggleAfterPause === 'Play', + }) await page.locator('#understood').focus() const reading = await page.evaluate(() => ({ bottom: document.getElementById('understood').getBoundingClientRect() diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index 9e6e6a750..5f4871609 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -11591,6 +11591,13 @@ export class StudyWorkspace { if (answer === 'retry') return this.openLearningReplay(studyId) // The keep-open refusal has told the learner already; a second notice would repeat it. if (answer === 'told') return + // The failure also goes to Output › Codebook, as a test replay's failed open does, so the learner can copy it into a report (Cycle 0052 task 59). + this.diagnostics?.log({ + surface: 'open:recording', + studyId, + profile: REPOSITORY_BROWSER_PROFILE, + cause: sanitizeCause(state.reason, this.privateRoots()), + }) // The controller keeps a failure as its string form, which starts with the error's name. void showPlainNotice( vscode.window, diff --git a/extension/src/tests/studyWorkspaceTestReplayBrowser.test.ts b/extension/src/tests/studyWorkspaceTestReplayBrowser.test.ts index 1eb14cc5b..37e4deef9 100644 --- a/extension/src/tests/studyWorkspaceTestReplayBrowser.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayBrowser.test.ts @@ -2,9 +2,11 @@ import assert from 'node:assert/strict' import { test } from 'node:test' import { waitUntil } from '../runtimeSupport/utils/waitUntil' +import type { CodebookDiagnostic } from '../utils/formatCodebookDiagnostic' -import { recordedReaderHarness } from './studyWorkspaceHarness' +import { readerSeams, recordedReaderHarness } from './studyWorkspaceHarness' import { READER_RESULT_BOUND_MS } from './utils/readerResultBound' +import { saveOtherBrowserStudy } from './utils/saveOtherBrowserStudy' import { lastView, saveTwoStopNodeHistory } from './utils/twoStopNodeHistory' // Value: protects=a browser recording that loads while a test replay is selected retires the test replay (D20): the Application pane shows the recording's own pages, not the test replay's output, and the Study message carries no test replay; fails_when=loadPlayback leaves the test replay selected, so capturedAppHtml gives it the pane (the recording's loading page or "Recorded playback unavailable" never shows) and the section stays drawn beside the recording; why_new=F132 and F146: only Saved studies' choice of a browser study retired the test replay, and the recordingActive arm of Focus application's guard decided no tested outcome; seam=none; no_claim=the native placement of the pane (stand-ins: the workspace harness's recorded panels, posted messages and closeTabByHand) @@ -1118,3 +1120,96 @@ test('Return to Live from a browser replay releases the recorded viewport withou await harness.close() } }) + +// Value: protects=another saved study chosen in Saved studies during a browser replay retires the shown replay and shows the chosen study in its own composition: the chosen recording plays in the summary composition, with the Study pane closed and the browser family's transport; fails_when=the choice leaves the first recording shown, or brings the Study composition back over a replay (Cycle 0052 task 22, the state cell browser replay × another saved study); why_new=the cell had no test; seam=recordedReaderHarness, a second saved browser study and the bottom panel's history message; no_claim=the chosen study's recording content, which the saved configured study does not carry +test('another saved study chosen during a browser replay retires it and shows the chosen study in its own composition', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + const other = await saveOtherBrowserStudy( + await harness.reader.learningStorage(), + ) + // Retry storage reloads the catalog the reader shows. + await harness.reader.action('retry') + await harness.reader.openRecording(harness.studyId) + await harness.controller.settle() + + // Act + harness.receivePlaybackPanel({ + type: 'history', + revision: harness.reader.inspect().view!.revision, + studyId: other.id, + }) + await waitUntil( + () => harness.controller.state().studyId === other.id, + 'the chosen study is opened', + READER_RESULT_BOUND_MS, + ) + await harness.controller.settle() + + // Assert + assert.deepEqual( + { + recording: harness.controller.state().studyId, + studyPane: harness.panelColumn('codebook.studyContext') !== undefined, + application: + harness.panelColumn('codebook.scopedBrowser') !== undefined, + summary: harness.summary.visible, + family: lastView(harness)?.replayFamily, + }, + { + recording: other.id, + studyPane: false, + application: true, + summary: true, + family: 'browser', + }, + ) + } finally { + await harness.close() + } +}) + +// Value: protects=a browser replay whose open fails is told with the load-failure notification and one Output › Codebook entry, keeps the reader's earlier state and leaves no empty summary composition; fails_when=the failure is not told, the Output entry is missing or repeated (the cell's test found it missing: Cycle 0052 task 59), or the reader stays in a summary composition with no replay (Cycle 0052 task 22, the state cell browser replay × a failed open); why_new=the cell had no test; seam=recordedReaderHarness, the reader's debugger replacement failing the next load, the reader's diagnostics; no_claim=Show details, which the notification's button runs +test('a browser replay whose open fails is told once, keeps the Study composition and leaves no empty summary composition', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + const logged: CodebookDiagnostic[] = [] + harness.reader.diagnostics = { + log: (entry) => logged.push(entry), + show: () => undefined, + } + try { + await harness.reader.open() + // The next recording load fails as a replay debugger that does not start. + const seams = readerSeams(harness) + const replace = seams.debuggerReplacement + seams.debuggerReplacement = async (): Promise => { + seams.debuggerReplacement = replace + throw new Error('The historical debugger did not start.') + } + + // Act + await harness.reader.openLearningReplay(harness.studyId) + + // Assert + assert.deepEqual( + { + notices: [...harness.errorMessages], + outputEntries: logged.map((entry) => entry.surface), + studyPane: harness.panelColumn('codebook.studyContext') !== undefined, + family: lastView(harness)?.replayFamily, + }, + { + notices: [ + 'Codebook could not open the learning replay: The historical debugger did not start.', + ], + outputEntries: ['open:recording'], + studyPane: true, + family: undefined, + }, + ) + } finally { + await harness.close() + } +}) From 10dd6d838c63d0e81af2836b357b07b12bd2f540 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 21:28:05 +0900 Subject: [PATCH 07/30] test: two Retry storage tests wait for the second run's refused save before the Retry ends (Cycle 0052 task 26) The second run's save goes through the write queue after refresh() returns. Under load its refusal was handled after the Retry ended, so the pane was read with the save still queued and no History not saved line. A 400 ms delayed refusal reproduces it (HEAD 3/3 fail, fixed 3/3 pass); the line-retirement mutation still fails both tests 5/5. --- extension/src/tests/studyWorkspaceRemoval.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/extension/src/tests/studyWorkspaceRemoval.test.ts b/extension/src/tests/studyWorkspaceRemoval.test.ts index 553dae78a..09598cc46 100644 --- a/extension/src/tests/studyWorkspaceRemoval.test.ts +++ b/extension/src/tests/studyWorkspaceRemoval.test.ts @@ -39,6 +39,7 @@ import { import { createConfiguredStudy } from './utils/createConfiguredStudy' import { idleChild } from './utils/idleChild' import { ID_OF_NO_PROCESS } from './utils/idOfNoProcess' +import { READER_RESULT_BOUND_MS } from './utils/readerResultBound' import { rewriteWriterRecord } from './utils/rewriteWriterRecord' type Harness = Awaited> @@ -5539,6 +5540,13 @@ test('a Retry storage / refresh that saved the earlier study keeps the line “H `${harness.service.instanceId}_${second.record.id}`, ) await readerSeams(harness).refresh() + // The second run's save is refused while the Retry still reconciles, as the case means: a test that read the pane before that refusal was handled raced it (Cycle 0052 task 26). + await waitUntil( + () => + harness.service.state(second.record).historyOutcome.status === 'failed', + "the refusal of the second run's save", + READER_RESULT_BOUND_MS, + ) continueReconcile() await retry const afterRetry = structuredClone({ @@ -5616,6 +5624,13 @@ test('a Retry storage / refresh whose selection names a study without an archive `${harness.service.instanceId}_${second.record.id}`, ) await readerSeams(harness).refresh() + // The second run's save is refused while the Retry still reconciles, as the case means (the same race as Cycle 0052 task 26). + await waitUntil( + () => + harness.service.state(second.record).historyOutcome.status === 'failed', + "the refusal of the second run's save", + READER_RESULT_BOUND_MS, + ) continueReconcile() await retry // Assert From 4ddbb683769a32040ee8de99f3927bec46f07e64 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 21:40:06 +0900 Subject: [PATCH 08/30] test: Removal tests read the write queue after the other study's write left it (Cycle 0052 task 47) A save reports 'saved' before it writes the recording's context and leaves the write queue, and decides about the History not saved line only then. The capture-failure fixture stopped the run, and two tests read the pane, right after 'saved', so a slow write read one more queued study. A 300 ms delayed saveRecordingContext reproduces it (HEAD: 9 of 204 fail; fixed: 0). The line-retirement mutation fails the same two tests before and after. --- .../src/tests/studyWorkspaceRemoval.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/extension/src/tests/studyWorkspaceRemoval.test.ts b/extension/src/tests/studyWorkspaceRemoval.test.ts index 09598cc46..99893e5b8 100644 --- a/extension/src/tests/studyWorkspaceRemoval.test.ts +++ b/extension/src/tests/studyWorkspaceRemoval.test.ts @@ -5420,6 +5420,12 @@ test('the line “History not saved: …” of a study that still waits stays, w 'the save of the second run', 1_000, ) + // The save decides about the line only once its write left the queue, after it reported 'saved': the pane is read after that decision, with the first study alone waiting (Cycle 0052 task 47). + await waitUntil( + () => readerSeams(harness).queuedStudyWrites() === 1, + 'the end of the second run’s write', + READER_RESULT_BOUND_MS, + ) const afterOtherSave = structuredClone({ queued: readerSeams(harness).queuedStudyWrites(), errors: harness.reader.inspect().view!.errors, @@ -5474,6 +5480,12 @@ test('the line “History not saved: …” of a study whose capture failed befo 'the save of the second run', 1_000, ) + // The save decides about the line only once its write left the queue, after it reported 'saved': the pane is read after that decision (Cycle 0052 task 47). + await waitUntil( + () => readerSeams(harness).queuedStudyWrites() === 0, + 'the end of the second run’s write', + READER_RESULT_BOUND_MS, + ) const afterOtherSave = structuredClone({ queued: readerSeams(harness).queuedStudyWrites(), errors: harness.reader.inspect().view!.errors, @@ -10036,6 +10048,12 @@ async function recordRunWithFailedCapture(context: TestContext) { 'the save of the second run', 2_000, ) + // The save reports 'saved' before it writes the recording's context and leaves the queue; a Stop before that read one queued write (Cycle 0052 task 47). + await waitUntil( + () => readerSeams(harness).queuedStudyWrites() === 0, + 'the end of the second run’s write', + READER_RESULT_BOUND_MS, + ) failedStudyId = studyId await harness.reader.action('stop') return { From adb6e2d049ea54a585b18c987b81a6f1459ed0ed Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 21:44:23 +0900 Subject: [PATCH 09/30] test: configured service tests wait for a run's release up to a shared bound (Cycle 0052 task 58) Nine waits for a run's release used a one-second bound. The release takes about 50 ms idle but passed one second inside pnpm health's instrumented run. RUN_RELEASE_BOUND_MS (10 s) replaces the nine. A release delayed by 1.5 s fails those nine tests on HEAD and none after; a failByRelease that leaves no error still fails the task's test 3/3. --- extension/src/tests/configuredService.test.ts | 19 ++++++++++--------- extension/src/tests/utils/runReleaseBound.ts | 7 +++++++ 2 files changed, 17 insertions(+), 9 deletions(-) create mode 100644 extension/src/tests/utils/runReleaseBound.ts diff --git a/extension/src/tests/configuredService.test.ts b/extension/src/tests/configuredService.test.ts index f6d871c65..46611bd0a 100644 --- a/extension/src/tests/configuredService.test.ts +++ b/extension/src/tests/configuredService.test.ts @@ -26,6 +26,7 @@ import { observeResult, } from './utils/configuredServiceHarness' import { loadModule } from './utils/loadModule' +import { RUN_RELEASE_BOUND_MS } from './utils/runReleaseBound' const receiptSchema = z.object({ instanceId: z.string(), @@ -753,7 +754,7 @@ test('a run whose preparation fails and whose release then fails keeps the prepa await waitUntil( () => Boolean(record.cleanupError) && !record.stopping, 'the failed release after the failed preparation', - 1_000, + RUN_RELEASE_BOUND_MS, ) const resource = await readResource(service, receipt.runUri) // The harness's own shutdown releases the run once the test has read it. @@ -822,7 +823,7 @@ test('a run that the maintenance turn ends for an evidence write failure names t await waitUntil( () => Boolean(record.cleanupError) && !record.stopping, 'the release after the evidence failure', - 1_000, + RUN_RELEASE_BOUND_MS, ) const resource = await readResource(service, receipt.runUri) // The harness's own shutdown releases the run once the test has read it. @@ -896,7 +897,7 @@ test('a run whose saved study was removed while it ran ends at the maintenance t await waitUntil( () => Boolean(record.cleanupError) && !record.stopping, 'the release after the evidence failure', - 1_000, + RUN_RELEASE_BOUND_MS, ) const resource = await readResource(service, receipt.runUri) // The harness's own shutdown releases the run once the test has read it. @@ -973,7 +974,7 @@ test('a run whose saved study was removed while it ran keeps a release failure t await waitUntil( () => Boolean(record.cleanupError) && !record.stopping, 'the release after the evidence failure', - 1_000, + RUN_RELEASE_BOUND_MS, ) const resource = await readResource(service, receipt.runUri) const retryFailure = await service @@ -1043,7 +1044,7 @@ test('a run whose saved study was found removed after its evidence write failed await waitUntil( () => Boolean(record.cleanupError) && !record.stopping, 'the release after the evidence failure', - 1_000, + RUN_RELEASE_BOUND_MS, ) const errorNamedPathBefore = Boolean( record.error?.message.includes(runtime.evidence.directory), @@ -1129,7 +1130,7 @@ test('a completed run whose release failed after its saved study was removed by await waitUntil( () => Boolean(record.cleanupError) && !record.stopping, 'the failed release of the completed run', - 1_000, + RUN_RELEASE_BOUND_MS, ) const errorBefore = { message: record.error?.message, @@ -1207,7 +1208,7 @@ test("a removed study's kept run failure is served by no resource: its events an await waitUntil( () => Boolean(record.cleanupError) && !record.stopping, 'the release after the evidence failure', - 1_000, + RUN_RELEASE_BOUND_MS, ) service.excludeHistory(record, 'archive-removed') const path = runtime.evidence.directory @@ -1347,7 +1348,7 @@ test('a run whose study was removed during its preparation and found removed aft await waitUntil( () => !record.stopping && (record.stopped || Boolean(record.cleanupError)), 'the release after the failed preparation', - 1_000, + RUN_RELEASE_BOUND_MS, ) const path = runtimes[0]!.evidence.directory const terminalNamedPathBefore = Boolean( @@ -1445,7 +1446,7 @@ test('a run whose study was found removed while its preparation was failing name await waitUntil( () => !record.stopping && (record.stopped || Boolean(record.cleanupError)), 'the release after the failed preparation', - 1_000, + RUN_RELEASE_BOUND_MS, ) const path = runtimes[0]!.evidence.directory const run = await readResource(service, receipt.runUri) diff --git a/extension/src/tests/utils/runReleaseBound.ts b/extension/src/tests/utils/runReleaseBound.ts new file mode 100644 index 000000000..150a53e37 --- /dev/null +++ b/extension/src/tests/utils/runReleaseBound.ts @@ -0,0 +1,7 @@ +/** + * How long a service test waits for a run's release to end (its final evidence write, its cleanup, its failure) before it reads the wait as a hang. + * Why: a release that takes about 50 ms on an idle machine took over one second inside `pnpm health`'s instrumented coverage run, so a one-second wait failed although the release came (Cycle 0052 task 58); a release that never ends still fails, at this bound. + * Read by the configured service tests that wait for a release. + * @example await waitUntil(() => Boolean(record.cleanupError) && !record.stopping, 'the failed release', RUN_RELEASE_BOUND_MS) + */ +export const RUN_RELEASE_BOUND_MS = 10_000 From 550b5ec0bca03e158921ed635aa4c5f411ce68dd Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 21:53:26 +0900 Subject: [PATCH 10/30] fix: an Update anyway chosen while this window's ended run changes its marker starts the update (Cycle 0052 task 27) The end of a Wiki run removes its marker, or turns it into a stopped watch's reservation, under the claim on the marker. A start of the same window that claimed in that moment met the marker in change and refused itself with "Another editor window is starting or ending an update of this Wiki". The start now waits for this window's own change and claims again; a marker in change that no change of this window explains still refuses the start. Root cause of the publisher test that failed once in pnpm health's coverage run: 24 parallel runs reproduced it 9 and 7 times in 96 with that refusal; 0 in 96 after the fix. The new host test holds the reservation's write under the claim (harness hold 'marker-write') and is red without the fix. --- extension/README.md | 2 +- extension/src/extension.ts | 53 ++++++++++++++++-- .../src/tests/utils/readerResultBound.ts | 2 +- extension/src/tests/utils/wikiUpdateWindow.ts | 35 ++++++++++-- .../src/tests/wikiUpdatePublisherFlow.test.ts | 55 +++++++++++++++++++ 5 files changed, 134 insertions(+), 13 deletions(-) diff --git a/extension/README.md b/extension/README.md index eb446494c..49ec5c9f5 100644 --- a/extension/README.md +++ b/extension/README.md @@ -169,7 +169,7 @@ The folder is `.codebook/wiki` in the project unless `codebook.wikiOutputDirecto A Wiki folder you commit to Git must keep the publisher's bytes: mark it `-text` in `.gitattributes` (the line `".codebook/wiki/**" -text`, with your folder's path), so that a checkout with `core.autocrlf`, the default of Git for Windows, keeps its LF line ends. The skill's publisher writes every page with LF line ends only, also when the text of a page held CRLF, so that this way out restores every page. The skill's publisher refuses a Wiki whose files a checkout gave CRLF line ends, with a sentence that names them and the way out: add that line to the `.gitattributes` file in the folder the sentence names (the project's, for a Wiki folder inside it) and commit that file; then, in that folder, run `git rm -r --cached --quiet -- ".codebook/wiki"` and `git checkout HEAD -- ".codebook/wiki"` (with your folder's path from there), which write the folder's committed files again with LF line ends (a change of them that is not committed is lost), and run the step again. The publisher also refuses a manifest that starts with a UTF-8 byte order mark (PowerShell 5.1's `Set-Content -Encoding UTF8` writes one) and says: `Write the file again as UTF-8 without that mark`. It reads `--output=` as it reads `--output `; a separate value that begins with a dash gets its usage. -While an update runs, its window keeps a marker file beside the output folder, `.update-run.json` (`.codebook/wiki.update-run.json` unless the setting names another folder), so that a second editor window on the same project does not start a second update. That window's start is refused with `Another editor window is updating this Wiki (, started