diff --git a/AGENTS.md b/AGENTS.md index 5ed26b3ef..508adc445 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,7 @@ The driving agent owns state, cycle selection and completion claims; GStack supp **Background work.** `node extension/scripts/watch-background.mjs` (from the directory Claude Code started in, else with `--project `) lists the current Claude Code session's background tasks and subagents (its `CLAUDE_CODE_SESSION_ID`), each with the time of its last output, and marks those silent for more than 20 minutes; a finished or stopped subagent is never marked, a shell's row stays marked after it ended (its task notification is the authority). Stop a review subagent that has written nothing for 20 minutes and dispatch it again on a narrower scope; dispatch a subagent that the API's safety filter stopped again at once, with the same brief, on the fallback model. While waiting longer than a few minutes, tell the owner what is awaited and since when. -**Ship review limit (owner, 2026-10-18; 2026-10-23).** The installed `/ship` runs at most three fixing cycles: no fourth cycle, no counter reset after a handoff, no round relabeled a repair. When the third cycle still leaves findings (`converged:false`), the owner's rule of 2026-10-23 applies, in a feature cycle and a bug-fix cycle alike: fix every finding of the last round, Minor and below included (so a Critical one is fixed before the merge in both, as the delivery rules above require); record only the Critical and Major defects still open in a GitHub Issue (the next cycle's tasks); then merge after required CI and the exact-HEAD gate, reporting `converged:false` in the PR. This overrides the no-carry rule for those recorded defects only. +**Ship review limit (owner, 2026-10-18; 2026-10-23).** The installed `/ship` runs at most three fixing cycles: no fourth cycle, no counter reset after a handoff, no round relabeled a repair. When the third cycle still leaves findings (`converged:false`), the owner's rule of 2026-10-23 applies, in a feature cycle and a bug-fix cycle alike: fix every finding of the last round, Minor and below included (so a Critical one is fixed before the merge in both, as the delivery rules above require); record only the Critical and Major defects still open in a GitHub Issue (the next cycle's tasks); then merge after required CI and the exact-HEAD gate, reporting `converged:false` in the PR. This overrides the no-carry rule for those recorded defects only. The limit is `/ship`'s alone (owner, 2026-10-23): one `/ship` round is long, and running it to full convergence would stall delivery. The defects it records are those of `/ship`'s review rounds that the pull request cannot close, such as a Major finding whose fix or proof needs a root cause still unknown or a native check. A Critical one never merges open: a Critical finding the pull request cannot close holds the merge until it is fixed, as the cycle rule and the delivery rules require. The local CodeRabbit CLI and the hosted CodeRabbit review have no round limit: their findings follow the cycle rule at every review, as do failures of the gate, CI and native checks. **Skills.** Read each installed SKILL.md at invocation; this map guides and copies no workflow. Premise or final behavior: `/office-hours` or `/plan-ceo-review` (owner decision). Lasting API, MCP or integration change: `/plan-devex-review` (capability, error, lifecycle contract). Flow, layout, hierarchy, interaction: `/plan-design-review` (UX and state decisions). Architecture, concurrency, storage, meaningful code: `/plan-eng-review` after UX/API amendments (bounded implementation and test plan). Combined review on owner request: `/autoplan` (CEO → conditional Design → conditional DX → Eng). Unexplained failure: `/investigate` (root cause, new hypothesis). Working browser or webview flow: `/qa` or `/qa-only` (observed behavior, fixes). Implemented UI: `/design-review` (real visual and interaction evidence). Ready diff: `/review` (revision-bound findings, coverage limits). Releasable PR: `/ship` (actual commit, PR, checks). Merge or deploy: `/land-and-deploy` if available, else the established extension delivery. Changed public behavior: `/document-release`. Pause or handoff: `/context-save`, then `/context-restore`, reconciled with the repository and Issue. Ordinary coding is the agent's work; documentation-only changes need only the applicable reviews; a failed experiment may inform the next cycle before release. Browser QA, Reader DOM checks included, proves no native pixels, DAP targeting, MCP integration or installed VSIX behavior. Report a missing tool as missing coverage. @@ -61,7 +61,7 @@ The driving agent owns state, cycle selection and completion claims; GStack supp **Invocation.** Pass the Issue, phase and bounded scope explicitly. PRODUCT_GOAL.md is the destination, not permission to build everything. Retired G08/G09 never return through an old prompt. Give design skills `## Design target` and the PNG. Honor scope gates without re-asking authorized routine choices or passing agent-chosen targets off as the user's; keep required reports; one approved recommendation authorizes no unrelated change. Shared-skill and Wiki-generation scope gets DX and engineering review; discovery, loaded identity, runtime connection and AI-owned results are separate checks; the Wiki works without runtime or debug MCP. -**CodeRabbit and OCR.** [.coderabbit.yaml](.coderabbit.yaml) holds policy and exclusions: keep its guidelines current, validate with `coderabbit config validate .coderabbit.yaml`, and with `--config` pass it beside the extra context. Required: the local CodeRabbit CLI, findings triage, CI and `open-code-review-delegate` (OCR picks files and rules, the host reviews; each eligible file reviewed or skipped with a reason; default exclusions reported apart; no independent external review). Review against the implementation base; record completion, source cut, covered files, findings. Hosted CodeRabbit findings fall under the cycle rule; if it did not run, report "not run", never APPROVED. Missing or private provider context is a coverage limit. Each user-visible behavior that a CHANGELOG entry names has a sentence in the packaged README (`extension/README.md`), as `.coderabbit.yaml`'s `CHANGELOG.md` instruction checks; a fix that changes a user-visible behavior changes that README in the same commit, and fixers' briefs say so. Claude Code CLI review stays owner-skipped and is no native chat acceptance. +**CodeRabbit and OCR.** [.coderabbit.yaml](.coderabbit.yaml) holds policy and exclusions: keep its guidelines current, validate with `coderabbit config validate .coderabbit.yaml`, and with `--config` pass it beside the extra context. Required: the local CodeRabbit CLI, findings triage, CI and `open-code-review-delegate` (OCR picks files and rules, the host reviews; each eligible file reviewed or skipped with a reason; default exclusions reported apart; no independent external review). Review against the implementation base; record completion, source cut, covered files, findings. The hosted CodeRabbit review is triggered on each pull request (owner, 2026-10-23): wait for it beside CI and fix its findings under the cycle rule at every review, with no round limit (the ship review limit is `/ship`'s alone); when a rate limit or a failed trigger keeps it from running, report it "not run", never APPROVED, and go on with the local review, which runs every cycle either way. Missing or private provider context is a coverage limit. Each user-visible behavior that a CHANGELOG entry names has a sentence in the packaged README (`extension/README.md`), as `.coderabbit.yaml`'s `CHANGELOG.md` instruction checks; a fix that changes a user-visible behavior changes that README in the same commit, and fixers' briefs say so. Claude Code CLI review stays owner-skipped and is no native chat acceptance. **Status:** ready → planning → implementing → validating → closed, or blocked with its condition and unblocking action (say why work moves back). **Result:** pending, demonstrated, falsified, inconclusive. **Delivery:** not-applicable, local-only, committed, PR-open, merged, packaged, published, installed-verified. The three are independent. diff --git a/CHANGELOG.md b/CHANGELOG.md index b22732ee9..d68bbfecf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## [0.2.37.0] - 2026-10-23 (development candidate) + +Cycle 0050's second pull request ([Issue #118](https://github.com/laststance/codebook/issues/118)): a test replay's Application pane and Study section. The recorded-source tab and the moves between moments come with the third. + +**The Application pane of a test replay.** + +- **Your place in the output is kept.** The choice to follow the latest output and the line you read are kept per replay by Codebook, not by the page, so a rebuilt pane, a Retry, a presentation change or a page VS Code reloads opens where you were, with **Jump to latest** when you were reading higher up. Scrolling up stops following and Jump to latest follows again, decided by the scroll position alone. While you read higher up, a later moment that drops the earliest kept lines leaves the line you read where it stood. +- **Show recorded output brings a closed pane back above the source.** It opens in a group of its own above the recorded-source tab's group, and no other pane moves; when your editor kept the emptied group (`workbench.editor.closeEmptyGroups` off), the pane returns into it. +- **Opening another test replay** leaves the replay you see as it is until the other one shows, and the loading line names the replay being opened. A view the page did not take is sent again. +- **Output reads as the test printed it.** Color sequences and terminal strings that the recording split across two pieces are removed whole, an unterminated one removes only itself, and a recording full of them is cleaned in time linear in its length. Lines with emoji count each emoji once, and the muted lines read `1 earlier character not shown` and `1 Node inspector line hidden` in the singular. +- **Faster in long recordings.** Moments in a burst are drawn once per frame, newest only, unchanged lines keep their place on the page, and the output is read once per opened replay. + +**The Study section of a test replay.** + +- **No hidden reordering.** Bidirectional and terminal controls are removed from every text the section, the Application header, Saved studies and the learning summary print: test titles and files, output and result lines, stop paths, frames and values, and the learning material's question, title, summary, participants and steps. +- **Why a capture is partial or interrupted** shows under the status, on one line in test replay words. +- **Inspect in native debugger beside Show Variables.** While the debugger shows the stop and its values are not loaded, the row says `Debugger stop selected`; once the debugger no longer shows the stop (you ended its session, or it could not start), **Inspect in native debugger** there starts it again. The button follows the selected stop, also while the event list shows another page. +- **Quick moves add up.** Two quick Next presses move two stops, and a move that fails, or that a Close, a reopen, Return to Live or a browser recording cuts off, gives its place back, so the next move starts from the moment shown. +- **Keyboard.** A long title is a tab stop that shows whole on focus, the Application pane's output region is a tab stop you can scroll by keyboard, a control that leaves during Play hands the focus to the transport, and a control you reach with Tab never hides under the sticky transport. +- **The sequence diagram** wraps names and labels at word boundaries, also Japanese and Chinese text, and scrolls by keyboard to its last participant. +- **Test replay words.** Saved studies rows read `Test replay · …`, and refusals say test replay where they said Node history: `Test replays cannot be deleted here yet.`, `There is no next stop in this test replay.`, `The replay debugger was stopped. Select this stop or use Inspect in native debugger to restart it.` (said by a seek or Play that reaches a stop after you stopped the debugger; Next stop, Previous stop or Inspect start it again). +- **A browser recording opened while a test replay is selected** retires the test replay, and the Application pane shows the recording's own pages. + +**Another window of the same workspace.** A test history that another live window records is listed and opened as recording, not interrupted, and its journal is read only where it grew; once that window seals it, it reads with its sealed status, and if that window ends without sealing it, as in a crash, it reads interrupted. A writer record that could not be read for a moment is judged again a few seconds later. + +**Compatibility.** The MCP API stays 1.8. A history being recorded now carries a small writer record in its folder, removed when it seals; earlier versions ignore it, and test histories, learning materials and reader positions open in both directions unchanged. + +**Observed natively.** Show recorded output after closing the pane, in VS Code 1.142.0 and Cursor 3.26.16 on macOS: the pane returns alone above the source and nothing else moves. In VS Code, an output reached from a result read away from its end keeps following off and Jump to latest. Windows and Linux are not run. + ## [0.2.36.0] - 2026-10-23 (development candidate) Cycle 0050 is a bug-fix cycle ([Issue #118](https://github.com/laststance/codebook/issues/118)): it fixes the defects Cycle 0049 left, in three pull requests merged in order. This version holds the first: the test run's start, stops and refusals, saving and storing test replays, causes that name no private path, the bundled skill and the tests that failed under load. The Application pane and the Study section come with the second, the recorded-source tab and the moves between moments with the third. @@ -23,7 +52,7 @@ Cycle 0050 is a bug-fix cycle ([Issue #118](https://github.com/laststance/codebo - **A replay debugger that cannot start** (a declined workspace trust, the capture timeout) no longer fails the open: the replay opens on its moment, the Study pane says `The replay debugger could not start: . Select this stop or use Inspect in native debugger to restart it.`, and Retry starts it again. A debugger you stopped stays stopped when you move to an output or the result. - **`codebook_playback` refuses a test replay** with `PLAYBACK_PROFILE_UNSUPPORTED`, also one of an earlier session that this window has not listed yet. -**Causes name no private path.** Every cause an agent or a learner reads (MCP answers and refusals, the run resource, `codebook_status`, Saved studies, the book catalog, notices, Output › Codebook) shows the extension's storage folder as ``, the opened repository as `` and the home directory as `~`, also under their real paths. A long cause is cut between characters and ends with `… [cut]`; the run resource shows at most 300 characters of it. +**Causes name no private path.** Every cause an agent or a learner reads (MCP answers and refusals, the run resource, `codebook_status`, Saved studies, the book catalog, notices, Output › Codebook) shows the extension's storage folder as ``, the opened repository as `` and the home directory as `~`, also under their real paths. A long cause that a tooltip, a notification, the run resource or an MCP message shows is cut between characters and ends with `… [cut]` (the run resource shows at most 300 characters of it); the Output › Codebook entry keeps the whole cause. **The bundled skill (1.0.47).** In a new VS Code chat that runs in the Copilot harness, where Codebook's MCP tools are not reachable, the skill tells the learner to open a Local chat session, as **Codebook: Set Up AI Chat** says. diff --git a/VERSION b/VERSION index 74e649bdf..f5edbebed 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.36.0 +0.2.37.0 diff --git a/extension/README.md b/extension/README.md index a6243f542..a2a29f679 100644 --- a/extension/README.md +++ b/extension/README.md @@ -69,16 +69,17 @@ A Vitest test run that the chat explained (the `node-test` profile) is saved the - **Learning saved · Open replay** when the test passed, every stop's recorded debugger state verifies, and the learning material explains every stop and the result. - **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. - A test run that ends before it records any output, stop or result leaves no test replay: it is not listed in Saved studies and takes no place of the saved study limit. A read error that a retry can clear (a busy or locked file, too many open files) shows a replay as unavailable only until the next refresh. Codebook runs a root Vitest only from inside the repository; a `node_modules/vitest` that links outside it is named as such in `codebook_status`. A history sealed with no entry is removed when it seals, also when a list or a count meets one that an earlier version sealed; a history that holds a saved learning material or understanding marks is kept, and so is one whose seal records entries but whose journal was lost: a status call or a list never removes it. +- Saved studies in a second editor window of the same workspace lists a test replay that another live window is still recording as `recording`, not interrupted, and refreshes it by reading only what was appended; once the recording window seals it, the window shows the sealed status, and a replay whose recording window crashed reads as interrupted, with no verified final seal. A history that is being recorded carries a small writer record (`writer.json`) in its folder, which is removed when the history seals; earlier versions ignore it, a record that could not be read for a moment is judged again within 5 seconds, and test histories, learning materials and reader positions open in both directions unchanged. - A stop before the first stop reads stopped, not refused. A run stopped before its history began, or whose history sealed with no entry, offers no `read-history`. After a stop whose release failed, the run offers only `stop`, and a continue is refused with `STALE_PAUSE`. - **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. If the replay debugger cannot start at that first open (a declined workspace trust, for example), the replay still opens on that moment and the Study pane says on its replay-debugger line why the debugger is missing; **Retry storage / refresh** or Inspect presents it again. A debugger you stopped stays stopped when you move to an output or the result. A failed open shows its cause with the storage folder replaced by ``. 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`. +**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, and a browser recording that loads while a test replay is shown replaces the replay, so the Application pane shows the recording's own pages. If the replay debugger cannot start at that first open (a declined workspace trust, for example), the replay still opens on that moment and the Study pane says on its replay-debugger line why the debugger is missing; **Retry storage / refresh** or Inspect presents it again. A debugger you stopped stays stopped when you move to an output or the result. A failed open shows its cause with the storage folder replaced by ``. 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.`, and one this window is still recording says nothing of an end. 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; moving back to a shorter moment does not turn following back on. A page the editor loads again (the pane moved to another window, webviews reloaded) shows the latest moment it had and keeps your choice; a pane the editor replaces follows again. Moving through a long output costs about the 65,536 characters the pane keeps, however many lines it holds or however long a line printed without line feeds is. +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; while the replay loads and for a replay without entries the line reads `Recorded test output` alone, and while another replay opens, the one shown keeps its output untouched, and the replay being opened reads `Loading recorded test output…` under its own name). Lines the test wrote to standard error start with `[stderr]` and a space; terminal colors, cursor moves, window titles and other control characters are removed, also when a sequence is split across two output entries, and the same removal applies to every recorded text the replay shows (the Study section's events with their file paths, frame names and value names, the line that says why a capture ended, the result's output tail, the captured values and the test titles in the header); 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 (`… 1 earlier character not shown` and `1 Node inspector line hidden` in the singular; each emoji counts as one character). 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.`, and one this window is still recording says nothing of an end. The output region is a tab stop named `Recorded test output up to s`, so the keyboard can scroll it. 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; moving back to a shorter moment does not turn following back on. While you read above the end, the line you read stays where it stood when a later moment drops the earliest kept lines. The extension keeps your follow choice and your scroll position in each test replay while it is selected, so a page the editor loads again (the pane moved to another window, webviews reloaded) or a pane it replaces (a layout change, Retry) shows the latest moment and opens where you were reading; moments that arrive faster than the screen redraws (Play) are drawn once per frame. The saved place is kept when such moments are joined, and it never overrides a scroll you made after the pane opened. Moving through a long output costs about the 65,536 characters the pane keeps, however many lines it holds or however long a line printed without line feeds is. -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**, first in the section's transport, 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. Where that pane opens is not checked in either editor yet: it can open beside the source rather than above it. +The Study pane's **Test replay** section names the result, the capture and the explained stops (for an interrupted or partial capture, on a line of its own, why it ended), 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.`; at a stop whose values are not loaded, **Inspect in native debugger** stands beside the hint and **Show Variables**. While the debugger shows the selected stop and its values are not loaded, that button reads `Debugger stop selected`; once the debugger no longer shows the stop (you ended its session, or it could not start), it reads **Inspect in native debugger** again and starts it. The button follows the selected stop, also while the event list shows another page. When either button goes away while the keyboard is on it, the focus moves to the transport. **Previous stop** and **Next stop**, first in the section's transport, move between stops, **Previous event** and **Next event** through every event, and **Play** and the time slider move through the recording. Two quick presses of **Next stop** move two stops, and a move that fails, or that a Close, a reopen, Return to Live or a browser recording cuts off, gives its place back, so the next move starts from the moment shown; at the last stop it is refused with `There is no next stop in this test replay.`. After you stopped the replay debugger, **Next stop**, **Previous stop** or **Inspect in native debugger** starts it again at the stop, while a seek or **Play** that reaches a stop stops there with `The replay debugger was stopped. Select this stop or use Inspect in native debugger to restart it.` The learning summary is drawn as a browser replay's: a sequence diagram, whose names and step labels wrap at word boundaries inside their boxes (a Japanese, Chinese or Korean label has no spaces and wraps between its characters, measured in the diagram's font, so it is shown whole and clear of its neighbours) and which scrolls sideways inside its own region when it is wider than the pane, 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 ()**. The section's transport stays in view below the live strip while you scroll, a control you reach with Tab or Shift+Tab is scrolled clear of it, and the learning title, like the Application header's test line, is a tab stop that shows the whole name while it has the focus when it is cut at two lines. 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. The reopened pane gets a group of its own above the recorded-source tab's group, and no other pane moves; when your editor kept the emptied group (`workbench.editor.closeEmptyGroups` off), the pane returns into it. -The executed source shows in one **recorded-source tab**, named after the stop's file (`Captured-`). At a stop whose recorded source verifies, the tab holds that file as it ran, with the stopped line highlighted and labelled `Recorded: : · s`; the native debugger's thread reads `Recorded test stop · :`, shows that stop's recorded values and stack, and its frames open the same tab. At an output, at the result and at a stop whose source does not verify, the tab holds `// Recorded source · Not recorded at this moment` and `// The test journal holds no source state for this moment.`, and the debugger's thread reads `Not recorded at this moment` with no frames, also when the replay opens on such a moment. A stop in a file with another name renames the tab, and Codebook closes the tab it opened before, so a replay never leaves a tab per stop; returning to Live or opening another saved study closes the tab. Each move first takes the earlier moment off the debugger and the tab, then shows the new moment's source in the tab, then presents the debugger at it, and then shows the stopped line, its label, the Study position and the Application output together; quick moves, an overtaken move and a return to the moment already shown leave no other moment's stop in the debugger and no extra tab. If the tab cannot open, the Study pane says `Codebook could not open the recorded source of stop : . Details: Output › Codebook.`, the moment stays selected with no stop in the debugger, and **Retry** opens the tab again; returning to Live takes that line with it. +The executed source shows in one **recorded-source tab**, named after the stop's file (`Captured-`). At a stop whose recorded source verifies, the tab holds that file as it ran, with the stopped line highlighted and labelled `Recorded: : · s`; the native debugger's thread reads `Recorded test stop · :`, shows that stop's recorded values and stack, and its frames open the same tab. At an output, at the result and at a stop whose source does not verify, the tab holds `// Recorded source · Not recorded at this moment` and `// The test journal holds no source state for this moment.`, and the debugger's thread reads `Not recorded at this moment` with no frames, also when the replay opens on such a moment. A file name with terminal or bidirectional control characters is shown without them in the tab title, the label and the thread, as the Study pane shows it. A stop in a file with another name renames the tab, and Codebook closes the tab it opened before, so a replay never leaves a tab per stop; returning to Live or opening another saved study closes the tab. Each move first takes the earlier moment off the debugger and the tab, then shows the new moment's source in the tab, then presents the debugger at it, and then shows the stopped line, its label, the Study position and the Application output together; quick moves, an overtaken move and a return to the moment already shown leave no other moment's stop in the debugger and no extra tab. If the tab cannot open, the Study pane says `Codebook could not open the recorded source of stop : . Details: Output › Codebook.`, the moment stays selected with no stop in the debugger, and **Retry** opens the tab again; returning to Live takes that line with it. The time slider marks the recording between its first and last event as unrecorded, with a tick at each stop whose recorded source verifies, under `Ticks mark recorded stops; source and debugger are not recorded between them.` (a test journal records when a stop paused, not when the test resumed). **Play** moves from event to event and waits at most 1.5 seconds for the next one, also across an unrecorded interval, which it neither pauses at nor asks about; each surface shows the selected event's own time. @@ -443,7 +444,7 @@ Only you remove saved material; an agent has no tool for it. In the Study pane, - The raw evidence stays in place when the run belongs to another Codebook window that may still be open; the line then ends with `The run's raw evidence was left in place because its Codebook window may still be open.` Evidence of runs that were never saved as a study is not touched. - After the removal an agent that reads the run's evidence (`manifest`, `events`, observations, explanations) or adds an explanation gets `EVIDENCE_UNAVAILABLE` with `The learner moved this saved study to the Trash.`; the run resource itself still answers and names the same reason. - When a study's files were removed by hand (the only way in a window without a Trash) or the study was deleted in another Codebook window, the next **Retry storage / refresh** finds its archive gone. An agent that then reads a run of this window whose study that was, or adds an explanation to it, gets `EVIDENCE_UNAVAILABLE` with `The archive of this run's saved study is no longer in the study library: its files were removed by hand or by another Codebook window.`; the run resource still answers and names the same reason. -- The button says why it cannot be used: `Wait for the chosen saved study to open.`, `Choose a saved study to delete.`, `Node test histories cannot be deleted here yet.`, `Stop the run before deleting its study.` or `Choose Overview or Live to leave Recording before deleting this study.` (also while a recording is still opening). +- The button says why it cannot be used: `Wait for the chosen saved study to open.`, `Choose a saved study to delete.`, `Test replays cannot be deleted here yet.`, `Stop the run before deleting its study.` or `Choose Overview or Live to leave Recording before deleting this study.` (also while a recording is still opening). - An entry that cannot be opened (an older format, a study whose catalog file is missing, or what is left of a study whose archive was removed) can still be chosen and deleted. For a study whose catalog checkpoint is missing or out of date the pane says `This saved study's catalog checkpoint is missing or out of date. Repair it, or delete the study.` and offers **Repair saved study** beside the delete button. - What is left of a study whose archive was removed (its catalog file, or a recording that no editor of this computer still writes and whose study no other editor window of this computer may still save) stays in the list with `Archive missing; remaining files kept` in its label, so that the rest can be removed: **Delete saved study…** moves it to the Trash, and in a window without a Trash **Remove by hand** lists its files. A recording that names another computer is listed too, and so is one whose editor Codebook cannot tell from another program of this computer; for such a recording **Delete saved study…** asks first, as the next point says. Such an entry can be chosen and not opened, it has no **Repair saved study**, a reader that starts neither selects it nor shows it first, and it takes no place of the saved study limit. The finished recording of a run of this window whose archive could not be saved yet is listed with the same words; that study has not left the library, and **Retry storage / refresh** writes its archive. - When a recording of the study may still be in use on another computer (an editor window there still records it, or has finished it and not saved the study yet), Codebook asks once more after **Move to Trash**: `This study's recording may still be in use on another computer. Move the study to the Trash anyway?`, with the detail `Its recording was started on at . Codebook cannot tell from this computer whether an editor window still records it or still has to save it. If a window still records it or still has to save it, on that computer or on this one, the recording is lost. If none does, nothing is lost. Cancel keeps the study and its recording as they are.` **Move to Trash Anyway** moves the study with its recording; **Cancel** keeps both as they are. When the recording names this computer but Codebook cannot tell its editor from another program (the editor's process number now belongs to a program that started later, or to another user), the dialog reads `This study's recording may still be in use in another editor window. Move the study to the Trash anyway?` and `Its recording was started on at . Codebook cannot tell whether an editor window of this computer still records it or still has to save it. If a window still records it or still has to save it, the recording is lost. If none does, nothing is lost. Cancel keeps the study and its recording as they are.` When Codebook could not read the files of the recording at that moment (a virus scanner may hold them, or a file is damaged), it lists the study in the same way and cannot name a host or a start: the dialog reads `This study's recording may still be in use in another editor window. Move the study to the Trash anyway?` and `Codebook could not read the files of its recording now. A virus scanner may hold them, or another editor window may still record it or still have to save it. If a window still records it or still has to save it, the recording is lost. If none does, nothing is lost. Cancel keeps the study and its recording as they are.` For a study with several such recordings the title begins `Some of this study's recordings may still be in use` and names the places that apply (`on another computer`, `in another editor window`, or `on another computer or in another editor window`), and the detail names each host and start. Nothing moves while the dialog is open. If a recording of another computer appears, or another window opens Recording, before you answer, **Move to Trash Anyway** moves nothing and the line says why. @@ -458,7 +459,7 @@ Only you remove saved material; an agent has no tool for it. In the Study pane, - When the next study cannot be arranged after a removal (for example while a presentation change is pending), the removal's line is shown all the same, no study is shown and the pane's errors name the reason; choose a study in **Saved studies** to continue. - The delete button waits only while the entry you chose is opening. A choice the reader could not open (it names the reason among its errors) puts the list back on the study that is shown. - A run the library refused can be started again with the same request once there is room: the agent repeats its `codebook_start` with the same key and a new run begins. -- Not covered yet: Node test histories, removing several studies at once, and a study whose run is still held by another window of the same workspace. +- Not covered yet: test replays, removing several studies at once, and a study whose run is still held by another window of the same workspace. Existing studies, recordings and learner decisions are never evicted. Lowering the limit does not hide existing history or prevent updates to a previously admitted study. The fixed catalog ceiling is 256; per-recording frame/byte limits remain unchanged. Raising a count limit permits more disk use, not larger individual recordings. diff --git a/extension/package.json b/extension/package.json index b5450a5e4..966e07c2d 100644 --- a/extension/package.json +++ b/extension/package.json @@ -3,7 +3,7 @@ "displayName": "codebook", "description": "Connect an external agent to source-grounded codebase studies.", "publisher": "laststance", - "version": "0.2.36", + "version": "0.2.37", "private": true, "engines": { "vscode": "^1.120.0", diff --git a/extension/scripts/fixtures/learning-sequence-golden.txt b/extension/scripts/fixtures/learning-sequence-golden.txt index 6f001d237..c5362a6e6 100644 --- a/extension/scripts/fixtures/learning-sequence-golden.txt +++ b/extension/scripts/fixtures/learning-sequence-golden.txt @@ -1 +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 +
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.

diff --git a/extension/scripts/study-bootstrap-qa.mjs b/extension/scripts/study-bootstrap-qa.mjs index 5bcc12854..ee76b4d24 100644 --- a/extension/scripts/study-bootstrap-qa.mjs +++ b/extension/scripts/study-bootstrap-qa.mjs @@ -772,6 +772,7 @@ async function check(page, options, collected) { document.querySelector('#recording-transport .timeline-track') .outerHTML, ) + // Value: protects=the browser transport's slider keeps the markup it had before its drawing was shared with test replays (AC14): the gap and stop marks, mapped and unknown, in the same elements and attributes; fails_when=the shared drawing changes the browser slider's DOM, so a recording's transport reads or looks different; why_new=PR-B2 shared the coverage drawing and pinned the old markup as a text fixture; seam=none; no_claim=how the slider looks (native pixels) cases.push({ name: 'the browser transport’s slider with its gap and stop marks, mapped and unknown, renders the same markup as before its drawing was shared with test replays', actual: `${mappedTransportMarkup}\n---\n${unknownTransportMarkup}`, @@ -1253,12 +1254,10 @@ async function check(page, options, collected) { (await page.locator('#playback-hint').isHidden()) && (await page.locator('#playback-reason').isVisible()), }) + // The one-event history's stop is its first event (F305). const nodeStop = { sequence: 2, - type: 'stop', - index: 0, - eventIndex: 1, - elapsedMs: 1200, + eventIndex: 0, sourceAvailable: true, debuggerAvailable: true, } @@ -1341,6 +1340,7 @@ async function check(page, options, collected) { }, }) const nodeSourceMismatch = await nodeMomentLines() + // Value: protects=ending the native session of a saved stop keeps the Study section's position line, and only a real source mismatch shows the unavailable-source and not-recorded lines; fails_when=a session that ended is told as a missing source, or a real mismatch shows no line; why_new=PR-B1 moved these lines into the section (the String catalog) and changed this case's expectations; seam=none; no_claim=the host's verification of the stop's source (the workspace tests) (stand-ins: the Study messages the host posts) cases.push({ 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 }, @@ -1422,6 +1422,136 @@ async function check(page, options, collected) { inspectAfterSessionEnd.label === 'Inspect in native debugger' && inspectAfterSessionEnd.disabled === false, }) + // Value: protects=a selected stop whose values are not loaded offers Inspect in native debugger beside the hint and Show Variables (DS6), not only in the collapsed event list, and a press asks the host to select that stop; the control leaves with the hint once values load and is not offered for a stop whose captured source does not match; fails_when=the row has no Inspect once the session ended, it shows an action while the debugger already shows the stop, it stays after the values load or for a source mismatch, or its press sends another stop or nothing; why_new=F121: Inspect lived in the collapsed event list only; why_changed=F348 (PR-2 /ship Step 11 Codex adversarial): the case published a state the host never sends (no debugger with values not loaded); the host's two states are now checked: the debugger showing the stop with values not loaded (pressed), and the session ended (offered); seam=none; no_claim=the host's selection of the stop (studyWorkspace tests) (stand-ins: the Study messages the host posts and the pane's sent messages) + // Arrange + const rowInspect = page.locator('#node-history-inspect') + const rowInspectState = async () => ({ + visible: await rowInspect.isVisible(), + label: (await rowInspect.textContent()) ?? '', + followsShowVariables: await rowInspect.evaluate( + (button) => + button.previousElementSibling?.id === 'node-history-show-variables', + ), + }) + // Act: the debugger shows the stop and its values are not loaded; then the learner ends the native session. + await publish({ + ...nodeReader, + nodeHistoryPosition: { + ...nodeStop, + debuggerAvailable: true, + nativeValuesUninspected: true, + }, + }) + const rowInspectWhileShown = { + ...(await rowInspectState()), + pressed: await rowInspect.getAttribute('aria-pressed'), + } + await publish({ + ...nodeReader, + nodeHistoryPosition: { + ...nodeStop, + debuggerAvailable: false, + nativeValuesUninspected: false, + }, + }) + const rowInspectOffered = await rowInspectState() + await page.evaluate(() => { + window.sent.length = 0 + }) + await rowInspect.click() + const rowInspectRequests = await page.evaluate(() => + window.sent.filter((message) => message.type === 'node-history-stop'), + ) + await publish({ + ...nodeReader, + nodeHistoryPosition: { + ...nodeStop, + debuggerAvailable: true, + nativeValuesUninspected: false, + }, + }) + const rowInspectAfterValues = await rowInspectState() + await publish({ + ...nodeReader, + nodeHistorySection: unverifiedNodeSection, + nodeHistoryPosition: { + ...nodeStop, + sourceAvailable: false, + debuggerAvailable: false, + nativeValuesUninspected: true, + }, + }) + const rowInspectForMismatch = await rowInspectState() + // Assert + cases.push({ + name: 'a selected Node stop shows the debugger state beside Show Variables while its values are not loaded, and offers Inspect in native debugger once the session ended, whose press selects that stop (F348)', + actual: { + rowInspectWhileShown, + rowInspectOffered, + rowInspectRequests, + rowInspectAfterValues, + rowInspectForMismatch, + }, + passed: + rowInspectWhileShown.visible && + rowInspectWhileShown.label === 'Debugger stop selected' && + rowInspectWhileShown.pressed === 'true' && + rowInspectOffered.visible && + rowInspectOffered.label === 'Inspect in native debugger' && + rowInspectOffered.followsShowVariables && + rowInspectRequests.length === 1 && + rowInspectRequests[0].sequence === 2 && + !rowInspectAfterValues.visible && + !rowInspectForMismatch.visible, + }) + // Value: protects=the row's Inspect in native debugger follows the selected stop, not the page of the event list: once the learner pages the list away from the stop, the row still offers Inspect for that stop and a press selects it; fails_when=the row looks for the selected stop among the shown page's events, so it hides the control while another page of events shows; why_new=F353 (hosted CodeRabbit on #120); seam=none; no_claim=the host's paging and its selection of the stop (studyWorkspace tests) (stand-ins: the Study messages the host posts and the pane's sent messages) + // Arrange: the selected stop is event 1 of 26; the event list shows the second page, which holds outputs only. + const laterPage = { + ...nodeReader, + nodeHistoryEventCount: 26, + nodeHistoryPage: 1, + nodeHistoryPageCount: 2, + nodeHistory: { + ...nodeReader.nodeHistory, + entries: [ + { + type: 'output', + sequence: 27, + channel: 'stdout', + elapsedMs: 4800, + text: 'page two output', + }, + ], + }, + nodeHistoryUnverifiedStops: [], + } + // Act: the learner ended the native session, then pages the list away from the stop. + await publish({ + ...laterPage, + nodeHistoryPosition: { + ...nodeStop, + debuggerAvailable: false, + nativeValuesUninspected: false, + }, + }) + const rowInspectOnLaterPage = await rowInspectState() + await page.evaluate(() => { + window.sent.length = 0 + }) + if (rowInspectOnLaterPage.visible) await rowInspect.click() + const rowInspectOnLaterPageRequests = await page.evaluate(() => + window.sent.filter((message) => message.type === 'node-history-stop'), + ) + // Assert + cases.push({ + name: 'the row offers Inspect in native debugger for the selected stop while the event list shows another page, and its press selects that stop (F353)', + actual: { rowInspectOnLaterPage, rowInspectOnLaterPageRequests }, + passed: + rowInspectOnLaterPage.visible && + rowInspectOnLaterPage.label === 'Inspect in native debugger' && + rowInspectOnLaterPageRequests.length === 1 && + rowInspectOnLaterPageRequests[0].sequence === 2, + }) // Act: the selected stop's captured source no longer matches its recorded hash. await publish({ ...nodeReader, @@ -1683,7 +1813,6 @@ async function check(page, options, collected) { nodeHistoryPosition: { ...nodeStop, sequence: 3, - index: 1, eventIndex: 1, debuggerAvailable: false, }, @@ -1872,7 +2001,12 @@ async function check(page, options, collected) { } 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') => ({ + const testReplayAt = ( + position, + section, + mode = 'paused', + positionMs = 0, + ) => ({ ...nodeReader, nodeHistoryEventCount: 4, nodeHistory: { @@ -1886,28 +2020,27 @@ async function check(page, options, collected) { nodeHistoryPlayback: { mode, sequence: position.sequence, - positionMs: position.elapsedMs, + positionMs, startMs: 100, endMs: 900, loading: false, }, }) const atFirstOutput = testReplayAt( - { sequence: 1, type: 'output', eventIndex: 0, elapsedMs: 100 }, + { sequence: 1, eventIndex: 0 }, { position: 'Output · 0.10 s', notRecorded: notRecordedLine, previousStop: false, nextStop: true, }, + 'paused', + 100, ), atStop = testReplayAt( { sequence: 3, - type: 'stop', - index: 0, eventIndex: 2, - elapsedMs: 420, sourceAvailable: true, debuggerAvailable: true, }, @@ -1919,9 +2052,11 @@ async function check(page, options, collected) { previousStop: false, nextStop: false, }, + 'paused', + 420, ), atResult = testReplayAt( - { sequence: 4, type: 'result', eventIndex: 3, elapsedMs: 900 }, + { sequence: 4, eventIndex: 3 }, { position: 'Result · Test passed · exit code 0 · 0.90 s', explanation: 'The job ran after its timer, so the test passed.', @@ -1930,6 +2065,8 @@ async function check(page, options, collected) { previousStop: true, nextStop: false, }, + 'paused', + 900, ) /** Reads the section's lines, the step marked current and the steps marked as the same recorded moment. */ const replaySectionLines = () => @@ -1967,6 +2104,8 @@ async function check(page, options, collected) { await publish(atResult) const resultLines = await replaySectionLines() // Assert + // Moves with D8 (the later region-2 composition): this Study-pane pin follows the test replay section to wherever D8 places it. + // Value: protects=the 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 as the same recorded moment; fails_when=a moment shows another moment's line, two steps read as current, or the same-moment note names the wrong step; why_new=PR-B1 (Design 8, DS21) draws the summary through the shared renderer with a selected step per event; seam=none; no_claim=how the host chooses the step (projectNodeTestSection's tests) (stand-ins: the Study messages of the job scheduler's replay at three moments) 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 }, @@ -1999,6 +2138,47 @@ async function check(page, options, collected) { }, }), }) + // Moves with D8 (the later region-2 composition): this Study-pane pin follows the test replay section to wherever D8 places it. + // Value: protects=an interrupted or partial test replay shows why its capture ended on its own line under the status, and the line takes no room (it is not displayed) when the capture has no reason; fails_when=the reason never reaches the page (the regression of PR-B1), it is shown for a complete capture, or the empty line keeps its margin; why_new=F130: the section lost the old status line's reason; seam=none; no_claim=how the host words the reason (projectNodeTestSection's tests) (stand-ins: the Study messages the host posts) + // Arrange + const readCaptureReason = () => + page.evaluate(() => { + const line = document.getElementById('node-history-capture-reason') + return { + text: line.textContent, + displayed: getComputedStyle(line).display !== 'none', + } + }) + // Act + await publish( + testReplayAt( + { sequence: 1, eventIndex: 0 }, + { + captureReason: + 'Capture interrupted: Interrupted test replay after its verified prefix: Error: EIO', + }, + 'paused', + 100, + ), + ) + const interruptedReason = await readCaptureReason() + await publish(atFirstOutput) + const completeReason = await readCaptureReason() + // Assert + cases.push({ + name: 'an interrupted test replay shows why its capture ended under the status, and a complete one shows no such line', + actual: { interruptedReason, completeReason }, + passed: + JSON.stringify({ interruptedReason, completeReason }) === + JSON.stringify({ + interruptedReason: { + text: 'Capture interrupted: Interrupted test replay after its verified prefix: Error: EIO', + displayed: true, + }, + completeReason: { text: '', displayed: false }, + }), + }) + // Moves with D8 (the later region-2 composition): this Study-pane pin follows the test replay section to wherever D8 places it. // Value: protects=the test replay's slider showing the whole recorded range as one unrecorded span with a tick at each verified stop and the catalog legend, and nothing for a one-event history; fails_when=the section's timeline is not drawn by the shared coverage drawing, the legend differs, or a one-event history shows a mark; why_new=Design 8's remaining part (gap mark at the first output), red first on the build without the shared drawing; seam=none; no_claim=native pixels or the browser transport (its golden DOM case) // Arrange: the replay at its first output, then a replay of one event, which has nothing to mark. await publish(atFirstOutput) @@ -2023,8 +2203,10 @@ async function check(page, options, collected) { const firstOutputCoverage = await readCoverage() await publish( testReplayAt( - { sequence: 1, type: 'output', eventIndex: 0, elapsedMs: 100 }, + { sequence: 1, eventIndex: 0 }, { timeline: undefined, previousStop: false, nextStop: false }, + 'paused', + 100, ), ) const oneEventCoverage = await readCoverage() @@ -2060,15 +2242,21 @@ async function check(page, options, collected) { }, }), }) + // Moves with D8 (the later region-2 composition): this Study-pane pin follows the test replay section to wherever D8 places it. // Value: protects=a closed Application pane is named in the test replay section with a way back: the row with Show recorded output appears, its click asks the host for the pane (`focus-app`), and the row goes once the pane is back; fails_when=the row stays hidden while the pane is closed, the button sends another action (`arrange`, which could not bring the pane back), or the row stays after the pane is back; why_new=Step 9 pass 1b's QA critical (Show recorded output could not bring the pane back) and pass 2's testing review: no DOM case showed the row or clicked it; seam=none; no_claim=that the host builds the pane again, which studyWorkspaceTestReplayOutput.test.ts proves // Arrange: the stop of the replay while the learner has closed the Application pane. const paneClosedLine = 'The Application pane is closed.' await publish( - testReplayAt(atStop.nodeHistoryPosition, { - ...atStop.nodeHistorySection, - applicationClosed: true, - pointer: paneClosedLine, - }), + testReplayAt( + atStop.nodeHistoryPosition, + { + ...atStop.nodeHistorySection, + applicationClosed: true, + pointer: paneClosedLine, + }, + 'paused', + 420, + ), ) const whenClosed = await page.evaluate(() => ({ rowShown: !document.getElementById('node-history-closed').hidden, @@ -2122,6 +2310,7 @@ async function check(page, options, collected) { atStop.nodeHistoryPosition, atStop.nodeHistorySection, 'playing', + 420, ), ) await page.locator('#node-history-playback-toggle').focus() @@ -2139,6 +2328,8 @@ async function check(page, options, collected) { })), ) // Assert + // Moves with D8 (the later region-2 composition): this Study-pane pin follows the test replay section to wherever D8 places it. + // Value: protects=the keyboard alone opens Step details, selects a step of the stop, moves by Next stop and Previous stop, seeks and pauses, each press sending the one request the control names; fails_when=a control cannot be pressed by keyboard or sends another request; why_new=PR-B1 (Design 8, DS17) rebuilt the transport and the summary; seam=none; no_claim=the host's answer to each request and the Application pane's output region (the next case reaches it) cases.push({ name: 'the keyboard alone opens Step details, selects a step of the stop, moves by Next stop and Previous stop, seeks and pauses a test replay (Design 8)', actual: keyboardRequests, @@ -2152,6 +2343,179 @@ async function check(page, options, collected) { { type: 'node-history-playback', action: 'pause' }, ]), }) + // Moves with D8 (the later region-2 composition): this Study-pane pin follows the test replay section to wherever D8 places it. + // Value: protects=the keyboard's whole path through a test replay (DS17): Open the replay from Saved studies (the list's own key handling is the browser's), then Step details, a step, Next stop, Previous stop, a seek and Pause in the Study pane, and then the Application pane's output region, which the first Tab of the document reaches and a page key scrolls; fails_when=choosing the replay by keyboard asks the host for another entry or nothing, a step of the path is not reached by Tab presses (a control outside the tab order passes a script focus, F295), or the output region is no tab stop, has no name or does not scroll by keyboard; why_new=F122 (AC13 path): the keyboard case began after Open and never reached the output region; seam=none; no_claim=the host's answer to each request (the workspace tests) (stand-ins: the pane's sent messages and a document of the Application pane in a page of its own) + // Arrange: a saved replay listed in Saved studies and none opened. + const savedReplays = [ + { + id: 'job-study', + savedAt: '2026-10-22T06:00:00.000Z', + profile: 'node-test', + format: 'node-test-history', + outcome: + 'Test replay · Test passed · exit code 0 · capture complete · 1 of 1 stops explained', + metadata: 'validated', + }, + { + id: 'other-replay', + savedAt: '2026-10-21T06:00:00.000Z', + profile: 'node-test', + format: 'node-test-history', + outcome: + 'Test replay · Test failed · exit code 1 · capture complete · 0 of 1 stops explained', + metadata: 'validated', + }, + ], + savedReplayCatalog = [ + { studyId: 'job-study' }, + { studyId: 'other-replay' }, + ] + await publish({ + ...options.message, + revision: revisionAt(5), + studies: savedReplays, + nodeHistoryCatalog: savedReplayCatalog, + }) + // The replay chosen below, at a moment: Saved studies still lists both replays, and the history shown is the chosen one (F306). + const openedAt = (at) => ({ + ...at, + studies: savedReplays, + nodeHistoryCatalog: savedReplayCatalog, + nodeHistory: { ...at.nodeHistory, header: { studyId: 'other-replay' } }, + }) + await page.evaluate(() => { + window.sent = [] + }) + // Act: Open by keyboard, then the path inside the Study pane. + await page.locator('#studies').focus() + const sentHistory = () => + page.evaluate(() => + window.sent + .filter((message) => message.type === 'node-history') + .map(({ type, studyId }) => ({ type, studyId })), + ) + // The native list's own key handling is the browser's (it opens a popup on some systems); the choice is made as the list reports it. + await page.selectOption('#studies', 'other-replay') + const openRequests = await sentHistory() + const reachableControls = {}, + listedWhileOpen = [] + for (const [name, selector, at] of [ + [ + 'stepDetails', + '#node-learning-steps-details > summary', + atFirstOutput, + ], + ['nextStop', '#node-history-stop-next', atFirstOutput], + ['previousStop', '#node-history-stop-previous', atResult], + ['playhead', '#node-history-playhead', atStop], + ['pause', '#node-history-playback-toggle', atStop], + ]) { + await publish(openedAt(at)) + listedWhileOpen.push({ + listed: await page + .locator('#studies option') + .evaluateAll((choices) => choices.map((choice) => choice.value)), + chosen: await page.locator('#studies').inputValue(), + }) + // The press count that reaches the control by Tab from the replay chooser (0 when it is outside the tab order or beyond the limit), never a script focus: a control with tabindex -1 or one that is hidden is not reached (F295). + await page.locator('#studies').focus() + let presses = 0 + for (let press = 1; press <= 120 && presses === 0; press++) { + await page.keyboard.press('Tab') + if ( + await page.evaluate( + (selector) => + document.activeElement === document.querySelector(selector), + selector, + ) + ) + presses = press + } + reachableControls[name] = presses > 0 + } + // Act: the output region of the Application pane, from the first Tab of its document. + const outputPage = await page.context().newPage() + await outputPage.setViewportSize({ width: 760, height: 360 }) + await outputPage.goto(options.url) + await outputPage.evaluate(() => { + window.savedState = undefined + window.acquireVsCodeApi = () => ({ + getState: () => window.savedState, + setState(state) { + window.savedState = state + }, + postMessage: () => {}, + }) + }) + await outputPage.setContent(options.html.application) + // The result's view holds enough lines to scroll. + await outputPage.evaluate((view) => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'node-history-output', + generation: 2, + view: { ...view, generation: 2 }, + }, + }), + ) + }, options.testReplayViews.result) + await outputPage.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ) + await outputPage.keyboard.press('Tab') + const outputRegion = await outputPage.evaluate(() => { + const region = document.getElementById('test-output') + region.scrollTop = 0 + return { + focused: document.activeElement === region, + role: region.getAttribute('role'), + named: Boolean(region.getAttribute('aria-label')), + } + }) + await outputPage.keyboard.press('PageDown') + // The browser scrolls a key press smoothly: wait for the scroll itself, bounded, never a fixed time (F312); the case reads the outcome either way. + await outputPage + .waitForFunction( + () => document.getElementById('test-output').scrollTop > 0, + undefined, + { timeout: 5_000 }, + ) + .catch(() => undefined) + const outputScrolledByKey = await outputPage.evaluate( + () => document.getElementById('test-output').scrollTop > 0, + ) + await outputPage.close() + // Assert + cases.push({ + name: 'the keyboard reaches a test replay from Open through Step details, Next stop, Previous stop, a seek and Pause to the Application pane’s output region (DS17)', + actual: { + openRequests, + listedWhileOpen, + reachableControls, + outputRegion, + outputScrolledByKey, + }, + passed: + JSON.stringify(openRequests) === + JSON.stringify([ + { type: 'node-history', studyId: 'other-replay' }, + ]) && + listedWhileOpen.every( + ({ listed, chosen }) => + listed.includes('job-study') && + listed.includes('other-replay') && + chosen === 'other-replay', + ) && + Object.values(reachableControls).every(Boolean) && + outputRegion.focused && + outputRegion.role === 'region' && + outputRegion.named && + outputScrolledByKey, + }) // 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 = () => @@ -2182,6 +2546,7 @@ async function check(page, options, collected) { await publish(atResult) const movesAtTheResult = await enabledMoves() // Assert + // Moves with D8 (the later region-2 composition): this Study-pane pin follows the test replay section to wherever D8 places it. // Value: protects=the 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)', @@ -2268,6 +2633,8 @@ async function check(page, options, collected) { details.open = false }) // Assert + // Moves with D8 (the later region-2 composition): this Study-pane pin follows the test replay section to wherever D8 places it. + // Value: protects=the section never widens the page at 320, 480 or 760 px, keeps the learning title to two lines and lets the diagram scroll inside itself; fails_when=a long title, a wide diagram or a long event line widens the page; why_new=PR-B1 (Design 8, DS19) added the section's widths to the pane; seam=none; no_claim=native pixels (a screenshot is kept beside the result) 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, @@ -2279,6 +2646,216 @@ async function check(page, options, collected) { reading.titleLines <= 2, ), }) + // Moves with D8 (the later region-2 composition): this Study-pane pin follows the test replay section to wherever D8 places it. + // Value: protects=the test replay's learning title, clamped to two lines, follows the browser summary's title rule: a cut title is a tab stop that is shown whole while it has the keyboard focus, a title that fits is no tab stop, and the tooltip keeps the whole sentence; fails_when=a cut title can be read only from its hover tooltip, it is not shown whole on focus, or a fitting title takes a tab stop; why_new=F144 (pass 3 maintainability): unlike #learning-title, #node-learning-title had no tab stop; seam=none; no_claim=the Application header's test line (its case follows the Application widths) + // Arrange: the stop's section at 320 px with a long learning title, then with its short one. + const longTitle = + 'How the job scheduler delays a job until its timer fires and then reports the result of the test to the caller' + const titleStop = (title) => ({ + ...atStop, + nodeHistoryLearningSummary: { + ...atStop.nodeHistoryLearningSummary, + content: { ...atStop.nodeHistoryLearningSummary.content, title }, + }, + }) + const readLearningTitle = async () => ({ + tabindex: await page + .locator('#node-learning-title') + .getAttribute('tabindex'), + lines: await renderedLines(page, '#node-learning-title'), + tooltip: await page + .locator('#node-learning-title') + .getAttribute('title'), + }) + // Act + await page.setViewportSize({ width: 320, height: 720 }) + await publish(titleStop(longTitle)) + const clampedLearningTitle = await readLearningTitle() + await page.locator('#node-learning-title').focus() + const focusedLearningTitle = await readLearningTitle() + await page.evaluate(() => document.activeElement.blur()) + await publish(titleStop('Delayed job')) + const fittingLearningTitle = await readLearningTitle() + await page.setViewportSize({ width: 600, height: 800 }) + // Assert + cases.push({ + name: 'a test replay’s learning title that the two-line clamp cuts is a tab stop shown whole on focus, and a title that fits is no tab stop', + actual: { + clampedLearningTitle, + focusedLearningTitle, + fittingLearningTitle, + }, + passed: + clampedLearningTitle.tabindex === '0' && + clampedLearningTitle.lines === 2 && + clampedLearningTitle.tooltip === longTitle && + focusedLearningTitle.lines > 2 && + fittingLearningTitle.tabindex === null && + fittingLearningTitle.lines === 1, + }) + // Moves with D8 (the later region-2 composition): this Study-pane pin follows the test replay section to wherever D8 places it. + // Value: protects=markup in a test replay's learning material (title, question, summary, participants, step labels and explanations) is shown as text in the Study section and never becomes an element or runs; fails_when=an agent-written `` or ``, + participants: [ + { id: 'test', label: `Test ${hostile}` }, + { id: 'scheduler', label: 'schedule' }, + ], + steps: atStop.nodeHistoryLearningSummary.content.steps.map( + (step) => ({ + ...step, + label: `${step.label} ${hostile}`, + explanation: `${step.explanation} bold`, + }), + ), + }, + positions: atStop.nodeHistoryLearningSummary.positions, + }, + } + await page.evaluate(() => { + window.invented = undefined + }) + // Act + await publish(hostileStop) + const hostileReading = await page.evaluate(() => { + const block = document.getElementById('node-history-learning') + return { + elements: block.querySelectorAll('img, script, b').length, + invented: window.invented === true, + title: document.getElementById('node-learning-title').textContent, + summary: document.getElementById('node-learning-summary').textContent, + explanation: block.querySelector('#node-learning-steps p') + ?.textContent, + } + }) + await publish(atStop) + // Assert + cases.push({ + name: 'markup in a test replay’s learning material is shown as text in the Study section and never becomes an element', + actual: hostileReading, + passed: + hostileReading.elements === 0 && + !hostileReading.invented && + hostileReading.title === `Title ${hostile}` && + hostileReading.summary === + 'Summary ' && + hostileReading.explanation?.endsWith(' bold') === true, + }) + // Moves with D8 (the later region-2 composition): this Study-pane pin follows the test replay section to wherever D8 places it. + // Value: protects=the sticky test replay transport is counted in the pane's focus scroll room, so a control reached by Shift+Tab (Step details, a step, the learner's choice, Events) is never scrolled under it; the transport sticks below the live strip of a running study, not over it; a transport taller than the fraction of the pane that may stay fixed flows with the page; fails_when=a focused control's top lies under the transport (focus obscured, also with the live strip pinned above it at 960 x 600, F294), the transport and the live strip cover each other, or a pane too short for a fixed bar still pins it; why_new=F128 (pass 1b red team): --codebook-sticky-height measured the live strip only and the transport had no flowing fallback; seam=none; no_claim=native pixels (stand-ins: the Study message of a stop at 960 x 420 px, real Shift+Tab presses) + // Arrange: the stop's section in a short pane with Step details and the event list open, scrolled to the end. + await page.setViewportSize({ width: 960, height: 420 }) + await publish(atStop) + await page.evaluate(() => { + document.getElementById('node-learning-steps-details').open = true + document.getElementById('node-history-events-details').open = true + }) + await publish(atStop) + const readStickyBars = () => + page.evaluate(() => { + const transport = document.getElementById('node-history-transport'), + strip = document.querySelector('.live-strip'), + transportBox = transport.getBoundingClientRect(), + focusedBox = document.activeElement.getBoundingClientRect() + return { + position: getComputedStyle(transport).position, + transportTop: Math.round(transportBox.top), + transportBottom: Math.round(transportBox.bottom), + stripBottom: strip + ? Math.round(strip.getBoundingClientRect().bottom) + : 0, + stripSticky: strip + ? getComputedStyle(strip).position === 'sticky' + : false, + focusedId: document.activeElement.id, + focusedInTransport: transport.contains(document.activeElement), + focusedTop: Math.round(focusedBox.top), + } + }) + // Act: Shift+Tab from the event list back to Step details, reading where each focused control lies. + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ) + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)) + await page.locator('#node-history-events-details > summary').focus() + const focusReadings = [await readStickyBars()] + for (let press = 0; press < 12; press++) { + await page.keyboard.press('Shift+Tab') + const reading = await readStickyBars() + focusReadings.push(reading) + if (reading.focusedInTransport) break + } + // Act: a short pane flows the transport, a taller one pins it again, and a running study keeps the transport below its strip. + await page.setViewportSize({ width: 960, height: 150 }) + await publish(atStop) + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ) + const inShortPane = await readStickyBars() + await page.setViewportSize({ width: 960, height: 600 }) + await publish({ ...atStop, liveState: 'running' }) + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)) + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ) + const withRunningStudy = await readStickyBars() + // Act: with both bars pinned (the live strip and the transport at 960 x 600), Shift+Tab from the event list back toward the transport, reading each focused control's clearance (F294). + await page.locator('#node-history-events-details > summary').focus() + const withBothBars = [await readStickyBars()] + for (let press = 0; press < 12; press++) { + await page.keyboard.press('Shift+Tab') + const reading = await readStickyBars() + withBothBars.push(reading) + if (reading.focusedInTransport) break + } + await publish({ ...atStop, liveState: undefined }) + await page.setViewportSize({ width: 600, height: 800 }) + // Assert + const scrolledBelowBothBars = withBothBars.filter( + (reading) => !reading.focusedInTransport, + ) + const scrolledBelowTransport = focusReadings.filter( + (reading) => !reading.focusedInTransport, + ) + cases.push({ + name: 'a control reached by Shift+Tab is never scrolled under the test replay transport, a pane too short for a fixed bar flows it, and a running study keeps it below the live strip', + actual: { focusReadings, inShortPane, withRunningStudy, withBothBars }, + passed: + scrolledBelowTransport.length >= 3 && + scrolledBelowTransport.every( + (reading) => + reading.position === 'sticky' && + reading.transportTop === 0 && + reading.focusedTop >= reading.transportBottom - 1, + ) && + inShortPane.position === 'static' && + withRunningStudy.stripSticky && + withRunningStudy.transportTop >= withRunningStudy.stripBottom - 1 && + // Both bars pinned: the focused control lies below the lower of the two (a focus scroll room without the strip scrolls it under the transport). + scrolledBelowBothBars.length >= 3 && + scrolledBelowBothBars.every( + (reading) => + reading.stripSticky && + reading.focusedTop >= reading.transportBottom - 1, + ), + }) // Arrange: the section's states before it has a replay or an event to show (DS7), in the pane's fixed words (the String catalog): no replay saved, replays saved and none chosen, then a replay with no event selected and no saved learning, then the same replay with its learning again. /** 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 = () => @@ -2332,6 +2909,7 @@ async function check(page, options, collected) { learning: 'shown', drawn: 2, } + // Moves with D8 (the later region-2 composition): this Study-pane pin follows the test replay section to wherever D8 places it. // Value: protects=the 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)', @@ -2378,19 +2956,16 @@ async function check(page, options, collected) { 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. + // Arrange: the Application pane's document of the same replay at its first output (Design 3), in a page of its own; the stand-in for the webview API records what the document tells the host (D22); 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), + getState: () => undefined, + setState() {}, + postMessage: (message) => window.sent.push(structuredClone(message)), }) }) await applicationPage.setContent(options.html.application) @@ -2469,14 +3044,13 @@ async function check(page, options, collected) { output.scrollHeight - output.scrollTop - output.clientHeight <= 2, jumpShown: !document.getElementById('test-output-jump').hidden, focused: document.activeElement?.id, - follow: window.savedState?.testOutputFollow, + follow: window.sent + .filter((message) => message.type === 'node-history-output-state') + .at(-1)?.follow, } }) - // The webview state as the document left it at the result, for the reload case below. - const stateAtResult = await applicationPage.evaluate( - () => window.savedState, - ) // Assert + // Value: protects=the Application document shows only the output up to its moment, and an in-place update to the result keeps the learner's scroll position, text selection and focus, ignores an older view and returns to the end with Jump to latest; fails_when=an update rewrites the page (the selection or focus is lost), an older view overwrites a newer one, or the end cannot be reached again; why_new=PR-B1 (Design 8, EN3) posts each later moment into one document; seam=none; no_claim=VS Code's webview (stand-ins: the page's message event and a stub of the webview API) 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 }, @@ -2522,7 +3096,11 @@ async function check(page, options, collected) { }) } // 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 longUpdate = await applicationPage.evaluate(async () => { + const frames = () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ) const lines = Array.from({ length: 32768 }, (_, index) => ({ channel: 'stdout', text: String(index % 10), @@ -2545,20 +3123,24 @@ async function check(page, options, collected) { }), ) post(10, lines) + await frames() const started = performance.now() post(11, [ ...lines.slice(1), { channel: 'stdout', text: 'last', number: 33768 }, ]) + await frames() + const elapsed = performance.now() - started 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, + underASecond: elapsed < 1000, lines: document.querySelectorAll('#test-output .output-line').length, last: document.querySelector('#test-output .output-line:last-child') ?.textContent, } }) // Assert + // Value: protects=an in-place update of the longest recorded output (32,768 one-character lines, then one line dropped and one added) matches lines by their number and takes under a second; fails_when=the update rebuilds the output or matches lines in quadratic time, so Play on a long output stalls the pane; why_new=PR-B1 (EN3): the update path is new and its cost is bounded by this case; seam=the page's performance clock; no_claim=the speed of another machine (the bound is a second, not a benchmark) 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, @@ -2567,276 +3149,669 @@ 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( + // Arrange: Application documents the way the editor loads them, each in a page of its own (a webview loads a new document in a fresh page), with a stand-in for the webview API that records what the document tells the host. + /** Waits two frames, so a view queued for the next frame is drawn and the layout has settled. */ + const settleFrames = (target) => + target.evaluate( () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)), ), ) - const reading = await writtenPage.evaluate(() => { + /** Opens an Application document in a fresh page; `prepare` runs in the page before the document is set. */ + const openApplication = async (html, prepare) => { + const documentPage = await page.context().newPage() + await documentPage.setViewportSize({ width: 760, height: 360 }) + await documentPage.goto(options.url) + await documentPage.evaluate(() => { + window.sent = [] + window.acquireVsCodeApi = () => ({ + // State an earlier document of the replay left in the webview: the document must not read it (D22). + getState: () => window.priorState, + setState() {}, + postMessage: (message) => + window.sent.push(structuredClone(message)), + }) + }) + if (prepare) await documentPage.evaluate(prepare) + await documentPage.setContent(html) + await settleFrames(documentPage) + return documentPage + } + /** Posts a message into a document as the host does. */ + const postToDocument = (target, data) => + target.evaluate( + (message) => + window.dispatchEvent( + new MessageEvent('message', { data: message }), + ), + data, + ) + /** Reads where a document stands: its header time, whether it is at the end of the output, whether Jump to latest shows, the first line in view with how far its top is above the region's, the last reported state and the scroll position. */ + const readDocument = (target) => + target.evaluate(() => { const output = document.getElementById('test-output') + const regionTop = output.getBoundingClientRect().top + const firstInView = Array.from(output.children).find( + (line) => line.getBoundingClientRect().bottom > regionTop, + ) + const reports = window.sent.filter( + (message) => message.type === 'node-history-output-state', + ) return { + time: document.getElementById('test-output-time').textContent, atEnd: output.scrollHeight - output.scrollTop - output.clientHeight <= 2, jumpShown: !document.getElementById('test-output-jump').hidden, + firstLine: Number(firstInView?.dataset.line), + offsetPx: Math.round( + regionTop - firstInView.getBoundingClientRect().top, + ), + lastReport: reports.at(-1) && { + follow: reports.at(-1).follow, + anchorLine: reports.at(-1).anchor?.line, + }, + reportCount: reports.length, + scrollTop: output.scrollTop, } }) - 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 + /** Gives the document's HTML another reading of the learner, as the host writes a document of the same replay. */ + const withReading = (html, reading) => + html.replace( + '"reading":{"follow":true}', + `"reading":${JSON.stringify(reading)}`, + ) + const learnerScrolledTo = (target, top) => + target.evaluate((scrollTop) => { + document.getElementById('test-output').scrollTop = scrollTop + }, top) + const resultView = options.testReplayViews.result + // Value: protects=a document written for a replay opens where the host says the learner was, and ignores state an earlier document left in the webview: a stored choice not to follow does not outlive the host's choice to follow (F151), a choice not to follow with a scroll anchor opens at that line with Jump to latest, and the learner's place is found again by the line's number and offset (F120, F138); fails_when=the document reads the webview state (it opens at the top of the output for a replay the learner left following), drops the anchor (it opens at the oldest kept line), or follows the output although the host kept the learner reading higher up; why_new=D22 moves the follow choice and the scroll anchor from the webview's state to the host, which passes them in the document's options; seam=none; no_claim=how the host files the reading (the workspace tests), VS Code's own webview state, which the stand-in plays + // Act: three documents of the same replay at its result: the host's reading is following (with stale webview state saying otherwise), not following at line 20 and 7 px, and the reading a scroll in a first document reported. + const followingPage = await openApplication( + options.html.applicationAtResult, + () => { + window.priorState = { + testOutputFollow: false, + testOutputHistory: 'an earlier document of this replay', + } + }, + ) + const followingDespitePriorState = await readDocument(followingPage) + await followingPage.close() + const anchoredPage = await openApplication( + withReading(options.html.applicationAtResult, { + follow: false, + anchor: { line: 20, offsetPx: 7 }, + }), + ) + const openedAtAnchor = await readDocument(anchoredPage) + await anchoredPage.close() + const readingPage = await openApplication( + options.html.applicationAtResult, + ) + await learnerScrolledTo(readingPage, 250) + await settleFrames(readingPage) + const whereLearnerRead = await readDocument(readingPage) + const reported = await readingPage.evaluate(() => + window.sent + .filter((message) => message.type === 'node-history-output-state') + .at(-1), + ) + await readingPage.close() + const restoredPage = await openApplication( + withReading(options.html.applicationAtResult, { + follow: reported.follow, + anchor: reported.anchor, + }), + ) + const restoredReading = await readDocument(restoredPage) + await restoredPage.close() + // Assert + cases.push({ + name: 'an Application document opens where the host says the learner was: following, at a scroll anchor, or where the learner’s own scroll had been reported, and ignores state an earlier document left (D22, F120, F138, F151)', + actual: { + followingDespitePriorState, + openedAtAnchor, + whereLearnerRead, + reported, + restoredReading, + }, + passed: + followingDespitePriorState.atEnd && + !followingDespitePriorState.jumpShown && + followingDespitePriorState.time === 'Recorded test output · 0.90 s' && + !openedAtAnchor.atEnd && + openedAtAnchor.jumpShown && + openedAtAnchor.firstLine === 20 && + openedAtAnchor.offsetPx === 7 && + reported.follow === false && + reported.anchor.line === whereLearnerRead.firstLine && + restoredReading.firstLine === whereLearnerRead.firstLine && + Math.abs(restoredReading.offsetPx - whereLearnerRead.offsetPx) <= 1 && + restoredReading.jumpShown, + }) + // Value: protects=a document that the editor reloads in place from an earlier write (Developer: Reload Webviews) tells the host it is ready with the generation it shows, and the host's answer with its current view and reading brings it to the host's moment and the learner's place; fails_when=the document sends no ready message (the host cannot tell its view is older), ignores the answer's view or reading, or draws it above an older generation; why_new=F141: the reloaded document showed an earlier moment until the next move; seam=none; no_claim=the host's decision to answer, which the workspace tests pin + // Act: a document written with the first moment; the host answers its ready message with the result and a reading not following at line 12 and 4 px. + const readyPage = await openApplication(options.html.application) + const readyMessage = await readyPage.evaluate(() => + window.sent.find( + (message) => message.type === 'node-history-output-ready', + ), + ) + const beforeAnswer = await readDocument(readyPage) + await postToDocument(readyPage, { + type: 'node-history-output', + generation: 2, + view: resultView, + reading: { follow: false, anchor: { line: 12, offsetPx: 4 } }, + }) + await settleFrames(readyPage) + const afterAnswer = await readDocument(readyPage) + await readyPage.close() + // Assert + cases.push({ + name: 'an Application document tells the host it is ready, and the host’s answer brings it to the current moment and the learner’s place (F141)', + actual: { readyMessage, beforeAnswer, afterAnswer }, + passed: + readyMessage?.generation === 1 && + readyMessage.follow === true && + typeof readyMessage.history === 'string' && + beforeAnswer.time === 'Recorded test output · 0.10 s' && + afterAnswer.time === 'Recorded test output · 0.90 s' && + !afterAnswer.atEnd && + afterAnswer.jumpShown && + afterAnswer.firstLine === 12 && + afterAnswer.offsetPx === 4, + }) + // Value: protects=the host's reading, delivered with the answer to `ready`, survives another view posted in the same animation frame (Play posts up to twenty a second), so the learner's place is still taken; fails_when=the newer queued view replaces the answer whole and the document stays following at the end of the output; why_new=F290: a queued update replaced the queued one with its reading; seam=none; no_claim=the host's decision to answer, which the workspace tests pin + // Act: the answer (a result view with a reading not following at line 12 and 4 px) and a newer view without a reading arrive in the same task. + const queuedReadingPage = await openApplication(options.html.application) + await queuedReadingPage.evaluate((result) => { + const post = (data) => + window.dispatchEvent(new MessageEvent('message', { data })) + post({ + type: 'node-history-output', + generation: 2, + view: { ...result, generation: 2 }, + reading: { follow: false, anchor: { line: 12, offsetPx: 4 } }, + }) + post({ + type: 'node-history-output', + generation: 3, + view: { + ...result, + generation: 3, + time: 'Recorded test output · newer', }, - 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() + }, resultView) + await settleFrames(queuedReadingPage) + const afterQueuedReading = await readDocument(queuedReadingPage) + await queuedReadingPage.close() // Assert cases.push({ - name: 'an Application document loaded again from its first write shows the latest moment it had received (EN3)', - actual: reloaded, + name: 'the host’s reading in an answer survives a newer view posted in the same frame (F290)', + actual: { afterQueuedReading }, passed: - JSON.stringify(reloaded) === - JSON.stringify({ - time: 'Recorded test output · 0.90 s', - end: 'Test passed · exit code 0', - endShown: true, - }), - }) - // Value: protects=an Application document the editor loads again after the learner stopped following shows the latest moment it had received and keeps not following: it stays where it starts, offers Jump to latest and keeps the choice in its state; fails_when=the reloaded document drops the learner's choice and scrolls to the end, hides Jump to latest, or shows its first moment; why_new=Step 9 pass 2's state-machines review: the reload case reloaded only a following document; 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 with the state the document kept at the result, the learner not following. - const reloadedNotFollowingPage = await page.context().newPage() - await reloadedNotFollowingPage.setViewportSize({ - width: 760, - height: 360, - }) - await reloadedNotFollowingPage.goto(options.url) - await reloadedNotFollowingPage.evaluate( - (state) => { - window.savedState = state - window.acquireVsCodeApi = () => ({ - getState: () => window.savedState, - setState(next) { - window.savedState = next - }, - postMessage() {}, - }) - }, - { ...stateAtResult, testOutputFollow: false }, + afterQueuedReading.time === 'Recorded test output · newer' && + !afterQueuedReading.atEnd && + afterQueuedReading.jumpShown && + afterQueuedReading.firstLine === 12 && + afterQueuedReading.offsetPx === 4, + }) + // Value: protects=an answer to `ready` that the host built before the learner scrolled does not pull the learner back: the scroll after `ready` is fresher than the reading the answer carries; fails_when=the answer's follow choice scrolls the output to its end under the learner; why_new=F290: the answer overrode a scroll made after `ready`; seam=none; no_claim=a reading the host files after the scroll's report, which agrees with the scroll + // Act: the document at the result follows the end; the learner scrolls up; then the answer built before that scroll (following) arrives with a newer view. + const staleAnswerPage = await openApplication( + options.html.applicationAtResult, ) - await reloadedNotFollowingPage.setContent(options.html.application) - await reloadedNotFollowingPage.evaluate( - () => - new Promise((resolve) => - requestAnimationFrame(() => requestAnimationFrame(resolve)), - ), + await learnerScrolledTo(staleAnswerPage, 250) + await settleFrames(staleAnswerPage) + const afterLearnerScroll = await readDocument(staleAnswerPage) + await postToDocument(staleAnswerPage, { + type: 'node-history-output', + generation: 5, + view: { ...resultView, generation: 5 }, + reading: { follow: true }, + }) + await settleFrames(staleAnswerPage) + const afterStaleAnswer = await readDocument(staleAnswerPage) + await staleAnswerPage.close() + // Assert + cases.push({ + name: 'an answer built before the learner’s scroll does not override that scroll (F290)', + actual: { afterLearnerScroll, afterStaleAnswer }, + passed: + afterLearnerScroll.jumpShown && + !afterLearnerScroll.atEnd && + !afterStaleAnswer.atEnd && + afterStaleAnswer.jumpShown && + afterStaleAnswer.firstLine === afterLearnerScroll.firstLine && + afterStaleAnswer.lastReport?.follow === false, + }) + // Value: protects=a reading whose anchor line the view no longer holds (the kept output moved past it) opens the output at its start with Jump to latest, not where the document stood; fails_when=a missing anchor line leaves the region at the end the document followed, or follows again; why_new=F307 (PR-2 coverage audit): restoreAnchor's missing-line branch had no case; seam=none; no_claim=how the host chooses the anchor + // Act: the document at the result follows its end; the host's answer carries a reading not following, anchored at a line the view does not hold. + const missingAnchorPage = await openApplication( + options.html.applicationAtResult, ) - const reloadedNotFollowing = await reloadedNotFollowingPage.evaluate( - () => { - const output = document.getElementById('test-output') - return { - time: document.getElementById('test-output-time').textContent, - atEnd: - output.scrollHeight - output.scrollTop - output.clientHeight <= 2, - jumpShown: !document.getElementById('test-output-jump').hidden, - savedFollow: window.savedState?.testOutputFollow, - } - }, + const beforeMissingAnchor = await readDocument(missingAnchorPage) + await postToDocument(missingAnchorPage, { + type: 'node-history-output', + generation: 4, + view: { ...resultView, generation: 4 }, + reading: { follow: false, anchor: { line: 999_999, offsetPx: 7 } }, + }) + await settleFrames(missingAnchorPage) + const afterMissingAnchor = await readDocument(missingAnchorPage) + await missingAnchorPage.close() + // Assert + cases.push({ + name: 'a reading anchored at a line the view no longer holds opens the output at its start, not where the document followed (F307)', + actual: { beforeMissingAnchor, afterMissingAnchor }, + passed: + beforeMissingAnchor.atEnd && + beforeMissingAnchor.scrollTop > 0 && + afterMissingAnchor.scrollTop === 0 && + !afterMissingAnchor.atEnd && + afterMissingAnchor.jumpShown, + }) + // Value: protects=a document that does not follow keeps the line the learner reads where it stood when a later moment's kept output drops its earliest lines (the kept tail slides), whatever the webview's own scroll anchoring does; fails_when=the update only records the scroll position, so the lines dropped above move the learner's line up and the region shows later lines than the learner read; why_new=F354 (hosted CodeRabbit on #120); seam=the browser's scroll anchoring is turned off in the region (`overflow-anchor: none`), so the case sees the document's own anchor and not Chromium's; no_claim=Chromium's scroll anchoring in VS Code's and Cursor's webviews + // Arrange: the learner reads line 20, 7 px into it, not following. + const slidingPage = await openApplication( + withReading(options.html.applicationAtResult, { + follow: false, + anchor: { line: 20, offsetPx: 7 }, + }), ) - await reloadedNotFollowingPage.close() + await slidingPage.evaluate(() => { + document.getElementById('test-output').style.overflowAnchor = 'none' + }) + const beforeSlide = await readDocument(slidingPage) + // Act: the next moment keeps the output from line 10 on. + await postToDocument(slidingPage, { + type: 'node-history-output', + generation: 4, + view: { + ...resultView, + generation: 4, + lines: resultView.lines.filter((value) => value.number >= 10), + }, + }) + await settleFrames(slidingPage) + const afterSlide = await readDocument(slidingPage) + await slidingPage.close() // Assert cases.push({ - name: 'an Application document loaded again after the learner stopped following shows the latest moment and keeps Jump to latest (DS20, EN3)', - actual: reloadedNotFollowing, + name: 'a document that does not follow keeps the line the learner reads in place when the next moment drops the earliest kept lines (F354)', + actual: { beforeSlide, afterSlide }, passed: - JSON.stringify(reloadedNotFollowing) === - JSON.stringify({ - time: 'Recorded test output · 0.90 s', - atEnd: false, - jumpShown: true, - savedFollow: false, - }), + beforeSlide.firstLine === 20 && + beforeSlide.offsetPx === 7 && + afterSlide.firstLine === 20 && + afterSlide.offsetPx === 7 && + !afterSlide.atEnd && + afterSlide.jumpShown, + }) + // Value: protects=the document draws only the host's output updates: a message of another kind, or an update without a numeric generation or a view, changes nothing, and a later valid update still draws; fails_when=a malformed update is drawn (a string generation compares as a number and replaces the shown moment) or throws so that later updates stop; why_new=F308 (PR-2 coverage audit): the message filter (F304's isOutputUpdate) had no case; seam=none; no_claim=the host's own filter of the document's reports (readNodeTestOutputReport's unit test) + // Act: foreign and malformed messages, then a valid newer update. + const malformedPage = await openApplication(options.html.application) + const beforeMalformed = await readDocument(malformedPage) + for (const data of [ + null, + { type: 'node-history-output-state', follow: true }, + { type: 'node-history-output', view: resultView }, + { + type: 'node-history-output', + generation: '9', + view: { ...resultView, time: 'Recorded test output · forged' }, + }, + { type: 'node-history-output', generation: 9, view: null }, + ]) + await postToDocument(malformedPage, data) + await settleFrames(malformedPage) + const afterMalformed = await readDocument(malformedPage) + await postToDocument(malformedPage, { + type: 'node-history-output', + generation: 2, + view: { ...resultView, generation: 2 }, + }) + await settleFrames(malformedPage) + const afterValid = await readDocument(malformedPage) + await malformedPage.close() + // Assert + cases.push({ + name: 'messages that are not the host’s output update leave the Application document as it is, and a valid update after them is drawn (F308)', + actual: { beforeMalformed, afterMalformed, afterValid }, + passed: + beforeMalformed.time === 'Recorded test output · 0.10 s' && + afterMalformed.time === 'Recorded test output · 0.10 s' && + afterValid.time === 'Recorded test output · 0.90 s', + }) + // Value: protects=the cost of drawing a moment in the Application document: an update compares each kept line with the line the document drew, with no document query per line, and an unchanged line keeps its node; fails_when=each frame queries every kept line's text node (a `querySelector` per line, the cost F314 removed) or draws an unchanged line again; why_new=F314 (PR-2 /ship Step 9 performance): every frame walked the output and queried each line's text; seam=the page's Element.prototype.querySelector and querySelectorAll, counted while the update draws; no_claim=wall-clock time + // Act: the result's document with its first line's node marked; a newer view that changes only its last line, drawn while the document's queries are counted. + const queryCountPage = await openApplication( + options.html.applicationAtResult, + ) + await queryCountPage.evaluate(() => { + document.getElementById( + 'test-output', + ).firstElementChild.dataset.marked = 'kept' + window.queries = 0 + for (const method of ['querySelector', 'querySelectorAll']) { + const before = Element.prototype[method] + Element.prototype[method] = function (...input) { + window.queries++ + return Reflect.apply(before, this, input) + } + } }) - const anotherReplay = await followAfterWrite({ - testOutputFollow: false, - testOutputHistory: 'another-test-replay', + const lastLineNumber = resultView.lines.at(-1).number + await postToDocument(queryCountPage, { + type: 'node-history-output', + generation: 6, + view: { + ...resultView, + generation: 6, + lines: resultView.lines.map((line) => + line.number === lastLineNumber + ? { ...line, text: `${line.text} changed` } + : line, + ), + }, }) - const sameReplayKept = await followAfterWrite({ - testOutputFollow: false, - testOutputHistory: sameReplay, + await settleFrames(queryCountPage) + const drawnWithoutQueries = await queryCountPage.evaluate(() => { + // The count is read before anything else here can query. + const queries = window.queries + const output = document.getElementById('test-output') + return { + queries, + firstLineKept: output.firstElementChild.dataset.marked === 'kept', + lastLine: output.lastElementChild.textContent, + lines: output.children.length, + } }) + await queryCountPage.close() // 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 }, + name: 'a newer moment is drawn in the Application document without a query per kept line, keeping the unchanged lines’ nodes (F314)', + actual: { drawnWithoutQueries, lines: resultView.lines.length }, passed: - typeof sameReplay === 'string' && - JSON.stringify({ anotherReplay, sameReplayKept }) === - JSON.stringify({ - anotherReplay: { atEnd: true, jumpShown: false }, - sameReplayKept: { atEnd: false, jumpShown: true }, - }), - }) - // Value: protects=an Application document written again for the same replay with a newer moment keeps that moment over an older view its state kept (a document written again after the learner came back to the replay); fails_when=the kept view is applied whatever its generation, and the pane goes back to an older moment while the Study pane, the source and the debugger show the current one; why_new=Step 9 pass 2's testing review: only a newer kept view was tested; seam=none; no_claim=the host's generation counter, which only grows within a window - // Arrange: the state keeps the result's view at generation 0; the document is written with the first output at generation 1. - const olderKeptPage = await page.context().newPage() - await olderKeptPage.setViewportSize({ width: 760, height: 360 }) - await olderKeptPage.goto(options.url) - await olderKeptPage.evaluate( - (state) => { - window.savedState = state - window.acquireVsCodeApi = () => ({ - getState: () => window.savedState, - setState(next) { - window.savedState = next - }, - postMessage() {}, - }) - }, - { - testOutputHistory: sameReplay, - testOutputFollow: true, - testOutputView: { ...options.testReplayViews.result, generation: 0 }, - }, + drawnWithoutQueries.queries === 0 && + drawnWithoutQueries.firstLineKept && + drawnWithoutQueries.lastLine.endsWith(' changed\n') && + drawnWithoutQueries.lines === resultView.lines.length, + }) + // Value: protects=following is decided by the scroll position alone: a scroll to the end that moves nothing (a view of the same height) leaves no mark that swallows the learner's next scroll up, so Jump to latest shows, following is reported off and the next moment does not pull the output back to its end; fails_when=the document's scroll that moved nothing is remembered, the learner's scroll up is read as the document's own, and the next moment scrolls to the end (DS20); why_new=F106: `scrolledByDocument` stayed set when the scroll to the end caused no scroll event; seam=none; no_claim=a clamp to a shorter output, which the in-place case below pins + // Act: the result's output followed to its end; a view of the same height arrives (no scroll to make); the learner scrolls up; another view arrives. + const sameHeightPage = await openApplication( + options.html.applicationAtResult, ) - // Act - await olderKeptPage.setContent(options.html.application) - const olderKept = await olderKeptPage.evaluate(() => ({ - time: document.getElementById('test-output-time').textContent, - endShown: !document.getElementById('test-output-end').hidden, - })) - await olderKeptPage.close() + const changedLine = (generation, text) => ({ + ...resultView, + generation, + lines: resultView.lines.map((line) => + line.number === 1 ? { ...line, text } : line, + ), + }) + await postToDocument(sameHeightPage, { + type: 'node-history-output', + generation: 3, + view: changedLine(3, 'job 1 changed'), + }) + await settleFrames(sameHeightPage) + await sameHeightPage.evaluate(() => { + const output = document.getElementById('test-output') + output.scrollTop = output.scrollTop - 120 + }) + await settleFrames(sameHeightPage) + const afterScrollUp = await readDocument(sameHeightPage) + await postToDocument(sameHeightPage, { + type: 'node-history-output', + generation: 4, + view: changedLine(4, 'job 1 changed again'), + }) + await settleFrames(sameHeightPage) + const afterNextMoment = await readDocument(sameHeightPage) + await sameHeightPage.close() // Assert cases.push({ - name: 'an Application document written again for the same replay keeps its own newer moment over an older view its state kept (EN3)', - actual: olderKept, + name: 'a scroll up after a scroll to the end that moved nothing stops following, shows Jump to latest and is not undone by the next moment (DS20, F106)', + actual: { afterScrollUp, afterNextMoment }, passed: - JSON.stringify(olderKept) === - JSON.stringify({ - time: 'Recorded test output · 0.10 s', - endShown: false, - }), - }) - // Value: protects=while moments stream into a test replay's Application document (Play posts up to twenty a second), the document writes its state, which copies the whole shown output, once for the stream, and writes the last moment when it is left; fails_when=each posted moment writes the state again (twenty serialized copies of the output for twenty moments), or a document left after the stream keeps an older moment; why_new=Step 9 pass 2's core checklist and performance review (C3): the state write copied the whole view at every moment; seam=none; no_claim=VS Code's own state transport, which the stand-in imitates by serializing each write, and the timer's interval, which no assertion here waits for - // Arrange: a document whose state writes are counted and serialized, as VS Code's webview serializes them. - const streamPage = await page.context().newPage() - await streamPage.setViewportSize({ width: 760, height: 360 }) - await streamPage.goto(options.url) - await streamPage.evaluate(() => { - window.savedState = undefined - window.stateWrites = 0 - window.acquireVsCodeApi = () => ({ - getState: () => - window.savedState === undefined - ? undefined - : JSON.parse(window.savedState), - setState(next) { - window.stateWrites += 1 - window.savedState = JSON.stringify(next) - }, - postMessage() {}, - }) + afterScrollUp.jumpShown && + afterScrollUp.lastReport?.follow === false && + !afterNextMoment.atEnd && + afterNextMoment.jumpShown && + afterNextMoment.scrollTop === afterScrollUp.scrollTop, + }) + // Value: protects=a clamp of the scroll position caused by hiding the verdict and muted lines is the document's own: at the result, scrolled 6 px above the end, the next moment (the same output without the verdict) leaves following off and Jump to latest shown; fails_when=the clamp is read as the learner's return to the end and turns following on; why_new=F142 (not reproduced in Cycle 0049; the host's native check in VS Code is the driving agent's, D25); seam=none; no_claim=the native editor's own scrolling, which the native check covers + // Act: the learner scrolls to 6 px above the end of the result's output; the same lines arrive without the verdict. + const clampPage = await openApplication(options.html.applicationAtResult) + await clampPage.evaluate(() => { + const output = document.getElementById('test-output') + output.scrollTop = output.scrollHeight - output.clientHeight - 6 + }) + await settleFrames(clampPage) + const sixAboveEnd = await readDocument(clampPage) + const withoutVerdict = { ...resultView, generation: 3 } + delete withoutVerdict.end + await postToDocument(clampPage, { + type: 'node-history-output', + generation: 3, + view: withoutVerdict, + }) + await settleFrames(clampPage) + const afterClamp = await readDocument(clampPage) + await clampPage.close() + // Assert + cases.push({ + name: 'a clamp of the scroll position caused by hiding the verdict leaves following off (F142)', + actual: { sixAboveEnd, afterClamp }, + passed: + sixAboveEnd.jumpShown && + sixAboveEnd.lastReport?.follow === false && + afterClamp.jumpShown && + afterClamp.lastReport?.follow === false, + }) + // Value: protects=moments posted in a burst, as Play posts them (up to twenty a second), are drawn once per animation frame, the newest only, and an older one that arrives after is ignored; fails_when=each moment is drawn in full (twenty draws of the output for twenty moments), or the older view that came last wins; why_new=F140: a log-heavy replay fell behind Play's 50 ms tick; seam=Element.prototype.setAttribute, counted for the document's main region, which every draw sets once; no_claim=the draw's cost, which the long-output case bounds + // Act: twenty moments in one task, then one older. + const burstPage = await openApplication(options.html.application, () => { + window.draws = 0 + const setAttribute = Element.prototype.setAttribute + Element.prototype.setAttribute = function (name, value) { + if (this.id === 'test-output-main' && name === 'aria-label') + window.draws += 1 + return setAttribute.call(this, name, value) + } }) - await streamPage.setContent(options.html.application) - // Act: twenty moments posted in a row, as the host posts them during Play (each message is handled as it is dispatched); then the document is left. - const streamed = await streamPage.evaluate((result) => { + const drawsAtOpen = await burstPage.evaluate(() => window.draws) + await burstPage.evaluate((result) => { for (let generation = 2; generation <= 21; generation += 1) window.dispatchEvent( new MessageEvent('message', { data: { type: 'node-history-output', generation, - view: { ...result, generation }, + view: { + ...result, + generation, + time: `Recorded test output · burst ${generation}`, + }, }, }), ) - const whileStreaming = window.stateWrites - window.dispatchEvent(new Event('pagehide')) - return { - whileStreaming, - afterLeaving: window.stateWrites, - keptGeneration: JSON.parse(window.savedState).testOutputView - .generation, - } - }, options.testReplayViews.result) - await streamPage.close() + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'node-history-output', + generation: 5, + view: { ...result, generation: 5, time: 'older' }, + }, + }), + ) + }, resultView) + await settleFrames(burstPage) + const burst = await burstPage.evaluate(() => ({ + draws: window.draws, + time: document.getElementById('test-output-time').textContent, + })) + await burstPage.close() + // Assert + cases.push({ + name: 'moments posted in a burst are drawn once per frame, the newest only, and an older one that comes last is ignored (F140)', + actual: { drawsAtOpen, burst }, + passed: + drawsAtOpen === 1 && + burst.draws === 2 && + burst.time === 'Recorded test output · burst 21', + }) + // Value: protects=the learner's scroll anchor reaches the host at most once per TEST_OUTPUT_STATE_REPORT_INTERVAL_MS (500 ms), the anchors of the scrolls in between are joined into one report when the interval ends, a change of the follow choice is reported at once, and the joined anchor is also reported when the document is left; fails_when=each scroll event reports (a message per event), the timer forgets the scrolls joined during the interval, or the pagehide report is missing; why_new=D22: the anchor is reported to the host, and scrolling produces events faster than the host needs them; seam=the page's setTimeout, replaced to hold the document's timers of 100 ms or more until the case runs them; no_claim=the real passage of time, which the held timer stands in for + // Act: the learner scrolls up (a change of the follow choice) and then scrolls four more times inside the interval. + const intervalPage = await openApplication( + options.html.applicationAtResult, + () => { + window.heldTimers = [] + const realSetTimeout = window.setTimeout.bind(window) + window.setTimeout = (callback, delay) => { + if (delay === undefined || delay >= 100) { + window.heldTimers.push({ callback, delay }) + return window.heldTimers.length + } + return realSetTimeout(callback, delay) + } + }, + ) + const stateReports = () => + intervalPage.evaluate( + () => + window.sent.filter( + (message) => message.type === 'node-history-output-state', + ).length, + ) + for (const top of [300, 250, 200, 150, 100]) { + await learnerScrolledTo(intervalPage, top) + await settleFrames(intervalPage) + } + const duringInterval = { + reports: await stateReports(), + timers: await intervalPage.evaluate(() => + window.heldTimers.map((timer) => timer.delay), + ), + } + await intervalPage.evaluate(() => window.heldTimers[0].callback()) + const afterInterval = await stateReports() + await learnerScrolledTo(intervalPage, 60) + await settleFrames(intervalPage) + const beforeLeaving = await stateReports() + await intervalPage.evaluate(() => + window.dispatchEvent(new Event('pagehide')), + ) + const afterLeaving = await stateReports() + await intervalPage.close() // Assert cases.push({ - name: 'a test replay’s Application document writes its state once for a stream of moments and writes the last moment when it is left (DS20, EN3)', - actual: streamed, + name: 'the learner’s scroll anchor is reported to the host once per interval, the follow choice at once and the joined anchor when the document is left (D22)', + actual: { duringInterval, afterInterval, beforeLeaving, afterLeaving }, passed: - JSON.stringify(streamed) === JSON.stringify({ - whileStreaming: 1, - afterLeaving: 2, - keptGeneration: 21, + duringInterval, + afterInterval, + beforeLeaving, + afterLeaving, + }) === + JSON.stringify({ + duringInterval: { reports: 2, timers: [500] }, + afterInterval: 3, + beforeLeaving: 3, + afterLeaving: 4, }), }) - // Value: protects=moments posted one by one, as the host posts them during Play, are joined into one state write per TEST_OUTPUT_STATE_WRITE_INTERVAL_MS (500 ms), and the joined last moment is written when the interval ends, without the document being left; fails_when=the interval is dropped or zero (a write per moment, each copying the whole output), or the timer forgets the moments joined during the interval, so a document reloaded later shows an older moment; why_new=Step 9 pass 3's testing review: the stream case posts every moment in one task and flushes by pagehide, so neither the interval nor the timer's write was tested; seam=the page's setTimeout, replaced to hold the document's timers of 100 ms or more until the case runs them; no_claim=the real passage of time, which the held timer stands in for - // Arrange: a document whose state writes are counted, with its long timers held. - const timerPage = await page.context().newPage() - await timerPage.setViewportSize({ width: 760, height: 360 }) - await timerPage.goto(options.url) - await timerPage.evaluate(() => { - window.savedState = undefined - window.stateWrites = 0 - window.heldTimers = [] - const realSetTimeout = window.setTimeout.bind(window) - window.setTimeout = (callback, delay) => { - if (delay === undefined || delay >= 100) { - window.heldTimers.push({ callback, delay }) - return window.heldTimers.length + // Value: protects=a clamped test line in the Application header is a tab stop that shows the whole name while it has the keyboard focus, as the browser summary's title does, and a test line that fits is no tab stop; fails_when=a name cut at two lines can be read only from its tooltip (a keyboard or touch learner cannot read it), the cut line is not shown whole on focus, or a fitting line takes a tab stop; why_new=F144 (pass 3 maintainability): the clamp had a hover tooltip only; seam=none; no_claim=the Study pane's own title (its case follows the test replay section widths) (stand-ins: the document's own resize measure after the page sets the name) + // Arrange: the header's test line at 320 px, first with a name of many titles, then with a short one. + await applicationPage.setViewportSize({ width: 320, height: 360 }) + const readTestLine = () => + applicationPage.evaluate(() => { + const line = document.getElementById('test-output-test') + return { + tabindex: line.getAttribute('tabindex'), + lines: Math.round( + line.scrollHeight / + Number.parseFloat(getComputedStyle(line).lineHeight), + ), + displayed: getComputedStyle(line).display, } - return realSetTimeout(callback, delay) - } - window.acquireVsCodeApi = () => ({ - getState: () => window.savedState, - setState(next) { - window.stateWrites += 1 - window.savedState = structuredClone(next) - }, - postMessage() {}, }) - }) - await timerPage.setContent(options.html.application) - // Act: three moments, each posted in its own task. - for (const generation of [2, 3, 4]) - await timerPage.evaluate( + const setTestLine = (name) => + applicationPage.evaluate((value) => { + const line = document.getElementById('test-output-test') + line.textContent = value + line.title = value + window.dispatchEvent(new Event('resize')) + }, name) + // Act + await setTestLine( + 'src/jobs.test.ts › job scheduler › runs a delayed job after its timer fires and reports the result to the caller › even when the fake clock advances twice', + ) + const clampedTestLine = await readTestLine() + await applicationPage.locator('#test-output-test').focus() + const focusedTestLine = await readTestLine() + await applicationPage.evaluate(() => document.activeElement.blur()) + await setTestLine('src/jobs.test.ts') + const fittingTestLine = await readTestLine() + // Assert + cases.push({ + name: 'a test line of the Application header that the clamp cuts is a tab stop shown whole on focus, and a test line that fits is no tab stop', + actual: { clampedTestLine, focusedTestLine, fittingTestLine }, + passed: + clampedTestLine.tabindex === '0' && + clampedTestLine.lines > 2 && + focusedTestLine.displayed === 'block' && + fittingTestLine.tabindex === null, + }) + // Value: protects=a test line that fits after the learner has focused and left it drops its tab stop (the stop is decided again on blur); fails_when=a line that was cut while it held the focus, came to fit meanwhile and kept tabindex=0 after the focus left it, so a keyboard learner tabs through a title that needs no stop; why_new=F297 (OCR batch C): installClampedTitleStop measured only at load and resize, and not while the heading held the focus; seam=none; no_claim=the Study pane's own titles (they are measured after every message and were not red) + // Arrange: a cut test line that holds the keyboard focus. + await setTestLine( + 'src/jobs.test.ts › job scheduler › runs a delayed job after its timer fires and reports the result to the caller › even when the fake clock advances twice', + ) + await applicationPage.locator('#test-output-test').focus() + // Act: the name becomes short while focused (the resize skips a focused line), then the focus leaves. + await setTestLine('src/jobs.test.ts') + const whileFocused = await readTestLine() + await applicationPage.evaluate(() => document.activeElement.blur()) + const afterBlur = await readTestLine() + // Assert + cases.push({ + name: 'a test line that came to fit while it held the focus drops its tab stop when the focus leaves it', + actual: { whileFocused, afterBlur }, + passed: whileFocused.tabindex === '0' && afterBlur.tabindex === null, + }) + // Value: protects=three cells of the Application document's follow state that no case read, each its own case below: a document that follows the output ignores an older view and stays at the end; a learner who stopped following and scrolls back to the end follows again, with Jump to latest gone; a document loaded again never uses the newer view or the choice not to follow that another replay left in the webview's kept state (it shows its own first view, at the end, with Jump to latest hidden, as its options say); fails_when=an older view moves a following document off its latest moment, scrolling back to the end leaves Jump to latest and the choice behind, or another replay's kept view (a newer generation) or kept choice not to follow is read by this replay's document; why_new=F125 (the document's following x an older view, not-following x a scroll back to the end) and F147 (another replay's newer kept view); seam=none; no_claim=VS Code's own state transport (stand-ins: a stub of the webview API with a kept state) + // Arrange: a document of its own in a page, as the cases above build one. + const cellPage = await page.context().newPage() + await cellPage.setViewportSize({ width: 760, height: 360 }) + const loadCellDocument = async (kept) => { + await cellPage.goto(options.url) + await cellPage.evaluate((state) => { + window.savedState = state + window.acquireVsCodeApi = () => ({ + getState: () => window.savedState, + setState(next) { + window.savedState = next + }, + postMessage: () => {}, + }) + }, kept) + await cellPage.setContent(options.html.application) + await cellPage.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ) + } + const postCellView = async (generation, view) => { + await cellPage.evaluate( ({ generation, view }) => window.dispatchEvent( new MessageEvent('message', { @@ -2847,35 +3822,100 @@ async function check(page, options, collected) { }, }), ), - { generation, view: options.testReplayViews.result }, + { generation, view }, ) - const beforeInterval = await timerPage.evaluate(() => ({ - writes: window.stateWrites, - stateTimers: window.heldTimers - .filter((timer) => timer.delay === 500) - .map((timer) => timer.delay), - })) - const afterInterval = await timerPage.evaluate(() => { - window.heldTimers.find((timer) => timer.delay === 500)?.callback() - return { - writes: window.stateWrites, - kept: window.savedState?.testOutputView?.generation, - } + await cellPage.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ) + } + const readCell = () => + cellPage.evaluate(() => { + const output = document.getElementById('test-output') + return { + time: document.getElementById('test-output-time').textContent, + atEnd: + output.scrollHeight - output.scrollTop - output.clientHeight <= 2, + jumpHidden: document.getElementById('test-output-jump').hidden, + } + }) + await loadCellDocument(undefined) + // Act: a following document is told a newer moment, then an older one. + await postCellView(6, options.testReplayViews.result) + const followingAtNewest = await readCell() + await postCellView(4, { + ...options.testReplayViews.result, + time: 'Recorded test output · 0.05 s', + }) + const followingAfterOlder = await readCell() + // Act: the learner scrolls up (not following), then back to the end. + await cellPage.evaluate(() => { + const output = document.getElementById('test-output') + output.scrollTop = 0 + output.dispatchEvent(new Event('scroll')) + }) + await cellPage.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ) + const notFollowing = await readCell() + await cellPage.evaluate(() => { + const output = document.getElementById('test-output') + output.scrollTop = output.scrollHeight + output.dispatchEvent(new Event('scroll')) + }) + await cellPage.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ) + const followingAgain = await readCell() + // Act: a document of this replay, written with the host's reading (following), loaded in a webview whose kept state holds the newer view and the choice not to follow that another replay left (D22: the document reads only the host's reading in its options, never the kept state). + await loadCellDocument({ + testOutputHistory: 'another-replay', + testOutputFollow: false, + testOutputView: { + ...options.testReplayViews.result, + time: 'Recorded test output · 9.99 s', + generation: 99, + }, }) - await timerPage.close() + const withOtherReplaysState = await readCell() + await cellPage.close() // Assert cases.push({ - name: 'moments posted one by one are joined into one state write per interval, and the joined moment is written when the interval ends (DS20, EN3)', - actual: { beforeInterval, afterInterval }, + name: 'a following Application document ignores an older view and stays at the end of its latest moment (DS20)', + actual: { followingAtNewest, followingAfterOlder }, passed: - JSON.stringify({ beforeInterval, afterInterval }) === - JSON.stringify({ - beforeInterval: { writes: 1, stateTimers: [500] }, - afterInterval: { writes: 2, kept: 4 }, - }), + followingAtNewest.atEnd && + followingAfterOlder.time === followingAtNewest.time && + followingAfterOlder.atEnd, + }) + cases.push({ + name: 'a learner who stopped following and scrolls back to the end follows again, with Jump to latest gone (DS20)', + actual: { notFollowing, followingAgain }, + passed: + !notFollowing.atEnd && + !notFollowing.jumpHidden && + followingAgain.atEnd && + followingAgain.jumpHidden, + }) + cases.push({ + name: 'a document loaded again never shows the newer view that another replay left in the kept state (DS20)', + actual: { withOtherReplaysState }, + passed: + withOtherReplaysState.time === 'Recorded test output · 0.10 s' && + withOtherReplaysState.atEnd && + withOtherReplaysState.jumpHidden, }) await applicationPage.close() // Assert + // Value: protects=the Application document never widens the page at 320, 480 or 760 px and keeps the test name to two lines; fails_when=a long test name or output line widens the page; why_new=PR-B1 (Design 8, DS19) added the document's widths; seam=none; no_claim=native pixels 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, @@ -2886,18 +3926,16 @@ 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. + // Arrange: a second Application document of the same replay, in a page of its own, 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.sent = [] window.acquireVsCodeApi = () => ({ - getState: () => window.savedState, - setState(state) { - window.savedState = state - }, - postMessage: () => {}, + getState: () => undefined, + setState() {}, + postMessage: (message) => window.sent.push(structuredClone(message)), }) }) await updatePage.setContent(options.html.application) @@ -3114,7 +4152,9 @@ async function check(page, options, collected) { const readFollow = () => updatePage.evaluate(() => ({ jumpShown: !document.getElementById('test-output-jump').hidden, - follow: window.savedState?.testOutputFollow, + follow: window.sent + .filter((message) => message.type === 'node-history-output-state') + .at(-1)?.follow, })) await postOutputView(8, atTheResult) await updatePage.evaluate(() => { @@ -3251,6 +4291,14 @@ async function check(page, options, collected) { }, }, } + // The golden below pins the diagram's markup, not a system's fonts: while this sample is drawn every character measures 6 px, so its labels break where they break on every system (F350: Chrome on CI's Linux measured "1. Observed submission" wider than its 144 px box and wrapped it, while the Mac's font fits it). + const measureText = await page.evaluateHandle(() => { + const measured = CanvasRenderingContext2D.prototype.measureText + CanvasRenderingContext2D.prototype.measureText = function (value) { + return { width: [...value].length * 6 } + } + return measured + }) await publish(learning) await page.locator('#learning-material').scrollIntoViewIfNeeded() const diagram = await page.evaluate(() => ({ @@ -3278,11 +4326,18 @@ async function check(page, options, collected) { diagram.summary.includes('' /** - * Reads the recorded test output that the Application pane shows, so a test matches what the learner reads there (Design 3, EN3): the latest in-place update when it is newer than the written document, else the document's own lines; used by the Study workspace's test replay tests. + * Reads the first view a written Application document draws: the `view` of the options its output script takes, as {@link testOutputHtml} writes them. + * @param document - The document's HTML. + * @returns The written view; `undefined` for a document without the output script. + * @example writtenView(panel.webview.html)?.lines // => [{ number: 0, channel: 'stdout', text: 'RUN src/theme.test.ts' }] + */ +function writtenView(document: string): NodeTestApplicationView | undefined { + const start = document.lastIndexOf(SCRIPT_OPTIONS_START) + const end = document.lastIndexOf(SCRIPT_OPTIONS_END) + if (start < 0 || end < start) return undefined + const options: Pick = JSON.parse( + document.slice(start + SCRIPT_OPTIONS_START.length, end), + ) + return options.view +} + +/** + * Reads the recorded test output that the Application pane shows, so a test matches what the learner reads there (Design 3, EN3): the latest in-place update when it is newer than the view the document was written with, else that written view's lines; used by the Study workspace's test replay tests. * @param harness - The Study workspace harness, which records the Application pane's updates and keeps its panels. * @returns The shown output lines joined by line feeds; empty without a test replay document or output. * @example shownTestOutput(harness) // => 'output 21\noutput 22' @@ -24,17 +39,12 @@ export function shownTestOutput(harness: { .nativePanels() .find((panel) => panel.kind === 'codebook.scopedBrowser')?.webview.html ?? '' - // The document starts its update script with the generation it was written at. - const written = Number(/\)\(api, (\d+), \{/.exec(document)?.[1] ?? -1) + const written = writtenView(document) const update = harness.applicationOutputs().at(-1) - if (update && update.generation > written) - return update.view.lines.map((line) => line.text).join('\n') - return Array.from( - document.matchAll(/([^<]*)<\/span>/g), - (match) => - ENTITIES.reduce( - (text, [entity, character]) => text.replaceAll(entity, character), - match[1] ?? '', - ), - ).join('\n') + // A document draws an update only when it is newer than the view it was written with. + const shown = + update && (!written || update.generation > written.generation) + ? update.view + : written + return shown?.lines.map((line) => line.text).join('\n') ?? '' } diff --git a/extension/src/tests/utils/twoStopNodeHistory.ts b/extension/src/tests/utils/twoStopNodeHistory.ts new file mode 100644 index 000000000..fe990e965 --- /dev/null +++ b/extension/src/tests/utils/twoStopNodeHistory.ts @@ -0,0 +1,156 @@ +import type { NodeTestHistoryStore } from '../../nodeTestHistoryStore' +import type { NodeTestSourceStop } from '../../ownedNodeTestRun' +import { sha256 } from '../../runtimeSupport/utils/sha256' +import type { workspaceHarness } from '../studyWorkspaceHarness' + +import { nodeTestHistoryHeader } from './nodeTestHistoryFixture' + +const SOURCE_TEXT = 'const nextTheme = "dark";\n' + +/** The last view the reader posted to its panes. */ +export function lastView( + harness: Awaited>, +) { + return harness.postedMessages().slice(-1)[0] +} + +/** + * Saves a sealed test replay of two stops with output before and between them and a passing result: output 1, stop 2, output 3, stop 4, result 5. + * @param store - The workspace's history store. + * @param testTitles - The title path the history cites, absent for a history that cites its file alone. + * @param forged - Whether the recording carries a right-to-left override (U+202E) in its title path, first output and result tail (F131). + * @returns The history's header. + * @example const history = await saveTwoStopNodeHistory(harness.service.nodeHistoryStore!) + */ +export async function saveTwoStopNodeHistory( + store: NodeTestHistoryStore, + testTitles?: string[], + forged = false, +) { + const oneStop = nodeTestHistoryHeader(store.workspaceKey) + // The second stop is declared at the same line, as a loop's second pass stops there again. + const configuration = { + ...oneStop.configuration, + ...(testTitles || forged + ? { testTitles: testTitles ?? ['suite \u202Etitle', 'works'] } + : {}), + additionalStops: [ + { + ...oneStop.configuration.source, + anchorIndex: 0, + inspections: oneStop.configuration.inspections, + }, + ], + } + const history = { + ...oneStop, + configuration, + configurationHash: sha256(JSON.stringify(configuration)), + } + const writer = await store.start(history) + const stopAt = (index: number, elapsedMs: number): NodeTestSourceStop => ({ + index, + runId: 'test-run', + sessionId: 'test-session', + threadId: 1, + elapsedMs, + worker: { pid: 1, processGroup: 1, workspace: '/private/test' }, + source: history.configuration.source, + frame: { id: index + 1, name: 'applyTheme', line: 1, column: 1 }, + variables: [{ name: 'nextTheme', value: 'dark', variablesReference: 0 }], + facts: [ + { path: 'nextTheme', mode: 'value', display: 'dark', type: 'string' }, + ], + debuggerSnapshot: { + version: 1, + frames: [ + { + name: 'applyTheme', + line: 1, + column: 1, + source: { + name: history.configuration.source.path, + text: SOURCE_TEXT, + hash: sha256(SOURCE_TEXT), + line: 1, + }, + scopes: [{ name: 'Local', variables: [0] }], + }, + ], + variables: [ + { name: 'nextTheme', value: 'dark', type: 'string', children: [] }, + ], + limitations: [], + }, + }) + await writer.appendOutput({ + sequence: 1, + elapsedMs: 100, + channel: 'stdout', + text: forged ? 'RUN\u202E src/theme.test.ts\n' : 'RUN src/theme.test.ts\n', + }) + await writer.appendStop(stopAt(0, 200)) + await writer.appendOutput({ + sequence: 2, + elapsedMs: 250, + channel: 'stderr', + text: 'theme switched\n', + }) + await writer.appendStop(stopAt(1, 300)) + await writer.appendResult( + { + exitCode: 0, + signal: null, + output: forged ? 'Tests\u202E 1 passed' : 'Tests 1 passed', + }, + 400, + ) + await writer.finish('complete') + return history +} + +/** + * 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) + */ +export 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 }, + }, + ], + }, + }) +} diff --git a/extension/src/tests/wordNodeCaptureReason.test.ts b/extension/src/tests/wordNodeCaptureReason.test.ts new file mode 100644 index 000000000..71aa24b3b --- /dev/null +++ b/extension/src/tests/wordNodeCaptureReason.test.ts @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { wordNodeCaptureReason } from '../utils/wordNodeCaptureReason' + +// Value: protects=a stored capture reason reaches the learner as one short sentence in test replay words; fails_when=a line break or control character survives, "Node history" or "Node test history" stays in the sentence, or a long reason is shown whole; why_new=F130 shows the reason again under the Study status; seam=none; no_claim=where the reason comes from +test('a capture reason becomes one capitalized sentence without Node words, line breaks or length beyond 240 characters', () => { + // Arrange + const long = 'x'.repeat(300) + + // Act + const words = wordNodeCaptureReason( + 'node history ended\r\nafter the Node test histories closed', + ) + const cut = wordNodeCaptureReason(long) + + // Assert + assert.equal(words, 'Test replay ended after the test replays closed') + assert.equal(cut?.length, 240) + assert.equal(cut?.endsWith('…'), true) + assert.equal(wordNodeCaptureReason(undefined), undefined) + assert.equal(wordNodeCaptureReason(' \n '), undefined) +}) + +// Value: protects=the capture-reason line shows no terminal tail, C1 or bidirectional control; fails_when=the `[31m` tail of a color sequence, a U+0085 or a U+202E reaches the line; why_new=F288 (only C0 and DEL were replaced); seam=none; no_claim=the sentence's wording +test('a capture reason loses color sequences, C1 controls and bidirectional controls', () => { + // Arrange + const reason = 'Interrupted \u001b[31mby\u001b[0m disk\u0085 error\u202E EIO' + + // Act + const words = wordNodeCaptureReason(reason) + + // Assert + assert.equal(words, 'Interrupted by disk error EIO') +}) + +// Value: protects=the 240-character cut never leaves half of an emoji (a lone surrogate) in the line; fails_when=the cut falls between the two UTF-16 units of a pair; why_new=F288; seam=none; no_claim=the length of an ordinary cut +test('a capture reason cut inside an emoji drops the whole emoji before the ellipsis', () => { + // Arrange + const reason = `${'x'.repeat(238)}😀${'y'.repeat(20)}` + + // Act + const words = wordNodeCaptureReason(reason) + + // Assert + assert.equal(words, `X${'x'.repeat(237)}…`) +}) + +// Value: protects=each of the four stored spellings of a history reaches the learner as test replay words, in its number: "Node test history", "Node test histories", "Node history" and "Node histories"; fails_when=one spelling keeps "Node" (the expression loses its optional "test " or its plural), or a plural reads as one test replay; why_new=F319 (PR-2 /ship Step 7 re-entry): F316 joined the two expressions, and only "node history" and "Node test histories" were asserted; seam=none; no_claim=the rest of the sentence +test('a capture reason reads each spelling of Node history as test replay words in its number', () => { + // Arrange + const reason = + 'Node test history ended; Node test histories, Node history and Node histories kept' + + // Act + const words = wordNodeCaptureReason(reason) + + // Assert + assert.equal( + words, + 'Test replay ended; test replays, test replay and test replays kept', + ) +}) + +// Value: protects=the 240-character cut leaves no lone surrogate at the end of the line also when the reason itself holds a lone high surrogate there (a reason cut from a longer text); fails_when=the cut keeps a high surrogate that no low one follows, so the line ends in half a character before the ellipsis; why_new=F330 (PR-2 /ship Step 8 re-entry): F328's shared pair check made the cut drop a high surrogate only when its low one follows; seam=none; no_claim=lone surrogates elsewhere in the reason +test('a capture reason cut after a lone high surrogate drops it before the ellipsis', () => { + // Arrange + const reason = `${'x'.repeat(238)}\uD83D${'y'.repeat(20)}` + + // Act + const words = wordNodeCaptureReason(reason) + + // Assert + assert.equal(words, `X${'x'.repeat(237)}…`) +}) diff --git a/extension/src/ui/constants.ts b/extension/src/ui/constants.ts index e75f3bf83..088a0ae9d 100644 --- a/extension/src/ui/constants.ts +++ b/extension/src/ui/constants.ts @@ -25,7 +25,12 @@ const STUDY_SEQUENCE_LAYOUT = { topPaddingPx: 72, rowHeightPx: 64, nameYpx: 24, - nameChunkChars: 20, + // The width of one character of the diagram's 12 px text by kind and the room a name or label leaves at both sides of its box; a line holds the characters whose widths fit in (box width - padding) (F206, F293); the widths below estimate a diagram that cannot measure its text. A Japanese, Chinese or Korean glyph and a full-width form is about one em wide, an upper-case Latin letter wider than a lower-case one. + fontSizePx: 12, + charWidthPx: 6.5, + wideCharWidthPx: 13, + upperCharWidthPx: 8.5, + columnPaddingPx: 16, nameLineHeightPx: 14, lifelineYpx: 66, firstArrowYpx: 92, @@ -187,7 +192,7 @@ export const STUDY_STYLES = ` /* A status region stays rendered while it is empty and takes no room, so its first text changes a region assistive technology already knows; only lines that are no status region leave the page while empty. */ .error:empty:not([role=status]){display:none}[role=status]:empty{margin:0;padding:0;border:0}#observation{border:0;padding:0;margin:8px 0 12px;color:var(--vscode-descriptionForeground,#aaa)}@media(min-width:900px){.live-strip{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:4px 12px}.live-strip>.row{justify-content:flex-end}.live-strip>#deadline{grid-column:1/-1}} :root{color-scheme:light dark;font-family:var(--vscode-font-family,system-ui);color:var(--vscode-foreground,#d4d4d4);background:var(--vscode-editor-background,#181818);font-size:16px;line-height:1.5;scroll-padding-top:calc(var(--codebook-sticky-height,0px) + ${STUDY_FOCUS_MARGIN_PX}px);scroll-padding-bottom:calc(var(--playback-height,0px) + ${STUDY_FOCUS_MARGIN_PX}px)} -#save-state{display:block;margin-top:4px}#flow{display:flex;flex-wrap:wrap;gap:8px 16px}.live-strip.flowing{position:static}[role=tablist]{flex-wrap:wrap}[role=tab]{overflow-wrap:normal} +#save-state{display:block;margin-top:4px}#flow{display:flex;flex-wrap:wrap;gap:8px 16px}.live-strip.flowing,#node-history-transport.flowing{position:static}[role=tablist]{flex-wrap:wrap}[role=tab]{overflow-wrap:normal} /* The host styles body directly; inherit the reading scale explicitly. */ /* Titles keep twelve characters per line: body wraps anywhere, and a pane squeezed during arrangement would show them one character per line. */ *{box-sizing:border-box}body{margin:0;padding:16px;overflow-wrap:anywhere;font-size:inherit;line-height:inherit}h1{font-size:20px;line-height:1.25;margin:16px 0 8px;min-width:12ch}h2{font-size:16px;margin:0 0 12px}p{margin:8px 0 16px}button,input,select{font:inherit}button{cursor:pointer;padding:6px 10px;border:1px solid var(--vscode-button-border,var(--vscode-contrastBorder,transparent));border-radius:4px;background:var(--vscode-button-secondaryBackground,#333);color:var(--vscode-button-secondaryForeground,#eee)}button.primary{background:var(--vscode-button-background,#0e639c);color:var(--vscode-button-foreground,#fff)}button:hover:not(:disabled):not([aria-disabled=true]){filter:brightness(1.12)}button:disabled,button[aria-disabled=true]{opacity:.6;cursor:default}button:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid var(--vscode-focusBorder,#007fd4);outline-offset:2px}.row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.muted{color:var(--vscode-descriptionForeground,#aaa)}.status{border:1px solid var(--vscode-contrastBorder,var(--vscode-panel-border,#555));padding:8px 12px;margin:12px 0}.error{color:var(--vscode-editorError-foreground,var(--vscode-foreground,#d4d4d4));white-space:pre-wrap}section{padding:20px 0;border-bottom:1px solid var(--vscode-panel-border,#444)}[role=tablist]{display:flex;gap:8px;border-bottom:1px solid var(--vscode-panel-border,#444);padding-bottom:12px}[role=tab][aria-selected=true]{border-bottom:3px solid var(--vscode-focusBorder,#007fd4)}.link{background:none;border:0;color:var(--vscode-textLink-foreground,#4daafc);text-decoration:underline;padding:4px 0}.link.visited{color:var(--vscode-textLink-activeForeground,#9b8cdd)}label{display:flex;align-items:flex-start;gap:12px;margin:12px 0}input[type=checkbox]{width:20px;height:20px;accent-color:var(--vscode-focusBorder,#007fd4)}.interpretation{white-space:pre-wrap}img{display:block;max-width:100%;height:auto;border:1px solid var(--vscode-panel-border,#444)}.moments{display:flex;gap:8px;flex-wrap:wrap}.moments button[aria-current=true]{outline:2px solid var(--vscode-focusBorder,#007fd4)}.live-strip{position:sticky;top:0;z-index:1;padding:12px;background:var(--vscode-editor-background,#181818);border-bottom:1px solid var(--vscode-panel-border,#444)}body.moments-only{padding:8px 16px}body.moments-only .live-strip{position:static;padding:0 0 8px}body.moments-only section{padding:8px 0}select{max-width:100%;padding:6px;background:var(--vscode-dropdown-background,#333);color:var(--vscode-dropdown-foreground,#eee);border:1px solid var(--vscode-dropdown-border,#666)}[hidden]{display:none!important}@media(max-width:360px){body{padding:12px}.row{gap:8px}}body.vscode-high-contrast button,body.vscode-high-contrast-light button{border-color:var(--vscode-contrastBorder)} @@ -215,11 +220,11 @@ body.moments-only #recording-transport{display:flex;flex-wrap:wrap;align-items:c /* Each event card's text wraps inside the reader, and scrolls there if a line still cannot, instead of widening the page (Cycle 0044, 0031-W1 and 0038-W). */ .node-history-event pre{white-space:pre-wrap;overflow-wrap:anywhere;max-width:100%;overflow:auto} /* A test replay's transport stays in view while its summary and steps scroll under it (DS16); Previous stop and Next stop lead it (DS12). */ -#node-history-transport{position:sticky;top:0;z-index:2;background:var(--vscode-editor-background,#181818);padding:4px 0;border-bottom:1px solid var(--vscode-panel-border,#444)}#node-history-transport .row{margin:4px 0 0}#node-history-moment p{margin:4px 0}#node-history-explanation:empty,#node-history-not-recorded:empty,#node-history-unverified:empty{display:none}.same-moment{color:var(--vscode-descriptionForeground,#aaa);font-size:12px} +#node-history-transport{position:sticky;top:0;z-index:2;background:var(--vscode-editor-background,#181818);padding:4px 0;border-bottom:1px solid var(--vscode-panel-border,#444)}#node-history-transport .row{margin:4px 0 0}#node-history-moment p{margin:4px 0}#node-history-explanation:empty,#node-history-not-recorded:empty,#node-history-unverified:empty,#node-history-capture-reason:empty{display:none}.same-moment{color:var(--vscode-descriptionForeground,#aaa);font-size:12px} #node-history-moment>p:empty{margin:0;padding:0;border:0} -#node-learning-title{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden} +#node-learning-title{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden}#node-learning-title:focus{display:block;overflow:visible}#node-learning-title:focus-visible,#test-output-test:focus-visible{outline:2px solid var(--vscode-focusBorder,#007fd4);outline-offset:2px} /* A test replay's Application pane: the header and footer stay pinned and the output region is the only scroller (DS4); a standard-error line starts with a mark in the terminal's red, and the mark, never the color alone, says so. */ -body.test-output{height:100vh;padding:0;overflow:hidden}body.test-output main{display:flex;flex-direction:column;height:100%;min-height:0;padding:12px 16px}.test-output-header p{margin:0}#test-output-test{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden}#test-output{flex:1;min-height:0;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;margin:8px 0;padding:8px;border:1px solid var(--vscode-panel-border,#444)}#test-output:focus-visible{outline:2px solid var(--vscode-focusBorder,#007fd4)}.stderr-mark{color:var(--vscode-terminal-ansiRed,#f14c4c)}#test-output-jump{align-self:flex-start}body.test-output p{margin:4px 0} +body.test-output{height:100vh;padding:0;overflow:hidden}body.test-output main{display:flex;flex-direction:column;height:100%;min-height:0;padding:12px 16px}#test-output-test{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden}#test-output-test:focus{display:block;overflow:visible}#test-output{flex:1;min-height:0;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;margin:8px 0;padding:8px;border:1px solid var(--vscode-panel-border,#444)}#test-output:focus-visible{outline:2px solid var(--vscode-focusBorder,#007fd4)}.stderr-mark{color:var(--vscode-terminal-ansiRed,#f14c4c)}#test-output-jump{align-self:flex-start}body.test-output p{margin:4px 0} /* A Learning summary in a narrow side bar keeps a long title to two lines and its sequence in view; the full title stays in its tooltip, and a title the clamp cuts is a tab stop that is shown whole while it has the keyboard focus. A wider summary shows the title whole. */ #title:focus-visible,#learning-title:focus-visible{outline:2px solid var(--vscode-focusBorder,#007fd4);outline-offset:2px} /* Compact replay keeps its empty header spacing; a disclosed layout notice uses the shared gap. */ diff --git a/extension/src/ui/studyHtml.ts b/extension/src/ui/studyHtml.ts index 67ff231ea..32f7b1d89 100644 --- a/extension/src/ui/studyHtml.ts +++ b/extension/src/ui/studyHtml.ts @@ -3,7 +3,6 @@ import { randomUUID } from 'node:crypto' import type { NodeTestHistoryIndex, NodeTestHistoryLearningDecision, - NodeTestLearningMaterial, } from '../nodeTestHistorySchemas' import type { NodeTestHistoryStore } from '../nodeTestHistoryStore' import type { PlaybackState, PlaybackBundle } from '../playbackController' @@ -118,7 +117,6 @@ export interface StudyViewMessage { nodeHistoryPageCount?: number nodeHistoryEventCount?: number nodeHistoryLearningDecision?: NodeTestHistoryLearningDecision - nodeHistoryLearningMaterial?: NodeTestLearningMaterial /** Sequences of the shown Node stops whose captured source failed the extension's verification (path, line or text hash); they get no Inspect. */ nodeHistoryUnverifiedStops?: number[] nodeHistoryPlayback?: { @@ -131,10 +129,7 @@ export interface StudyViewMessage { } nodeHistoryPosition?: { sequence: number - type: 'output' | 'stop' | 'result' - index?: number eventIndex: number - elapsedMs: number /** The stop's captured source is verified and matches it; unlike {@link debuggerAvailable}, ending the session never clears it. */ sourceAvailable?: boolean /** The owned native session shows this stop now. */ @@ -210,7 +205,7 @@ export function studyHtml(kind: 'context' | 'moments'): string {
Study controls

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

Workspace panes

-

${STUDY_UI_OPTIONS.testReplay.heading}

Test journal playback
Events
+

${STUDY_UI_OPTIONS.testReplay.heading}

Test journal playback
Events

Observed moments

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

Saved studies

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

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

${markupOnlyIn(kind, 'moments', valuesHint)}
@@ -547,21 +542,34 @@ function studyClient( revealInTransport(playbackDiagnostics) }) const strip = document.querySelector('.live-strip') + const replayTransportBar = document.getElementById('node-history-transport') + // The sticky bars this page has, top first: the live strip, then the test replay's transport. + const stickyBars = [strip, replayTransportBar].filter( + (bar): bar is HTMLElement => bar !== null, + ) // Oversized sticky controls must not hide keyboard focus at narrow widths or enlarged text. + // The live strip sticks at the top and the test replay's transport sticks right below it, so the sticky bars never cover each other; a bar taller than the fraction of the pane flows with the page instead, and the focus scroll room counts the bars that stay. const adaptStrip = (): void => { - if (!strip) return - const height = strip.getBoundingClientRect().height - strip.classList.toggle( - 'flowing', - height > window.innerHeight * options.maxStickyFraction, - ) - // Native focus scrolling must leave room for the visible sticky strip and its outline. + let stickyHeight = 0 + for (const bar of stickyBars) { + const height = bar.getBoundingClientRect().height + bar.classList.toggle( + 'flowing', + height > window.innerHeight * options.maxStickyFraction, + ) + // The transport sticks below the strip: its offset is the height of the bar above it. + if (bar === replayTransportBar) bar.style.top = `${stickyHeight}px` + if (getComputedStyle(bar).position === 'sticky') stickyHeight += height + } + // Native focus scrolling must leave room for the visible sticky bars and their outline. document.documentElement.style.setProperty( '--codebook-sticky-height', - `${getComputedStyle(strip).position === 'sticky' ? height : 0}px`, + `${stickyHeight}px`, ) } - if (strip) new ResizeObserver(adaptStrip).observe(strip) + // One observer measures every bar again when any of them changes size. + const stickyBarObserver = new ResizeObserver(adaptStrip) + for (const bar of stickyBars) stickyBarObserver.observe(bar) window.addEventListener('resize', adaptStrip) document.addEventListener('click', (event) => { const target = @@ -579,7 +587,8 @@ function studyClient( if (target.dataset.nodeHistorySequence && current?.nodeHistory) { // A summary step's action selects its event and that step (DS21); an Inspect selects the stop in the native debugger. send( - target.dataset.inspectNodeHistoryStop + target.dataset.inspectNodeHistoryStop || + target.id === 'node-history-inspect' ? { type: 'node-history-stop', studyId: current.nodeHistory.header.studyId, @@ -1229,7 +1238,6 @@ function studyClient( study: SavedStudyEntry, profiles: StudyViewMessage['profiles'], ): string => { - if (study.format === 'node-test-history') return 'Node test history' if (study.profile === 'repository-browser') return 'Repository browser study' return ( @@ -1256,7 +1264,7 @@ function studyClient( message.removalWithoutTrash ? options.studyRemovalRefusalsWithoutTrash : options.studyRemovalRefusals - /** Says why the chosen entry cannot be removed, first match wins, in the words of this window ({@link removalRefusals}); empty when it can. @example chosenStudyRefusal(message, nodeEntry) // => 'Node test histories cannot be deleted here yet.' */ + /** Says why the chosen entry cannot be removed, first match wins, in the words of this window ({@link removalRefusals}); empty when it can. @example chosenStudyRefusal(message, nodeEntry) // => 'Test replays cannot be deleted here yet.' */ const chosenStudyRefusal = ( message: StudyViewMessage, chosen: SavedStudyEntry, @@ -1806,16 +1814,10 @@ function studyClient( ...Array.from(element(owed.section).querySelectorAll('button,input')), ].find( (control): control is HTMLElement => - (control instanceof HTMLButtonElement || - control instanceof HTMLInputElement || - (control instanceof HTMLElement && - control.localName === 'summary')) && + control instanceof HTMLElement && + control.matches('button,input,summary') && control !== owed.leaving && - !( - (control instanceof HTMLButtonElement || - control instanceof HTMLInputElement) && - control.disabled - ) && + !control.matches(':disabled') && // Shown: a control inside a closed disclosure still has boxes, and cannot take the focus. control.checkVisibility(), ) @@ -2042,7 +2044,132 @@ function studyClient( generalName: string, ): string => message.liveTitle ?? profileTitle ?? generalName /** - * Draws a saved learning sequence, a browser replay's and a test replay's alike (Design 5): the participants' lifelines, one numbered arrow per step, then the numbered steps with their evidence, explanation and action. Participant names and explanations are text nodes; agent-authored markup never becomes executable content. + * Splits a name or label into the pieces a sequence diagram may break between: a run of letters without a space is one piece, and every Japanese, Chinese or Korean character or full-width form is a piece of its own (such text has no spaces), with a closing mark such as `、` or `。` kept on the character before it; called by {@link wrapSequenceText} and {@link sequenceView}. + * @param value - The text to split. + * @returns The pieces, each with whether a space came before it. + * @example sequencePieces('Ask 取得。') // => [{ text: 'Ask', spaceBefore: false }, { text: '取', spaceBefore: true }, { text: '得。', spaceBefore: false }] + */ + const sequencePieces = ( + value: string, + ): { text: string; spaceBefore: boolean }[] => { + const pieces: { text: string; spaceBefore: boolean }[] = [] + let spaceBefore = false + for (const match of value.matchAll( + /(\s+)|(\p{Script=Han}|\p{Script=Hiragana}|\p{Script=Katakana}|\p{Script=Hangul}|[\u3000-\u303F\uFF00-\uFFEF])|([^\s]+?(?=\s|\p{Script=Han}|\p{Script=Hiragana}|\p{Script=Katakana}|\p{Script=Hangul}|[\u3000-\u303F\uFF00-\uFFEF]|$))/gu, + )) { + if (match[1] !== undefined) { + spaceBefore = true + continue + } + const text = match[0], + previous = pieces[pieces.length - 1] + // A closing mark never starts a line: it stays with the character before it. + if ( + previous && + !spaceBefore && + /^[、。,.)」』】!?:;]$/u.test(text) + ) + previous.text += text + else pieces.push({ text, spaceBefore }) + spaceBefore = false + } + return pieces + } + /** + * Measures text in the diagram's font, so a Japanese, Chinese or upper-case label is sized as it is drawn (F293); called by {@link wrapSequenceText} and {@link sequenceView}. + * The width comes from a canvas set to the diagram's font; where no canvas is available (a test document), it is estimated from the kind of each character: a Japanese, Chinese or Korean glyph and a full-width form is about one em wide, an upper-case Latin letter wider than a lower-case one. + * @param value - The text to measure. + * @returns The width in pixels. + * @example sequenceTextWidthPx('取得') // => 24 + */ + const sequenceTextWidthPx = (value: string): number => { + const layout = options.sequence + const measured = sequenceFontContext()?.measureText(value).width + if (measured) return measured + let width = 0 + for (const character of value) + width += + /[\u1100-\u11FF\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE6F\uFF00-\uFF60\uFFE0-\uFFE6\u{20000}-\u{3FFFD}]/u.test( + character, + ) + ? layout.wideCharWidthPx + : /\p{Lu}/u.test(character) + ? layout.upperCharWidthPx + : layout.charWidthPx + return width + } + let sequenceMeasure: CanvasRenderingContext2D | null | undefined + /** + * Returns the canvas context that measures the diagram's text (its font size and the page's font family), made once; called by {@link sequenceTextWidthPx}. + * @returns The context, or `null` where the document has no canvas. + * @example sequenceFontContext()?.measureText('Source').width + */ + const sequenceFontContext = (): CanvasRenderingContext2D | null => { + if (sequenceMeasure === undefined) { + try { + sequenceMeasure = document.createElement('canvas').getContext('2d') + } catch { + // A document without canvas support falls back to the estimate. + sequenceMeasure = null + } + if (sequenceMeasure) + sequenceMeasure.font = `${options.sequence.fontSizePx}px ${getComputedStyle(document.body).fontFamily}` + } + return sequenceMeasure + } + /** + * Breaks a name or label into lines no wider than `maxPx` between words, and between the characters of text without spaces (Japanese, Chinese), so a sequence diagram never cuts a Latin word or ends a label with an ellipsis (F206, F293); called by {@link sequenceView}. + * A piece wider than the limit keeps a line of its own: the caller widens its column for the widest piece. + * @param value - The text to break. + * @param maxPx - The most pixels a line holds. + * @returns The lines, one empty line for empty text. + * @example wrapSequenceText('fetch filtered invoices', 80) // => ['fetch', 'filtered', 'invoices'] + */ + const wrapSequenceText = (value: string, maxPx: number): string[] => { + const lines: string[] = [] + for (const piece of sequencePieces(value)) { + const last = lines.length - 1, + joined = `${lines[last] ?? ''}${piece.spaceBefore ? ' ' : ''}${piece.text}` + if (last >= 0 && sequenceTextWidthPx(joined) <= maxPx) + lines[last] = joined + else lines.push(piece.text) + } + return lines.length ? lines : [''] + } + /** + * Writes the lines of a sequence name or label into an SVG text element; called by {@link sequenceView}. + * @param text - The SVG text element. + * @param x - The lines' horizontal anchor. + * @param lines - The wrapped lines. + * @param asTspans - Whether a single line is also written as a tspan (a participant's name is). + * @example writeSequenceLines(name, 80, ['Source', 'handler'], true) + */ + const writeSequenceLines = ( + text: SVGTextElement, + x: number, + lines: string[], + asTspans: boolean, + ): void => { + if (!asTspans && lines.length <= 1) { + text.textContent = lines[0] ?? '' + return + } + lines.forEach((value, index) => { + const span = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'tspan', + ) + span.setAttribute('x', String(x)) + span.setAttribute( + 'dy', + String(index ? options.sequence.nameLineHeightPx : 0), + ) + span.textContent = value + text.append(span) + }) + } + /** + * Draws a saved learning sequence, a browser replay's and a test replay's alike (Design 5): the participants' lifelines, one numbered arrow per step, then the numbered steps with their evidence, explanation and action. Participant names, labels and explanations are text nodes; agent-authored markup never becomes executable content. Names and labels wrap at word boundaries inside their boxes and the diagram grows taller; a longer word widens its column, and a diagram wider than the pane scrolls inside its own region (F206). * The browser replay passes its seek link as the action, a test replay its event selection; called for the Learning summary and the test replay section. * @param content - The material's participants and steps. * @param label - The diagram's accessible name. @@ -2069,55 +2196,84 @@ function studyClient( ): [visual: HTMLDivElement, steps: HTMLOListElement] => { const namespace = 'http://www.w3.org/2000/svg', diagram = document.createElementNS(namespace, 'svg') - const layout = options.sequence, - width = Math.max( - layout.minWidthPx, - content.participants.length * layout.columnWidthPx, + const layout = options.sequence + const stepLabels = content.steps.map( + (step, index) => `${index + 1}. ${step.label}`, + ) + // A column is wide enough for the longest word of any name or label, so no word is ever broken inside its box (F206). + const widestPiecePx = Math.max( + 0, + ...[...content.participants.map((value) => value.label), ...stepLabels] + .flatMap((value) => sequencePieces(value)) + .map((piece) => sequenceTextWidthPx(piece.text)), + ) + const columnWidth = Math.max( + layout.columnWidthPx, + Math.ceil(widestPiecePx + layout.columnPaddingPx), + ) + const textWithin = (spanPx: number): number => + spanPx - layout.columnPaddingPx + const width = Math.max( + layout.minWidthPx, + content.participants.length * columnWidth, + ) + const column = (id: string) => + columnWidth / 2 + + content.participants.findIndex((value) => value.id === id) * columnWidth + const nameLines = content.participants.map((participant) => + wrapSequenceText(participant.label, textWithin(columnWidth)), + ) + // The diagram grows taller for every extra line of a name or a label. + const namesExtraPx = + (Math.max(1, ...nameLines.map((lines) => lines.length)) - 1) * + layout.nameLineHeightPx + const stepLines = content.steps.map((step, index) => + wrapSequenceText( + stepLabels[index] ?? '', + textWithin( + Math.max(columnWidth, Math.abs(column(step.to) - column(step.from))), + ), ), - height = layout.topPaddingPx + content.steps.length * layout.rowHeightPx + ) + const rowOffsetsPx: number[] = [] + let rowsHeightPx = 0 + for (const lines of stepLines) { + rowOffsetsPx.push(rowsHeightPx) + rowsHeightPx += + layout.rowHeightPx + (lines.length - 1) * layout.nameLineHeightPx + } + const height = layout.topPaddingPx + namesExtraPx + rowsHeightPx diagram.setAttribute('viewBox', `0 0 ${width} ${height}`) diagram.setAttribute('width', String(width)) diagram.setAttribute('height', String(height)) diagram.setAttribute('role', 'img') diagram.setAttribute('aria-label', label) - const column = (id: string) => - layout.columnWidthPx / 2 + - content.participants.findIndex((value) => value.id === id) * - layout.columnWidthPx - for (const participant of content.participants) { + content.participants.forEach((participant, index) => { const x = column(participant.id), name = document.createElementNS(namespace, 'text'), line = document.createElementNS(namespace, 'line') name.setAttribute('x', String(x)) name.setAttribute('y', String(layout.nameYpx)) name.setAttribute('text-anchor', 'middle') - for ( - let offset = 0; - offset < participant.label.length; - offset += layout.nameChunkChars - ) { - const span = document.createElementNS(namespace, 'tspan') - span.setAttribute('x', String(x)) - span.setAttribute('dy', String(offset ? layout.nameLineHeightPx : 0)) - span.textContent = participant.label.slice( - offset, - offset + layout.nameChunkChars, - ) - name.append(span) - } + writeSequenceLines(name, x, nameLines[index] ?? [], true) line.setAttribute('x1', String(x)) line.setAttribute('x2', String(x)) - line.setAttribute('y1', String(layout.lifelineYpx)) + line.setAttribute('y1', String(layout.lifelineYpx + namesExtraPx)) line.setAttribute('y2', String(height)) line.setAttribute('class', 'sequence-lifeline') diagram.append(name, line) - } + }) const list = document.createElement('ol') list.className = 'sequence-steps' content.steps.forEach((step, index) => { - const x1 = column(step.from), + const lines = stepLines[index] ?? [], + x1 = column(step.from), x2 = column(step.to), - y = layout.firstArrowYpx + index * layout.rowHeightPx, + y = + layout.firstArrowYpx + + namesExtraPx + + (rowOffsetsPx[index] ?? 0) + + (lines.length - 1) * layout.nameLineHeightPx, arrow = document.createElementNS(namespace, 'path'), number = document.createElementNS(namespace, 'text') const direction = x2 >= x1 ? 1 : -1 @@ -2128,13 +2284,17 @@ function studyClient( : `M ${x1} ${y} H ${x2} m ${-layout.arrowWidthPx * direction} ${-layout.arrowHeightPx} l ${layout.arrowWidthPx * direction} ${layout.arrowHeightPx} l ${-layout.arrowWidthPx * direction} ${layout.arrowHeightPx}`, ) arrow.setAttribute('class', `sequence-arrow ${step.evidence}`) + // The label is centered over its arrow (a loop's over its lifeline), so a last participant's label stays inside the diagram; its lines stack upward from the arrow. + const labelX = (x1 + x2) / 2 + number.setAttribute('x', String(labelX)) number.setAttribute( - 'x', - String((x1 + x2) / 2 + (x1 === x2 ? layout.loopHeightPx : 0)), + 'y', + String( + y - layout.numberGapPx - (lines.length - 1) * layout.nameLineHeightPx, + ), ) - number.setAttribute('y', String(y - layout.numberGapPx)) number.setAttribute('text-anchor', 'middle') - number.textContent = `${index + 1}. ${step.label.slice(0, layout.nameChunkChars)}${step.label.length > layout.nameChunkChars ? '…' : ''}` + writeSequenceLines(number, labelX, lines, false) diagram.append(arrow, number) const row = document.createElement('li'), heading = document.createElement('strong'), @@ -2170,9 +2330,9 @@ function studyClient( ): void => { const suppressed = isReplayInBottomPanel(message) const section = suppressed ? undefined : message.nodeHistorySection - renderTestReplayMoment(message, section, suppressed) + renderTestReplayMoment(message, section, suppressed, replaying) renderTestReplayTransport(message, section, replaying, suppressed) - renderTestReplayLearning(message, section, replaying) + renderTestReplayLearning(message, section, replaying, suppressed) renderTestReplayUnderstanding(message, replaying) renderTestReplayEvents(message, section, replaying) } @@ -2187,6 +2347,7 @@ function studyClient( message: StudyViewMessage, section: StudyViewMessage['nodeHistorySection'], suppressed: boolean, + replaying: boolean, ): void => { const idle = suppressed ? '' @@ -2205,6 +2366,49 @@ function studyClient( options.nodeTransportFocus, 'node-history', ) + // The row's Inspect is wanted while the stop's values are not loaded (beside Show Variables) and whenever the debugger does not show the selected stop: after the learner ended its session, or when it could not start, the press starts it again (F348). + const inspectWanted = + !suppressed && + (uninspected || !message.nodeHistoryPosition?.debuggerAvailable) + renderTestReplayInspect(message, inspectWanted, replaying) + } + /** + * Offers Inspect in native debugger beside the values hint and Show Variables (DS6) for the selected stop, so the learner finds it without opening the event list: pressed ("Debugger stop selected") while the debugger shows the stop and its values are not loaded, and a press that starts the debugger again once it does not show the stop (F348); called by {@link renderTestReplayMoment} for every Study message. + * The stop is read from the selected moment's position, which the host sends whatever page the event list shows, so paging the list away from the stop keeps the control (F353). + * @param message - The current Study message. + * @param wanted - Whether the row wants the control: the selected stop's values are not loaded, or the debugger does not show the stop. + * @param replaying - Whether a browser recording is shown, which disables the control. + * @example renderTestReplayInspect(message, true, false) + */ + const renderTestReplayInspect = ( + message: StudyViewMessage, + wanted: boolean, + replaying: boolean, + ): void => { + const inspect = element('node-history-inspect') + const position = message.nodeHistoryPosition + // Only a selected stop has `sourceAvailable`, true when the extension verified its captured source. + const offered = + wanted && + inspect instanceof HTMLButtonElement && + position?.sourceAvailable === true + // Hidden through the rule Show Variables follows: a keyboard learner on it is handed to the transport, not to the page (F292). + offerShowVariables( + 'node-history-inspect', + offered, + options.nodeTransportFocus, + 'node-history', + ) + if (!offered) return + const shown = Boolean(position.debuggerAvailable) + inspect.dataset.nodeHistorySequence = String(position.sequence) + inspect.textContent = shown + ? 'Debugger stop selected' + : 'Inspect in native debugger' + // As the event list's control: the pressed Inspect stays focusable and sends nothing. + inspect.disabled = replaying + inspect.setAttribute('aria-disabled', String(shown)) + inspect.setAttribute('aria-pressed', String(shown)) } /** * Places the section's worded lines: status, locator, explanation, the not-recorded and unavailable-source lines, the output pointer and the closed pane's action; called by {@link renderTestReplayMoment}. @@ -2222,6 +2426,14 @@ function studyClient( text(id, value) element('node-history-closed').hidden = !section?.applicationClosed } + /** The section's lines that show one of its fields as it is, by element id, each empty when the field is absent. */ + const testReplayFieldLines = [ + ['node-history-capture-reason', 'captureReason'], + ['node-history-explanation', 'explanation'], + ['node-history-not-recorded', 'notRecorded'], + ['node-history-unverified', 'unverified'], + ['node-history-pointer', 'pointer'], + ] as const /** * Gives the text of each of the section's worded lines by its element id; called by {@link renderTestReplayLines}. * @param section - The section's worded view, absent while suppressed or without a test replay. @@ -2233,24 +2445,24 @@ function studyClient( section: StudyViewMessage['nodeHistorySection'], idle: string, ): Record => - section - ? { - 'node-history-status': section.status, - 'node-history-position': - section.position ?? options.testReplay.noSelection, - 'node-history-explanation': section.explanation ?? '', - 'node-history-not-recorded': section.notRecorded ?? '', - 'node-history-unverified': section.unverified ?? '', - 'node-history-pointer': section.pointer ?? '', - } - : { - 'node-history-status': idle, - 'node-history-position': '', - 'node-history-explanation': '', - 'node-history-not-recorded': '', - 'node-history-unverified': '', - 'node-history-pointer': '', - } + // Without a section every line is empty and the status line says `idle`. + ({ + 'node-history-status': section?.status ?? idle, + 'node-history-position': testReplayPositionText(section), + ...Object.fromEntries( + testReplayFieldLines.map(([id, field]) => [id, section?.[field] ?? '']), + ), + }) + /** + * Gives the selected moment's locator line; called by {@link testReplayLineTexts}. + * @param section - The section's worded view, absent while suppressed or without a test replay. + * @returns The section's position, the line that no moment is selected, or nothing without a section. + * @example testReplayPositionText(undefined) // => '' + */ + const testReplayPositionText = ( + section: StudyViewMessage['nodeHistorySection'], + ): string => + section ? (section.position ?? options.testReplay.noSelection) : '' /** * Enables or disables a control of the section by its id; called by the transport's renderers. * @param id - The control's id. @@ -2460,17 +2672,20 @@ function studyClient( * @param message - The current Study message. * @param section - The section's worded view. * @param replaying - Whether a browser recording is shown. - * @example renderTestReplayLearning(message, message.nodeHistorySection, false) + * @param suppressed - Whether a browser replay holds the bottom panel, which hides the block. + * @example renderTestReplayLearning(message, message.nodeHistorySection, false, false) */ const renderTestReplayLearning = ( message: StudyViewMessage, section: StudyViewMessage['nodeHistorySection'], replaying: boolean, + suppressed: boolean, ): void => { - const learning = message.nodeHistoryLearningSummary + // While a browser replay holds the bottom panel the block is not drawn: its steps would all read `No recorded event is linked to this step.` (F153). + const learning = suppressed ? undefined : message.nodeHistoryLearningSummary element('node-history-learning').hidden = !learning element('node-history-no-material').hidden = - !message.nodeHistory || Boolean(learning) + suppressed || !message.nodeHistory || Boolean(learning) if (!learning) { // A message without material keeps no earlier replay's diagram or steps in the page, hidden or not. renderKeys.delete('node-learning-sequence') @@ -2494,6 +2709,18 @@ function studyClient( element('node-learning-sequence').replaceChildren(visual) element('node-learning-steps').replaceChildren(list) } + markTestReplaySteps(section, replaying) + } + /** + * Marks the test replay's numbered steps for the selected moment (DS21): the selected step `aria-current`, the selected event's other steps `Same recorded moment`, and every step's control disabled while a browser recording is shown; called by {@link renderTestReplayLearning} for every Study message, also when the steps were drawn before. + * @param section - The section's worded view, which names the selected step and the steps of the same moment. + * @param replaying - Whether a browser recording is shown. + * @example markTestReplaySteps(message.nodeHistorySection, false) + */ + const markTestReplaySteps = ( + section: StudyViewMessage['nodeHistorySection'], + replaying: boolean, + ): void => { for (const control of Array.from( element('node-learning-steps').querySelectorAll( 'button[data-node-step]', @@ -2762,31 +2989,7 @@ function studyClient( } } /** - * Says whether a stop's first recorded frame names the stop's own source, hash, line and column, so the native debugger can show it at its captured source; called by {@link testReplayInspectControl}. - * @param entry - The stop. - * @returns `true` when the frame, the stop's frame and its source agree. - * @example stopFrameNamesItsSource(stop) // => true - */ - const stopFrameNamesItsSource = ( - entry: Extract< - NonNullable['entries'][number], - { type: 'stop' } - >, - ): boolean => { - const frame = entry.debuggerSnapshot.frames[0] - if (!frame?.source) return false - return ( - frame.source.name === entry.source.path && - frame.source.hash === entry.source.hash && - frame.source.line === entry.source.line && - entry.frame.line === entry.source.line && - entry.frame.column === entry.source.column && - frame.line === entry.frame.line && - frame.column === entry.frame.column - ) - } - /** - * Says whether a stop can be inspected in the native debugger: its first recorded frame names its own source ({@link stopFrameNamesItsSource}), the extension verified its captured text, and a selected stop's source is available; called by {@link testReplayInspectControl}. + * Says whether a stop can be inspected in the native debugger: the extension verified its captured source (the host names every shown stop that failed in `nodeHistoryUnverifiedStops`), and a selected stop's source is available; called by {@link testReplayInspectControl}. * @param entry - The stop. * @param message - The current Study message. * @param isSelected - Whether the stop is the selected event. @@ -2801,13 +3004,12 @@ function studyClient( message: StudyViewMessage, isSelected: boolean, ): boolean => - stopFrameNamesItsSource(entry) && - // The extension hashed every shown stop's captured text; a mismatch there cannot be inspected either. + // The extension verified every shown stop's captured source (path, line, column and text hash); one that failed cannot be inspected. !(message.nodeHistoryUnverifiedStops ?? []).includes(entry.sequence) && !(isSelected && message.nodeHistoryPosition?.sourceAvailable === false) /** * Builds a stop row's Inspect in native debugger, or the line that its captured source cannot be inspected; called by {@link testReplayEventRow}. - * A stop is inspectable when its first recorded frame names the stop's own source, hash, line and column and the extension verified its captured text. + * A stop is inspectable when the extension verified its captured source. * @param entry - The stop. * @param message - The current Study message. * @param isSelected - Whether the stop is the selected event. @@ -2913,7 +3115,11 @@ function studyClient( * @example offerClampedTitles() */ const offerClampedTitles = (): void => { - for (const heading of [element('title'), element('learning-title')]) { + for (const heading of [ + element('title'), + element('learning-title'), + element('node-learning-title'), + ]) { if (document.activeElement === heading) continue if (heading.scrollHeight > heading.clientHeight) heading.tabIndex = 0 else heading.removeAttribute('tabindex') diff --git a/extension/src/utils/accumulateNodeTestOutput.ts b/extension/src/utils/accumulateNodeTestOutput.ts index 1f1030368..dc801814b 100644 --- a/extension/src/utils/accumulateNodeTestOutput.ts +++ b/extension/src/utils/accumulateNodeTestOutput.ts @@ -1,12 +1,11 @@ import { NODE_TEST_OUTPUT_TAIL_CHARS } from '../constants' import { cutTextToTail } from './cutTextToTail' -import { NODE_TEST_REPLAY_TEXT } from './describeNodeTestReplay' import type { NodeTestOutputIndex, NodeTestOutputLine, - NodeTestOutputPendingLine, } from './indexNodeTestOutput' +import { nodeTestOutputLineCost } from './nodeTestOutputLineCost' /** A kept line of the Application pane: its absolute number in the replay's output (an unfinished last line takes the next number), and `cut` when only its end fits. */ export type NodeTestOutputViewLine = NodeTestOutputLine & { @@ -49,7 +48,7 @@ export function accumulateNodeTestOutput( } const { lineCount, pending, inspectorLines } = index.after[position]! const pendingCost = pending - ? markCost(pending.channel) + pending.codePoints + ? nodeTestOutputLineCost(pending.channel, pending.codePoints, false) : 0 const total = index.lineTotals[lineCount]! + pendingCost // The kept lines are gathered from the end backward and put in order once. @@ -58,46 +57,40 @@ export function accumulateNodeTestOutput( startsMidLine = false /** Keeps a line whole when it fits, else its end, and says whether an earlier line can still be kept; `cost` is the line's shown code points, read from the index, never counted again. */ const keep = ( - line: () => NodeTestOutputLine, - tail: (characters: number) => string, + channel: NodeTestOutputLine['channel'], + span: { start: number; end: number }, cost: number, number: number, lineFeed: boolean, ): boolean => { if (used + cost <= limit) { - keptFromEnd.push({ ...line(), number }) + keptFromEnd.push({ + channel, + text: index.body.slice(span.start, span.end), + number, + }) used += cost return used < limit } // A cut line keeps its end and no `[stderr] ` mark, which belongs to its start. const characters = limit - used - (lineFeed ? 1 : 0) if (characters > 0) { - keptFromEnd.push({ - channel: line().channel, - text: tail(characters), - number, - cut: true, - }) - used += characters + (lineFeed ? 1 : 0) + const text = spanTail(index.body, span, characters) + keptFromEnd.push({ channel, text, number, cut: true }) + // The count is taken from the kept text: a line shorter than the room left keeps all of it (F114). + used += Array.from(text).length + (lineFeed ? 1 : 0) startsMidLine = true } return false } // From the end backward: the unfinished line first, then the complete lines, until the tail is full. let earlierFits = pending - ? keep( - () => ({ channel: pending.channel, text: pendingText(pending) }), - (characters) => pendingTail(pending, characters), - pendingCost, - lineCount, - false, - ) + ? keep(pending.channel, pending, pendingCost, lineCount, false) : true for (let number = lineCount - 1; earlierFits && number >= 0; number--) { - const line = index.lines[number]! earlierFits = keep( - () => line, - (characters) => cutTextToTail(line.text, characters), + index.lineChannels[number]!, + { start: index.lineStarts[number]!, end: index.lineEnds[number]! }, index.lineTotals[number + 1]! - index.lineTotals[number]!, number, true, @@ -114,49 +107,23 @@ export function accumulateNodeTestOutput( } /** - * The code points the `[stderr] ` mark adds to a line of a channel; called by {@link accumulateNodeTestOutput} for the unfinished line, whose own code points the index counted. - * @param channel - The line's channel. - * @returns The mark's length for standard error, else 0. - * @example markCost('stderr') // => 9 - */ -function markCost(channel: NodeTestOutputLine['channel']): number { - return channel === 'stderr' ? NODE_TEST_REPLAY_TEXT.stderrMark.length : 0 -} - -/** - * Joins the unfinished line as an entry left it; called by {@link accumulateNodeTestOutput} only when the whole line fits the tail. - * @param pending - The unfinished line at the selected entry. - * @returns Its text. - * @example pendingText({ channel: 'stdout', chunks: ['1 ', '2 ', '3'], chunkCount: 2, codePoints: 4 }) // => '1 2 ' - */ -function pendingText(pending: NodeTestOutputPendingLine): string { - return pending.chunks.slice(0, pending.chunkCount).join('') -} - -/** - * Keeps the end of the unfinished line as an entry left it, reading its chunks from the last one backward only until they hold enough, so a line printed without line feeds costs its tail, not its length; called by {@link accumulateNodeTestOutput} when the line does not fit whole. - * @param pending - The unfinished line at the selected entry. + * Keeps the end of a line, reading only as much of the shared body as the end needs, so a line printed without line feeds costs its tail, not its length; called by {@link accumulateNodeTestOutput} for a line that does not fit whole. + * @param body - The index's shown text. + * @param span - The line's span in the body. * @param characters - How many code points of its end to keep. - * @returns Its last `characters` code points. - * @example pendingTail({ channel: 'stdout', chunks: ['1 2 ', '3 4 '], chunkCount: 2, codePoints: 8 }, 3) // => ' 4 ' + * @returns The line's last `characters` code points (all of it when it has fewer). + * @example spanTail('1 2 3 4 ', { start: 0, end: 8 }, 3) // => ' 4 ' */ -function pendingTail( - pending: NodeTestOutputPendingLine, +function spanTail( + body: string, + span: { start: number; end: number }, characters: number, ): string { - const parts: string[] = [] - let held = 0 - // A code point is at most two UTF-16 units, so chunks holding twice the units hold at least the code points asked for. - for ( - let chunk = pending.chunkCount - 1; - chunk >= 0 && held < characters * 2; - chunk-- - ) { - const text = pending.chunks[chunk]! - parts.push(text) - held += text.length - } - return cutTextToTail(parts.reverse().join(''), characters) + // A code point is at most two UTF-16 units, so the last twice the units hold at least the code points asked for. + return cutTextToTail( + body.slice(Math.max(span.start, span.end - characters * 2), span.end), + characters, + ) } /** diff --git a/extension/src/utils/capturedAppHtml.ts b/extension/src/utils/capturedAppHtml.ts index 165483cbf..8a1377975 100644 --- a/extension/src/utils/capturedAppHtml.ts +++ b/extension/src/utils/capturedAppHtml.ts @@ -5,10 +5,19 @@ import { STUDY_STYLES, STUDY_UI_OPTIONS } from '../ui/constants' import type { StudyViewMessage } from '../ui/studyHtml' import { escapeHtml } from './escapeHtml' +import type { NodeTestOutputReading } from './installNodeTestOutput' import { testOutputHtml } from './testOutputHtml' -/** Renders a labeled historical PNG, a test replay's recorded output, or explicit absence without granting a runtime paint receipt. @example capturedAppHtml(message) */ -export function capturedAppHtml(message: StudyViewMessage): string { +/** + * Renders a labeled historical PNG, a test replay's recorded output, or explicit absence without granting a runtime paint receipt. + * @param message - The Study message whose selection the pane shows. + * @param reading - For a test replay, the learner's follow choice and scroll anchor as the host keeps them (D22); the document follows the output without it. + * @example capturedAppHtml(message) + */ +export function capturedAppHtml( + message: StudyViewMessage, + reading?: NodeTestOutputReading, +): string { const nonce = randomUUID() // A test replay has no application to show; its pane is the recorded output, one document updated in place for each event (EN3). if (message.nodeHistoryApplication) @@ -16,6 +25,7 @@ export function capturedAppHtml(message: StudyViewMessage): string { message.nodeHistoryApplication, nonce, message.nodeHistory?.header.studyId ?? '', + reading, ) const image = message.model.image, observation = message.model.observation diff --git a/extension/src/utils/countCodePoints.ts b/extension/src/utils/countCodePoints.ts new file mode 100644 index 000000000..716745052 --- /dev/null +++ b/extension/src/utils/countCodePoints.ts @@ -0,0 +1,24 @@ +import { isSurrogatePairAt } from './isSurrogatePairAt' + +/** + * Counts the code points of a part of a text without copying it, so the output index can size a line of a long recording without making an array of its characters; called by {@link indexNodeTestOutput} for each part of a line. + * A lone surrogate counts as one code point, as `Array.from` counts it. + * @param text - The text. + * @param from - The UTF-16 offset the part starts at. + * @param to - The UTF-16 offset the part ends at (exclusive). + * @returns The code points of `text.slice(from, to)`. + * @example countCodePoints('a😀b', 0, 4) // => 3 + */ +export function countCodePoints( + text: string, + from: number, + to: number, +): number { + let count = 0 + for (let position = from; position < to; position++) { + // A high surrogate followed by a low one inside the part is one code point, so the low one is skipped. + if (position + 1 < to && isSurrogatePairAt(text, position)) position++ + count++ + } + return count +} diff --git a/extension/src/utils/describeNodeRecordedSource.ts b/extension/src/utils/describeNodeRecordedSource.ts index 6fc7de3c3..4e6943466 100644 --- a/extension/src/utils/describeNodeRecordedSource.ts +++ b/extension/src/utils/describeNodeRecordedSource.ts @@ -1,6 +1,7 @@ import { basename } from 'node:path' import { formatEventSeconds } from './formatEventSeconds' +import { stripTerminalControls } from './stripTerminalControls' /** * The fixed words of a test replay's recorded-source slot and its debugger thread, the String catalog of plan version 7 (DS5, DS14); words that hold a value are worded by {@link describeNodeRecordedSource}. @@ -43,6 +44,7 @@ export type NodeRecordedSourceMessage = /** * Words one recorded-source message of a test replay; {@link StudyWorkspace} calls it for the slot's label and tab, the replay debugger's thread and the slot's open failure. + * A recorded path is shown without terminal and bidirectional controls ({@link stripTerminalControls}), so a control in a file name never reorders the label, the tab title or the thread name around it; the real path stays with the caller for the slot's URI hash and the source identity (F286). * @param message - The surface and its values; `stop` is 1-based. * @returns The catalog's sentence with the values filled in. * @example @@ -55,14 +57,14 @@ export function describeNodeRecordedSource( ): string { switch (message.kind) { case 'label': - return `Recorded: ${message.path}:${message.line} · ${formatEventSeconds(message.elapsedMs)} s` + return `Recorded: ${stripTerminalControls(message.path)}:${message.line} · ${formatEventSeconds(message.elapsedMs)} s` case 'thread': - return `Recorded test stop ${message.stop} · ${message.path}:${message.line}` + return `Recorded test stop ${message.stop} · ${stripTerminalControls(message.path)}:${message.line}` case 'open-failed': return message.stop === undefined ? `Codebook could not open the recorded source: ${message.cause}. Details: Output › Codebook.` : `Codebook could not open the recorded source of stop ${message.stop}: ${message.cause}. Details: Output › Codebook.` case 'tab-title': - return `Captured-${basename(message.path)}` + return `Captured-${basename(stripTerminalControls(message.path))}` } } diff --git a/extension/src/utils/describeNodeTestReplay.ts b/extension/src/utils/describeNodeTestReplay.ts index 0ee1ce78d..27e381ed2 100644 --- a/extension/src/utils/describeNodeTestReplay.ts +++ b/extension/src/utils/describeNodeTestReplay.ts @@ -48,22 +48,12 @@ export const NODE_TEST_REPLAY_TEXT = { /** 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' - summary: Pick< - NodeTestHistorySummary, - | 'status' - | 'verification' - | 'verdict' - | 'stopCount' - | 'explainedStopCount' - > - } + | { kind: 'saved-row'; summary: NodeTestReplayMessageSummary } | { kind: 'study-status' summary: Omit } - | { kind: 'application-time'; elapsedMs: number } + | { kind: 'application-time'; elapsedMs?: number } | { kind: 'application-test'; test: NodeTestReplayTestName } | { kind: 'application-name' @@ -87,6 +77,11 @@ export type NodeTestReplayMessage = elapsedMs: number } | { kind: 'events'; count: number } + | { + kind: 'capture-reason' + status: 'partial' | 'interrupted' + reason: string + } /** The parts of a history's summary its Study status line names. */ type NodeTestReplayMessageSummary = Pick< @@ -131,6 +126,8 @@ export function describeNodeTestReplay(message: NodeTestReplayMessage): string { : `Inspect ${message.event.type} · ${formatEventSeconds(message.elapsedMs)} s` case 'events': return `Events (${message.count})` + case 'capture-reason': + return `Capture ${message.status}: ${message.reason}` default: return applicationWords(message) } @@ -141,13 +138,17 @@ export function describeNodeTestReplay(message: NodeTestReplayMessage): string { * @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' + * applicationWords({ kind: 'application-time' }) // => 'Recorded test output' */ function applicationWords( message: Extract, ): string { switch (message.kind) { case 'application-time': - return `Recorded test output · ${formatEventSeconds(message.elapsedMs)} s` + // Without a selected event (loading, no entries) the header names the surface and no time (E2, AC5). + return message.elapsedMs === undefined + ? 'Recorded test output' + : `Recorded test output · ${formatEventSeconds(message.elapsedMs)} s` case 'application-test': return testName(message.test) case 'application-name': @@ -157,9 +158,9 @@ function applicationWords( case 'application-region': return `Recorded test output up to ${formatEventSeconds(message.elapsedMs)} s` case 'application-cut': - return `… ${message.characters} earlier characters not shown${message.startsMidLine ? '; the first line below is cut' : ''}` + return `… ${message.characters} earlier ${message.characters === 1 ? 'character' : 'characters'} not shown${message.startsMidLine ? '; the first line below is cut' : ''}` case 'application-inspector': - return `${message.lines} Node inspector lines hidden` + return `${message.lines} Node inspector ${message.lines === 1 ? 'line' : 'lines'} hidden` } } diff --git a/extension/src/utils/indexNodeTestOutput.ts b/extension/src/utils/indexNodeTestOutput.ts index cbdae04e0..54acde1ec 100644 --- a/extension/src/utils/indexNodeTestOutput.ts +++ b/extension/src/utils/indexNodeTestOutput.ts @@ -1,7 +1,9 @@ import type { NodeTestHistoryIndex } from '../nodeTestHistorySchemas' +import { countCodePoints } from './countCodePoints' +import { isSurrogatePairAt } from './isSurrogatePairAt' import { nodeTestOutputLineCost } from './nodeTestOutputLineCost' -import { stripTerminalControls } from './stripTerminalControls' +import { stripTerminalControlsAcross } 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 = { @@ -10,13 +12,13 @@ export type NodeTestOutputLine = { } /** - * A line the output left unfinished at one entry, as its chunks so far: `chunks` is shared with the later entries that continue the line, and this entry's part of it is its first `chunkCount` chunks, so the index keeps one copy of the line however many entries continue it. - * `codePoints` counts the code points of those chunks. + * A line the output left unfinished at one entry, as the span of the index's `body` it holds so far: `body.slice(start, end)`; `codePoints` counts the code points of that span. + * The later entries that continue the line extend the same span, and the index keeps no copy of its text. */ export type NodeTestOutputPendingLine = { channel: NodeTestOutputLine['channel'] - chunks: readonly string[] - chunkCount: number + start: number + end: number codePoints: number } @@ -29,11 +31,15 @@ export type NodeTestOutputAfterEntry = { /** * 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`. + * The lines are numbers, not objects (F149): `body` is the shown text of every output entry in order, and complete line `i` is `body.slice(lineStarts[i], lineEnds[i])`, written by `lineChannels[i]`; line objects are made only for the kept tail. + * `lineTotals[i]` is how many code points lines `0..i-1` take as shown (the `[stderr] ` mark and a line feed included); it has one more element than there are lines. */ export type NodeTestOutputIndex = { sequences: number[] - lines: NodeTestOutputLine[] + body: string + lineStarts: number[] + lineEnds: number[] + lineChannels: NodeTestOutputLine['channel'][] lineTotals: number[] after: NodeTestOutputAfterEntry[] } @@ -50,92 +56,124 @@ const INSPECTOR_LINES = [ /** * 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. + * 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, and the Node inspector's own standard-error lines are counted instead of shown; the entries' terminal controls were removed before, across the entries of each channel ({@link stripOutputEntries}). Each line is kept as the offsets of its text in one shared body (F149). + * @param entries - The shown history's events in sequence order ({@link sanitizeNodeTestHistory} removed their terminal controls, once); only output entries are read. + * @returns The index: the output entries' sequences, the shown text, the complete lines' offsets and channels, 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', chunks: ['ok'], chunkCount: 1, codePoints: 2 }, inspectorLines: 0 } + * // => { lineCount: 1, pending: { channel: 'stdout', start: 4, end: 6, codePoints: 2 }, inspectorLines: 0 } */ export function indexNodeTestOutput( entries: readonly NodeTestHistoryIndex['entries'][number][], ): NodeTestOutputIndex { + const outputEntries = entries.filter((entry) => entry.type === 'output') + // The output is shown text already: stripping it again would pass over the whole output for nothing (F315, F339). + const body = outputEntries.map((entry) => entry.text).join('') const index: NodeTestOutputIndex = { sequences: [], - lines: [], + body, + lineStarts: [], + lineEnds: [], + lineChannels: [], lineTotals: [0], after: [], } - /** The unfinished line: its chunks (shared with every entry's record of it) and their code points. */ - let pending: - | { - channel: NodeTestOutputLine['channel'] - chunks: string[] - codePoints: number - } - | undefined, + /** The unfinished line: the span of the body it holds so far. */ + let pending: NodeTestOutputPendingLine | undefined, inspectorLines = 0 - const complete = (line: NodeTestOutputLine): void => { + const complete = (line: NodeTestOutputPendingLine): void => { if ( line.channel === 'stderr' && - INSPECTOR_LINES.some((pattern) => pattern.test(line.text)) + INSPECTOR_LINES.some((pattern) => + pattern.test(body.slice(line.start, line.end)), + ) ) { inspectorLines++ return } - index.lines.push(line) + index.lineStarts.push(line.start) + index.lineEnds.push(line.end) + index.lineChannels.push(line.channel) index.lineTotals.push( - index.lineTotals.at(-1)! + nodeTestOutputLineCost(line, true), + index.lineTotals.at(-1)! + + nodeTestOutputLineCost(line.channel, line.codePoints, true), ) } - /** Ends the unfinished line: its chunks are joined once, when the line is complete. */ + /** Ends the unfinished line. */ const completePending = (): void => { - if (pending) - complete({ channel: pending.channel, text: pending.chunks.join('') }) + if (pending) complete(pending) pending = undefined } - /** Adds a part to the unfinished line of a channel, starting the line when there is none. */ + /** Adds the span `from..to` of the body to the unfinished line of a channel, starting the line when there is none. */ const continuePending = ( channel: NodeTestOutputLine['channel'], - part: string, + from: number, + to: number, ): void => { - pending ??= { channel, chunks: [], codePoints: 0 } + const isContinuing = pending !== undefined + pending ??= { channel, start: from, end: from, codePoints: 0 } // An empty part continues nothing: a chunk that ends with a line feed leaves no unfinished line. - if (!part) return - pending.chunks.push(part) - pending.codePoints += Array.from(part).length + if (to <= from) return + // A surrogate pair split across two entries is one code point, not two (F291). + const joinsPair = + isContinuing && pending.end === from && isSurrogatePairAt(body, from - 1) + pending.end = to + pending.codePoints += countCodePoints(body, from, to) - (joinsPair ? 1 : 0) } - /** 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 => { + /** Joins one output chunk (the span `from..to` of the body) 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'], + from: number, + to: number, + ): void => { if (pending && pending.channel !== channel) completePending() - const parts = text.split('\n') - continuePending(channel, parts[0]!) - for (const part of parts.slice(1)) { + const text = body.slice(from, to) + let cursor = 0 + for (;;) { + const feed = text.indexOf('\n', cursor) + continuePending(channel, from + cursor, feed === -1 ? to : from + feed) + if (feed === -1) break completePending() - continuePending(channel, part) + cursor = feed + 1 } - if (pending && !pending.chunks.length) pending = undefined + if (pending && pending.end === pending.start) pending = undefined } - for (const entry of entries) { - if (entry.type !== 'output') continue - const text = stripTerminalControls(entry.text) + let offset = 0 + for (const entry of outputEntries) { + const from = offset + offset += entry.text.length // A chunk that held only controls changes no line, not even the channel of an unfinished one. - if (text) read(entry.channel, text) + if (offset > from) read(entry.channel, from, offset) index.sequences.push(entry.sequence) index.after.push({ - lineCount: index.lines.length, - ...(pending - ? { - pending: { - channel: pending.channel, - chunks: pending.chunks, - chunkCount: pending.chunks.length, - codePoints: pending.codePoints, - }, - } - : {}), + lineCount: index.lineStarts.length, + ...(pending ? { pending: { ...pending } } : {}), inspectorLines, }) } return index } + +/** + * Removes the terminal controls of every output entry, reading each channel's entries as one stream so a control string split across entries goes whole (F109, F148); called by {@link sanitizeNodeTestHistory} once per opened test replay, before {@link indexNodeTestOutput} reads the shown text. + * @param entries - The history's events in sequence order; only output entries are read. + * @returns Each output entry's shown text, by the entry's sequence. + * @example stripOutputEntries([{ type: 'output', sequence: 1, elapsedMs: 5, channel: 'stdout', text: '\u001b[3' }, { type: 'output', sequence: 2, elapsedMs: 6, channel: 'stdout', text: '2mok' }]) // => Map { 1 => '', 2 => 'ok' } + */ +export function stripOutputEntries( + entries: readonly NodeTestHistoryIndex['entries'][number][], +): Map { + const shown = new Map() + for (const channel of ['stdout', 'stderr'] as const) { + const stream = entries.filter( + (entry) => entry.type === 'output' && entry.channel === channel, + ) + const stripped = stripTerminalControlsAcross( + stream.map((entry) => (entry.type === 'output' ? entry.text : '')), + ) + stream.forEach((entry, position) => + shown.set(entry.sequence, stripped[position]!), + ) + } + return shown +} diff --git a/extension/src/utils/installClampedTitleStop.ts b/extension/src/utils/installClampedTitleStop.ts new file mode 100644 index 000000000..a976a244d --- /dev/null +++ b/extension/src/utils/installClampedTitleStop.ts @@ -0,0 +1,21 @@ +/** + * Makes a heading that a line clamp cuts a tab stop in a webview document, so a keyboard or touch learner can focus it and read it whole (the style shows a focused heading unclamped); runs in the page, so it reads nothing outside its arguments. + * Called by {@link testOutputHtml} for the Application pane's test line, as the Study pane does for its titles (F144). + * The heading is measured again after the page's layout settles, when the window is resized and when it loses the focus (a title that was skipped while focused, and has since come to fit, then drops its tab stop, F297); a focused heading is not measured, because it is shown whole and would read as fitting. + * @param headingId - The id of the clamped heading. + * @example installClampedTitleStop('test-output-test') + */ +export function installClampedTitleStop(headingId: string): void { + const heading = document.getElementById(headingId) + if (!heading) return + const offer = (): void => { + if (document.activeElement === heading) return + if (heading.scrollHeight > heading.clientHeight) heading.tabIndex = 0 + else heading.removeAttribute('tabindex') + } + window.addEventListener('resize', offer) + heading.addEventListener('blur', offer) + // The first measure waits for a frame, when the document's styles have laid the heading out. + requestAnimationFrame(offer) + offer() +} diff --git a/extension/src/utils/installNodeTestOutput.ts b/extension/src/utils/installNodeTestOutput.ts index 30e5a245f..088c2b1fe 100644 --- a/extension/src/utils/installNodeTestOutput.ts +++ b/extension/src/utils/installNodeTestOutput.ts @@ -1,38 +1,65 @@ import type { NodeTestApplicationView } from './projectNodeTestApplication' +/** Where the learner reads in the output region: the number of the first line in view and how far its top is above the region's top, so the same place is found again in a document written later (EN3). */ +export type NodeTestOutputAnchor = { + line: number + offsetPx: number +} + +/** What the host keeps of the learner's reading of one test replay's output (D22): the follow choice and the scroll anchor. */ +export type NodeTestOutputReading = { + follow: boolean + anchor?: NodeTestOutputAnchor +} + /** What the document's script needs from the host; passed in, as the script imports nothing. */ export type NodeTestOutputOptions = { /** Starts a line the test wrote to standard error. */ stderrMark: string - /** The test replay the document shows; the learner's follow choice belongs to it (DS20). */ + /** The test replay the document shows; the host files the learner's reading under it (DS20). */ history: string /** How close to its end the output region counts as at its end. */ endTolerancePx: number - /** At most how often the document writes its state; the writes in between are joined into one. */ - stateWriteIntervalMs: number + /** At most how often the document reports the learner's scroll anchor to the host; the reports in between are joined into one. */ + stateReportIntervalMs: number + /** The first view, drawn by the same code that draws every later one (F136). */ + view: NodeTestApplicationView + /** The learner's reading of this replay as the host keeps it: a new document takes it, never state an earlier document left (D22). */ + reading: NodeTestOutputReading } -/** The message the Study workspace posts to the Application pane for each later event of the same test replay. */ +/** The message the Study workspace posts to the Application pane for each later event of the same test replay; the answer to the document's ready message also carries the host's reading. */ export type NodeTestOutputUpdate = { type: 'node-history-output' generation: number view: NodeTestApplicationView + reading?: NodeTestOutputReading } +/** What the document tells the host: it is ready for views (with the generation it shows and its follow choice), or the learner's reading changed. */ +export type NodeTestOutputReport = + | { + type: 'node-history-output-ready' + history: string + generation: number + follow: boolean + } + | ({ + type: 'node-history-output-state' + history: string + } & NodeTestOutputReading) + /** - * 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 testOutputHtml} writes, serialized with `toString()`, so it imports nothing. - * The follow choice and the latest moment's view are kept in the webview's state with the replay they belong to: a document of the same replay that the editor loads again in the same panel (moved to another window, webviews reloaded) keeps the learner's choice and shows the latest moment. A replacement panel starts with an empty state, so the restore copy written into it starts following, as another replay's document does. The state is written at most once per interval while moments stream in, and when the document is left. + * Draws the Application pane's one document for a test replay and applies each later event to it 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 testOutputHtml} writes, serialized with `toString()`, so it imports nothing. + * The first view is drawn by the same code as every later one. The learner's follow choice and scroll anchor belong to the host, which files them under the replay and passes them in the options of each document it writes, so a document that replaces another of the same replay (a presentation change, a Retry, a reload) opens where the learner was (D22). The document reports a change of its follow choice at once and its scroll anchor at most once per interval, and tells the host when it is ready, so a document that VS Code reloads in place is brought to the host's current view (F141). Views posted in a burst are drawn once per animation frame, the newest only (F140), and the host's reading in a replaced view is carried to the newest, so it is not lost (F290); a reading that arrives after the learner scrolled or pressed Jump to latest is ignored, because the learner's own place is newer (F290). Following is decided by the scroll position alone: a scroll that leaves the position the document itself set is the learner's (F106). * @param api - The webview's API. - * @param generation - The generation of the view the document was written with. - * @param options - The stderr mark, the shown replay, the end tolerance and the interval of state writes. - * @example installNodeTestOutput(acquireVsCodeApi(), 3, { stderrMark: '[stderr] ', history: 'study_1', endTolerancePx: 2, stateWriteIntervalMs: 500 }) + * @param options - The stderr mark, the shown replay, the end tolerance, the report interval, the first view and the host's reading. + * @example installNodeTestOutput(acquireVsCodeApi(), { stderrMark: '[stderr] ', history: 'study_1', endTolerancePx: 2, stateReportIntervalMs: 500, view, reading: { follow: true } }) */ export function installNodeTestOutput( api: { - getState(): unknown - setState(state: unknown): void + postMessage(message: NodeTestOutputReport): void }, - generation: number, options: NodeTestOutputOptions, ): void { const element = (id: string): HTMLElement => { @@ -42,78 +69,73 @@ export function installNodeTestOutput( } const output = element('test-output'), jump = element('test-output-jump') - /** The webview's state as an object, or none. */ - const savedState = (): Record | undefined => { - const state: unknown = api.getState() - return typeof state === 'object' && state !== null - ? { ...state } - : undefined - } - /** What this document kept of its own replay before the editor loaded it again (moved to another window, webviews reloaded): the follow choice and the latest moment's view; another replay's state does not carry over (DS20). */ - const keptState = (): { - follow: boolean - view: NodeTestApplicationView | undefined - } => { - const state = savedState() - if (state?.testOutputHistory !== options.history) - return { follow: true, view: undefined } - const view = state.testOutputView + let shown = options.view.generation, + follow = options.reading.follow, + // The scroll position the document itself left; a scroll event that ends elsewhere is the learner's. + expectedTop = 0, + queued: NodeTestOutputUpdate | undefined, + // Whether the learner scrolled or pressed Jump to latest in this document: a reading the host built before that is older than the learner's own place (F290). + learnerActed = false, + frame: number | undefined, + reportTimer: ReturnType | undefined, + reportDue = false + /** Finds the first line in view and how far its top is above the region's top, by bisection over the lines (each probe reads one rectangle). */ + const currentAnchor = (): NodeTestOutputAnchor | undefined => { + const lines = output.children + const regionTop = output.getBoundingClientRect().top + let low = 0, + high = lines.length - 1 + while (low < high) { + const middle = (low + high) >> 1 + if (lines[middle]!.getBoundingClientRect().bottom > regionTop) + high = middle + else low = middle + 1 + } + const first = lines[low] + if (!(first instanceof HTMLElement)) return undefined return { - follow: - typeof state.testOutputFollow === 'boolean' - ? state.testOutputFollow - : true, - // The kept view is this document's own earlier update, which it wrote into its state itself. - view: - typeof view === 'object' && - view !== null && - 'generation' in view && - typeof view.generation === 'number' - ? (view as NodeTestApplicationView) - : undefined, + line: Number(first.dataset.line), + offsetPx: regionTop - first.getBoundingClientRect().top, } } - const kept = keptState() - let shown = generation, - follow = kept.follow, - scrolledByDocument = false, - latestView: NodeTestApplicationView | null = null, - writeTimer: ReturnType | undefined, - writeDue = false - /** Writes the follow choice and the latest moment's view with the replay they belong to, so the document the editor loads again shows them. */ - const writeState = (): void => { - writeDue = false - api.setState({ - ...savedState(), - testOutputFollow: follow, - testOutputHistory: options.history, - testOutputView: latestView, + /** Tells the host the learner's reading: the follow choice and the scroll anchor. */ + const reportState = (): void => { + reportDue = false + api.postMessage({ + type: 'node-history-output-state', + history: options.history, + follow, + anchor: currentAnchor(), }) } - /** Writes the state now, or once the interval since the last write ends: each write copies the whole shown output, and Play posts up to twenty moments a second. */ - const remember = (): void => { - if (writeTimer !== undefined) { - writeDue = true + /** Reports now, or once the interval since the last report ends: scrolling produces events faster than the host needs the anchor. */ + const reportSoon = (): void => { + if (reportTimer !== undefined) { + reportDue = true return } - writeState() - writeTimer = setTimeout(() => { - writeTimer = undefined - // The moments that came during the interval are written as one. - if (writeDue) remember() - }, options.stateWriteIntervalMs) - } - // A document that is left (moved to another window, webviews reloaded) writes the moment it shows before it goes. + reportState() + reportTimer = setTimeout(() => { + reportTimer = undefined + // The scrolls that came during the interval are reported as one. + if (reportDue) reportSoon() + }, options.stateReportIntervalMs) + } + // A document that is left reports the anchor it had joined before it goes. window.addEventListener('pagehide', () => { - if (writeDue) writeState() + if (reportDue) reportState() }) + /** Remembers where the document left the scroll position, so the scroll event it causes (or none) is told from the learner's. */ + const settle = (): void => { + expectedTop = output.scrollTop + } /** 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. */ + /** 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. The one place that draws a line (F136). */ const lineNode = ( view: NodeTestApplicationView['lines'][number], ): HTMLSpanElement => { @@ -138,50 +160,80 @@ export function installNodeTestOutput( output.scrollHeight - output.scrollTop - output.clientHeight <= options.endTolerancePx const toEnd = (): void => { - scrolledByDocument = true // Instant, never smooth: the learner's reduced-motion choice needs no separate path. output.scrollTop = output.scrollHeight + settle() } - /** 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))) - kept.set(Number(node.dataset.line), node) - else node.remove() - return kept + /** Every drawn line by its number, with the line it shows: a frame compares a line with what it drew, never by querying the document, and the lines a view leaves are found without walking the output (F314). */ + const drawn = new Map< + number, + { node: HTMLElement; shows: NodeTestApplicationView['lines'][number] } + >() + /** Removes the drawn lines a view no longer holds. */ + const dropLeftLines = (lines: NodeTestApplicationView['lines']): void => { + const wanted = new Set(lines.map((value) => value.number)) + for (const [number, line] of drawn) + if (!wanted.has(number)) { + line.node.remove() + drawn.delete(number) + } } /** 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 kept = drawn.get(value.number) const unchanged = - node?.dataset.channel === value.channel && - (node.dataset.cut === 'true') === Boolean(value.cut) && - node.querySelector('.output-text')?.textContent === value.text - if (unchanged && node) return node + kept?.shows.channel === value.channel && + Boolean(kept.shows.cut) === Boolean(value.cut) && + kept.shows.text === value.text + if (unchanged && kept) return kept.node const placed = lineNode(value) - if (node) node.replaceWith(placed) + if (kept) kept.node.replaceWith(placed) else if (previous) previous.after(placed) else output.prepend(placed) + drawn.set(value.number, { node: placed, shows: value }) return placed } /** Brings the output region's lines to a view's in place, in time linear in the lines. */ const placeLines = (lines: NodeTestApplicationView['lines']): void => { - const kept = keptLines(lines) + dropLeftLines(lines) let previous: Element | undefined - for (const value of lines) - previous = placeLine(value, kept.get(value.number), previous) + for (const value of lines) previous = placeLine(value, previous) + } + /** Scrolls so that the anchor's line stands where it stood; an anchor whose line the view no longer holds leaves the region at its start. */ + const restoreAnchor = (anchor: NodeTestOutputAnchor): void => { + const target = drawn.get(anchor.line)?.node + if (target) + output.scrollTop += + target.getBoundingClientRect().top - + output.getBoundingClientRect().top + + anchor.offsetPx + else output.scrollTop = 0 + settle() + } + /** Takes the host's reading of the replay: following goes to the end, otherwise the anchor's line is found again. */ + const takeReading = (reading: NodeTestOutputReading): void => { + follow = reading.follow + jump.hidden = follow + if (follow) toEnd() + else if (reading.anchor) restoreAnchor(reading.anchor) + else settle() + } + /** Places the region after a view was drawn: following goes to the end; otherwise the learner's line stands where it stood, whatever the webview's own scroll anchoring did with lines dropped above it (F354), or the position is recorded as the document's own. */ + const placeReading = (anchor: NodeTestOutputAnchor | undefined): void => { + // A shorter output clamps the scroll position: that is the document's change, never the learner's return to the end (DS20), and `settle` records it as such. + if (follow) toEnd() + else if (anchor && drawn.has(anchor.line)) restoreAnchor(anchor) + else settle() } const apply = (view: NodeTestApplicationView): void => { + // The line the learner reads, taken before the lines change: a view that drops earlier lines would otherwise move it up (F354). + const readingAnchor = follow ? undefined : currentAnchor() document.title = view.name element('test-output-main').setAttribute('aria-label', view.name) - element('test-output-time').textContent = view.time ?? '' + element('test-output-time').textContent = view.time const test = element('test-output-test') test.textContent = view.test test.title = view.test @@ -190,59 +242,85 @@ export function installNodeTestOutput( line('test-output-cut', view.cut) line('test-output-placeholder', view.placeholder) line('test-output-end', view.end) - const scrollTopBefore = output.scrollTop placeLines(view.lines) - // A shorter output clamps the scroll position: that scroll is the document's, never the learner's return to the end (DS20). - if (output.scrollTop !== scrollTopBefore) scrolledByDocument = true - if (follow) toEnd() + placeReading(readingAnchor) jump.hidden = follow } + /** Draws the newest view that came since the last frame, and takes the host's reading when the update carries it. */ + const drawQueued = (): void => { + frame = undefined + const update = queued + queued = undefined + if (!update) return + shown = update.generation + apply(update.view) + // The host's reading is the answer to `ready`, built before anything the learner did since; it never overrides the learner's own place (F290). + if (update.reading && !learnerActed) takeReading(update.reading) + } output.addEventListener('scroll', () => { - // The document's own scroll to the end is no choice of the learner's. - if (scrolledByDocument) { - scrolledByDocument = false + // A scroll that ends where the document left the position (its own scroll to the end, or a clamp to a shorter output, or none at all) is no choice of the learner's. + if (output.scrollTop === expectedTop) return + expectedTop = output.scrollTop + learnerActed = true + const following = atEnd() + if (following === follow) { + reportSoon() return } - const following = atEnd() - if (following === follow) return follow = following jump.hidden = follow - // The learner's own choice is written at once; only the stream of moments is joined. - writeState() + // The learner's own choice is reported at once; only the anchor of a long scroll is joined. + reportState() }) jump.addEventListener('click', () => { + learnerActed = true follow = true - writeState() toEnd() jump.hidden = true + reportState() // The button leaves the page, so the keyboard stays in the output it led to. output.focus() }) + /** Tells a view the host posted from any other message the webview receives: it has a generation and a view. */ + const isOutputUpdate = ( + data: Partial | null, + ): data is NodeTestOutputUpdate => + data?.type === 'node-history-output' && + typeof data.generation === 'number' && + Boolean(data.view) + /** Whether an update is older than the one shown or waiting: it came late, and the newer selection stands. */ + const isLate = (generation: number): boolean => + generation < Math.max(shown, queued?.generation ?? 0) + /** Draws the queued view at the next frame: views that arrive faster than frames (Play posts up to twenty a second) are drawn once, the newest. */ + const drawSoon = (): void => { + frame ??= requestAnimationFrame(drawQueued) + } + /** Queues the newest view, keeping the host's reading that waited in the update it replaces unless this one brings its own (F290). */ + const queue = (update: NodeTestOutputUpdate): void => { + const reading = update.reading ?? queued?.reading + queued = { + type: 'node-history-output', + generation: update.generation, + view: update.view, + ...(reading ? { reading } : {}), + } + drawSoon() + } window.addEventListener( 'message', (event: MessageEvent | 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) - latestView = data.view - remember() + if (!isOutputUpdate(data) || isLate(data.generation)) return + queue(data) }, ) - // A document loaded again from its first write shows the latest moment it had received, not the moment it was written with. - if (kept.view && kept.view.generation > generation) { - latestView = kept.view - shown = kept.view.generation - apply(kept.view) - return - } - if (follow) toEnd() - jump.hidden = follow + apply(options.view) + takeReading(options.reading) + // The host answers with its current view when this document's is older, as after VS Code reloads the page in place (F141). + api.postMessage({ + type: 'node-history-output-ready', + history: options.history, + generation: shown, + follow, + }) } diff --git a/extension/src/utils/isSurrogatePairAt.ts b/extension/src/utils/isSurrogatePairAt.ts new file mode 100644 index 000000000..d3af940d2 --- /dev/null +++ b/extension/src/utils/isSurrogatePairAt.ts @@ -0,0 +1,26 @@ +import { + HIGH_SURROGATE_FIRST_UNIT, + HIGH_SURROGATE_LAST_UNIT, + LOW_SURROGATE_FIRST_UNIT, + LOW_SURROGATE_LAST_UNIT, +} from '../constants' + +/** + * Tells whether a surrogate pair starts at an offset of a text: a high surrogate there and a low one after it, which together are one code point, so a count or a cut never splits one character in two; called by {@link countCodePoints}, the pair table of {@link stripTerminalControls} and the split-pair check of {@link indexNodeTestOutput} (F328). + * @param text - The text. + * @param offset - The UTF-16 offset of the pair's first unit; an offset outside the text reads no pair. + * @returns Whether `text[offset]` is a high surrogate and `text[offset + 1]` a low one. + * @example + * isSurrogatePairAt('a\u{1F600}', 1) // => true + * isSurrogatePairAt('a\u{1F600}', 2) // => false + */ +export function isSurrogatePairAt(text: string, offset: number): boolean { + const high = text.charCodeAt(offset) + const low = text.charCodeAt(offset + 1) + return ( + high >= HIGH_SURROGATE_FIRST_UNIT && + high <= HIGH_SURROGATE_LAST_UNIT && + low >= LOW_SURROGATE_FIRST_UNIT && + low <= LOW_SURROGATE_LAST_UNIT + ) +} diff --git a/extension/src/utils/judgeNodeTestWriter.ts b/extension/src/utils/judgeNodeTestWriter.ts new file mode 100644 index 000000000..06a2392cb --- /dev/null +++ b/extension/src/utils/judgeNodeTestWriter.ts @@ -0,0 +1,32 @@ +import { join } from 'node:path' + +import { NODE_TEST_HISTORY_WRITER_FILE } from '../constants' + +import { currentStorageOwner } from './currentStorageOwner' +import { isRecordedOwnerGone } from './isRecordedOwnerGone' +import { readRecordedOwner } from './readRecordedOwner' + +/** What a test history's writer record says of its writer: `live` while the process it names may still write, `gone` once that process is known to have ended, `unread` when the record could not be read this time. */ +export type NodeTestWriterVerdict = 'live' | 'gone' | 'unread' + +/** + * Judges whether the editor process named by a test history's writer record ({@link writeNodeTestWriterRecord}) may still be writing it; called by {@link NodeTestHistoryStore} for a history this window does not write and that has no seal, to list it as recording instead of interrupted and to read only its appended bytes. + * The judgement is a storage lock owner's ({@link isRecordedOwnerGone}): a process of this machine that ended, an id that went to another process, and a record of an earlier boot call the writer gone. A writer this computer cannot probe (another machine) counts as live while its record stands. A record that cannot be read is `unread`, not `gone`: a transient I/O error (EMFILE, EBUSY) says nothing of the writer, so the store judges it again (F347). + * @param historyDirectory - The history's folder. + * @returns + * - `live` when a record stands and its writer is not gone + * - `gone` when its writer is known to have ended + * - `unread` when the record is missing, unreadable or malformed this time + * @example await judgeNodeTestWriter('/storage/learning/node-tests.v1/') // => 'gone' after the writing window crashed + */ +export async function judgeNodeTestWriter( + historyDirectory: string, +): Promise { + const owner = await readRecordedOwner( + join(historyDirectory, NODE_TEST_HISTORY_WRITER_FILE), + ) + if (!owner) return 'unread' + return (await isRecordedOwnerGone(owner, await currentStorageOwner())) + ? 'gone' + : 'live' +} diff --git a/extension/src/utils/nodeHistoryPlaybackController.ts b/extension/src/utils/nodeHistoryPlaybackController.ts index 96875f39b..99871982f 100644 --- a/extension/src/utils/nodeHistoryPlaybackController.ts +++ b/extension/src/utils/nodeHistoryPlaybackController.ts @@ -9,7 +9,8 @@ export interface NodeHistoryPlaybackEvent { } export interface NodeHistoryPlaybackDependencies { - present(sequence: number): Promise + /** `isCurrent` says whether the request is still the controller's newest, so a presentation that ends after a newer seek can commit nothing. */ + present(sequence: number, isCurrent: () => boolean): Promise changed(): void /** Receives an autoplay presentation failure; manual seeks reject to their own caller instead. */ failed?(error: unknown): void @@ -135,7 +136,9 @@ export class NodeHistoryPlaybackController { positionMs < first.elapsedMs || positionMs > last.elapsedMs ) { - throw new Error('Seek lies outside the verified Node history range.') + throw new Error( + 'Seek lies outside the verified range of this test replay.', + ) } this.mode = 'paused' this.generation++ @@ -216,7 +219,10 @@ export class NodeHistoryPlaybackController { private async present(index: number, generation: number): Promise { const event = this.events[index] if (!event) return - const operation = this.dependencies.present(event.sequence) + const operation = this.dependencies.present( + event.sequence, + () => generation === this.generation, + ) this.pending = operation try { await operation diff --git a/extension/src/utils/nodeTestOutputLineCost.ts b/extension/src/utils/nodeTestOutputLineCost.ts index 7d6f8998c..b53369c3d 100644 --- a/extension/src/utils/nodeTestOutputLineCost.ts +++ b/extension/src/utils/nodeTestOutputLineCost.ts @@ -2,19 +2,21 @@ 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. + * How many code points a line takes as the Application pane shows it; called by {@link indexNodeTestOutput} for each line it completes and by {@link accumulateNodeTestOutput} for the unfinished one. + * @param channel - The channel that wrote the line. + * @param codePoints - The code points of the line's text. * @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 + * @returns The text's code points, plus the `[stderr] ` mark for a standard-error line and one for the line feed. + * @example nodeTestOutputLineCost('stderr', 4, true) // => 14 */ export function nodeTestOutputLineCost( - line: NodeTestOutputLine, + channel: NodeTestOutputLine['channel'], + codePoints: number, lineFeed: boolean, ): number { return ( - Array.from(line.text).length + - (line.channel === 'stderr' ? NODE_TEST_REPLAY_TEXT.stderrMark.length : 0) + + codePoints + + (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 index 848e5f484..50896a87c 100644 --- a/extension/src/utils/projectNodeLearningSummary.ts +++ b/extension/src/utils/projectNodeLearningSummary.ts @@ -3,10 +3,11 @@ import type { 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. */ +import { stripTerminalControls } from './stripTerminalControls' + +/** Where a step of a test replay's learning material leads: the event its reference names, by sequence. */ export type NodeLearningStepPosition = { stepId: string - elapsedMs: number sequence: number } @@ -21,9 +22,9 @@ export type NodeLearningSummaryProjection = { * 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. + * @returns The material's content and, for each step whose reference names an event of the history, that event's sequence; `undefined` without material. * @example - * projectNodeLearningSummary(material, history)?.positions // => [{ stepId: 'stop-1', elapsedMs: 420, sequence: 3 }] + * projectNodeLearningSummary(material, history)?.positions // => [{ stepId: 'stop-1', sequence: 3 }] */ export function projectNodeLearningSummary( material: NodeTestLearningMaterial | undefined, @@ -40,9 +41,34 @@ export function projectNodeLearningSummary( if (event) positions.push({ stepId: step.id, - elapsedMs: event.elapsedMs, sequence: event.sequence, }) } - return { content: material.content, positions } + return { content: shownLearningContent(material.content), positions } +} + +/** + * Removes terminal and bidirectional controls from every text of a test replay's learning material that the summary, its sequence and the Study section print, as {@link sanitizeNodeTestHistory} does for the recorded texts: the editor agent writes the material from output a test can shape, so a U+202E in it could otherwise reorder what the learner reads (F131, F341); called by {@link projectNodeLearningSummary}. + * @param content - The material's content as saved. + * @returns The content with its question, title, summary, participant labels, step labels and explanations free of controls; ids, evidence and references unchanged. + * @example shownLearningContent(content).title // => 'Theme switch' for a saved 'Theme\u202E switch' + */ +function shownLearningContent( + content: NodeTestLearningMaterial['content'], +): NodeTestLearningMaterial['content'] { + return { + ...content, + question: stripTerminalControls(content.question), + title: stripTerminalControls(content.title), + summary: stripTerminalControls(content.summary), + participants: content.participants.map((participant) => ({ + ...participant, + label: stripTerminalControls(participant.label), + })), + steps: content.steps.map((step) => ({ + ...step, + label: stripTerminalControls(step.label), + explanation: stripTerminalControls(step.explanation), + })), + } } diff --git a/extension/src/utils/projectNodeTestApplication.ts b/extension/src/utils/projectNodeTestApplication.ts index 48adc7fe8..d3273d309 100644 --- a/extension/src/utils/projectNodeTestApplication.ts +++ b/extension/src/utils/projectNodeTestApplication.ts @@ -18,8 +18,8 @@ import type { NodeTestOutputIndex } from './indexNodeTestOutput' */ export type NodeTestApplicationView = { generation: number - /** Header line 1 (`Recorded test output · s`), absent without a selected event. */ - time?: string + /** Header line 1: `Recorded test output · s`, or `Recorded test output` alone without a selected event (loading, no entries). */ + 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. */ @@ -58,18 +58,14 @@ export function projectNodeTestApplication( generation: number, loading: boolean, ): NodeTestApplicationView { - const configuration = history.header.configuration - const test = { - testFile: configuration.testFile, - ...(configuration.testTitles - ? { testTitles: configuration.testTitles } - : {}), - } + // The configuration names the cited test (file and title path) as {@link describeNodeTestReplay} reads it. + const test = history.header.configuration 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. + // A history without entries has no time to name; neither has one still opening or one without a selected event: its header line 1 names the surface alone (F116). if (loading || !history.entries.length || !selected) return { generation, + time: describeNodeTestReplay({ kind: 'application-time' }), test: testLine, name: describeNodeTestReplay({ kind: 'application-name', test }), lines: [], diff --git a/extension/src/utils/projectNodeTestSection.ts b/extension/src/utils/projectNodeTestSection.ts index a91021fc0..f9a1b208f 100644 --- a/extension/src/utils/projectNodeTestSection.ts +++ b/extension/src/utils/projectNodeTestSection.ts @@ -11,6 +11,7 @@ import { import { projectNodeHistoryTimeline } from './projectNodeHistoryTimeline' import type { NodeHistoryTimeline } from './projectNodeHistoryTimeline' import type { NodeLearningSummaryProjection } from './projectNodeLearningSummary' +import { wordNodeCaptureReason } from './wordNodeCaptureReason' /** * 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. @@ -25,6 +26,8 @@ export type NodeTestSectionView = { notRecorded?: string /** The line for a selected stop whose recorded source does not verify. */ unverified?: string + /** Why the capture is interrupted or partial, worded without "Node" (DS14); absent for a complete capture or one without a stored reason. */ + captureReason?: string /** Where the output up to the selected moment is, or that its pane is closed. */ pointer: string applicationClosed: boolean @@ -47,7 +50,7 @@ export type NodeTestSectionView = { /** The inputs of the section's view. */ export type NodeTestSectionInput = { - history: Pick + history: Pick selected?: NodeTestHistoryIndex['entries'][number] /** Whether the selected event is a stop whose recorded source verifies. */ selectedStopVerified: boolean @@ -80,6 +83,7 @@ export function projectNodeTestSection( const marks = timeline ? { timeline } : {} return { status: sectionStatus(history, stops, verdict, learning), + ...captureReasonLine(history), ...(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. @@ -111,6 +115,30 @@ export function projectNodeTestSection( } } +/** + * Words why an interrupted or partial capture ended, beside the status; called by {@link projectNodeTestSection}. + * @param history - The test replay. + * @returns `{ captureReason }` for an interrupted or partial capture with a stored reason; nothing otherwise. + * @example captureReasonLine({ status: 'interrupted', reason: 'Interrupted Node history after its verified prefix: EIO' }) // => { captureReason: 'Capture interrupted: Interrupted test replay after its verified prefix: EIO' } + */ +function captureReasonLine( + history: Pick, +): { captureReason?: string } { + const reason = wordNodeCaptureReason(history.reason) + if ( + !reason || + (history.status !== 'partial' && history.status !== 'interrupted') + ) + return {} + return { + captureReason: describeNodeTestReplay({ + kind: 'capture-reason', + status: history.status, + reason, + }), + } +} + /** An event of a test replay. */ type HistoryEvent = NodeTestHistoryIndex['entries'][number] diff --git a/extension/src/utils/readNodeTestOutputReport.ts b/extension/src/utils/readNodeTestOutputReport.ts new file mode 100644 index 000000000..00b425d76 --- /dev/null +++ b/extension/src/utils/readNodeTestOutputReport.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' + +import type { NodeTestOutputReport } from './installNodeTestOutput' + +const anchorSchema = z.strictObject({ + line: z.number().int().nonnegative(), + offsetPx: z.number().finite(), +}) + +const reportSchema = z.discriminatedUnion('type', [ + z.strictObject({ + type: z.literal('node-history-output-ready'), + history: z.string(), + generation: z.number().int().nonnegative(), + follow: z.boolean(), + }), + z.strictObject({ + type: z.literal('node-history-output-state'), + history: z.string(), + follow: z.boolean(), + anchor: anchorSchema.optional(), + }), +]) + +/** + * Reads what a test replay's Application document tells the host (it is ready for views, or the learner's reading changed); a message from a webview is untrusted, so anything else is refused. Called by the Study workspace for each message the Application pane's webview posts. + * @param message - The message as the webview posted it. + * @returns The report as the document sent it (a state report's absent anchor may read `anchor: undefined`), or `undefined` when the message is no report of the document. + * @example readNodeTestOutputReport({ type: 'node-history-output-state', history: 'study_1', follow: false, anchor: { line: 3, offsetPx: 4 } })?.type // => 'node-history-output-state' + */ +export function readNodeTestOutputReport( + message: unknown, +): NodeTestOutputReport | undefined { + const parsed = reportSchema.safeParse(message) + // A state report without an anchor may carry `anchor: undefined`; the host files only an anchor that is there. + return parsed.success ? parsed.data : undefined +} diff --git a/extension/src/utils/sanitizeNodeTestHistory.ts b/extension/src/utils/sanitizeNodeTestHistory.ts new file mode 100644 index 000000000..7e56bd22d --- /dev/null +++ b/extension/src/utils/sanitizeNodeTestHistory.ts @@ -0,0 +1,49 @@ +import type { NodeTestHistoryIndex } from '../nodeTestHistorySchemas' + +import { stripOutputEntries } from './indexNodeTestOutput' +import { stripNodeHistorySummaryTexts } from './stripNodeHistorySummaryTexts' +import { stripTerminalControls } from './stripTerminalControls' + +/** + * Removes terminal and bidirectional controls from every recorded text the replay's surfaces print, in one place on the host, so a U+202E in a test title, an output entry, a captured fact or the result's output tail cannot reorder a line in the Study section or the Application header while the output region shows the same text in logical order (F131); called by the Study workspace once per opened test replay, before the Study message and the Application view are projected. + * A stop's source path is stripped for display only: its hash, line and column, the debugger snapshots and the counts are untouched, and the host verifies a stop against the stored history, not this one; the input is not changed. + * @param history - The history as the store read it. + * @returns A history whose output text, result output tail, stop source paths, frame names, fact paths and displays, and the cited test file and titles are free of controls (output entries read as one stream per channel, {@link stripOutputEntries}). + * @example sanitizeNodeTestHistory(history).entries[0] // => { type: 'output', text: 'ok\n', … } for a recorded 'o\u202Ek\n' + */ +export function sanitizeNodeTestHistory( + history: NodeTestHistoryIndex, +): NodeTestHistoryIndex { + const shownOutput = stripOutputEntries(history.entries) + return { + ...history, + header: { + ...history.header, + // The cited test file and titles are stripped as a Saved studies entry's are, by one rule. + configuration: stripNodeHistorySummaryTexts(history.header.configuration), + }, + entries: history.entries.map((entry) => { + if (entry.type === 'output') + return { ...entry, text: shownOutput.get(entry.sequence)! } + if (entry.type === 'result') + return { ...entry, outputTail: stripTerminalControls(entry.outputTail) } + // Every text of a stop that the Study section's event rows print is stripped (F287); the hash, line and column stay. + return { + ...entry, + source: { + ...entry.source, + path: stripTerminalControls(entry.source.path), + }, + frame: { + ...entry.frame, + name: stripTerminalControls(entry.frame.name), + }, + facts: entry.facts.map((fact) => ({ + ...fact, + path: stripTerminalControls(fact.path), + display: stripTerminalControls(fact.display), + })), + } + }), + } +} diff --git a/extension/src/utils/savedStudyLabel.ts b/extension/src/utils/savedStudyLabel.ts index 3b80f1d4a..d73b87330 100644 --- a/extension/src/utils/savedStudyLabel.ts +++ b/extension/src/utils/savedStudyLabel.ts @@ -4,8 +4,8 @@ import type { StudyCatalogEntry } from '../studySchemas' * Builds the four-part name of one saved study, so the Saved studies option in the Study pane and the removal dialog in the host cannot drift apart; called by {@link savedStudyIdentityLine} and, serialized into the webview, by the pane's list. * It reads nothing outside its arguments, because the pane runs its source text. * @param entry - The catalog entry. - * @param kind - The entry's kind as the caller names it (the pane also knows Node test histories and scenario titles). - * @returns `kind[ (legacy)] · saved date · outcome · last eight characters of the id`. + * @param kind - The entry's kind as the caller names it (the pane also knows scenario titles); a test replay's row leaves it out. + * @returns `kind[ (legacy)] · saved date · outcome · last eight characters of the id`; for a test replay `outcome · saved date · last eight characters of the id`, where the outcome starts with `Test replay · `. * @example * savedStudyLabel({ id: 'a…_b…9f3c2d1e', format: 'v4', savedAt: '2026-10-02T01:00:00.000Z', outcome: 'not-demonstrated' }, 'Repository browser study') * // => 'Repository browser study · 10/2/2026, 10:00:00 AM · not-demonstrated · 9f3c2d1e' @@ -14,5 +14,8 @@ export function savedStudyLabel( entry: Pick, kind: string, ): string { + // A test replay's outcome already starts with the String catalog's `Test replay · …`, so it leads the row and no kind goes before it (DS14). + if (entry.format === 'node-test-history') + return `${entry.outcome} · ${new Date(entry.savedAt).toLocaleString()} · ${entry.id.slice(-8)}` return `${kind}${entry.format === 'legacy-v1' ? ' (legacy)' : ''} · ${new Date(entry.savedAt).toLocaleString()} · ${entry.outcome} · ${entry.id.slice(-8)}` } diff --git a/extension/src/utils/stripNodeHistorySummaryTexts.ts b/extension/src/utils/stripNodeHistorySummaryTexts.ts new file mode 100644 index 000000000..80bfef72e --- /dev/null +++ b/extension/src/utils/stripNodeHistorySummaryTexts.ts @@ -0,0 +1,20 @@ +import { stripTerminalControls } from './stripTerminalControls' + +/** + * Removes terminal and bidirectional controls from the recorded test file and titles of a Saved studies catalog entry, so a U+202E in a test title cannot reorder the text the Study message or a notice prints about a saved test replay (F131); called by the Study workspace for each catalog entry it sends or names, and by {@link sanitizeNodeTestHistory} for an opened replay's configuration. + * The entry's identity and every other field are untouched. + * @param summary - A catalog entry as the history store lists it. + * @returns The entry with its test file and titles free of controls; the input is not changed. + * @example stripNodeHistorySummaryTexts({ testFile: 'a.test.ts', testTitles: ['x\u202Ey'] }).testTitles // => ['xy'] + */ +export function stripNodeHistorySummaryTexts< + Summary extends { testFile: string; testTitles?: string[] }, +>(summary: Summary): Summary { + return { + ...summary, + testFile: stripTerminalControls(summary.testFile), + ...(summary.testTitles + ? { testTitles: summary.testTitles.map(stripTerminalControls) } + : {}), + } +} diff --git a/extension/src/utils/stripTerminalControls.ts b/extension/src/utils/stripTerminalControls.ts index 69a904e1b..37a67a1fc 100644 --- a/extension/src/utils/stripTerminalControls.ts +++ b/extension/src/utils/stripTerminalControls.ts @@ -1,21 +1,177 @@ -/** ANSI escape sequences: CSI (`ESC [`, or its 8-bit form U+009B, then parameters, intermediates and a final byte), OSC (`ESC ]`, or its 8-bit form U+009D, up to BEL, `ESC \` or the 8-bit string terminator U+009C, 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 = - /\u009B[0-?]*[ -/]*[@-~]|(?:\u001B\]|\u009D)[^\u0007\u001B\u009C]*(?:\u0007|\u001B\\|\u009C)?|\u001B(?:\[[0-?]*[ -/]*[@-~]|[ -/]*[0-~])/g +import { TERMINAL_CONTROL_STRING_LOOKAHEAD_CODE_POINTS } from '../constants' + +import { isSurrogatePairAt } from './isSurrogatePairAt' + +const LOOKAHEAD = TERMINAL_CONTROL_STRING_LOOKAHEAD_CODE_POINTS + +/** + * The ANSI escape sequences that need no scan of a string payload, found by one pass: + * - the introducers of the control strings (OSC `ESC ]` or U+009D; DCS, SOS, PM and APC `ESC P`, `ESC X`, `ESC ^`, `ESC _` or U+0090, U+0098, U+009E, U+009F), whose payload {@link findStringEnd} measures; + * - CSI (`ESC [`, or U+009B, then parameters, intermediates and a final byte), each part bounded by {@link TERMINAL_CONTROL_STRING_LOOKAHEAD_CODE_POINTS} code points; + * - the other escapes (intermediates such as the `(` of a character-set choice, then one final character, as in `ESC 7` or `ESC M`). + * The order is the priority: a string introducer that no terminator closes falls through to the single-escape branch, which removes only its own code points. + */ +const ANSI_TOKENS = new RegExp( + [ + '(?\\u001B\\]|\\u009D)', + '(?\\u001B[PX^_]|[\\u0090\\u0098\\u009E\\u009F])', + `(?:\\u001B\\[|\\u009B)[0-?]{0,${LOOKAHEAD}}[ -/]{0,${LOOKAHEAD}}[@-~]`, + '\\u001B[ -/]*[0-~]', + ].join('|'), + 'gu', +) + +/** The first code unit that ends or breaks an OSC payload (BEL, ESC or the 8-bit string terminator) and the first that does so for the other strings (ESC or the 8-bit string terminator). */ +const OSC_STOP = /[\u0007\u001B\u009C]/g +const OTHER_STOP = /[\u001B\u009C]/g + +/** A remembered search for a payload stop: the search began at `from` and found the first stop at `at` (`-1` for none), so every start in `from..at` has the same answer. */ +type RememberedStop = { from: number; at: number } + +/** + * Finds where the control string that starts at `from` ends, in time linear in the text however many unterminated introducers it holds: the first code unit that can end or break the payload is searched once and remembered, so a later introducer before it reuses the answer (F289). + * @param joined - The stream. + * @param from - The UTF-16 offset of the first payload code unit (after the introducer). + * @param isOsc - Whether the string is an OSC (BEL ends it too). + * @param remembered - The remembered search of this string kind, updated here. + * @param astralBefore - The count of surrogate pairs before each offset, or `undefined` when the stream holds none (a code unit is then a code point). + * @returns The offset just after the terminator, or `undefined` when the string has none within {@link TERMINAL_CONTROL_STRING_LOOKAHEAD_CODE_POINTS} code points (a stray introducer). + * @example findStringEnd('\u009Dtitle\u0007x', 1, true, { from: 0, at: -2 }, undefined) // => 8 + */ +function findStringEnd( + joined: string, + from: number, + isOsc: boolean, + remembered: RememberedStop, + astralBefore: Int32Array | undefined, +): number | undefined { + // A search that began at or before `from` and found its stop at or after it answers this start too. + if (!( + remembered.from <= from && + (remembered.at === -1 || from <= remembered.at) + )) { + const stop = isOsc ? OSC_STOP : OTHER_STOP + stop.lastIndex = from + const found = stop.exec(joined) + remembered.from = from + remembered.at = found ? found.index : -1 + } + const at = remembered.at + if (at === -1) return undefined + const unit = joined.charCodeAt(at) + // ESC ends the string only as the first half of `ESC \`; any other ESC breaks it. + const length = + unit === 0x1b ? (joined.charCodeAt(at + 1) === 0x5c ? 2 : 0) : 1 + if (length === 0) return undefined + const codePoints = + at - from - (astralBefore ? astralBefore[at]! - astralBefore[from]! : 0) + return codePoints <= LOOKAHEAD ? at + length : undefined +} + +/** + * Counts the surrogate pairs before each offset of a text, so the code points between two offsets are their distance less the pairs between them; called by {@link findAnsiRanges} (F289). + * @param text - The stream. + * @returns `table[i]` is the number of pairs inside `text.slice(0, i)`; `undefined` for a text without a pair, where a code unit is a code point. + * @example countSurrogatePairsBefore('a😀')?.at(-1) // => 1 + */ +function countSurrogatePairsBefore(text: string): Int32Array | undefined { + if (!/[\ud800-\udbff][\udc00-\udfff]/.test(text)) return undefined + const table = new Int32Array(text.length + 1) + for (let index = 0; index < text.length; index++) { + table[index + 1] = table[index]! + (isSurrogatePairAt(text, index) ? 1 : 0) + } + return table +} + +/** + * Finds every ANSI escape sequence of the stream as the offsets it spans, in one pass over the text. + * @param joined - The stream. + * @returns The ranges in order, each `from` inclusive and `to` exclusive. + * @example findAnsiRanges('a\u001b[31mb') // => [{ from: 1, to: 6 }] + */ +function findAnsiRanges(joined: string): { from: number; to: number }[] { + const astralBefore = countSurrogatePairsBefore(joined) + const oscStop: RememberedStop = { from: 0, at: -2 } + const otherStop: RememberedStop = { from: 0, at: -2 } + const ranges: { from: number; to: number }[] = [] + ANSI_TOKENS.lastIndex = 0 + for (;;) { + const match = ANSI_TOKENS.exec(joined) + if (!match) break + const from = match.index + let to = from + match[0].length + const groups = match.groups! + if (groups.osc !== undefined || groups.other !== undefined) { + const end = findStringEnd( + joined, + to, + groups.osc !== undefined, + groups.osc !== undefined ? oscStop : otherStop, + astralBefore, + ) + if (end !== undefined) to = end + // A stray 8-bit introducer is left to the control removal below; a stray 7-bit one goes with its own second code point. + else if (match[0].length === 1) continue + } + ranges.push({ from, to }) + ANSI_TOKENS.lastIndex = to + } + return ranges +} /** C0 controls other than tab and line feed, DEL and the C1 controls, and the bidirectional controls that can reorder what a reader sees (the set {@link sanitizeCause} removes, with tab and line feed kept). */ const UNREADABLE_CONTROLS = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F\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. + * Removes what a terminal would interpret instead of show from the pieces of one recorded stream, so a control string split across pieces (the launcher ends an output entry at 4,096 characters) is removed whole and its tail never shows as text (F109, F148); called by {@link stripOutputEntries} for each channel's output entries (once per opened replay, through {@link sanitizeNodeTestHistory}) and by {@link stripTerminalControls} for one text. + * Escapes are found in the pieces joined; a string whose terminator arrives in a later piece is removed across both, and an introducer with no terminator within the next 4,096 code points of the joined stream is a stray: only its own code points go. + * @param pieces - The stream's pieces in order. + * @returns The pieces in order, each without ANSI escape sequences, C0 controls other than tab and line feed, DEL, C1 controls and bidirectional controls; the same count as `pieces`. + * @example + * stripTerminalControlsAcross(['a\u001b[3', '2mb\u001b]0;ti', 'tle\u0007c']) // => ['a', 'b', 'c'] + */ +export function stripTerminalControlsAcross( + pieces: readonly string[], +): string[] { + const joined = pieces.join('') + // Escapes go first: their ESC is itself a C0 control, and removing it alone would leave `[32m` behind. + const ranges = findAnsiRanges(joined) + let rangeIndex = 0, + offset = 0 + return pieces.map((piece) => { + const start = offset, + end = offset + piece.length + offset = end + // A range that ends before this piece cannot touch a later one either. + while (rangeIndex < ranges.length && ranges[rangeIndex]!.to <= start) + rangeIndex++ + let kept = '', + cursor = start + for ( + let index = rangeIndex; + index < ranges.length && ranges[index]!.from < end; + index++ + ) { + const { from, to } = ranges[index]! + // Text before the range stays; the part of a range inside this piece goes, wherever the range began or ends. + if (from > cursor) kept += joined.slice(cursor, from) + cursor = Math.max(cursor, Math.min(to, end)) + } + kept += joined.slice(cursor, end) + return kept.replace(UNREADABLE_CONTROLS, '') + }) +} + +/** + * Removes what a terminal would interpret instead of show from a recorded text, so the replay's surfaces show the text and nothing in it can recolor, move or reorder what the learner reads; called by {@link sanitizeNodeTestHistory} for each recorded text a replay surface prints, by {@link stripNodeHistorySummaryTexts} for Saved studies' test names, by {@link projectNodeLearningSummary} for the learning material's texts, by {@link wordNodeCaptureReason} for a capture reason and by {@link describeNodeRecordedSource} for a recorded path. * 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, DEL, C1 controls and bidirectional controls. + * @param text - One recorded text. + * @returns The text without ANSI escape sequences, C0 controls other than tab and line feed, DEL, C1 controls 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, '') + return stripTerminalControlsAcross([text])[0]! } diff --git a/extension/src/utils/testOutputHtml.ts b/extension/src/utils/testOutputHtml.ts index 5a166ede0..62527e55a 100644 --- a/extension/src/utils/testOutputHtml.ts +++ b/extension/src/utils/testOutputHtml.ts @@ -1,43 +1,45 @@ import { TEST_OUTPUT_END_TOLERANCE_PX, - TEST_OUTPUT_STATE_WRITE_INTERVAL_MS, + TEST_OUTPUT_STATE_REPORT_INTERVAL_MS, } from '../constants' 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 { installClampedTitleStop } from './installClampedTitleStop' +import { + installNodeTestOutput, + type NodeTestOutputReading, +} 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. + * Writes the Application pane's document for a test replay (Design 3): the two-line header pinned above the output region, the muted lines for what was left out, the verdict or the ended-early line and the footer. The output lines are drawn by {@link installNodeTestOutput} from the first view in its options, so one piece of code draws every line (F136), and it 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. - * @param history - The shown test replay's study id, which the learner's follow choice belongs to (DS20). + * @param history - The shown test replay's study id, which the learner's reading is filed under (DS20). + * @param reading - The learner's follow choice and scroll anchor for this replay as the host keeps them; following the output when the host has none yet (D22). * @returns The document. * Called by {@link capturedAppHtml} for a selected test replay. - * @example testOutputHtml(view, randomUUID(), 'study_1') + * @example testOutputHtml(view, randomUUID(), 'study_1', { follow: true }) */ export function testOutputHtml( view: NodeTestApplicationView, nonce: string, history: string, + reading: NodeTestOutputReading = { follow: true }, ): string { // The script's options are JSON inside a script element: `<` is written as an escape, so no value can end the element. const scriptOptions = JSON.stringify({ stderrMark: NODE_TEST_REPLAY_TEXT.stderrMark, history, endTolerancePx: TEST_OUTPUT_END_TOLERANCE_PX, - stateWriteIntervalMs: TEST_OUTPUT_STATE_WRITE_INTERVAL_MS, + stateReportIntervalMs: TEST_OUTPUT_STATE_REPORT_INTERVAL_MS, + view, + reading, }).replaceAll('<', '\\u003c') const muted = (id: string, value: string | undefined): string => `

