From d015a1e36500916eec2d84547be2b8b9c1b97415 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Thu, 22 Oct 2026 16:10:21 +0900 Subject: [PATCH 01/13] feat: recorded test output in the Application pane and the test replay's selected step (Cycle 0049, PR-B1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Application pane is now a test replay's one output surface (Design 3): one document per history, updated in place for each later moment, with the header `Recorded test output · s` over the test's file and title path, `[stderr] ` marks, terminal and bidirectional controls removed, the Node inspector's lines folded into one muted line, the last 65,536 code points kept with the count of what is left out, the verdict at the result and the ended-early line otherwise. It follows the latest output until the learner scrolls up, and Jump to latest follows again. The Study pane's test replay section is reordered (DS16): status, the selected moment with its explanation in one polite live region, the not-recorded and unavailable-source lines, the sticky transport with Previous stop and Next stop first (DS12), the learning material drawn by the browser replay's sequence renderer with the selected step marked and the other steps of its moment reading `Same recorded moment` (Design 5, DS21), and the events in a collapsed list with humanized rows. Tests: output utilities, the Application document and its hostile markup, the section projection, stop moves, the workspace's in-place updates and stop navigation, the viewport's restore copy, and the Reader DOM case over both documents (keyboard path, three widths, an in-place update keeping scroll, selection and focus), red first on main's build. The browser replay's Learning summary markup is identical to its golden copy taken before the renderer was shared. Refs #107 --- extension/README.md | 10 +- .../fixtures/learning-sequence-golden.txt | 1 + extension/scripts/study-bootstrap-qa.mjs | 671 +++++++++- extension/src/constants.ts | 2 + extension/src/nodeTestHistorySchemas.ts | 3 +- .../src/runtimeSupport/browserViewport.ts | 10 + extension/src/studyWorkspace.ts | 198 ++- .../tests/accumulateNodeTestOutput.test.ts | 164 +++ extension/src/tests/cutTextToTail.test.ts | 22 + .../src/tests/describeNodeTestReplay.test.ts | 108 +- .../src/tests/nodeHistoryStepTarget.test.ts | 99 ++ .../tests/projectNodeTestApplication.test.ts | 187 +++ .../src/tests/projectNodeTestSection.test.ts | 309 +++++ .../src/tests/runtime-browserViewport.test.ts | 31 + .../src/tests/stripTerminalControls.test.ts | 32 + extension/src/tests/studyWorkspace.test.ts | 48 +- extension/src/tests/studyWorkspaceHarness.ts | 10 + .../studyWorkspaceTestReplayOutput.test.ts | 297 +++++ extension/src/tests/testOutputHtml.test.ts | 93 ++ extension/src/tests/utils/shownTestOutput.ts | 40 + extension/src/ui/constants.ts | 18 +- extension/src/ui/studyHtml.ts | 1184 ++++++++++------- .../src/utils/accumulateNodeTestOutput.ts | 115 ++ extension/src/utils/capturedAppHtml.ts | 43 +- extension/src/utils/cutTextToTail.ts | 17 + extension/src/utils/describeNodeTestReplay.ts | 198 ++- extension/src/utils/escapeHtml.ts | 2 +- extension/src/utils/indexNodeTestOutput.ts | 99 ++ extension/src/utils/installNodeTestOutput.ts | 178 +++ extension/src/utils/nodeHistoryStepTarget.ts | 29 + extension/src/utils/nodeTestOutputLineCost.ts | 20 + .../src/utils/projectNodeLearningSummary.ts | 48 + .../src/utils/projectNodeTestApplication.ts | 163 +++ extension/src/utils/projectNodeTestSection.ts | 251 ++++ extension/src/utils/stripTerminalControls.ts | 21 + extension/src/utils/testOutputHtml.ts | 30 + 36 files changed, 4132 insertions(+), 619 deletions(-) create mode 100644 extension/scripts/fixtures/learning-sequence-golden.txt create mode 100644 extension/src/tests/accumulateNodeTestOutput.test.ts create mode 100644 extension/src/tests/cutTextToTail.test.ts create mode 100644 extension/src/tests/nodeHistoryStepTarget.test.ts create mode 100644 extension/src/tests/projectNodeTestApplication.test.ts create mode 100644 extension/src/tests/projectNodeTestSection.test.ts create mode 100644 extension/src/tests/stripTerminalControls.test.ts create mode 100644 extension/src/tests/studyWorkspaceTestReplayOutput.test.ts create mode 100644 extension/src/tests/testOutputHtml.test.ts create mode 100644 extension/src/tests/utils/shownTestOutput.ts create mode 100644 extension/src/utils/accumulateNodeTestOutput.ts create mode 100644 extension/src/utils/cutTextToTail.ts create mode 100644 extension/src/utils/indexNodeTestOutput.ts create mode 100644 extension/src/utils/installNodeTestOutput.ts create mode 100644 extension/src/utils/nodeHistoryStepTarget.ts create mode 100644 extension/src/utils/nodeTestOutputLineCost.ts create mode 100644 extension/src/utils/projectNodeLearningSummary.ts create mode 100644 extension/src/utils/projectNodeTestApplication.ts create mode 100644 extension/src/utils/projectNodeTestSection.ts create mode 100644 extension/src/utils/stripTerminalControls.ts create mode 100644 extension/src/utils/testOutputHtml.ts diff --git a/extension/README.md b/extension/README.md index feff52a72..fff47e8eb 100644 --- a/extension/README.md +++ b/extension/README.md @@ -70,7 +70,11 @@ A Vitest test run that the chat explained (the `node-test` profile) is saved the - **Learning partially saved · Open replay** otherwise. The tooltip names the result and every missing part, and a notification says what to ask the chat for (a missing explanation) or that only a new test run records it (missing capture). A failing test's replay is saved too: it holds the failing run. - **Learning save failed · Retry** when stopping the run did not finish (Retry stops it again) or the sealed history could not be read back (Retry reads it again; the test does not run again), and **Learning save failed · Open retained history** when the history could not be sealed (the recorded part opens read-only). Each tooltip names the cause; the details are in **Output › Codebook**. -**Open replay**, or the test replay's row in Saved studies, opens it in the Study pane at its saved reader position, or on its first stop whose recorded source verifies. One replay shows at a time: while a browser replay is open, Codebook asks you to close it first. Saved studies list each test replay with its result, its capture and its explained stops, for example `Test replay · Test passed · exit code 0 · capture complete · 2 of 2 stops explained`. In this version the recorded output shows in the Study pane, and the Application pane points there. +**Open replay**, or the test replay's row in Saved studies, opens it in the Study pane at its saved reader position, or on its first stop whose recorded source verifies. One replay shows at a time: while a browser replay is open, Codebook asks you to close it first. Saved studies list each test replay with its result, its capture and its explained stops, for example `Test replay · Test passed · exit code 0 · capture complete · 2 of 2 stops explained`. + +The **Application pane** shows a test replay's recorded test output up to the selected moment and nothing printed after it. Its header reads `Recorded test output · s` over the test's file and title path (`` counts from the run's start, paused time included). Lines the test wrote to standard error start with `[stderr] `; terminal colors, cursor moves and other control characters are removed; the Node inspector's own lines fold into one muted line such as `2 Node inspector lines hidden`; and the pane keeps the last 65,536 characters, with a line such as `… 1200 earlier characters not shown` before them. At the result the output ends with the verdict (`Test passed · exit code 0`, `Test failed · exit code ` or `Ended by signal `); a replay that ended before its result says `Recording ended before the test result.` Moving through the replay updates the same page in place, so your scroll position, text selection and keyboard focus stay; the pane follows the latest output until you scroll up, and **Jump to latest** follows it again. + +The Study pane's **Test replay** section names the result, the capture and the explained stops, then the selected moment (`Stop of · s`, `Output · s` or `Result · · s`) with the explanation the chat saved for it, or `No saved explanation for this stop.`; at an output, at the result and at a stop whose recorded source does not verify it adds `Source and debugger not recorded at this moment.` **Previous stop** and **Next stop** move between stops, **Previous event** and **Next event** through every event, and **Play** and the time slider move through the recording. The learning summary is drawn as a browser replay's: a sequence diagram and its numbered steps under **Step details**, where the selected step is marked and the other steps of the same moment read `Same recorded moment`; a step's link selects its moment and that step. The events are listed under the collapsed **Events ()**. When the Application pane is closed, the section says `The Application pane is closed.`, and **Show recorded output** restores the Study arrangement with it. Unexpected failures on these paths write one entry each to **Output › Codebook**, with the surface, the study, the profile, the versions, the cause and the stack. The causes they show name the extension's storage, the workspace and the home folder as ``, `` and `~`; some other messages can still show full local paths. @@ -82,14 +86,14 @@ The bundled skill (1.0.46) routes a question about a test, or about library beha 2. In Copilot Chat (in VS Code, a new chat in a **Local** session) or Cursor Agent, ask `/codebook` your own question about a test, or about library behavior a test exercises. 3. Follow the chat: it pauses the test at the stops it chose and explains each from the captured values, which you can inspect in the native debugger meanwhile. 4. When the test has run, click **Save learning replay** in the status bar. -5. Click **Open replay**, or open the test replay from Saved studies, and step through its stops, its output and its result. +5. Click **Open replay**, or open the test replay from Saved studies, and step through its stops with **Previous stop** and **Next stop**: the Application pane shows the test output up to each moment, and the Study pane its explanation. #### Terms - **Learning replay**: a saved explained run that you can replay after it ended; a browser run's holds the application video. - **Test replay**: the learning replay of a test run: its test output, the executed source and the debugger's recorded stops; no application video. - **Study pane**: Codebook's reader beside the editor, with Saved studies, a replay's summary and its controls. -- **Application pane**: the pane that shows the recorded application of a browser replay. +- **Application pane**: the pane that shows the recorded application of a browser replay, or the recorded test output of a test replay. - **Stop**: a place in source where the run paused for the explanation, numbered in the order the run reached it. - **Saved studies**: the list of saved replays in the Study pane. diff --git a/extension/scripts/fixtures/learning-sequence-golden.txt b/extension/scripts/fixtures/learning-sequence-golden.txt new file mode 100644 index 000000000..6f001d237 --- /dev/null +++ b/extension/scripts/fixtures/learning-sequence-golden.txt @@ -0,0 +1 @@ +
ApplicationSource handler1. Observed submission2. Source relationship3. Unknown completion
  1. Observed submission · observed

    The recorded source stop follows the user input.

  2. Source relationship · source-derived

    Derived from captured source, not proof of execution.

  3. Unknown completion · unknown

    <img src=x onerror="window.invented=true">

    No recorded evidence is linked to this step.

