From 5f0bbe0a0cad7c6188576a6600b11c63c3805162 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sat, 24 Oct 2026 18:20:48 +0900 Subject: [PATCH 01/13] feat: a test replay opens in the summary composition (Cycle 0051, DS-A, DS-F, DS-G) A selected or opening test replay holds the summary composition a browser replay has: the Study pane closes, the Learning summary stands in the chat's place, and the bottom panel reads Learning replay. Moves from the summary and the bottom panel are routed, the latest learner entry wins, a move's debugger failure carries the open path's recovery clause, the host projects the history's first and last sequence and a move refusal for the transport, Restore works in the composition, and a failed open of another replay pauses the shown one. Tests: 27 composition host tests; the Study-pane pins (moves with D8, F123) changed with their reasons; harness seams receive/receiveContext follow the composition and constrainStudyGrid takes a group count. Refs #122 --- extension/src/studyWorkspace.ts | 252 +++- .../src/tests/studyWorkspaceEntry.test.ts | 27 +- .../studyWorkspaceEntryObstacles.test.ts | 5 +- .../studyWorkspaceEntryPlacement.test.ts | 15 +- extension/src/tests/studyWorkspaceHarness.ts | 34 +- .../tests/studyWorkspaceTestReplay.test.ts | 27 +- ...tudyWorkspaceTestReplayComposition.test.ts | 1015 +++++++++++++++++ .../studyWorkspaceTestReplayPane.test.ts | 22 +- .../studyWorkspaceTestReplaySource.test.ts | 46 +- 9 files changed, 1306 insertions(+), 137 deletions(-) create mode 100644 extension/src/tests/studyWorkspaceTestReplayComposition.test.ts diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index ba00281f7..8baeb43e3 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -955,6 +955,8 @@ export class StudyWorkspace { private contextPanel?: vscode.WebviewPanel private liveComposition = false /** `true` while {@link openNodeHistoryFromHost} enters the Study composition for a test replay that has not begun opening ({@link testReplayHoldsComposition}). */ + /** Counts the learner's test replay entries ({@link openNodeHistoryFromHost}) in the order they were made, so the latest one wins also while an earlier one still enters the summary composition (EN19, Cycle 0051). */ + private nodeHistoryEntryRequest = 0 private hostOpeningTestReplay = false /** The editor's recent group and active text editor changes, for {@link refusePlacement}'s report (F382). */ private readonly placementEvents = new PlacementEventLog() @@ -2065,6 +2067,12 @@ export class StudyWorkspace { ) return this.selectAfterClosingLeftoverTabs(input, requester) if (input.action === 'focus') return this.focusCurrentPresentation(input) + // A test replay in the summary composition has no recording to arrange: its Restore shows its slot again, as in the Study composition (Cycle 0051, DS-A; F413). + if ( + this.summaryComposition && + this.isTestReplayRestore(input, this.presentationState()) + ) + return this.restoreTestReplayArrangement() if (this.liveComposition || this.summaryComposition) { this.assertCompositionAllows(input) // Selecting the arrangement that is confirmed already changes nothing: it is read once more without taking the focus, and only a layout that no longer holds is arranged again. @@ -2269,7 +2277,7 @@ export class StudyWorkspace { * Says whether a presentation request is a Restore or selection of the Study workspace that a selected test replay's composition answers; asked by {@link admitPresentation} before the article's placement (F413). * @param input - The requested change. * @param state - The current presentation state. - * @returns `true` for a Restore of the Study workspace, or a selection of it while its layout is unconfirmed, while a test replay holds the composition and both Study panes stand. + * @returns `true` for a Restore of the Study workspace, or a selection of it while its layout is unconfirmed, while a test replay holds the composition and its panes stand: the Application pane, and the Study pane outside the summary composition. * @example this.isTestReplayRestore(input, this.presentationState()) // => true for Restore beside a selected test replay */ private isTestReplayRestore( @@ -2283,7 +2291,8 @@ export class StudyWorkspace { input.mode === 'study-workspace' && input.mode === state.mode && this.testReplayHoldsComposition() && - Boolean(this.app && this.contextPanel) + // The summary composition closes the Study pane: the Application and the slot are the panes a test replay's Restore arranges there (Cycle 0051, DS-A). + Boolean(this.app && (this.contextPanel || this.summaryComposition)) ) } @@ -5642,8 +5651,8 @@ export class StudyWorkspace { input.mode === this.presentationMode && this.usesPreparedApplicationLayout()) ) { - // A recording's source is shown by the recording's own load, which the open only began: the selection waits for it instead of refusing while it is opening. - if (this.summaryComposition) { + // A recording's source is shown by the recording's own load, which the open only began: the selection waits for it instead of refusing while it is opening. A test replay's summary composition has no recording to wait for. + if (this.summaryComposition && this.recordingActive) { await this.settleOwnedPlayback(signal) assertCurrent() } @@ -8151,7 +8160,9 @@ export class StudyWorkspace { this.isLiveCompositionRequested() || (currentRecord && !currentRecord.stopped), ) - const summaryComposition = this.recordingActive + // A browser recording and a selected or opening test replay share the summary composition (Cycle 0051, DS-A): the Learning summary stands in the chat's place and the Study pane closes. + const summaryComposition = + this.recordingActive || this.testReplayHoldsComposition() const compositionChanged = liveComposition !== this.liveComposition || summaryComposition !== this.summaryComposition @@ -10842,6 +10853,7 @@ export class StudyWorkspace { !this.closing && !this.liveComposition && !this.recordingActive && + !this.testReplayHoldsComposition() && !this.sourceEntryPending && !this.presentationTransition && this.repositoryNavigation === undefined && @@ -11521,8 +11533,8 @@ export class StudyWorkspace { } /** - * Says whether a test replay is selected, opening, or about to open from the host, so that {@link open} arranges the Study composition and not the Live one; read by {@link open}. - * @returns `true` while a test replay needs the Study pane. + * Says whether a test replay is selected, opening, or about to open from the host, so that {@link open} arranges the summary composition and not the Live one (Cycle 0051, DS-A); read by {@link open} and by the message and refresh rules that keep a test replay's summary free of live-run content. + * @returns `true` while a test replay holds the summary composition. * @example if (this.testReplayHoldsComposition()) liveComposition = false */ private testReplayHoldsComposition(): boolean { @@ -11533,6 +11545,23 @@ export class StudyWorkspace { ) } + /** + * Names the replay the summary composition shows, so an entry that awaits the editor can tell that the learner chose another replay meanwhile; read by {@link showLearningSummary} before and after each await. + * @returns + * - `browser:` while a browser recording is shown + * - `test:` while a test replay is selected, or `test-opening:` while one is opening + * - `none` otherwise + * @example this.shownReplayIdentity() // => 'test:i1_r2' after a test replay opened from Saved studies + */ + private shownReplayIdentity(): string { + if (this.recordingActive) return `browser:${this.selection.studyId ?? ''}` + if (this.selectedNodeHistory) + return `test:${this.selectedNodeHistory.header.studyId}` + if (this.pendingNodeHistoryStudyId !== undefined) + return `test-opening:${this.pendingNodeHistoryStudyId}` + return 'none' + } + /** * Answers an Open replay that a learner's preview tab refused with a modal warning that names the file, in both hosts (a toast is cut to one line in Cursor), and carries out the learner's choice; called by {@link openLearningReplay} and {@link openNodeHistoryFromHost} before they tell a failure (F158, D33). * Keep Tab Open pins the tab (it stops being a preview) and Close Tab closes it; each is followed by a retry of the open. @@ -11647,68 +11676,115 @@ export class StudyWorkspace { } /** - * Opens a test replay for the status item's Open replay and Open retained history: the Study pane is opened first when it is closed, then the shared open runs ({@link openNodeHistory}); called by {@link openLearningReplay}. - * During a browser replay the learner is told that one replay shows at a time and may close it first (DS10). A failed open raises the load-failure notification with Show details and writes one Output › Codebook entry; the Study pane stays open on Saved studies (X4). + * Opens a test replay for the learner's two entries, the status item's Open replay (and Open retained history) and a Saved studies choice: the summary composition is entered first (Cycle 0051, DS-A), then the shared open runs ({@link openNodeHistory}); called by {@link openLearningReplay} and by the reader's `node-history` message. + * During a browser replay the learner is told that one replay shows at a time and may close it first (DS10). A failed open raises the load-failure notification with Show details and writes one Output › Codebook entry; a reader the open left in the summary composition with no replay shown goes back to the Study composition, so the Study pane stands open on Saved studies (X4, DS-G). * @param studyId - The test replay's study id. + * @param revision - The reader revision a Saved studies choice belongs to, which a newer revision supersedes; omitted for the status item, whose open takes the revision current after the summary composition was entered (and a browser replay closed). * @returns Once the replay is shown, the learner declined to close a browser replay, or the failure was told. * @example await workspace.openNodeHistoryFromHost(studyId) */ - async openNodeHistoryFromHost(studyId: string): Promise { - if (this.recordingActive) { - const refusal = describeNodeLearningSave({ kind: 'open-refused' }) - const action = await showPlainNotice( - vscode.window, - 'warning', - refusal.text, - ...(refusal.actions ?? []), - ) - if (action !== NODE_LEARNING_SAVE_ACTION.closeBrowserReplay) return - await this.returnCurrentSelection('learner') - if (this.recordingActive) return - } + async openNodeHistoryFromHost( + studyId: string, + revision?: number, + ): Promise { + const entry = ++this.nodeHistoryEntryRequest + if (!(await this.closeBrowserReplayForTestReplay())) return try { - await this.enterStudyCompositionForReplay() + await this.enterSummaryCompositionForReplay() } catch (error) { + // A newer entry answers for the learner now. + if (entry !== this.nodeHistoryEntryRequest) return // A refused entry is told like a failed replay open: one Output › Codebook entry and the notification, never a bare command failure (F382, F384). - const cause = sanitizeCause( + await this.leaveEmptySummaryComposition() + await this.tellTestReplayOpenFailure( + studyId, error, - this.service?.causeRoots ?? { storage: [] }, + sanitizeCause(error, this.service?.causeRoots ?? { storage: [] }), ) - await this.tellTestReplayOpenFailure(studyId, error, cause) return } - const outcome = await this.openNodeHistory(studyId, this.revision) + // A newer entry made while this one entered the composition is the learner's latest choice (EN19). + if (entry !== this.nodeHistoryEntryRequest) return + const outcome = await this.openNodeHistory( + studyId, + revision ?? this.revision, + ) if (outcome.status !== 'failed') { // The learner's own replay open, after the replay is shown: the summary moves beside the chat once per profile (F209). await this.moveSummaryBesideChatOnce() return } - const roots = this.service?.causeRoots ?? { storage: [] } - const cause = sanitizeCause(outcome.error, roots) - // A learner's preview tab that blocks the open is answered with a choice and a retry (F158). + if ( + (await this.answerFailedTestReplayOpen(studyId, outcome.error)) === + 'retry' + ) + return this.openNodeHistoryFromHost(studyId) + } + + /** + * Answers a test replay entry made during a browser replay: one replay shows at a time, so the learner is told so and may close the browser replay first (DS10); called by {@link openNodeHistoryFromHost}. + * @returns `true` when no browser replay is open, or the learner closed it; `false` when it stays open. + * @example if (!(await this.closeBrowserReplayForTestReplay())) return + */ + private async closeBrowserReplayForTestReplay(): Promise { + if (!this.recordingActive) return true + const refusal = describeNodeLearningSave({ kind: 'open-refused' }) + const action = await showPlainNotice( + vscode.window, + 'warning', + refusal.text, + ...(refusal.actions ?? []), + ) + if (action !== NODE_LEARNING_SAVE_ACTION.closeBrowserReplay) return false + await this.returnCurrentSelection('learner') + return !this.recordingActive + } + + /** + * Answers a test replay open that failed: a learner's preview tab that blocked it gets its choice and a retry (F158), in the composition the open met it in; otherwise the empty summary composition is left and the failure is told once (DS-G), or only recorded when Keep Tab Open has told it already (F407). Called by {@link openNodeHistoryFromHost}. + * @param studyId - The test replay's study id. + * @param error - What the open threw. + * @returns `retry` when the learner chose a way out of a blocking preview; `told` once the failure is answered. + * @example if ((await this.answerFailedTestReplayOpen(studyId, outcome.error)) === 'retry') return this.openNodeHistoryFromHost(studyId) + */ + private async answerFailedTestReplayOpen( + studyId: string, + error: unknown, + ): Promise<'retry' | 'told'> { + const cause = sanitizeCause( + error, + this.service?.causeRoots ?? { storage: [] }, + ) const answer = await this.resolveForeignPreviewRefusal(cause) - if (answer === 'retry') return this.openNodeHistoryFromHost(studyId) + if (answer === 'retry') return 'retry' + // A failed open shows no replay in the summary composition: the Study composition comes back, with the failure on its line (DS-G). + await this.leaveEmptySummaryComposition() // The keep-open refusal has told the learner already, so no second notification follows; the refusal is still recorded once in Output › Codebook, where other open refusals are, so a later take can tell why the replay did not open (F407). - if (answer === 'told') { + if (answer === 'told') this.diagnostics?.log({ surface: 'open:test-replay', studyId, profile: NODE_TEST_PROFILE, cause, }) - return - } - await this.tellTestReplayOpenFailure(studyId, outcome.error, cause) + else await this.tellTestReplayOpenFailure(studyId, error, cause) + return 'told' } /** - * Opens the Study composition for a test replay that is about to open: a closed reader opens, the Live composition (which has no Study pane) gives way to it (F208), and a reader that stands in the Study composition without its Study pane gets the pane again; called by {@link openNodeHistoryFromHost}. - * An earlier entry that failed after it left the Live composition leaves exactly that state (open, not live, no Study pane); skipping the open then showed the Application, the slot and the debugger with no transport (F384). + * Opens the summary composition for a test replay that is about to open (Cycle 0051, DS-A): a closed reader opens, and the Live composition and the Study composition give way to it, so the Study pane closes and the Learning summary takes the chat's place; called by {@link openNodeHistoryFromHost}. + * Only a reader that already stands in the summary composition for a test replay (another test replay was shown) is left as it is; an earlier entry that failed after it left the Live composition is opened again, so the Application, the slot and the debugger never show without a transport (F384). * @throws What {@link open} throws; the caller tells it. - * @example await this.enterStudyCompositionForReplay() + * @example await this.enterSummaryCompositionForReplay() */ - private async enterStudyCompositionForReplay(): Promise { - if (this.opened && !this.liveComposition && this.contextPanel) return + private async enterSummaryCompositionForReplay(): Promise { + if ( + this.opened && + this.summaryComposition && + !this.liveComposition && + !this.recordingActive + ) + return this.hostOpeningTestReplay = true try { await this.open() @@ -11717,6 +11793,28 @@ export class StudyWorkspace { } } + /** + * Leaves a summary composition that shows no replay after a failed test replay open: the reader opens again in the composition its selection asks for, so the Study pane comes back (DS-G); called by {@link openNodeHistoryFromHost} before it tells the failure. + * A test replay that was shown before the failed open stays selected and keeps the composition: the last committed moment stays, never shown as the new one. + * @returns Once the reader stands in a composition that matches its selection, or at once when nothing is to change. + * @example await this.leaveEmptySummaryComposition() + */ + private async leaveEmptySummaryComposition(): Promise { + if ( + !this.opened || + this.closing || + !this.summaryComposition || + this.recordingActive || + this.testReplayHoldsComposition() + ) + return + try { + await this.open() + } catch (error) { + this.report('control', error) + } + } + /** * Tells the learner that a test replay did not open: one Output › Codebook entry and the load-failure notification, whose Show details button opens the channel; called by {@link openNodeHistoryFromHost}. * @param studyId - The test replay's study id. @@ -11776,6 +11874,8 @@ export class StudyWorkspace { > { const request = ++this.historyRequest this.studyChoices++ + // A test replay that plays stops before another one opens: a failed open leaves it on its last committed moment with no timer running, and a shown replay never moves behind the opening (DS-A, DS-G). + this.nodeHistoryPlaybackController?.pause() try { // The opening marker is set inside the try, so its finally ends it even when the first publish throws. this.pendingNodeHistory = request @@ -13229,9 +13329,20 @@ export class StudyWorkspace { (entry) => entry.type === 'stop' && !nodeStopSourceMatches(entry), ) .map((entry) => entry.sequence), + // The host's sentence for a refused or failed test replay move, read in the shared transport's status line with the summary hidden too (DS-G). + nodeHistoryRefusal: this.errors + .get('node-history-control') + ?.replace(/^Error: /, ''), // The time readout and the slider follow the moment being selected, as the Study position does, until the commit (F386). nodeHistoryPlayback: this.nodeHistoryPlaybackController && { ...this.nodeHistoryPlaybackController.state(), + // The history's first and last events, from all its entries: the event list reaches the page one page at a time, and a seek to a time would land on the last of several events at that time (DS-C). + ...(selectedNodeHistory && { + firstSequence: + this.shownNodeHistory(selectedNodeHistory).entries[0]?.sequence, + lastSequence: + this.shownNodeHistory(selectedNodeHistory).entries.at(-1)?.sequence, + }), ...(pendingNodeHistoryEvent && { sequence: pendingNodeHistoryEvent.sequence, positionMs: pendingNodeHistoryEvent.elapsedMs, @@ -13355,7 +13466,8 @@ export class StudyWorkspace { record?.historyExcluded === 'refused-at-admission' && record.error && record.id !== this.answeredRunRefusalId && - !this.recordingActive + !this.recordingActive && + !this.testReplayHoldsComposition() ? `The last run did not start: ${record.error.message}` : undefined, unstoppedStudyId: this.unstoppedStudyId(), @@ -13423,6 +13535,16 @@ export class StudyWorkspace { this.publish() } + /** + * Names the replay family the summary composition shows, which the Study views read for their page (Cycle 0051, DS-A); the one place the family is decided, read by {@link publish}. + * @returns `browser` while a browser recording is active, `test` for a selected or opening test replay, `undefined` outside the summary composition. + * @example this.shownReplayFamily() // => 'test' while a test replay holds the composition + */ + private shownReplayFamily(): 'browser' | 'test' | undefined { + if (!this.summaryComposition) return undefined + return this.recordingActive ? 'browser' : 'test' + } + /** Publishes a matching projection with stable DOM controls, excluding images from the context channel. @example this.publish() */ private publish(): void { this.publishLearningSave() @@ -13441,8 +13563,9 @@ export class StudyWorkspace { // The recorded test output is the Application pane's alone; the Study views never read it. nodeHistoryApplication: undefined, playbackPanelVisible: Boolean( - this.recordingActive && this.moments?.visible, + this.summaryComposition && this.moments?.visible, ), + replayFamily: this.shownReplayFamily(), model: { ...message.model, image: undefined, @@ -13460,7 +13583,7 @@ export class StudyWorkspace { if (this.summaryComposition) void this.summaryView?.webview.postMessage(compact) if (this.moments) - this.moments.title = this.recordingActive + this.moments.title = this.summaryComposition ? 'Learning replay' : 'Observed moments' void this.moments?.webview.postMessage(compact) @@ -19077,7 +19200,7 @@ export class StudyWorkspace { preserveFocus = false, keepsViewsInFront = false, ): Promise { - if (!this.recordingActive || !this.summaryComposition || this.closing) + if (!this.summaryComposition || this.closing) throw new StudyError( 'EVIDENCE_UNAVAILABLE', 'Open saved learning before showing its summary.', @@ -19088,7 +19211,7 @@ export class StudyWorkspace { 'Focus the Codebook work window before reading the learning summary.', ) const lifetime = this.lifetime, - studyId = this.selection.studyId + replay = this.shownReplayIdentity() let lostFocus = false // A window round trip cannot renew the earlier focus request. const focusSubscription = vscode.window.onDidChangeWindowState((state) => { @@ -19102,11 +19225,7 @@ export class StudyWorkspace { 'PRESENTATION_UNAVAILABLE', 'The Codebook work window lost focus while opening its learning summary.', ) - if ( - !this.recordingActive || - studyId !== this.selection.studyId || - !this.summaryComposition - ) + if (replay !== this.shownReplayIdentity() || !this.summaryComposition) throw new StudyError( 'STALE_PRESENTATION', 'The selected learning changed while opening its summary.', @@ -19605,7 +19724,7 @@ export class StudyWorkspace { if (this.isSummaryViewInState(view, false)) this.releaseHiddenSummaryFocus() }) - if (this.recordingActive && this.summaryComposition) { + if (this.summaryComposition) { this.summaryDocumentActive = true view.webview.html = studyHtml('context') this.publish() @@ -22716,7 +22835,7 @@ export class StudyWorkspace { ? this.summaryView : this.contextPanel) || (owner === this.summaryView && - (!this.opened || !this.recordingActive || this.closing))) + (!this.opened || !this.summaryComposition || this.closing))) ) return const parsed = messageSchema.safeParse(value) @@ -22747,7 +22866,7 @@ export class StudyWorkspace { ? this.summaryView : this.contextPanel) || (owner === this.summaryView && - (!this.opened || !this.recordingActive || this.closing))) + (!this.opened || !this.summaryComposition || this.closing))) ) return if (message.type === 'ready') { @@ -22926,7 +23045,8 @@ export class StudyWorkspace { if (message.type === 'node-history') { if (this.recordingActive) throw new Error('Close browser replay before opening a test replay.') - await this.openNodeHistory(message.studyId, message.revision) + // A Saved studies choice is the learner's entry: it enters the summary composition and tells a failure as Open replay does (DS-A, DS-G). + await this.openNodeHistoryFromHost(message.studyId, message.revision) } if (message.type === 'node-history-understanding') { if (this.recordingActive) @@ -23130,6 +23250,9 @@ export class StudyWorkspace { if (!this.isLatestHistoryRequest(request)) return await this.showSavedStudy(archive) this.publishChoiceOfClosedReader(request) + // A browser study chosen from a test replay's summary leaves the summary composition for the Study composition (DS-A). + if (this.isLatestHistoryRequest(request)) + await this.leaveEmptySummaryComposition() } /** @@ -24578,13 +24701,14 @@ export class StudyWorkspace { return } } catch (error) { - if (request !== this.selectedNodeHistoryRequest) throw error + // A newer selection owns the surfaces and the status line: a superseded move reports no refusal (DS-F). + if (request !== this.selectedNodeHistoryRequest) return // The Study position stays on the moment committed before, so the slot and the debugger go back to it: no surface shows the moment whose presentation failed (F199). - // A failed return is told on the slot's row, and the presentation's own failure still reaches the caller (F368). + // A failed return is told on the slot's row, and the presentation's own failure still reaches the caller (F368), with the open path's recovery clause (DS-G). await this.returnToCommittedNodeMoment(history, request).catch( (returnError: unknown) => this.report('history-source', returnError), ) - throw error + throw new Error(this.describeNodeHistoryMoveFailure(error)) } } // A caller that withdrew meanwhile (Pause, dispose) commits nothing, also after a slot that failed to open, which skips the debugger's own check. @@ -24749,6 +24873,20 @@ export class StudyWorkspace { }, 0) } + /** + * Words a replay debugger that did not start on a move of a test replay as the open path words its own failure, adding that the last moment is still shown (DS-G), so the transport's status line names the way back with the summary hidden too; called by {@link selectNodeHistoryEvent} when the presentation of a move fails. + * @param error - The presentation's failure; its cause is sanitized as on the Study pane's lines. + * @returns The sentence the node-history control line shows. + * @example this.describeNodeHistoryMoveFailure(new Error('Workspace trust was declined.')) // => 'The replay debugger could not start: Workspace trust was declined. The last moment is still shown. Select this stop or use Inspect in native debugger to restart it.' + */ + private describeNodeHistoryMoveFailure(error: unknown): string { + const cause = sanitizeCause( + error, + this.service?.causeRoots ?? { storage: [] }, + ).replace(/\.$/, '') + return `The replay debugger could not start: ${cause}. The last moment is still shown. Select this stop or use Inspect in native debugger to restart it.` + } + /** * Presents a moment in the replay debugger as {@link presentNodeHistoryDebugger} does; when the debugger cannot start and the selection is the open's landing, the open still completes on that moment and the failure goes to the `replay-debugger` row with its restart hint (D13); called by {@link selectNodeHistoryEvent}. * A declined workspace trust or the capture timeout used to fail the whole open while the pane already showed the replay, and every retry of the open failed the same way. Now the pane stays open and Retry (or Select this stop, or Inspect) presents the debugger again. diff --git a/extension/src/tests/studyWorkspaceEntry.test.ts b/extension/src/tests/studyWorkspaceEntry.test.ts index 27a9eae30..0bdbc8b4f 100644 --- a/extension/src/tests/studyWorkspaceEntry.test.ts +++ b/extension/src/tests/studyWorkspaceEntry.test.ts @@ -2975,8 +2975,8 @@ test("a live entry from the three-pane Study layout stays confirmed when the con } }) -// Value: protects=Open replay during a live run: the learner reaches the test replay's Study pane (its transport, position and summary) instead of a replay shown only in the Application, the slot and the debugger; fails_when=the live composition keeps the reader without a Study pane while a test replay is opening or selected; why_new=Cycle 0050 F208 (N49 Cursor part 7: Open replay after a live run opened no Study pane); seam=workspaceHarness with the controlled runtime, the reader's open, returnToCurrent and openLearningReplay, panelColumn -test('Open replay during a live run creates the Study pane and selects the test replay in it', async () => { +// Value: protects=Open replay during a live run: the learner reaches the test replay's transport, position and summary in the summary composition (the Learning summary in the chat's place) instead of a replay shown only in the Application, the slot and the debugger; fails_when=the live composition is kept while a test replay is opening or selected, or the Study pane comes back for it; why_new=Cycle 0050 F208 (N49 Cursor part 7), changed in Cycle 0051 (DS-A, moves with D8, F123): a test replay no longer lives in the Study pane; seam=workspaceHarness with the controlled runtime, the reader's open, returnToCurrent and openLearningReplay, panelColumn, the harness's summary view +test('Open replay during a live run enters the summary composition with the test replay selected', async () => { // Arrange: the learner returned the Study to the current run, and a run started, so the reader stands in the Live composition without a Study pane. const harness = await workspaceHarness(false) await harness.reader.attach(harness.service) @@ -3010,7 +3010,9 @@ test('Open replay during a live run creates the Study pane and selects the test await harness.reader.openLearningReplay(history.studyId) // Assert - assert.notEqual(harness.panelColumn('codebook.studyContext'), undefined) + assert.equal(harness.panelColumn('codebook.studyContext'), undefined) + assert.equal(harness.summary.visible, true) + assert.equal(harness.postedMessages().at(-1)?.replayFamily, 'test') assert.equal( harness.reader.inspect().view?.nodeHistory?.header.studyId, history.studyId, @@ -3064,8 +3066,8 @@ test('Open Study Workspace after the Application pane left its allocated group o } }) -// Value: protects=Focus Explanation for a test replay whose Study pane is gone: the command opens the pane again so the transport, position and summary are reachable, instead of changing nothing visible; fails_when=the focus action only reveals an existing pane; why_new=Cycle 0050 F208 (Focus Explanation changed nothing visible after Open replay lost its Study pane); seam=workspaceHarness, openLearningReplay, tabGroups.close of the Study pane's tab, the reader's focus-context action, panelColumn -test('Focus Explanation opens the Study pane again for a selected test replay whose pane was closed', async () => { +// Value: protects=Focus Explanation for a test replay whose Learning summary is hidden: the command shows the summary again so the position and summary are reachable, instead of changing nothing visible; fails_when=the focus action changes nothing, or opens the Study pane the summary composition closes; why_new=Cycle 0050 F208, changed in Cycle 0051 (DS-A and DS-E, moves with D8, F123): a test replay's content is the Learning summary's; seam=workspaceHarness, openLearningReplay, hideSummary, the reader's focus-context action, panelColumn +test('Focus Explanation shows the hidden Learning summary of a selected test replay and keeps the Study pane closed', async () => { // Arrange const harness = await workspaceHarness() try { @@ -3074,23 +3076,14 @@ test('Focus Explanation opens the Study pane again for a selected test replay wh ) await harness.reader.action('retry') await harness.reader.openLearningReplay(history.studyId) - await harness.editor.window.tabGroups.close( - harness.groups - .flatMap((group) => group.tabs) - .filter( - (tab) => - tab.input instanceof harness.editor.TabInputWebview && - tab.input.viewType.includes('codebook.studyContext'), - ), - true, - ) - assert.equal(harness.panelColumn('codebook.studyContext'), undefined) + harness.hideSummary() // Act await harness.reader.action('focus-context') // Assert - assert.notEqual(harness.panelColumn('codebook.studyContext'), undefined) + assert.equal(harness.summary.visible, true) + assert.equal(harness.panelColumn('codebook.studyContext'), undefined) } finally { await harness.close() } diff --git a/extension/src/tests/studyWorkspaceEntryObstacles.test.ts b/extension/src/tests/studyWorkspaceEntryObstacles.test.ts index a4f5a2f72..d5897d4d8 100644 --- a/extension/src/tests/studyWorkspaceEntryObstacles.test.ts +++ b/extension/src/tests/studyWorkspaceEntryObstacles.test.ts @@ -430,13 +430,14 @@ test('a test replay Open replay refused for a non-text preview that cannot be ke harness.focusMutableDraft('learner-notes.ts', 1) harness.addNonTextPreview('diagram.png', 1, false) harness.answerWarningDialogs(async () => 'Keep Tab Open') + // The summary composition the open enters first puts the Application pane in group 1, so the learner's group with the preview is group 2 when the open meets it (Cycle 0051, DS-A). context.mock.method( harness.reader as unknown as { openNodeHistory: () => Promise }, 'openNodeHistory', async () => ({ status: 'failed', error: new Error( - 'A destination group contains a foreign preview: "diagram.png" in editor group 1. Close that tab, or keep it open (double-click its tab), then try again.', + 'A destination group contains a foreign preview: "diagram.png" in editor group 2. Close that tab, or keep it open (double-click its tab), then try again.', ), }), ) @@ -458,7 +459,7 @@ test('a test replay Open replay refused for a non-text preview that cannot be ke [ 'open:test-replay', 'a_b', - 'A destination group contains a foreign preview: "diagram.png" in editor group 1. Close that tab, or keep it open (double-click its tab), then try again.', + 'A destination group contains a foreign preview: "diagram.png" in editor group 2. Close that tab, or keep it open (double-click its tab), then try again.', ], ], ) diff --git a/extension/src/tests/studyWorkspaceEntryPlacement.test.ts b/extension/src/tests/studyWorkspaceEntryPlacement.test.ts index 858d0d48b..cb81c52a5 100644 --- a/extension/src/tests/studyWorkspaceEntryPlacement.test.ts +++ b/extension/src/tests/studyWorkspaceEntryPlacement.test.ts @@ -6,7 +6,7 @@ import type { CodebookDiagnostic } from '../utils/formatCodebookDiagnostic' import { readerSeams, workspaceHarness } from './studyWorkspaceHarness' import { startLiveTestRun } from './utils/startLiveTestRun' -// Value: protects=the first Open replay after a node-test run whose live Application pane closed at the run's end and left its empty, locked group at column 1 beside a learner's source group and a Welcome group (F382, VS Code 1.142.0 N50 round 1): the replay is shown with the Application at column 1 and the Study pane, nothing is written as a refused placement, and the learner's Welcome and source tabs stay; fails_when=the first Open replay refuses with the placement sentence, writes a placement-refused entry, shows no replay, or loses the learner's tabs; why_new=Cycle 0050 F382 (the native layout before the press: an empty locked group above the source group and a Welcome group to the right); seam=workspaceHarness with the fake owned Node run, the run's own Application preparation with the reader closed (prepareApplicationGroup), closeApplicationPaneOfEndedRun with refuseNextGroupClose (the group the editor keeps), the diagnostics log; no_claim=this passes on the code of 5868b54, so it does not show F382's cause: the native refusal is not reproduced by the fake editor +// Value: protects=the first Open replay after a node-test run whose live Application pane closed at the run's end and left its empty, locked group at column 1 beside a learner's source group and a Welcome group (F382, VS Code 1.142.0 N50 round 1): the replay is shown with the Application at column 1 in the summary composition (no Study pane since Cycle 0051, moves with D8, F123), nothing is written as a refused placement, and the learner's Welcome and source tabs stay; fails_when=the first Open replay refuses with the placement sentence, writes a placement-refused entry, shows no replay, or loses the learner's tabs; why_new=Cycle 0050 F382 (the native layout before the press: an empty locked group above the source group and a Welcome group to the right); seam=workspaceHarness with the fake owned Node run, the run's own Application preparation with the reader closed (prepareApplicationGroup), closeApplicationPaneOfEndedRun with refuseNextGroupClose (the group the editor keeps), the diagnostics log; no_claim=this passes on the code of 5868b54, so it does not show F382's cause: the native refusal is not reproduced by the fake editor test('the first Open replay after a node-test run that left its empty locked Application group shows the replay without a placement refusal', async () => { // Arrange: the run's Application stood in its own locked group above the source group; the run ended, the pane closed and the editor kept the emptied group. const harness = await workspaceHarness() @@ -46,15 +46,17 @@ test('the first Open replay after a node-test run that left its empty locked App harness.refuseNextGroupClose() await harness.reader['closeApplicationPaneOfEndedRun']() await harness.reader.saveLearningReplay(studyId) - const leftover = harness.groups.find((group) => group.viewColumn === 1)! // Act await harness.reader.openLearningReplay(studyId) - // Assert - assert.equal(leftover.tabs.length, 1) + // Assert: the summary composition (Cycle 0051, DS-A) has no Study pane; the leftover group stands empty no more. + assert.equal( + harness.groups.some((group) => group.tabs.length === 0), + false, + ) assert.equal(harness.panelColumn('codebook.scopedBrowser'), 1) - assert.equal(harness.panelColumn('codebook.studyContext'), 3) + assert.equal(harness.panelColumn('codebook.studyContext'), undefined) assert.equal( harness.reader.inspect().view?.nodeHistory?.header.studyId, studyId, @@ -127,7 +129,8 @@ test('the first Open replay after a window reload retires the restored empty loc // Assert assert.equal(harness.groups.includes(leftover), false) assert.equal(harness.panelColumn('codebook.scopedBrowser'), 1) - assert.equal(harness.panelColumn('codebook.studyContext'), 3) + // The summary composition (Cycle 0051, DS-A) has no Study pane. + assert.equal(harness.panelColumn('codebook.studyContext'), undefined) assert.equal( harness.reader.inspect().view?.nodeHistory?.header.studyId, studyId, diff --git a/extension/src/tests/studyWorkspaceHarness.ts b/extension/src/tests/studyWorkspaceHarness.ts index dd089b63c..669ef1391 100644 --- a/extension/src/tests/studyWorkspaceHarness.ts +++ b/extension/src/tests/studyWorkspaceHarness.ts @@ -1063,7 +1063,8 @@ export async function workspaceHarness( { wait: Promise; entered(): void } | undefined let nextLayoutResponse: { wait: Promise; entered(): void } | undefined const commands: string[] = [] - let constrainStudyGrid = false + // The group count of the Study grid the fake editor settles at other sizes than given (see `constrainStudyGrid`); `undefined`: every layout is kept as given. + let constrainedStudyGridGroups: 2 | 3 | undefined /** The editor area in which the fake editor answers a layout given in proportions as pixels; see `answerProportionsAsPixels`. `undefined`: such a layout is kept as it was given. */ let proportionsAnsweredInPx: { width: number; height: number } | undefined let hideGridWhileMaximized = false @@ -4375,8 +4376,11 @@ export async function workspaceHarness( layout = proportionsAsPixels(layout, proportionsAnsweredInPx) // A layout given to the main grid brings a hidden editor area back; the given layout replaces the sizes of that return. returnHiddenEditorArea() - settledLayout = - constrainStudyGrid && nativeGroupCount(layout) === 3 + const constrained = + nativeGroupCount(layout) === constrainedStudyGridGroups + settledLayout = !constrained + ? structuredClone(layout) + : constrainedStudyGridGroups === 3 ? { orientation: 0, groups: [ @@ -4384,7 +4388,8 @@ export async function workspaceHarness( { size: 220 }, ], } - : structuredClone(layout) + : // The summary composition's two groups, the Application above the recorded source, settle at sizes the editor chose. + { orientation: 1, groups: [{ size: 100 }, { size: 40 }] } editorLayout = transientLayout ? { orientation: 0, @@ -5191,6 +5196,9 @@ export async function workspaceHarness( repositoryWikiReaders.push(wiki) return wiki } + /** Says whether the reader last published a replay's summary composition, in which the Study pane is closed and the Learning summary view shows the study's content (Cycle 0051, DS-A). @example isSummaryCompositionShown() // => true while a test replay is selected */ + const isSummaryCompositionShown = (): boolean => + Boolean(postedMessages.at(-1)?.replayFamily) return { ...harness, createRepositoryWikiReader, @@ -5340,13 +5348,20 @@ export async function workspaceHarness( viewFocusCommands, articleOpens, articleReturns, - receiveContext: (message: unknown): void => - receivers.get('codebook.studyContext')!(message), + /** Delivers a message as the pane that shows the study's content posts it: the Study pane, or the Learning summary view while a replay holds the summary composition and the Study pane is closed (Cycle 0051, DS-A). @example harness.receiveContext({ type: 'node-history-step', studyId, direction: 'next', unit: 'stop' }) */ + receiveContext: (message: unknown): void => { + if (isSummaryCompositionShown()) { + for (const listener of summaryListeners) listener(message) + } else receivers.get('codebook.studyContext')!(message) + }, /** Delivers a message to the host as the Application pane's webview posts it (its test replay document's reports). @example harness.receiveApplication({ type: 'node-history-output-ready', history, generation: 1, follow: true }) */ receiveApplication: (message: unknown): void => receivers.get('codebook.scopedBrowser')!(message), receive: (message: unknown): void => { - if (reader.inspect().view?.contentTab === 'recording') { + if ( + reader.inspect().view?.contentTab === 'recording' || + isSummaryCompositionShown() + ) { for (const listener of summaryListeners) listener(message) } else receivers.get('codebook.studyContext')!(message) }, @@ -6781,8 +6796,9 @@ export async function workspaceHarness( activeTabGroup.activeTab = activeTabGroup.tabs[0] changedGroups({ opened: [...groups], closed: previous, changed: [] }) }, - constrainStudyGrid(): void { - constrainStudyGrid = true + /** Makes the fake editor settle a Study grid of that many groups at sizes of its own, as an editor with a minimum group size does: three for the Study composition (two columns), two for the summary composition (Cycle 0051). @example harness.constrainStudyGrid(2) */ + constrainStudyGrid(groupCount: 2 | 3 = 3): void { + constrainedStudyGridGroups = groupCount }, /** * Makes `workbench.action.newGroupAbove` leave the text editor reported before it as the active text editor until a pane's tab stands in front of the new empty group, as Cursor 3.23.12 reported it in a Repository Wiki recording visit (C61 c61-cu-695c: the first-group focus made the Wiki's cited copy the active text editor, the insertion and the lock of the Application's group left it so, and the active text editor was reported as none only 43 ms after the lock, while the parked Application pane moved into the new group; Cycle 0046 row 710). Off by default: the other tests keep the fake editor's report of no active text editor at the insertion. diff --git a/extension/src/tests/studyWorkspaceTestReplay.test.ts b/extension/src/tests/studyWorkspaceTestReplay.test.ts index 1af0e3811..b1c30566f 100644 --- a/extension/src/tests/studyWorkspaceTestReplay.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplay.test.ts @@ -1496,9 +1496,9 @@ test('a test replay that cannot be read names a title with a U+202E in its failu } }) -// Value: protects=Open replay after a live node-test run whose reader stood in the Live composition and whose Study entry failed once: the failure is told in Output › Codebook and a notification, not as a bare command error, and the learner's next Open replay still reaches a Study pane with the replay in it; fails_when=the composition entry's failure escapes the command with no Output entry, or the failed entry leaves the reader open, not live and without a Study pane so the retry skips the entry and shows the Application, the slot and the debugger with no transport (F384, F382 closure); why_new=Cycle 0050 F384 and F382 (N50, the first Open replay after a live run); seam=workspaceHarness with the fake owned Node run, failNextPanelCreation of the Study pane, the diagnostics log, the reader's open, continue and stop; no_claim=which native change F382's guard saw (the cause of that refusal is not settled in the harness) -test('a failed Study entry of Open replay after a live node-test run is told in Output and the next Open replay shows the Study pane', async () => { - // Arrange: the reader stands in the Live composition for the run, which then completes and stops; the Study pane's creation fails once. +// Value: protects=Open replay after a live node-test run whose reader stood in the Live composition and whose Study entry failed once: the failure is told in Output › Codebook and a notification, not as a bare command error, and the learner's next Open replay still reaches the summary composition with the replay in it (a Study pane before Cycle 0051; moves with D8, F123); fails_when=the composition entry's failure escapes the command with no Output entry, or the failed entry leaves the reader open, not live and outside the composition so the retry skips the entry and shows the Application, the slot and the debugger with no transport (F384, F382 closure); why_new=Cycle 0050 F384 and F382 (N50, the first Open replay after a live run); seam=workspaceHarness with the fake owned Node run, failNextCommand of the Learning summary's focus command, the diagnostics log, the reader's open, continue and stop; no_claim=which native change F382's guard saw (the cause of that refusal is not settled in the harness) +test('a failed summary-composition entry of Open replay after a live node-test run is told in Output and the next Open replay shows the composition', async () => { + // Arrange: the reader stands in the Live composition for the run, which then completes and stops; the Learning summary's focus command fails once (the summary composition creates no Study pane and reuses the run's Application pane, Cycle 0051). const harness = await workspaceHarness() const logged: CodebookDiagnostic[] = [] harness.reader.diagnostics = { @@ -1529,7 +1529,7 @@ test('a failed Study entry of Open replay after a live node-test run is told in }) await readerSeams(harness).refresh() await harness.reader.saveLearningReplay(studyId) - harness.failNextPanelCreation('codebook.studyContext') + harness.failNextCommand('codebook.learningSummary.focus') // Act const firstOpen = await harness.reader.openLearningReplay(studyId).then( @@ -1542,10 +1542,12 @@ test('a failed Study entry of Open replay after a live node-test run is told in assert.equal(firstOpen, 'returned') assert.deepEqual( logged.map((entry) => [entry.surface, entry.studyId, entry.cause]), - [['open:test-replay', studyId, 'Injected native panel creation failure']], + [['open:test-replay', studyId, 'Native layout command failed']], ) assert.equal(harness.errorMessages.length, 1) - assert.notEqual(harness.panelColumn('codebook.studyContext'), undefined) + assert.equal(harness.panelColumn('codebook.studyContext'), undefined) + assert.notEqual(harness.panelColumn('codebook.scopedBrowser'), undefined) + assert.equal(harness.summary.visible, true) assert.equal( harness.reader.inspect().view?.nodeHistory?.header.studyId, studyId, @@ -1612,7 +1614,9 @@ test("Open replay that arranges the panes after an agent's refused Study workspa // Assert assert.match(refusal, /did not report the keyboard focus within 5 s/) - assert.notEqual(harness.panelColumn('codebook.studyContext'), undefined) + // The summary composition (Cycle 0051, DS-A): the Learning summary stands in the Study pane's place. + assert.equal(harness.panelColumn('codebook.studyContext'), undefined) + assert.equal(harness.summary.visible, true) // No refusal of the agent's request and no captured-source reason of the article is shown beside a test replay (F389, F390). assert.doesNotMatch( harness.reader.presentationState().reason ?? '', @@ -1625,8 +1629,8 @@ test("Open replay that arranges the panes after an agent's refused Study workspa } }) -// Value: protects=the first Open replay after a node-test run beside Cursor's Agent, which Cursor refuses with the unreported-focus sentence after five seconds: the refusal is told in Output › Codebook and in a notification instead of failing the command bare, and after the learner clicks into a file the next Open replay opens the Study pane with the replay; fails_when=the refusal escapes openLearningReplay with no Output entry, or the retry shows the replay without a Study pane; why_new=Cycle 0050 F384 (N50 Cursor 3.26.16); seam=workspaceHarness with the fake owned Node run, addSeparateEditorPart and leavePaneFocusUnreportedBesideChat (Cursor's chat part), the fake clock, focusMutableDraft as the learner's click; no_claim=Cursor's own focus reports -test('Open replay refused beside the Cursor Agent is told in Output and the retry after a click into a file shows the Study pane', async (context) => { +// Value: protects=the first Open replay after a node-test run beside Cursor's Agent, which Cursor refuses with the unreported-focus sentence after five seconds: the refusal is told in Output › Codebook and in a notification instead of failing the command bare, and after the learner clicks into a file the next Open replay opens the summary composition with the replay (the Study pane before Cycle 0051; moves with D8, F123); fails_when=the refusal escapes openLearningReplay with no Output entry, or the retry shows the replay without the Learning summary; why_new=Cycle 0050 F384 (N50 Cursor 3.26.16); seam=workspaceHarness with the fake owned Node run, addSeparateEditorPart and leavePaneFocusUnreportedBesideChat (Cursor's chat part), the fake clock, focusMutableDraft as the learner's click; no_claim=Cursor's own focus reports +test('Open replay refused beside the Cursor Agent is told in Output and the retry after a click into a file shows the summary composition', async (context) => { // Arrange const harness = await workspaceHarness() const logged: CodebookDiagnostic[] = [] @@ -1687,7 +1691,10 @@ test('Open replay refused beside the Cursor Agent is told in Output and the retr openFailures[0]!.cause, /did not report the keyboard focus within 5 s/, ) - assert.notEqual(harness.panelColumn('codebook.studyContext'), undefined) + // The summary composition (Cycle 0051, DS-A): the Learning summary and the Application stand, the Study pane is closed. + assert.equal(harness.panelColumn('codebook.studyContext'), undefined) + assert.notEqual(harness.panelColumn('codebook.scopedBrowser'), undefined) + assert.equal(harness.summary.visible, true) assert.equal( harness.reader.inspect().view?.nodeHistory?.header.studyId, studyId, diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts new file mode 100644 index 000000000..da87ec1cf --- /dev/null +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -0,0 +1,1015 @@ +import assert from 'node:assert/strict' +import { appendFile } from 'node:fs/promises' +import { join } from 'node:path' +import { test } from 'node:test' + +import { + NODE_TEST_HISTORY_DIRECTORY, + STUDY_REMOVAL_REFUSALS, + SUMMARY_PLACEMENT_OFFER, +} from '../constants' +import { StudyError } from '../errors' +import { sha256 } from '../runtimeSupport/utils/sha256' +import { waitUntil } from '../runtimeSupport/utils/waitUntil' + +import { + recordedReaderHarness, + workspaceHarness, +} from './studyWorkspaceHarness' +import { nodeTestHistoryHeader } from './utils/nodeTestHistoryFixture' +import { READER_RESULT_BOUND_MS } from './utils/readerResultBound' +import { lastView, saveTwoStopNodeHistory } from './utils/twoStopNodeHistory' + +type Harness = Awaited> +type HistoryHeader = ReturnType +type HistoryWriter = Awaited< + ReturnType['start']> +> + +const STOP_SOURCE_TEXT = 'const nextTheme = "dark";\n' + +/** + * Appends the one stop a history's header declares, with its recorded value, so the history can be sealed with its result. + * @param writer - The history's writer. + * @param header - The history's header, whose configuration names the stop's source. + * @param elapsedMs - When the stop paused. + * @example await appendDeclaredStop(writer, header, 5) + */ +async function appendDeclaredStop( + writer: HistoryWriter, + header: HistoryHeader, + elapsedMs: number, +): Promise { + await writer.appendStop({ + index: 0, + runId: 'test-run', + sessionId: 'test-session', + threadId: 1, + elapsedMs, + worker: { pid: 1, processGroup: 1, workspace: '/private/test' }, + source: header.configuration.source, + frame: { id: 1, name: 'applyTheme', line: 1, column: 1 }, + variables: [{ name: 'nextTheme', value: 'dark', variablesReference: 0 }], + facts: [ + { path: 'nextTheme', mode: 'value', display: 'dark', type: 'string' }, + ], + debuggerSnapshot: { + version: 1, + frames: [ + { + name: 'applyTheme', + line: 1, + column: 1, + source: { + name: header.configuration.source.path, + text: STOP_SOURCE_TEXT, + hash: sha256(STOP_SOURCE_TEXT), + line: 1, + }, + scopes: [{ name: 'Local', variables: [0] }], + }, + ], + variables: [ + { name: 'nextTheme', value: 'dark', type: 'string', children: [] }, + ], + limitations: [], + }, + }) +} + +const SUMMARY_MOVE = { + viewIds: ['codebook.learningSummary'], + destinationId: 'workbench.panel.chat', +} + +/** + * Waits until the selected test replay shows one committed moment. + * @param harness - The workspace harness. + * @param sequence - The event the replay should show. + * @param description - What the wait is for, named in its timeout. + * @example await landsOn(harness, 2, 'the test replay lands on its first stop') + */ +async function landsOn( + harness: Harness, + sequence: number, + description: string, +): Promise { + await waitUntil( + () => + lastView(harness)?.nodeHistoryPosition?.sequence === sequence && + !lastView(harness)?.nodeHistoryPosition?.pending && + !lastView(harness)?.loading, + description, + READER_RESULT_BOUND_MS, + ) +} + +/** + * Opens the Study composition, then a saved two-stop test replay by the learner's Saved studies choice in the Study pane, and waits for its first stop. + * @param harness - The workspace harness. + * @returns The replay's header. + * @example const history = await openFromSavedStudies(harness) + */ +async function openFromSavedStudies(harness: Harness) { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + await harness.reader.open() + harness.receiveContext({ + type: 'node-history', + revision: lastView(harness)!.revision, + studyId: history.studyId, + }) + await landsOn(harness, 2, 'the Saved studies choice lands on the first stop') + return history +} + +/** + * Opens the Study composition, then a saved two-stop test replay by the status item's Open replay, and waits for its first stop. + * @param harness - The workspace harness. + * @returns The replay's header. + * @example const history = await openByOpenReplay(harness) + */ +async function openByOpenReplay(harness: Harness) { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + await harness.reader.open() + await harness.reader.openLearningReplay(history.studyId) + await landsOn(harness, 2, 'Open replay lands on the first stop') + return history +} + +/** + * Reads the parts of the composition a test replay's open decides. + * @param harness - The workspace harness. + * @returns Whether the Study pane, the Application pane and the Learning summary show, and the family the panes are told. + * @example compositionOf(harness) // => { studyPane: false, application: true, summary: true, family: 'test' } + */ +function compositionOf(harness: Harness) { + return { + studyPane: harness.panelColumn('codebook.studyContext') !== undefined, + application: harness.panelColumn('codebook.scopedBrowser') !== undefined, + summary: harness.summary.visible, + family: lastView(harness)?.replayFamily, + } +} + +for (const entry of ['Open replay', 'Saved studies'] as const) { + // Value: protects=the learner's entry into a test replay in VS Code gives region 2's composition: the Study pane closes, the Application pane and the recorded source stay, and the Learning summary shows in the chat's place, moved beside the chat once per profile; fails_when=the Study pane stays open with the replay in its Test replay section, the summary keeps its placeholder, or the move does not run; why_new=Cycle 0051 DS-A (AC1): a test replay entered the Study composition; seam=workspaceHarness (VS Code), openLearningReplay and the Study pane's node-history message, the fake vscode.moveViews; no_claim=the native placement, which N51 (a) checks + test(`${entry} in VS Code opens a test replay with the Study pane closed and the Learning summary beside the chat`, async () => { + // Arrange + const harness = await workspaceHarness() + try { + // Act + if (entry === 'Open replay') await openByOpenReplay(harness) + else await openFromSavedStudies(harness) + + // Assert + assert.deepEqual(compositionOf(harness), { + studyPane: false, + application: true, + summary: true, + family: 'test', + }) + assert.deepEqual(harness.moveViewsCalls(), [SUMMARY_MOVE]) + } finally { + await harness.close() + } + }) + + // Value: protects=the learner's entry into a test replay in Cursor gives the same composition with the summary in its own container and the published Move View offer, as a browser replay does (D31 of #118); fails_when=the composition differs from VS Code's, a move runs in Cursor, or no offer is made; why_new=Cycle 0051 DS-A (AC1); seam=workspaceHarness with useHostAppName('Cursor'), the harness's information messages; no_claim=Cursor's own Move View list + test(`${entry} in Cursor opens a test replay with the Study pane closed, moves no view and offers Move View`, async () => { + // Arrange + const harness = await workspaceHarness() + harness.useHostAppName('Cursor') + try { + // Act + if (entry === 'Open replay') await openByOpenReplay(harness) + else await openFromSavedStudies(harness) + await waitUntil( + () => + harness.informationMessages.some( + (notice) => notice.message === SUMMARY_PLACEMENT_OFFER, + ), + 'the Move View offer is made', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.deepEqual(compositionOf(harness), { + studyPane: false, + application: true, + summary: true, + family: 'test', + }) + assert.deepEqual(harness.moveViewsCalls(), []) + } finally { + await harness.close() + } + }) +} + +// Value: protects=MCP 1.8's contract for a test replay: an agent's codebook_playback open of its study id is refused with PLAYBACK_PROFILE_UNSUPPORTED and its recovery sentence, and the refused call changes no selection, view or focus; fails_when=the agent's open enters the composition, moves a view or the keyboard, or the refusal's code or words change; why_new=Cycle 0051 DS-A (AC1), the DX review's correction that an agent cannot open a test replay; seam=the service's playback call; no_claim=the agent's own retry +test('an agent’s codebook_playback open of a test replay is refused and changes no selection, view or focus', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + await harness.reader.open() + const before = { + messages: harness.postedMessages().length, + focusCommands: harness.viewFocusCommands.length, + composition: compositionOf(harness), + } + + // Act + let refusal: unknown + try { + harness.service.playback({ + instanceId: harness.service.instanceId, + studyId: history.studyId, + action: 'open', + expectedRevision: 0, + }) + } catch (error) { + refusal = error + } + + // Assert + assert.ok(refusal instanceof StudyError) + assert.equal(refusal.code, 'PLAYBACK_PROFILE_UNSUPPORTED') + assert.equal( + refusal.message, + `codebook_playback cannot control study ${history.studyId}: it is a node-test replay, which the learner opens from Saved studies or Open replay.`, + ) + assert.equal(harness.postedMessages().length, before.messages) + assert.equal(harness.viewFocusCommands.length, before.focusCommands) + assert.deepEqual(compositionOf(harness), before.composition) + assert.equal(lastView(harness)?.nodeHistory, undefined) + } finally { + await harness.close() + } +}) + +// Value: protects=the presentation an agent reads for a test replay: the summary composition's pane set (the Application and the recorded source, no Study pane), as for a browser replay; fails_when=the presentation still names a Study pane column for a test replay; why_new=Cycle 0051 AC1; seam=the reader's presentation state; no_claim=the native columns +test('the presentation of a shown test replay reports the Application and the recorded source with no Study pane', async () => { + // Arrange + const harness = await workspaceHarness() + try { + await openByOpenReplay(harness) + + // Act + const panes = harness.reader.inspect().presentation.panes + + // Assert + assert.notEqual(panes.appColumn, undefined) + assert.notEqual(panes.sourceColumn, undefined) + assert.equal(panes.contextColumn, undefined) + } finally { + await harness.close() + } +}) + +// Value: protects=a node-history move sent from the Learning summary view moves the test replay (DS-F); fails_when=the summary view's message is dropped because the reader accepts the Study pane's alone; why_new=Cycle 0051 DS-F (AC6): the summary view's messages were dropped unless a browser recording was active; seam=the harness's summary view listener; no_claim=the view's own buttons (Reader DOM) +test('Next stop sent from the Learning summary moves the test replay', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const history = await openByOpenReplay(harness) + + // Act + harness.receive({ + type: 'node-history-step', + revision: lastView(harness)!.revision, + studyId: history.studyId, + direction: 'next', + unit: 'stop', + }) + await landsOn(harness, 4, 'the summary view’s Next stop reaches stop 2') + + // Assert + assert.equal(lastView(harness)?.nodeHistoryPosition?.sequence, 4) + assert.equal(lastView(harness)?.nodeHistoryRefusal, undefined) + } finally { + await harness.close() + } +}) + +// Value: protects=a node-history move sent from the bottom panel's transport moves the test replay (DS-F); fails_when=the bottom panel's message is dropped or refused; why_new=Cycle 0051 DS-F (AC6): the shared transport sends from the bottom panel during a test replay; seam=the harness's bottom panel listener; no_claim=the panel's own buttons (Reader DOM) +test('Next event sent from the bottom panel moves the test replay', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const history = await openByOpenReplay(harness) + + // Act + harness.receivePlaybackPanel({ + type: 'node-history-step', + revision: lastView(harness)!.revision, + studyId: history.studyId, + direction: 'next', + unit: 'event', + }) + await landsOn( + harness, + 3, + 'the bottom panel’s Next event reaches the output after stop 1', + ) + + // Assert + assert.equal(lastView(harness)?.nodeHistoryPosition?.sequence, 3) + assert.equal(lastView(harness)?.nodeHistoryRefusal, undefined) + } finally { + await harness.close() + } +}) + +// Value: protects=the one-replay-at-a-time rule in the shared views: during a browser replay a test replay's move from the Learning summary is refused with the existing sentence and moves nothing; fails_when=the move is accepted or refused silently; why_new=Cycle 0051 DS-F (AC6); seam=recordedReaderHarness, the harness's summary listener; no_claim=none beyond protects +test('a test replay move sent from the Learning summary during a browser replay is refused with its sentence', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + await harness.reader.openRecording(harness.studyId) + await harness.controller.settle() + const testReplayId = nodeTestHistoryHeader( + harness.service.nodeHistoryStore!.workspaceKey, + ).studyId + + // Act + harness.receive({ + type: 'node-history-step', + revision: lastView(harness)!.revision, + studyId: testReplayId, + direction: 'next', + unit: 'stop', + }) + await waitUntil( + () => + lastView(harness)?.errors.some((line) => + line.includes( + 'Close browser replay before navigating a test replay.', + ), + ) === true, + 'the move is refused', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.equal(lastView(harness)?.replayFamily, 'browser') + assert.equal(lastView(harness)?.nodeHistory, undefined) + } finally { + await harness.close() + } +}) + +// Value: protects=5A for a paused test replay: hiding the Learning summary and pressing the Learning summary button (Codebook: Show Learning Summary) shows it again on the same moment, playhead and replay debugger presentation; fails_when=the reopen moves the replay, presents the debugger again or refuses a test replay; why_new=Cycle 0051 DS-E (AC5): Show Learning Summary refused a test replay; seam=hideSummary and the debugger stand-in's presentation list; no_claim=the scroll position and focus in the real hosts (N51 (c)) +test('hiding a paused test replay’s Learning summary and showing it again keeps the moment, the playhead and the debugger presentation', async () => { + // Arrange + const harness = await workspaceHarness() + try { + await openByOpenReplay(harness) + const before = { + sequence: lastView(harness)?.nodeHistoryPosition?.sequence, + positionMs: lastView(harness)?.nodeHistoryPlayback?.positionMs, + presentations: harness.historicalDebugPresentations.length, + } + harness.hideSummary() + + // Act + await harness.reader.showLearningSummary() + + // Assert + assert.equal(harness.summary.visible, true) + assert.deepEqual( + { + sequence: lastView(harness)?.nodeHistoryPosition?.sequence, + positionMs: lastView(harness)?.nodeHistoryPlayback?.positionMs, + presentations: harness.historicalDebugPresentations.length, + }, + before, + ) + assert.equal( + harness.viewFocusCommands.at(-1)?.command, + 'codebook.learningSummary.focus', + ) + } finally { + await harness.close() + } +}) + +// Value: protects=5A for a paused browser replay: the same hide and reopen keeps its moment and playhead; fails_when=the reopen seeks or reloads the recording; why_new=Cycle 0051 DS-E (AC5), both families; seam=recordedReaderHarness and hideSummary; no_claim=the scroll position and focus in the real hosts (N51 (c)) +test('hiding a paused browser replay’s Learning summary and showing it again keeps its moment and playhead', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + await harness.reader.openRecording(harness.studyId) + await harness.controller.settle() + const before = { + presentedMs: lastView(harness)?.recording?.presentedMs, + mode: lastView(harness)?.recording?.mode, + } + harness.hideSummary() + + // Act + await harness.reader.showLearningSummary() + await harness.controller.settle() + + // Assert + assert.equal(harness.summary.visible, true) + assert.deepEqual( + { + presentedMs: lastView(harness)?.recording?.presentedMs, + mode: lastView(harness)?.recording?.mode, + }, + before, + ) + } finally { + await harness.close() + } +}) + +// Value: protects=5A for a playing test replay: hiding the summary neither pauses nor resumes playback, and the reopened summary shows the moment then current; fails_when=the hide pauses the timer or the reopen restarts it; why_new=Cycle 0051 DS-E (AC5); seam=hideSummary and the host's real playback timer; no_claim=native timing +test('a playing test replay goes on playing while its Learning summary is hidden', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const history = await openByOpenReplay(harness) + harness.hideSummary() + + // Act + harness.receivePlaybackPanel({ + type: 'node-history-playback', + revision: lastView(harness)!.revision, + studyId: history.studyId, + action: 'play', + }) + await landsOn( + harness, + 5, + 'playback reaches the result with the summary hidden', + ) + await harness.reader.showLearningSummary() + + // Assert + assert.equal(harness.summary.visible, true) + assert.equal(lastView(harness)?.nodeHistoryPosition?.sequence, 5) + } finally { + await harness.close() + } +}) + +// Value: protects=Return to Live from a test replay gives the Live composition as a browser replay's Return to Live does: the replay is retired and the Study pane stays closed; fails_when=the Study pane comes back, or the replay stays selected; why_new=Cycle 0051 DS-A (AC15), the Eng review's correction; seam=the reader's return-current action; no_claim=the keyboard focus in the real hosts (N51 (e)) +test('Return to Live from a test replay retires it and keeps the Study pane closed', async () => { + // Arrange + const harness = await workspaceHarness() + try { + await openByOpenReplay(harness) + + // Act + await harness.reader.action('return-current') + + // Assert: the Live composition has no Study pane, so the bottom panel holds the reader's last message. + const bottomPanelView = harness.playbackPanelMessages.at(-1) + assert.equal(bottomPanelView?.nodeHistory, undefined) + assert.equal(bottomPanelView?.replayFamily, undefined) + assert.equal(harness.panelColumn('codebook.studyContext'), undefined) + } finally { + await harness.close() + } +}) + +// Value: protects=a refused test replay move shows the host's sentence in the shared transport's status line, read with the summary hidden too, and leaves the committed moment; fails_when=the refusal reaches only the Study pane's error lines, or the moment changes; why_new=Cycle 0051 DS-G (AC14); seam=the projection's nodeHistoryRefusal; no_claim=the status line's rendering (Reader DOM) +test('a refused Previous stop at the first stop shows its sentence for the transport and keeps the moment', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const history = await openByOpenReplay(harness) + harness.hideSummary() + + // Act + harness.receivePlaybackPanel({ + type: 'node-history-step', + revision: lastView(harness)!.revision, + studyId: history.studyId, + direction: 'previous', + unit: 'stop', + }) + await waitUntil( + () => lastView(harness)?.nodeHistoryRefusal !== undefined, + 'the refusal reaches the transport', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.equal( + lastView(harness)?.nodeHistoryRefusal, + 'There is no previous stop in this test replay.', + ) + assert.equal(lastView(harness)?.nodeHistoryPosition?.sequence, 2) + } finally { + await harness.close() + } +}) + +// Value: protects=a move whose replay debugger does not start: the transport's sentence carries the open path's recovery clause and says the last moment is still shown, and following it (selecting that stop again) reaches the target; fails_when=the bare cause is shown, the failed moment is shown as new, or the stop cannot be reached again; why_new=Cycle 0051 DS-G (AC14), the DX review's finding; seam=the harness's held historical debug presentation, rejected as a start failure; no_claim=a real DAP failure +test('a move whose replay debugger does not start names the recovery and selecting that stop again reaches it', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const history = await openByOpenReplay(harness) + harness.hideSummary() + const held = harness.holdHistoricalDebugPresentation(true) + harness.receivePlaybackPanel({ + type: 'node-history-step', + revision: lastView(harness)!.revision, + studyId: history.studyId, + direction: 'next', + unit: 'stop', + }) + await held.started + + // Act + held.reject(new Error('Workspace trust was declined.')) + await waitUntil( + () => lastView(harness)?.nodeHistoryRefusal !== undefined, + 'the failure reaches the transport', + READER_RESULT_BOUND_MS, + ) + const failure = lastView(harness)?.nodeHistoryRefusal + const shownAfterFailure = lastView(harness)?.nodeHistoryPosition?.sequence + harness.receivePlaybackPanel({ + type: 'node-history-event', + revision: lastView(harness)!.revision, + studyId: history.studyId, + sequence: 4, + }) + await landsOn(harness, 4, 'selecting the stop again reaches it') + + // Assert + assert.equal( + failure, + 'The replay debugger could not start: Workspace trust was declined. The last moment is still shown. Select this stop or use Inspect in native debugger to restart it.', + ) + assert.equal(shownAfterFailure, 2) + assert.equal(lastView(harness)?.nodeHistoryRefusal, undefined) + } finally { + await harness.close() + } +}) + +// Value: protects=the host's first and last events by sequence: in a history whose first two events share one time, Replay from start and Home (node-history-event with firstSequence) show sequence 1, not the second event a seek to that time would select; fails_when=firstSequence is missing or is the last event at the first time; why_new=Cycle 0051 DS-C, the Eng review's finding (equal times are stored); seam=a saved history whose first two outputs share 0 ms, then its stop and result; no_claim=the page's buttons (Reader DOM) +test('Replay from start in a history whose first two events share one time reaches the first event', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const store = harness.service.nodeHistoryStore! + const header = nodeTestHistoryHeader(store.workspaceKey) + const writer = await store.start(header) + for (const sequence of [1, 2]) + await writer.appendOutput({ + sequence, + elapsedMs: 0, + channel: 'stdout', + text: `output ${sequence}\n`, + }) + await appendDeclaredStop(writer, header, 5) + await writer.appendResult( + { exitCode: 0, signal: null, output: 'Tests 1 passed' }, + 9, + ) + await writer.finish('complete') + await harness.reader.action('retry') + await harness.reader.openLearningReplay(header.studyId) + await waitUntil( + () => + !lastView(harness)?.loading && Boolean(lastView(harness)?.nodeHistory), + 'the history opens', + READER_RESULT_BOUND_MS, + ) + harness.receive({ + type: 'node-history-event', + revision: lastView(harness)!.revision, + studyId: header.studyId, + sequence: 4, + }) + await landsOn(harness, 4, 'the result is selected') + + // Act + const firstSequence = lastView(harness)?.nodeHistoryPlayback?.firstSequence + harness.receive({ + type: 'node-history-event', + revision: lastView(harness)!.revision, + studyId: header.studyId, + sequence: firstSequence, + }) + await landsOn(harness, 1, 'Replay from start reaches the first event') + + // Assert + assert.equal(firstSequence, 1) + assert.equal(lastView(harness)?.nodeHistoryPlayback?.lastSequence, 4) + } finally { + await harness.close() + } +}) + +// Value: protects=the first and last events from any page of the event list: a 41-event history on its second page still projects its whole-history first and last sequence, and End reaches the last event; fails_when=the page's own first or last entry is projected; why_new=Cycle 0051 DS-C, the Eng review's finding (pages of 20); seam=a saved history of 39 outputs, its stop and a result, the node-history-page message; no_claim=none beyond protects +test('the first and last events of a 41-event history are projected from its second page and End reaches the last', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const store = harness.service.nodeHistoryStore! + const header = nodeTestHistoryHeader(store.workspaceKey) + const writer = await store.start(header) + for (let sequence = 1; sequence <= 39; sequence++) + await writer.appendOutput({ + sequence, + elapsedMs: sequence * 10, + channel: 'stdout', + text: `output ${sequence}\n`, + }) + await appendDeclaredStop(writer, header, 395) + await writer.appendResult( + { exitCode: 0, signal: null, output: 'Tests 1 passed' }, + 500, + ) + await writer.finish('complete') + await harness.reader.action('retry') + await harness.reader.openLearningReplay(header.studyId) + await waitUntil( + () => + !lastView(harness)?.loading && Boolean(lastView(harness)?.nodeHistory), + 'the history opens', + READER_RESULT_BOUND_MS, + ) + harness.receive({ + type: 'node-history-page', + revision: lastView(harness)!.revision, + studyId: header.studyId, + page: 1, + }) + await waitUntil( + () => lastView(harness)?.nodeHistory?.entries[0]?.sequence === 21, + 'the second page is shown', + READER_RESULT_BOUND_MS, + ) + + // Act + const playback = lastView(harness)?.nodeHistoryPlayback + harness.receive({ + type: 'node-history-event', + revision: lastView(harness)!.revision, + studyId: header.studyId, + sequence: playback?.lastSequence, + }) + await landsOn(harness, 41, 'End reaches the last event') + + // Assert + assert.equal(playback?.firstSequence, 1) + assert.equal(playback?.lastSequence, 41) + } finally { + await harness.close() + } +}) + +// Value: protects=a failed open of a test replay leaves no empty summary composition: the Study composition comes back with the failure on its line, beside the load-failure notification that ends with the Saved studies sentence (DS-G); fails_when=the reader stays in the summary composition with no replay shown, or the notification loses its recovery sentence; why_new=Cycle 0051 DS-G (AC14): the open enters the composition before it reads the history; seam=a journal edited after its seal; no_claim=Show details, which the notification's button runs +test('a test replay whose history cannot be read leaves the summary composition and tells the failure with its Saved studies sentence', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + await harness.reader.open() + await appendFile( + join( + (await harness.reader.learningStorage()).directory, + NODE_TEST_HISTORY_DIRECTORY, + history.studyId, + 'events.jsonl', + ), + '{"type":"forged"}\n', + ) + + // Act + await harness.reader.openLearningReplay(history.studyId) + + // Assert + assert.equal( + harness.panelColumn('codebook.studyContext') !== undefined, + true, + ) + assert.equal(lastView(harness)?.replayFamily, undefined) + assert.equal(lastView(harness)?.nodeHistory, undefined) + assert.equal(harness.errorMessages.length, 1) + assert.match( + harness.errorMessages[0]!, + /The saved history is unchanged; open it again from Saved studies\.$/, + ) + } finally { + await harness.close() + } +}) + +// Value: protects=choosing another saved test replay retires the shown one and stops its playback timer, also with its summary hidden: the other replay opens paused on its own first stop and the first one moves no more; fails_when=the first replay's timer keeps selecting its events, or the second opens playing; why_new=Cycle 0051 DS-A (AC15), the Eng review's retirement rule; seam=two saved histories, the host's real playback timer; no_claim=native timing +test('choosing another saved test replay while the first plays with its summary hidden stops the first one’s timer', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const first = await openByOpenReplay(harness) + const second = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + // The library lists the second replay once it is read again. + await harness.reader.action('retry') + harness.hideSummary() + harness.receivePlaybackPanel({ + type: 'node-history-playback', + revision: lastView(harness)!.revision, + studyId: first.studyId, + action: 'play', + }) + + // 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 + await new Promise((resolve) => setTimeout(resolve, 600)) + + // Assert + assert.equal(shownAfterOpen, second.studyId) + assert.equal(lastView(harness)?.nodeHistory?.header.studyId, second.studyId) + assert.equal(lastView(harness)?.nodeHistoryPosition?.sequence, 2) + assert.equal(lastView(harness)?.nodeHistoryPlayback?.mode, 'paused') + } finally { + await harness.close() + } +}) + +// Value: protects=Return to Live while a test replay plays with its summary hidden stops its timer with the retirement, so no event is selected after the return; fails_when=the timer keeps ticking and a late selection brings the retired replay back into a message; why_new=Cycle 0051 DS-A (AC15); seam=the host's real playback timer and the bottom panel's messages; no_claim=native timing +test('Return to Live while a test replay plays with its summary hidden stops its timer', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const history = await openByOpenReplay(harness) + harness.hideSummary() + harness.receivePlaybackPanel({ + type: 'node-history-playback', + revision: lastView(harness)!.revision, + studyId: history.studyId, + action: 'play', + }) + + // Act + await harness.reader.action('return-current') + const messagesAtReturn = harness.playbackPanelMessages.length + await new Promise((resolve) => setTimeout(resolve, 600)) + + // Assert + assert.equal( + harness.playbackPanelMessages + .slice(messagesAtReturn) + .some((message) => message.nodeHistory !== undefined), + false, + ) + assert.equal(harness.reader.inspect().view?.nodeHistory, undefined) + } finally { + await harness.close() + } +}) + +// Value: protects=the shown test replay against a delete request from the Learning summary's Saved studies: test replays cannot be deleted, so the refusal is said and the replay keeps its moment (the plan's retirement by deletion does not arise); fails_when=the request removes the replay or moves it; why_new=Cycle 0051 finding 1 (a premise of the plan's AC15); seam=the summary view's delete-study message; no_claim=the removal of a browser study +test('a delete request for the shown test replay is refused and the replay keeps its moment', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const history = await openByOpenReplay(harness) + + // Act + harness.receive({ + type: 'delete-study', + revision: lastView(harness)!.revision, + studyId: history.studyId, + removalRequest: 1, + }) + await waitUntil( + () => lastView(harness)?.studyRemoval?.status === 'refused', + 'the delete request is answered', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.equal( + lastView(harness)?.studyRemoval?.text, + STUDY_REMOVAL_REFUSALS.nodeHistory, + ) + assert.equal( + lastView(harness)?.nodeHistory?.header.studyId, + history.studyId, + ) + assert.equal(lastView(harness)?.nodeHistoryPosition?.sequence, 2) + } finally { + await harness.close() + } +}) + +// Value: protects=a node-history move that arrives after Return to Live retired the test replay is refused with the existing sentence and moves nothing; fails_when=the late move brings the retired replay back or is accepted silently; why_new=Cycle 0051 DS-F (AC15): the summary and the bottom panel send moves now; seam=the bottom panel's listener after the return; no_claim=none beyond protects +test('a test replay move that arrives after Return to Live is refused and moves nothing', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const history = await openByOpenReplay(harness) + await harness.reader.action('return-current') + + // Act: the move carries the bottom panel's current revision, so only the retirement can refuse it. + harness.receivePlaybackPanel({ + type: 'node-history-step', + revision: harness.playbackPanelMessages.at(-1)!.revision, + studyId: history.studyId, + direction: 'next', + unit: 'stop', + }) + await waitUntil( + () => + harness.playbackPanelMessages + .at(-1) + ?.errors.some((line) => + line.includes( + 'Select the matching saved test replay before navigating its timeline.', + ), + ) === true, + 'the late move is refused', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.equal(harness.reader.inspect().view?.nodeHistory, undefined) + } finally { + await harness.close() + } +}) + +// Value: protects=a node-history-event naming a sequence the history does not hold is refused before any move, so the shown moment stays; fails_when=the request moves the replay or clears its moment; why_new=Cycle 0051 DS-F (AC15); seam=the summary view's listener; no_claim=none beyond protects +test('a test replay event request for a sequence the history does not hold is refused and keeps the moment', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const history = await openByOpenReplay(harness) + + // Act + harness.receive({ + type: 'node-history-event', + revision: lastView(harness)!.revision, + studyId: history.studyId, + sequence: 99, + }) + await waitUntil( + () => lastView(harness)?.nodeHistoryRefusal !== undefined, + 'the request is refused', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.equal( + lastView(harness)?.nodeHistoryRefusal, + 'The requested event of this test replay is unavailable.', + ) + assert.equal(lastView(harness)?.nodeHistoryPosition?.sequence, 2) + } finally { + await harness.close() + } +}) + +// Value: protects=moves from two views and a held key end at the latest request: while a move is still presenting, Next event from the bottom panel and then from the summary add up from the moment being reached, and no superseded move reports a refusal; fails_when=the moves queue on the shown moment, or a superseded move's failure shows in the transport; why_new=Cycle 0051 DS-F (AC15): three views share the one selection; seam=the harness's held historical debug presentation; no_claim=real key repeat rates +test('moves from the bottom panel and the Learning summary while a move presents end at the latest request with no refusal', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const history = await openByOpenReplay(harness) + const held = harness.holdHistoricalDebugPresentation(true) + harness.receivePlaybackPanel({ + type: 'node-history-step', + revision: lastView(harness)!.revision, + studyId: history.studyId, + direction: 'next', + unit: 'stop', + }) + await held.started + + // Act + harness.receive({ + type: 'node-history-step', + revision: lastView(harness)!.revision, + studyId: history.studyId, + direction: 'next', + unit: 'event', + }) + await landsOn(harness, 5, 'the latest request reaches the result') + held.reject(new Error('Historical debugger presentation was superseded.')) + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Assert + assert.equal(lastView(harness)?.nodeHistoryPosition?.sequence, 5) + assert.equal(lastView(harness)?.nodeHistoryRefusal, undefined) + assert.equal(lastView(harness)?.errors.length, 0) + } finally { + await harness.close() + } +}) + +// Value: protects=a Learning summary view the host had not resolved before the learner's first test replay entry gets the reader's document and the test replay's latest Study message when it resolves, instead of the closed replay's placeholder; fails_when=the resolved view shows the placeholder or holds no test replay message; why_new=Cycle 0051 DS-E (AC5): the test replay never used the summary view before; seam=the harness's lazily resolved summary view; no_claim=native focus timing (N51 (c)) +test('a Learning summary view first resolved at a test replay entry gets the reader’s document and the replay’s message', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + const htmlBefore = harness.summary.webview.html + + // Act + await harness.reader.openLearningReplay(history.studyId) + await landsOn(harness, 2, 'the replay lands on its first stop') + + // Assert + assert.equal(htmlBefore, '') + assert.match(harness.summary.webview.html, /id="node-history"/) + assert.equal( + lastView(harness)?.nodeHistory?.header.studyId, + history.studyId, + ) + } finally { + await harness.close() + } +}) + +// Value: protects=the bottom panel's title and content: "Learning replay" with the shared transport during a test replay, and "Observed moments" outside a replay (DS-D); fails_when=a test replay keeps "Observed moments" and its second copy of the Study pane, or the title stays after the replay; why_new=Cycle 0051 task 7 (the entry audit's finding); seam=the harness's bottom panel; no_claim=the panel's drawing (Reader DOM) +test('the bottom panel reads Learning replay during a test replay and Observed moments after Return to Live', async () => { + // Arrange + const harness = await workspaceHarness() + try { + await openByOpenReplay(harness) + const duringReplay = { + title: (harness.playbackPanel as { title?: string }).title, + family: harness.playbackPanelMessages.at(-1)?.replayFamily, + } + + // Act + await harness.reader.action('return-current') + + // Assert + assert.deepEqual(duringReplay, { title: 'Learning replay', family: 'test' }) + assert.equal( + (harness.playbackPanel as { title?: string }).title, + 'Observed moments', + ) + assert.equal(harness.playbackPanelMessages.at(-1)?.replayFamily, undefined) + } finally { + await harness.close() + } +}) + +// Value: protects=no test replay reopens by itself after a window reload, and reopening it from Saved studies enters the summary composition at its saved reader position without moving the summary again (once per profile); fails_when=the fresh reader opens a replay by itself, lands on the first stop instead of the saved one, or moves the view a second time; why_new=Cycle 0051 AC13, the Eng review's correction (the persisted selection is the browser profile's only); seam=a second reader over the same storage and profile state (harness.createReader) after the first one closed; no_claim=VS Code's own restore of the views +test('after a reload no test replay reopens by itself, and its reopen lands on the saved stop without a second summary move', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const history = await openByOpenReplay(harness) + harness.receive({ + type: 'node-history-step', + revision: lastView(harness)!.revision, + studyId: history.studyId, + direction: 'next', + unit: 'stop', + }) + await landsOn(harness, 4, 'the learner moves to stop 2') + await harness.reader.close() + const fresh = harness.createReader() + await fresh.attach(harness.service) + + // Act + const shownAfterReload = fresh.inspect().view?.nodeHistory + await fresh.openLearningReplay(history.studyId) + await waitUntil( + () => + fresh.inspect().view?.nodeHistoryPosition?.sequence === 4 && + !fresh.inspect().view?.loading, + 'the reopen lands on the saved stop', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.equal(shownAfterReload, undefined) + assert.deepEqual(harness.moveViewsCalls(), [SUMMARY_MOVE]) + await fresh.close() + } finally { + await harness.close() + } +}) diff --git a/extension/src/tests/studyWorkspaceTestReplayPane.test.ts b/extension/src/tests/studyWorkspaceTestReplayPane.test.ts index 50ea15953..6e2b4cd38 100644 --- a/extension/src/tests/studyWorkspaceTestReplayPane.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayPane.test.ts @@ -92,7 +92,7 @@ function shapeOf(layout: unknown): unknown { : null } -// Value: protects=Show recorded output brings a closed Application pane back above the recorded source in a group of its own when the editor removed the emptied group (workbench.editor.closeEmptyGroups, on by default), with the Study pane's group where it was; fails_when=open() rebuilds the pane as a tab of the source's group and the arrangement moves the Study group, leaving an empty group on the right (the defect VS Code 1.142.0 showed); why_new=F137: the first fix (D21) only asserted that arrange ran, and a native check falsified it; seam=the workspace harness's group model with closeDisposedPanelTabsLater (the editor removes the emptied group), closeTabByHand and the Study pane's focus-app action; no_claim=the native placement itself, which the driving agent checks in VS Code and Cursor +// Value: protects=Show recorded output brings a closed Application pane back above the recorded source in a group of its own when the editor removed the emptied group (workbench.editor.closeEmptyGroups, on by default), with the composition's other groups where they were (the Study pane's before Cycle 0051; moves with D8, F123); fails_when=open() rebuilds the pane as a tab of the source's group and the arrangement moves the other groups (the defect VS Code 1.142.0 showed); why_new=F137: the first fix (D21) only asserted that arrange ran, and a native check falsified it; seam=the workspace harness's group model with closeDisposedPanelTabsLater (the editor removes the emptied group), closeTabByHand and the Study pane's focus-app action; no_claim=the native placement itself, which the driving agent checks in VS Code and Cursor test('Show recorded output puts a closed Application pane back above the recorded source in its own group when the editor removed the emptied group', async () => { // Arrange & Act const { before, closed, after } = @@ -100,21 +100,17 @@ test('Show recorded output puts a closed Application pane back above the recorde emptyGroupsClose: true, }) - // Assert + // Assert: the summary composition (Cycle 0051, DS-A) has no Study pane; its third group stands where it was, as in a browser replay's composition. assert.deepEqual(before.groups, [ - { column: 2, tabs: ['Captured-theme.ts'] }, { column: 1, tabs: ['codebook.scopedBrowser'] }, - { column: 3, tabs: ['codebook.studyContext'] }, + { column: 3, tabs: [] }, + { column: 2, tabs: ['Captured-theme.ts'] }, ]) assert.deepEqual(closed.groups, [ + { column: 2, tabs: [] }, { column: 1, tabs: ['Captured-theme.ts'] }, - { column: 2, tabs: ['codebook.studyContext'] }, - ]) - assert.deepEqual(after.groups, [ - { column: 1, tabs: ['codebook.scopedBrowser'] }, - { column: 2, tabs: ['Captured-theme.ts'] }, - { column: 3, tabs: ['codebook.studyContext'] }, ]) + 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. assert.deepEqual(after.layout, before.layout) }) @@ -127,11 +123,11 @@ test('Show recorded output reuses the emptied group of the closed Application pa emptyGroupsClose: false, }) - // Assert + // Assert: the summary composition (Cycle 0051, DS-A) has no Study pane. assert.deepEqual(closed.groups, [ - { column: 2, tabs: ['Captured-theme.ts'] }, { column: 1, tabs: [] }, - { column: 3, tabs: ['codebook.studyContext'] }, + { column: 3, tabs: [] }, + { column: 2, tabs: ['Captured-theme.ts'] }, ]) assert.deepEqual(after.groups, before.groups) assert.deepEqual(after.layout, before.layout) diff --git a/extension/src/tests/studyWorkspaceTestReplaySource.test.ts b/extension/src/tests/studyWorkspaceTestReplaySource.test.ts index 5ac83fcad..f4c1930be 100644 --- a/extension/src/tests/studyWorkspaceTestReplaySource.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplaySource.test.ts @@ -752,7 +752,9 @@ test('a failed presentation is reported as itself when the return to the committ ) // The rejection is awaited below, after the held presentation fails. const outcome = assert.rejects(selecting, { - message: 'The presentation failed.', + // The move's failure carries the open path's recovery clause and names the presentation's own cause (Cycle 0051, DS-G). + message: + 'The replay debugger could not start: The presentation failed. The last moment is still shown. Select this stop or use Inspect in native debugger to restart it.', }) await held.started @@ -3502,7 +3504,9 @@ test('the playback time follows the pending stop and returns to the committed mo 4, ) const outcome = assert.rejects(selecting, { - message: 'The presentation failed.', + // The move's failure carries the open path's recovery clause and names the presentation's own cause (Cycle 0051, DS-G). + message: + 'The replay debugger could not start: The presentation failed. The last moment is still shown. Select this stop or use Inspect in native debugger to restart it.', }) await held.started @@ -3545,7 +3549,9 @@ test('the position line and the playback readout say the requested moment is loa 4, ) const outcome = assert.rejects(selecting, { - message: 'The presentation failed.', + // The move's failure carries the open path's recovery clause and names the presentation's own cause (Cycle 0051, DS-G). + message: + 'The replay debugger could not start: The presentation failed. The last moment is still shown. Select this stop or use Inspect in native debugger to restart it.', }) await held.started @@ -3889,12 +3895,10 @@ test('Restore with a test replay selected gives the grid back the sizes the lear const arranged = await harness.editor.commands.executeCommand( 'vscode.getEditorLayout', ) + // The learner resizes the summary composition's two groups, the Application above the recorded source (Cycle 0051, DS-A). await harness.editor.commands.executeCommand('vscode.setEditorLayout', { - orientation: 0, - groups: [ - { size: 0.8, groups: [{ size: 0.5 }, { size: 0.5 }] }, - { size: 0.2 }, - ], + orientation: 1, + groups: [{ size: 0.8 }, { size: 0.2 }], }) const resized = await harness.editor.commands.executeCommand( 'vscode.getEditorLayout', @@ -3957,8 +3961,8 @@ test('Restore with a test replay selected fronts the slot and leaves the keyboar } }) -// Value: protects=a test replay opened in a saved layout whose source column is not the Study workspace's (Living book: the third column) reading its layout as confirmed once the slot stands at that column; fails_when=layoutSourceReady looks for the slot in the Study workspace's second column whatever the mode, so the layout can never confirm; why_new=F424 (OCR L5: layoutSourceReady hardcoded the study-workspace source column while matchingColumns no longer checked it); seam=the workspace harness with a saved Living book preference and a test replay opened through Open replay; no_claim=a native Living book arrangement, which is retired and selectable by no command -test('a test replay opened in a saved layout with another source column reads its layout as confirmed', async () => { +// Value: protects=a test replay opened with a saved Living book preference reading its layout as confirmed: the summary composition gives it the Study workspace mode, as it gives a browser replay, and the slot stands at that mode's source column; fails_when=the retired mode's source column is looked for, so the layout never confirms; why_new=F424 (OCR L5), changed in Cycle 0051 (DS-A, moves with D8, F123): a test replay enters the summary composition, whose mode is the Study workspace; seam=the workspace harness with a saved Living book preference and a test replay opened through Open replay; no_claim=a native Living book arrangement, which is retired and selectable by no command +test('a test replay opened with a saved Living book preference stands in the Study workspace mode and reads its layout as confirmed', async () => { // Arrange const harness = await workspaceHarness() harness.useNativeLayout({ orientation: 0, groups: [{}] }) @@ -3976,7 +3980,7 @@ test('a test replay opened in a saved layout with another source column reads it await openReplayAt(harness, history.studyId, 2) // Assert - assert.equal(harness.reader.presentationState().mode, 'living-book') + assert.equal(harness.reader.presentationState().mode, 'study-workspace') assert.equal(harness.reader.presentationState().layoutConfirmed, true) } finally { await harness.close() @@ -3993,12 +3997,10 @@ test('a test replay Restore whose layout command fails reads the layout as uncon harness.service.nodeHistoryStore!, ) await openReplayAt(harness, history.studyId, 2) + // The learner resizes the summary composition's two groups, the Application above the recorded source (Cycle 0051, DS-A). await harness.editor.commands.executeCommand('vscode.setEditorLayout', { - orientation: 0, - groups: [ - { size: 0.8, groups: [{ size: 0.5 }, { size: 0.5 }] }, - { size: 0.2 }, - ], + orientation: 1, + groups: [{ size: 0.8 }, { size: 0.2 }], }) const confirmedBefore = harness.reader.presentationState().layoutConfirmed harness.failNextCommand('vscode.setEditorLayout') @@ -4062,7 +4064,7 @@ test('a test replay Restore that finds a pane outside its Study destination keep } }) -// Value: protects=a test replay's Restore whose grid step ends with the editor not keeping the Study layout telling that sentence as the reason, without the error class in front of it; fails_when=the failure text is String(error) ("Error: The editor did not keep ..."); why_new=F443 (the failure wording of the Restore's other steps, next to the Restore's own refusal); seam=the workspace harness's constrainStudyGrid (a three-group layout settles at two columns) after the learner's resize; no_claim=the editor's own wording of a layout it does not keep +// Value: protects=a test replay's Restore whose grid step ends with the editor not keeping the Study layout telling that sentence as the reason, without the error class in front of it; fails_when=the failure text is String(error) ("Error: The editor did not keep ..."); why_new=F443 (the failure wording of the Restore's other steps, next to the Restore's own refusal); seam=the workspace harness's constrainStudyGrid(2) (the summary composition's two groups settle at the editor's own sizes) after the learner's resize; no_claim=the editor's own wording of a layout it does not keep test('a test replay Restore whose layout the editor does not keep tells that failure as the reason without an error prefix', async () => { // Arrange const harness = await workspaceHarness() @@ -4072,14 +4074,12 @@ test('a test replay Restore whose layout the editor does not keep tells that fai harness.service.nodeHistoryStore!, ) await openReplayAt(harness, history.studyId, 2) + // The learner resizes the summary composition's two groups, the Application above the recorded source (Cycle 0051, DS-A). await harness.editor.commands.executeCommand('vscode.setEditorLayout', { - orientation: 0, - groups: [ - { size: 0.8, groups: [{ size: 0.5 }, { size: 0.5 }] }, - { size: 0.2 }, - ], + orientation: 1, + groups: [{ size: 0.8 }, { size: 0.2 }], }) - harness.constrainStudyGrid() + harness.constrainStudyGrid(2) const before = harness.reader.presentationState() // Act From 5f4ab5aebce1135ef9e8fa2c542487f3bfb31edd Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sat, 24 Oct 2026 18:20:59 +0900 Subject: [PATCH 02/13] feat: a test replay's Learning summary, one transport and 5A (Cycle 0051, DS-B to DS-E, C2) The Learning summary shows a test replay in region 2's order with a step heading and the drawing's step mark (moved by attribute, so the diagram keeps its scroll), three-line clamps with Show more, and the summary's focus fallback (step heading, then title). The shared #recording-transport serves both families: event steps for a test replay, its keys by event, stop and first or last sequence, a status line of state and refusals only, and the browser's recovery hidden. The bottom panel's transport adds 5A's Learning summary button. The golden's attribute order no longer depends on a race. Reader DOM: 46 new cases in study-bootstrap-qa.mjs (each case group recorded on its own when it throws, so a red run reports every group); the moves-with-D8 pins changed with their reasons. Refs #122 --- extension/scripts/study-bootstrap-qa.mjs | 1891 +++++++++++++++-- extension/src/ui/constants.ts | 73 +- extension/src/ui/studyHtml.ts | 714 +++++-- extension/src/utils/describeNodeTestReplay.ts | 10 + extension/src/utils/installShortcutHelp.ts | 3 +- 5 files changed, 2313 insertions(+), 378 deletions(-) diff --git a/extension/scripts/study-bootstrap-qa.mjs b/extension/scripts/study-bootstrap-qa.mjs index f1aa3fcb2..fbab8cc61 100644 --- a/extension/scripts/study-bootstrap-qa.mjs +++ b/extension/scripts/study-bootstrap-qa.mjs @@ -1366,8 +1366,10 @@ async function check(page, options, collected) { }), }) // Arrange: the Node history reader alone, with its selected stop shown by the native session. + // The host shows a selected test replay in the summary composition and names its family (Cycle 0051, DS-A). const nodeReader = { ...options.message, + replayFamily: 'test', revision: revisionAt(5), nodeHistoryEventCount: 1, nodeHistory: { @@ -1461,7 +1463,7 @@ async function check(page, options, collected) { JSON.stringify(articleAfterReplayLeft) === JSON.stringify([true, true, true, true]), }) - // Value: protects=a keyboard learner on the selected content tab keeping a focused control when a selected test replay hides the content tabs: the focus goes to the test replay's transport, not to the document body; fails_when=the pane hands the lost focus to the selected content tab, which the test replay hid, so nothing takes it and the keyboard rests on the body; why_new=F567 (hosted CodeRabbit on #121); why_changed=F570 (local CodeRabbit CLI): the case publishes its own starting message instead of relying on the state the case before it left; seam=none; no_claim=which transport control the host enables for a real history (stand-ins: the Study messages the host posts, and the flow's test replay message nodeReader) + // Value: protects=a keyboard learner on the selected content tab keeping a focused control when a selected test replay hides the content tabs: the focus goes to the summary's fallback (the step heading, else the title heading; Cycle 0051, DS-E), not to the document body; fails_when=the pane hands the lost focus to the selected content tab, which the test replay hid, so nothing takes it and the keyboard rests on the body; why_new=F567 (hosted CodeRabbit on #121); why_changed=F570 (local CodeRabbit CLI): the case publishes its own starting message instead of relying on the state the case before it left; seam=none; no_claim=which transport control the host enables for a real history (stand-ins: the Study messages the host posts, and the flow's test replay message nodeReader) // Arrange: no test replay is selected, so the article and its content tabs show; the keyboard stands on the selected content tab. await publish({ ...options.message, revision: revisionAt(7) }) const selectedTab = page.locator('[role=tab][aria-selected=true]') @@ -1472,11 +1474,12 @@ async function check(page, options, collected) { const focusWhileReplaySelected = await focusName() // Assert cases.push({ - name: 'the keyboard on the selected content tab goes to the test replay’s transport when the selected test replay hides the tabs', + name: 'the keyboard on the selected content tab goes to the summary’s title heading when the selected test replay hides the tabs and shows no step heading', actual: { focusBeforeReplay, focusWhileReplaySelected }, passed: focusBeforeReplay === 'live-tab' && - focusWhileReplaySelected === 'node-history-moment-previous', + // The summary's fallback (Cycle 0051, DS-E): the moment block's step heading, else the title heading. + focusWhileReplaySelected === 'title', }) // Value: protects=a selected stop whose values are not loaded offers Inspect in native debugger beside the hint and Show Variables (DS6), not only in the collapsed event list, and a press asks the host to select that stop; the control leaves with the hint once values load and is not offered for a stop whose captured source does not match; fails_when=the row has no Inspect once the session ended, it shows an action while the debugger already shows the stop, it stays after the values load or for a source mismatch, or its press sends another stop or nothing; why_new=F121: Inspect lived in the collapsed event list only; why_changed=F348 (PR-2 /ship Step 11 Codex adversarial): the case published a state the host never sends (no debugger with values not loaded); the host's two states are now checked: the debugger showing the stop with values not loaded (pressed), and the session ended (offered); seam=none; no_claim=the host's selection of the stop (studyWorkspace tests) (stand-ins: the Study messages the host posts and the pane's sent messages) // Arrange @@ -1860,8 +1863,10 @@ async function check(page, options, collected) { nodeFocusOnReturn === 'button: Debugger stop selected', }) // Arrange: a Node playhead scrub that ends without a change event (Cycle 0044, 0031-U6). + // Two events: a history of one event has no usable slider (Cycle 0051, DS-C). const nodeTimeline = { ...nodeReader, + nodeHistoryEventCount: 2, nodeHistoryPosition: nodeStop, nodeHistoryPlayback: { mode: 'paused', @@ -1873,7 +1878,7 @@ async function check(page, options, collected) { }, } await publish(nodeTimeline) - await page.locator('#node-history-playhead').evaluate((playhead) => { + await page.locator('#playhead').evaluate((playhead) => { playhead.value = '150000' playhead.dispatchEvent(new Event('input', { bubbles: true })) playhead.dispatchEvent(new Event('blur')) @@ -1887,7 +1892,7 @@ async function check(page, options, collected) { }, }) const nodePlayheadAfterStep = await page - .locator('#node-history-playhead') + .locator('#playhead') .evaluate((playhead) => ({ value: playhead.value, valueText: playhead.getAttribute('aria-valuetext'), @@ -1903,10 +1908,9 @@ async function check(page, options, collected) { // Arrange const loadingReadout = () => page.evaluate(() => ({ - readout: document.querySelector('#node-history-playback-position') - ?.textContent, + readout: document.querySelector('#playback-position')?.textContent, spoken: document - .querySelector('#node-history-playhead') + .querySelector('#playhead') ?.getAttribute('aria-valuetext'), })) // Act @@ -1932,26 +1936,26 @@ async function check(page, options, collected) { name: 'the test time readout and its spoken time say loading beside the requested time until the moment commits', actual: { readoutWhilePending, readoutCommitted }, passed: - readoutWhilePending.readout === - 'Recorded test time · 120.64 s · loading…' && + // The shared transport's readout names the selected and the last test time (Cycle 0051, DS-C). + readoutWhilePending.readout === '120.64 s / 400.00 s · loading…' && readoutWhilePending.spoken === '120.64 s of recorded test time · loading…' && - readoutCommitted.readout === 'Recorded test time · 120.64 s' && + readoutCommitted.readout === '120.64 s / 400.00 s' && readoutCommitted.spoken === '120.64 s of recorded test time', }) // Arrange: the thumb of the Node playhead is moved and the host has not answered yet (SD-5). await publish(nodeTimeline) - await page.locator('#node-history-playhead').evaluate((playhead) => { + await page.locator('#playhead').evaluate((playhead) => { playhead.value = '150000' playhead.dispatchEvent(new Event('input', { bubbles: true })) }) const spokenUnderThumb = await page - .locator('#node-history-playhead') + .locator('#playhead') .getAttribute('aria-valuetext') // Act: a Study message for another reason arrives while the thumb is still held. await publish({ ...nodeTimeline, busy: true }) const spokenDuringScrub = await page - .locator('#node-history-playhead') + .locator('#playhead') .getAttribute('aria-valuetext') // What Chrome's own accessibility tree holds for the same thumb, beside the attribute (row NF-18, 0031-U6). // The tree a debugging client is given carries the role, the name and the range, and has no field for `aria-valuetext` @@ -1960,7 +1964,7 @@ async function check(page, options, collected) { const { root } = await accessibility.send('DOM.getDocument') const { nodeId: playheadNodeId } = await accessibility.send( 'DOM.querySelector', - { nodeId: root.nodeId, selector: '#node-history-playhead' }, + { nodeId: root.nodeId, selector: '#playhead' }, ) const { nodes: playheadAccessibility } = await accessibility.send( 'Accessibility.getPartialAXTree', @@ -1979,7 +1983,7 @@ async function check(page, options, collected) { maximum: rangeProperty('valuemax'), } await page - .locator('#node-history-playhead') + .locator('#playhead') .dispatchEvent('pointercancel', { bubbles: true }) // Assert cases.push({ @@ -1990,11 +1994,12 @@ async function check(page, options, collected) { spokenDuringScrub === '150.00 s of recorded test time', }) cases.push({ - name: 'Chrome’s accessibility tree gives the Node playhead as a slider named Recorded test time whose value is the time under the thumb', + name: 'Chrome’s accessibility tree gives the test replay’s slider as a slider named Recorded position whose value is the time under the thumb', actual: readFromAccessibilityTree, passed: readFromAccessibilityTree.role === 'slider' && - readFromAccessibilityTree.name === 'Recorded test time' && + // The shared slider keeps its one label for both families (Cycle 0051, DS-C); its spoken value names the family's time. + readFromAccessibilityTree.name === 'Recorded position' && readFromAccessibilityTree.value === 150000 && readFromAccessibilityTree.minimum === 0 && readFromAccessibilityTree.maximum === 400000, @@ -2228,6 +2233,9 @@ async function check(page, options, collected) { startMs: 100, endMs: 900, loading: false, + // The host's whole-history first and last events (Cycle 0051, DS-C). + firstSequence: 1, + lastSequence: 4, }, }) const atFirstOutput = testReplayAt( @@ -2388,14 +2396,14 @@ async function check(page, options, collected) { await publish(atFirstOutput) const readCoverage = () => page.evaluate(() => { - const strip = document.getElementById('node-history-coverage') + const strip = document.getElementById('history-coverage') return { hidden: strip.hidden, gaps: strip.querySelectorAll('.history-gap').length, stops: strip.querySelectorAll('.history-stop').length, gapWidth: strip.querySelector('.history-gap')?.style.width ?? '', stopLeft: strip.querySelector('.history-stop')?.style.left ?? '', - legend: document.getElementById('node-history-coverage-label') + legend: document.getElementById('history-coverage-label') .textContent, name: strip.getAttribute('aria-label'), // The strip is drawn inside the slider's track, below the slider. @@ -2458,86 +2466,79 @@ async function check(page, options, collected) { }, }), }) - // Moves with D8 (the later region-2 composition): this Study-pane pin follows the test replay section to wherever D8 places it. - // Value: protects=a test replay's open putting its section in view once, so the Study's article above it does not push the transport out of sight, while the learner's later scroll is left alone and the next open scrolls again; fails_when=the message's scroll token is ignored, is honoured at every message (the learner cannot scroll away), or the next open does not scroll; why_new=F156 (the test replay section started far below the article); seam=none; no_claim=where the host places the pane (the native check) - // Arrange: a short pane (240 px), the section below the fold, the page at its top; at the end of the page the section's top is its header's height from the pane's top, inside the pane. + // Value: protects=a test replay's open putting its summary in view from its top once (the section follows the summary's title in region 2, Cycle 0051, DS-B), while the learner's later scroll is left alone and the next open scrolls again; fails_when=the message's scroll token is ignored, is honoured at every message (the learner cannot scroll away), the next open does not scroll, or the open scrolls the title out of sight; why_new=F156 (the test replay section started far below the article), changed in Cycle 0051 (moves with D8, F123): the article's controls follow the summary now; seam=none; no_claim=where the host places the pane (the native check) + // Arrange: a short pane (240 px) scrolled to the end of the page, as a learner who read the event list leaves it. const shortPaneHeight = 240 await page.setViewportSize({ width: 600, height: shortPaneHeight }) - let topBeforeOpen, topAtOpen, topAfterLearnerScroll, topAtNextOpen + let scrollBeforeOpen, + scrollAtOpen, + scrollAfterLearnerScroll, + scrollAtNextOpen try { await publish(atFirstOutput) - await page.evaluate(() => window.scrollTo(0, 0)) - const sectionTop = () => - page.evaluate(() => - Math.round( - document.getElementById('node-history').getBoundingClientRect() - .top, - ), - ) - topBeforeOpen = await sectionTop() + const scrollToEnd = () => + page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)) + const scrolled = () => page.evaluate(() => Math.round(scrollY)) + await scrollToEnd() + scrollBeforeOpen = await scrolled() // Act await publish({ ...atFirstOutput, nodeHistoryScrollToken: 11 }) - topAtOpen = await sectionTop() - await page.evaluate(() => window.scrollTo(0, 0)) + scrollAtOpen = await scrolled() + // The learner scrolls to the middle of the page: a scroll at its very end would follow a change of the page's height. + await page.evaluate(() => window.scrollTo(0, 300)) + const learnersScroll = await scrolled() await publish({ ...atFirstOutput, nodeHistoryScrollToken: 11 }) - topAfterLearnerScroll = await sectionTop() + scrollAfterLearnerScroll = (await scrolled()) - learnersScroll await publish({ ...atFirstOutput, nodeHistoryScrollToken: 12 }) - topAtNextOpen = await sectionTop() + scrollAtNextOpen = await scrolled() } finally { // The next cases need the 800 px pane even when a step above threw. await page.setViewportSize({ width: 600, height: 800 }) } - // Assert: the section starts below the 240 px pane, so only a scroll to it brings its top inside. + // Assert cases.push({ - name: 'a test replay’s open scrolls its section into view once, and a later message of the same open leaves the learner’s scroll alone', + name: 'a test replay’s open shows its summary from the top once, and a later message of the same open leaves the learner’s scroll alone', actual: { - topBeforeOpen, - topAtOpen, - topAfterLearnerScroll, - topAtNextOpen, + scrollBeforeOpen, + scrollAtOpen, + scrollAfterLearnerScroll, + scrollAtNextOpen, }, passed: - topBeforeOpen >= shortPaneHeight && - topAtOpen < topBeforeOpen && - topAtOpen < shortPaneHeight && - topAfterLearnerScroll === topBeforeOpen && - topAtNextOpen < topAfterLearnerScroll && - topAtNextOpen < shortPaneHeight, - }) - // Value: protects=a test replay's open token surviving a message that names no section yet, so the section that arrives next is scrolled into view; fails_when=the token is spent on a message without the section and the open never scrolls (F371); why_new=F371, the F156 case sends its token with the section already shown; seam=none; no_claim=when the host sends the section (studyWorkspace tests) - // Arrange: the 240 px pane with the page at its top, the replay selected but its section not worded yet. + scrollBeforeOpen > 0 && + scrollAtOpen === 0 && + // The same open's later message leaves the learner's own scroll where the learner put it. + scrollAfterLearnerScroll === 0 && + scrollAtNextOpen === 0, + }) + // Value: protects=a test replay's open token surviving a message that names no section yet, so the open shows the summary from its top once the section arrives; fails_when=the token is spent on a message without the section and the open never scrolls (F371); why_new=F371, the F156 case sends its token with the section already shown; changed in Cycle 0051 (moves with D8, F123): the open shows the summary from its top; seam=none; no_claim=when the host sends the section (studyWorkspace tests) + // Arrange: the 240 px pane scrolled to the end, the replay selected but its section not worded yet. await page.setViewportSize({ width: 600, height: shortPaneHeight }) - let topWithoutSection, topWhenSectionArrives + let scrollWithoutSection, scrollWhenSectionArrives try { + await publish(atFirstOutput) + await page.evaluate(() => + window.scrollTo(0, document.body.scrollHeight), + ) await publish({ ...atFirstOutput, nodeHistorySection: undefined, nodeHistoryScrollToken: 21, }) - await page.evaluate(() => window.scrollTo(0, 0)) - topWithoutSection = await page.evaluate(() => - Math.round( - document.getElementById('node-history').getBoundingClientRect().top, - ), - ) + scrollWithoutSection = await page.evaluate(() => Math.round(scrollY)) // Act await publish({ ...atFirstOutput, nodeHistoryScrollToken: 21 }) - topWhenSectionArrives = await page.evaluate(() => - Math.round( - document.getElementById('node-history').getBoundingClientRect().top, - ), + scrollWhenSectionArrives = await page.evaluate(() => + Math.round(scrollY), ) } finally { await page.setViewportSize({ width: 600, height: 800 }) } // Assert cases.push({ - name: 'a test replay’s open token that arrives before its section is worded scrolls the section into view once the section arrives', - actual: { topWithoutSection, topWhenSectionArrives }, - passed: - topWithoutSection >= shortPaneHeight && - topWhenSectionArrives < topWithoutSection && - topWhenSectionArrives < shortPaneHeight, + name: 'a test replay’s open token that arrives before its section is worded shows the summary from its top once the section arrives', + actual: { scrollWithoutSection, scrollWhenSectionArrives }, + passed: scrollWithoutSection > 0 && scrollWhenSectionArrives === 0, }) // Moves with D8 (the later region-2 composition): this Study-pane pin follows the test replay section to wherever D8 places it. // Value: protects=a closed Application pane is named in the test replay section with a way back: the row with Show recorded output appears, its click asks the host for the pane (`focus-app`), and the row goes once the pane is back; fails_when=the row stays hidden while the pane is closed, the button sends another action (`arrange`, which could not bring the pane back), or the row stays after the pane is back; why_new=Step 9 pass 1b's QA critical (Show recorded output could not bring the pane back) and pass 2's testing review: no DOM case showed the row or clicked it; seam=none; no_claim=that the host builds the pane again, which studyWorkspaceTestReplayOutput.test.ts proves @@ -2585,7 +2586,7 @@ async function check(page, options, collected) { rowShownWhenBack: false, }), }) - // Arrange: the keyboard alone, from the opened replay (DS17): Step details, a step of the stop, Next stop, Previous stop, a seek and Pause. + // Arrange: the keyboard alone, from the opened replay (DS17): Step details, a step of the stop, Next stop, Previous stop, End on the slider (the last event, C2) and Pause. await publish(atFirstOutput) await page.evaluate(() => { window.sent = [] @@ -2594,13 +2595,13 @@ async function check(page, options, collected) { await toggleByKeyboard('node-learning-steps-details') await page.locator('#node-learning-steps [data-node-step="keep"]').focus() await page.keyboard.press('Enter') - await page.locator('#node-history-stop-next').focus() + await page.locator('#next-moment').focus() await page.keyboard.press('Enter') await publish(atResult) - await page.locator('#node-history-stop-previous').focus() + await page.locator('#previous-moment').focus() await page.keyboard.press('Enter') await publish(atStop) - await page.locator('#node-history-playhead').focus() + await page.locator('#playhead').focus() await page.keyboard.press('End') await publish( testReplayAt( @@ -2610,7 +2611,7 @@ async function check(page, options, collected) { 420, ), ) - await page.locator('#node-history-playback-toggle').focus() + await page.locator('#playback-toggle').focus() await page.keyboard.press('Enter') const keyboardRequests = await page.evaluate(() => window.sent @@ -2628,7 +2629,7 @@ async function check(page, options, collected) { // Moves with D8 (the later region-2 composition): this Study-pane pin follows the test replay section to wherever D8 places it. // Value: protects=the keyboard alone opens Step details, selects a step of the stop, moves by Next stop and Previous stop, seeks and pauses, each press sending the one request the control names; fails_when=a control cannot be pressed by keyboard or sends another request; why_new=PR-B1 (Design 8, DS17) rebuilt the transport and the summary; seam=none; no_claim=the host's answer to each request and the Application pane's output region (the next case reaches it) cases.push({ - name: 'the keyboard alone opens Step details, selects a step of the stop, moves by Next stop and Previous stop, seeks and pauses a test replay (Design 8)', + name: 'the keyboard alone opens Step details, selects a step of the stop, moves by Next stop and Previous stop, selects the last event by End and pauses a test replay (Design 8)', actual: keyboardRequests, passed: JSON.stringify(keyboardRequests) === @@ -2636,7 +2637,8 @@ async function check(page, options, collected) { { type: 'node-history-event', sequence: 3, stepId: 'keep' }, { type: 'node-history-step', direction: 'next', unit: 'stop' }, { type: 'node-history-step', direction: 'previous', unit: 'stop' }, - { type: 'node-history-playback', action: 'seek' }, + // End on the shared slider selects the history's last event by its sequence (Cycle 0051, C2). + { type: 'node-history-event', sequence: 4 }, { type: 'node-history-playback', action: 'pause' }, ]), }) @@ -2702,10 +2704,10 @@ async function check(page, options, collected) { '#node-learning-steps-details > summary', atFirstOutput, ], - ['nextStop', '#node-history-stop-next', atFirstOutput], - ['previousStop', '#node-history-stop-previous', atResult], - ['playhead', '#node-history-playhead', atStop], - ['pause', '#node-history-playback-toggle', atStop], + ['nextStop', '#next-moment', atFirstOutput], + ['previousStop', '#previous-moment', atResult], + ['playhead', '#playhead', atStop], + ['pause', '#playback-toggle', atStop], ]) { await publish(openedAt(at)) listedWhileOpen.push({ @@ -2819,10 +2821,10 @@ async function check(page, options, collected) { page.evaluate(() => Object.fromEntries( [ - ['previousStop', 'node-history-stop-previous'], - ['nextStop', 'node-history-stop-next'], - ['previousEvent', 'node-history-moment-previous'], - ['nextEvent', 'node-history-moment-next'], + ['previousStop', 'previous-moment'], + ['nextStop', 'next-moment'], + ['previousEvent', 'previous-event'], + ['nextEvent', 'next-event'], ].map(([move, id]) => [ move, !document.getElementById(id).disabled, @@ -3023,7 +3025,8 @@ async function check(page, options, collected) { // Act await publish(hostileStop) const hostileReading = await page.evaluate(() => { - const block = document.getElementById('node-history-learning') + // The summary's learning material spans the section (Cycle 0051, DS-B): the title, the clamped texts, the diagram and Step details. + const block = document.getElementById('node-history') return { elements: block.querySelectorAll('img, script, b').length, invented: window.invented === true, @@ -3036,7 +3039,7 @@ async function check(page, options, collected) { await publish(atStop) // Assert cases.push({ - name: 'markup in a test replay’s learning material is shown as text in the Study section and never becomes an element', + name: 'markup in a test replay’s learning material is shown as text in its Learning summary and never becomes an element', actual: hostileReading, passed: hostileReading.elements === 0 && @@ -3058,7 +3061,7 @@ async function check(page, options, collected) { await publish(atStop) const readStickyBars = () => page.evaluate(() => { - const transport = document.getElementById('node-history-transport'), + const transport = document.getElementById('recording-transport'), strip = document.querySelector('.live-strip'), transportBox = transport.getBoundingClientRect(), focusedBox = document.activeElement.getBoundingClientRect() @@ -3075,6 +3078,10 @@ async function check(page, options, collected) { focusedId: document.activeElement.id, focusedInTransport: transport.contains(document.activeElement), focusedTop: Math.round(focusedBox.top), + focusedBottom: Math.round(focusedBox.bottom), + focusedInSection: document + .getElementById('node-history') + .contains(document.activeElement), } }) // Act: Shift+Tab from the event list back to Step details, reading where each focused control lies. @@ -3125,32 +3132,34 @@ async function check(page, options, collected) { await publish({ ...atStop, liveState: undefined }) await page.setViewportSize({ width: 600, height: 800 }) // Assert + // The transport stands below the section, so Shift+Tab from the event list climbs through the section into the header: the controls of the section are the ones the bars could cover. const scrolledBelowBothBars = withBothBars.filter( - (reading) => !reading.focusedInTransport, + (reading) => reading.focusedInSection, ) const scrolledBelowTransport = focusReadings.filter( - (reading) => !reading.focusedInTransport, + (reading) => reading.focusedInSection, ) cases.push({ - name: 'a control reached by Shift+Tab is never scrolled under the test replay transport, a pane too short for a fixed bar flows it, and a running study keeps it below the live strip', + name: 'a control reached by Shift+Tab is never scrolled under the test replay’s transport fixed at the bottom, in a short pane too, and a running study keeps it clear of the live strip', actual: { focusReadings, inShortPane, withRunningStudy, withBothBars }, + // The shared transport is the browser replay's, fixed at the pane's bottom with its own scroll room (Cycle 0051, DS-C): a focused control lies above it. passed: scrolledBelowTransport.length >= 3 && scrolledBelowTransport.every( (reading) => - reading.position === 'sticky' && - reading.transportTop === 0 && - reading.focusedTop >= reading.transportBottom - 1, + reading.position === 'fixed' && + reading.focusedBottom <= reading.transportTop + 1, ) && - inShortPane.position === 'static' && + inShortPane.position === 'fixed' && withRunningStudy.stripSticky && withRunningStudy.transportTop >= withRunningStudy.stripBottom - 1 && - // Both bars pinned: the focused control lies below the lower of the two (a focus scroll room without the strip scrolls it under the transport). + // Both bars pinned: the focused control lies between the strip and the transport. scrolledBelowBothBars.length >= 3 && scrolledBelowBothBars.every( (reading) => reading.stripSticky && - reading.focusedTop >= reading.transportBottom - 1, + reading.focusedTop >= reading.stripBottom - 1 && + reading.focusedBottom <= reading.transportTop + 1, ), }) // Arrange: the section's states before it has a replay or an event to show (DS7), in the pane's fixed words (the String catalog): no replay saved, replays saved and none chosen, then a replay with no event selected and no saved learning, then the same replay with its learning again. @@ -5943,9 +5952,10 @@ async function check(page, options, collected) { await publish(nodeWithNativeValues) // Assert cases.push({ - name: 'after Show Variables is pressed by keyboard and the values load, focus rests on Play, not on the document body (Node test history)', + name: 'after Show Variables is pressed by keyboard and the values load, focus rests on the summary’s title heading, not on the document body (Node test history)', actual: await focusedControl(), - passed: (await focusedControl()) === 'node-history-playback-toggle', + // The summary's fallback (Cycle 0051, DS-E): the moment block's step heading, else the title heading; this stop has no saved learning, so no step heading. + passed: (await focusedControl()) === 'title', }) // Value: protects=a keyboard learner on Inspect in native debugger keeps the pane's focus when the control is hidden (the stop's values load, or Play moves to an output), as with Show Variables: it goes to the first usable control of the transport, never to the document body; fails_when=Inspect is hidden through `hidden` alone, so the focused control disappears and the focus falls to the body; why_new=F292 (OCR batch C): the Inspect control was hidden with no focus handling; seam=none; no_claim=the host's answer to the press or native pixels @@ -5961,17 +5971,18 @@ async function check(page, options, collected) { inspectHidden: document.getElementById('node-history-inspect').hidden, focusedId: document.activeElement?.id ?? '', inTransport: document - .getElementById('node-history-transport') + .getElementById('recording-transport') .contains(document.activeElement), })) // Assert cases.push({ - name: 'when Inspect in native debugger is hidden while the keyboard stands on it, the focus goes to the test replay transport, not to the document body', + name: 'when Inspect in native debugger is hidden while the keyboard stands on it, the focus goes to the summary’s fallback heading, not to the document body', actual: { inspectFocused, afterInspectHidden }, passed: inspectFocused === 'node-history-inspect' && afterInspectHidden.inspectHidden && - afterInspectHidden.inTransport, + // The summary's fallback (Cycle 0051, DS-E): no saved learning, so the title heading. + afterInspectHidden.focusedId === 'title', }) // Act: Play cannot take the focus at the end of a recording (PC-3.7). @@ -6039,17 +6050,17 @@ async function check(page, options, collected) { }) const focusWithSingleEvent = await page.evaluate(() => ({ transportDisabled: [ - 'node-history-playback-toggle', - 'node-history-playhead', - 'node-history-moment-previous', - 'node-history-moment-next', + 'playback-toggle', + 'playhead', + 'previous-event', + 'next-event', ].every((id) => document.getElementById(id).disabled), - focused: document.activeElement.textContent, + focused: document.activeElement.id, focusedSequence: document.activeElement.dataset.nodeHistorySequence, })) // Assert cases.push({ - name: "when Play cannot be used as Show Variables leaves, focus goes to the first transport control that can: at the end of a recording and while a moment loads; in a history with a single event it goes to the collapsed event list's summary, Events (1)", + name: "when Play cannot be used as Show Variables leaves, focus goes to the first transport control that can: at the end of a recording and while a moment loads; in a history with a single event it goes to the summary's title heading", actual: { focusAtRecordingEnd, focusWhileLoading, @@ -6061,7 +6072,8 @@ async function check(page, options, collected) { focusWhileLoading.focused === 'playback-toggle' && focusWhileLoading.label === 'Pause' && focusWithSingleEvent.transportDisabled && - focusWithSingleEvent.focused === 'Events (1)' && + // The summary's fallback (Cycle 0051, DS-E): the title heading, the shared transport having no usable control for one event. + focusWithSingleEvent.focused === 'title' && focusWithSingleEvent.focusedSequence === undefined, }) await publish({ ...replay, revision: revisionAt(10) }) @@ -6431,9 +6443,10 @@ async function check(page, options, collected) { }), }) - // Arrange: a retained Node journal must not add another transport to application replay (row 509). + // Arrange: a retained Node journal must not add another transport to application replay (row 509); the host shows a selected test replay in the summary composition (Cycle 0051, DS-A). const nodeJournalWithoutReplay = { ...options.message, + replayFamily: 'test', nodeHistory: { header: { studyId: 'node-study' }, status: 'complete', @@ -6482,6 +6495,7 @@ async function check(page, options, collected) { await publish({ ...panelReplay, ...nodeJournalWithoutReplay, + replayFamily: 'browser', recording: panelReplay.recording, }) // Act: inspect the unrelated controls and status regions, then return to the independent Node reader. @@ -6496,15 +6510,6 @@ async function check(page, options, collected) { .evaluate((section) => Math.round(section.getBoundingClientRect().height), ), - nodePlayVisible: await page - .locator('#node-history-playback-toggle') - .isVisible(), - nodePlayDisabled: await page - .locator('#node-history-playback-toggle') - .isDisabled(), - nodeTimelineVisible: await page - .locator('#node-history-playhead') - .isVisible(), applicationPlayVisible: await page .locator('#playback-toggle') .isVisible(), @@ -6513,20 +6518,20 @@ async function check(page, options, collected) { await publish(nodeJournalWithoutReplay) const nodeAfterApplicationReplay = { status: await page.locator('#node-history-status').textContent(), - nodePlayVisible: await page - .locator('#node-history-playback-toggle') - .isVisible(), - nodePlayEnabled: await page - .locator('#node-history-playback-toggle') - .isEnabled(), - nodeTimelineVisible: await page - .locator('#node-history-playhead') - .isVisible(), + sectionHeight: await page + .locator('#node-history') + .evaluate((section) => + Math.round(section.getBoundingClientRect().height), + ), + playVisible: await page.locator('#playback-toggle').isVisible(), + playEnabled: await page.locator('#playback-toggle').isEnabled(), + timelineVisible: await page.locator('#playhead').isVisible(), + eventStepsVisible: await page.locator('#next-event').isVisible(), understood: await page.locator('#node-history-understood').isChecked(), } // Assert: empty rendered status nodes take no space; the normal Node reader keeps its own controls. cases.push({ - name: 'application replay in the bottom panel shows one transport and empty rendered Node status regions; leaving it restores the independent Node journal without marking understanding', + name: 'application replay in the bottom panel shows one transport and empty rendered Node status regions; leaving it for the test replay shows the shared transport alone, without marking understanding', actual: { nodeDuringApplicationReplay, nodeAfterApplicationReplay }, passed: JSON.stringify(nodeDuringApplicationReplay) === @@ -6537,19 +6542,18 @@ async function check(page, options, collected) { emptyRegion('node-history-hint'), ], height: 0, - nodePlayVisible: false, - nodePlayDisabled: true, - nodeTimelineVisible: false, applicationPlayVisible: true, looks: { moved: [], samePixels: true }, }) && + // The test replay in the bottom panel shows the shared transport with its event steps and no second copy of the section (Cycle 0051, DS-D). JSON.stringify(nodeAfterApplicationReplay) === JSON.stringify({ - status: - 'No result recorded · capture complete · 0 of 0 stops explained', - nodePlayVisible: true, - nodePlayEnabled: true, - nodeTimelineVisible: true, + status: '', + sectionHeight: 0, + playVisible: true, + playEnabled: true, + timelineVisible: true, + eventStepsVisible: true, understood: false, }), }) @@ -6600,6 +6604,7 @@ async function check(page, options, collected) { await publish({ ...panelReplay, ...journalWithLearning, + replayFamily: 'browser', recording: panelReplay.recording, }) const learningDuringReplay = await learningBlock() @@ -6607,8 +6612,9 @@ async function check(page, options, collected) { const learningAfterReplay = await learningBlock() // Assert cases.push({ - name: 'while a browser replay holds the bottom panel the test replay’s learning block and its missing-material line are not drawn, and the block returns when the replay is left', + name: 'while a replay of either family holds the bottom panel the test replay’s learning block and its missing-material line are not drawn there', actual: { learningDuringReplay, learningAfterReplay }, + // The test replay's learning block is the Learning summary's; the bottom panel shows its transport alone (Cycle 0051, DS-D). passed: JSON.stringify(learningDuringReplay) === JSON.stringify({ @@ -6618,9 +6624,9 @@ async function check(page, options, collected) { }) && JSON.stringify(learningAfterReplay) === JSON.stringify({ - blockHidden: false, + blockHidden: true, noMaterialHidden: true, - missingEventLines: 1, + missingEventLines: 0, }), }) // Value: protects=a test replay's open token surviving a browser replay that holds the bottom panel, so the section is scrolled into view once it shows instead of the token being spent while it is not; fails_when=the token is spent on the first message that names it while the replay is shown, so the open never scrolls once the replay is left (F371); why_new=F371, the F156 case sends its token with the section already shown; seam=none; no_claim=where the host places the panes or that a hidden bottom-panel section scrolls anything (native check) @@ -9854,6 +9860,1613 @@ async function check(page, options, collected) { railStrip.railScrollHeight <= railStrip.railHeight && railStrip.groupBottomBelowRailBottom <= 0, }) + { + // Cycle 0051: a test replay in the region-2 composition of the v9 image (DS-A to DS-G; AC2 to AC5, AC11, AC13, AC14). Each case loads a fresh document of its pane, so no earlier case's focus or scroll reaches it. + /** Loads a fresh document of one pane kind at a viewport, with a recorder of what it sends, and waits for its ready message. */ + const freshPane = async (kind, viewport = { width: 420, height: 720 }) => { + await page.setViewportSize(viewport) + await page.goto(options.url) + await page.evaluate(() => { + window.sent = [] + window.acquireVsCodeApi = () => ({ + getState: () => undefined, + setState() {}, + postMessage: (message) => window.sent.push(message), + }) + }) + await page.setContent(options.html[kind]) + await page.waitForFunction(() => + window.sent.some((message) => message.type === 'ready'), + ) + } + let regionTwoRevision = 200 + const c51Hash = 'c'.repeat(64) + /** One recorded stop of the revenue query's test replay. */ + const c51Stop = (sequence, index, elapsedMs, line) => ({ + type: 'stop', + sequence, + index, + elapsedMs, + source: { path: 'app/lib/data.ts', hash: c51Hash, line, column: 3 }, + frame: { name: 'fetchRevenue', line, column: 3 }, + facts: [], + debuggerSnapshot: { + frames: [ + { + line, + column: 3, + source: { name: 'app/lib/data.ts', hash: c51Hash, line }, + }, + ], + variables: [], + }, + }) + const c51Entries = [ + { + type: 'output', + sequence: 1, + elapsedMs: 100, + channel: 'stdout', + text: 'RUN app/lib/data.test.ts\n', + }, + c51Stop(2, 0, 300, 12), + { + type: 'output', + sequence: 3, + elapsedMs: 450, + channel: 'stdout', + text: 'revenue rows: 12\n', + }, + c51Stop(4, 1, 600, 18), + { + type: 'result', + sequence: 5, + elapsedMs: 900, + exitCode: 0, + signal: null, + outputTail: '', + }, + ] + const c51Content = { + question: 'Which months does the revenue query return?', + title: 'How the revenue query reads its rows', + summary: + 'The test calls fetchRevenue, which reads the rows and returns them in month order.', + participants: [ + { id: 'test', label: 'Revenue test' }, + { id: 'query', label: 'fetchRevenue' }, + { id: 'db', label: 'Database' }, + ], + steps: [ + { + id: 'call', + from: 'test', + to: 'query', + label: 'Call fetchRevenue', + explanation: 'The test calls the query.', + evidence: 'observed', + reference: { type: 'stop', sequence: 2, sourceHash: c51Hash }, + }, + { + id: 'rows', + from: 'query', + to: 'db', + label: 'Read the rows', + explanation: 'The query reads twelve rows.', + evidence: 'observed', + reference: { type: 'stop', sequence: 4, sourceHash: c51Hash }, + }, + { + id: 'verdict', + from: 'query', + to: 'test', + label: 'Return the rows', + explanation: 'The rows come back and the test passes.', + evidence: 'observed', + reference: { type: 'result', sequence: 5 }, + }, + ], + } + const c51Positions = [ + { stepId: 'call', elapsedMs: 300, sequence: 2 }, + { stepId: 'rows', elapsedMs: 600, sequence: 4 }, + { stepId: 'verdict', elapsedMs: 900, sequence: 5 }, + ] + const notRecordedLine = 'Source and debugger not recorded at this moment.' + // What the host says at each moment (projectNodeTestSection), by sequence. + const c51Moments = { + 1: { + position: 'Output · 0.10 s', + notRecorded: notRecordedLine, + previousStop: false, + nextStop: true, + }, + 2: { + position: 'Stop 1 of 2 · 0.30 s', + explanation: 'The test calls the query.', + selectedStepId: 'call', + previousStop: false, + nextStop: true, + }, + 3: { + position: 'Output · 0.45 s', + notRecorded: notRecordedLine, + previousStop: true, + nextStop: true, + }, + 4: { + position: 'Stop 2 of 2 · 0.60 s', + explanation: 'The query reads twelve rows.', + selectedStepId: 'rows', + previousStop: true, + nextStop: false, + }, + 5: { + position: 'Result · Test passed · exit code 0 · 0.90 s', + explanation: 'The rows come back and the test passes.', + notRecorded: notRecordedLine, + selectedStepId: 'verdict', + previousStop: true, + nextStop: false, + }, + } + /** + * Builds the Study message of the revenue query's test replay at one moment, as the host sends it in the summary composition: the family named, the whole-history first and last events, the transport in this pane unless `extra` says the bottom panel holds it. + */ + const regionTwoAt = (sequence, extra = {}) => { + const entry = c51Entries.find( + (candidate) => candidate.sequence === sequence, + ) + return { + ...options.message, + revision: revisionAt(regionTwoRevision++), + replayFamily: 'test', + playbackPanelVisible: false, + nodeHistoryEventCount: c51Entries.length, + nodeHistory: { + header: { studyId: 'revenue-study' }, + status: 'complete', + entries: c51Entries, + }, + nodeHistoryCatalog: [ + { + studyId: 'revenue-study', + testFile: 'app/lib/data.test.ts', + testTitles: ['revenue', 'returns twelve months'], + }, + ], + nodeHistoryLearningSummary: { + content: c51Content, + positions: c51Positions, + }, + nodeHistoryPosition: + entry.type === 'stop' + ? { + sequence, + eventIndex: sequence - 1, + sourceAvailable: true, + debuggerAvailable: true, + } + : { sequence, eventIndex: sequence - 1 }, + nodeHistorySection: { + status: + 'Test passed · exit code 0 · capture complete · 2 of 2 stops explained', + pointer: 'Output up to this moment is in the Application pane.', + applicationClosed: false, + sameMomentStepIds: [], + stepActions: { + call: 'Inspect stop 1 · 0.30 s', + rows: 'Inspect stop 2 · 0.60 s', + verdict: 'Inspect result · 0.90 s', + }, + events: 'Events (5)', + verdict: 'Test passed · exit code 0', + timeline: { + startMs: 100, + endMs: 900, + gaps: [{ startMs: 100, endMs: 900 }], + recordedPoints: [300, 600], + }, + ...c51Moments[sequence], + }, + nodeHistoryPlayback: { + mode: 'paused', + sequence, + positionMs: entry.elapsedMs, + startMs: 100, + endMs: 900, + loading: false, + firstSequence: 1, + lastSequence: 5, + }, + ...extra, + } + } + /** A browser replay's Study message in the summary composition, paused at 5 s of a 20 s recording. */ + const browserReplayAt = (extra = {}) => ({ + ...options.message, + revision: revisionAt(regionTwoRevision++), + contentTab: 'recording', + replayFamily: 'browser', + playbackPanelVisible: false, + recording: { + revision: revisionAt(regionTwoRevision++), + mode: 'paused', + readiness: 'ready', + cleared: false, + presentedMs: 5000, + range: { startMs: 0, endMs: 20000, endKnown: true }, + recordingStatus: 'finalized', + }, + ...extra, + }) + /** Reads the node-history messages the pane sent since the recorder was last cleared, in the fields a request carries. */ + const nodeRequests = () => + page.evaluate(() => + window.sent + .filter((message) => message.type.startsWith('node-history')) + .map(({ type, direction, unit, action, sequence, positionMs }) => ({ + type, + ...(direction === undefined ? {} : { direction }), + ...(unit === undefined ? {} : { unit }), + ...(action === undefined ? {} : { action }), + ...(sequence === undefined ? {} : { sequence }), + ...(positionMs === undefined ? {} : { positionMs }), + })), + ) + const clearSent = () => + page.evaluate(() => { + window.sent = [] + }) + /** Reads which of the summary's parts are shown, in the order the page holds them (DS-B). */ + const summaryOrder = () => + page.evaluate(() => { + const ids = [ + 'title', + 'node-learning-title', + 'node-history-status', + 'node-history-capture-reason', + 'node-learning-question', + 'node-learning-summary', + 'node-learning-sequence', + 'node-learning-steps-details', + 'node-history-step-heading', + 'node-history-position', + 'node-history-explanation', + 'node-history-not-recorded', + 'node-history-unverified', + 'node-history-pointer', + 'node-history-understood', + 'node-history-events-details', + ] + return ids + .map((id) => document.getElementById(id)) + .filter((element) => element.checkVisibility()) + .sort((a, b) => + a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING + ? -1 + : 1, + ) + .map((element) => element.id) + }) + /** Reads the drawing's marked step: each marked node by tag and step, and how the marked label is drawn. */ + const drawingMark = () => + page.evaluate(() => { + const marked = Array.from( + document.querySelectorAll( + '#node-learning-sequence [aria-current="step"]', + ), + ) + const label = marked.find((node) => node.tagName === 'text') + return { + marked: marked.map((node) => `${node.tagName}:${node.dataset.step}`), + classed: marked.every((node) => + node.classList.contains('current-step'), + ), + weight: label ? getComputedStyle(label).fontWeight : '', + underline: label + ? getComputedStyle(label).textDecorationLine.includes('underline') + : false, + heading: document.getElementById('node-history-step-heading').hidden + ? '' + : document.getElementById('node-history-step-heading').textContent, + detailsOpen: document.getElementById('node-learning-steps-details') + .open, + } + }) + + const longText = (start) => + `${start} ${'month rows are read in order and checked against the expected list. '.repeat(120)}`.slice( + 0, + 8192, + ) + const longRegionTwo = (sequence, extra = {}) => + regionTwoAt(sequence, { + nodeHistoryLearningSummary: { + content: { + ...c51Content, + question: + 'Which months does the revenue query return, in which order, and what does the test compare them with before it passes?', + summary: longText('The test calls fetchRevenue.'), + participants: [ + ...c51Content.participants, + { id: 'cache', label: 'Revenue cache' }, + { id: 'clock', label: 'Clock' }, + { id: 'log', label: 'Logger' }, + ], + }, + positions: c51Positions, + }, + nodeHistorySection: { + ...regionTwoAt(sequence).nodeHistorySection, + status: + 'Test passed · exit code 0 · capture partial · 2 of 2 stops explained', + captureReason: longText('Capture interrupted:'), + }, + ...extra, + }) + const clampReading = () => + page.evaluate(() => + Array.from(document.querySelectorAll('[data-show-more]')).map( + (control) => { + const target = document.getElementById(control.dataset.showMore) + return { + id: target.id, + controlShown: !control.hidden, + expanded: control.getAttribute('aria-expanded'), + label: control.textContent, + clamped: target.classList.contains('clamp'), + cut: target.scrollHeight > target.clientHeight + 1, + } + }, + ), + ) + /** Reads the shown controls of the transport's playback row, in order, by id or name. */ + const playbackRow = () => + page.evaluate(() => + Array.from( + document.querySelectorAll( + '#recording-transport .playback-row button, #recording-transport .playback-row input', + ), + ) + .filter((control) => control.checkVisibility()) + .map( + (control) => + control.id || + control.getAttribute('aria-label') || + control.textContent, + ), + ) + const transportControls = () => + page.evaluate(() => + Array.from( + document.querySelectorAll( + '#recording-transport button, #recording-transport input, #recording-transport summary', + ), + ) + .filter((control) => control.checkVisibility()) + .map( + (control) => + `${control.id || control.getAttribute('aria-label') || control.textContent.trim()}`, + ), + ) + /** Reads the browser seeks the pane sent since the recorder was cleared. */ + const playbackSeeks = () => + page.evaluate(() => + window.sent + .filter( + (message) => + message.type === 'playback' && message.action === 'seek', + ) + .map((message) => message.positionMs), + ) + const hostileLabel = 'Read "rows"injected' + /** Runs one case group of this block, and records a group that throws (a control or line it reads is missing, as on a build without the change) as one failed case, so every group gives its own result. */ + const regionTwoCase = async (group, body) => { + try { + await body() + } catch (error) { + cases.push({ + name: `a Cycle 0051 case group threw: ${group}`, + actual: String(error).slice(0, 400), + passed: false, + }) + } + } + // Value: protects=a test replay's Learning summary in region 2's order (DS-B): title, status and question, summary, diagram and Step details, the moment block with its step heading, the understanding check and the event list, for a stop, an output and the result; fails_when=a part is missing, out of order, or the step heading names the step of another moment; why_new=Cycle 0051 AC2: the parts stood in the Study pane's section in another order; seam=none; no_claim=the host's wording of each line (projectNodeTestSection's tests) + await regionTwoCase( + "a test replay's Learning summary in region 2's order (DS-B): title, status and question, summary, diagram and Step detai", + async () => { + await freshPane('context') + for (const [sequence, kind, expected, heading] of [ + [ + 2, + 'a stop', + [ + 'title', + 'node-learning-title', + 'node-history-status', + 'node-learning-question', + 'node-learning-summary', + 'node-learning-sequence', + 'node-learning-steps-details', + 'node-history-step-heading', + 'node-history-position', + 'node-history-explanation', + 'node-history-pointer', + 'node-history-understood', + 'node-history-events-details', + ], + 'Step 1 · Call fetchRevenue', + ], + [ + 3, + 'an output', + [ + 'title', + 'node-learning-title', + 'node-history-status', + 'node-learning-question', + 'node-learning-summary', + 'node-learning-sequence', + 'node-learning-steps-details', + 'node-history-position', + 'node-history-not-recorded', + 'node-history-pointer', + 'node-history-understood', + 'node-history-events-details', + ], + '', + ], + [ + 5, + 'the result', + [ + 'title', + 'node-learning-title', + 'node-history-status', + 'node-learning-question', + 'node-learning-summary', + 'node-learning-sequence', + 'node-learning-steps-details', + 'node-history-step-heading', + 'node-history-position', + 'node-history-explanation', + 'node-history-not-recorded', + 'node-history-pointer', + 'node-history-understood', + 'node-history-events-details', + ], + 'Step 3 · Return the rows', + ], + ]) { + // Arrange & Act + await publish(regionTwoAt(sequence)) + const order = await summaryOrder() + const reading = await drawingMark() + // Assert + cases.push({ + name: `a test replay’s Learning summary at ${kind} shows region 2’s parts in its order, with the step heading of that moment only`, + actual: { order, heading: reading.heading }, + passed: + JSON.stringify(order) === JSON.stringify(expected) && + reading.heading === heading, + }) + } + }, + ) + // Value: protects=the drawing marks the selected step on its arrow and label with aria-current="step" and a cue that is not color alone (weight 600, underline), with Step details collapsed, and the moment block's heading names it; fails_when=the mark needs the opened Step details, rides on color alone, or names another step; why_new=Cycle 0051 DS-B (AC2): the PNG marks its current arrow, and only the numbered steps were marked; seam=none; no_claim=native pixels + await regionTwoCase( + 'the drawing marks the selected step on its arrow and label with aria-current="step" and a cue that is not color alone (w', + async () => { + await publish(regionTwoAt(4)) + const markAtStop = await drawingMark() + cases.push({ + name: 'a test replay’s drawing marks the selected step’s arrow and label with Step details collapsed, by weight and underline as well as color, and the step heading names it', + actual: markAtStop, + passed: + JSON.stringify(markAtStop) === + JSON.stringify({ + marked: ['path:rows', 'text:rows'], + classed: true, + weight: '600', + underline: true, + heading: 'Step 2 · Read the rows', + detailsOpen: false, + }), + }) + }, + ) + // Value: protects=an event no step references (an output) marks no step and shows no step heading, never the step of an earlier moment; fails_when=the mark or the heading of the stop before stays; why_new=Cycle 0051 DS-B (AC2); seam=none; no_claim=none beyond protects + await regionTwoCase( + 'an event no step references (an output) marks no step and shows no step heading, never the step of an earlier moment', + async () => { + await publish(regionTwoAt(3)) + const markAtOutput = await drawingMark() + cases.push({ + name: 'a test replay’s output marks no step in the drawing and shows no step heading after a stop that had one', + actual: markAtOutput, + passed: + markAtOutput.marked.length === 0 && + markAtOutput.heading === '' && + !markAtOutput.detailsOpen, + }) + }, + ) + // Value: protects=a pending moment (D29) withholds the step heading, the explanation and the drawing's mark although the host's section still names the step; fails_when=the mark or the heading shows the moment before it commits; why_new=Cycle 0051 DS-G (AC14): the mark is new; seam=none; no_claim=native paint timing + await regionTwoCase( + "a pending moment (D29) withholds the step heading, the explanation and the drawing's mark although the host's section st", + async () => { + await publish(regionTwoAt(4)) + await publish( + regionTwoAt(4, { + nodeHistoryPosition: { sequence: 5, eventIndex: 4, pending: true }, + nodeHistorySection: { + ...regionTwoAt(5).nodeHistorySection, + explanation: undefined, + }, + }), + ) + const markWhilePending = await drawingMark() + const explanationWhilePending = await page + .locator('#node-history-explanation') + .textContent() + cases.push({ + name: 'a pending test replay moment withholds the step heading, the explanation and the drawing’s mark', + actual: { markWhilePending, explanationWhilePending }, + passed: + markWhilePending.marked.length === 0 && + markWhilePending.heading === '' && + explanationWhilePending === '', + }) + }, + ) + // Value: protects=the mark moves by attribute on the drawing that stays, so a move keeps the diagram's own scroll position (and the focus inside it); fails_when=each move draws the diagram again, which resets its scroll; why_new=Cycle 0051 DS-B, the Eng review's finding; seam=none; no_claim=none beyond protects + await regionTwoCase( + "the mark moves by attribute on the drawing that stays, so a move keeps the diagram's own scroll position (and the focus ", + async () => { + await page.setViewportSize({ width: 298, height: 885 }) + await publish(regionTwoAt(2)) + await page.evaluate(() => { + const area = document.querySelector( + '#node-learning-sequence .sequence-diagram', + ) + window.regionTwoDrawing = area.querySelector('svg') + area.scrollLeft = 40 + area.scrollTop = 10 + }) + const scrollBefore = await page.evaluate(() => { + const area = document.querySelector( + '#node-learning-sequence .sequence-diagram', + ) + return { left: area.scrollLeft, top: area.scrollTop } + }) + await publish(regionTwoAt(4)) + const afterMove = await page.evaluate(() => { + const area = document.querySelector( + '#node-learning-sequence .sequence-diagram', + ) + return { + sameDrawing: area.querySelector('svg') === window.regionTwoDrawing, + left: area.scrollLeft, + top: area.scrollTop, + } + }) + const markAfterMove = await drawingMark() + cases.push({ + name: 'a test replay move marks the next step on the drawing that stays, keeping the diagram’s scroll position', + actual: { scrollBefore, afterMove, marked: markAfterMove.marked }, + passed: + scrollBefore.left > 0 && + afterMove.sameDrawing && + afterMove.left === scrollBefore.left && + afterMove.top === scrollBefore.top && + JSON.stringify(markAfterMove.marked) === + JSON.stringify(['path:rows', 'text:rows']), + }) + }, + ) + // Value: protects=markup in a step label and an output line (`<`, `"`, ``) shown as text in the summary's SVG, Step details and the event list, never as an element; fails_when=innerHTML or unescaped SVG text turns it into markup; why_new=Cycle 0051 AC2: the strings moved into a new view; seam=none; no_claim=CSP enforcement in the real webview + await regionTwoCase( + 'markup in a step label and an output line (`<`, `"`, ``) shown as text in the summary\'s SVG, Step details and the', + async () => { + await freshPane('context') + await publish( + regionTwoAt(4, { + nodeHistory: { + header: { studyId: 'revenue-study' }, + status: 'complete', + entries: c51Entries.map((entry) => + entry.sequence === 3 + ? { ...entry, text: `${hostileLabel}\n` } + : entry, + ), + }, + nodeHistoryLearningSummary: { + content: { + ...c51Content, + steps: c51Content.steps.map((step) => + step.id === 'rows' ? { ...step, label: hostileLabel } : step, + ), + }, + positions: c51Positions, + }, + }), + ) + await page.evaluate(() => { + document.getElementById('node-history-events-details').open = true + }) + const hostileRegionTwo = await page.evaluate( + (label) => ({ + elements: document.querySelectorAll('#node-history b').length, + drawnLabel: + document.querySelector( + '#node-learning-sequence text[data-step="rows"]', + )?.textContent ?? '', + svgTexts: document.querySelectorAll('#node-learning-sequence text') + .length, + heading: document.getElementById('node-history-step-heading') + .textContent, + eventListHoldsText: document + .getElementById('node-history-events') + .textContent.includes(label), + }), + hostileLabel, + ) + cases.push({ + name: 'markup in a test replay’s step label and output line is shown as text in the summary’s drawing, step heading and event list', + actual: hostileRegionTwo, + passed: + hostileRegionTwo.elements === 0 && + // The drawing sets a label as one span per word after its number, so its text reads without the spaces; three participants and three steps make its six text elements, none of them the injected one. + hostileRegionTwo.drawnLabel === + `2. ${hostileLabel.replace(/\s/g, '')}` && + hostileRegionTwo.svgTexts === 6 && + hostileRegionTwo.heading === `Step 2 · ${hostileLabel}` && + hostileRegionTwo.eventListHoldsText, + }) + }, + ) + // Value: protects=the question, the processing summary and the capture reason each clamped to three lines with Show more, which shows the whole text in place and Show less takes back; fails_when=a long text pushes the moment block down unclamped, the control is missing, or it does not expand and collapse; why_new=Cycle 0051 DS-B, the Eng review's finding (schema-length texts); seam=none; no_claim=native fonts + await regionTwoCase( + 'the question, the processing summary and the capture reason each clamped to three lines with Show more, which shows the ', + async () => { + await freshPane('context', { width: 298, height: 885 }) + await publish(longRegionTwo(4)) + const clampedTexts = await clampReading() + await page.locator('[data-show-more="node-learning-summary"]').click() + const expandedSummary = (await clampReading()).find( + (reading) => reading.id === 'node-learning-summary', + ) + await page.locator('[data-show-more="node-learning-summary"]').click() + const collapsedSummary = (await clampReading()).find( + (reading) => reading.id === 'node-learning-summary', + ) + cases.push({ + name: 'a test replay’s long processing summary and capture reason are clamped to three lines with Show more, which expands one in place and Show less takes back', + actual: { clampedTexts, expandedSummary, collapsedSummary }, + passed: + ['node-history-capture-reason', 'node-learning-summary'].every( + (id) => + clampedTexts.some( + (reading) => + reading.id === id && + reading.controlShown && + reading.expanded === 'false' && + reading.label === 'Show more' && + reading.clamped && + reading.cut, + ), + ) && + expandedSummary.expanded === 'true' && + expandedSummary.label === 'Show less' && + !expandedSummary.clamped && + !expandedSummary.cut && + collapsedSummary.expanded === 'false' && + collapsedSummary.clamped, + }) + }, + ) + // Value: protects=the first viewport at task 2's measured summary sizes (VS Code 298 × 885, Cursor 440 × 965): with a three-line question, schema-length summary and capture reason and six participants, the moment block's step heading starts inside the view without scrolling; fails_when=the long texts or the diagram push the moment below the fold; why_new=Cycle 0051 DS-B (AC2); seam=none; no_claim=sizes in hosts other than task 2's measurement + await regionTwoCase( + "the first viewport at task 2's measured summary sizes (VS Code 298 × 885, Cursor 440 × 965): with a three-line question,", + async () => { + const firstViewport = {} + for (const [host, viewport] of [ + ['VS Code', { width: 298, height: 885 }], + ['Cursor', { width: 440, height: 965 }], + ]) { + await freshPane('context', viewport) + await publish(longRegionTwo(4, { playbackPanelVisible: true })) + await page.evaluate(() => window.scrollTo(0, 0)) + firstViewport[host] = await page.evaluate(() => { + const box = document + .getElementById('node-history-step-heading') + .getBoundingClientRect() + return { + top: Math.round(box.top), + bottom: Math.round(box.bottom), + viewportHeight: innerHeight, + } + }) + } + cases.push({ + name: 'at the measured Learning summary sizes the moment block’s step heading starts in the first viewport with schema-length texts and six participants', + actual: firstViewport, + passed: Object.values(firstViewport).every( + (reading) => + reading.top >= 0 && reading.bottom <= reading.viewportHeight, + ), + }) + }, + ) + // Value: protects=each of the shared transport's seven move controls sends the test replay's own request (DS-C): Replay from start the first event by sequence, the stop and event buttons their steps, Play the playback, the slider a seek; fails_when=a control sends the browser's request, another test replay request, or nothing; why_new=Cycle 0051 AC3: the test replay had a transport of its own; seam=none; no_claim=the host's answer (studyWorkspace tests) + await regionTwoCase( + "each of the shared transport's seven move controls sends the test replay's own request (DS-C): Replay from start the fir", + async () => { + await freshPane('context') + await publish(regionTwoAt(3)) + for (const [control, act, expected] of [ + [ + 'Replay from start', + () => page.locator('[data-playback="start"]').click(), + { type: 'node-history-event', sequence: 1 }, + ], + [ + 'Previous recorded stop', + () => page.locator('#previous-moment').click(), + { type: 'node-history-step', direction: 'previous', unit: 'stop' }, + ], + [ + 'Play', + () => page.locator('#playback-toggle').click(), + { type: 'node-history-playback', action: 'play' }, + ], + [ + 'Next recorded stop', + () => page.locator('#next-moment').click(), + { type: 'node-history-step', direction: 'next', unit: 'stop' }, + ], + [ + 'the slider', + () => + page.locator('#playhead').evaluate((playhead) => { + playhead.value = '700' + playhead.dispatchEvent(new Event('input', { bubbles: true })) + playhead.dispatchEvent(new Event('change', { bubbles: true })) + }), + { type: 'node-history-playback', action: 'seek', positionMs: 700 }, + ], + [ + 'Previous event', + () => page.locator('#previous-event').click(), + { type: 'node-history-step', direction: 'previous', unit: 'event' }, + ], + [ + 'Next event', + () => page.locator('#next-event').click(), + { type: 'node-history-step', direction: 'next', unit: 'event' }, + ], + ]) { + // Arrange + await publish(regionTwoAt(3)) + await clearSent() + // Act + await act() + const requests = await nodeRequests() + // Assert + cases.push({ + name: `the shared transport’s ${control} sends the test replay’s own request`, + actual: requests, + passed: JSON.stringify(requests) === JSON.stringify([expected]), + }) + } + }, + ) + // Value: protects=the transport's order and groups per family (DS-C): ?, Replay from start, Previous stop, Play, Next stop, the slider, then for a test replay only the event group; fails_when=the event steps show for a browser replay, stand before the slider, or a control moves; why_new=Cycle 0051 AC3; seam=none; no_claim=native layout + await regionTwoCase( + "the transport's order and groups per family (DS-C): ?, Replay from start, Previous stop, Play, Next stop, the slider, th", + async () => { + await publish(regionTwoAt(3)) + const testReplayRow = await playbackRow() + cases.push({ + name: 'a test replay’s transport row reads ?, Replay from start, Previous stop, Play, Next stop, the slider, then Previous event and Next event', + actual: testReplayRow, + passed: + JSON.stringify(testReplayRow) === + JSON.stringify([ + 'Keyboard shortcuts', + 'Replay from start', + 'previous-moment', + 'playback-toggle', + 'next-moment', + 'playhead', + 'previous-event', + 'next-event', + ]), + }) + await publish(browserReplayAt()) + const browserReplayRow = await playbackRow() + cases.push({ + name: 'a browser replay’s transport row keeps its controls and order with no event steps', + actual: browserReplayRow, + passed: + JSON.stringify(browserReplayRow) === + JSON.stringify([ + 'Keyboard shortcuts', + 'Replay from start', + 'previous-moment', + 'playback-toggle', + 'next-moment', + 'playhead', + ]), + }) + }, + ) + // Value: protects=a browser replay's transport in the Learning summary keeps exactly today's controls (ids, order, labels): the 5A button stands in the bottom panel's transport only; fails_when=any browser control is added, removed, renamed or reordered in the summary; why_new=Cycle 0051 AC3 (a closed list); seam=none; no_claim=the bottom panel (its own case) + await regionTwoCase( + "a browser replay's transport in the Learning summary keeps exactly today's controls (ids, order, labels): the 5A button ", + async () => { + const browserSummaryControls = await transportControls() + cases.push({ + name: 'a browser replay’s transport in the Learning summary keeps exactly its controls, ids, order and labels', + actual: browserSummaryControls, + passed: + JSON.stringify(browserSummaryControls) === + JSON.stringify([ + 'Keyboard shortcuts', + 'Replay from start', + 'previous-moment', + 'playback-toggle', + 'next-moment', + 'playhead', + 'Playback details', + ]), + }) + }, + ) + // Value: protects=a test replay transport after a browser replay redraws for its family (AC13): the event steps, the slider's test wording and family mark, and back again for the browser replay; fails_when=the slider keeps the other family's tooltip or wording, or the event steps stay; why_new=Cycle 0051 AC13; seam=none; no_claim=none beyond protects + await regionTwoCase( + "a test replay transport after a browser replay redraws for its family (AC13): the event steps, the slider's test wording", + async () => { + await publish(regionTwoAt(3)) + const testReplaySlider = await page.evaluate(() => ({ + family: + document.getElementById('playhead').dataset.replayFamily ?? '', + spoken: document + .getElementById('playhead') + .getAttribute('aria-valuetext'), + eventSteps: document + .getElementById('playback-events') + .checkVisibility(), + keys: document + .getElementById('playhead') + .getAttribute('aria-keyshortcuts'), + })) + await publish(browserReplayAt()) + const browserSlider = await page.evaluate(() => ({ + family: + document.getElementById('playhead').dataset.replayFamily ?? '', + spoken: document + .getElementById('playhead') + .getAttribute('aria-valuetext'), + eventSteps: document + .getElementById('playback-events') + .checkVisibility(), + keys: document + .getElementById('playhead') + .getAttribute('aria-keyshortcuts'), + titleNamesRecording: document + .getElementById('playhead') + .title.includes('Focused recording timeline'), + })) + cases.push({ + name: 'the shared transport redraws for each family: a test replay after a browser replay shows its event steps and test wording, and the browser replay after it its own', + actual: { testReplaySlider, browserSlider }, + passed: + JSON.stringify(testReplaySlider) === + JSON.stringify({ + family: 'test', + spoken: '0.45 s of recorded test time', + eventSteps: true, + keys: 'ArrowLeft ArrowRight ArrowUp ArrowDown PageUp PageDown Home End', + }) && + JSON.stringify(browserSlider) === + JSON.stringify({ + family: '', + spoken: '5.00 s presented', + eventSteps: false, + keys: null, + titleNamesRecording: true, + }), + }) + }, + ) + // Value: protects=each key of the focused test replay slider (C2): arrows step by event, Page keys by stop (Page Up forward), Home and End select the first and last event by sequence; fails_when=a key seeks by time, steps the other unit or direction, or sends nothing; why_new=Cycle 0051 AC11 (C2): the browser keys seek by time and a test replay's moments are events; seam=none; no_claim=the host's answer (studyWorkspace tests) + await regionTwoCase( + 'each key of the focused test replay slider (C2): arrows step by event, Page keys by stop (Page Up forward), Home and End', + async () => { + for (const [key, expected] of [ + [ + 'ArrowLeft', + { type: 'node-history-step', direction: 'previous', unit: 'event' }, + ], + [ + 'ArrowDown', + { type: 'node-history-step', direction: 'previous', unit: 'event' }, + ], + [ + 'ArrowRight', + { type: 'node-history-step', direction: 'next', unit: 'event' }, + ], + [ + 'ArrowUp', + { type: 'node-history-step', direction: 'next', unit: 'event' }, + ], + [ + 'PageDown', + { type: 'node-history-step', direction: 'previous', unit: 'stop' }, + ], + [ + 'PageUp', + { type: 'node-history-step', direction: 'next', unit: 'stop' }, + ], + ['Home', { type: 'node-history-event', sequence: 1 }], + ['End', { type: 'node-history-event', sequence: 5 }], + ]) { + // Arrange + await publish(regionTwoAt(3)) + await page.locator('#playhead').focus() + await clearSent() + // Act + await page.keyboard.press(key) + const requests = await nodeRequests() + // Assert + cases.push({ + name: `${key} on the focused test replay slider sends ${expected.type === 'node-history-event' ? `the event of sequence ${expected.sequence}` : `a step to the ${expected.direction} ${expected.unit}`}`, + actual: requests, + passed: JSON.stringify(requests) === JSON.stringify([expected]), + }) + } + }, + ) + // Value: protects=modifier keys, IME composition and an event already handled move nothing on the test replay slider; fails_when=a chord or a composing key moves the replay; why_new=Cycle 0051 AC11; seam=none; no_claim=a real IME + await regionTwoCase( + 'modifier keys, IME composition and an event already handled move nothing on the test replay slider', + async () => { + await publish(regionTwoAt(3)) + await page.locator('#playhead').focus() + await clearSent() + for (const chord of [ + 'Shift+ArrowRight', + 'Control+End', + 'Alt+PageUp', + 'Meta+Home', + ]) + await page.keyboard.press(chord) + await page.evaluate(() => { + const playhead = document.getElementById('playhead') + playhead.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowRight', + isComposing: true, + bubbles: true, + cancelable: true, + }), + ) + const handled = new KeyboardEvent('keydown', { + key: 'End', + bubbles: true, + cancelable: true, + }) + handled.preventDefault() + playhead.dispatchEvent(handled) + }) + const ignoredRequests = await nodeRequests() + // The same key without a modifier still moves the replay: the slider handles its keys, and only the chords are ignored. + await page.keyboard.press('ArrowRight') + const plainRequests = await nodeRequests() + cases.push({ + name: 'modifier chords, IME composition and an already handled key move nothing on the test replay slider, while the plain key moves it', + actual: { ignoredRequests, plainRequests }, + passed: + ignoredRequests.length === 0 && + JSON.stringify(plainRequests) === + JSON.stringify([ + { type: 'node-history-step', direction: 'next', unit: 'event' }, + ]), + }) + }, + ) + // Value: protects=the browser replay's slider keys as before (C2 keeps them): arrows seek one second, Page keys ten seconds with Page Up forward, Home and End the recording's ends; fails_when=a browser key steps by event or seeks another time; why_new=Cycle 0051 AC11 (three regression cases); seam=none; no_claim=native timing + await regionTwoCase( + "the browser replay's slider keys as before (C2 keeps them): arrows seek one second, Page keys ten seconds with Page Up f", + async () => { + for (const [kind, keys, expected] of [ + [ + 'arrows', + ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'], + [6000, 4000, 6000, 4000], + ], + ['Page keys', ['PageUp', 'PageDown'], [15000, 0]], + ['Home and End', ['Home', 'End'], [0, 20000]], + ]) { + // Arrange + await publish(browserReplayAt()) + await page.locator('#playhead').focus() + await clearSent() + // Act + for (const key of keys) await page.keyboard.press(key) + const seeks = await playbackSeeks() + // Assert + cases.push({ + name: `the browser replay’s slider ${kind} seek as before`, + actual: seeks, + passed: JSON.stringify(seeks) === JSON.stringify(expected), + }) + } + }, + ) + // Value: protects=the shortcut list shows the six test replay timeline entries with their context, beside the recording timeline's, so the list, the tooltips and the keys agree (D035); fails_when=an entry is missing or names no context; why_new=Cycle 0051 AC11; seam=none; no_claim=the host's own keybindings + await regionTwoCase( + "the shortcut list shows the six test replay timeline entries with their context, beside the recording timeline's, so the", + async () => { + await publish(regionTwoAt(3)) + await page.locator('#recording-transport [data-shortcut-help]').click() + // The list groups its entries under a heading per context. + const listedTestReplayKeys = await page.evaluate(() => + Array.from(document.querySelectorAll('.shortcut-list section')) + .filter( + (group) => + group.querySelector('h3')?.textContent === + 'Focused test replay timeline', + ) + .flatMap((group) => + Array.from(group.querySelectorAll('.shortcut-row'), (row) => + row.textContent.replace(/\s+/g, ' ').trim(), + ), + ), + ) + await page.keyboard.press('Escape') + cases.push({ + name: 'the Keyboard shortcuts list shows the six test replay timeline entries with their context', + actual: listedTestReplayKeys, + passed: + listedTestReplayKeys.length === 6 && + [ + 'Previous event', + 'Next event', + 'Previous stop', + 'Next stop', + 'First event', + 'Last event', + ].every((entry) => + listedTestReplayKeys.some((text) => text.includes(entry)), + ), + }) + }, + ) + // Value: protects=Replay from start and Home reach the first event by sequence when the first two events share one time, from any page of the event list, and End the history's last event; fails_when=either seeks by time (selecting the second event) or takes the page's own first or last entry; why_new=Cycle 0051 DS-C, the Eng review's findings (equal times, pages of 20); seam=none; no_claim=the host's projection (studyWorkspace tests) + await regionTwoCase( + 'Replay from start and Home reach the first event by sequence when the first two events share one time, from any page of ', + async () => { + const sameTimeEntries = c51Entries.map((entry) => + entry.sequence === 2 ? { ...entry, elapsedMs: 100 } : entry, + ) + await publish( + regionTwoAt(3, { + nodeHistory: { + header: { studyId: 'revenue-study' }, + status: 'complete', + entries: sameTimeEntries, + }, + }), + ) + await clearSent() + await page.locator('[data-playback="start"]').click() + await page.locator('#playhead').focus() + await page.keyboard.press('Home') + const firstEventRequests = await nodeRequests() + const secondPageEntries = Array.from({ length: 20 }, (_, index) => ({ + type: 'output', + sequence: index + 21, + elapsedMs: (index + 21) * 10, + channel: 'stdout', + text: `row ${index + 21}\n`, + })) + await publish( + regionTwoAt(3, { + nodeHistoryEventCount: 41, + nodeHistoryPage: 1, + nodeHistoryPageCount: 3, + nodeHistory: { + header: { studyId: 'revenue-study' }, + status: 'complete', + entries: secondPageEntries, + }, + nodeHistoryPosition: { sequence: 30, eventIndex: 29 }, + nodeHistoryPlayback: { + mode: 'paused', + sequence: 30, + positionMs: 300, + startMs: 10, + endMs: 410, + loading: false, + firstSequence: 1, + lastSequence: 41, + }, + }), + ) + await clearSent() + await page.locator('#playhead').focus() + await page.keyboard.press('Home') + await page.keyboard.press('End') + const pagedRequests = await nodeRequests() + cases.push({ + name: 'Replay from start and Home reach the first event by sequence when two events share its time, and Home and End reach the whole history’s ends from the event list’s second page', + actual: { firstEventRequests, pagedRequests }, + passed: + JSON.stringify(firstEventRequests) === + JSON.stringify([ + { type: 'node-history-event', sequence: 1 }, + { type: 'node-history-event', sequence: 1 }, + ]) && + JSON.stringify(pagedRequests) === + JSON.stringify([ + { type: 'node-history-event', sequence: 1 }, + { type: 'node-history-event', sequence: 41 }, + ]), + }) + }, + ) + // Value: protects=a history whose events all share one time keeps the slider enabled and focusable for its keys, and a drag sends no seek; fails_when=the zero time range disables the slider (today's rule) or a drag seeks; why_new=Cycle 0051 DS-C, the Eng review's finding; seam=none; no_claim=none beyond protects + await regionTwoCase( + 'a history whose events all share one time keeps the slider enabled and focusable for its keys, and a drag sends no seek', + async () => { + const zeroRangeEntries = c51Entries.map((entry) => ({ + ...entry, + elapsedMs: 100, + })) + await publish( + regionTwoAt(3, { + nodeHistory: { + header: { studyId: 'revenue-study' }, + status: 'complete', + entries: zeroRangeEntries, + }, + nodeHistoryPlayback: { + ...regionTwoAt(3).nodeHistoryPlayback, + positionMs: 100, + startMs: 100, + endMs: 100, + }, + }), + ) + await clearSent() + const zeroRangeSlider = await page.evaluate(() => ({ + disabled: document.getElementById('playhead').disabled, + })) + await page.locator('#playhead').focus() + await page.keyboard.press('ArrowRight') + await page.locator('#playhead').evaluate((playhead) => { + playhead.dispatchEvent(new Event('input', { bubbles: true })) + playhead.dispatchEvent(new Event('change', { bubbles: true })) + }) + const zeroRangeRequests = await nodeRequests() + const zeroRangeFocused = await focusName() + cases.push({ + name: 'a test replay whose events share one time keeps its slider enabled for its keys, and a drag sends no seek', + actual: { zeroRangeSlider, zeroRangeRequests, zeroRangeFocused }, + passed: + !zeroRangeSlider.disabled && + zeroRangeFocused === 'playhead' && + JSON.stringify(zeroRangeRequests) === + JSON.stringify([ + { type: 'node-history-step', direction: 'next', unit: 'event' }, + ]), + }) + }, + ) + // Value: protects=a key move speaks once: the slider's spoken value changes once with the host's answer and the status line, a live region, says nothing for a move; fails_when=the status line repeats the moment, or the spoken value changes twice; why_new=Cycle 0051 DS-C; seam=none; no_claim=a real screen reader + await regionTwoCase( + "a key move speaks once: the slider's spoken value changes once with the host's answer and the status line, a live region", + async () => { + await publish(regionTwoAt(3)) + await page.locator('#playhead').focus() + await page.evaluate(() => { + window.spokenChanges = 0 + window.statusChanges = 0 + new MutationObserver(() => { + window.spokenChanges++ + }).observe(document.getElementById('playhead'), { + attributes: true, + attributeFilter: ['aria-valuetext'], + }) + new MutationObserver(() => { + window.statusChanges++ + }).observe(document.getElementById('recording-status'), { + childList: true, + characterData: true, + subtree: true, + }) + }) + await page.keyboard.press('ArrowRight') + await publish(regionTwoAt(4)) + const speaking = await page.evaluate(() => ({ + spokenChanges: window.spokenChanges, + statusChanges: window.statusChanges, + status: document.getElementById('recording-status').textContent, + })) + cases.push({ + name: 'a test replay key move changes the slider’s spoken value once and leaves the transport’s status line as it was', + actual: speaking, + passed: + speaking.spokenChanges === 1 && + speaking.statusChanges === 0 && + speaking.status === 'Test replay paused', + }) + }, + ) + // Value: protects=a refused or failed move shows the host's sentence in the transport's status line (role=status), which is read with the summary hidden too, and the committed moment stays; fails_when=the sentence reaches only the error lines, or the moment changes; why_new=Cycle 0051 DS-G (AC14); seam=none; no_claim=the host's wording (studyWorkspace tests) + await regionTwoCase( + "a refused or failed move shows the host's sentence in the transport's status line (role=status), which is read with the ", + async () => { + await publish( + regionTwoAt(2, { + nodeHistoryRefusal: + 'There is no previous stop in this test replay.', + }), + ) + const refusalLine = await page.evaluate(() => ({ + status: document.getElementById('recording-status').textContent, + role: document + .getElementById('recording-status') + .getAttribute('role'), + position: document.getElementById('node-history-position') + .textContent, + })) + cases.push({ + name: 'a refused test replay move shows the host’s sentence in the transport’s status line and keeps the moment', + actual: refusalLine, + passed: + JSON.stringify(refusalLine) === + JSON.stringify({ + status: + 'Test replay paused · There is no previous stop in this test replay.', + role: 'status', + position: 'Stop 1 of 2 · 0.30 s', + }), + }) + }, + ) + // Value: protects=a test replay's transport hides the browser-only recovery: Reload saved recording, Recording error details, the pending and reason lines and the covered-application remedy; fails_when=a browser control or line shows for a test replay; why_new=Cycle 0051 DS-G (AC14); seam=none; no_claim=none beyond protects + await regionTwoCase( + "a test replay's transport hides the browser-only recovery: Reload saved recording, Recording error details, the pending ", + async () => { + await publish( + browserReplayAt({ + recording: { + ...browserReplayAt().recording, + applicationCovered: true, + nativeValuesUninspected: true, + }, + }), + ) + await publish(regionTwoAt(2)) + const browserRecovery = await page.evaluate(() => ({ + transportShown: document + .getElementById('recording-transport') + .checkVisibility(), + status: document.getElementById('recording-status').textContent, + details: document + .getElementById('playback-options') + .checkVisibility(), + reload: document + .querySelector('[data-action="reload-recording"]') + .checkVisibility(), + pending: document.getElementById('playback-pending').textContent, + reason: document.getElementById('playback-reason').textContent, + remedy: document.getElementById('application-covered-remedy') + .textContent, + hint: document.getElementById('playback-hint').textContent, + })) + cases.push({ + name: 'a test replay’s transport hides the browser replay’s recovery controls and lines, also after a browser replay showed them', + actual: browserRecovery, + passed: + JSON.stringify(browserRecovery) === + JSON.stringify({ + transportShown: true, + status: 'Test replay paused', + details: false, + reload: false, + pending: '', + reason: '', + remedy: '', + hint: '', + }), + }) + }, + ) + // Value: protects=the step mark and the event buttons' text keep a contrast of at least 4.5:1 against the editor background in the four supported themes' variable sets, and the mark keeps its non-color cue; fails_when=a theme's link or button color falls below 4.5:1, or the cue is color alone; why_new=Cycle 0051 AC14; seam=the four themes' variable sets (fixtures); no_claim=installed themes (N51 (f) covers two per host) + await regionTwoCase( + "the step mark and the event buttons' text keep a contrast of at least 4.5:1 against the editor background in the four su", + async () => { + await publish(regionTwoAt(3)) + await publish(regionTwoAt(4)) + const themeContrast = await page.evaluate(() => { + const sets = { + dark: { editor: '#1f1f1f', link: '#4daafc', button: '#cccccc' }, + light: { editor: '#ffffff', link: '#005fb8', button: '#3b3b3b' }, + highContrastDark: { + editor: '#000000', + link: '#21a6ff', + button: '#ffffff', + }, + highContrastLight: { + editor: '#ffffff', + link: '#0f4a85', + button: '#292929', + }, + } + /** Gives the relative luminance of a CSS color the page computed. */ + const luminance = (color) => { + const [red, green, blue] = color + .match(/\d+(\.\d+)?/g) + .slice(0, 3) + .map((value) => Number(value) / 255) + .map((value) => + value <= 0.03928 + ? value / 12.92 + : ((value + 0.055) / 1.055) ** 2.4, + ) + return 0.2126 * red + 0.7152 * green + 0.0722 * blue + } + const ratio = (first, second) => { + const [lighter, darker] = [ + luminance(first), + luminance(second), + ].sort((a, b) => b - a) + return (lighter + 0.05) / (darker + 0.05) + } + const root = document.documentElement + const results = {} + for (const [name, set] of Object.entries(sets)) { + root.style.setProperty('--vscode-editor-background', set.editor) + root.style.setProperty('--vscode-textLink-foreground', set.link) + root.style.setProperty( + '--vscode-button-secondaryForeground', + set.button, + ) + const label = document.querySelector( + '#node-learning-sequence text.current-step', + ) + // The page's background is the root's (`:root`), which the body shows through. + const background = getComputedStyle(root).backgroundColor + results[name] = { + mark: + Math.round( + ratio(getComputedStyle(label).fill, background) * 10, + ) / 10, + eventButton: + Math.round( + ratio( + getComputedStyle(document.getElementById('previous-event')) + .color, + background, + ) * 10, + ) / 10, + underline: + getComputedStyle(label).textDecorationLine.includes( + 'underline', + ), + } + } + for (const variable of [ + '--vscode-editor-background', + '--vscode-textLink-foreground', + '--vscode-button-secondaryForeground', + ]) + root.style.removeProperty(variable) + return results + }) + cases.push({ + name: 'the step mark and the event buttons keep a contrast of at least 4.5:1 in the four themes’ variable sets, and the mark keeps its underline', + actual: themeContrast, + passed: Object.values(themeContrast).every( + (reading) => + reading.mark >= 4.5 && + reading.eventButton >= 4.5 && + reading.underline, + ), + }) + }, + ) + // Value: protects=5A's reopen for a paused test replay and a paused browser replay keeps the control the summary held and its reading position (DS-E); fails_when=the reopen drops the focus on the body, moves it, or scrolls the summary; why_new=Cycle 0051 AC5; seam=leavePane and returnToPane as the host's hide and Show Learning Summary; no_claim=the hosts' own hide and focus (N51 (c)) + await regionTwoCase( + "5A's reopen for a paused test replay and a paused browser replay keeps the control the summary held and its reading posi", + async () => { + for (const [family, message, control] of [ + [ + 'test', + () => regionTwoAt(2), + '#node-learning-steps-details > summary', + ], + ['browser', () => browserReplayAt(), '#playback-options > summary'], + ]) { + // Arrange + await freshPane('context', { width: 420, height: 500 }) + await publish(message()) + await page.locator(control).focus() + const before = await page.evaluate(() => ({ + scroll: Math.round(scrollY), + })) + const focusBefore = await focusName() + // Act + await leavePane() + await returnToPane() + const after = await page.evaluate(() => ({ + scroll: Math.round(scrollY), + })) + const focusAfter = await focusName() + // Assert + cases.push({ + name: `5A’s reopen of a paused ${family} replay’s summary gives the keyboard back to the control it held and keeps the reading position`, + actual: { before, after, focusBefore, focusAfter }, + passed: + focusAfter === focusBefore && + focusAfter !== 'body' && + after.scroll === before.scroll, + }) + } + }, + ) + // Value: protects=the summary's focus fallback on reopen (DS-E): with no control held before (the first open) the title heading takes the focus, and a held control gone at the reopen (Inspect hidden after playback moved to a stop) gives it to the moment block's step heading, both without scrolling; fails_when=the focus rests on the body, or the reopen scrolls; why_new=Cycle 0051 AC5: the removed transport was the last resort; seam=leavePane and returnToPane; no_claim=the hosts' focus timing (N51 (c)) + await regionTwoCase( + "the summary's focus fallback on reopen (DS-E): with no control held before (the first open) the title heading takes the ", + async () => { + await freshPane('context', { width: 420, height: 500 }) + await publish(regionTwoAt(2)) + await returnToPane() + const firstOpenFocus = await focusName() + await freshPane('context', { width: 420, height: 500 }) + await publish( + regionTwoAt(2, { + nodeHistoryPosition: { + ...regionTwoAt(2).nodeHistoryPosition, + nativeValuesUninspected: true, + }, + }), + ) + await page.locator('#node-history-inspect').focus() + const heldControl = await focusName() + await leavePane() + await publish(regionTwoAt(4)) + const scrollAtReopen = await page.evaluate(() => Math.round(scrollY)) + await returnToPane() + const goneControlFocus = await focusName() + const scrollAfterReopen = await page.evaluate(() => Math.round(scrollY)) + cases.push({ + name: 'the reopened summary focuses its title heading with no control held before, and the step heading when the held Inspect is gone', + actual: { + firstOpenFocus, + heldControl, + goneControlFocus, + scrollAtReopen, + scrollAfterReopen, + }, + passed: + firstOpenFocus === 'title' && + heldControl === 'node-history-inspect' && + goneControlFocus === 'node-history-step-heading' && + scrollAfterReopen === scrollAtReopen, + }) + }, + ) + // Value: protects=the bottom panel during a test replay shows the shared transport alone (DS-D): no Saved studies, no Observed moments, no copy of the test replay section, with its event steps and 5A's Learning summary button; fails_when=the panel draws a second working copy of the Study pane or misses the transport; why_new=Cycle 0051 task 7 (AC4), the entry audit's finding; seam=none; no_claim=the panel's title (host tests) + await regionTwoCase( + 'the bottom panel during a test replay shows the shared transport alone (DS-D): no Saved studies, no Observed moments, no', + async () => { + await freshPane('moments', { width: 1200, height: 260 }) + await publish(regionTwoAt(2, { playbackPanelVisible: true })) + const bottomPanel = await page.evaluate(() => ({ + transport: document + .getElementById('recording-transport') + .checkVisibility(), + eventSteps: document + .getElementById('playback-events') + .checkVisibility(), + learningSummaryButton: document + .getElementById('show-learning-summary') + .checkVisibility(), + savedStudies: document + .getElementById('saved-studies') + .checkVisibility(), + observedMoments: document + .getElementById('observed-moments') + .checkVisibility(), + sectionHeight: Math.round( + document.getElementById('node-history').getBoundingClientRect() + .height, + ), + header: document.querySelector('.learning-header').checkVisibility(), + })) + cases.push({ + name: 'during a test replay the bottom panel shows only the shared transport, with its event steps and the Learning summary button', + actual: bottomPanel, + passed: + JSON.stringify(bottomPanel) === + JSON.stringify({ + transport: true, + eventSteps: true, + learningSummaryButton: true, + savedStudies: false, + observedMoments: false, + sectionHeight: 0, + header: false, + }), + }) + }, + ) + // Value: protects=5A's Learning summary button in the bottom panel: labelled Learning summary with the accessible name Show learning summary, outside the status line's live region, and its press asks the host to show the summary; fails_when=the button is missing, sits in the live region, or sends another action; why_new=Cycle 0051 DS-E (AC5); seam=none; no_claim=the host's Show Learning Summary (host tests) + await regionTwoCase( + "5A's Learning summary button in the bottom panel: labelled Learning summary with the accessible name Show learning summa", + async () => { + await clearSent() + await page.locator('#show-learning-summary').click() + const learningSummaryButton = await page.evaluate(() => { + const button = document.getElementById('show-learning-summary') + return { + label: button.textContent, + name: button.getAttribute('aria-label'), + tooltip: button.title, + inLiveRegion: Boolean(button.closest('[role=status],[aria-live]')), + sent: window.sent + .filter((message) => message.type === 'action') + .map((message) => message.action), + } + }) + cases.push({ + name: 'the bottom panel’s Learning summary button is named Show learning summary, stands outside the status line’s live region and asks the host to show the summary', + actual: learningSummaryButton, + passed: + JSON.stringify(learningSummaryButton) === + JSON.stringify({ + label: 'Learning summary', + name: 'Show learning summary', + tooltip: 'Show learning summary', + inLiveRegion: false, + sent: ['focus-context'], + }), + }) + }, + ) + // Value: protects=a browser replay's transport in the bottom panel is today's controls plus the Learning summary button, in a closed list; fails_when=any other browser control changes; why_new=Cycle 0051 AC3; seam=none; no_claim=native layout + await regionTwoCase( + "a browser replay's transport in the bottom panel is today's controls plus the Learning summary button, in a closed list", + async () => { + await publish(browserReplayAt({ playbackPanelVisible: true })) + const browserPanelControls = await transportControls() + cases.push({ + name: 'a browser replay’s transport in the bottom panel is its controls plus the Learning summary button', + actual: browserPanelControls, + passed: + JSON.stringify(browserPanelControls) === + JSON.stringify([ + 'Keyboard shortcuts', + 'Replay from start', + 'previous-moment', + 'playback-toggle', + 'next-moment', + 'playhead', + 'Playback details', + 'show-learning-summary', + ]), + }) + }, + ) + await page.setViewportSize({ width: 420, height: 720 }) + } } /** diff --git a/extension/src/ui/constants.ts b/extension/src/ui/constants.ts index f38c94ed2..9a5104d46 100644 --- a/extension/src/ui/constants.ts +++ b/extension/src/ui/constants.ts @@ -19,6 +19,8 @@ import { NODE_TEST_REPLAY_TEXT } from '../utils/describeNodeTestReplay' import type { ShortcutHelpEntry } from '../utils/installShortcutHelp' const STUDY_FOCUS_MARGIN_PX = 4 const STUDY_COMPACT_READER_MAX_WIDTH_PX = 759 +/** The height a test replay's sequence diagram takes in its Learning summary before its own area scrolls (DS-B), so the moment block starts in the first viewport at the measured summary sizes (VS Code 298 × 885 px, Cursor 440 × 965 px); the whole drawing stays reachable by that area's scroll. */ +const TEST_REPLAY_DIAGRAM_MAX_HEIGHT_PX = 150 const STUDY_SEQUENCE_LAYOUT = { minWidthPx: 320, columnWidthPx: 160, @@ -40,6 +42,26 @@ const STUDY_SEQUENCE_LAYOUT = { arrowHeightPx: 5, numberGapPx: 8, } +/** The shared slider while it shows a browser recording: the page marks it `data-replay-family="test"` during a test replay (DS-C), so each family's shortcut entries name only its own slider. */ +const BROWSER_PLAYHEAD_SELECTOR = '#playhead:not([data-replay-family="test"])' +/** The shared slider while it shows a test replay. */ +const TEST_PLAYHEAD_SELECTOR = '#playhead[data-replay-family="test"]' +/** The test replay timeline's keys (C2 and DS-C): arrows step by event, Page keys by stop (Page Up forward, as for a recording), Home and End select the first and last event. */ +const TEST_REPLAY_TIMELINE_SHORTCUTS: ShortcutHelpEntry[] = ( + [ + ['Previous event', '← / ↓'], + ['Next event', '→ / ↑'], + ['Previous stop', 'Page Down'], + ['Next stop', 'Page Up'], + ['First event', 'Home'], + ['Last event', 'End'], + ] as const +).map(([action, keys]) => ({ + action, + keys, + context: 'Focused test replay timeline', + selector: TEST_PLAYHEAD_SELECTOR, +})) export const STUDY_SHORTCUTS: ShortcutHelpEntry[] = [ { action: 'Keyboard shortcuts', @@ -56,38 +78,39 @@ export const STUDY_SHORTCUTS: ShortcutHelpEntry[] = [ action: 'Seek backward one second', keys: '← / ↓', context: 'Focused recording timeline', - selector: '#playhead', + selector: BROWSER_PLAYHEAD_SELECTOR, }, { action: 'Seek forward one second', keys: '→ / ↑', context: 'Focused recording timeline', - selector: '#playhead', + selector: BROWSER_PLAYHEAD_SELECTOR, }, { action: `Seek backward ${PLAYBACK_PAGE_STEP_MS / MILLISECONDS_PER_SECOND} seconds`, keys: 'Page Down', context: 'Focused recording timeline', - selector: '#playhead', + selector: BROWSER_PLAYHEAD_SELECTOR, }, { action: `Seek forward ${PLAYBACK_PAGE_STEP_MS / MILLISECONDS_PER_SECOND} seconds`, keys: 'Page Up', context: 'Focused recording timeline', - selector: '#playhead', + selector: BROWSER_PLAYHEAD_SELECTOR, }, { action: 'Seek recording start', keys: 'Home', context: 'Focused recording timeline', - selector: '#playhead', + selector: BROWSER_PLAYHEAD_SELECTOR, }, { action: 'Seek recording end', keys: 'End', context: 'Focused recording timeline', - selector: '#playhead', + selector: BROWSER_PLAYHEAD_SELECTOR, }, + ...TEST_REPLAY_TIMELINE_SHORTCUTS, { action: 'Previous / next content tab', keys: '← / →', @@ -170,6 +193,16 @@ export const STUDY_UI_OPTIONS = { }, // The name that makes the replay transport's section a landmark. The pane gives it only while it shows the transport: the section stays rendered and empty otherwise, and an unnamed section is no landmark. replayTransportLabel: 'Recorded playback', + // The 5A button of the transport's status row (Cycle 0051, DS-E): its visible label is the name PRODUCT_GOAL.md gives it, and its accessible name and tooltip say what pressing it does, containing the label (WCAG 2.5.3). + learningSummaryButton: 'Learning summary', + showLearningSummary: 'Show learning summary', + // The shared slider's tooltip while it shows a test replay, in the format of the recording timeline's tooltip, so the tooltip, the shortcut list and the keys agree (D035). + testReplayTimelineTitle: TEST_REPLAY_TIMELINE_SHORTCUTS.map( + (entry) => `${entry.action} — ${entry.keys} (${entry.context})`, + ).join('; '), + // The shared slider's keyboard shortcuts while it shows a test replay, for its `aria-keyshortcuts`. + testReplayTimelineKeys: + 'ArrowLeft ArrowRight ArrowUp ArrowDown PageUp PageDown Home End', playbackFailureRecovery: 'Reload the saved recording to try again. If it still fails, ask your agent for a new recording.', nativeValuesHint: `Recorded values for this moment are not loaded yet. Show Variables opens Run and Debug and expands them; if the first scope stays closed, expand it. If Call Stack lists several sessions, select “${REPLAY_DEBUG_SESSION_NAME}” first.`, @@ -188,18 +221,12 @@ export const STUDY_UI_OPTIONS = { '#previous-moment', '#next-moment', '#playhead', + // A test replay's event steps, shown for that family only. + '#previous-event', + '#next-event', ], - nodeTransportFocus: [ - '#node-history-stop-next', - '#node-history-stop-previous', - '#node-history-playback-toggle', - '#node-history-playhead', - '#node-history-moment-previous', - '#node-history-moment-next', - // A history with a single event has no usable transport control: its selected event's own control takes the focus, which sends nothing when pressed, or, while the event list is collapsed, the list's summary. - '#node-history-events [aria-pressed="true"]', - '#node-history-events-summary', - ], + // Where a lost focus goes in a test replay's Learning summary when no disclosure around the control that left is shown (DS-E): the moment block's step heading, else the summary's title heading, both focusable by script only. + summaryFocusFallback: ['#node-history-step-heading', '#title'], } export const STUDY_STYLES = ` .shortcut-dialog{width:min(640px,90vw);max-height:85vh;overflow:auto;background:var(--vscode-editor-background,#181818);color:var(--vscode-foreground,#ddd);border:1px solid var(--vscode-panel-border,#777);border-radius:8px;padding:20px}.shortcut-dialog::backdrop{background:rgb(0 0 0 / .5)}.shortcut-dialog input{display:block;width:100%;padding:10px;margin:16px 0;background:var(--vscode-input-background,#333);color:var(--vscode-input-foreground,#fff);border:1px solid var(--vscode-input-border,#777)}.shortcut-row{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:12px 0;border-bottom:1px solid var(--vscode-panel-border,#444)}.shortcut-list section{padding:12px 0;border:0}.shortcut-list h3{font-size:13px;color:var(--vscode-descriptionForeground,#aaa);margin:0 0 8px}kbd{font:inherit;white-space:nowrap;border:1px solid var(--vscode-panel-border,#777);border-radius:4px;padding:2px 6px}.shortcut-tooltip{position:fixed;z-index:10;left:16px;right:16px;bottom:12px;max-height:40vh;overflow:auto;padding:8px 12px;background:var(--vscode-editorHoverWidget-background,#333);color:var(--vscode-editorHoverWidget-foreground,#fff);border:1px solid var(--vscode-editorHoverWidget-border,#777);pointer-events:none} @@ -210,7 +237,7 @@ export const STUDY_STYLES = ` /* A status region stays rendered while it is empty and takes no room, so its first text changes a region assistive technology already knows; only lines that are no status region leave the page while empty. */ .error:empty:not([role=status]){display:none}[role=status]:empty{margin:0;padding:0;border:0}#observation{border:0;padding:0;margin:8px 0 12px;color:var(--vscode-descriptionForeground,#aaa)}@media(min-width:900px){.live-strip{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:4px 12px}.live-strip>.row{justify-content:flex-end}.live-strip>#deadline{grid-column:1/-1}} :root{color-scheme:light dark;font-family:var(--vscode-font-family,system-ui);color:var(--vscode-foreground,#d4d4d4);background:var(--vscode-editor-background,#181818);font-size:16px;line-height:1.5;scroll-padding-top:calc(var(--codebook-sticky-height,0px) + ${STUDY_FOCUS_MARGIN_PX}px);scroll-padding-bottom:calc(var(--playback-height,0px) + ${STUDY_FOCUS_MARGIN_PX}px)} -#save-state{display:block;margin-top:4px}#flow{display:flex;flex-wrap:wrap;gap:8px 16px}.live-strip.flowing,#node-history-transport.flowing{position:static}[role=tablist]{flex-wrap:wrap}[role=tab]{overflow-wrap:normal} +#save-state{display:block;margin-top:4px}#flow{display:flex;flex-wrap:wrap;gap:8px 16px}.live-strip.flowing{position:static}[role=tablist]{flex-wrap:wrap}[role=tab]{overflow-wrap:normal} /* The host styles body directly; inherit the reading scale explicitly. */ /* Titles keep twelve characters per line: body wraps anywhere, and a pane squeezed during arrangement would show them one character per line. */ *{box-sizing:border-box}body{margin:0;padding:16px;overflow-wrap:anywhere;font-size:inherit;line-height:inherit}h1{font-size:20px;line-height:1.25;margin:16px 0 8px;min-width:12ch}h2{font-size:16px;margin:0 0 12px}p{margin:8px 0 16px}button,input,select{font:inherit}button{cursor:pointer;padding:6px 10px;border:1px solid var(--vscode-button-border,var(--vscode-contrastBorder,transparent));border-radius:4px;background:var(--vscode-button-secondaryBackground,#333);color:var(--vscode-button-secondaryForeground,#eee)}button.primary{background:var(--vscode-button-background,#0e639c);color:var(--vscode-button-foreground,#fff)}button:hover:not(:disabled):not([aria-disabled=true]){filter:brightness(1.12)}button:disabled,button[aria-disabled=true]{opacity:.6;cursor:default}button:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid var(--vscode-focusBorder,#007fd4);outline-offset:2px}.row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.muted{color:var(--vscode-descriptionForeground,#aaa)}.status{border:1px solid var(--vscode-contrastBorder,var(--vscode-panel-border,#555));padding:8px 12px;margin:12px 0}.error{color:var(--vscode-editorError-foreground,var(--vscode-foreground,#d4d4d4));white-space:pre-wrap}section{padding:20px 0;border-bottom:1px solid var(--vscode-panel-border,#444)}[role=tablist]{display:flex;gap:8px;border-bottom:1px solid var(--vscode-panel-border,#444);padding-bottom:12px}[role=tab][aria-selected=true]{border-bottom:3px solid var(--vscode-focusBorder,#007fd4)}.link{background:none;border:0;color:var(--vscode-textLink-foreground,#4daafc);text-decoration:underline;padding:4px 0}.link.visited{color:var(--vscode-textLink-activeForeground,#9b8cdd)}label{display:flex;align-items:flex-start;gap:12px;margin:12px 0}input[type=checkbox]{width:20px;height:20px;accent-color:var(--vscode-focusBorder,#007fd4)}.interpretation{white-space:pre-wrap}img{display:block;max-width:100%;height:auto;border:1px solid var(--vscode-panel-border,#444)}.moments{display:flex;gap:8px;flex-wrap:wrap}.moments button[aria-current=true]{outline:2px solid var(--vscode-focusBorder,#007fd4)}.live-strip{position:sticky;top:0;z-index:1;padding:12px;background:var(--vscode-editor-background,#181818);border-bottom:1px solid var(--vscode-panel-border,#444)}body.moments-only{padding:8px 16px}body.moments-only .live-strip{position:static;padding:0 0 8px}body.moments-only section{padding:8px 0}select{max-width:100%;padding:6px;background:var(--vscode-dropdown-background,#333);color:var(--vscode-dropdown-foreground,#eee);border:1px solid var(--vscode-dropdown-border,#666)}[hidden]{display:none!important}@media(max-width:360px){body{padding:12px}.row{gap:8px}}body.vscode-high-contrast button,body.vscode-high-contrast-light button{border-color:var(--vscode-contrastBorder)} @@ -231,16 +258,22 @@ body.moments-only #recording-transport{display:flex;flex-wrap:wrap;align-items:c .timeline-track{position:relative;flex:1;min-width:100px;display:flex;flex-direction:column;align-items:stretch}.timeline-track #playhead{width:100%;flex:none}.timeline-track #history-coverage,.timeline-track .history-coverage{position:relative;height:6px;margin:0 8px;overflow:hidden;background:var(--vscode-panel-border,#555)}.timeline-track .history-gap{position:absolute;top:0;bottom:0;background:repeating-linear-gradient(135deg,var(--vscode-editorWarning-foreground,#d7ba7d) 0 3px,transparent 3px 6px)}.timeline-track .history-stop{position:absolute;top:0;bottom:0;width:3px;background:var(--vscode-focusBorder,#007fd4)}#history-coverage-label,.history-coverage-label{font-size:11px;line-height:1.2} .playback-status-row{display:flex;justify-content:space-between;gap:12px;align-items:start;font-size:12px}.playback-status-row summary{padding:13px 0}.playback-status-row>#recording-status{margin-top:13px}body.moments-only .playback-status-row{flex-wrap:wrap;row-gap:0}body.moments-only #application-covered-remedy{order:1;flex-basis:100%}.playback-status-row p{margin:8px 0}#playback-pending:empty,#playback-reason:empty{display:none}#playback-hint:empty,#node-history-hint:empty{margin:0}#playback-pending,#playback-reason,#playback-hint,#node-history-hint{margin:6px 0 0;font-size:12px} /* The live strip and the replay transport stay in the page while this pane does not show them, so the status regions inside exist before their first text. Each shows nothing and takes no room exactly while its own status is empty: #runtime for the strip, #recording-status for the transport. */ -.live-strip:has(>#runtime:empty),#recording-transport:has(#recording-status:empty){padding:0;border:0}.live-strip:has(>#runtime:empty)>:not(#runtime),#recording-transport:has(#recording-status:empty)>:not(.playback-status-row,[role=status]),#recording-transport:has(#recording-status:empty) #playback-options{display:none}.playback-status-row>#recording-status:empty{margin:0} +.live-strip:has(>#runtime:empty),#recording-transport:has(#recording-status:empty){padding:0;border:0}.live-strip:has(>#runtime:empty)>:not(#runtime),#recording-transport:has(#recording-status:empty)>:not(.playback-status-row,[role=status]),#recording-transport:has(#recording-status:empty) #playback-options,#recording-transport:has(#recording-status:empty) #show-learning-summary{display:none}.playback-status-row>#recording-status:empty{margin:0} #saved-studies select{width:100%}#delete-study-row,#study-unopenable,#study-newer,#study-repairable,#delete-study-reason,#delete-study-status,#study-files{margin:8px 0 0}#delete-study-reason:empty{display:none}#delete-study-status:empty{margin:0}#study-files-list{margin:0 0 8px;padding-left:20px}#study-files-note{margin:0 0 8px}#copy-study-files-status{margin:8px 0 0}#copy-study-files-status:empty{margin:0} #learning-summary,.sequence-steps p{white-space:pre-wrap}.sequence-diagram{overflow:auto;max-height:420px;border:1px solid var(--vscode-panel-border,#444);padding:8px}.sequence-diagram svg{display:block;max-width:none;font-size:12px}.sequence-diagram text{fill:var(--vscode-foreground,#ddd)}.sequence-lifeline{stroke:var(--vscode-descriptionForeground,#aaa);stroke-dasharray:4 4}.sequence-arrow{fill:none;stroke:var(--vscode-textLink-foreground,#4daafc);stroke-width:2}.sequence-arrow.source-derived{stroke-dasharray:6 4}.sequence-arrow.unknown{stroke-dasharray:2 5;stroke:var(--vscode-descriptionForeground,#aaa)}.sequence-steps{padding-left:24px}.sequence-steps li{padding:10px 0}.sequence-steps p{margin:6px 0}.sequence-steps [aria-current=true]{font-weight:bold} /* Each event card's text wraps inside the reader, and scrolls there if a line still cannot, instead of widening the page (Cycle 0044, 0031-W1 and 0038-W). */ .node-history-event pre{white-space:pre-wrap;overflow-wrap:anywhere;max-width:100%;overflow:auto} /* A test replay's transport stays in view while its summary and steps scroll under it (DS16); Previous stop and Next stop lead it (DS12). */ -#node-history-transport{position:sticky;top:0;z-index:2;background:var(--vscode-editor-background,#181818);padding:4px 0;border-bottom:1px solid var(--vscode-panel-border,#444)}#node-history-transport .row{margin:4px 0 0}#node-history-moment p{margin:4px 0}#node-history-explanation:empty,#node-history-not-recorded:empty,#node-history-unverified:empty,#node-history-capture-reason:empty{display:none}.same-moment{color:var(--vscode-descriptionForeground,#aaa);font-size:12px} +#node-history-moment p{margin:4px 0}#node-history-explanation:empty,#node-history-not-recorded:empty,#node-history-unverified:empty,#node-history-capture-reason:empty{display:none}.same-moment{color:var(--vscode-descriptionForeground,#aaa);font-size:12px} #node-history-moment>p:empty{margin:0;padding:0;border:0} #node-learning-title{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden}#node-learning-title:focus{display:block;overflow:visible}#node-learning-title:focus-visible,#test-output-test:focus-visible{outline:2px solid var(--vscode-focusBorder,#007fd4);outline-offset:2px} +/* A test replay's Learning summary (Cycle 0051, DS-B, DS-E): long texts clamped to three lines with Show more, a capped diagram, the moment's step heading, the drawing's step mark (not color alone) and the summary's focusable headings. */ +.clamp{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden}.clamped-text>p{margin:4px 0}.clamped-text>.show-more{min-height:24px;padding:0;margin:0 0 8px}#node-learning-sequence .sequence-diagram{max-height:${TEST_REPLAY_DIAGRAM_MAX_HEIGHT_PX}px}.sequence-diagram text.current-step{fill:var(--vscode-textLink-foreground,#4daafc);font-weight:600;text-decoration:underline}.sequence-arrow.current-step{stroke-width:3}#node-history-step-heading{font-size:14px;margin:8px 0 4px}#node-history-step-heading:focus-visible,#title:focus-visible{outline:2px solid var(--vscode-focusBorder,#007fd4);outline-offset:2px}body[data-replay=true] #node-history-heading{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap} +/* A test replay's summary leads with its own content (DS-B): the learning title has no section padding of its own, and the article's pane controls follow the summary instead of standing above it. */ +#node-history-learning{padding:0;border:0}body[data-replay-family=test] #node-history{padding-top:0}#node-learning-title{margin-top:0}body[data-replay-family=test] .learning-header{display:flex;flex-wrap:wrap;align-items:start;gap:0 8px}body[data-replay-family=test] #reader-kind{flex-basis:100%}body[data-replay-family=test] .learning-header h1{flex:1}body[data-replay-family=test] .learning-header [data-shortcut-help]{flex:none}body[data-replay-family=test] #study-content{display:flex;flex-direction:column}body[data-replay-family=test] #study-content>div:has(>#content-tabs){order:1} +/* The shared transport's event group wraps as one: it takes its own line before the slider shrinks below its minimum width (DS-C); the 5A button ends the status row, apart from the move controls. */ +.playback-events{display:inline-flex;gap:6px;flex:none}.playback-status-row #show-learning-summary{flex:none;margin-left:auto;min-height:32px;padding:4px 8px} /* A test replay's Application pane: the header and footer stay pinned and the output region is the only scroller (DS4); a standard-error line starts with a mark in the terminal's red, and the mark, never the color alone, says so. */ body.test-output{height:100vh;padding:0;overflow:hidden}body.test-output main{display:flex;flex-direction:column;height:100%;min-height:0;padding:12px 16px}#test-output-test{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden}#test-output-test:focus{display:block;overflow:visible}#test-output{flex:1;min-height:0;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;margin:8px 0;padding:8px;border:1px solid var(--vscode-panel-border,#444)}#test-output:focus-visible{outline:2px solid var(--vscode-focusBorder,#007fd4)}.stderr-mark{color:var(--vscode-terminal-ansiRed,#f14c4c)}#test-output-jump{align-self:flex-start}body.test-output p{margin:4px 0} /* A Learning summary in a narrow side bar keeps a long title to two lines and its sequence in view; the full title stays in its tooltip, and a title the clamp cuts is a tab stop that is shown whole while it has the keyboard focus. A wider summary shows the title whole. */ diff --git a/extension/src/ui/studyHtml.ts b/extension/src/ui/studyHtml.ts index 2361ed072..dfc504dba 100644 --- a/extension/src/ui/studyHtml.ts +++ b/extension/src/ui/studyHtml.ts @@ -128,6 +128,10 @@ export interface StudyViewMessage { loading: boolean /** The shown time is the moment being selected, not yet committed: the readout says it is loading (F388). */ pending?: boolean + /** The sequence of the history's first event, from all its entries whatever page the event list shows; Replay from start and Home select it (DS-C). */ + firstSequence?: number + /** The sequence of the history's last event; End selects it (DS-C). */ + lastSequence?: number } nodeHistoryPosition?: { sequence: number @@ -148,8 +152,12 @@ export interface StudyViewMessage { nodeHistoryScrollToken?: number /** The test replay's learning material for the one summary renderer, each referenced step with its event (Design 5). */ nodeHistoryLearningSummary?: NodeLearningSummaryProjection + /** The host's sentence for the last test replay move it refused or that failed, so the shared transport's status line reads it with the summary hidden too (DS-G); absent once a move commits. */ + nodeHistoryRefusal?: string contentTab?: 'overview' | 'live' | 'recording' playbackPanelVisible?: boolean + /** The replay the summary composition shows: a browser recording or a test replay (Cycle 0051, DS-A); absent outside it. The page lays a replay out by this value and reads `recording` for a browser recording's own parts. */ + replayFamily?: 'browser' | 'test' presentation?: PresentationState repositoryReturnAvailable?: boolean repositoryArticleAvailable?: boolean @@ -194,9 +202,11 @@ export function studyHtml(kind: 'context' | 'moments'): string { // The values hint and its button stand where their arrival moves no transport control: above the controls in the summary, whose transport is fixed to the bottom and grows upward, and below them in the bottom panel, whose transport starts at the top. const valuesHint = '