${escapeHtml(value ?? '')}

` - const lines = view.lines - .map( - (line) => - `${line.channel === 'stderr' && !line.cut ? `${escapeHtml(NODE_TEST_REPLAY_TEXT.stderrMark)}` : ''}${escapeHtml(line.text)}\n`, - ) - .join('') - return `${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 ?? '')}

` + return `${escapeHtml(view.name)}

${escapeHtml(view.time)}

${escapeHtml(view.test)}

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

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

` } diff --git a/extension/src/utils/wordNodeCaptureReason.ts b/extension/src/utils/wordNodeCaptureReason.ts new file mode 100644 index 000000000..1c24bf4c8 --- /dev/null +++ b/extension/src/utils/wordNodeCaptureReason.ts @@ -0,0 +1,41 @@ +import { + HIGH_SURROGATE_FIRST_UNIT, + HIGH_SURROGATE_LAST_UNIT, + NODE_CAPTURE_REASON_LIMIT_CHARS, +} from '../constants' + +import { stripTerminalControls } from './stripTerminalControls' + +/** + * Words why a test replay's capture is interrupted or partial for the Study section, so the learner reads the reason in the String catalog's words (DS14) on one line; called by {@link projectNodeTestSection} for each Study message of such a replay. + * The stored reason comes from the store or from an error, so it may hold line breaks, control characters and the word "Node"; this keeps one short line without them. + * @param reason - The history's stored reason, absent for a complete capture. + * @returns The reason on one line (terminal, C1 and bidirectional controls removed, line breaks and runs of blanks collapsed, cut at {@link NODE_CAPTURE_REASON_LIMIT_CHARS} characters with an ellipsis), with "Node test history" and "Node history" read as "test replay" (their plurals as "test replays") and the first letter capitalized; `undefined` for no reason or an empty one. + * @example + * wordNodeCaptureReason('Interrupted Node history after its verified prefix: Error: EIO') // => 'Interrupted test replay after its verified prefix: Error: EIO' + * wordNodeCaptureReason(undefined) // => undefined + */ +export function wordNodeCaptureReason( + reason: string | undefined, +): string | undefined { + const words = stripTerminalControls(reason ?? '') + // ANSI sequences, C1 and bidirectional controls are gone (F288); the line breaks and tabs left become a blank. + .replace(/[\u0000-\u001f\u007f]+/g, ' ') + .replace(/\bNode (?:test )?histor(y|ies)\b/gi, (_match, ending: string) => + ending === 'y' ? 'test replay' : 'test replays', + ) + .replace(/\s+/g, ' ') + .trim() + if (!words) return undefined + const sentence = `${words.charAt(0).toUpperCase()}${words.slice(1)}` + if (sentence.length <= NODE_CAPTURE_REASON_LIMIT_CHARS) return sentence + // The cut falls between characters: a high surrogate left at its end goes, whether its pair follows or it stands alone (F288, F330). + let cut = NODE_CAPTURE_REASON_LIMIT_CHARS - 1 + const lastUnit = sentence.charCodeAt(cut - 1) + if ( + lastUnit >= HIGH_SURROGATE_FIRST_UNIT && + lastUnit <= HIGH_SURROGATE_LAST_UNIT + ) + cut-- + return `${sentence.slice(0, cut)}…` +} diff --git a/extension/src/utils/writeNodeTestWriterRecord.ts b/extension/src/utils/writeNodeTestWriterRecord.ts new file mode 100644 index 000000000..6fd1b59ab --- /dev/null +++ b/extension/src/utils/writeNodeTestWriterRecord.ts @@ -0,0 +1,29 @@ +import { join } from 'node:path' + +import { + NODE_TEST_HISTORY_WRITER_FILE, + NODE_TEST_HISTORY_WRITER_LIMIT_BYTES, +} from '../constants' + +import { atomicPrivateWrite } from './atomicPrivateWrite' +import { currentStorageOwner } from './currentStorageOwner' + +/** + * Writes the full identity of this editor process (machine, boot and process start, as a storage lock records it) into the folder of a test history it starts, so that another window of the same workspace can tell a history that is being written from one whose writer crashed; called by {@link NodeTestHistoryStore.start} under the storage lock, after the history's first files. + * The record is a help, not a part of the history: a record that cannot be written leaves the history to read as interrupted in other windows, as before. + * @param historyDirectory - The history's folder. + * @example await writeNodeTestWriterRecord('/storage/learning/node-tests.v1/') + */ +export async function writeNodeTestWriterRecord( + historyDirectory: string, +): Promise { + try { + await atomicPrivateWrite( + join(historyDirectory, NODE_TEST_HISTORY_WRITER_FILE), + { pid: process.pid, ...(await currentStorageOwner()) }, + NODE_TEST_HISTORY_WRITER_LIMIT_BYTES, + ) + } catch { + // Without the record other windows read the history as interrupted. + } +}