\ No newline at end of file diff --git a/extension/scripts/study-bootstrap-qa.mjs b/extension/scripts/study-bootstrap-qa.mjs index 04feb4bbd..c6acd146a 100644 --- a/extension/scripts/study-bootstrap-qa.mjs +++ b/extension/scripts/study-bootstrap-qa.mjs @@ -8,6 +8,7 @@ import { resolve } from 'node:path' import { projectStudy } from '../dist/studyModel.js' import { createConfiguredStudy } from '../dist/tests/utils/createConfiguredStudy.js' import { studyHtml } from '../dist/ui/studyHtml.js' +import { capturedAppHtml } from '../dist/utils/capturedAppHtml.js' import { decodeLearnerProgress } from '../dist/utils/decodeLearnerProgress.js' import { selectStudy } from '../dist/utils/selectStudy.js' @@ -1243,8 +1244,28 @@ async function check(page, options, collected) { sourceAvailable: true, debuggerAvailable: true, } + // The host words the test replay section (projectNodeTestSection); the pane places its lines. + const nodeSection = { + status: + 'Test passed · exit code 0 · capture complete · 1 of 1 stops explained', + position: 'Stop 1 of 1 · 1.20 s', + pointer: 'Output up to this moment is in the Application pane.', + applicationClosed: false, + sameMomentStepIds: [], + stepActions: {}, + events: 'Events (1)', + verdict: 'Test passed · exit code 0', + previousStop: false, + nextStop: false, + } + const unverifiedNodeSection = { + ...nodeSection, + notRecorded: 'Source and debugger not recorded at this moment.', + unverified: 'Recorded source unavailable for this stop.', + } await publish({ ...replay, + nodeHistorySection: nodeSection, nodeHistoryPosition: { ...nodeStop, nativeValuesUninspected: true }, }) const nodeWithoutValues = { @@ -1257,6 +1278,7 @@ async function check(page, options, collected) { } await publish({ ...replay, + nodeHistorySection: nodeSection, nodeHistoryPosition: { ...nodeStop, nativeValuesUninspected: false }, }) const nodeWithValues = { @@ -1267,42 +1289,56 @@ async function check(page, options, collected) { name: 'a saved Node stop without native values shows the Run and Debug hint in its own muted status until values load', actual: { nodeWithoutValues, nodeWithValues }, passed: - nodeWithoutValues.position === - 'Debugger stop 1 · 1.20 s · output shown through this event.' && + nodeWithoutValues.position === 'Stop 1 of 1 · 1.20 s' && nodeWithoutValues.hint === '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 “Codebook · Recorded learning” first.' && nodeWithoutValues.role === 'status' && nodeWithoutValues.muted && - nodeWithValues.position === - 'Debugger stop 1 · 1.20 s · output shown through this event.' && + nodeWithValues.position === 'Stop 1 of 1 · 1.20 s' && nodeWithValues.hint === '', }) + /** Reads the test replay section's position and the two lines that say what is not recorded. */ + const nodeMomentLines = async () => ({ + position: await page.locator('#node-history-position').textContent(), + notRecorded: await page + .locator('#node-history-not-recorded') + .textContent(), + unverified: await page + .locator('#node-history-unverified') + .textContent(), + }) await publish({ ...replay, + nodeHistorySection: nodeSection, nodeHistoryPosition: { ...nodeStop, debuggerAvailable: false }, }) - const nodeSessionEnded = await page - .locator('#node-history-position') - .textContent() + const nodeSessionEnded = await nodeMomentLines() await publish({ ...replay, + nodeHistorySection: unverifiedNodeSection, nodeHistoryPosition: { ...nodeStop, sourceAvailable: false, debuggerAvailable: false, }, }) - const nodeSourceMismatch = await page - .locator('#node-history-position') - .textContent() + const nodeSourceMismatch = await nodeMomentLines() cases.push({ - name: 'a saved Node stop whose native session ended keeps its source label, and only a real source mismatch says captured source unavailable', + name: 'a saved test replay stop whose native session ended keeps its position line, and only a real source mismatch shows the unavailable-source and not-recorded lines', actual: { nodeSessionEnded, nodeSourceMismatch }, passed: - nodeSessionEnded === - 'Debugger stop 1 · 1.20 s · output shown through this event.' && - nodeSourceMismatch === - 'Debugger stop 1 · captured source unavailable · 1.20 s · output shown through this event.', + JSON.stringify(nodeSessionEnded) === + JSON.stringify({ + position: 'Stop 1 of 1 · 1.20 s', + notRecorded: '', + unverified: '', + }) && + JSON.stringify(nodeSourceMismatch) === + JSON.stringify({ + position: 'Stop 1 of 1 · 1.20 s', + notRecorded: 'Source and debugger not recorded at this moment.', + unverified: 'Recorded source unavailable for this stop.', + }), }) // Arrange: the Node history reader alone, with its selected stop shown by the native session. const nodeReader = { @@ -1371,6 +1407,7 @@ async function check(page, options, collected) { // Act: the selected stop's captured source no longer matches its recorded hash. await publish({ ...nodeReader, + nodeHistorySection: unverifiedNodeSection, nodeHistoryPosition: { ...nodeStop, sourceAvailable: false, @@ -1379,7 +1416,9 @@ async function check(page, options, collected) { }) // Counted and read without waiting, so a missing element is a failed case instead of a harness timeout. const sourceMismatch = { - position: await page.locator('#node-history-position').textContent(), + unverified: await page + .locator('#node-history-unverified') + .textContent(), inspectButtons: await nodeInspect.count(), explanation: await page .locator('#node-history-events .node-history-event p.muted') @@ -1389,8 +1428,8 @@ async function check(page, options, collected) { name: 'a selected Node stop whose captured source does not match offers no Inspect in native debugger', actual: sourceMismatch, passed: - sourceMismatch.position === - 'Debugger stop 1 · captured source unavailable · 1.20 s · output shown through this event.' && + sourceMismatch.unverified === + 'Recorded source unavailable for this stop.' && sourceMismatch.inspectButtons === 0 && sourceMismatch.explanation.join('|') === 'Matching captured source is unavailable for native debugging.', @@ -1450,11 +1489,14 @@ async function check(page, options, collected) { unselectedMismatch.explanation.join('|') === 'Matching captured source is unavailable for native debugging.', }) - // Arrange: keyboard focus on the selected stop's Inspect (Cycle 0044, 0033-OCR4). + // Arrange: keyboard focus on the selected stop's Inspect (Cycle 0044, 0033-OCR4), in the event list the learner expanded (DS16 collapses it). await publish({ ...nodeReader, nodeHistoryPosition: { ...nodeStop, debuggerAvailable: false }, }) + await page.locator('#node-history-events-details').evaluate((details) => { + details.open = true + }) await nodeInspect.focus() const stopRequestsBefore = await page.evaluate( () => @@ -1690,6 +1732,505 @@ async function check(page, options, collected) { cardOverflow.cardTextLength === 400 && cardOverflow.scrollWidth === cardOverflow.clientWidth, }) + + // Arrange: a test replay of two outputs, one stop and its passing result, with saved learning whose two steps share the stop; every line is worded by the host, as projectNodeTestSection words it (Design 8, red first). + const replayHash = 'b'.repeat(64) + const replayEntries = [ + { + type: 'output', + sequence: 1, + elapsedMs: 100, + channel: 'stdout', + text: 'RUN src/jobs.test.ts\n', + }, + { + type: 'output', + sequence: 2, + elapsedMs: 150, + channel: 'stderr', + text: 'slow timer\n', + }, + { + type: 'stop', + sequence: 3, + index: 0, + elapsedMs: 420, + source: { + path: 'src/jobs.ts', + hash: replayHash, + line: 12, + column: 5, + }, + frame: { name: 'schedule', line: 12, column: 5 }, + facts: [], + debuggerSnapshot: { + frames: [ + { + line: 12, + column: 5, + source: { name: 'src/jobs.ts', hash: replayHash, line: 12 }, + }, + ], + variables: [], + }, + }, + { + type: 'result', + sequence: 4, + elapsedMs: 900, + exitCode: 0, + signal: null, + outputTail: '', + }, + ] + const replayLearning = { + content: { + question: 'How does the scheduler delay a job?', + title: + 'How the job scheduler delays a job until its timer fires and then runs it exactly once', + summary: + 'The test schedules a job and checks that it runs after its timer.', + participants: [ + { id: 'test', label: 'Job test' }, + { id: 'scheduler', label: 'schedule' }, + { id: 'timer', label: 'Timer' }, + ], + steps: [ + { + id: 'read', + from: 'test', + to: 'scheduler', + label: 'Read the delay', + explanation: 'The stop shows the delay of 5,000 ms.', + evidence: 'observed', + reference: { type: 'stop', sequence: 3, sourceHash: replayHash }, + }, + { + id: 'keep', + from: 'scheduler', + to: 'timer', + label: 'Keep the timer', + explanation: 'The scheduler keeps the timer it created.', + evidence: 'source-derived', + reference: { type: 'stop', sequence: 3, sourceHash: replayHash }, + }, + { + id: 'verdict', + from: 'timer', + to: 'test', + label: 'Pass', + explanation: 'The job ran after its timer, so the test passed.', + evidence: 'observed', + reference: { type: 'result', sequence: 4 }, + }, + ], + }, + positions: [ + { stepId: 'read', elapsedMs: 420, sequence: 3 }, + { stepId: 'keep', elapsedMs: 420, sequence: 3 }, + { stepId: 'verdict', elapsedMs: 900, sequence: 4 }, + ], + } + const replaySection = { + status: + 'Test passed · exit code 0 · capture complete · 1 of 1 stops explained', + pointer: 'Output up to this moment is in the Application pane.', + applicationClosed: false, + sameMomentStepIds: [], + stepActions: { + read: 'Inspect stop 1 · 0.42 s', + keep: 'Inspect stop 1 · 0.42 s', + verdict: 'Inspect result · 0.90 s', + }, + events: 'Events (4)', + verdict: 'Test passed · exit code 0', + } + const notRecordedLine = 'Source and debugger not recorded at this moment.' + /** The Study message of the job scheduler's test replay at one selected event. */ + const testReplayAt = (position, section, mode = 'paused') => ({ + ...nodeReader, + nodeHistoryEventCount: 4, + nodeHistory: { + header: { studyId: 'job-study' }, + status: 'complete', + entries: replayEntries, + }, + nodeHistoryLearningSummary: replayLearning, + nodeHistoryPosition: position, + nodeHistorySection: { ...replaySection, ...section }, + nodeHistoryPlayback: { + mode, + sequence: position.sequence, + positionMs: position.elapsedMs, + startMs: 100, + endMs: 900, + loading: false, + }, + }) + const atFirstOutput = testReplayAt( + { sequence: 1, type: 'output', eventIndex: 0, elapsedMs: 100 }, + { + position: 'Output · 0.10 s', + notRecorded: notRecordedLine, + previousStop: false, + nextStop: true, + }, + ), + atStop = testReplayAt( + { + sequence: 3, + type: 'stop', + index: 0, + eventIndex: 2, + elapsedMs: 420, + sourceAvailable: true, + debuggerAvailable: true, + }, + { + position: 'Stop 1 of 1 · 0.42 s', + explanation: 'The stop shows the delay of 5,000 ms.', + selectedStepId: 'read', + sameMomentStepIds: ['keep'], + previousStop: false, + nextStop: false, + }, + ), + atResult = testReplayAt( + { sequence: 4, type: 'result', eventIndex: 3, elapsedMs: 900 }, + { + position: 'Result · Test passed · exit code 0 · 0.90 s', + explanation: 'The job ran after its timer, so the test passed.', + notRecorded: notRecordedLine, + selectedStepId: 'verdict', + previousStop: true, + nextStop: false, + }, + ) + /** Reads the section's lines, the step marked current and the steps marked as the same recorded moment. */ + const replaySectionLines = () => + page.evaluate(() => ({ + position: document.getElementById('node-history-position') + .textContent, + explanation: document.getElementById('node-history-explanation') + .textContent, + notRecorded: document.getElementById('node-history-not-recorded') + .textContent, + current: Array.from( + document.querySelectorAll( + '#node-learning-steps [data-node-step][aria-current="true"]', + ), + (control) => control.dataset.nodeStep, + ), + sameMoment: Array.from( + document.querySelectorAll( + '#node-learning-steps .same-moment:not([hidden])', + ), + (note) => + note.parentElement.querySelector('[data-node-step]').dataset + .nodeStep, + ), + stepControls: document.querySelectorAll( + '#node-learning-steps [data-node-step]', + ).length, + })) + // Act: the replay opens on its first output, then shows the stop and the result. + await page.setViewportSize({ width: 600, height: 800 }) + await publish(atFirstOutput) + const firstOutputLines = await replaySectionLines() + await publish(atStop) + const stopLines = await replaySectionLines() + await publish(atResult) + const resultLines = await replaySectionLines() + // Assert + cases.push({ + name: 'a test replay section shows each moment’s locator, explanation and not-recorded line, marks exactly the selected step current and names the other steps of its moment (Design 8)', + actual: { firstOutputLines, stopLines, resultLines }, + passed: + JSON.stringify({ firstOutputLines, stopLines, resultLines }) === + JSON.stringify({ + firstOutputLines: { + position: 'Output · 0.10 s', + explanation: '', + notRecorded: notRecordedLine, + current: [], + sameMoment: [], + stepControls: 3, + }, + stopLines: { + position: 'Stop 1 of 1 · 0.42 s', + explanation: 'The stop shows the delay of 5,000 ms.', + notRecorded: '', + current: ['read'], + sameMoment: ['keep'], + stepControls: 3, + }, + resultLines: { + position: 'Result · Test passed · exit code 0 · 0.90 s', + explanation: 'The job ran after its timer, so the test passed.', + notRecorded: notRecordedLine, + current: ['verdict'], + sameMoment: [], + stepControls: 3, + }, + }), + }) + // Arrange: the keyboard alone, from the opened replay (DS17): Step details, a step of the stop, Next stop, Previous stop, a seek and Pause. + await publish(atFirstOutput) + await page.evaluate(() => { + window.sent = [] + }) + // Act + 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.keyboard.press('Enter') + await publish(atResult) + await page.locator('#node-history-stop-previous').focus() + await page.keyboard.press('Enter') + await publish(atStop) + await page.locator('#node-history-playhead').focus() + await page.keyboard.press('End') + await publish( + testReplayAt( + atStop.nodeHistoryPosition, + atStop.nodeHistorySection, + 'playing', + ), + ) + await page.locator('#node-history-playback-toggle').focus() + await page.keyboard.press('Enter') + const keyboardRequests = await page.evaluate(() => + window.sent + .filter((message) => message.type.startsWith('node-history')) + .map(({ type, sequence, stepId, direction, unit, action }) => ({ + type, + sequence, + stepId, + direction, + unit, + action, + })), + ) + // Assert + 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)', + actual: keyboardRequests, + passed: + JSON.stringify(keyboardRequests) === + JSON.stringify([ + { 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' }, + { type: 'node-history-playback', action: 'pause' }, + ]), + }) + /** Counts the rendered lines of an element by the height of a one-word copy of it, so a clamp to two lines is measured as the learner sees it. */ + const renderedLines = (shownIn, selector) => + shownIn.evaluate((selector) => { + const shown = document.querySelector(selector), + probe = shown.cloneNode(false) + probe.removeAttribute('id') + probe.textContent = 'X' + shown.after(probe) + const lines = Math.round( + shown.getBoundingClientRect().height / + probe.getBoundingClientRect().height, + ) + probe.remove() + return lines + }, selector) + // Act: the stop's section at three widths (DS19). + await publish(atStop) + const studyWidths = [] + for (const width of [320, 480, 760]) { + await page.setViewportSize({ width, height: 720 }) + studyWidths.push({ + width, + ...(await page.evaluate(() => { + const diagram = document.querySelector( + '#node-learning-sequence .sequence-diagram', + ) + return { + pageScrolls: + document.documentElement.scrollWidth > + document.documentElement.clientWidth, + diagramFitsOrScrollsInside: + diagram.scrollWidth <= diagram.clientWidth || + getComputedStyle(diagram).overflowX === 'auto', + } + })), + titleLines: await renderedLines(page, '#node-learning-title'), + }) + } + await page.setViewportSize({ width: 600, height: 800 }) + await page.locator('#node-learning-steps-details').evaluate((details) => { + details.open = false + }) + // Assert + cases.push({ + name: 'a test replay section at 320, 480 and 760 px has no page-level horizontal scroll, a title of at most two lines and a sequence diagram that scrolls inside itself (Design 8)', + actual: studyWidths, + passed: studyWidths.every( + (reading) => + !reading.pageScrolls && + reading.diagramFitsOrScrollsInside && + reading.titleLines >= 1 && + reading.titleLines <= 2, + ), + }) + // Arrange: the Application pane's document of the same replay at its first output (Design 3), in a page of its own with its saved webview state; the Study page stays as it is for the cases that follow. + const applicationPage = await page.context().newPage() + await applicationPage.setViewportSize({ width: 760, height: 360 }) + await applicationPage.goto(options.url) + await applicationPage.evaluate(() => { + window.sent = [] + window.savedState = undefined + window.acquireVsCodeApi = () => ({ + getState: () => window.savedState, + setState(state) { + window.savedState = state + }, + postMessage: (message) => window.sent.push(message), + }) + }) + await applicationPage.setContent(options.html.application) + const twoFrames = () => + applicationPage.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ) + await twoFrames() + const firstOutputDocument = await applicationPage.evaluate(() => ({ + time: document.getElementById('test-output-time').textContent, + lines: Array.from( + document.querySelectorAll('#test-output .output-text'), + (line) => line.textContent, + ).length, + firstLine: document.querySelector('#test-output .output-text') + ?.textContent, + endHidden: document.getElementById('test-output-end').hidden, + })) + // Act: the learner scrolls up, selects a line and leaves the keyboard in the output; the host then posts the result's view, and a late older one. + await applicationPage.evaluate(() => { + document.getElementById('test-output').scrollTop = 0 + }) + await twoFrames() + await applicationPage.evaluate(() => { + const output = document.getElementById('test-output') + getSelection().selectAllChildren( + output.querySelectorAll('.output-text')[4], + ) + output.focus() + }) + await applicationPage.evaluate((views) => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'node-history-output', + generation: 2, + view: views.result, + }, + }), + ) + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'node-history-output', + generation: 1, + view: views.first, + }, + }), + ) + }, options.testReplayViews) + await twoFrames() + const afterUpdate = await applicationPage.evaluate(() => { + const output = document.getElementById('test-output') + return { + scrollTop: output.scrollTop, + selection: getSelection().toString(), + focused: document.activeElement?.id, + lines: output.querySelectorAll('.output-line').length, + stderrMarks: output.querySelectorAll('.stderr-mark').length, + end: document.getElementById('test-output-end').textContent, + time: document.getElementById('test-output-time').textContent, + jumpShown: !document.getElementById('test-output-jump').hidden, + } + }) + // Act: Jump to latest by keyboard. + await applicationPage.locator('#test-output-jump').focus() + await applicationPage.keyboard.press('Enter') + await twoFrames() + const afterJump = await applicationPage.evaluate(() => { + const output = document.getElementById('test-output') + return { + atEnd: + output.scrollHeight - output.scrollTop - output.clientHeight <= 2, + jumpShown: !document.getElementById('test-output-jump').hidden, + focused: document.activeElement?.id, + follow: window.savedState?.testOutputFollow, + } + }) + // Assert + cases.push({ + name: 'the Application document of a test replay shows only the output up to its moment, and an in-place update to the result keeps the learner’s scroll position, selection and focus, ignores an older view and returns to the end with Jump to latest (Design 8)', + actual: { firstOutputDocument, afterUpdate, afterJump }, + passed: + JSON.stringify({ firstOutputDocument, afterUpdate, afterJump }) === + JSON.stringify({ + firstOutputDocument: { + time: 'Recorded test output · 0.10 s', + lines: 40, + firstLine: 'RUN src/jobs.test.ts', + endHidden: true, + }, + afterUpdate: { + scrollTop: 0, + selection: 'job 4 scheduled', + focused: 'test-output', + lines: 41, + stderrMarks: 1, + end: 'Test passed · exit code 0', + time: 'Recorded test output · 0.90 s', + jumpShown: true, + }, + afterJump: { + atEnd: true, + jumpShown: false, + focused: 'test-output', + follow: true, + }, + }), + }) + // Act: the Application document at three widths (DS19). + const applicationWidths = [] + for (const width of [320, 480, 760]) { + await applicationPage.setViewportSize({ width, height: 360 }) + applicationWidths.push({ + width, + pageScrolls: await applicationPage.evaluate( + () => + document.documentElement.scrollWidth > + document.documentElement.clientWidth, + ), + testLines: await renderedLines(applicationPage, '#test-output-test'), + }) + } + await applicationPage.close() + // Assert + cases.push({ + name: 'the Application document of a test replay at 320, 480 and 760 px has no page-level horizontal scroll and a test name of at most two lines (Design 8)', + actual: applicationWidths, + passed: applicationWidths.every( + (reading) => + !reading.pageScrolls && + reading.testLines >= 1 && + reading.testLines <= 2, + ), + }) await publish({ ...replay, recording: { @@ -1807,6 +2348,15 @@ async function check(page, options, collected) { diagram.links === 2 && diagram.summary.includes('', number: 0 }, + { channel: 'stderr', text: '">x', number: 1 }, + ], + end: 'Test passed · exit code 0', +} + +// Value: protects=test output and test titles that hold markup render as text in the Application pane, in its body, its title and its attributes, so a repository's test cannot run script or add elements there; fails_when=a `` + const content = failedRecording + ? `

${STUDY_UI_OPTIONS.playbackFailureRecovery}

Recording error details

${escapeHtml(message.recording?.reason ?? 'The selected recording could not be read.')}

` + : !message.loading && image?.status === 'captured' + ? `Application pixels captured at the selected ${escapeHtml(message.model.selectedItem.title)} pause` + : `

${escapeHtml(message.loading ? 'Waiting for the matching source and observation.' : image?.status === 'captured' ? 'Image unavailable' : (image?.reason ?? 'Run the selected scenario to observe this item.'))}

` + return `

${escapeHtml(label)}

${content}

Historical observation. Reading it does not resume execution.