' + // 5A's Learning summary button stands in the bottom panel's transport, the one shown while the summary is hidden; the summary's own transport shows only with the summary, which the button would bring back (Cycle 0051, DS-E). + const learningSummaryButton = `` return `

Loading study…

-

Codebook study

Codebook study

+

Codebook study

Codebook study

Study view and pane controls
View options

@@ -211,10 +221,10 @@ export function studyHtml(kind: 'context' | 'moments'): string {
Study controls

Ask your editor agent to explain a code path in the open repository.

Workspace panes

-

${STUDY_UI_OPTIONS.testReplay.heading}

Test journal playback
Events
+

${STUDY_UI_OPTIONS.testReplay.heading}

Events

Observed moments

Select a captured observation or open Recording to follow its time.

Saved studies

-
${markupOnlyIn(kind, 'context', valuesHint)}
History coverage unknown
Playback details

Recorded history only. Playback does not run application code or mark understanding.

${markupOnlyIn(kind, 'moments', valuesHint)}
+
${markupOnlyIn(kind, 'context', valuesHint)}
History coverage unknown
Playback details

Recorded history only. Playback does not run application code or mark understanding.

${markupOnlyIn(kind, 'moments', learningSummaryButton)}

${markupOnlyIn(kind, 'moments', valuesHint)}
` } @@ -250,7 +260,6 @@ function studyClient( // In a window without a Trash: the entry the "Remove by hand" disclosure is offered for; another entry, or none, closes it. let filesDisclosureStudyId: string | undefined let scrubbing = false - let nodeHistoryScrubbing = false // A Show Variables button that left while it held this pane's focus, and where that focus goes: paid when the Study message is rendered if this document has the focus, else at its next focus event, so a pane the learner left for Run and Debug takes no focus back. let transportFocusOwed: | { leaving: HTMLElement; preferred: readonly string[]; section: string } @@ -517,6 +526,11 @@ function studyClient( readerOptionsOpenedByLearner = !readerOptions.hasAttribute('open') }) const transport = element('recording-transport') + // The shared slider of both replay families (DS-C), and the tooltip the shortcut help gave it for a recording, which it gets back when it leaves a test replay. + const playheadInput = element('playhead') as HTMLInputElement + const browserPlayheadTitle = playheadInput.title + // The slider says it presents no position from the start: its accessible value is the attribute the first replay message writes, before anything else may add one (the browser transport's markup, which the golden pins, keeps its order). + playheadInput.setAttribute('aria-valuetext', 'No recorded position presented') // Reserve the actual footer height, including expanded recovery details, so reading controls remain reachable; a transport this pane does not show is rendered with nothing in it and has no height. new ResizeObserver(() => document.documentElement.style.setProperty( @@ -558,13 +572,10 @@ function studyClient( revealInTransport(playbackDiagnostics) }) const strip = document.querySelector('.live-strip') - const replayTransportBar = document.getElementById('node-history-transport') - // The sticky bars this page has, top first: the live strip, then the test replay's transport. - const stickyBars = [strip, replayTransportBar].filter( - (bar): bar is HTMLElement => bar !== null, - ) + // The sticky bars this page has: the live strip (a replay's transport is the shared one, fixed or in the bottom panel). + const stickyBars = [strip].filter((bar): bar is HTMLElement => bar !== null) // Oversized sticky controls must not hide keyboard focus at narrow widths or enlarged text. - // The live strip sticks at the top and the test replay's transport sticks right below it, so the sticky bars never cover each other; a bar taller than the fraction of the pane flows with the page instead, and the focus scroll room counts the bars that stay. + // A bar taller than the fraction of the pane flows with the page instead, and the focus scroll room counts the bars that stay. const adaptStrip = (): void => { let stickyHeight = 0 for (const bar of stickyBars) { @@ -573,8 +584,6 @@ function studyClient( 'flowing', height > window.innerHeight * options.maxStickyFraction, ) - // The transport sticks below the strip: its offset is the height of the bar above it. - if (bar === replayTransportBar) bar.style.top = `${stickyHeight}px` if (getComputedStyle(bar).position === 'sticky') stickyHeight += height } // Native focus scrolling must leave room for the visible sticky bars and their outline. @@ -619,10 +628,6 @@ function studyClient( : {}), }, ) - } else if (target.dataset.nodeHistoryPlayback) { - nodeHistoryPlayback( - target.dataset.nodeHistoryPlayback as 'play' | 'pause', - ) } else if (target.dataset.summaryStep) { const positions = current?.recording?.learningSummary?.positions const position = positions?.find( @@ -644,7 +649,9 @@ function studyClient( } send({ type: 'action', action: target.dataset.action }) } else if (target.dataset.playback) { - if (target.dataset.playback === 'start' && current?.recording?.range) + if (current?.replayFamily === 'test') + playTestReplayControl(target.dataset.playback) + else if (target.dataset.playback === 'start' && current?.recording?.range) playback('seek', current.recording.range.startMs) else if ( target.dataset.playback === 'previous' || @@ -779,43 +786,44 @@ function studyClient( unit, }) } - element('node-history-stop-previous').addEventListener('click', () => - stepTestReplay('previous', 'stop'), - ) - element('node-history-stop-next').addEventListener('click', () => - stepTestReplay('next', 'stop'), - ) - element('node-history-moment-previous').addEventListener('click', () => + /** + * Asks the host to select one event of the test replay by its sequence, as the event list does; sent by the shared transport's Replay from start, Home and End (DS-C), so several events at one time never skip the first. + * @param sequence - The event's sequence, from the host's whole-history projection. + * @example selectTestReplayEvent(current.nodeHistoryPlayback.firstSequence) + */ + const selectTestReplayEvent = (sequence: number | undefined): void => { + if (current?.nodeHistory && sequence !== undefined) + send({ + type: 'node-history-event', + studyId: current.nodeHistory.header.studyId, + 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') + */ + const playTestReplayControl = (control: string): void => { + if (control === 'start') + selectTestReplayEvent(current?.nodeHistoryPlayback?.firstSequence) + else if (control === 'previous' || control === 'next') + stepTestReplay(control, 'stop') + else if (control === 'play' || control === 'pause') + nodeHistoryPlayback(control) + } + element('previous-event').addEventListener('click', () => stepTestReplay('previous', 'event'), ) - element('node-history-moment-next').addEventListener('click', () => + element('next-event').addEventListener('click', () => stepTestReplay('next', 'event'), ) - const nodePlayhead = element('node-history-playhead') as HTMLInputElement - nodePlayhead.addEventListener('input', () => { - nodeHistoryScrubbing = true - text( - 'node-history-playback-position', - `${(Number(nodePlayhead.value) / options.secondMs).toFixed(2)} s`, - ) - // Assistive technology hears the time under the thumb while it moves, before the host answers the seek. - nodePlayhead.setAttribute( - 'aria-valuetext', - `${seconds(Number(nodePlayhead.value))} of recorded test time`, - ) - }) - nodePlayhead.addEventListener('change', () => { - nodeHistoryPlayback('seek', Number(nodePlayhead.value)) - nodeHistoryScrubbing = false - }) - // A scrub that ends without a change event (focus lost, pointer cancelled) must not keep the playhead from following later seeks and steps. - for (const scrubEnd of ['blur', 'pointercancel']) - nodePlayhead.addEventListener(scrubEnd, () => { - nodeHistoryScrubbing = false - }) // Only a moved thumb holds the playhead: a press without a drag fires no input and no change, and must not stop it from following later seeks. element('playhead').addEventListener('input', () => { scrubbing = true + // Assistive technology hears the test time under the thumb while it moves, before the host answers the seek. + if (current?.replayFamily === 'test') + describeTestReplayPlayhead(Number(playheadInput.value), '') }) // A scrub that ends without a change event (focus lost, pointer cancelled) releases the playhead as well. for (const scrubEnd of ['blur', 'pointercancel']) @@ -824,31 +832,91 @@ function studyClient( }) element('playhead').addEventListener('change', (event) => { scrubbing = false - if (event.target instanceof HTMLInputElement) - playback('seek', Number(event.target.value)) + if (!(event.target instanceof HTMLInputElement)) return + // A test replay's drag seeks its recorded test time; with no time range (every event at one time) there is no time to seek (DS-C). + if (current?.replayFamily === 'test') { + const playbackState = current.nodeHistoryPlayback + if ( + playbackState?.startMs !== undefined && + playbackState.endMs !== undefined && + playbackState.endMs > playbackState.startMs + ) + nodeHistoryPlayback('seek', Number(event.target.value)) + return + } + playback('seek', Number(event.target.value)) }) - element('playhead').addEventListener('keydown', (event) => { - if ( - !(event instanceof KeyboardEvent) || - event.defaultPrevented || - event.isComposing || + /** The keys the shared slider handles itself, for both families (C2). */ + const timelineKeys = [ + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'ArrowDown', + 'PageUp', + 'PageDown', + 'Home', + 'End', + ] + /** + * Says whether a keydown on the shared slider is one the page handles: one of its keys, with no modifier, outside IME composition and not handled already (C2). + * @param event - The keydown. + * @returns `true` when the slider's own handler moves the replay for this key. + * @example isTimelineKey(new KeyboardEvent('keydown', { key: 'End' })) // => true + */ + const isTimelineKey = (event: Event): event is KeyboardEvent => + event instanceof KeyboardEvent && + !event.defaultPrevented && + !event.isComposing && + !event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey && + timelineKeys.includes(event.key) + /** + * Says whether a keydown on the shared slider is one of its keys pressed with a modifier, or one already handled, outside IME composition; read by the slider's keydown listener, which moves a test replay by none of these and holds back the native step they would take (C2). + * @param event - The keydown. + * @returns `true` for Shift, Control, Alt or Meta with a slider key, and for a slider key whose default an earlier listener prevented. + * @example isTimelineChord(new KeyboardEvent('keydown', { key: 'End', ctrlKey: true })) // => true + */ + const isTimelineChord = (event: Event): boolean => + event instanceof KeyboardEvent && + !event.isComposing && + timelineKeys.includes(event.key) && + (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey || - event.shiftKey || - !current?.recording?.range || - ![ - 'ArrowLeft', - 'ArrowRight', - 'ArrowUp', - 'ArrowDown', - 'PageUp', - 'PageDown', - 'Home', - 'End', - ].includes(event.key) - ) + event.shiftKey) + /** + * Moves a test replay for a key on the shared slider (C2, DS-C): arrows step by event and Page keys by stop (Page Up forward, as for a recording), Home and End select the first and last event by sequence; a time step would land on the same event again, as a test replay's seek selects the event at or before the time. Called by the slider's one keydown listener while it shows a test replay. + * @param key - The key, one of {@link timelineKeys}. + * @example moveTestReplayByKey('ArrowRight') + */ + const moveTestReplayByKey = (key: string): void => { + const forward = ['ArrowRight', 'ArrowUp', 'PageUp'].includes(key) + const playbackState = current?.nodeHistoryPlayback + if (key === 'Home') selectTestReplayEvent(playbackState?.firstSequence) + else if (key === 'End') selectTestReplayEvent(playbackState?.lastSequence) + else + stepTestReplay( + forward ? 'next' : 'previous', + key.startsWith('Page') ? 'stop' : 'event', + ) + } + // One listener reads the family shown when the key is pressed, so a family change with the slider focused uses no stale handler (DS-C). + element('playhead').addEventListener('keydown', (event) => { + // A test replay's slider has no time step of its own: a chord of its keys moves nothing, and the native step it would take is held back (C2). + if (current?.replayFamily === 'test' && isTimelineChord(event)) { + event.preventDefault() return + } + if (!isTimelineKey(event)) return + if (current?.replayFamily === 'test') { + event.preventDefault() + moveTestReplayByKey(event.key) + return + } + if (!current?.recording?.range) return event.preventDefault() const { startMs, endMs } = current.recording.range, position = current.recording.presentedMs ?? startMs @@ -1800,7 +1868,7 @@ function studyClient( * Shows or hides a Show Variables button, and remembers where the keyboard focus goes when the button leaves while it holds this pane's focus; called for the replay's and the Node history's button on every Study message. * @param buttonId - The Show Variables button. * @param isOffered - Whether the selected moment's native values are not loaded, so the button is shown. - * @param preferred - Selectors of that transport's controls in the order the focus tries them, Play first. + * @param preferred - Selectors of the controls in the order the focus tries them: the transport's, Play first, or the summary's fallback headings. * @param section - Id of the section whose first usable control takes the focus when none of the preferred ones can. * @example offerShowVariables('playback-show-variables', false, options.replayTransportFocus, 'recording-transport') */ @@ -1831,7 +1899,7 @@ function studyClient( ].find( (control): control is HTMLElement => control instanceof HTMLElement && - control.matches('button,input,summary') && + control.matches('button,input,summary,[data-focus-fallback]') && control !== owed.leaving && !control.matches(':disabled') && // Shown: a control inside a closed disclosure still has boxes, and cannot take the focus. @@ -1890,7 +1958,7 @@ function studyClient( * Gives the control of the pane that stays and takes a lost focus when no disclosure around the control that left is shown; read by {@link focusTargetFor}. * @param leaving - The control that left while it held the focus. * @returns - * - In the Study pane: the selected content tab while it is shown; while a selected test replay hides the content tabs ({@link hideArticleForTestReplay}), the first test replay transport control that can be used (F567) + * - In the Study pane and the Learning summary: the selected content tab while it is shown; while a selected test replay hides the content tabs ({@link hideArticleForTestReplay}), the moment block's step heading, else the summary's title heading (DS-E) * - In the bottom panel's pane, which has no content tabs: Play or the next transport control that can be used while the transport is shown, else the first control the pane shows * @example stayingControl(button('stop')!)?.id // => 'live-tab' */ @@ -1908,7 +1976,7 @@ function studyClient( if (tab?.checkVisibility()) return tab return usableTransportControl({ leaving, - preferred: options.nodeTransportFocus, + preferred: options.summaryFocusFallback, section: 'node-history', }) } @@ -1925,7 +1993,10 @@ function studyClient( (control) => { if (control) focusHolder = { control, isTakenByPage: true } }, - () => settleFocus(), + () => { + settleFocus() + focusSummaryStart() + }, ) /** * Says whether a control lost the focus without the learner taking it off inside the pane: the editor took it when the learner left the pane at that control, or the page took it by disabling the control or by taking it out. Read by {@link keptFocusHolder}. @@ -1996,6 +2067,28 @@ 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 + }) + const focusSummaryStart = (): void => { + if ( + controlEverFocused || + isBottomPanel || + document.body.dataset.replay !== 'true' || + !document.hasFocus() || + document.activeElement !== document.body + ) + return + element('title').focus({ preventScroll: true }) + } /** Says whether the transport's status describes a shown moment, which is when it carries the covered label: not while the replay failed, nor while a requested moment is pending. Read by {@link coveredAnnouncement}. @example showsMomentStatus(recording) // => false for a failed replay */ const showsMomentStatus = ( recording: NonNullable, @@ -2043,6 +2136,9 @@ function studyClient( const replay = Boolean(message.nodeHistory) element('content-tabs').hidden = replay element('source-flow').hidden = replay + // The article's source links and its own understanding check describe the article, not the replay, whose check stands in its summary (Cycle 0051, DS-B). + const articleSource = element('source-version').closest('section') + if (articleSource) articleSource.hidden = replay // The loading line and the selected block were just set from the article's own state; a shown test replay only hides them. if (!replay) return element('loading').hidden = true @@ -2345,6 +2441,16 @@ function studyClient( visual.append(diagram) return [visual, list] } + /** + * Gives the replay family a Study message shows, which decides the summary composition's page (Cycle 0051, DS-A): the host's `replayFamily`, else a browser replay when the message carries a recording, as the page decided before the host named the family. + * @param message - The current Study message. + * @returns `'browser'`, `'test'` or `undefined` outside a replay. + * @example replayFamilyOf({ ...message, recording }) // => 'browser' + */ + const replayFamilyOf = ( + message: StudyViewMessage, + ): StudyViewMessage['replayFamily'] => + message.replayFamily ?? (message.recording ? 'browser' : undefined) /** * Says whether a Study message shows a replay in the bottom panel's pane, which shows one transport; read by {@link showsLiveStrip} and before the save state and unrelated Node history are written. * The live strip and the save state are left out there by keeping their status regions empty, not by taking them from the page: when the replay is left, the run's state and the save state are written into regions that exist. Both say how things stand at that moment, so their announcement then is no old news. @@ -2352,7 +2458,22 @@ function studyClient( * @example isReplayInBottomPanel(message) // => false in the Study pane, which shows the strip beside a replay */ const isReplayInBottomPanel = (message: StudyViewMessage): boolean => - isBottomPanel && Boolean(message.recording) + 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' + */ + const testReplayTitle = (message: StudyViewMessage): string => { + const studyId = message.nodeHistory?.header.studyId + const summary = message.nodeHistoryCatalog?.find( + (entry) => entry.studyId === studyId, + ) + return summary + ? [summary.testFile, ...(summary.testTitles ?? [])].join(' › ') + : options.testReplay.heading + } /** * Renders the Study pane's test replay section for a Study message (Designs 5 and 6, DS16): status, the selected moment and its explanation, the transport, the summary with the selected step, the learner's decision and the collapsed event list; called by the message listener, which holds no branch of the section's own. * During a browser replay in the bottom panel the section's status regions stay empty and in the page. @@ -2367,7 +2488,6 @@ function studyClient( const suppressed = isReplayInBottomPanel(message) const section = suppressed ? undefined : message.nodeHistorySection renderTestReplayMoment(message, section, suppressed, replaying) - renderTestReplayTransport(message, section, replaying, suppressed) renderTestReplayLearning(message, section, replaying, suppressed) renderTestReplayUnderstanding(message, replaying) renderTestReplayEvents(message, section, replaying) @@ -2411,7 +2531,7 @@ function studyClient( offerShowVariables( 'node-history-show-variables', uninspected, - options.nodeTransportFocus, + options.summaryFocusFallback, 'node-history', ) // The row's Inspect is wanted while the stop's values are not loaded (beside Show Variables) and whenever the debugger does not show the selected stop: after the learner ended its session, or when it could not start, the press starts it again (F348). @@ -2444,7 +2564,7 @@ function studyClient( offerShowVariables( 'node-history-inspect', offered, - options.nodeTransportFocus, + options.summaryFocusFallback, 'node-history', ) if (!offered) return @@ -2512,163 +2632,192 @@ function studyClient( ): string => section ? (section.position ?? options.testReplay.noSelection) : '' /** - * Enables or disables a control of the section by its id; called by the transport's renderers. - * @param id - The control's id. - * @param disabled - Whether it is disabled. - * @example setDisabled('node-history-stop-next', true) - */ - const setDisabled = (id: string, disabled: boolean): void => { - const control = element(id) - if ( - control instanceof HTMLButtonElement || - control instanceof HTMLInputElement - ) - control.disabled = disabled - } - /** - * Sets the section's transport: Previous stop and Next stop first (DS12), Play or Pause, the time slider and Previous event and Next event; called by {@link renderTestReplay}. + * Writes a test replay into the shared transport (Cycle 0051, DS-C): the status line, Replay from start, Previous and Next recorded stop, Play or Pause, the slider with its coverage and the readout, and the event group; a browser recording's own parts (playback details, pending and reason lines, the covered-application remedy) stay out. Called by the message listener while the summary composition shows a test replay. + * The status regions carry text only in the pane that shows the transport, as for a recording. * @param message - The current Study message. - * @param section - The section's worded view. - * @param replaying - Whether a browser recording is shown. - * @param suppressed - Whether a browser replay holds the bottom panel. - * @example renderTestReplayTransport(message, message.nodeHistorySection, false, false) + * @param isTransportShown - Whether this pane shows the transport ({@link showsTransport}). + * @example renderTestReplaySharedTransport(message, true) */ - const renderTestReplayTransport = ( + const renderTestReplaySharedTransport = ( message: StudyViewMessage, - section: StudyViewMessage['nodeHistorySection'], - replaying: boolean, - suppressed: boolean, + isTransportShown: boolean, ): void => { - const eventCount = message.nodeHistoryEventCount ?? 0, - selectedIndex = message.nodeHistoryPosition?.eventIndex ?? -1 - const unavailable = !message.nodeHistory || suppressed || replaying - renderTestReplayMoves(section, unavailable, eventCount, selectedIndex) - renderTestReplayToggle( - message, - suppressed, - eventCount > 0 && selectedIndex === eventCount - 1, - ) - renderTestReplayPlayhead( - message.nodeHistoryPlayback, - !message.nodeHistory || replaying, + const section = message.nodeHistorySection, + playbackState = message.nodeHistoryPlayback + const playing = playbackState?.mode === 'playing' + if (isTransportShown) + text('recording-status', testReplayTransportStatus(message, playing)) + hideBrowserRecovery() + element('playback-events').hidden = false + renderTestReplayToggle(message, playing) + renderTestReplayMoves(message) + renderTestReplayPlayhead(playbackState, message.nodeHistoryEventCount ?? 0) + renderTimelineCoverage( + element('history-coverage'), + element('history-coverage-label'), + section?.timeline, + section?.timeline ? options.testReplay.timelineLegend : '', ) - renderTestReplayTimeline(section, unavailable) } /** - * Draws the test replay slider's marks, the whole range unrecorded with a tick at each verified stop, and its legend; called by {@link renderTestReplayTransport}. - * @param section - The section's worded view, which carries the timeline of the shown replay. - * @param unavailable - Whether no test replay is shown: none, a browser recording shown, or the bottom panel held by a browser replay; the marks belong to the shown replay only. - * @example renderTestReplayTimeline(message.nodeHistorySection, false) + * Takes a browser recording's recovery and values hint off the shared transport while it shows a test replay (DS-G): the pending and reason lines, the error details, the covered-application remedy, the hint with its Show Variables, and Playback details; the test replay's own hint stands in its summary. Called by {@link renderTestReplaySharedTransport}. + * @example hideBrowserRecovery() */ - const renderTestReplayTimeline = ( - section: StudyViewMessage['nodeHistorySection'], - unavailable: boolean, - ): void => { - const marks = unavailable ? undefined : section?.timeline - renderTimelineCoverage( - element('node-history-coverage'), - element('node-history-coverage-label'), - marks, - marks ? options.testReplay.timelineLegend : '', + const hideBrowserRecovery = (): void => { + for (const id of [ + 'playback-pending', + 'playback-reason', + 'playback-diagnostic', + 'application-covered-remedy', + 'playback-hint', + ]) + text(id, '') + offerShowVariables( + 'playback-show-variables', + false, + options.replayTransportFocus, + 'recording-transport', ) + element('playback-options').hidden = true } /** - * Enables Previous stop, Next stop, Previous event and Next event where a move exists; called by {@link renderTestReplayTransport}. - * @param section - The section's worded view, which says whether an earlier or later stop exists. - * @param unavailable - Whether no move is possible: no test replay, a browser recording shown, or the bottom panel held by a browser replay. - * @param eventCount - How many events the replay holds. - * @param selectedIndex - The selected event's position among them, or -1. - * @example renderTestReplayMoves(message.nodeHistorySection, false, 4, 0) + * Enables the shared transport's move buttons for a test replay where the move exists (DS-C, DS12): the stop buttons by the host's stop flags, the event buttons and Replay from start by the selected event's place among the whole history's first and last events, compared by sequence (several events may share a time); a history of fewer than two events has no move. Called by {@link renderTestReplaySharedTransport}. + * @param message - The current Study message. + * @example renderTestReplayMoves(message) */ - const renderTestReplayMoves = ( - section: StudyViewMessage['nodeHistorySection'], - unavailable: boolean, - eventCount: number, - selectedIndex: number, - ): void => { - setDisabled( - 'node-history-stop-previous', - unavailable || !section?.previousStop, - ) - setDisabled('node-history-stop-next', unavailable || !section?.nextStop) - // Without an event there is no event to move to in either direction. - const noEvent = unavailable || eventCount === 0 - setDisabled('node-history-moment-previous', noEvent || selectedIndex === 0) - setDisabled( - 'node-history-moment-next', - noEvent || selectedIndex === eventCount - 1, + const renderTestReplayMoves = (message: StudyViewMessage): void => { + const section = message.nodeHistorySection, + playbackState = message.nodeHistoryPlayback + const noMove = !message.nodeHistory || !message.nodeHistoryEventCount + const noEventMove = noMove || (message.nodeHistoryEventCount ?? 0) < 2 + const selected = + message.nodeHistoryPosition?.sequence ?? playbackState?.sequence + const atFirst = + selected !== undefined && selected === playbackState?.firstSequence + const atLast = + selected !== undefined && selected === playbackState?.lastSequence + setDisabled('previous-moment', noMove || !section?.previousStop) + setDisabled('next-moment', noMove || !section?.nextStop) + setDisabled('previous-event', noEventMove || atFirst) + setDisabled('next-event', noEventMove || atLast) + const start = document.querySelector( + '[data-playback="start"]', ) + // Replay from start is wanted unless the first event is selected. + if (start) start.disabled = noEventMove || atFirst } /** - * Sets Play or Pause: Pause while the replay plays, Play otherwise, which cannot start at the last event; called by {@link renderTestReplayTransport}. + * Words the shared transport's status line for a test replay: playing or paused, and the host's sentence for a move it refused or that failed (DS-G), so the reason is read with the summary hidden too; called by {@link renderTestReplaySharedTransport}. + * The moment a move reaches is said once, by the slider's spoken value and the readout, so the status line, a live region, does not say it a second time. * @param message - The current Study message. - * @param suppressed - Whether a browser replay holds the bottom panel. - * @param atLastEvent - Whether the last event is selected. - * @example renderTestReplayToggle(message, false, false) + * @param playing - Whether the test replay plays. + * @returns The status line. + * @example testReplayTransportStatus(message, false) // => 'Test replay paused' */ - const renderTestReplayToggle = ( + const testReplayTransportStatus = ( message: StudyViewMessage, - suppressed: boolean, - atLastEvent: boolean, - ): void => { - const toggle = element('node-history-playback-toggle') - const playing = message.nodeHistoryPlayback?.mode === 'playing' - toggle.dataset.nodeHistoryPlayback = playing ? 'pause' : 'play' - toggle.textContent = playing - ? options.testReplay.pause - : options.testReplay.play - setDisabled( - 'node-history-playback-toggle', - testReplayToggleDisabled(message, suppressed, playing || !atLastEvent), + playing: boolean, + ): string => { + const status = playing + ? options.testReplay.playingStatus + : options.testReplay.pausedStatus + return message.nodeHistoryRefusal + ? `${status} · ${message.nodeHistoryRefusal}` + : status + } + /** + * Enables or disables a control by its id; called by the transport's renderers. + * @param id - The control's id. + * @param disabled - Whether it is disabled. + * @example setDisabled('next-moment', true) + */ + const setDisabled = (id: string, disabled: boolean): void => { + const control = element(id) + if ( + control instanceof HTMLButtonElement || + control instanceof HTMLInputElement ) + control.disabled = disabled } /** - * Says whether Play or Pause is unavailable; called by {@link renderTestReplayToggle}. + * Sets the shared Play or Pause for a test replay: Pause while it plays, Play otherwise, which cannot start at the last event or while loading; called by {@link renderTestReplaySharedTransport}. * @param message - The current Study message. - * @param suppressed - Whether a browser replay holds the bottom panel. - * @param canMove - Whether the control has somewhere to go: Pause while playing, or Play before the last event. - * @returns `true` without a test replay, while suppressed, without a selected event, while loading, or with nowhere to go. - * @example testReplayToggleDisabled(message, false, true) // => false + * @param playing - Whether the test replay plays. + * @example renderTestReplayToggle(message, false) */ - const testReplayToggleDisabled = ( + const renderTestReplayToggle = ( message: StudyViewMessage, - suppressed: boolean, - canMove: boolean, - ): boolean => { + playing: boolean, + ): void => { + const toggle = element('playback-toggle') + if (!(toggle instanceof HTMLButtonElement)) return const playbackState = message.nodeHistoryPlayback - return ( + toggle.dataset.playback = playing ? 'pause' : 'play' + toggle.textContent = playing + ? options.testReplay.pause + : options.testReplay.play + toggle.setAttribute( + 'aria-label', + playing ? 'Pause test replay' : 'Play test replay', + ) + toggle.title = playing ? 'Pause test replay' : 'Play test replay' + const atLastEvent = + playbackState?.sequence !== undefined && + playbackState.sequence === playbackState.lastSequence + toggle.disabled = !message.nodeHistory || - suppressed || + // A history of one event has nothing to play to (DS-C). + (message.nodeHistoryEventCount ?? 0) < 2 || !playbackState?.sequence || Boolean(playbackState.loading) || - !canMove - ) + (!playing && atLastEvent) } /** - * Places the section's time slider at the selected time, unless the learner is dragging it; called by {@link renderTestReplayTransport}. + * Places the shared slider at a test replay's selected time, unless the learner drags it, and names its keys for this family (DS-C, D035); called by {@link renderTestReplaySharedTransport}. + * A history of two or more events keeps the slider enabled for its keys even when every event has one time: the arrows still step by event (DS-C). * @param playbackState - The test replay's playback state. - * @param unavailable - Whether no test replay is shown or a browser recording is. - * @example renderTestReplayPlayhead(message.nodeHistoryPlayback, false) + * @param eventCount - How many events the history holds. + * @example renderTestReplayPlayhead(message.nodeHistoryPlayback, 4) */ const renderTestReplayPlayhead = ( playbackState: StudyViewMessage['nodeHistoryPlayback'], - unavailable: boolean, + eventCount: number, ): void => { const range = testReplayPlayheadRange(playbackState) - // A replay without a time range has a range of nothing (0 to 0), which disables the slider like a single moment does. - nodePlayhead.disabled = - unavailable || range.startMs === range.endMs || range.loading + playheadInput.dataset.replayFamily = 'test' + playheadInput.title = options.testReplayTimelineTitle + playheadInput.setAttribute( + 'aria-keyshortcuts', + options.testReplayTimelineKeys, + ) + playheadInput.step = '1' + playheadInput.disabled = eventCount < 2 || range.loading // A disabled playhead cannot be scrubbed, so a scrub interrupted by loading ends here. - if (nodePlayhead.disabled) nodeHistoryScrubbing = false - nodePlayhead.min = String(range.startMs) - nodePlayhead.max = String(range.endMs) - if (nodeHistoryScrubbing) return - nodePlayhead.value = String(range.positionMs) + if (playheadInput.disabled) scrubbing = false + playheadInput.min = String(range.startMs) + playheadInput.max = String(range.endMs) + if (scrubbing) return + playheadInput.value = String(range.positionMs) describeTestReplayPlayhead( playbackState?.positionMs, testReplayLoadingSuffix(playbackState), + range.endMs, ) } + /** + * Gives the shared slider back to a browser recording: its family mark, its keys and its tooltip as the shortcut help set them; called by the message listener whenever the summary composition does not show a test replay. + * @example releaseTestReplayPlayhead() + */ + const releaseTestReplayPlayhead = (): void => { + if (playheadInput.dataset.replayFamily !== 'test') return + delete playheadInput.dataset.replayFamily + playheadInput.title = browserPlayheadTitle + playheadInput.removeAttribute('aria-keyshortcuts') + element('playback-events').hidden = true + setDisabled('previous-event', true) + setDisabled('next-event', true) + element('playback-options').hidden = false + } /** * Reads the slider's range and position from a test replay's playback state; called by {@link renderTestReplayPlayhead}. * @param playbackState - The test replay's playback state. @@ -2706,26 +2855,28 @@ function studyClient( playbackState: StudyViewMessage['nodeHistoryPlayback'], ): string => (playbackState?.pending ? options.testReplay.loadingSuffix : '') /** - * Says the selected test time to assistive technology and beside the slider, never the raw millisecond value; called by {@link renderTestReplayPlayhead}. + * Says the selected test time to assistive technology and in the shared readout (` s / s`), never the raw millisecond value; called by {@link renderTestReplayPlayhead} and while the learner drags the slider. * @param positionMs - The selected time, if any. * @param suffix - What both texts end with: the loading words while the time is a moment still being selected, else nothing (F388). - * @example describeTestReplayPlayhead(420, ' · loading…') + * @param endMs - The history's last time, for the readout's total. + * @example describeTestReplayPlayhead(420, ' · loading…', 900) */ const describeTestReplayPlayhead = ( positionMs: number | undefined, suffix: string, + endMs = current?.nodeHistoryPlayback?.endMs, ): void => { - nodePlayhead.setAttribute( + playheadInput.setAttribute( 'aria-valuetext', positionMs === undefined ? 'No recorded test time selected' : `${seconds(positionMs)} of recorded test time${suffix}`, ) text( - 'node-history-playback-position', + 'playback-position', positionMs === undefined ? 'Test journal playback' - : `Recorded test time · ${(positionMs / options.secondMs).toFixed(2)} s${suffix}`, + : `${seconds(positionMs)} / ${seconds(endMs ?? positionMs)}${suffix}`, ) } /** @@ -2745,8 +2896,10 @@ function studyClient( // While a browser replay holds the bottom panel the block is not drawn: its steps would all read `No recorded event is linked to this step.` (F153). const learning = suppressed ? undefined : message.nodeHistoryLearningSummary element('node-history-learning').hidden = !learning + element('node-history-material').hidden = !learning element('node-history-no-material').hidden = suppressed || !message.nodeHistory || Boolean(learning) + renderTestReplayStepHeading(message, section, learning) if (!learning) { // A message without material keeps no earlier replay's diagram or steps in the page, hidden or not. renderKeys.delete('node-learning-sequence') @@ -2767,11 +2920,112 @@ function studyClient( `Sequence for ${learning.content.title}. Numbered interactions are described below.`, (step) => testReplayStepAction(step.id, learning, section), ) + tagTestReplayDrawing(visual, learning.content.steps) element('node-learning-sequence').replaceChildren(visual) element('node-learning-steps').replaceChildren(list) } markTestReplaySteps(section, replaying) + markTestReplayDrawing( + message.nodeHistoryPosition?.pending ? undefined : section, + ) + } + /** + * Names each arrow label of a test replay's drawing by its step (`data-step`), so the selected step can be marked on the drawing that stays (DS-B); called by {@link renderTestReplayLearning} once per drawing. The shared {@link sequenceView} is unchanged, so the browser replay's diagram keeps its markup. + * Each step's label is the text the renderer appends right after that step's arrow. + * @param visual - The drawing's container. + * @param steps - The material's steps, in the drawing's order. + * @example tagTestReplayDrawing(visual, learning.content.steps) + */ + const tagTestReplayDrawing = ( + visual: HTMLElement, + steps: readonly { id: string }[], + ): void => { + visual.querySelectorAll('path.sequence-arrow').forEach((arrow, index) => { + const label = arrow.nextElementSibling + const step = steps[index] + if (label instanceof SVGElement && step) { + label.dataset.step = step.id + if (arrow instanceof SVGElement) arrow.dataset.step = step.id + } + }) + } + /** + * Marks the selected step on the test replay's drawing by setting `aria-current="step"` and a class on its existing arrow and label, never by drawing again, so a move keeps the diagram's scroll position and the focus (DS-B, Eng); a pending moment or an event with no step marks none. Called by {@link renderTestReplayLearning} for every Study message. + * @param section - The section's worded view, which names the selected step; absent while the moment is pending. + * @example markTestReplayDrawing(message.nodeHistorySection) + */ + const markTestReplayDrawing = ( + section: StudyViewMessage['nodeHistorySection'] | undefined, + ): void => { + const selected = section?.selectedStepId + for (const node of Array.from( + element('node-learning-sequence').querySelectorAll( + '[data-step]', + ), + )) { + const isSelected = + selected !== undefined && node.dataset.step === selected + node.classList.toggle('current-step', isSelected) + if (isSelected) node.setAttribute('aria-current', 'step') + else node.removeAttribute('aria-current') + } + } + /** + * Heads the moment block with the selected step's number and label (`Step ·