` } diff --git a/extension/src/utils/cutTextToTail.ts b/extension/src/utils/cutTextToTail.ts new file mode 100644 index 000000000..a153fe70a --- /dev/null +++ b/extension/src/utils/cutTextToTail.ts @@ -0,0 +1,17 @@ +/** + * Keeps the end of a text, so a reader of recorded output sees what came last before the selected moment; the counterpart of {@link cutTextToFit}, which keeps a text's beginning. Called by {@link accumulateNodeTestOutput} for the first line it keeps when that line does not fit whole. + * The text is cut between characters (code points), never inside a surrogate pair. + * @param text - The text to cut. + * @param keep - How many code points of its end to keep; zero keeps nothing. + * @returns + * - `text` itself when it has at most `keep` code points + * - Otherwise its last `keep` code points + * @example + * cutTextToTail('stdout line', 4) // => 'line' + * cutTextToTail('ok', 10) // => 'ok' + */ +export function cutTextToTail(text: string, keep: number): string { + const characters = Array.from(text) + if (characters.length <= keep) return text + return keep > 0 ? characters.slice(-keep).join('') : '' +} diff --git a/extension/src/utils/describeNodeTestReplay.ts b/extension/src/utils/describeNodeTestReplay.ts index da5e9fb10..226402f73 100644 --- a/extension/src/utils/describeNodeTestReplay.ts +++ b/extension/src/utils/describeNodeTestReplay.ts @@ -1,6 +1,47 @@ import type { NodeTestHistorySummary } from '../nodeTestHistoryStore' -/** What a test replay shows the learner outside its Save, keyed by surface (the String catalog of plan version 7). */ +/** + * The fixed words of a test replay's surfaces, the String catalog of plan version 7 (DS18); the Study pane receives them through {@link STUDY_UI_OPTIONS}, as its client code imports nothing, and the Application pane through {@link capturedAppHtml}. + * Words that hold a value are worded by {@link describeNodeTestReplay}. + */ +export const NODE_TEST_REPLAY_TEXT = { + /** Starts a line the test wrote to standard error; a color never says it alone. */ + stderrMark: '[stderr] ', + loading: 'Loading recorded test output…', + nothingYet: 'No test output recorded up to this moment.', + noEntries: 'No test output recorded.', + endedEarly: 'Recording ended before the test result.', + jumpToLatest: 'Jump to latest', + applicationFooter: + 'Historical test output. Reading it does not rerun the target.', + heading: 'Test replay', + noStepForStop: 'No saved explanation for this stop.', + noStepForResult: 'No saved explanation for the result.', + notRecorded: 'Source and debugger not recorded at this moment.', + unverifiedStop: 'Recorded source unavailable for this stop.', + outputPointer: 'Output up to this moment is in the Application pane.', + applicationClosed: 'The Application pane is closed.', + showRecordedOutput: 'Show recorded output', + noMaterial: 'No learning summary was saved for this test replay.', + sameMoment: 'Same recorded moment', + previousStop: 'Previous stop', + nextStop: 'Next stop', + previousEvent: 'Previous event', + nextEvent: 'Next event', + play: 'Play', + pause: 'Pause', + stepDetails: 'Step details', + noSelection: 'Select an event to move through the recorded test history.', + chooseReplay: + 'Choose a test replay from Saved studies to step through its output and debugger stops.', + noReplay: 'No test replay has been saved in this workspace.', + noLinkedEvent: 'No recorded event is linked to this step.', + eventOutput: 'Output', + eventStop: 'Stop', + eventResult: 'Result', +} as const + +/** What a test replay shows the learner outside its Save, keyed by surface and value (the String catalog of plan version 7). */ export type NodeTestReplayMessage = | { kind: 'saved-row' @@ -13,9 +54,45 @@ export type NodeTestReplayMessage = | 'explainedStopCount' > } - | { kind: 'application-interim' } + | { kind: 'study-status'; summary: NodeTestReplayMessageSummary } + | { kind: 'application-time'; elapsedMs: number } + | { kind: 'application-test'; test: NodeTestReplayTestName } + | { + kind: 'application-name' + test: NodeTestReplayTestName + elapsedMs?: number + } + | { kind: 'application-region'; elapsedMs: number } + | { kind: 'application-cut'; characters: number; startsMidLine: boolean } + | { kind: 'application-inspector'; lines: number } + | { + kind: 'position' + event: + | { type: 'stop'; index: number; stopCount: number } + | { type: 'output' } + | { type: 'result'; verdict: string } + elapsedMs: number + } + | { + kind: 'step-action' + event: { type: 'stop'; index: number } | { type: 'output' | 'result' } + elapsedMs: number + } + | { kind: 'events'; count: number } + +/** The parts of a history's summary its Study status line names. */ +type NodeTestReplayMessageSummary = Pick< + NodeTestHistorySummary, + 'status' | 'verification' | 'verdict' | 'stopCount' | 'explainedStopCount' +> + +/** The cited test: its file and, in a history written since `testTitles`, the title path; a retired history named its test otherwise and shows its file alone. */ +export type NodeTestReplayTestName = { + testFile: string + testTitles?: readonly string[] +} -/** The capture word of a sealed or interrupted history in its Saved studies row. */ +/** The capture word of a sealed or interrupted history. */ const CAPTURE_WORDS = { complete: 'complete', partial: 'partial', @@ -23,19 +100,116 @@ const CAPTURE_WORDS = { } as const /** - * Words what a test replay shows the learner outside its Save, so Saved studies and the Application pane read as the String catalog writes them; called by the Study workspace for each Saved studies row and by {@link capturedAppHtml} for a selected test replay. - * A history this window still writes reads `recording`; one that fails verification reads `unavailable` beside its reason; a sealed or interrupted one names its verdict, its capture and how many of its stops the learning material explains. - * @param message - The surface and what it shows. + * Words what a test replay shows the learner outside its Save, so Saved studies, the Study pane and the Application pane read as the String catalog writes them; called by the Study workspace for each Saved studies row and the Study pane's test replay section, and by {@link capturedAppHtml} for the Application pane. + * `` is the event's time since the run started, paused time included, in seconds with two decimals. + * @param message - The surface, and the value it shows. * @returns The words. * @example * describeNodeTestReplay({ kind: 'saved-row', summary: { status: 'complete', verification: 'verified', verdict: 'Test passed · exit code 0', stopCount: 2, explainedStopCount: 1 } }) * // => 'Test replay · Test passed · exit code 0 · capture complete · 1 of 2 stops explained' + * describeNodeTestReplay({ kind: 'position', event: { type: 'stop', index: 0, stopCount: 2 }, elapsedMs: 420 }) + * // => 'Stop 1 of 2 · 0.42 s' */ export function describeNodeTestReplay(message: NodeTestReplayMessage): string { - if (message.kind === 'application-interim') - return 'Recorded test output for this test replay is in the Study pane.' - const { summary } = message - if (summary.verification === 'unavailable') return 'Test replay · unavailable' - if (summary.status === 'recording') return 'Test replay · recording' - return `Test replay · ${summary.verdict} · capture ${CAPTURE_WORDS[summary.status]} · ${summary.explainedStopCount} of ${summary.stopCount} stops explained` + switch (message.kind) { + case 'saved-row': + return message.summary.verification === 'unavailable' + ? 'Test replay · unavailable' + : message.summary.status === 'recording' + ? 'Test replay · recording' + : `Test replay · ${studyStatus(message.summary)}` + case 'study-status': + return message.summary.verification === 'unavailable' + ? 'Test replay unavailable' + : message.summary.status === 'recording' + ? 'Test replay · recording' + : studyStatus(message.summary) + case 'position': + return positionWords(message.event, message.elapsedMs) + case 'step-action': + return message.event.type === 'stop' + ? `Inspect stop ${message.event.index + 1} · ${seconds(message.elapsedMs)} s` + : `Inspect ${message.event.type} · ${seconds(message.elapsedMs)} s` + case 'events': + return `Events (${message.count})` + default: + return applicationWords(message) + } +} + +/** + * Words the Application pane's lines of a test replay; called by {@link describeNodeTestReplay}. + * @param message - An Application pane surface and its value. + * @returns The words. + * @example applicationWords({ kind: 'application-time', elapsedMs: 420 }) // => 'Recorded test output · 0.42 s' + */ +function applicationWords( + message: Extract, +): string { + switch (message.kind) { + case 'application-time': + return `Recorded test output · ${seconds(message.elapsedMs)} s` + case 'application-test': + return testName(message.test) + case 'application-name': + return message.elapsedMs === undefined + ? `Recorded test output · ${testName(message.test)}` + : `Recorded test output · ${testName(message.test)} · ${seconds(message.elapsedMs)} s` + case 'application-region': + return `Recorded test output up to ${seconds(message.elapsedMs)} s` + case 'application-cut': + return `… ${message.characters} earlier characters not shown${message.startsMidLine ? '; the first line below is cut' : ''}` + case 'application-inspector': + return `${message.lines} Node inspector lines hidden` + } +} + +/** + * Words the Study section's locator of the selected event; called by {@link describeNodeTestReplay}. + * @param event - The selected event: a stop with its number and the replay's stop count, an output, or the result with its verdict. + * @param elapsedMs - The event's time. + * @returns `Stop of · s`, `Output · s` or `Result · · s`. + * @example positionWords({ type: 'stop', index: 0, stopCount: 2 }, 420) // => 'Stop 1 of 2 · 0.42 s' + */ +function positionWords( + event: Extract['event'], + elapsedMs: number, +): string { + if (event.type === 'stop') + return `Stop ${event.index + 1} of ${event.stopCount} · ${seconds(elapsedMs)} s` + return event.type === 'result' + ? `Result · ${event.verdict} · ${seconds(elapsedMs)} s` + : `Output · ${seconds(elapsedMs)} s` +} + +/** + * Words a sealed or interrupted history's verdict, capture and explained stops, the Saved studies row after `Test replay · ` and the Study pane's status line; called by {@link describeNodeTestReplay}. + * @param summary - The history's summary. + * @returns The status words. + * @example studyStatus({ status: 'partial', verification: 'verified', verdict: 'Test failed · exit code 1', stopCount: 1, explainedStopCount: 0 }) // => 'Test failed · exit code 1 · capture partial · 0 of 1 stops explained' + */ +function studyStatus(summary: NodeTestReplayMessageSummary): string { + const capture = + summary.status === 'recording' ? 'partial' : CAPTURE_WORDS[summary.status] + return `${summary.verdict} · capture ${capture} · ${summary.explainedStopCount} of ${summary.stopCount} stops explained` +} + +/** + * Names the cited test as the Application pane's second header line does: the file, then each title of its path after ` › `. + * @param test - The cited test. + * @returns ` › › …`, or the file alone without titles. + * @example testName({ testFile: 'src/jobs.test.ts', testTitles: ['job scheduler', 'runs a delayed job'] }) // => 'src/jobs.test.ts › job scheduler › runs a delayed job' + */ +function testName(test: NodeTestReplayTestName): string { + return [test.testFile, ...(test.testTitles ?? [])].join(' › ') +} + +/** + * Writes an event's time as the String catalog's `<t>`: seconds since the run started, with two decimals. + * @param elapsedMs - The event's elapsed milliseconds. + * @returns The seconds. + * @example seconds(1234) // => '1.23' + */ +function seconds(elapsedMs: number): string { + return (elapsedMs / 1_000).toFixed(2) } diff --git a/extension/src/utils/escapeHtml.ts b/extension/src/utils/escapeHtml.ts index 1bf4b2c35..4faa75e08 100644 --- a/extension/src/utils/escapeHtml.ts +++ b/extension/src/utils/escapeHtml.ts @@ -8,7 +8,7 @@ const HTML_ENTITIES: Readonly<Record<string, string>> = { } /** - * Makes a text safe to print into HTML, the way the Wiki publisher escapes what it prints; called by {@link rebindDeepWikiEditorLinks} when it prints a source path into a served page, by {@link withServedAddresses} when it writes a Codebook address anew, by {@link servedWikiPageHtml} for the reader's script tag, and by {@link capturedAppHtml} for every text of the captured application page. + * Makes a text safe to print into HTML, the way the Wiki publisher escapes what it prints; called by {@link rebindDeepWikiEditorLinks} when it prints a source path into a served page, by {@link withServedAddresses} when it writes a Codebook address anew, by {@link servedWikiPageHtml} for the reader's script tag, and by {@link capturedAppHtml} and {@link testOutputHtml} for every text of the Application pane's page. * @param text - The plain text. * @returns The text with `&`, `<`, `>`, `"` and `'` replaced by their entities; any other character unchanged. * @example diff --git a/extension/src/utils/indexNodeTestOutput.ts b/extension/src/utils/indexNodeTestOutput.ts new file mode 100644 index 000000000..6bb770d7c --- /dev/null +++ b/extension/src/utils/indexNodeTestOutput.ts @@ -0,0 +1,99 @@ +import type { NodeTestHistoryIndex } from '../nodeTestHistorySchemas' + +import { nodeTestOutputLineCost } from './nodeTestOutputLineCost' +import { stripTerminalControls } from './stripTerminalControls' + +/** One line of a test's recorded output, as the Application pane shows it: the channel that wrote it and its text without a line end. */ +export type NodeTestOutputLine = { + channel: 'stdout' | 'stderr' + text: string +} + +/** What one output entry leaves behind once it is read: the complete lines so far, the line it left unfinished and the Node inspector lines so far. */ +export type NodeTestOutputAfterEntry = { + lineCount: number + pending?: NodeTestOutputLine + inspectorLines: number +} + +/** + * A test replay's recorded output read once into lines, so that showing the output up to any event costs a lookup and its tail ({@link accumulateNodeTestOutput}). + * `lineTotals[i]` is how many code points `lines[0..i)` take as shown (the `[stderr] ` mark and a line feed included); it has one more element than `lines`. + */ +export type NodeTestOutputIndex = { + sequences: number[] + lines: NodeTestOutputLine[] + lineTotals: number[] + after: NodeTestOutputAfterEntry[] +} + +/** The Node inspector's own lines on standard error; the pane folds them into one muted line with their count instead of dropping them silently. */ +const INSPECTOR_LINES = [ + /^Debugger listening on \S+$/, + /^For help, see: https:\/\/nodejs\.org\/en\/docs\/inspector$/, + /^Debugger attached\.$/, + /^Waiting for the debugger to disconnect\.\.\.$/, +] + +/** + * Reads a test replay's output entries once into lines, so the Application pane can show the output up to any selected event without reading every entry again (S1-2); called by the Study workspace once per opened test replay. + * A line-state machine joins the entries: a chunk of the same channel continues an unfinished line, a chunk of the other channel ends it first, terminal controls are removed ({@link stripTerminalControls}) and the Node inspector's own standard-error lines are counted instead of shown. + * @param entries - The history's events in sequence order; only output entries are read. + * @returns The index: the output entries' sequences, the complete lines, their running totals and what each output entry leaves behind. + * @example + * indexNodeTestOutput([{ type: 'output', sequence: 1, elapsedMs: 5, channel: 'stdout', text: 'RUN\nok' }]).after[0] + * // => { lineCount: 1, pending: { channel: 'stdout', text: 'ok' }, inspectorLines: 0 } + */ +export function indexNodeTestOutput( + entries: readonly NodeTestHistoryIndex['entries'][number][], +): NodeTestOutputIndex { + const index: NodeTestOutputIndex = { + sequences: [], + lines: [], + lineTotals: [0], + after: [], + } + let pending: NodeTestOutputLine | undefined, + inspectorLines = 0 + const complete = (line: NodeTestOutputLine): void => { + if ( + line.channel === 'stderr' && + INSPECTOR_LINES.some((pattern) => pattern.test(line.text)) + ) { + inspectorLines++ + return + } + index.lines.push(line) + index.lineTotals.push( + index.lineTotals.at(-1)! + nodeTestOutputLineCost(line, true), + ) + } + /** Joins one output chunk to the lines: a chunk of the other channel ends the unfinished line first, so no line mixes the two channels; the first part continues the unfinished line and each later part starts a new one after a line feed. */ + const read = (channel: NodeTestOutputLine['channel'], text: string): void => { + if (pending && pending.channel !== channel) { + complete(pending) + pending = undefined + } + const parts = text.split('\n') + pending = { channel, text: (pending?.text ?? '') + parts[0] } + for (const part of parts.slice(1)) { + complete(pending) + pending = { channel, text: part } + } + // A chunk that ended with a line feed leaves no unfinished line. + if (!pending.text) pending = undefined + } + for (const entry of entries) { + if (entry.type !== 'output') continue + const text = stripTerminalControls(entry.text) + // A chunk that held only controls changes no line, not even the channel of an unfinished one. + if (text) read(entry.channel, text) + index.sequences.push(entry.sequence) + index.after.push({ + lineCount: index.lines.length, + ...(pending ? { pending: { ...pending } } : {}), + inspectorLines, + }) + } + return index +} diff --git a/extension/src/utils/installNodeTestOutput.ts b/extension/src/utils/installNodeTestOutput.ts new file mode 100644 index 000000000..f94625422 --- /dev/null +++ b/extension/src/utils/installNodeTestOutput.ts @@ -0,0 +1,178 @@ +import type { NodeTestApplicationView } from './projectNodeTestApplication' + +/** The fixed words the document's script needs; passed in, as the script imports nothing. */ +export type NodeTestOutputText = { + stderrMark: string +} + +/** The message the Study workspace posts to the Application pane for each later event of the same test replay. */ +export type NodeTestOutputUpdate = { + type: 'node-history-output' + generation: number + view: NodeTestApplicationView +} + +/** + * Applies each later event of a test replay to the Application pane's one document in place (EN3), so moving through the replay never reloads the page: unchanged lines keep their nodes, so the learner's text selection, the scroll position and the keyboard focus survive, and an update older than the one shown is ignored. It follows the latest output until the learner scrolls up, and `Jump to latest` follows again (DS20); called by the document {@link capturedAppHtml} writes, serialized with `toString()`, so it imports nothing. + * The follow state is kept in the webview's state, so a document written again from the restore copy keeps it. + * @param api - The webview's API. + * @param generation - The generation of the view the document was written with. + * @param text - The fixed words it needs. + * @example installNodeTestOutput(acquireVsCodeApi(), 3, { stderrMark: '[stderr] ' }) + */ +export function installNodeTestOutput( + api: { + getState(): unknown + setState(state: unknown): void + }, + generation: number, + text: NodeTestOutputText, +): void { + const element = (id: string): HTMLElement => { + const found = document.getElementById(id) + if (!found) throw new Error(`Test output element ${id} is missing.`) + return found + } + const output = element('test-output'), + jump = element('test-output-jump') + const saved = api.getState() + let shown = generation, + follow = + typeof saved === 'object' && + saved !== null && + 'testOutputFollow' in saved && + typeof saved.testOutputFollow === 'boolean' + ? saved.testOutputFollow + : true, + scrolledByDocument = false + const remember = (): void => { + const state = api.getState() + api.setState({ + ...(typeof state === 'object' && state !== null ? state : {}), + testOutputFollow: follow, + }) + } + /** Shows a muted line with its text, or leaves it out without text. */ + const line = (id: string, value: string | undefined): void => { + const target = element(id) + target.textContent = value ?? '' + target.hidden = value === undefined + } + /** Makes the node of one output line: the `[stderr] ` mark for a standard-error line that is not cut, its text and a line feed, all as text. */ + const lineNode = ( + view: NodeTestApplicationView['lines'][number], + ): HTMLSpanElement => { + const node = document.createElement('span') + node.className = 'output-line' + node.dataset.line = String(view.number) + node.dataset.channel = view.channel + if (view.cut) node.dataset.cut = 'true' + if (view.channel === 'stderr' && !view.cut) { + const mark = document.createElement('span') + mark.className = 'stderr-mark' + mark.textContent = text.stderrMark + node.append(mark) + } + const body = document.createElement('span') + body.className = 'output-text' + body.textContent = view.text + node.append(body, '\n') + return node + } + const atEnd = (): boolean => + output.scrollHeight - output.scrollTop - output.clientHeight <= 2 + const toEnd = (): void => { + scrolledByDocument = true + // Instant, never smooth: the learner's reduced-motion choice needs no separate path. + output.scrollTop = output.scrollHeight + } + /** Removes the lines a view no longer holds. */ + const dropLinesNotIn = (lines: NodeTestApplicationView['lines']): void => { + const wanted = new Set(lines.map((value) => value.number)) + for (const node of Array.from(output.children)) + if ( + !(node instanceof HTMLElement) || + !wanted.has(Number(node.dataset.line)) + ) + node.remove() + } + /** Places one line after the line before it: lines are matched by their number, so an unchanged line keeps its node and a selection or focus inside it survives; a changed line is replaced and a new one inserted. */ + const placeLine = ( + value: NodeTestApplicationView['lines'][number], + previous: Element | undefined, + ): Element => { + const node = output.querySelector<HTMLElement>( + `.output-line[data-line="${value.number}"]`, + ) + const kept = + node?.dataset.channel === value.channel && + (node.dataset.cut === 'true') === Boolean(value.cut) && + node.querySelector('.output-text')?.textContent === value.text + if (kept && node) return node + const placed = lineNode(value) + if (node) node.replaceWith(placed) + else if (previous) previous.after(placed) + else output.prepend(placed) + return placed + } + /** Brings the output region's lines to a view's in place. */ + const placeLines = (lines: NodeTestApplicationView['lines']): void => { + dropLinesNotIn(lines) + let previous: Element | undefined + for (const value of lines) previous = placeLine(value, previous) + } + const apply = (view: NodeTestApplicationView): void => { + document.title = view.name + element('test-output-main').setAttribute('aria-label', view.name) + element('test-output-time').textContent = view.time ?? '' + const test = element('test-output-test') + test.textContent = view.test + test.title = view.test + output.setAttribute('aria-label', view.region ?? view.name) + line('test-output-inspector', view.inspector) + line('test-output-cut', view.cut) + line('test-output-placeholder', view.placeholder) + line('test-output-end', view.end) + placeLines(view.lines) + if (follow) toEnd() + jump.hidden = follow + } + output.addEventListener('scroll', () => { + // The document's own scroll to the end is no choice of the learner's. + if (scrolledByDocument) { + scrolledByDocument = false + return + } + const following = atEnd() + if (following === follow) return + follow = following + jump.hidden = follow + remember() + }) + jump.addEventListener('click', () => { + follow = true + remember() + toEnd() + jump.hidden = true + // The button leaves the page, so the keyboard stays in the output it led to. + output.focus() + }) + window.addEventListener( + 'message', + (event: MessageEvent<Partial<NodeTestOutputUpdate> | null>) => { + const data = event.data + if ( + data?.type !== 'node-history-output' || + typeof data.generation !== 'number' || + !data.view + ) + return + // An update older than the one shown came late: the newer selection stands. + if (data.generation < shown) return + shown = data.generation + apply(data.view) + }, + ) + if (follow) toEnd() + jump.hidden = follow +} diff --git a/extension/src/utils/nodeHistoryStepTarget.ts b/extension/src/utils/nodeHistoryStepTarget.ts new file mode 100644 index 000000000..254aac952 --- /dev/null +++ b/extension/src/utils/nodeHistoryStepTarget.ts @@ -0,0 +1,29 @@ +import type { NodeTestHistoryIndex } from '../nodeTestHistorySchemas' + +/** + * Finds the event a test replay's Previous or Next moves to, by event or by stop (DS12: Previous stop and Next stop are the transport's primary buttons, Previous event and Next event its secondary ones); called by the Study workspace when the Study pane sends `node-history-step`. + * Without a selected event, Next moves to the first candidate and Previous to the last. + * @param entries - The history's events in sequence order. + * @param current - The selected event's sequence, if any. + * @param direction - Which way to move. + * @param unit - `event` moves to any event, `stop` only to stops. + * @returns The sequence of the event to select, or `undefined` when there is none that way. + * @example + * nodeHistoryStepTarget(history.entries, 4, 'next', 'stop') // => 7, the next stop after event 4 + */ +export function nodeHistoryStepTarget( + entries: readonly NodeTestHistoryIndex['entries'][number][], + current: number | undefined, + direction: 'previous' | 'next', + unit: 'event' | 'stop' = 'event', +): number | undefined { + const candidates = + unit === 'stop' ? entries.filter((entry) => entry.type === 'stop') : entries + if (current === undefined) + return (direction === 'next' ? candidates[0] : candidates.at(-1))?.sequence + return ( + direction === 'next' + ? candidates.find((entry) => entry.sequence > current) + : candidates.findLast((entry) => entry.sequence < current) + )?.sequence +} diff --git a/extension/src/utils/nodeTestOutputLineCost.ts b/extension/src/utils/nodeTestOutputLineCost.ts new file mode 100644 index 000000000..7d6f8998c --- /dev/null +++ b/extension/src/utils/nodeTestOutputLineCost.ts @@ -0,0 +1,20 @@ +import { NODE_TEST_REPLAY_TEXT } from './describeNodeTestReplay' +import type { NodeTestOutputLine } from './indexNodeTestOutput' + +/** + * How many code points a line takes as the Application pane shows it; called by {@link indexNodeTestOutput} and {@link accumulateNodeTestOutput}. + * @param line - The line. + * @param lineFeed - Whether a line feed ends it (every line but an unfinished last one). + * @returns Its text's code points, plus the `[stderr] ` mark for a standard-error line and one for the line feed. + * @example nodeTestOutputLineCost({ channel: 'stderr', text: 'warn' }, true) // => 14 + */ +export function nodeTestOutputLineCost( + line: NodeTestOutputLine, + lineFeed: boolean, +): number { + return ( + Array.from(line.text).length + + (line.channel === 'stderr' ? NODE_TEST_REPLAY_TEXT.stderrMark.length : 0) + + (lineFeed ? 1 : 0) + ) +} diff --git a/extension/src/utils/projectNodeLearningSummary.ts b/extension/src/utils/projectNodeLearningSummary.ts new file mode 100644 index 000000000..848e5f484 --- /dev/null +++ b/extension/src/utils/projectNodeLearningSummary.ts @@ -0,0 +1,48 @@ +import type { + NodeTestHistoryIndex, + NodeTestLearningMaterial, +} from '../nodeTestHistorySchemas' + +/** Where a step of a test replay's learning material leads: the event its reference names, by sequence, with that event's time. */ +export type NodeLearningStepPosition = { + stepId: string + elapsedMs: number + sequence: number +} + +/** A test replay's learning material as the Study pane's summary renderer takes it, the browser summary's shape, with each referenced step's event. */ +export type NodeLearningSummaryProjection = { + content: NodeTestLearningMaterial['content'] + positions: NodeLearningStepPosition[] +} + +/** + * Projects a test replay's saved learning material for the one summary renderer the browser replay also uses (Design 5), so each step can select the event it references; called by the Study workspace for each Study message while a test replay with material is selected. + * A position names its event by sequence, so a step finds its own event even when several events share a time. + * @param material - The saved learning material, absent when none was saved. + * @param history - The selected history, all its entries. + * @returns The material's content and, for each step whose reference names an event of the history, that event's sequence and time; `undefined` without material. + * @example + * projectNodeLearningSummary(material, history)?.positions // => [{ stepId: 'stop-1', elapsedMs: 420, sequence: 3 }] + */ +export function projectNodeLearningSummary( + material: NodeTestLearningMaterial | undefined, + history: Pick<NodeTestHistoryIndex, 'entries'>, +): NodeLearningSummaryProjection | undefined { + if (!material) return + const events = new Map( + history.entries.map((entry) => [entry.sequence, entry] as const), + ) + const positions: NodeLearningStepPosition[] = [] + for (const step of material.content.steps) { + const event = step.reference && events.get(step.reference.sequence) + // A step without a reference, or one whose event this history lacks, leads nowhere and gets no position. + if (event) + positions.push({ + stepId: step.id, + elapsedMs: event.elapsedMs, + sequence: event.sequence, + }) + } + return { content: material.content, positions } +} diff --git a/extension/src/utils/projectNodeTestApplication.ts b/extension/src/utils/projectNodeTestApplication.ts new file mode 100644 index 000000000..7f4783919 --- /dev/null +++ b/extension/src/utils/projectNodeTestApplication.ts @@ -0,0 +1,163 @@ +import type { NodeTestHistoryIndex } from '../nodeTestHistorySchemas' + +import { + accumulateNodeTestOutput, + type NodeTestOutputTail, + type NodeTestOutputViewLine, +} from './accumulateNodeTestOutput' +import { + describeNodeTestReplay, + NODE_TEST_REPLAY_TEXT, +} from './describeNodeTestReplay' +import { describeNodeTestResult } from './describeNodeTestResult' +import type { NodeTestOutputIndex } from './indexNodeTestOutput' + +/** + * What the Application pane shows of a test replay at one moment (EN14's narrow view model): sent once with the document and then, for each later event, as an in-place update that a newer `generation` supersedes. + * Every field is text; the document renders each as text, never as markup. + */ +export type NodeTestApplicationView = { + generation: number + /** `loading` while the replay opens, `empty` for a history without entries, `event` at a selected event. */ + state: 'loading' | 'empty' | 'event' + /** Header line 1 (`Recorded test output · <t> s`), absent without a selected event. */ + time?: string + /** Header line 2: the cited test, shown in at most two lines with this whole text in its tooltip. */ + test: string + /** The pane's accessible name. */ + name: string + /** The output region's accessible name, `Recorded test output up to <t> s`. */ + region?: string + /** The kept end of the output up to the event ({@link accumulateNodeTestOutput}). */ + lines: NodeTestOutputViewLine[] + /** One muted line for each part left out: the cut and the Node inspector lines. */ + cut?: string + inspector?: string + /** The line that stands for the output when there is none to show: loading, nothing yet or no entries. */ + placeholder?: string + /** The last line: the verdict at the result, or the ended-early line at a last event that is no result. */ + end?: string +} + +/** The event the learner selected in a test replay. */ +type SelectedEvent = Pick< + NodeTestHistoryIndex['entries'][number], + 'sequence' | 'type' | 'elapsedMs' +> + +/** + * Projects what the Application pane shows of a test replay at the selected moment, from every entry of the history (not the Study pane's page) and its output read once; called by the Study workspace for each Study message while a test replay is selected. + * @param history - The selected history, all its entries. + * @param index - Its output read once ({@link indexNodeTestOutput}). + * @param selected - The selected event, absent before one is selected. + * @param generation - The workspace's count of selections, so a document ignores an update older than the one it shows. + * @param loading - Whether the replay is still opening. + * @returns The view. + * @example + * projectNodeTestApplication(history, indexNodeTestOutput(history.entries), history.entries[0], 1, false).time // => 'Recorded test output · 0.12 s' + */ +export function projectNodeTestApplication( + history: Pick<NodeTestHistoryIndex, 'header' | 'entries'>, + index: NodeTestOutputIndex, + selected: SelectedEvent | undefined, + generation: number, + loading: boolean, +): NodeTestApplicationView { + const configuration = history.header.configuration + const test = { + testFile: configuration.testFile, + ...(configuration.testTitles + ? { testTitles: configuration.testTitles } + : {}), + } + const testLine = describeNodeTestReplay({ kind: 'application-test', test }) + // A history without entries has no time to name; neither has one still opening or one without a selected event. + if (loading || !history.entries.length || !selected) + return { + generation, + state: loading ? 'loading' : history.entries.length ? 'event' : 'empty', + test: testLine, + name: describeNodeTestReplay({ kind: 'application-name', test }), + lines: [], + placeholder: loading + ? NODE_TEST_REPLAY_TEXT.loading + : history.entries.length + ? NODE_TEST_REPLAY_TEXT.nothingYet + : NODE_TEST_REPLAY_TEXT.noEntries, + } + const tail = accumulateNodeTestOutput(index, selected.sequence) + const end = outputEnd(history.entries, selected) + return { + generation, + state: 'event', + time: describeNodeTestReplay({ + kind: 'application-time', + elapsedMs: selected.elapsedMs, + }), + test: testLine, + name: describeNodeTestReplay({ + kind: 'application-name', + test, + elapsedMs: selected.elapsedMs, + }), + region: describeNodeTestReplay({ + kind: 'application-region', + elapsedMs: selected.elapsedMs, + }), + lines: tail.lines, + ...leftOutLines(tail), + ...(tail.hasOutput + ? {} + : { placeholder: NODE_TEST_REPLAY_TEXT.nothingYet }), + ...(end === undefined ? {} : { end }), + } +} + +/** + * Words the muted lines for what the kept output leaves out: the earlier characters cut and the Node inspector lines folded away; called by {@link projectNodeTestApplication}. + * @param tail - The kept output at the selected event. + * @returns `cut` and `inspector`, each only when something was left out. + * @example leftOutLines({ omittedCharacters: 120, startsMidLine: false, inspectorLines: 0, … }) // => { cut: '… 120 earlier characters not shown' } + */ +function leftOutLines( + tail: NodeTestOutputTail, +): Pick<NodeTestApplicationView, 'cut' | 'inspector'> { + return { + ...(tail.omittedCharacters > 0 + ? { + cut: describeNodeTestReplay({ + kind: 'application-cut', + characters: tail.omittedCharacters, + startsMidLine: tail.startsMidLine, + }), + } + : {}), + ...(tail.inspectorLines > 0 + ? { + inspector: describeNodeTestReplay({ + kind: 'application-inspector', + lines: tail.inspectorLines, + }), + } + : {}), + } +} + +/** + * Gives the line that ends the shown output: the verdict at the result, the ended-early line at a last event that is no result (DS2, DS7); called by {@link projectNodeTestApplication}. + * @param entries - The history's events in sequence order. + * @param selected - The selected event. + * @returns The line, or `undefined` at any other event. + * @example outputEnd(history.entries, result) // => 'Test passed · exit code 0' + */ +function outputEnd( + entries: NodeTestHistoryIndex['entries'], + selected: SelectedEvent, +): string | undefined { + const event = entries.find((entry) => entry.sequence === selected.sequence) + if (event?.type === 'result') return describeNodeTestResult(event) + const last = entries.at(-1) + return last?.sequence === selected.sequence && last.type !== 'result' + ? NODE_TEST_REPLAY_TEXT.endedEarly + : undefined +} diff --git a/extension/src/utils/projectNodeTestSection.ts b/extension/src/utils/projectNodeTestSection.ts new file mode 100644 index 000000000..79793d9b4 --- /dev/null +++ b/extension/src/utils/projectNodeTestSection.ts @@ -0,0 +1,251 @@ +import type { NodeTestHistoryIndex } from '../nodeTestHistorySchemas' + +import { + describeNodeTestReplay, + NODE_TEST_REPLAY_TEXT, +} from './describeNodeTestReplay' +import { + describeNodeTestResult, + type NodeTestVerdict, +} from './describeNodeTestResult' +import type { NodeLearningSummaryProjection } from './projectNodeLearningSummary' + +/** + * What the Study pane's test replay section shows beside the selected event (Designs 5 and 6, DS3, DS21): every line already worded, so the pane's client code only places them. + */ +export type NodeTestSectionView = { + status: string + /** The selected event's locator (`Stop <n> of <m> · <t> s`, `Output · <t> s`, `Result · <verdict> · <t> s`), absent before a selection. */ + position?: string + /** The selected step's explanation, or the line that no step explains the selected stop or result; absent for an output without a step. */ + explanation?: string + /** The 4A line at an output, the result and a stop whose source does not verify. */ + notRecorded?: string + /** The line for a selected stop whose recorded source does not verify. */ + unverified?: string + /** Where the output up to the selected moment is, or that its pane is closed. */ + pointer: string + applicationClosed: boolean + /** The step shown with the selected event: the one the learner chose, else the event's first. */ + selectedStepId?: string + /** The selected event's other steps, which read `Same recorded moment`. */ + sameMomentStepIds: string[] + /** Each step's action label by step id, for the steps whose reference names an event of the history. */ + stepActions: Record<string, string> + /** The collapsed event list's summary, `Events (<count>)`. */ + events: string + /** The history's verdict, which the result's row in the event list leads with. */ + verdict: string + /** Whether a stop lies before and after the selected event, for Previous stop and Next stop. */ + previousStop: boolean + nextStop: boolean +} + +/** The inputs of the section's view. */ +export type NodeTestSectionInput = { + history: Pick<NodeTestHistoryIndex, 'entries' | 'status'> + selected?: NodeTestHistoryIndex['entries'][number] + /** Whether the selected event is a stop whose recorded source verifies. */ + selectedStopVerified: boolean + learning?: NodeLearningSummaryProjection + /** The step the learner chose last, kept apart from the selected event (DS21). */ + chosenStepId?: string + applicationClosed: boolean +} + +/** + * Words the Study pane's test replay section for the selected event: status, locator, the selected step and its explanation, the not-recorded lines and the steps that share the moment; called by the Study workspace for each Study message while a test replay is selected. + * The selected step is the one the learner chose when it references the selected event, else the event's first step (Next and Previous select the event's first step). + * The status line counts the stops a step of the material references as explained, as Saved studies does. + * @param input - The history, the selected event, its learning material and the learner's chosen step. + * @returns The section's view. + * @example + * projectNodeTestSection({ history, selected: stop, selectedStopVerified: true, learning, applicationClosed: false }).position // => 'Stop 1 of 2 · 0.42 s' + */ +export function projectNodeTestSection( + input: NodeTestSectionInput, +): NodeTestSectionView { + const { history, selected, learning } = input + const stops = history.entries.filter((entry) => entry.type === 'stop') + const verdict = historyVerdict(history) + const moment = momentSteps(selected, learning, input.chosenStepId) + const explanation = momentExplanation(selected, moment.selectedStep) + const unverifiedStop = + selected?.type === 'stop' && !input.selectedStopVerified + return { + status: sectionStatus(history, stops, verdict, learning), + ...(selected ? { position: positionLine(selected, stops.length) } : {}), + ...(explanation === undefined ? {} : { explanation }), + // The 4A line stands at an output, the result and a stop whose source does not verify. + ...(selected && (selected.type !== 'stop' || unverifiedStop) + ? { notRecorded: NODE_TEST_REPLAY_TEXT.notRecorded } + : {}), + ...(unverifiedStop + ? { unverified: NODE_TEST_REPLAY_TEXT.unverifiedStop } + : {}), + pointer: input.applicationClosed + ? NODE_TEST_REPLAY_TEXT.applicationClosed + : NODE_TEST_REPLAY_TEXT.outputPointer, + applicationClosed: input.applicationClosed, + ...(moment.selectedStepId ? { selectedStepId: moment.selectedStepId } : {}), + sameMomentStepIds: moment.sameMomentStepIds, + stepActions: stepActionLabels(history, learning), + events: describeNodeTestReplay({ + kind: 'events', + count: history.entries.length, + }), + verdict, + previousStop: stops.some( + (stop) => selected !== undefined && stop.sequence < selected.sequence, + ), + nextStop: stops.some( + (stop) => selected === undefined || stop.sequence > selected.sequence, + ), + } +} + +/** An event of a test replay. */ +type HistoryEvent = NodeTestHistoryIndex['entries'][number] + +/** + * Words the verdict of a test replay from its result entry; called by {@link projectNodeTestSection}. + * @param history - The test replay. + * @returns The verdict, `No result recorded` without a result. + * @example historyVerdict(history) // => 'Test passed · exit code 0' + */ +function historyVerdict( + history: Pick<NodeTestHistoryIndex, 'entries'>, +): NodeTestVerdict { + const result = history.entries.find((entry) => entry.type === 'result') + return describeNodeTestResult(result?.type === 'result' ? result : undefined) +} + +/** + * Words the section's status line, counting as explained the stops a step of the material references, as Saved studies does; called by {@link projectNodeTestSection}. + * @param history - The test replay. + * @param stops - Its stops. + * @param verdict - Its verdict. + * @param learning - Its projected material, if any. + * @returns `<verdict> · capture <…> · <k> of <m> stops explained`. + * @example sectionStatus(history, stops, 'Test passed · exit code 0', learning) // => 'Test passed · exit code 0 · capture complete · 1 of 2 stops explained' + */ +function sectionStatus( + history: Pick<NodeTestHistoryIndex, 'status'>, + stops: readonly HistoryEvent[], + verdict: NodeTestVerdict, + learning: NodeLearningSummaryProjection | undefined, +): string { + const referenced = new Set( + (learning?.positions ?? []).map((position) => position.sequence), + ) + return describeNodeTestReplay({ + kind: 'study-status', + summary: { + status: history.status, + verification: 'verified', + verdict, + stopCount: stops.length, + explainedStopCount: stops.filter((stop) => referenced.has(stop.sequence)) + .length, + }, + }) +} + +/** + * Words the selected event's locator; called by {@link projectNodeTestSection}. + * @param selected - The selected event. + * @param stopCount - How many stops the replay holds. + * @returns `Stop <n> of <m> · <t> s`, `Output · <t> s` or `Result · <verdict> · <t> s`. + * @example positionLine(stop, 2) // => 'Stop 1 of 2 · 0.42 s' + */ +function positionLine(selected: HistoryEvent, stopCount: number): string { + return describeNodeTestReplay({ + kind: 'position', + event: + selected.type === 'stop' + ? { type: 'stop', index: selected.index, stopCount } + : selected.type === 'result' + ? { type: 'result', verdict: describeNodeTestResult(selected) } + : { type: 'output' }, + elapsedMs: selected.elapsedMs, + }) +} + +/** + * Finds the steps of the selected event: the one shown, the learner's choice when it references the event, else the event's first, and the others, which read `Same recorded moment` (DS21); called by {@link projectNodeTestSection}. + * @param selected - The selected event, if any. + * @param learning - The projected material, if any. + * @param chosenStepId - The step the learner chose last. + * @returns The shown step's id and content, and the other steps' ids. + * @example momentSteps(stop, learning, 'keep').selectedStepId // => 'keep' + */ +function momentSteps( + selected: HistoryEvent | undefined, + learning: NodeLearningSummaryProjection | undefined, + chosenStepId: string | undefined, +): { + selectedStepId?: string + selectedStep?: NodeLearningSummaryProjection['content']['steps'][number] + sameMomentStepIds: string[] +} { + const stepIds = (learning?.positions ?? []) + .filter((position) => position.sequence === selected?.sequence) + .map((position) => position.stepId) + const selectedStepId = + chosenStepId && stepIds.includes(chosenStepId) ? chosenStepId : stepIds[0] + const selectedStep = learning?.content.steps.find( + (step) => step.id === selectedStepId, + ) + return { + ...(selectedStepId ? { selectedStepId } : {}), + ...(selectedStep ? { selectedStep } : {}), + sameMomentStepIds: stepIds.filter((stepId) => stepId !== selectedStepId), + } +} + +/** + * Gives the explanation shown under the locator: the shown step's, else the line that no step explains a selected stop or the result; an output without a step needs no line. Called by {@link projectNodeTestSection}. + * @param selected - The selected event, if any. + * @param step - The shown step, if any. + * @returns The explanation, or `undefined` for none. + * @example momentExplanation(stop, undefined) // => 'No saved explanation for this stop.' + */ +function momentExplanation( + selected: HistoryEvent | undefined, + step: { explanation: string } | undefined, +): string | undefined { + if (step) return step.explanation + if (selected?.type === 'stop') return NODE_TEST_REPLAY_TEXT.noStepForStop + return selected?.type === 'result' + ? NODE_TEST_REPLAY_TEXT.noStepForResult + : undefined +} + +/** + * Words each step's action by the event its reference names, for the steps whose event the history holds; called by {@link projectNodeTestSection}. + * @param history - The test replay. + * @param learning - Its projected material, if any. + * @returns The action labels by step id. + * @example stepActionLabels(history, learning) // => { read: 'Inspect stop 1 · 0.42 s' } + */ +function stepActionLabels( + history: Pick<NodeTestHistoryIndex, 'entries'>, + learning: NodeLearningSummaryProjection | undefined, +): Record<string, string> { + const labels: Record<string, string> = {} + for (const position of learning?.positions ?? []) { + const event = history.entries.find( + (entry) => entry.sequence === position.sequence, + ) + if (event) + labels[position.stepId] = describeNodeTestReplay({ + kind: 'step-action', + event: + event.type === 'stop' + ? { type: 'stop', index: event.index } + : { type: event.type }, + elapsedMs: event.elapsedMs, + }) + } + return labels +} diff --git a/extension/src/utils/stripTerminalControls.ts b/extension/src/utils/stripTerminalControls.ts new file mode 100644 index 000000000..fd1cae63e --- /dev/null +++ b/extension/src/utils/stripTerminalControls.ts @@ -0,0 +1,21 @@ +/** ANSI escape sequences: CSI (`ESC [`, parameters, intermediates and a final byte), OSC (`ESC ]` up to BEL or `ESC \`, or the text's end) and the other escapes (intermediates such as the `(` of a character-set choice, then one final character, as in `ESC 7` or `ESC M`). */ +const ANSI_ESCAPES = + /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001B]*(?:\u0007|\u001B\\)?|[ -/]*[0-~])/g + +/** C0 controls other than tab and line feed, and the bidirectional controls that can reorder what a reader sees. */ +const UNREADABLE_CONTROLS = + /[\u0000-\u0008\u000B-\u001F\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/g + +/** + * Removes what a terminal would interpret instead of show from a test's recorded output, so the Application pane shows the output's text and nothing in it can recolor, move or reorder what the learner reads; called by {@link indexNodeTestOutput} for each output entry. + * Tabs and line feeds stay; a carriage return goes, so a `\r\n` line end reads as one line feed. + * @param text - One recorded output chunk. + * @returns The chunk without ANSI escape sequences, C0 controls other than tab and line feed, and bidirectional controls. + * @example + * stripTerminalControls('\u001b[32m✓\u001b[39m passes\r\n') // => '✓ passes\n' + * stripTerminalControls('total\t3\n') // => 'total\t3\n' + */ +export function stripTerminalControls(text: string): string { + // Escapes go first: their ESC is itself a C0 control, and removing it alone would leave `[32m` behind. + return text.replace(ANSI_ESCAPES, '').replace(UNREADABLE_CONTROLS, '') +} diff --git a/extension/src/utils/testOutputHtml.ts b/extension/src/utils/testOutputHtml.ts new file mode 100644 index 000000000..5fbd4590e --- /dev/null +++ b/extension/src/utils/testOutputHtml.ts @@ -0,0 +1,30 @@ +import { installWebviewFocus } from '../runtimeSupport/utils/installWebviewFocus' +import { STUDY_STYLES } from '../ui/constants' + +import { NODE_TEST_REPLAY_TEXT } from './describeNodeTestReplay' +import { escapeHtml } from './escapeHtml' +import { installNodeTestOutput } from './installNodeTestOutput' +import type { NodeTestApplicationView } from './projectNodeTestApplication' + +/** + * Writes the Application pane's document for a test replay (Design 3): the two-line header pinned above the output region, the output up to the selected event, the lines for what was left out, the verdict or the ended-early line, and the footer; {@link installNodeTestOutput} then applies each later event in place. Every text from the history is printed escaped, so test output and test titles that hold markup render as text. + * @param view - What the pane shows ({@link projectNodeTestApplication}). + * @param nonce - The document's script and style nonce. + * @returns The document. + * Called by {@link capturedAppHtml} for a selected test replay. + * @example testOutputHtml(view, randomUUID()) + */ +export function testOutputHtml( + view: NodeTestApplicationView, + nonce: string, +): string { + const muted = (id: string, value: string | undefined): string => + `<p id="${id}" class="muted"${value === undefined ? ' hidden' : ''}>${escapeHtml(value ?? '')}</p>` + const lines = view.lines + .map( + (line) => + `<span class="output-line" data-line="${line.number}" data-channel="${line.channel}"${line.cut ? ' data-cut="true"' : ''}>${line.channel === 'stderr' && !line.cut ? `<span class="stderr-mark">${escapeHtml(NODE_TEST_REPLAY_TEXT.stderrMark)}</span>` : ''}<span class="output-text">${escapeHtml(line.text)}</span>\n</span>`, + ) + .join('') + return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'nonce-${nonce}'; script-src 'nonce-${nonce}'"><style nonce="${nonce}">${STUDY_STYLES}</style><title>${escapeHtml(view.name)}

${escapeHtml(view.time ?? '')}

${escapeHtml(view.test)}

${muted('test-output-inspector', view.inspector)}${muted('test-output-cut', view.cut)}
${lines}
${muted('test-output-placeholder', view.placeholder)}

${escapeHtml(view.end ?? '')}

` +} From 94f93d487c9fb617219aca3c405906dbaf019edf Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Thu, 22 Oct 2026 16:15:41 +0900 Subject: [PATCH 02/13] fix: an in-place update of a long recorded test output no longer freezes the Application pane (Cycle 0049, PR-B1) The document looked up each kept line with a query over the whole output, so an update of a replay whose kept tail held 32,768 short lines took 3.5 s in Chrome (local OCR review, critical: the pane hangs at every step). It now lists the kept lines by number once per update (61 ms for the same update). The Reader DOM case of a 32,768-line update fails on the earlier document (over 1 s) and passes on this one. Refs #107 --- extension/scripts/study-bootstrap-qa.mjs | 46 ++++++++++++++++++++ extension/src/utils/installNodeTestOutput.ts | 33 +++++++------- 2 files changed, 63 insertions(+), 16 deletions(-) diff --git a/extension/scripts/study-bootstrap-qa.mjs b/extension/scripts/study-bootstrap-qa.mjs index c6acd146a..1965cb4b4 100644 --- a/extension/scripts/study-bootstrap-qa.mjs +++ b/extension/scripts/study-bootstrap-qa.mjs @@ -2219,6 +2219,52 @@ async function check(page, options, collected) { testLines: await renderedLines(applicationPage, '#test-output-test'), }) } + // Act: the host posts two views of a long recorded output, 32,768 one-character lines, the second without the first line and with one more (EN3: the update matches lines by their number in time linear in the lines). + const longUpdate = await applicationPage.evaluate(() => { + const lines = Array.from({ length: 32768 }, (_, index) => ({ + channel: 'stdout', + text: String(index % 10), + number: index + 1000, + })) + const view = { + state: 'event', + time: 'Recorded test output · 0.90 s', + test: 'src/jobs.test.ts', + name: 'Recorded test output · src/jobs.test.ts · 0.90 s', + region: 'Recorded test output up to 0.90 s', + } + const post = (generation, shown) => + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'node-history-output', + generation, + view: { ...view, generation, lines: shown }, + }, + }), + ) + post(10, lines) + const started = performance.now() + post(11, [ + ...lines.slice(1), + { channel: 'stdout', text: 'last', number: 33768 }, + ]) + return { + underASecond: performance.now() - started < 1000, + lines: document.querySelectorAll('#test-output .output-line').length, + last: document.querySelector('#test-output .output-line:last-child') + ?.textContent, + } + }) + // Assert + cases.push({ + name: 'an in-place update of a recorded test output of 32,768 lines takes less than a second in the Application pane (Design 8, EN3)', + actual: longUpdate, + passed: + longUpdate.underASecond && + longUpdate.lines === 32768 && + longUpdate.last === 'last\n', + }) await applicationPage.close() // Assert cases.push({ diff --git a/extension/src/utils/installNodeTestOutput.ts b/extension/src/utils/installNodeTestOutput.ts index f94625422..5b46e3ff0 100644 --- a/extension/src/utils/installNodeTestOutput.ts +++ b/extension/src/utils/installNodeTestOutput.ts @@ -86,40 +86,41 @@ export function installNodeTestOutput( // Instant, never smooth: the learner's reduced-motion choice needs no separate path. output.scrollTop = output.scrollHeight } - /** Removes the lines a view no longer holds. */ - const dropLinesNotIn = (lines: NodeTestApplicationView['lines']): void => { - const wanted = new Set(lines.map((value) => value.number)) + /** Removes the lines a view no longer holds and lists the kept ones by their number, so each line is found without searching the whole output. */ + const keptLines = ( + lines: NodeTestApplicationView['lines'], + ): Map => { + const wanted = new Set(lines.map((value) => value.number)), + kept = new Map() for (const node of Array.from(output.children)) - if ( - !(node instanceof HTMLElement) || - !wanted.has(Number(node.dataset.line)) - ) - node.remove() + if (node instanceof HTMLElement && wanted.has(Number(node.dataset.line))) + kept.set(Number(node.dataset.line), node) + else node.remove() + return kept } /** Places one line after the line before it: lines are matched by their number, so an unchanged line keeps its node and a selection or focus inside it survives; a changed line is replaced and a new one inserted. */ const placeLine = ( value: NodeTestApplicationView['lines'][number], + node: HTMLElement | undefined, previous: Element | undefined, ): Element => { - const node = output.querySelector( - `.output-line[data-line="${value.number}"]`, - ) - const kept = + const unchanged = node?.dataset.channel === value.channel && (node.dataset.cut === 'true') === Boolean(value.cut) && node.querySelector('.output-text')?.textContent === value.text - if (kept && node) return node + if (unchanged && node) return node const placed = lineNode(value) if (node) node.replaceWith(placed) else if (previous) previous.after(placed) else output.prepend(placed) return placed } - /** Brings the output region's lines to a view's in place. */ + /** Brings the output region's lines to a view's in place, in time linear in the lines. */ const placeLines = (lines: NodeTestApplicationView['lines']): void => { - dropLinesNotIn(lines) + const kept = keptLines(lines) let previous: Element | undefined - for (const value of lines) previous = placeLine(value, previous) + for (const value of lines) + previous = placeLine(value, kept.get(value.number), previous) } const apply = (view: NodeTestApplicationView): void => { document.title = view.name From e97407dcdf12100a2ad50a87d29f56695b415461 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Thu, 22 Oct 2026 16:23:03 +0900 Subject: [PATCH 03/13] test: the pane DOM check's read list names its golden sequence fixture (Cycle 0049, PR-B1) The script test that keeps every file the Reader DOM check reads beside the script did not list the golden markup fixture this pull request reads the same way (new URL relative to the script), so /ship's pre-gate failed on it. The list now names it, and the test carries its value card. The 32,768-line update case states why its time bound is no speed claim. Refs #107 --- extension/scripts/study-bootstrap-qa.mjs | 1 + extension/scripts/tests/studyBootstrapQa.test.mjs | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/extension/scripts/study-bootstrap-qa.mjs b/extension/scripts/study-bootstrap-qa.mjs index 1965cb4b4..d212b85e6 100644 --- a/extension/scripts/study-bootstrap-qa.mjs +++ b/extension/scripts/study-bootstrap-qa.mjs @@ -2250,6 +2250,7 @@ async function check(page, options, collected) { { channel: 'stdout', text: 'last', number: 33768 }, ]) return { + // gstack-timing-ok: the bound guards a hang, not a speed: the earlier per-line lookup took 3.5 s and this update 61 ms on the Mac, so 1 s keeps a wide margin each way. underASecond: performance.now() - started < 1000, lines: document.querySelectorAll('#test-output .output-line').length, last: document.querySelector('#test-output .output-line:last-child') diff --git a/extension/scripts/tests/studyBootstrapQa.test.mjs b/extension/scripts/tests/studyBootstrapQa.test.mjs index 46f741a80..770ceab36 100644 --- a/extension/scripts/tests/studyBootstrapQa.test.mjs +++ b/extension/scripts/tests/studyBootstrapQa.test.mjs @@ -36,7 +36,8 @@ test('the pane DOM check loads its flow without eval', async () => { }) // Local CodeRabbit review of Cycle 0045: the script read the compiled reader and the Wiki shell by a path from the working directory, so it ran from the repository root only. -test('the pane DOM check reads the compiled reader and the Wiki shell beside the script, so it runs from any folder', async () => { +// Value: protects=every file the pane DOM check reads is found beside the script (the compiled reader, the Wiki shell and, since Cycle 0049, the golden markup of the browser replay's sequence), so it runs from any folder; fails_when=a read uses a path from the working directory or the script reads a file this list does not name; why_new=the golden fixture read joins the list (Design 5's golden DOM); seam=none; no_claim=what the check asserts with those files +test('the pane DOM check reads the compiled reader, the Wiki shell and its golden sequence markup beside the script, so it runs from any folder', async () => { // Arrange const script = await readFile(scriptUrl, 'utf8') @@ -49,6 +50,7 @@ test('the pane DOM check reads the compiled reader and the Wiki shell beside the assert.deepEqual(readTargets, [ "new URL('../dist/ui/studyHtml.js', import.meta.url)", "new URL('../skills/codebook/reader-shell.html', import.meta.url)", + "new URL('./fixtures/learning-sequence-golden.txt', import.meta.url)", ]) }) From 139e53e81d221e4ca2cfdea15f620a0a72bb768b Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Thu, 22 Oct 2026 19:56:09 +0900 Subject: [PATCH 04/13] test(test-replay): Step 7 coverage tests of the Application output and the Study section (Cycle 0049 PR-B1) --- extension/scripts/study-bootstrap-qa.mjs | 406 ++++++++++++++++++ .../tests/accumulateNodeTestOutput.test.ts | 28 ++ .../tests/projectNodeTestApplication.test.ts | 42 ++ .../src/tests/projectNodeTestSection.test.ts | 41 ++ .../studyWorkspaceTestReplayOutput.test.ts | 308 ++++++++++++- 5 files changed, 824 insertions(+), 1 deletion(-) diff --git a/extension/scripts/study-bootstrap-qa.mjs b/extension/scripts/study-bootstrap-qa.mjs index d212b85e6..5bc8c9a35 100644 --- a/extension/scripts/study-bootstrap-qa.mjs +++ b/extension/scripts/study-bootstrap-qa.mjs @@ -2026,6 +2026,79 @@ async function check(page, options, collected) { { type: 'node-history-playback', action: 'pause' }, ]), }) + // Arrange: the section's four move buttons with a replay saved and none chosen, then at three moments of the replay (DS12): the stop buttons follow the stops around the selected event, the event buttons its place among the events. + /** Reads which of the section's move buttons the learner can press. */ + const enabledMoves = () => + page.evaluate(() => + Object.fromEntries( + [ + ['previousStop', 'node-history-stop-previous'], + ['nextStop', 'node-history-stop-next'], + ['previousEvent', 'node-history-moment-previous'], + ['nextEvent', 'node-history-moment-next'], + ].map(([move, id]) => [ + move, + !document.getElementById(id).disabled, + ]), + ), + ) + // Act + await publish({ + ...options.message, + revision: revisionAt(5), + nodeHistoryCatalog: [{ studyId: 'job-study' }], + }) + const movesWithNoReplay = await enabledMoves() + await publish(atFirstOutput) + const movesAtTheFirstOutput = await enabledMoves() + await publish(atStop) + const movesAtTheOnlyStop = await enabledMoves() + await publish(atResult) + const movesAtTheResult = await enabledMoves() + // Assert + // Value: protects=the section's four move buttons can be pressed only where the move exists: with no replay chosen none can; at the first output Next stop and Next event can; at the only stop, which has events on both sides, neither stop button can but both event buttons can; at the result Previous stop and Previous event can, and Next stop and Next event cannot; fails_when=a stop button is enabled where no stop lies that way (the learner's press ends in a refusal line), a button the host would honor stays disabled, the stop buttons follow the event position or the event buttons follow the stops, or a button can be pressed with no replay; why_new=PR-B1 adds the stop buttons beside the event buttons and takes their state from the host's flags (DS12), and no case reads a disabled state: the keyboard case presses buttons that are enabled and the focus case reads them for a replay of one event only; seam=none; no_claim=which flags the host sends for each moment (projectNodeTestSection's tests) or what a press sends (the keyboard case) (stand-ins: the Study messages the host posts) + cases.push({ + name: 'a test replay offers Previous stop and Next stop only where a stop lies that way and Previous event and Next event only where an event does (Design 8, DS12)', + actual: { + movesWithNoReplay, + movesAtTheFirstOutput, + movesAtTheOnlyStop, + movesAtTheResult, + }, + passed: + JSON.stringify({ + movesWithNoReplay, + movesAtTheFirstOutput, + movesAtTheOnlyStop, + movesAtTheResult, + }) === + JSON.stringify({ + movesWithNoReplay: { + previousStop: false, + nextStop: false, + previousEvent: false, + nextEvent: false, + }, + movesAtTheFirstOutput: { + previousStop: false, + nextStop: true, + previousEvent: false, + nextEvent: true, + }, + movesAtTheOnlyStop: { + previousStop: false, + nextStop: false, + previousEvent: true, + nextEvent: true, + }, + movesAtTheResult: { + previousStop: true, + nextStop: false, + previousEvent: true, + nextEvent: false, + }, + }), + }) /** Counts the rendered lines of an element by the height of a one-word copy of it, so a clamp to two lines is measured as the learner sees it. */ const renderedLines = (shownIn, selector) => shownIn.evaluate((selector) => { @@ -2080,6 +2153,105 @@ async function check(page, options, collected) { reading.titleLines <= 2, ), }) + // 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. + /** Reads the section's status and locator lines, whether it says that no learning was saved, and what of the replay's diagram and steps is drawn. */ + const sectionReading = () => + page.evaluate(() => { + const noMaterial = document.getElementById('node-history-no-material') + return { + status: document.getElementById('node-history-status').textContent, + position: document.getElementById('node-history-position') + .textContent, + noMaterial: noMaterial.hidden ? 'hidden' : noMaterial.textContent, + learning: document.getElementById('node-history-learning').hidden + ? 'hidden' + : 'shown', + drawn: + document.getElementById('node-learning-sequence').children + .length + + document.getElementById('node-learning-steps').children.length, + } + }) + // Act + await publish({ ...options.message, revision: revisionAt(5) }) + const noReplaySaved = await sectionReading() + await publish({ + ...options.message, + revision: revisionAt(5), + nodeHistoryCatalog: [{ studyId: 'job-study' }], + }) + const noneChosen = await sectionReading() + await publish(atStop) + const withLearning = await sectionReading() + await publish({ + ...atStop, + nodeHistoryPosition: undefined, + nodeHistoryPlayback: undefined, + nodeHistoryLearningSummary: undefined, + nodeHistorySection: { + ...replaySection, + previousStop: false, + nextStop: true, + }, + }) + const withoutSelectionAndLearning = await sectionReading() + await publish(atStop) + const learningAgain = await sectionReading() + // Assert + const withTheStop = { + status: + 'Test passed · exit code 0 · capture complete · 1 of 1 stops explained', + position: 'Stop 1 of 1 · 0.42 s', + noMaterial: 'hidden', + learning: 'shown', + drawn: 2, + } + // Value: protects=the Study pane's test replay section is never blank and never stale: it says that no replay is saved, to choose one, or to select an event, says when no learning summary was saved, and keeps no diagram or steps of the replay before it; fails_when=a state shows another's line or none, the no-learning line is missing or stays after learning returns, or a replay without learning shows the diagram of the replay before; why_new=PR-B1 reworded and moved these states (the String catalog) and no test, new or old, read any of them; seam=none; no_claim=what the host projects for each state (projectNodeTestSection's tests) or the line for a closed Application pane (stand-ins: the pane's page and the Study messages the host posts) + cases.push({ + name: 'a test replay section says that no replay is saved, to choose one and to select an event, says when no learning summary was saved and draws no diagram of the replay before it (Design 8, DS7)', + actual: { + noReplaySaved, + noneChosen, + withLearning, + withoutSelectionAndLearning, + learningAgain, + }, + passed: + JSON.stringify({ + noReplaySaved, + noneChosen, + withLearning, + withoutSelectionAndLearning, + learningAgain, + }) === + JSON.stringify({ + noReplaySaved: { + status: 'No test replay has been saved in this workspace.', + position: '', + noMaterial: 'hidden', + learning: 'hidden', + drawn: 0, + }, + noneChosen: { + status: + 'Choose a test replay from Saved studies to step through its output and debugger stops.', + position: '', + noMaterial: 'hidden', + learning: 'hidden', + drawn: 0, + }, + withLearning: withTheStop, + withoutSelectionAndLearning: { + status: withTheStop.status, + position: + 'Select an event to move through the recorded test history.', + noMaterial: 'No learning summary was saved for this test replay.', + learning: 'hidden', + drawn: 0, + }, + learningAgain: withTheStop, + }), + }) // Arrange: the Application pane's document of the same replay at its first output (Design 3), in a page of its own with its saved webview state; the Study page stays as it is for the cases that follow. const applicationPage = await page.context().newPage() await applicationPage.setViewportSize({ width: 760, height: 360 }) @@ -2278,6 +2450,240 @@ async function check(page, options, collected) { reading.testLines <= 2, ), }) + // Arrange: a second Application document of the same replay, in a page of its own with its saved webview state, for the in-place updates that move to another moment (Design 3, EN3, DS20); the page above is closed, and the cases below keep their own. + const updatePage = await page.context().newPage() + await updatePage.setViewportSize({ width: 760, height: 360 }) + await updatePage.goto(options.url) + await updatePage.evaluate(() => { + window.savedState = undefined + window.acquireVsCodeApi = () => ({ + getState: () => window.savedState, + setState(state) { + window.savedState = state + }, + postMessage: () => {}, + }) + }) + await updatePage.setContent(options.html.application) + /** Posts one view into the document as the host does for a later moment, then waits two frames so the layout has settled. */ + const postOutputView = async (generation, view) => { + await updatePage.evaluate( + ({ generation, view }) => + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'node-history-output', + generation, + view: { ...view, generation }, + }, + }), + ), + { generation, view }, + ) + await updatePage.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ) + } + /** Reads what the document shows after an update: the numbers of its lines, its header and names, its muted lines (`hidden` when one is left out) and whether it stands at the end of the output. */ + const readOutputUpdate = () => + updatePage.evaluate(() => { + const output = document.getElementById('test-output') + const mutedLine = (id) => { + const shown = document.getElementById(id) + return shown.hidden ? 'hidden' : shown.textContent + } + const numbers = Array.from(output.children, (line) => + Number(line.dataset.line), + ) + return { + first: numbers[0], + last: numbers.at(-1), + count: numbers.length, + ascending: numbers.every( + (number, index) => + index === 0 || number === numbers[index - 1] + 1, + ), + time: document.getElementById('test-output-time').textContent, + title: document.title, + region: output.getAttribute('aria-label'), + inspector: mutedLine('test-output-inspector'), + cut: mutedLine('test-output-cut'), + placeholder: mutedLine('test-output-placeholder'), + end: mutedLine('test-output-end'), + atEnd: + output.scrollHeight - output.scrollTop - output.clientHeight <= 2, + } + }) + const atTheResult = options.testReplayViews.result + const withoutEnd = { ...atTheResult } + delete withoutEnd.end + const earlierName = `Recorded test output · ${atTheResult.test} · 0.05 s` + /** The view of an earlier moment: jobs `from` to 25 of the output, with the lines it leaves out named. */ + const earlierWindow = (from) => ({ + ...withoutEnd, + time: 'Recorded test output · 0.05 s', + name: earlierName, + region: 'Recorded test output up to 0.05 s', + lines: Array.from({ length: 26 - from }, (_, index) => ({ + channel: 'stdout', + text: `job ${from + index} scheduled`, + number: from + index, + })), + cut: '… 120 earlier characters not shown', + inspector: '2 Node inspector lines hidden', + }) + // Act: the document follows the output to the result; the host then moves back to an earlier window of the output, widens it at its start and moves to a moment with no output yet. + await postOutputView(2, atTheResult) + const followedToResult = await readOutputUpdate() + await postOutputView(3, earlierWindow(10)) + const windowFromTen = await readOutputUpdate() + await postOutputView(4, earlierWindow(5)) + const windowFromFive = await readOutputUpdate() + await postOutputView(5, { + ...withoutEnd, + time: 'Recorded test output · 0.05 s', + name: earlierName, + region: 'Recorded test output up to 0.05 s', + lines: [], + placeholder: 'No test output recorded up to this moment.', + }) + const noOutputYet = await readOutputUpdate() + // Assert + const mutedWhenShown = { + inspector: '2 Node inspector lines hidden', + cut: '… 120 earlier characters not shown', + placeholder: 'hidden', + end: 'hidden', + atEnd: true, + } + // Value: protects=the Application document's in-place update: while the learner follows the output it ends at the latest line, an update to an earlier moment drops the later lines and the verdict, inserts lines at the start, and shows then hides the lines for what the output leaves out and for no output yet, with the header, the title and the region name following the moment; fails_when=a verdict, a later line or a muted line stays after the move back, the lines come out of order, the document stops following the end, or a name keeps the earlier moment's time; why_new=PR-B1 posts every later moment into one document and no other case posts a view that shortens or restarts the output, shows the cut and inspector lines or hides the verdict; seam=none; no_claim=the host's choice of views (the workspace and projection tests) or VS Code's own webview (stand-ins: the page's message event as the host posts it and a stub of the webview API) + cases.push({ + name: 'an in-place update of the Application document follows the output to the result, then at an earlier moment drops the later lines and the verdict, shows what the output leaves out and hides it again (Design 8, EN3, DS20)', + actual: { + followedToResult, + windowFromTen, + windowFromFive, + noOutputYet, + }, + passed: + JSON.stringify({ + followedToResult, + windowFromTen, + windowFromFive, + noOutputYet, + }) === + JSON.stringify({ + followedToResult: { + first: 0, + last: 40, + count: 41, + ascending: true, + time: 'Recorded test output · 0.90 s', + title: atTheResult.name, + region: 'Recorded test output up to 0.90 s', + inspector: 'hidden', + cut: 'hidden', + placeholder: 'hidden', + end: 'Test passed · exit code 0', + atEnd: true, + }, + windowFromTen: { + first: 10, + last: 25, + count: 16, + ascending: true, + time: 'Recorded test output · 0.05 s', + title: earlierName, + region: 'Recorded test output up to 0.05 s', + ...mutedWhenShown, + }, + windowFromFive: { + first: 5, + last: 25, + count: 21, + ascending: true, + time: 'Recorded test output · 0.05 s', + title: earlierName, + region: 'Recorded test output up to 0.05 s', + ...mutedWhenShown, + }, + noOutputYet: { + count: 0, + ascending: true, + time: 'Recorded test output · 0.05 s', + title: earlierName, + region: 'Recorded test output up to 0.05 s', + inspector: 'hidden', + cut: 'hidden', + placeholder: 'No test output recorded up to this moment.', + end: 'hidden', + atEnd: true, + }, + }), + }) + // Act: a line the test was still printing at one moment is finished at the next, and a later line holds markup as a repository's test may print it. + const markup = '' + const runLine = { + channel: 'stdout', + text: 'RUN src/jobs.test.ts', + number: 0, + } + await postOutputView(6, { + ...withoutEnd, + lines: [runLine, { channel: 'stdout', text: 'job 1 sch', number: 1 }], + }) + await updatePage.evaluate(() => { + window.runNode = document.querySelector('#test-output [data-line="0"]') + window.unfinishedNode = document.querySelector( + '#test-output [data-line="1"]', + ) + }) + await postOutputView(7, { + ...withoutEnd, + lines: [ + runLine, + { channel: 'stdout', text: 'job 1 scheduled', number: 1 }, + { channel: 'stderr', text: markup, number: 2 }, + ], + }) + const finishedLine = await updatePage.evaluate(() => { + const output = document.getElementById('test-output') + return { + unchangedLineKeepsItsNode: + output.querySelector('[data-line="0"]') === window.runNode, + finishedText: output.querySelector('[data-line="1"] .output-text') + .textContent, + unfinishedNodeLeft: !window.unfinishedNode.isConnected, + markupText: output.querySelector('[data-line="2"] .output-text') + .textContent, + markupMark: output.querySelector('[data-line="2"] .stderr-mark') + ?.textContent, + elementsInOutput: output.querySelectorAll('img, script, [onerror]') + .length, + scriptRan: window.invented === true, + } + }) + await updatePage.close() + // Assert + // Value: protects=the in-place update replaces exactly the line whose text changed (a line finished at the next moment), keeps the nodes of the lines that did not change, and shows output that holds markup as text, never as elements; fails_when=a finished line keeps its earlier text, an unchanged line's node is replaced (the learner's selection and focus there are lost), or markup in recorded output becomes an element; why_new=PR-B1's update path builds the line nodes in the browser, which the escaping test of the first document does not cover; seam=none; no_claim=the document's Content Security Policy, which the browser enforces (stand-ins: the page's message event and a stub of the webview API) + cases.push({ + name: 'an in-place update replaces a line whose text changed, keeps the nodes of the other lines and shows output that holds markup as text (Design 8, EN3, S3-1)', + actual: finishedLine, + passed: + JSON.stringify(finishedLine) === + JSON.stringify({ + unchangedLineKeepsItsNode: true, + finishedText: 'job 1 scheduled', + unfinishedNodeLeft: true, + markupText: markup, + markupMark: '[stderr] ', + elementsInOutput: 0, + scriptRan: false, + }), + }) await publish({ ...replay, recording: { diff --git a/extension/src/tests/accumulateNodeTestOutput.test.ts b/extension/src/tests/accumulateNodeTestOutput.test.ts index 7cf4a1402..24ffd2264 100644 --- a/extension/src/tests/accumulateNodeTestOutput.test.ts +++ b/extension/src/tests/accumulateNodeTestOutput.test.ts @@ -162,3 +162,31 @@ test('Node inspector lines are counted, not shown, and controls alone are no out { channel: 'stdout', text: 'RUN src/jobs.test.ts', number: 0 }, ]) }) + +// Value: protects=a chunk of only terminal controls, written on the other stream while a line is still being printed (a reporter's erase or cursor move on standard error in the middle of a standard-output line), changes no line and no channel: the unfinished line stays one line of its own channel and the lines after it keep their numbers; fails_when=the controls-only chunk ends the unfinished line (the line shows in two halves and every later line number moves by one) or is read as an empty line of its own channel; why_new=indexNodeTestOutput skips a chunk that holds only controls so a terminal's cursor moves cannot cut a line (EN10), and the test above puts its controls-only chunk where no line is unfinished, so removing that skip changes nothing it asserts; seam=none; no_claim=how the controls are removed (stripTerminalControls's tests) +test('a chunk of only terminal controls on the other stream leaves an unfinished line whole', () => { + // Arrange + const index = indexNodeTestOutput([ + printed(1, 'stdout', 'job 1 sch'), + printed(2, 'stderr', '\u001b[2K\u001b[?25l'), + printed(3, 'stdout', 'eduled\n'), + printed(4, 'stdout', 'job 2 scheduled\n'), + ]) + + // Act + const whileControlsArrive = accumulateNodeTestOutput(index, 2) + const afterLineEnds = accumulateNodeTestOutput(index, 3) + const afterNextLine = accumulateNodeTestOutput(index, 4) + + // Assert + assert.deepEqual(whileControlsArrive.lines, [ + { channel: 'stdout', text: 'job 1 sch', number: 0 }, + ]) + assert.deepEqual(afterLineEnds.lines, [ + { channel: 'stdout', text: 'job 1 scheduled', number: 0 }, + ]) + assert.deepEqual(afterNextLine.lines, [ + { channel: 'stdout', text: 'job 1 scheduled', number: 0 }, + { channel: 'stdout', text: 'job 2 scheduled', number: 1 }, + ]) +}) diff --git a/extension/src/tests/projectNodeTestApplication.test.ts b/extension/src/tests/projectNodeTestApplication.test.ts index f7a6ce07e..95038ef9c 100644 --- a/extension/src/tests/projectNodeTestApplication.test.ts +++ b/extension/src/tests/projectNodeTestApplication.test.ts @@ -185,3 +185,45 @@ test('the Application view says loading, nothing printed yet and no entries, and placeholder: 'No test output recorded.', }) }) + +// Value: protects=the Application pane tells the learner what it leaves out instead of hiding it: a long output keeps its last 65,536 characters with its first kept line cut, and a muted line counts the earlier characters not shown (4,465 here) while the Node inspector's own lines are counted in another; fails_when=a long output shows no cut line or a wrong count, the cut first line is not named, the kept end is longer or shorter than 65,536 characters, or hidden inspector lines are neither shown nor counted; why_new=PR-B1 keeps only the end of the recorded output (EN10) and folds the inspector's lines, and a view that truncates must disclose it (PRODUCT_GOAL: disclose truncated evidence; the README promises the last 65,536 characters); seam=none; no_claim=the wording for other counts or for a count of one, which describeNodeTestReplay's tests and the carried wording task F105 cover (also: the default limit, not a test limit) +test('the Application view of a long output says how many earlier characters it leaves out and how many inspector lines it hides', () => { + // Arrange + const noisy: NodeTestHistoryEntry[] = [ + { + type: 'output', + sequence: 1, + elapsedMs: 20, + outputSequence: 1, + channel: 'stderr', + text: 'Debugger listening on ws://127.0.0.1:9229/5f0c\nDebugger attached.\n', + }, + output(2, 120, `${'x'.repeat(70_000)}\n`), + stop(3, 420), + result(4, 900, 0), + ] + + // Act + const view = projectNodeTestApplication( + { header, entries: noisy }, + indexNodeTestOutput(noisy), + noisy[2], + 5, + false, + ) + + // Assert + assert.deepEqual(view, { + generation: 5, + state: 'event', + time: 'Recorded test output · 0.42 s', + test: 'src/theme.test.ts › theme › switches to dark', + name: 'Recorded test output · src/theme.test.ts › theme › switches to dark · 0.42 s', + region: 'Recorded test output up to 0.42 s', + lines: [ + { channel: 'stdout', text: 'x'.repeat(65_535), number: 0, cut: true }, + ], + cut: '… 4465 earlier characters not shown; the first line below is cut', + inspector: '2 Node inspector lines hidden', + }) +}) diff --git a/extension/src/tests/projectNodeTestSection.test.ts b/extension/src/tests/projectNodeTestSection.test.ts index 898c3f6d4..1ffca4c0d 100644 --- a/extension/src/tests/projectNodeTestSection.test.ts +++ b/extension/src/tests/projectNodeTestSection.test.ts @@ -307,3 +307,44 @@ test('before a selection the section offers the first stop and says when the App assert.equal(view.previousStop, false) assert.equal(view.nextStop, true) }) + +// Value: protects=Previous stop and Next stop are offered only where a stop lies before or after the selected event, whatever type that event is: both at an output between two stops, only Next stop before the first stop, only Previous stop at the last stop and at the result, and neither in a replay that recorded no stop; fails_when=a side is decided by the selected event's own type or position instead of by the stops around it (an output between two stops loses a side), the first stop offers Previous stop, the last stop or the result offers Next stop, or a replay without stops offers a move the host would refuse; why_new=the section tests above assert Previous stop only as false, and its true cases exist only in the Study messages the Reader DOM check builds by hand (DS12); seam=none; no_claim=how the pane draws or disables the buttons (the Reader DOM check) or where a move lands (nodeHistoryStepTarget's tests) +test('Previous stop and Next stop are offered only where a stop lies before or after the selected event', () => { + // Arrange + const stopFlagsAt = ( + selected: NodeTestHistoryEntry | undefined, + shown: typeof history = history, + ) => { + const { previousStop, nextStop } = projectNodeTestSection({ + history: shown, + selected, + selectedStopVerified: true, + applicationClosed: false, + }) + return { previousStop, nextStop } + } + const withoutStops = { + entries: [entries[0]!, entries[2]!, entries[4]!], + status: 'complete' as const, + } + + // Act + const flags = { + atFirstOutput: stopFlagsAt(entries[0]), + atFirstStop: stopFlagsAt(entries[1]), + betweenStops: stopFlagsAt(entries[2]), + atLastStop: stopFlagsAt(entries[3]), + atResult: stopFlagsAt(entries[4]), + withoutStops: stopFlagsAt(entries[4], withoutStops), + } + + // Assert + assert.deepEqual(flags, { + atFirstOutput: { previousStop: false, nextStop: true }, + atFirstStop: { previousStop: false, nextStop: true }, + betweenStops: { previousStop: true, nextStop: true }, + atLastStop: { previousStop: true, nextStop: false }, + atResult: { previousStop: true, nextStop: false }, + withoutStops: { previousStop: false, nextStop: false }, + }) +}) diff --git a/extension/src/tests/studyWorkspaceTestReplayOutput.test.ts b/extension/src/tests/studyWorkspaceTestReplayOutput.test.ts index 15425250e..a62aa543d 100644 --- a/extension/src/tests/studyWorkspaceTestReplayOutput.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayOutput.test.ts @@ -98,7 +98,7 @@ async function saveTwoStopNodeHistory(store: NodeTestHistoryStore) { return history } -// Value: protects=the Application pane of a test replay is one document per history: opening writes it once, each later moment reaches it as a posted view with a newer generation and that moment's time and output, and the result's view ends with the verdict; fails_when=a moment of the same history writes the document again (the learner's scroll, selection and focus there are lost), a moment posts no view, or a later view carries an older generation; why_new=EN3 replaces a document written per event; seam=the harness records the Application panel's document and posted views; no_claim=the document's own application of a view, which the Reader DOM check covers +// Value: protects=the Application pane of a test replay is one document per history: opening writes it once, each later moment reaches it as a posted view with a newer generation and that moment's time and output, and the result's view ends with the verdict; fails_when=a moment of the same history writes the document again (the learner's scroll, selection and focus there are lost), a moment posts no view, or a later view carries an older generation; why_new=EN3 replaces a document written per event; seam=none; no_claim=the document's own application of a view, which the Reader DOM check covers (stand-ins: the harness records the Application panel's document and posted views) test('a test replay’s Application document is written once and each later moment is posted into it', async () => { // Arrange const harness = await workspaceHarness() @@ -295,3 +295,309 @@ test('stop moves skip outputs and a summary step’s action selects its event an await harness.close() } }) + +// Value: protects=the Study pane's test replay section tells the truth about the Application pane: once the learner closes the pane by hand the section says it is closed, and moving to another event still works with nothing posted into the closed pane; fails_when=the section keeps saying the output is in the Application pane after the close, the close publishes no new section, or a move with the pane closed reports an error or posts a view into the gone pane; why_new=PR-B1 wires DS7's closed-pane line to the workspace's own pane (projectNodeTestSection's test passes the flag by hand), and presentNodeHistoryApplication must not touch a pane that is gone; seam=none; no_claim=Show recorded output's restoring of the pane, which needs the native layout and which neither this test nor the Reader DOM check drives (stand-ins: the workspace harness's closeTabByHand (it closes the Application tab and tells the tab listeners), its posted messages and its recorded Application updates) +test('closing the Application pane makes the test replay section say so and moving on posts nothing into it', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + await harness.reader.openLearningReplay(history.studyId) + await waitUntil( + () => + lastView(harness)?.nodeHistoryPosition?.sequence === 2 && + !lastView(harness)?.loading, + 'the test replay lands on its first stop', + 2_000, + ) + const application = harness + .nativePanels() + .find((panel) => panel.kind === 'codebook.scopedBrowser') + const pointerWhileOpen = lastView(harness)?.nodeHistorySection?.pointer + const updatesBeforeClose = harness.applicationOutputs().length + + // Act + harness.closeTabByHand(application!.nativeTab) + await waitUntil( + () => lastView(harness)?.nodeHistorySection?.applicationClosed === true, + 'the section says the Application pane is closed', + 2_000, + ) + const whenClosed = lastView(harness)?.nodeHistorySection + harness.receiveContext({ + type: 'node-history-step', + revision: lastView(harness)!.revision, + studyId: history.studyId, + direction: 'next', + unit: 'stop', + }) + await waitUntil( + () => lastView(harness)?.nodeHistoryPosition?.sequence === 4, + 'Next stop reaches the second stop', + 2_000, + ) + + // Assert + assert.equal( + pointerWhileOpen, + 'Output up to this moment is in the Application pane.', + ) + assert.equal(whenClosed?.pointer, 'The Application pane is closed.') + assert.equal( + lastView(harness)?.nodeHistorySection?.position, + 'Stop 2 of 2 · 0.30 s', + ) + assert.equal(lastView(harness)?.errors.length, 0) + assert.equal(harness.applicationOutputs().length, updatesBeforeClose) + } finally { + await harness.close() + } +}) + +/** + * Saves learning material that explains a two-stop test replay's first stop (sequence 2) with two steps, so the stop shows `read` first and `keep` once the learner chooses it; the replay's step ids repeat from one replay to the next, as a chat's explanations of two runs of one test do. + * @param harness - The workspace harness whose service saves the material. + * @param history - The replay's header, as {@link saveTwoStopNodeHistory} returns it. + * @example await explainFirstStop(harness, history) + */ +async function explainFirstStop( + harness: Awaited>, + history: Awaited>, +): Promise { + const sourceHash = history.configuration.source.hash + await harness.service.explainNodeHistory({ + studyId: history.studyId, + expectedRevision: 0, + content: { + question: 'Where does the test switch the theme?', + title: 'Theme switch', + summary: 'The test reads the next theme twice and passes.', + participants: [ + { id: 'test', label: 'Theme test' }, + { id: 'theme', label: 'applyTheme' }, + ], + steps: [ + { + id: 'read', + from: 'test', + to: 'theme', + label: 'Read the next theme', + explanation: 'The first stop shows nextTheme is dark.', + evidence: 'observed', + reference: { type: 'stop', sequence: 2, sourceHash }, + }, + { + id: 'keep', + from: 'theme', + to: 'theme', + label: 'Keep the theme', + explanation: 'applyTheme keeps the theme it read.', + evidence: 'source-derived', + reference: { type: 'stop', sequence: 2, sourceHash }, + }, + ], + }, + }) +} + +// Value: protects=a move to a stop or an event that does not exist (a press that raced ahead of the disabled button, or a keyboard repeat) is refused by name and changes nothing: Previous stop at the first stop and Next event at the result each leave one error line that names the move, and the selected moment, the step the learner chose and the Application pane stay as they were; fails_when=a refused move resets the chosen step (the explanation changes with no new moment), moves the selection, posts a view into the Application pane, says nothing, or names the wrong unit; why_new=PR-B1 adds the stop unit (DS12) and keeps the chosen step apart from the selected event (DS21), so the handler must refuse before it clears the choice; no test pressed a move past either end; seam=none; no_claim=the buttons' disabled state, which the Reader DOM check reads and which keeps a learner from pressing a move that does not exist +test('a move to a stop or event that does not exist is refused by name and keeps the moment, the chosen step and the Application pane', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + await harness.reader.openLearningReplay(history.studyId) + await waitUntil( + () => + lastView(harness)?.nodeHistoryPosition?.sequence === 2 && + !lastView(harness)?.loading, + 'the test replay lands on its first stop', + 2_000, + ) + await explainFirstStop(harness, history) + await waitUntil( + () => lastView(harness)?.nodeHistorySection?.selectedStepId === 'read', + 'the saved material shows the stop’s first step', + 2_000, + ) + harness.receiveContext({ + type: 'node-history-event', + revision: lastView(harness)!.revision, + studyId: history.studyId, + sequence: 2, + stepId: 'keep', + }) + await waitUntil( + () => lastView(harness)?.nodeHistorySection?.selectedStepId === 'keep', + 'the chosen step is shown', + 2_000, + ) + const updatesAtFirstStop = harness.applicationOutputs().length + + // Act + harness.receiveContext({ + type: 'node-history-step', + revision: lastView(harness)!.revision, + studyId: history.studyId, + direction: 'previous', + unit: 'stop', + }) + await waitUntil( + () => (lastView(harness)?.errors.length ?? 0) > 0, + 'the move before the first stop is refused', + 2_000, + ) + const afterStopRefusal = lastView(harness) + const updatesAfterStopRefusal = harness.applicationOutputs().length + harness.receiveContext({ + type: 'node-history-event', + revision: lastView(harness)!.revision, + studyId: history.studyId, + sequence: 5, + }) + await waitUntil( + () => lastView(harness)?.nodeHistoryPosition?.sequence === 5, + 'the result is selected', + 2_000, + ) + const updatesAtResult = harness.applicationOutputs().length + harness.receiveContext({ + type: 'node-history-step', + revision: lastView(harness)!.revision, + studyId: history.studyId, + direction: 'next', + unit: 'event', + }) + await waitUntil( + () => + lastView(harness)?.errors[0] === + 'Error: There is no next event in this Node history.', + 'the move past the result is refused', + 2_000, + ) + const afterEventRefusal = lastView(harness) + + // Assert + // The reader builds its error lines in its own realm, so they are read as a count and strings. + assert.equal(afterStopRefusal?.errors.length, 1) + assert.equal( + afterStopRefusal?.errors[0], + 'Error: There is no previous stop in this Node history.', + ) + assert.equal(afterStopRefusal?.nodeHistoryPosition?.sequence, 2) + assert.equal( + afterStopRefusal?.nodeHistorySection?.position, + 'Stop 1 of 2 · 0.20 s', + ) + assert.equal(afterStopRefusal?.nodeHistorySection?.selectedStepId, 'keep') + assert.equal( + afterStopRefusal?.nodeHistorySection?.explanation, + 'applyTheme keeps the theme it read.', + ) + assert.equal(updatesAfterStopRefusal, updatesAtFirstStop) + assert.equal(afterEventRefusal?.errors.length, 1) + assert.equal( + afterEventRefusal?.errors[0], + 'Error: There is no next event in this Node history.', + ) + assert.equal(afterEventRefusal?.nodeHistoryPosition?.sequence, 5) + assert.equal( + afterEventRefusal?.nodeHistorySection?.position, + 'Result · Test passed · exit code 0 · 0.40 s', + ) + assert.equal(harness.applicationOutputs().length, updatesAtResult) + } finally { + await harness.close() + } +}) + +// Value: protects=opening another test replay never shows the learner a stale or false pane: while it opens, the Application pane says the output is loading instead of keeping the replay before or claiming that no output was recorded, and the opened replay starts on its own first stop with that stop's first step, not the step the learner chose in the replay before even though both replays use the same step ids; fails_when=the pane keeps the earlier replay's output or says no output was recorded while the replay opens, the opened replay shows the step chosen in the replay before (its explanation and its same-moment marks swap), or the pane does not end on the new replay's first moment; why_new=PR-B1 keeps the learner's chosen step in the workspace (DS21) and takes the pane's loading state from the workspace's opening flag (DS7), which the projection's own test passes by hand; seam=none; no_claim=the loading line's drawing in the document (the Reader DOM check) or a replay opened while a browser replay is shown, which the host refuses +test('opening another test replay shows its loading line first and starts on its own first step, not the step chosen before', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const store = harness.service.nodeHistoryStore! + const first = await saveTwoStopNodeHistory(store) + const second = await saveTwoStopNodeHistory(store) + await explainFirstStop(harness, first) + await explainFirstStop(harness, second) + await harness.reader.action('retry') + await harness.reader.openLearningReplay(first.studyId) + await waitUntil( + () => + lastView(harness)?.nodeHistory?.header.studyId === first.studyId && + lastView(harness)?.nodeHistoryPosition?.sequence === 2 && + lastView(harness)?.nodeHistorySection?.selectedStepId === 'read' && + !lastView(harness)?.loading, + 'the first test replay lands on its first stop and its first step', + 2_000, + ) + harness.receiveContext({ + type: 'node-history-event', + revision: lastView(harness)!.revision, + studyId: first.studyId, + sequence: 2, + stepId: 'keep', + }) + await waitUntil( + () => lastView(harness)?.nodeHistorySection?.selectedStepId === 'keep', + 'the step chosen in the first replay is shown', + 2_000, + ) + const updatesBeforeOpening = harness.applicationOutputs().length + + // Act + await harness.reader.openLearningReplay(second.studyId) + await waitUntil( + () => + lastView(harness)?.nodeHistory?.header.studyId === second.studyId && + lastView(harness)?.nodeHistoryPosition?.sequence === 2 && + !lastView(harness)?.loading, + 'the second test replay lands on its first stop', + 2_000, + ) + const whileOpening = harness + .applicationOutputs() + .slice(updatesBeforeOpening) + const application = harness + .nativePanels() + .find((panel) => panel.kind === 'codebook.scopedBrowser') + + // Assert + assert.equal(whileOpening[0]?.view.state, 'loading') + assert.equal( + whileOpening[0]?.view.placeholder, + 'Loading recorded test output…', + ) + assert.deepEqual(whileOpening[0]?.view.lines, []) + assert.match( + application?.webview.html ?? '', + /

Loading recorded test output…<\/p>/, + ) + assert.equal(whileOpening.at(-1)?.view.state, 'event') + assert.equal( + whileOpening.at(-1)?.view.time, + 'Recorded test output · 0.20 s', + ) + assert.deepEqual( + whileOpening.at(-1)?.view.lines.map((line) => line.text), + ['RUN src/theme.test.ts'], + ) + assert.equal(lastView(harness)?.nodeHistorySection?.selectedStepId, 'read') + assert.equal( + lastView(harness)?.nodeHistorySection?.explanation, + 'The first stop shows nextTheme is dark.', + ) + assert.deepEqual(lastView(harness)?.nodeHistorySection?.sameMomentStepIds, [ + 'keep', + ]) + } finally { + await harness.close() + } +}) From 3029b7d875a169c775736997d5967c25844f0253 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Thu, 22 Oct 2026 19:56:17 +0900 Subject: [PATCH 05/13] fix(test-replay): Step 9 review fixes for the Application pane and the Study section (Cycle 0049 PR-B1) - Show recorded output and Focus application open a closed Application pane again at the selected moment (was Restore, which failed with "The three retained Study panes are unavailable."; QA critical). - A learning step id `__proto__` keeps its action label (own data properties; the client reads own labels only). - The chosen step commits with the selection: a refused or failed selection keeps the learner's step, and a seek, Play or Inspect onto another event shows that event's first step. - The Application document keeps its follow choice and its latest view with the replay they belong to: another replay starts following, and a document the editor loads again shows the latest moment. - The retired replay's output index is released; the restore copy is written only when a replacement panel needs it; step labels use one Map. - Dead `state` field, unreachable status branch and stale docs removed; the end tolerance is a named constant. --- extension/README.md | 2 +- extension/scripts/study-bootstrap-qa.mjs | 98 ++++++- extension/src/constants.ts | 2 + .../src/runtimeSupport/browserViewport.ts | 18 +- extension/src/studyWorkspace.ts | 41 ++- .../tests/accumulateNodeTestOutput.test.ts | 41 +++ .../src/tests/describeNodeTestReplay.test.ts | 38 +++ .../src/tests/nextNodeHistoryStepId.test.ts | 25 ++ .../tests/projectNodeTestApplication.test.ts | 4 - .../src/tests/projectNodeTestSection.test.ts | 30 ++ .../src/tests/runtime-browserViewport.test.ts | 17 +- .../studyWorkspaceTestReplayOutput.test.ts | 259 +++++++++++++++++- extension/src/tests/testOutputHtml.test.ts | 31 ++- extension/src/ui/studyHtml.ts | 8 +- extension/src/utils/capturedAppHtml.ts | 6 +- extension/src/utils/describeNodeTestReplay.ts | 12 +- extension/src/utils/installNodeTestOutput.ts | 65 ++++- extension/src/utils/nextNodeHistoryStepId.ts | 20 ++ .../src/utils/projectNodeTestApplication.ts | 4 - extension/src/utils/projectNodeTestSection.ts | 22 +- extension/src/utils/testOutputHtml.ts | 13 +- 21 files changed, 690 insertions(+), 66 deletions(-) create mode 100644 extension/src/tests/nextNodeHistoryStepId.test.ts create mode 100644 extension/src/utils/nextNodeHistoryStepId.ts diff --git a/extension/README.md b/extension/README.md index fff47e8eb..e60b72d49 100644 --- a/extension/README.md +++ b/extension/README.md @@ -74,7 +74,7 @@ A Vitest test run that the chat explained (the `node-test` profile) is saved the The **Application pane** shows a test replay's recorded test output up to the selected moment and nothing printed after it. Its header reads `Recorded test output · s` over the test's file and title path (`` counts from the run's start, paused time included). Lines the test wrote to standard error start with `[stderr] `; terminal colors, cursor moves and other control characters are removed; the Node inspector's own lines fold into one muted line such as `2 Node inspector lines hidden`; and the pane keeps the last 65,536 characters, with a line such as `… 1200 earlier characters not shown` before them. At the result the output ends with the verdict (`Test passed · exit code 0`, `Test failed · exit code ` or `Ended by signal `); a replay that ended before its result says `Recording ended before the test result.` Moving through the replay updates the same page in place, so your scroll position, text selection and keyboard focus stay; the pane follows the latest output until you scroll up, and **Jump to latest** follows it again. -The Study pane's **Test replay** section names the result, the capture and the explained stops, then the selected moment (`Stop of · s`, `Output · s` or `Result · · s`) with the explanation the chat saved for it, or `No saved explanation for this stop.`; at an output, at the result and at a stop whose recorded source does not verify it adds `Source and debugger not recorded at this moment.` **Previous stop** and **Next stop** move between stops, **Previous event** and **Next event** through every event, and **Play** and the time slider move through the recording. The learning summary is drawn as a browser replay's: a sequence diagram and its numbered steps under **Step details**, where the selected step is marked and the other steps of the same moment read `Same recorded moment`; a step's link selects its moment and that step. The events are listed under the collapsed **Events ()**. When the Application pane is closed, the section says `The Application pane is closed.`, and **Show recorded output** restores the Study arrangement with it. +The Study pane's **Test replay** section names the result, the capture and the explained stops, then the selected moment (`Stop of · s`, `Output · s` or `Result · · s`) with the explanation the chat saved for it, or `No saved explanation for this stop.`; at an output, at the result and at a stop whose recorded source does not verify it adds `Source and debugger not recorded at this moment.` **Previous stop** and **Next stop** move between stops, **Previous event** and **Next event** through every event, and **Play** and the time slider move through the recording. The learning summary is drawn as a browser replay's: a sequence diagram and its numbered steps under **Step details**, where the selected step is marked and the other steps of the same moment read `Same recorded moment`; a step's link selects its moment and that step. The events are listed under the collapsed **Events ()**. When the Application pane is closed, the section says `The Application pane is closed.`, and **Show recorded output** opens the pane again at the selected moment. Unexpected failures on these paths write one entry each to **Output › Codebook**, with the surface, the study, the profile, the versions, the cause and the stack. The causes they show name the extension's storage, the workspace and the home folder as ``, `` and `~`; some other messages can still show full local paths. diff --git a/extension/scripts/study-bootstrap-qa.mjs b/extension/scripts/study-bootstrap-qa.mjs index 5bc8c9a35..bd478bf89 100644 --- a/extension/scripts/study-bootstrap-qa.mjs +++ b/extension/scripts/study-bootstrap-qa.mjs @@ -2346,6 +2346,10 @@ async function check(page, options, collected) { follow: window.savedState?.testOutputFollow, } }) + // The webview state as the document left it at the result, for the reload case below. + const stateAtResult = await applicationPage.evaluate( + () => window.savedState, + ) // Assert cases.push({ name: 'the Application document of a test replay shows only the output up to its moment, and an in-place update to the result keeps the learner’s scroll position, selection and focus, ignores an older view and returns to the end with Jump to latest (Design 8)', @@ -2399,7 +2403,6 @@ async function check(page, options, collected) { number: index + 1000, })) const view = { - state: 'event', time: 'Recorded test output · 0.90 s', test: 'src/jobs.test.ts', name: 'Recorded test output · src/jobs.test.ts · 0.90 s', @@ -2438,6 +2441,97 @@ async function check(page, options, collected) { longUpdate.lines === 32768 && longUpdate.last === 'last\n', }) + // Value: protects=the follow choice belongs to its own test replay (DS20): a document of another replay written into the same Application pane starts at the end of its output, although the learner stopped following in the first one, while a document of the same replay, as its restore copy writes it, keeps the learner's choice; fails_when=the webview state keeps one follow choice for every replay, so the next replay opens at the top of its output with Jump to latest shown; why_new=Step 9's testing and maintainability reviews found the choice kept under one key that outlives a replay; seam=none; no_claim=VS Code's keeping of webview state across documents, which the page's saved-state stand-in plays + // Act: the pane's document written again, once for another replay and once for this one, each with a saved choice not to follow; each write gets a page of its own, as a webview loads a new document in a fresh page. + const sameReplay = await applicationPage.evaluate( + () => window.savedState?.testOutputHistory, + ) + const followAfterWrite = async (savedState) => { + const writtenPage = await page.context().newPage() + await writtenPage.setViewportSize({ width: 760, height: 360 }) + await writtenPage.goto(options.url) + await writtenPage.evaluate((state) => { + window.savedState = state + window.acquireVsCodeApi = () => ({ + getState: () => window.savedState, + setState(next) { + window.savedState = next + }, + postMessage() {}, + }) + }, savedState) + await writtenPage.setContent(options.html.application) + await writtenPage.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ) + const reading = await writtenPage.evaluate(() => { + const output = document.getElementById('test-output') + return { + atEnd: + output.scrollHeight - output.scrollTop - output.clientHeight <= 2, + jumpShown: !document.getElementById('test-output-jump').hidden, + } + }) + await writtenPage.close() + return reading + } + // Value: protects=an Application document that the editor loads again from its first write (the pane moved to another window, webviews reloaded) shows the latest moment it had received, with that moment's time and verdict, instead of the moment it was first written with; fails_when=the reloaded document shows its first moment or its loading line while the Study pane, the source and the debugger show the latest one; why_new=Step 9's state-machines review found the pane's html written once per replay and each later moment only posted; seam=none; no_claim=VS Code's own reload, which the fresh page with the kept state stands in for + // Act: the first write loaded again in a fresh page with the state the document kept at the result. + const reloadedPage = await page.context().newPage() + await reloadedPage.setViewportSize({ width: 760, height: 360 }) + await reloadedPage.goto(options.url) + await reloadedPage.evaluate((state) => { + window.savedState = state + window.acquireVsCodeApi = () => ({ + getState: () => window.savedState, + setState(next) { + window.savedState = next + }, + postMessage() {}, + }) + }, stateAtResult) + await reloadedPage.setContent(options.html.application) + const reloaded = await reloadedPage.evaluate(() => ({ + time: document.getElementById('test-output-time').textContent, + end: document.getElementById('test-output-end').textContent, + endShown: !document.getElementById('test-output-end').hidden, + })) + await reloadedPage.close() + // Assert + cases.push({ + name: 'an Application document loaded again from its first write shows the latest moment it had received (EN3)', + actual: reloaded, + passed: + JSON.stringify(reloaded) === + JSON.stringify({ + time: 'Recorded test output · 0.90 s', + end: 'Test passed · exit code 0', + endShown: true, + }), + }) + const anotherReplay = await followAfterWrite({ + testOutputFollow: false, + testOutputHistory: 'another-test-replay', + }) + const sameReplayKept = await followAfterWrite({ + testOutputFollow: false, + testOutputHistory: sameReplay, + }) + // Assert + cases.push({ + name: 'a test replay’s Application document follows its own output although the learner stopped following in another replay, and the same replay’s document keeps the learner’s choice (DS20)', + actual: { sameReplay, anotherReplay, sameReplayKept }, + passed: + typeof sameReplay === 'string' && + JSON.stringify({ anotherReplay, sameReplayKept }) === + JSON.stringify({ + anotherReplay: { atEnd: true, jumpShown: false }, + sameReplayKept: { atEnd: false, jumpShown: true }, + }), + }) await applicationPage.close() // Assert cases.push({ @@ -7688,7 +7782,6 @@ const replayFirstLines = [ const testReplayViews = { first: { generation: 1, - state: 'event', time: 'Recorded test output · 0.10 s', test: replayTestName, name: `Recorded test output · ${replayTestName} · 0.10 s`, @@ -7697,7 +7790,6 @@ const testReplayViews = { }, result: { generation: 2, - state: 'event', time: 'Recorded test output · 0.90 s', test: replayTestName, name: `Recorded test output · ${replayTestName} · 0.90 s`, diff --git a/extension/src/constants.ts b/extension/src/constants.ts index 4bf1fbf11..6e8357132 100644 --- a/extension/src/constants.ts +++ b/extension/src/constants.ts @@ -981,6 +981,8 @@ export const NODE_TEST_HISTORY_MAX_ENTRIES = 10_000 export const NODE_HISTORY_SUMMARY_READ_CONCURRENCY = 4 /** How much of a test replay's recorded output, counted in code points of the shown text (the `[stderr] ` marks and line feeds included), the Application pane keeps up to the selected event: its end, where the selected moment is. */ export const NODE_TEST_OUTPUT_TAIL_CHARS = 65_536 +/** How close to its end, in CSS pixels, a test replay's output region counts as at its end, so the Application pane follows the latest output (DS20); subpixel scroll positions never land exactly on it. */ +export const TEST_OUTPUT_END_TOLERANCE_PX = 2 /** Longest debugger type name a saved Node fact keeps; the type is supplementary, so a longer one is omitted rather than failing the run. */ export const NODE_TEST_FACT_TYPE_LIMIT_CHARS = 256 /** Oldest Vitest major whose file listing, exclusion, name matching and JSON report were verified for exact node-test selection (4.1.11 and 5.0.2). */ diff --git a/extension/src/runtimeSupport/browserViewport.ts b/extension/src/runtimeSupport/browserViewport.ts index 31c25b90a..cbe69e73a 100644 --- a/extension/src/runtimeSupport/browserViewport.ts +++ b/extension/src/runtimeSupport/browserViewport.ts @@ -111,6 +111,8 @@ export class BrowserViewport { private closing = false private historical = false private historicalHtml?: string + /** The shown history's latest moment, written into a document only when a replacement panel restores it ({@link updateHistory}). */ + private latestHistoricalHtml?: () => string private rendererAttached = true private readonly listeners: vscode.Disposable[] = [] private observerFailed = false @@ -713,6 +715,7 @@ export class BrowserViewport { if (this.closed) throw new Error('Viewport lease is released') this.historical = true this.historicalHtml = html + this.latestHistoricalHtml = undefined this.presentationRevision++ this.suspendRenderer() this.panel.webview.html = html @@ -721,12 +724,13 @@ export class BrowserViewport { /** * Keeps the restore copy of the shown history current without writing the document again: a test replay's later moments reach its document as messages (EN3), and a replacement panel restores this copy ({@link restorePanelContents}). Called by the Study workspace after each posted moment; nothing happens while the viewport is live or released. - * @param html - The history's document at its latest moment. - * @example viewport.updateHistory(capturedAppHtml(message)) + * The copy is written only when a replacement panel needs it, so stepping through a replay builds no document per moment. + * @param html - Writes the history's document at its latest moment. + * @example viewport.updateHistory(() => capturedAppHtml(message)) */ - updateHistory(html: string): void { + updateHistory(html: () => string): void { if (this.closed || !this.historical) return - this.historicalHtml = html + this.latestHistoricalHtml = html } /** Reconnects the same target presentation when the learner explicitly follows its current pause. @example await viewport.showLive(signal) */ @@ -740,6 +744,7 @@ export class BrowserViewport { } this.historical = false this.historicalHtml = undefined + this.latestHistoricalHtml = undefined this.panel.webview.html = viewportHtml(this.mode) this.rendererAttached = true await this.reveal(signal) @@ -878,6 +883,11 @@ export class BrowserViewport { private restorePanelContents(): void { if (this.rendererAttached) return if (this.historical) { + // A test replay's latest moment is written into its document now, once. + if (this.latestHistoricalHtml) { + this.historicalHtml = this.latestHistoricalHtml() + this.latestHistoricalHtml = undefined + } if (this.historicalHtml === undefined) throw new Error('Historical viewport content is unavailable.') this.panel.webview.html = this.historicalHtml diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index f41f395b3..fd270a4ee 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -254,6 +254,7 @@ import { layoutKeepsEveryGroup } from './utils/layoutKeepsEveryGroup' import { libraryHasRoom } from './utils/libraryHasRoom' import { listsEveryTabGroup } from './utils/listsEveryTabGroup' import { maximizedEditorAreaWidthPx } from './utils/maximizedEditorAreaWidthPx' +import { nextNodeHistoryStepId } from './utils/nextNodeHistoryStepId' import { NodeHistoryPlaybackController } from './utils/nodeHistoryPlaybackController' import { nodeHistoryStepTarget } from './utils/nodeHistoryStepTarget' import { nodeStopSourceMatches } from './utils/nodeStopSourceMatches' @@ -672,6 +673,8 @@ export class StudyWorkspace { private selectedNodeHistoryRequest = 0 /** The step of the selected event's learning material the learner chose last (DS21); a step of another event leaves the selected event's first step shown. */ private selectedNodeHistoryStepId?: string + /** The step a pending event selection will show once it commits: a summary step's action names its step, Next and Previous none (the event's first). A refused or superseded selection leaves the learner's chosen step as it was. */ + private nodeHistoryStepChoice?: { stepId: string | undefined } /** The selected test replay's output read once ({@link indexNodeTestOutput}), with the history it was read from. */ private selectedNodeHistoryOutputIndex?: { history: NodeTestHistoryIndex @@ -8532,7 +8535,7 @@ export class StudyWorkspace { } if (action === 'return-current') await this.returnCurrentSelection() if (action === 'source') await this.renderSelection(false) - if (action === 'focus-app') this.app?.reveal(undefined, false) + if (action === 'focus-app') await this.focusApplication() if (action === 'focus-context') { if (this.summaryComposition) await this.showLearningSummary() else this.contextPanel?.reveal(undefined, false) @@ -11915,6 +11918,8 @@ export class StudyWorkspace { this.selectedNodeHistory = undefined this.selectedNodeHistoryLearningDecision = undefined this.selectedNodeHistoryLearningMaterial = undefined + // The retired replay's output index goes with it, so its entries leave the extension host's memory. + this.selectedNodeHistoryOutputIndex = undefined this.selectedNodeHistorySequence = undefined this.selectedNodeHistoryStepId = undefined this.selectedNodeHistoryDebugAvailable = false @@ -13924,6 +13929,17 @@ export class StudyWorkspace { } } + /** + * Brings the Application pane to the front, building it again first when the learner closed it while a test replay is selected, so Show recorded output and Focus application show the selected moment's recorded output (DS7); called for the Study pane's `focus-app` action. + * The reader's own entry ({@link open}) rebuilds the closed pane and draws the selected moment into it. Outside a test replay a closed pane stays closed: a browser replay and a live run own their pane's lifecycle. + * @example await this.focusApplication() + */ + private async focusApplication(): Promise { + if (!this.app && this.selectedNodeHistory && !this.recordingActive) + await this.open() + this.app?.reveal(undefined, false) + } + /** * Shows a selected test replay in the Application pane as one document per history and posts each later moment into it (EN3), so moving through the replay never reloads the page and the learner's scroll position, text selection and focus survive; called by {@link presentApplication} for every render and refresh, and by {@link publish} for every published change, while a test replay is selected. * The document is written again only for another history or another pane; an unchanged view's generation posts nothing. A hidden pane's restore copy follows the latest view ({@link BrowserViewport.updateHistory}). @@ -13957,7 +13973,8 @@ export class StudyWorkspace { view, } void this.app.webview.postMessage(update) - this.viewport?.updateHistory(capturedAppHtml(message)) + // The restore copy is written only if a replacement panel needs it, not at every moment. + this.viewport?.updateHistory(() => capturedAppHtml(message)) } /** @@ -21715,8 +21732,8 @@ export class StudyWorkspace { throw new Error( `There is no ${message.direction} ${message.unit ?? 'event'} in this Node history.`, ) - // Next and Previous show the event's first step (DS21). - this.selectedNodeHistoryStepId = undefined + // Next and Previous show the event's first step (DS21), once the move commits. + this.nodeHistoryStepChoice = { stepId: undefined } await this.selectNodeHistoryEvent(history, target) } if (message.type === 'node-history-event') { @@ -21729,8 +21746,8 @@ export class StudyWorkspace { throw new Error( 'Select the matching saved Node history before navigating its timeline.', ) - // A step's action selects its event and that step; an event's own control, the event's first step (DS21). - this.selectedNodeHistoryStepId = message.stepId + // A step's action selects its event and that step; an event's own control, the event's first step (DS21), once the selection commits. + this.nodeHistoryStepChoice = { stepId: message.stepId } await this.selectNodeHistoryEvent(history, message.sequence) } if (message.type === 'node-history-stop') { @@ -23178,6 +23195,9 @@ export class StudyWorkspace { alignPlayback = true, ): Promise { const request = ++this.selectedNodeHistoryRequest + // The step this selection shows is taken now, so a later selection's choice is never applied to this one. + const stepChoice = this.nodeHistoryStepChoice + this.nodeHistoryStepChoice = undefined if (publish) this.nodeHistoryPlaybackController?.pause() const entry = history.entries.find((value) => value.sequence === sequence) if (!entry) @@ -23248,6 +23268,15 @@ export class StudyWorkspace { this.report('history-position', error) } if (request !== this.selectedNodeHistoryRequest) return + // A chosen step stays only with its own moment: a selection that lands on another event without a choice of its own (a seek, Play, Inspect) shows that event's first step (DS21). + this.selectedNodeHistoryStepId = nextNodeHistoryStepId( + stepChoice, + { + sequence: this.selectedNodeHistorySequence, + stepId: this.selectedNodeHistoryStepId, + }, + entry.sequence, + ) this.selectedNodeHistorySequence = entry.sequence this.selectedNodeHistoryPage = Math.floor( history.entries.findIndex((value) => value.sequence === entry.sequence) / diff --git a/extension/src/tests/accumulateNodeTestOutput.test.ts b/extension/src/tests/accumulateNodeTestOutput.test.ts index 24ffd2264..cff74bd2b 100644 --- a/extension/src/tests/accumulateNodeTestOutput.test.ts +++ b/extension/src/tests/accumulateNodeTestOutput.test.ts @@ -190,3 +190,44 @@ test('a chunk of only terminal controls on the other stream leaves an unfinished { channel: 'stdout', text: 'job 2 scheduled', number: 1 }, ]) }) + +// Value: protects=each standard-error line's `[stderr] ` mark counts toward the kept tail and toward the characters left out, so the pane never shows more than its limit of shown text and says exactly how much it left out; fails_when=the mark is left out of a line's cost, so a tail of standard-error lines keeps more than the limit and the left-out count is too small; why_new=Step 9's testing review found every limit test written with standard-output lines only; seam=none; no_claim=the cut of a standard-error line whose mark does not fit (F114) +test('standard-error marks count toward the kept tail and the characters left out', () => { + // Arrange + const index = indexNodeTestOutput([ + printed(1, 'stderr', 'warn one\n'), + printed(2, 'stderr', 'warn two\n'), + ]) + + // Act + const shown = accumulateNodeTestOutput(index, 2, 18) + + // Assert + assert.deepEqual(shown, { + lines: [{ channel: 'stderr', text: 'warn two', number: 1 }], + omittedCharacters: 18, + startsMidLine: false, + inspectorLines: 0, + hasOutput: true, + }) +}) + +// Value: protects=an unfinished last line longer than the tail, such as a progress line a reporter redraws with carriage returns and never ends, keeps its end, is marked cut and counts no line feed; fails_when=the cut of an unfinished line loses a character or counts a line feed it does not have, so its kept end or the left-out count is off by one; why_new=Step 9's testing review found only lines ending with a line feed cut in the tests; seam=none; no_claim=how the pane renders the cut line +test('an unfinished last line longer than the tail keeps its end and counts no line feed', () => { + // Arrange + const index = indexNodeTestOutput([ + printed(1, 'stdout', 'done\nprogress 1 2 3'), + ]) + + // Act + const shown = accumulateNodeTestOutput(index, 1, 5) + + // Assert + assert.deepEqual(shown, { + lines: [{ channel: 'stdout', text: '1 2 3', number: 1, cut: true }], + omittedCharacters: 14, + startsMidLine: true, + inspectorLines: 0, + hasOutput: true, + }) +}) diff --git a/extension/src/tests/describeNodeTestReplay.test.ts b/extension/src/tests/describeNodeTestReplay.test.ts index 97f73d924..acbfed9c6 100644 --- a/extension/src/tests/describeNodeTestReplay.test.ts +++ b/extension/src/tests/describeNodeTestReplay.test.ts @@ -185,3 +185,41 @@ test('an Output entry names the surface, the study, the profile, the versions, t 'HISTORY_SEAL_FAILED · study a_b · profile node-test\nCodebook 0.2.33 (API 1.8) · Visual Studio Code 1.143.0\nCause: ENOSPC: no space left on device, write\nat NodeTestHistoryWriter.finish (~/dist/nodeTestHistoryStore.js:812:13)', ) }) + +// Value: protects=the Study section's status line names a test replay that is still recording, and the capture of a partial or interrupted one, as Saved studies does; fails_when=a recording replay reads a verdict and a capture it does not have yet, or a partial or interrupted capture reads another word; why_new=Step 9's testing review found only a complete replay's status line pinned; seam=none; no_claim=which history the section shows +test('the Study section status names a recording, partial or interrupted test replay', () => { + // Arrange + const summary = { + verification: 'verified' as const, + verdict: 'Test failed · exit code 1' as const, + stopCount: 1, + explainedStopCount: 0, + } + + // Act + const words = [ + describeNodeTestReplay({ + kind: 'study-status', + summary: { + ...summary, + status: 'recording', + verdict: 'No result recorded', + }, + }), + describeNodeTestReplay({ + kind: 'study-status', + summary: { ...summary, status: 'partial' }, + }), + describeNodeTestReplay({ + kind: 'study-status', + summary: { ...summary, status: 'interrupted' }, + }), + ] + + // Assert + assert.deepEqual(words, [ + 'Test replay · recording', + 'Test failed · exit code 1 · capture partial · 0 of 1 stops explained', + 'Test failed · exit code 1 · capture interrupted · 0 of 1 stops explained', + ]) +}) diff --git a/extension/src/tests/nextNodeHistoryStepId.test.ts b/extension/src/tests/nextNodeHistoryStepId.test.ts new file mode 100644 index 000000000..f5cedb8bf --- /dev/null +++ b/extension/src/tests/nextNodeHistoryStepId.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { nextNodeHistoryStepId } from '../utils/nextNodeHistoryStepId' + +// Value: protects=the rule of which learning step a committed test replay selection shows (DS21): a step action's own step, the first step for Next and Previous, and a learner's chosen step only while a selection without a choice stays on its moment; fails_when=a seek, Play or Inspect that lands on another event keeps the chosen step (it returns when the selection comes back), or a step action's choice is ignored; why_new=Step 9's state-machines review found the chosen step outliving moves that made no choice; seam=none; no_claim=when the workspace commits a selection +test('a committed selection shows its own step choice, else keeps the chosen step only on its moment', () => { + // Arrange + const chosenOnStop = { sequence: 2, stepId: 'keep' } + + // Act + const steps = [ + nextNodeHistoryStepId( + { stepId: 'keep' }, + { sequence: 4, stepId: undefined }, + 2, + ), + nextNodeHistoryStepId({ stepId: undefined }, chosenOnStop, 2), + nextNodeHistoryStepId(undefined, chosenOnStop, 2), + nextNodeHistoryStepId(undefined, chosenOnStop, 4), + ] + + // Assert + assert.deepEqual(steps, ['keep', undefined, 'keep', undefined]) +}) diff --git a/extension/src/tests/projectNodeTestApplication.test.ts b/extension/src/tests/projectNodeTestApplication.test.ts index 95038ef9c..1cb9a43cc 100644 --- a/extension/src/tests/projectNodeTestApplication.test.ts +++ b/extension/src/tests/projectNodeTestApplication.test.ts @@ -100,7 +100,6 @@ test('the Application view at a stop names its time and test and shows the outpu // Assert assert.deepEqual(view, { generation: 7, - state: 'event', time: 'Recorded test output · 0.42 s', test: 'src/theme.test.ts › theme › switches to dark', name: 'Recorded test output · src/theme.test.ts › theme › switches to dark · 0.42 s', @@ -168,7 +167,6 @@ test('the Application view says loading, nothing printed yet and no entries, and ) // Assert - assert.equal(loading.state, 'loading') assert.equal(loading.placeholder, 'Loading recorded test output…') assert.equal(loading.time, undefined) assert.equal( @@ -178,7 +176,6 @@ test('the Application view says loading, nothing printed yet and no entries, and assert.equal(beforeOutput.time, 'Recorded test output · 0.05 s') assert.deepEqual(noEntries, { generation: 4, - state: 'empty', test: 'src/theme.test.ts', name: 'Recorded test output · src/theme.test.ts', lines: [], @@ -215,7 +212,6 @@ test('the Application view of a long output says how many earlier characters it // Assert assert.deepEqual(view, { generation: 5, - state: 'event', time: 'Recorded test output · 0.42 s', test: 'src/theme.test.ts › theme › switches to dark', name: 'Recorded test output · src/theme.test.ts › theme › switches to dark · 0.42 s', diff --git a/extension/src/tests/projectNodeTestSection.test.ts b/extension/src/tests/projectNodeTestSection.test.ts index 1ffca4c0d..ceb35c960 100644 --- a/extension/src/tests/projectNodeTestSection.test.ts +++ b/extension/src/tests/projectNodeTestSection.test.ts @@ -348,3 +348,33 @@ test('Previous stop and Next stop are offered only where a stop lies before or a withoutStops: { previousStop: false, nextStop: false }, }) }) + +// Value: protects=a summary step whose id is `__proto__` (the step id schema accepts it) keeps its action label through the message to the Study pane, as an own property of the labels; fails_when=the label is assigned through the prototype's setter and dropped, so the Study pane reads Object.prototype and shows "[object Object]" on the step's control; why_new=Step 9's security review found the labels built in a plain object keyed by agent-authored step ids, and the learning summary already guards prototype-shaped ids (learningSummary.test.ts); seam=none; no_claim=the Study pane's own read, which reads only own labels +test('a summary step named __proto__ keeps its action label through the message to the Study pane', () => { + // Arrange + const prototypeNamed: NodeTestLearningMaterial = { + ...material, + content: { + ...material.content, + steps: [{ ...material.content.steps[1]!, id: '__proto__' }], + }, + } + const learning = projectNodeLearningSummary(prototypeNamed, history) + + // Act + const view = projectNodeTestSection({ + history, + selected: entries[1]!, + selectedStopVerified: true, + ...(learning ? { learning } : {}), + applicationClosed: false, + }) + const posted: Record = JSON.parse( + JSON.stringify(view.stepActions), + ) + + // Assert + assert.equal(Object.hasOwn(posted, '__proto__'), true) + assert.equal(posted['__proto__'], 'Inspect stop 1 · 0.42 s') + assert.equal(view.selectedStepId, '__proto__') +}) diff --git a/extension/src/tests/runtime-browserViewport.test.ts b/extension/src/tests/runtime-browserViewport.test.ts index cd34c6c2a..bd413a462 100644 --- a/extension/src/tests/runtime-browserViewport.test.ts +++ b/extension/src/tests/runtime-browserViewport.test.ts @@ -1747,8 +1747,8 @@ test('rebinding both live-history and replay leases preserves the independent re assert.equal(replacement.callbacks().receivers.length, 0) }) -// Value: protects=a test replay's later moments keep the shown history's document and update only its restore copy, so a replacement panel shows the latest moment; fails_when=updateHistory writes the shown document again (its scroll, selection and focus are lost) or a replacement panel restores an earlier moment; why_new=PR-B1 posts later moments into the Application document in place (EN3) and the copy is how a replacement panel gets them; seam=the viewport harness's panels; no_claim=VS Code's own restore of a hidden webview -test('a history update keeps the shown document and a replacement panel restores the latest moment', async () => { +// Value: protects=a test replay's later moments keep the shown history's document and update only its restore copy, which is written once, when a replacement panel needs it, at the latest moment; fails_when=updateHistory writes the shown document again (its scroll, selection and focus are lost), a replacement panel restores an earlier moment, or a document is built at every moment (Step 9 performance: 100 to 300 KB per step on the extension host); why_new=PR-B1 posts later moments into the Application document in place (EN3) and the copy is how a replacement panel gets them; seam=the viewport harness's panels; no_claim=VS Code's own restore of a hidden webview +test('a history update keeps the shown document and a replacement panel restores the latest moment, written only then', async () => { // Arrange const original = viewportPanel(), replacement = viewportPanel(), @@ -1761,10 +1761,19 @@ test('a history update keeps the shown document and a replacement panel restores original, ) live.viewport.showHistory('

Stop 1 of 2 · 0.20 s

') + const written: string[] = [] // Act - live.viewport.updateHistory('

Stop 2 of 2 · 0.30 s

') + live.viewport.updateHistory(() => { + written.push('output') + return '

Output · 0.25 s

' + }) + live.viewport.updateHistory(() => { + written.push('stop 2') + return '

Stop 2 of 2 · 0.30 s

' + }) const shownAfterUpdate = original.panel.webview.html + const writtenBeforeRestore = [...written] const release = await live.viewport.holdPlacement(signal) live.rebind(replacement) original.panel.dispose() @@ -1773,6 +1782,8 @@ test('a history update keeps the shown document and a replacement panel restores // Assert assert.equal(shownAfterUpdate, '

Stop 1 of 2 · 0.20 s

') + assert.deepEqual(writtenBeforeRestore, []) + assert.deepEqual(written, ['stop 2']) assert.equal(replacement.panel.webview.html, '

Stop 2 of 2 · 0.30 s

') assert.deepEqual(live.failures, []) live.viewport.close() diff --git a/extension/src/tests/studyWorkspaceTestReplayOutput.test.ts b/extension/src/tests/studyWorkspaceTestReplayOutput.test.ts index a62aa543d..3d758e383 100644 --- a/extension/src/tests/studyWorkspaceTestReplayOutput.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayOutput.test.ts @@ -5,6 +5,7 @@ import type { NodeTestHistoryStore } from '../nodeTestHistoryStore' import type { NodeTestSourceStop } from '../ownedNodeTestRun' import { sha256 } from '../runtimeSupport/utils/sha256' import { waitUntil } from '../runtimeSupport/utils/waitUntil' +import { studyHtml } from '../ui/studyHtml' import { workspaceHarness } from './studyWorkspaceHarness' import { nodeTestHistoryHeader } from './utils/nodeTestHistoryFixture' @@ -357,6 +358,124 @@ test('closing the Application pane makes the test replay section say so and movi } }) +// Value: protects=Show recorded output, the Study line under "The Application pane is closed.", brings a closed Application pane back with the selected moment's recorded output, so the learner who closed the pane by hand can read the output again without leaving the replay; fails_when=the button sends an action that needs all three Study panes (Restore Study arrangement fails with "The three retained Study panes are unavailable."), the action leaves the pane closed, or the new pane shows another moment's output or no output document; why_new=Step 7's coverage audit and Step 9's QA found the line's button bound to Restore, which cannot rebuild a closed pane (DS7); seam=none; no_claim=the native placement of the new pane, which the harness does not lay out (stand-ins: the workspace harness's closeTabByHand, its native panel list and the panel's recorded document) +test('Show recorded output brings a closed Application pane back at the selected moment', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + await harness.reader.openLearningReplay(history.studyId) + await waitUntil( + () => + lastView(harness)?.nodeHistoryPosition?.sequence === 2 && + !lastView(harness)?.loading, + 'the test replay lands on its first stop', + 2_000, + ) + const closedPane = harness + .nativePanels() + .find((panel) => panel.kind === 'codebook.scopedBrowser') + harness.closeTabByHand(closedPane!.nativeTab) + await waitUntil( + () => lastView(harness)?.nodeHistorySection?.applicationClosed === true, + 'the section says the Application pane is closed', + 2_000, + ) + // The learner moves on while the pane is closed: the reopened pane shows this later moment. + harness.receiveContext({ + type: 'node-history-step', + revision: lastView(harness)!.revision, + studyId: history.studyId, + direction: 'next', + unit: 'stop', + }) + await waitUntil( + () => lastView(harness)?.nodeHistoryPosition?.sequence === 4, + 'Next stop reaches the second stop', + 2_000, + ) + + // Act + // The Study line's button sends its data-action through the pane's action message, as a click does. + harness.receiveContext({ + type: 'action', + revision: lastView(harness)!.revision, + action: 'focus-app', + }) + await waitUntil( + () => lastView(harness)?.nodeHistorySection?.applicationClosed === false, + 'the section says the Application pane is back', + 3_000, + ) + const reopened = harness + .nativePanels() + .find((panel) => panel.kind === 'codebook.scopedBrowser') + + // Assert + assert.match( + studyHtml('context'), + /