diff --git a/AGENTS.md b/AGENTS.md index 3f879db66..7cbf45147 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,7 @@ The driving agent owns state, cycle selection and completion claims; GStack supp Standing rules, stated as rules (no decision log); delivery chain and ship limit: [Delivery](#delivery). - **Fallow scores application code only (2026-10-19):** `extension/src` without `src/tests`, and `extension/skills`; tests and `extension/scripts` are out ([checks](#commands-and-checks)). +- **Everything for the product (owner, 2026-10-22):** 「お金を勝手に使うことと犯罪行為以外は禁止しないからPRODUCT_GOAL.mdの成果物/アプリケーションを最速/最高品質で作成する事に全リソースを捧げろ」. A rule, convention or skill step that plainly adds nothing to the product is skipped, not followed by habit; documents unrelated to the application ride on the current work branch, with no separate pull request and no wait on CI whose result is obvious. Spending money without the owner's permission and anything illegal stay forbidden, and the irreversible steps that **Never without the owner** lists still wait for the owner. - **The owner cannot answer:** every decision point takes the recommended option, named in the Issue's Owner decisions. A pause the owner directs stops the work at once; only the owner's explicit message lifts it. A conditional pause ("when X is done, stop") is not immediate. - **Dedicated Windows development machine (2026-10-14):** setup and repair (IME settings, QA reinstall or reset, needed data removal) need no new permission. Spend the time on live explanation, save and synchronized replay. A remedy that truly needs the owner: research it, finish independent preparation, give concrete verified steps. Outcomes and gates still govern. - **Never without the owner:** buying credits, subscription or billing changes, new or changed credentials, permanent deletion, security settings, repository visibility. Signing in with the owner's existing sessions to what the work needs is the agent's job, done without asking (2026-10-22). Preserve the owner's and others' edits, profiles, extensions, skills, settings, tabs, drafts, learning, target source and credentials. @@ -151,6 +152,20 @@ Never embed a demonstration or validation application in the extension, its runt - Check for existing utilities, hooks, components and types before adding one. Reusable functions and classes get short purpose, trigger and caller docs with an example; reference workspace symbols with JSDoc links; explain non-obvious branches and loops briefly. - DAMP behavior tests, hard-coded expectations, Arrange/Act/Assert, `test`; `toBeVisible` for displayed UI. +## Agent skills + +### Issue tracker + +GitHub Issues on laststance/codebook, through the `gh` CLI. See `docs/agents/issue-tracker.md`. + +### Triage labels + +The five default triage roles, each label named after its role (all five already exist on GitHub). See `docs/agents/triage-labels.md`. + +### Domain docs + +Single-context: one root `GLOSSARY.md` and `docs/adr/`, created lazily. See `docs/agents/domain.md`. + ## Codex compaction hook [`.codex/hooks.json`](.codex/hooks.json) registers only `SessionStart` with `^compact$`, ignoring startup and resume (Claude Code runs the same command from `.claude/settings.json`, the only shared Claude file: `compact` matcher, 5 s timeout); the command (Node, Git, POSIX shell) prints the owner's re-entry rule first (PRODUCT_GOAL.md in full and the PNG, then the Issue and this file in full), the scope and pause sentence, and this file's `## Mandatory entry` and `## Owner directions in force` sections verbatim, within 10,000 characters: Claude Code injects longer hook context only as a 2,000-character preview and a file path, so printing this whole file once cut the restore order. When those sections outgrow the limit, it prints the rule and points to this file; a missing section fails the hook. It restores no editing gate or Stop continuation. Each collaborator enables hooks, trusts the project, reviews the hook in Codex [`/hooks`](https://learn.chatgpt.com/docs/hooks) after every definition change (and disables it alone there if needed), and checks that a fresh `/compact` shows `CODEBOOK: RESTORE PRODUCT CONTEXT AFTER COMPACTION`, which proves delivery, not reading or understanding. Files alone do not establish activation; this repository changes no global Codex setting, and a Codex observation does not verify Claude Code delivery. `node --test .codex/restore-product-context.test.mjs` tests it (CI too): the rule within the first 2,000 characters, the whole output within 10,000 (about 1,500 tokens, under Codex's `additionalContextLimit` of 4,000), both sections verbatim; errors exit nonzero with stderr, with no fail-closed guarantee. diff --git a/CHANGELOG.md b/CHANGELOG.md index 044640967..42e0717aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [0.2.34.0] - 2026-10-22 (development candidate) + +Cycle 0049's second pull request ([Issue #107](https://github.com/laststance/codebook/issues/107)): a test replay's recorded test output in the Application pane, and the Study pane's test replay section rebuilt around the selected moment. The recorded source with its transitions and the native round in both editors come with the third pull request. + +**The Application pane shows the recorded test output.** + +- **Up to the selected moment.** A test replay's Application pane shows the output the test printed up to the selected moment and nothing printed after it, under the header `Recorded test output · s` and the test's file and title path. At the result it 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 still recording says nothing of an end. +- **Readable as text.** Lines written to standard error start with `[stderr] `. Terminal colors, cursor moves and other control characters (C0 and C1 controls, DEL and bidirectional controls) 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. +- **In place.** Moving through the replay updates the same page, 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. +- **Long output.** Moving through a long recorded output costs about the end the pane keeps: many short lines, or one very long line printed without line feeds, no longer slow the editor at each step or fill its memory. + +**The Study pane's test replay section.** It 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.`, and says `Source and debugger not recorded at this moment.` where they are not. **Previous stop** and **Next stop** come first in its transport, then **Play**, the time slider, **Previous event** and **Next event**. 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 you close the Application pane, the section says `The Application pane is closed.`, and **Show recorded output** opens it again at the selected moment. + +**Compatibility.** The MCP API stays 1.8 and the bundled skill 1.0.46. No stored format changes: test histories, learning materials and reader positions written by 0.2.33.0 open unchanged, and those written now open in 0.2.33.0. + +**Not observed.** No native run of this version has reached a test stop yet; the native round in VS Code and Cursor comes with the third pull request. A pane that Show recorded output opens again after you closed it can open beside the source rather than above it; its placement is checked natively in that round. + ## [0.2.33.0] - 2026-10-22 (development candidate) Cycle 0049 is a feature cycle ([Issue #107](https://github.com/laststance/codebook/issues/107)): a Vitest test run that the editor chat explained becomes a learning replay, a **test replay**. It is delivered in three pull requests, merged in order; this version holds the first, the loop and its contracts. The recorded test output in the Application pane and the summary's selected step come with the second, the recorded source with its transitions and the native round in both editors with the third. diff --git a/README.md b/README.md index d12ee4afc..8ad283a59 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Codebook contributes `debug.focusEditorOnBreak: false` as the native debugger de ## Implementation status -This is a preview, not a Marketplace or Open VSX release. The delivered version is **0.2.33.0** once the first pull request of [Cycle 0049](https://github.com/laststance/codebook/issues/107) is merged; until then 0.2.32.0 ([PR #106](https://github.com/laststance/codebook/pull/106)) is the delivered one, and the active cycle Issue says where the delivery stands. Cycle 0049 is a feature cycle: a Vitest test run that the editor chat explained becomes a learning replay, a **test replay**. Its first pull request holds the loop and its contracts: a test run reads `demonstrated` once its test passed after every declared stop and its history sealed complete; **Save learning replay** and **Open replay** in the status bar save and open it (its test output, the executed source and the native debugger's recorded stops; no application video); the run resource tells the agent the test's result and which explanations the saved learning still lacks; a node-test start's refusals arrive before its first stop with their fix; and the bundled skill 1.0.46 routes a question about a test to this flow. The MCP API is 1.8. The recorded test output in the Application pane and the summary's selected step come with its second pull request, the recorded source with its transitions and the native round in both editors with its third; the test replay's summary in the Learning summary view is the next feature cycle. What each earlier cycle delivered, with its tests, trials and limits, is in the [change history](CHANGELOG.md) and in that cycle's record, kept under the tag [`docs-archive-2026-10-20`](https://github.com/laststance/codebook/tree/docs-archive-2026-10-20/docs/cycles); from Cycle 0048 on, the cycle's Issue holds its tasks and state, and its pull requests the results. +This is a preview, not a Marketplace or Open VSX release. The delivered version is **0.2.34.0** once the second pull request of [Cycle 0049](https://github.com/laststance/codebook/issues/107) is merged; until then 0.2.33.0 ([PR #113](https://github.com/laststance/codebook/pull/113)) is the delivered one, and the active cycle Issue says where the delivery stands. Cycle 0049 is a feature cycle: a Vitest test run that the editor chat explained becomes a learning replay, a **test replay**. Its first pull request holds the loop and its contracts: a test run reads `demonstrated` once its test passed after every declared stop and its history sealed complete; **Save learning replay** and **Open replay** in the status bar save and open it (its test output, the executed source and the native debugger's recorded stops; no application video); the run resource tells the agent the test's result and which explanations the saved learning still lacks; a node-test start's refusals arrive before its first stop with their fix; and the bundled skill 1.0.46 routes a question about a test to this flow. The MCP API is 1.8. Its second pull request shows the recorded test output in the Application pane, updated in place up to the selected moment, and rebuilds the Study pane's test replay section around that moment (Previous stop and Next stop first, the learning summary drawn as a browser replay's with the selected step); the recorded source with its transitions and the native round in both editors come with its third; the test replay's summary in the Learning summary view is the next feature cycle. What each earlier cycle delivered, with its tests, trials and limits, is in the [change history](CHANGELOG.md) and in that cycle's record, kept under the tag [`docs-archive-2026-10-20`](https://github.com/laststance/codebook/tree/docs-archive-2026-10-20/docs/cycles); from Cycle 0048 on, the cycle's Issue holds its tasks and state, and its pull requests the results. The specification is [goal version 10](PRODUCT_GOAL.md), and the implementation targets external repositories. Historical fixture or coupled-Wiki success is not proof of that destination. diff --git a/VERSION b/VERSION index 29df183cb..f4c9ab72e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.33.0 +0.2.34.0 diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 000000000..e8084bf31 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,53 @@ +# Domain Docs + +How the engineering skills should consume this repo's domain documentation when exploring the codebase. + +In this repository, product outcomes live in [PRODUCT_GOAL.md](../../PRODUCT_GOAL.md) and owner rules in [AGENTS.md](../../AGENTS.md), stated as rules; a glossary entry or ADR never restates or overrides either. + +## Before exploring, read these + +- **`GLOSSARY.md`** at the repo root, or +- **`GLOSSARY-MAP.md`** at the repo root if it exists: it points at one `GLOSSARY.md` per context. Read each one relevant to the topic. +- **`docs/adr/`**: read ADRs that touch the area you're about to work in. In multi-context repos, also check `src//docs/adr/` for context-scoped decisions. + +If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `mattpocock-skills:domain-modeling` skill (reached via `mattpocock-skills:grilling` and `mattpocock-skills:codebase-design`) creates them lazily when terms or decisions actually get resolved. + +## File structure + +Single-context repo (most repos): + +``` +/ +├── GLOSSARY.md +├── docs/adr/ +│ ├── 0001-event-sourced-orders.md +│ └── 0002-postgres-for-write-model.md +└── src/ +``` + +Multi-context repo (presence of `GLOSSARY-MAP.md` at the root): + +``` +/ +├── GLOSSARY-MAP.md +├── docs/adr/ ← system-wide decisions +└── src/ + ├── ordering/ + │ ├── GLOSSARY.md + │ └── docs/adr/ ← context-specific decisions + └── billing/ + ├── GLOSSARY.md + └── docs/adr/ +``` + +## Use the glossary's vocabulary + +When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `GLOSSARY.md`. Don't drift to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, that's a signal: either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `mattpocock-skills:domain-modeling`). + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than silently overriding: + +> _Contradicts ADR-0007 (event-sourced orders), but worth reopening because…_ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 000000000..a78451e36 --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,50 @@ +# Issue tracker: GitHub + +Issues and specs for this repo live as GitHub issues. Use the `gh` CLI for all operations. + +## This repository + +[AGENTS.md](../../AGENTS.md) wins over this file: its `Where things live`, `Cycle rule` and `GitHub` sections define the cycle Issue (label `cycle`, title `Cycle NNNN: `, four body sections), goal Issue #1 and the `goal` label. Open an Issue from evidence, never from a planning tool's need. The repository is public: everything posted is world-readable, a security defect before its fix too. + +## Conventions + +- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies. +- **Read an issue**: `gh issue view --json number,title,body,labels,comments`. +- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters. +- **Make an issue a sub-issue of a parent**: `gh issue create --parent ...`, or `gh issue edit --add-sub-issue ` afterwards (`gh` 2.94+). Older `gh`: `gh api --method POST repos///issues//sub_issues -F sub_issue_id=` (database id, as in **Blocking** below). Without sub-issues, put `Part of #` at the top of the child body. +- **Comment on an issue**: `gh issue comment --body "..."` +- **Apply / remove labels**: `gh issue edit --add-label "..."` / `--remove-label "..."` +- **Close**: `gh issue close --comment "..."` + +Infer the repo from `git remote -v`; `gh` does this automatically when run inside a clone. + +## Pull requests as a triage surface + +**PRs as a request surface: no.** _(Set to `yes` if this repo treats external PRs as feature requests; `/triage` reads this flag.)_ + +When set to `yes`, PRs run through the same labels and states as issues, using the `gh pr` equivalents: + +- **Read a PR**: `gh pr view --comments` and `gh pr diff ` for the diff. +- **List external PRs for triage**: `gh api --paginate 'repos/{owner}/{repo}/pulls?state=open' --jq '.[] | select(.author_association | IN("OWNER","MEMBER","COLLABORATOR") | not) | {number, title, author: .user.login, author_association, labels: [.labels[].name]}'`. +- **Comment / label / close**: `gh pr comment`, `gh pr edit --add-label`/`--remove-label`, `gh pr close`. + +GitHub shares one number space across issues and PRs, so a bare `#42` may be either: resolve with `gh pr view 42` and fall back to `gh issue view 42`. + +## When a skill says "publish to the issue tracker" + +Create a GitHub issue. + +## When a skill says "fetch the relevant ticket" + +Read it as in **Read an issue** above. + +## Wayfinding operations + +Used by `/wayfinder`. The **map** is a single issue with **child** issues as tickets. + +- **Map**: a single issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body. `gh issue create --label wayfinder:map`. +- **Child ticket**: an issue linked to the map as a GitHub sub-issue (see **Make an issue a sub-issue of a parent**). Where sub-issues aren't enabled, add the child to a task list in the map body and put `Part of #` at the top of the child body. Labels: `wayfinder:` (`research`/`prototype`/`grilling`/`task`). Once claimed, the ticket is assigned to the driving dev. +- **Blocking**: GitHub's **native issue dependencies**, the canonical, UI-visible representation. Add an edge with `gh api --method POST repos///issues//dependencies/blocked_by -F issue_id=`, where `` is the blocker's numeric **database id** (`gh api repos///issues/ --jq .id`, _not_ the `#number` or `node_id`). GitHub reports `issue_dependencies_summary.blocked_by` (open blockers only, the live gate). Where dependencies aren't available, fall back to a `Blocked by: #, #` line at the top of the child body. A ticket is unblocked when every blocker is closed. +- **Frontier query**: list the map's open children (`gh issue list --state open`, scoped to the map's sub-issues / task list), drop any with an open blocker (`issue_dependencies_summary.blocked_by > 0`, or an open issue in the `Blocked by` line) or an assignee; first in map order wins. +- **Claim**: `gh issue edit --add-assignee @me`, the session's first write. +- **Resolve**: `gh issue comment --body ""`, then `gh issue close `, then append a context pointer (gist + link) to the map's Decisions-so-far. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 000000000..527646424 --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,15 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker. + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table. + +Edit the "Label in our tracker" column to match whatever vocabulary you actually use. diff --git a/extension/README.md b/extension/README.md index feff52a72..b7323d8d6 100644 --- a/extension/README.md +++ b/extension/README.md @@ -70,7 +70,11 @@ A Vitest test run that the chat explained (the `node-test` profile) is saved the - **Learning partially saved · Open replay** otherwise. The tooltip names the result and every missing part, and a notification says what to ask the chat for (a missing explanation) or that only a new test run records it (missing capture). A failing test's replay is saved too: it holds the failing run. - **Learning save failed · Retry** when stopping the run did not finish (Retry stops it again) or the sealed history could not be read back (Retry reads it again; the test does not run again), and **Learning save failed · Open retained history** when the history could not be sealed (the recorded part opens read-only). Each tooltip names the cause; the details are in **Output › Codebook**. -**Open replay**, or the test replay's row in Saved studies, opens it in the Study pane at its saved reader position, or on its first stop whose recorded source verifies. One replay shows at a time: while a browser replay is open, Codebook asks you to close it first. Saved studies list each test replay with its result, its capture and its explained stops, for example `Test replay · Test passed · exit code 0 · capture complete · 2 of 2 stops explained`. In this version the recorded output shows in the Study pane, and the Application pane points there. +**Open replay**, or the test replay's row in Saved studies, opens it in the Study pane at its saved reader position, or on its first stop whose recorded source verifies. One replay shows at a time: while a browser replay is open, Codebook asks you to close it first. Saved studies list each test replay with its result, its capture and its explained stops, for example `Test replay · Test passed · exit code 0 · capture complete · 2 of 2 stops explained`. + +The **Application pane** shows a test replay's recorded test output up to the selected moment and nothing printed after it. Its header reads `Recorded test output · s` over the test's file and title path (`` counts from the run's start, paused time included). Lines the test wrote to standard error start with `[stderr] `; terminal colors, cursor moves and other control characters are removed; the Node inspector's own lines fold into one muted line such as `2 Node inspector lines hidden`; and the pane keeps the last 65,536 characters, with a line such as `… 1200 earlier characters not shown` before them. At the result the output ends with the verdict (`Test passed · exit code 0`, `Test failed · exit code ` or `Ended by signal `); a replay that ended before its result says `Recording ended before the test result.`, 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 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. Unexpected failures on these paths write one entry each to **Output › Codebook**, with the surface, the study, the profile, the versions, the cause and the stack. The causes they show name the extension's storage, the workspace and the home folder as ``, `` and `~`; some other messages can still show full local paths. @@ -82,14 +86,14 @@ The bundled skill (1.0.46) routes a question about a test, or about library beha 2. In Copilot Chat (in VS Code, a new chat in a **Local** session) or Cursor Agent, ask `/codebook` your own question about a test, or about library behavior a test exercises. 3. Follow the chat: it pauses the test at the stops it chose and explains each from the captured values, which you can inspect in the native debugger meanwhile. 4. When the test has run, click **Save learning replay** in the status bar. -5. Click **Open replay**, or open the test replay from Saved studies, and step through its stops, its output and its result. +5. Click **Open replay**, or open the test replay from Saved studies, and step through its stops with **Previous stop** and **Next stop**: the Application pane shows the test output up to each moment, and the Study pane its explanation. #### Terms - **Learning replay**: a saved explained run that you can replay after it ended; a browser run's holds the application video. - **Test replay**: the learning replay of a test run: its test output, the executed source and the debugger's recorded stops; no application video. - **Study pane**: Codebook's reader beside the editor, with Saved studies, a replay's summary and its controls. -- **Application pane**: the pane that shows the recorded application of a browser replay. +- **Application pane**: the pane that shows the recorded application of a browser replay, or the recorded test output of a test replay. - **Stop**: a place in source where the run paused for the explanation, numbered in the order the run reached it. - **Saved studies**: the list of saved replays in the Study pane. @@ -182,7 +186,7 @@ Package builds require a POSIX host (macOS or Linux); Windows builds are rejecte ```sh pnpm --dir extension install --frozen-lockfile pnpm --dir extension package -pnpm --dir extension package:check "$PWD/artifacts/packages/codebook-0.2.33.vsix" +pnpm --dir extension package:check "$PWD/artifacts/packages/codebook-0.2.34.vsix" ``` The package command checks pnpm, clears only the extension compiler output, rebuilds, bundles production modules and stages an explicit file allowlist. Test/application fixtures and experimental dependencies are rejected. The independent checker validates archive entries, hashes, manifests and dependency notices before accepting the VSIX. @@ -348,7 +352,7 @@ Learner understanding is an explicit saved choice. Running, recording, replaying Recording starts before a configured repository run acquires its application. Saved films open independently of live execution and retain original source/interpretation timing. Missing frames or source context remain explicit. Historical files in retired formats are preserved on disk and listed as unavailable; they cannot start an application or be silently converted into repository evidence. -Opening saved learning starts a read-only **Codebook · Recorded learning** debug session: the editor's own debugger shows the recorded source, scopes and call stack of the selected moment. Once native Run and Debug receives a recorded stop, the moment waits up to one second for it to load the stop's values. If Variables is collapsed or hidden, or another stopped session is selected, the moment still appears and Study shows the hint `Recorded values for this moment are not loaded yet. Show Variables opens Run and Debug and expands them; if the first scope stays closed, expand it. If Call Stack lists several sessions, select “Codebook · Recorded learning” first.` with a **Show Variables** button below it. One press opens Run and Debug, focuses Variables and expands its first scope, which lists the recorded values; a host without a command for it gets `Open Run and Debug and expand Variables to see the recorded values.` instead. Later moments do not wait again until values load; the hint and the button then clear. Ending the **Codebook · Recorded learning** session or closing the reader also clears it. Unrecorded gaps, stops without recorded scopes and stops whose only Variables group is the recording-limitations note appear at once with no hint. Saved Node test stops show the same hint and button beside their position. A Node stop is labelled **captured source unavailable** only when its captured source does not match the stop; ending the session, opening a recording or reopening the reader no longer adds that label. Cursor lists the scopes of a collapsed Variables without loading values, which is why the hint names the first scope. +Opening saved learning starts a read-only **Codebook · Recorded learning** debug session: the editor's own debugger shows the recorded source, scopes and call stack of the selected moment. Once native Run and Debug receives a recorded stop, the moment waits up to one second for it to load the stop's values. If Variables is collapsed or hidden, or another stopped session is selected, the moment still appears and Study shows the hint `Recorded values for this moment are not loaded yet. Show Variables opens Run and Debug and expands them; if the first scope stays closed, expand it. If Call Stack lists several sessions, select “Codebook · Recorded learning” first.` with a **Show Variables** button below it. One press opens Run and Debug, focuses Variables and expands its first scope, which lists the recorded values; a host without a command for it gets `Open Run and Debug and expand Variables to see the recorded values.` instead. Later moments do not wait again until values load; the hint and the button then clear. Ending the **Codebook · Recorded learning** session or closing the reader also clears it. Unrecorded gaps, stops without recorded scopes and stops whose only Variables group is the recording-limitations note appear at once with no hint. Saved Node test stops show the same hint and button beside their position. A Node stop reads `Recorded source unavailable for this stop.` only when its captured source does not match the stop; ending the session, opening a recording or reopening the reader no longer adds that line. Cursor lists the scopes of a collapsed Variables without loading values, which is why the hint names the first scope. A slow start does not fail a replay. Loading a recording and starting or retiring the historical session do not count against the five-second paint bound; that bound applies to each frame's painting, and a presentation that never reaches painting ends after 23 seconds. If the editor needs more than five seconds to start the historical session, the moment that waited for it fails, and the session is adopted when its start completes: the next moment is presented without reopening the recording. A session you stopped meanwhile is not adopted. If the editor does not stop the historical session within five seconds, the session becomes unavailable and reopening the learning replay starts a new one. Closing the reader does not wait on the native debugger or on a frame that never paints beyond those bounds. It waits at most five seconds in all for its pending storage writes (a running study's final checkpoint, the saved replay position, the archive writes and the library queue); a write still pending then keeps running and the reader closes. diff --git a/extension/package.json b/extension/package.json index 0c2b9b5ff..b7dbe4137 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.33", + "version": "0.2.34", "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 new file mode 100644 index 000000000..6f001d237 --- /dev/null +++ b/extension/scripts/fixtures/learning-sequence-golden.txt @@ -0,0 +1 @@ +
ApplicationSource handler1. Observed submission2. Source relationship3. Unknown completion
  1. Observed submission · observed

    The recorded source stop follows the user input.

  2. Source relationship · source-derived

    Derived from captured source, not proof of execution.

  3. Unknown completion · unknown

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

    No recorded evidence is linked to this step.

\ No newline at end of file diff --git a/extension/scripts/study-bootstrap-qa.mjs b/extension/scripts/study-bootstrap-qa.mjs index 04feb4bbd..59dcc9f6b 100644 --- a/extension/scripts/study-bootstrap-qa.mjs +++ b/extension/scripts/study-bootstrap-qa.mjs @@ -8,6 +8,7 @@ import { resolve } from 'node:path' import { projectStudy } from '../dist/studyModel.js' import { createConfiguredStudy } from '../dist/tests/utils/createConfiguredStudy.js' import { studyHtml } from '../dist/ui/studyHtml.js' +import { capturedAppHtml } from '../dist/utils/capturedAppHtml.js' import { decodeLearnerProgress } from '../dist/utils/decodeLearnerProgress.js' import { selectStudy } from '../dist/utils/selectStudy.js' @@ -1243,8 +1244,28 @@ async function check(page, options, collected) { sourceAvailable: true, debuggerAvailable: true, } + // The host words the test replay section (projectNodeTestSection); the pane places its lines. + const nodeSection = { + status: + 'Test passed · exit code 0 · capture complete · 1 of 1 stops explained', + position: 'Stop 1 of 1 · 1.20 s', + pointer: 'Output up to this moment is in the Application pane.', + applicationClosed: false, + sameMomentStepIds: [], + stepActions: {}, + events: 'Events (1)', + verdict: 'Test passed · exit code 0', + previousStop: false, + nextStop: false, + } + const unverifiedNodeSection = { + ...nodeSection, + notRecorded: 'Source and debugger not recorded at this moment.', + unverified: 'Recorded source unavailable for this stop.', + } await publish({ ...replay, + nodeHistorySection: nodeSection, nodeHistoryPosition: { ...nodeStop, nativeValuesUninspected: true }, }) const nodeWithoutValues = { @@ -1257,6 +1278,7 @@ async function check(page, options, collected) { } await publish({ ...replay, + nodeHistorySection: nodeSection, nodeHistoryPosition: { ...nodeStop, nativeValuesUninspected: false }, }) const nodeWithValues = { @@ -1267,42 +1289,56 @@ async function check(page, options, collected) { name: 'a saved Node stop without native values shows the Run and Debug hint in its own muted status until values load', actual: { nodeWithoutValues, nodeWithValues }, passed: - nodeWithoutValues.position === - 'Debugger stop 1 · 1.20 s · output shown through this event.' && + nodeWithoutValues.position === 'Stop 1 of 1 · 1.20 s' && nodeWithoutValues.hint === 'Recorded values for this moment are not loaded yet. Show Variables opens Run and Debug and expands them; if the first scope stays closed, expand it. If Call Stack lists several sessions, select “Codebook · Recorded learning” first.' && nodeWithoutValues.role === 'status' && nodeWithoutValues.muted && - nodeWithValues.position === - 'Debugger stop 1 · 1.20 s · output shown through this event.' && + nodeWithValues.position === 'Stop 1 of 1 · 1.20 s' && nodeWithValues.hint === '', }) + /** Reads the test replay section's position and the two lines that say what is not recorded. */ + const nodeMomentLines = async () => ({ + position: await page.locator('#node-history-position').textContent(), + notRecorded: await page + .locator('#node-history-not-recorded') + .textContent(), + unverified: await page + .locator('#node-history-unverified') + .textContent(), + }) await publish({ ...replay, + nodeHistorySection: nodeSection, nodeHistoryPosition: { ...nodeStop, debuggerAvailable: false }, }) - const nodeSessionEnded = await page - .locator('#node-history-position') - .textContent() + const nodeSessionEnded = await nodeMomentLines() await publish({ ...replay, + nodeHistorySection: unverifiedNodeSection, nodeHistoryPosition: { ...nodeStop, sourceAvailable: false, debuggerAvailable: false, }, }) - const nodeSourceMismatch = await page - .locator('#node-history-position') - .textContent() + const nodeSourceMismatch = await nodeMomentLines() cases.push({ - name: 'a saved Node stop whose native session ended keeps its source label, and only a real source mismatch says captured source unavailable', + name: 'a saved test replay stop whose native session ended keeps its position line, and only a real source mismatch shows the unavailable-source and not-recorded lines', actual: { nodeSessionEnded, nodeSourceMismatch }, passed: - nodeSessionEnded === - 'Debugger stop 1 · 1.20 s · output shown through this event.' && - nodeSourceMismatch === - 'Debugger stop 1 · captured source unavailable · 1.20 s · output shown through this event.', + JSON.stringify(nodeSessionEnded) === + JSON.stringify({ + position: 'Stop 1 of 1 · 1.20 s', + notRecorded: '', + unverified: '', + }) && + JSON.stringify(nodeSourceMismatch) === + JSON.stringify({ + position: 'Stop 1 of 1 · 1.20 s', + notRecorded: 'Source and debugger not recorded at this moment.', + unverified: 'Recorded source unavailable for this stop.', + }), }) // Arrange: the Node history reader alone, with its selected stop shown by the native session. const nodeReader = { @@ -1371,6 +1407,7 @@ async function check(page, options, collected) { // Act: the selected stop's captured source no longer matches its recorded hash. await publish({ ...nodeReader, + nodeHistorySection: unverifiedNodeSection, nodeHistoryPosition: { ...nodeStop, sourceAvailable: false, @@ -1379,7 +1416,9 @@ async function check(page, options, collected) { }) // Counted and read without waiting, so a missing element is a failed case instead of a harness timeout. const sourceMismatch = { - position: await page.locator('#node-history-position').textContent(), + unverified: await page + .locator('#node-history-unverified') + .textContent(), inspectButtons: await nodeInspect.count(), explanation: await page .locator('#node-history-events .node-history-event p.muted') @@ -1389,8 +1428,8 @@ async function check(page, options, collected) { name: 'a selected Node stop whose captured source does not match offers no Inspect in native debugger', actual: sourceMismatch, passed: - sourceMismatch.position === - 'Debugger stop 1 · captured source unavailable · 1.20 s · output shown through this event.' && + sourceMismatch.unverified === + 'Recorded source unavailable for this stop.' && sourceMismatch.inspectButtons === 0 && sourceMismatch.explanation.join('|') === 'Matching captured source is unavailable for native debugging.', @@ -1450,11 +1489,14 @@ async function check(page, options, collected) { unselectedMismatch.explanation.join('|') === 'Matching captured source is unavailable for native debugging.', }) - // Arrange: keyboard focus on the selected stop's Inspect (Cycle 0044, 0033-OCR4). + // Arrange: keyboard focus on the selected stop's Inspect (Cycle 0044, 0033-OCR4), in the event list the learner expanded (DS16 collapses it). await publish({ ...nodeReader, nodeHistoryPosition: { ...nodeStop, debuggerAvailable: false }, }) + await page.locator('#node-history-events-details').evaluate((details) => { + details.open = true + }) await nodeInspect.focus() const stopRequestsBefore = await page.evaluate( () => @@ -1690,6 +1732,1348 @@ async function check(page, options, collected) { cardOverflow.cardTextLength === 400 && cardOverflow.scrollWidth === cardOverflow.clientWidth, }) + + // Arrange: a test replay of two outputs, one stop and its passing result, with saved learning whose two steps share the stop; every line is worded by the host, as projectNodeTestSection words it (Design 8, red first). + const replayHash = 'b'.repeat(64) + const replayEntries = [ + { + type: 'output', + sequence: 1, + elapsedMs: 100, + channel: 'stdout', + text: 'RUN src/jobs.test.ts\n', + }, + { + type: 'output', + sequence: 2, + elapsedMs: 150, + channel: 'stderr', + text: 'slow timer\n', + }, + { + type: 'stop', + sequence: 3, + index: 0, + elapsedMs: 420, + source: { + path: 'src/jobs.ts', + hash: replayHash, + line: 12, + column: 5, + }, + frame: { name: 'schedule', line: 12, column: 5 }, + facts: [], + debuggerSnapshot: { + frames: [ + { + line: 12, + column: 5, + source: { name: 'src/jobs.ts', hash: replayHash, line: 12 }, + }, + ], + variables: [], + }, + }, + { + type: 'result', + sequence: 4, + elapsedMs: 900, + exitCode: 0, + signal: null, + outputTail: '', + }, + ] + const replayLearning = { + content: { + question: 'How does the scheduler delay a job?', + title: + 'How the job scheduler delays a job until its timer fires and then runs it exactly once', + summary: + 'The test schedules a job and checks that it runs after its timer.', + participants: [ + { id: 'test', label: 'Job test' }, + { id: 'scheduler', label: 'schedule' }, + { id: 'timer', label: 'Timer' }, + ], + steps: [ + { + id: 'read', + from: 'test', + to: 'scheduler', + label: 'Read the delay', + explanation: 'The stop shows the delay of 5,000 ms.', + evidence: 'observed', + reference: { type: 'stop', sequence: 3, sourceHash: replayHash }, + }, + { + id: 'keep', + from: 'scheduler', + to: 'timer', + label: 'Keep the timer', + explanation: 'The scheduler keeps the timer it created.', + evidence: 'source-derived', + reference: { type: 'stop', sequence: 3, sourceHash: replayHash }, + }, + { + id: 'verdict', + from: 'timer', + to: 'test', + label: 'Pass', + explanation: 'The job ran after its timer, so the test passed.', + evidence: 'observed', + reference: { type: 'result', sequence: 4 }, + }, + ], + }, + positions: [ + { stepId: 'read', elapsedMs: 420, sequence: 3 }, + { stepId: 'keep', elapsedMs: 420, sequence: 3 }, + { stepId: 'verdict', elapsedMs: 900, sequence: 4 }, + ], + } + const replaySection = { + status: + 'Test passed · exit code 0 · capture complete · 1 of 1 stops explained', + pointer: 'Output up to this moment is in the Application pane.', + applicationClosed: false, + sameMomentStepIds: [], + stepActions: { + read: 'Inspect stop 1 · 0.42 s', + keep: 'Inspect stop 1 · 0.42 s', + verdict: 'Inspect result · 0.90 s', + }, + events: 'Events (4)', + verdict: 'Test passed · exit code 0', + } + const notRecordedLine = 'Source and debugger not recorded at this moment.' + /** The Study message of the job scheduler's test replay at one selected event. */ + const testReplayAt = (position, section, mode = 'paused') => ({ + ...nodeReader, + nodeHistoryEventCount: 4, + nodeHistory: { + header: { studyId: 'job-study' }, + status: 'complete', + entries: replayEntries, + }, + nodeHistoryLearningSummary: replayLearning, + nodeHistoryPosition: position, + nodeHistorySection: { ...replaySection, ...section }, + nodeHistoryPlayback: { + mode, + sequence: position.sequence, + positionMs: position.elapsedMs, + startMs: 100, + endMs: 900, + loading: false, + }, + }) + const atFirstOutput = testReplayAt( + { sequence: 1, type: 'output', eventIndex: 0, elapsedMs: 100 }, + { + position: 'Output · 0.10 s', + notRecorded: notRecordedLine, + previousStop: false, + nextStop: true, + }, + ), + atStop = testReplayAt( + { + sequence: 3, + type: 'stop', + index: 0, + eventIndex: 2, + elapsedMs: 420, + sourceAvailable: true, + debuggerAvailable: true, + }, + { + position: 'Stop 1 of 1 · 0.42 s', + explanation: 'The stop shows the delay of 5,000 ms.', + selectedStepId: 'read', + sameMomentStepIds: ['keep'], + previousStop: false, + nextStop: false, + }, + ), + atResult = testReplayAt( + { sequence: 4, type: 'result', eventIndex: 3, elapsedMs: 900 }, + { + position: 'Result · Test passed · exit code 0 · 0.90 s', + explanation: 'The job ran after its timer, so the test passed.', + notRecorded: notRecordedLine, + selectedStepId: 'verdict', + previousStop: true, + nextStop: false, + }, + ) + /** Reads the section's lines, the step marked current and the steps marked as the same recorded moment. */ + const replaySectionLines = () => + page.evaluate(() => ({ + position: document.getElementById('node-history-position') + .textContent, + explanation: document.getElementById('node-history-explanation') + .textContent, + notRecorded: document.getElementById('node-history-not-recorded') + .textContent, + current: Array.from( + document.querySelectorAll( + '#node-learning-steps [data-node-step][aria-current="true"]', + ), + (control) => control.dataset.nodeStep, + ), + sameMoment: Array.from( + document.querySelectorAll( + '#node-learning-steps .same-moment:not([hidden])', + ), + (note) => + note.parentElement.querySelector('[data-node-step]').dataset + .nodeStep, + ), + stepControls: document.querySelectorAll( + '#node-learning-steps [data-node-step]', + ).length, + })) + // Act: the replay opens on its first output, then shows the stop and the result. + await page.setViewportSize({ width: 600, height: 800 }) + await publish(atFirstOutput) + const firstOutputLines = await replaySectionLines() + await publish(atStop) + const stopLines = await replaySectionLines() + await publish(atResult) + const resultLines = await replaySectionLines() + // Assert + cases.push({ + name: 'a test replay section shows each moment’s locator, explanation and not-recorded line, marks exactly the selected step current and names the other steps of its moment (Design 8)', + actual: { firstOutputLines, stopLines, resultLines }, + passed: + JSON.stringify({ firstOutputLines, stopLines, resultLines }) === + JSON.stringify({ + firstOutputLines: { + position: 'Output · 0.10 s', + explanation: '', + notRecorded: notRecordedLine, + current: [], + sameMoment: [], + stepControls: 3, + }, + stopLines: { + position: 'Stop 1 of 1 · 0.42 s', + explanation: 'The stop shows the delay of 5,000 ms.', + notRecorded: '', + current: ['read'], + sameMoment: ['keep'], + stepControls: 3, + }, + resultLines: { + position: 'Result · Test passed · exit code 0 · 0.90 s', + explanation: 'The job ran after its timer, so the test passed.', + notRecorded: notRecordedLine, + current: ['verdict'], + sameMoment: [], + stepControls: 3, + }, + }), + }) + // 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, + }), + ) + const whenClosed = await page.evaluate(() => ({ + rowShown: !document.getElementById('node-history-closed').hidden, + pointer: document.getElementById('node-history-pointer').textContent, + })) + await page.evaluate(() => { + window.sent = [] + }) + // Act + await page.locator('#node-history-closed button').click() + const sentOnClick = await page.evaluate(() => + window.sent + .filter((message) => message.type === 'action') + .map((message) => message.action), + ) + await publish(atStop) + const rowShownWhenBack = await page.evaluate( + () => !document.getElementById('node-history-closed').hidden, + ) + // Assert + cases.push({ + name: 'a closed Application pane is named in the test replay section, Show recorded output asks for it again, and the row goes once the pane is back (DS7)', + actual: { whenClosed, sentOnClick, rowShownWhenBack }, + passed: + JSON.stringify({ whenClosed, sentOnClick, rowShownWhenBack }) === + JSON.stringify({ + whenClosed: { rowShown: true, pointer: paneClosedLine }, + sentOnClick: ['focus-app'], + rowShownWhenBack: false, + }), + }) + // Arrange: the keyboard alone, from the opened replay (DS17): Step details, a step of the stop, Next stop, Previous stop, a seek and Pause. + await publish(atFirstOutput) + await page.evaluate(() => { + window.sent = [] + }) + // Act + await toggleByKeyboard('node-learning-steps-details') + await page.locator('#node-learning-steps [data-node-step="keep"]').focus() + await page.keyboard.press('Enter') + await page.locator('#node-history-stop-next').focus() + await page.keyboard.press('Enter') + await publish(atResult) + await page.locator('#node-history-stop-previous').focus() + await page.keyboard.press('Enter') + await publish(atStop) + await page.locator('#node-history-playhead').focus() + await page.keyboard.press('End') + await publish( + testReplayAt( + atStop.nodeHistoryPosition, + atStop.nodeHistorySection, + 'playing', + ), + ) + await page.locator('#node-history-playback-toggle').focus() + await page.keyboard.press('Enter') + const keyboardRequests = await page.evaluate(() => + window.sent + .filter((message) => message.type.startsWith('node-history')) + .map(({ type, sequence, stepId, direction, unit, action }) => ({ + type, + sequence, + stepId, + direction, + unit, + action, + })), + ) + // Assert + cases.push({ + name: 'the keyboard alone opens Step details, selects a step of the stop, moves by Next stop and Previous stop, seeks and pauses a test replay (Design 8)', + actual: keyboardRequests, + passed: + JSON.stringify(keyboardRequests) === + JSON.stringify([ + { type: 'node-history-event', sequence: 3, stepId: 'keep' }, + { type: 'node-history-step', direction: 'next', unit: 'stop' }, + { type: 'node-history-step', direction: 'previous', unit: 'stop' }, + { type: 'node-history-playback', action: 'seek' }, + { type: 'node-history-playback', action: 'pause' }, + ]), + }) + // Arrange: the section's four move buttons with a replay saved and none chosen, then at three moments of the replay (DS12): the stop buttons follow the stops around the selected event, the event buttons its place among the events. + /** Reads which of the section's move buttons the learner can press. */ + const enabledMoves = () => + page.evaluate(() => + Object.fromEntries( + [ + ['previousStop', 'node-history-stop-previous'], + ['nextStop', 'node-history-stop-next'], + ['previousEvent', 'node-history-moment-previous'], + ['nextEvent', 'node-history-moment-next'], + ].map(([move, id]) => [ + move, + !document.getElementById(id).disabled, + ]), + ), + ) + // Act + await publish({ + ...options.message, + revision: revisionAt(5), + nodeHistoryCatalog: [{ studyId: 'job-study' }], + }) + const movesWithNoReplay = await enabledMoves() + await publish(atFirstOutput) + const movesAtTheFirstOutput = await enabledMoves() + await publish(atStop) + const movesAtTheOnlyStop = await enabledMoves() + await publish(atResult) + const movesAtTheResult = await enabledMoves() + // Assert + // Value: protects=the section's four move buttons can be pressed only where the move exists: with no replay chosen none can; at the first output Next stop and Next event can; at the only stop, which has events on both sides, neither stop button can but both event buttons can; at the result Previous stop and Previous event can, and Next stop and Next event cannot; fails_when=a stop button is enabled where no stop lies that way (the learner's press ends in a refusal line), a button the host would honor stays disabled, the stop buttons follow the event position or the event buttons follow the stops, or a button can be pressed with no replay; why_new=PR-B1 adds the stop buttons beside the event buttons and takes their state from the host's flags (DS12), and no case reads a disabled state: the keyboard case presses buttons that are enabled and the focus case reads them for a replay of one event only; seam=none; no_claim=which flags the host sends for each moment (projectNodeTestSection's tests) or what a press sends (the keyboard case) (stand-ins: the Study messages the host posts) + cases.push({ + name: 'a test replay offers Previous stop and Next stop only where a stop lies that way and Previous event and Next event only where an event does (Design 8, DS12)', + actual: { + movesWithNoReplay, + movesAtTheFirstOutput, + movesAtTheOnlyStop, + movesAtTheResult, + }, + passed: + JSON.stringify({ + movesWithNoReplay, + movesAtTheFirstOutput, + movesAtTheOnlyStop, + movesAtTheResult, + }) === + JSON.stringify({ + movesWithNoReplay: { + previousStop: false, + nextStop: false, + previousEvent: false, + nextEvent: false, + }, + movesAtTheFirstOutput: { + previousStop: false, + nextStop: true, + previousEvent: false, + nextEvent: true, + }, + movesAtTheOnlyStop: { + previousStop: false, + nextStop: false, + previousEvent: true, + nextEvent: true, + }, + movesAtTheResult: { + previousStop: true, + nextStop: false, + previousEvent: true, + nextEvent: false, + }, + }), + }) + /** Counts the rendered lines of an element by the height of a one-word copy of it, so a clamp to two lines is measured as the learner sees it. */ + const renderedLines = (shownIn, selector) => + shownIn.evaluate((selector) => { + const shown = document.querySelector(selector), + probe = shown.cloneNode(false) + probe.removeAttribute('id') + probe.textContent = 'X' + shown.after(probe) + const lines = Math.round( + shown.getBoundingClientRect().height / + probe.getBoundingClientRect().height, + ) + probe.remove() + return lines + }, selector) + // Act: the stop's section at three widths (DS19). + await publish(atStop) + const studyWidths = [] + for (const width of [320, 480, 760]) { + await page.setViewportSize({ width, height: 720 }) + studyWidths.push({ + width, + ...(await page.evaluate(() => { + const diagram = document.querySelector( + '#node-learning-sequence .sequence-diagram', + ) + return { + pageScrolls: + document.documentElement.scrollWidth > + document.documentElement.clientWidth, + diagramFitsOrScrollsInside: + diagram.scrollWidth <= diagram.clientWidth || + getComputedStyle(diagram).overflowX === 'auto', + } + })), + titleLines: await renderedLines(page, '#node-learning-title'), + }) + } + await page.setViewportSize({ width: 600, height: 800 }) + await page.locator('#node-learning-steps-details').evaluate((details) => { + details.open = false + }) + // Assert + cases.push({ + name: 'a test replay section at 320, 480 and 760 px has no page-level horizontal scroll, a title of at most two lines and a sequence diagram that scrolls inside itself (Design 8)', + actual: studyWidths, + passed: studyWidths.every( + (reading) => + !reading.pageScrolls && + reading.diagramFitsOrScrollsInside && + reading.titleLines >= 1 && + reading.titleLines <= 2, + ), + }) + // Arrange: the section's states before it has a replay or an event to show (DS7), in the pane's fixed words (the String catalog): no replay saved, replays saved and none chosen, then a replay with no event selected and no saved learning, then the same replay with its learning again. + /** Reads the section's status and locator lines, whether it says that no learning was saved, and what of the replay's diagram and steps is drawn. */ + const sectionReading = () => + page.evaluate(() => { + const noMaterial = document.getElementById('node-history-no-material') + return { + status: document.getElementById('node-history-status').textContent, + position: document.getElementById('node-history-position') + .textContent, + noMaterial: noMaterial.hidden ? 'hidden' : noMaterial.textContent, + learning: document.getElementById('node-history-learning').hidden + ? 'hidden' + : 'shown', + drawn: + document.getElementById('node-learning-sequence').children + .length + + document.getElementById('node-learning-steps').children.length, + } + }) + // Act + await publish({ ...options.message, revision: revisionAt(5) }) + const noReplaySaved = await sectionReading() + await publish({ + ...options.message, + revision: revisionAt(5), + nodeHistoryCatalog: [{ studyId: 'job-study' }], + }) + const noneChosen = await sectionReading() + await publish(atStop) + const withLearning = await sectionReading() + await publish({ + ...atStop, + nodeHistoryPosition: undefined, + nodeHistoryPlayback: undefined, + nodeHistoryLearningSummary: undefined, + nodeHistorySection: { + ...replaySection, + previousStop: false, + nextStop: true, + }, + }) + const withoutSelectionAndLearning = await sectionReading() + await publish(atStop) + const learningAgain = await sectionReading() + // Assert + const withTheStop = { + status: + 'Test passed · exit code 0 · capture complete · 1 of 1 stops explained', + position: 'Stop 1 of 1 · 0.42 s', + noMaterial: 'hidden', + learning: 'shown', + drawn: 2, + } + // Value: protects=the Study pane's test replay section is never blank and never stale: it says that no replay is saved, to choose one, or to select an event, says when no learning summary was saved, and keeps no diagram or steps of the replay before it; fails_when=a state shows another's line or none, the no-learning line is missing or stays after learning returns, or a replay without learning shows the diagram of the replay before; why_new=PR-B1 reworded and moved these states (the String catalog) and no test, new or old, read any of them; seam=none; no_claim=what the host projects for each state (projectNodeTestSection's tests) or the line for a closed Application pane (stand-ins: the pane's page and the Study messages the host posts) + cases.push({ + name: 'a test replay section says that no replay is saved, to choose one and to select an event, says when no learning summary was saved and draws no diagram of the replay before it (Design 8, DS7)', + actual: { + noReplaySaved, + noneChosen, + withLearning, + withoutSelectionAndLearning, + learningAgain, + }, + passed: + JSON.stringify({ + noReplaySaved, + noneChosen, + withLearning, + withoutSelectionAndLearning, + learningAgain, + }) === + JSON.stringify({ + noReplaySaved: { + status: 'No test replay has been saved in this workspace.', + position: '', + noMaterial: 'hidden', + learning: 'hidden', + drawn: 0, + }, + noneChosen: { + status: + 'Choose a test replay from Saved studies to step through its output and debugger stops.', + position: '', + noMaterial: 'hidden', + learning: 'hidden', + drawn: 0, + }, + withLearning: withTheStop, + withoutSelectionAndLearning: { + status: withTheStop.status, + position: + 'Select an event to move through the recorded test history.', + noMaterial: 'No learning summary was saved for this test replay.', + learning: 'hidden', + drawn: 0, + }, + learningAgain: withTheStop, + }), + }) + // Arrange: the Application pane's document of the same replay at its first output (Design 3), in a page of its own with its saved webview state; the Study page stays as it is for the cases that follow. + const applicationPage = await page.context().newPage() + await applicationPage.setViewportSize({ width: 760, height: 360 }) + await applicationPage.goto(options.url) + await applicationPage.evaluate(() => { + window.sent = [] + window.savedState = undefined + window.acquireVsCodeApi = () => ({ + getState: () => window.savedState, + setState(state) { + window.savedState = state + }, + postMessage: (message) => window.sent.push(message), + }) + }) + await applicationPage.setContent(options.html.application) + const twoFrames = () => + applicationPage.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ) + await twoFrames() + const firstOutputDocument = await applicationPage.evaluate(() => ({ + time: document.getElementById('test-output-time').textContent, + lines: Array.from( + document.querySelectorAll('#test-output .output-text'), + (line) => line.textContent, + ).length, + firstLine: document.querySelector('#test-output .output-text') + ?.textContent, + endHidden: document.getElementById('test-output-end').hidden, + })) + // Act: the learner scrolls up, selects a line and leaves the keyboard in the output; the host then posts the result's view, and a late older one. + await applicationPage.evaluate(() => { + document.getElementById('test-output').scrollTop = 0 + }) + await twoFrames() + await applicationPage.evaluate(() => { + const output = document.getElementById('test-output') + getSelection().selectAllChildren( + output.querySelectorAll('.output-text')[4], + ) + output.focus() + }) + await applicationPage.evaluate((views) => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'node-history-output', + generation: 2, + view: views.result, + }, + }), + ) + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'node-history-output', + generation: 1, + view: views.first, + }, + }), + ) + }, options.testReplayViews) + await twoFrames() + const afterUpdate = await applicationPage.evaluate(() => { + const output = document.getElementById('test-output') + return { + scrollTop: output.scrollTop, + selection: getSelection().toString(), + focused: document.activeElement?.id, + lines: output.querySelectorAll('.output-line').length, + stderrMarks: output.querySelectorAll('.stderr-mark').length, + end: document.getElementById('test-output-end').textContent, + time: document.getElementById('test-output-time').textContent, + jumpShown: !document.getElementById('test-output-jump').hidden, + } + }) + // Act: Jump to latest by keyboard. + await applicationPage.locator('#test-output-jump').focus() + await applicationPage.keyboard.press('Enter') + await twoFrames() + const afterJump = await applicationPage.evaluate(() => { + const output = document.getElementById('test-output') + return { + atEnd: + output.scrollHeight - output.scrollTop - output.clientHeight <= 2, + jumpShown: !document.getElementById('test-output-jump').hidden, + focused: document.activeElement?.id, + follow: window.savedState?.testOutputFollow, + } + }) + // The webview state as the document left it at the result, for the reload case below. + const stateAtResult = await applicationPage.evaluate( + () => window.savedState, + ) + // Assert + cases.push({ + name: 'the Application document of a test replay shows only the output up to its moment, and an in-place update to the result keeps the learner’s scroll position, selection and focus, ignores an older view and returns to the end with Jump to latest (Design 8)', + actual: { firstOutputDocument, afterUpdate, afterJump }, + passed: + JSON.stringify({ firstOutputDocument, afterUpdate, afterJump }) === + JSON.stringify({ + firstOutputDocument: { + time: 'Recorded test output · 0.10 s', + lines: 40, + firstLine: 'RUN src/jobs.test.ts', + endHidden: true, + }, + afterUpdate: { + scrollTop: 0, + selection: 'job 4 scheduled', + focused: 'test-output', + lines: 41, + stderrMarks: 1, + end: 'Test passed · exit code 0', + time: 'Recorded test output · 0.90 s', + jumpShown: true, + }, + afterJump: { + atEnd: true, + jumpShown: false, + focused: 'test-output', + follow: true, + }, + }), + }) + // Act: the Application document at three widths (DS19). + const applicationWidths = [] + for (const width of [320, 480, 760]) { + await applicationPage.setViewportSize({ width, height: 360 }) + applicationWidths.push({ + width, + pageScrolls: await applicationPage.evaluate( + () => + document.documentElement.scrollWidth > + document.documentElement.clientWidth, + ), + testLines: await renderedLines(applicationPage, '#test-output-test'), + }) + } + // Act: the host posts two views of a long recorded output, 32,768 one-character lines, the second without the first line and with one more (EN3: the update matches lines by their number in time linear in the lines). + const longUpdate = await applicationPage.evaluate(() => { + const lines = Array.from({ length: 32768 }, (_, index) => ({ + channel: 'stdout', + text: String(index % 10), + number: index + 1000, + })) + const view = { + time: 'Recorded test output · 0.90 s', + test: 'src/jobs.test.ts', + name: 'Recorded test output · src/jobs.test.ts · 0.90 s', + region: 'Recorded test output up to 0.90 s', + } + const post = (generation, shown) => + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'node-history-output', + generation, + view: { ...view, generation, lines: shown }, + }, + }), + ) + post(10, lines) + const started = performance.now() + post(11, [ + ...lines.slice(1), + { channel: 'stdout', text: 'last', number: 33768 }, + ]) + return { + // gstack-timing-ok: the bound guards a hang, not a speed: the earlier per-line lookup took 3.5 s and this update 61 ms on the Mac, so 1 s keeps a wide margin each way. + underASecond: performance.now() - started < 1000, + lines: document.querySelectorAll('#test-output .output-line').length, + last: document.querySelector('#test-output .output-line:last-child') + ?.textContent, + } + }) + // Assert + cases.push({ + name: 'an in-place update of a recorded test output of 32,768 lines takes less than a second in the Application pane (Design 8, EN3)', + actual: longUpdate, + passed: + longUpdate.underASecond && + longUpdate.lines === 32768 && + longUpdate.last === 'last\n', + }) + // Value: protects=the follow choice belongs to its own test replay (DS20): a document of another replay written into the same Application pane starts at the end of its output, although the learner stopped following in the first one, while a document of the same replay, as its restore copy writes it, keeps the learner's choice; fails_when=the webview state keeps one follow choice for every replay, so the next replay opens at the top of its output with Jump to latest shown; why_new=Step 9's testing and maintainability reviews found the choice kept under one key that outlives a replay; seam=none; no_claim=VS Code's keeping of webview state across documents, which the page's saved-state stand-in plays + // Act: the pane's document written again, once for another replay and once for this one, each with a saved choice not to follow; each write gets a page of its own, as a webview loads a new document in a fresh page. + const sameReplay = await applicationPage.evaluate( + () => window.savedState?.testOutputHistory, + ) + const followAfterWrite = async (savedState) => { + const writtenPage = await page.context().newPage() + await writtenPage.setViewportSize({ width: 760, height: 360 }) + await writtenPage.goto(options.url) + await writtenPage.evaluate((state) => { + window.savedState = state + window.acquireVsCodeApi = () => ({ + getState: () => window.savedState, + setState(next) { + window.savedState = next + }, + postMessage() {}, + }) + }, savedState) + await writtenPage.setContent(options.html.application) + await writtenPage.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ) + const reading = await writtenPage.evaluate(() => { + const output = document.getElementById('test-output') + return { + atEnd: + output.scrollHeight - output.scrollTop - output.clientHeight <= 2, + jumpShown: !document.getElementById('test-output-jump').hidden, + } + }) + await writtenPage.close() + return reading + } + // Value: protects=an Application document that the editor loads again from its first write (the pane moved to another window, webviews reloaded) shows the latest moment it had received, with that moment's time and verdict, instead of the moment it was first written with; fails_when=the reloaded document shows its first moment or its loading line while the Study pane, the source and the debugger show the latest one; why_new=Step 9's state-machines review found the pane's html written once per replay and each later moment only posted; seam=none; no_claim=VS Code's own reload, which the fresh page with the kept state stands in for + // Act: the first write loaded again in a fresh page with the state the document kept at the result. + const reloadedPage = await page.context().newPage() + await reloadedPage.setViewportSize({ width: 760, height: 360 }) + await reloadedPage.goto(options.url) + await reloadedPage.evaluate((state) => { + window.savedState = state + window.acquireVsCodeApi = () => ({ + getState: () => window.savedState, + setState(next) { + window.savedState = next + }, + postMessage() {}, + }) + }, stateAtResult) + await reloadedPage.setContent(options.html.application) + const reloaded = await reloadedPage.evaluate(() => ({ + time: document.getElementById('test-output-time').textContent, + end: document.getElementById('test-output-end').textContent, + endShown: !document.getElementById('test-output-end').hidden, + })) + await reloadedPage.close() + // Assert + cases.push({ + name: 'an Application document loaded again from its first write shows the latest moment it had received (EN3)', + actual: reloaded, + passed: + JSON.stringify(reloaded) === + JSON.stringify({ + time: 'Recorded test output · 0.90 s', + end: 'Test passed · exit code 0', + endShown: true, + }), + }) + // 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 }, + ) + await reloadedNotFollowingPage.setContent(options.html.application) + await reloadedNotFollowingPage.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ) + 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, + } + }, + ) + await reloadedNotFollowingPage.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, + passed: + JSON.stringify(reloadedNotFollowing) === + JSON.stringify({ + time: 'Recorded test output · 0.90 s', + atEnd: false, + jumpShown: true, + savedFollow: false, + }), + }) + const anotherReplay = await followAfterWrite({ + testOutputFollow: false, + testOutputHistory: 'another-test-replay', + }) + const sameReplayKept = await followAfterWrite({ + testOutputFollow: false, + testOutputHistory: sameReplay, + }) + // Assert + cases.push({ + name: 'a test replay’s Application document follows its own output although the learner stopped following in another replay, and the same replay’s document keeps the learner’s choice (DS20)', + actual: { sameReplay, anotherReplay, sameReplayKept }, + passed: + typeof sameReplay === 'string' && + JSON.stringify({ anotherReplay, sameReplayKept }) === + JSON.stringify({ + anotherReplay: { atEnd: true, jumpShown: false }, + sameReplayKept: { atEnd: false, jumpShown: true }, + }), + }) + // 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 }, + }, + ) + // 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() + // 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, + 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() {}, + }) + }) + 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) => { + for (let generation = 2; generation <= 21; generation += 1) + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'node-history-output', + generation, + view: { ...result, 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() + // 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, + passed: + JSON.stringify(streamed) === + JSON.stringify({ + whileStreaming: 1, + afterLeaving: 2, + keptGeneration: 21, + }), + }) + // 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 + } + 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( + ({ generation, view }) => + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'node-history-output', + generation, + view: { ...view, generation }, + }, + }), + ), + { generation, view: options.testReplayViews.result }, + ) + 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 timerPage.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 }, + passed: + JSON.stringify({ beforeInterval, afterInterval }) === + JSON.stringify({ + beforeInterval: { writes: 1, stateTimers: [500] }, + afterInterval: { writes: 2, kept: 4 }, + }), + }) + await applicationPage.close() + // Assert + cases.push({ + name: 'the Application document of a test replay at 320, 480 and 760 px has no page-level horizontal scroll and a test name of at most two lines (Design 8)', + actual: applicationWidths, + passed: applicationWidths.every( + (reading) => + !reading.pageScrolls && + reading.testLines >= 1 && + reading.testLines <= 2, + ), + }) + // Arrange: a second Application document of the same replay, in a page of its own with its saved webview state, for the in-place updates that move to another moment (Design 3, EN3, DS20); the page above is closed, and the cases below keep their own. + const updatePage = await page.context().newPage() + await updatePage.setViewportSize({ width: 760, height: 360 }) + await updatePage.goto(options.url) + await updatePage.evaluate(() => { + window.savedState = undefined + window.acquireVsCodeApi = () => ({ + getState: () => window.savedState, + setState(state) { + window.savedState = state + }, + postMessage: () => {}, + }) + }) + await updatePage.setContent(options.html.application) + /** Posts one view into the document as the host does for a later moment, then waits two frames so the layout has settled. */ + const postOutputView = async (generation, view) => { + await updatePage.evaluate( + ({ generation, view }) => + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'node-history-output', + generation, + view: { ...view, generation }, + }, + }), + ), + { generation, view }, + ) + await updatePage.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ) + } + /** Reads what the document shows after an update: the numbers of its lines, its header and names, its muted lines (`hidden` when one is left out) and whether it stands at the end of the output. */ + const readOutputUpdate = () => + updatePage.evaluate(() => { + const output = document.getElementById('test-output') + const mutedLine = (id) => { + const shown = document.getElementById(id) + return shown.hidden ? 'hidden' : shown.textContent + } + const numbers = Array.from(output.children, (line) => + Number(line.dataset.line), + ) + return { + first: numbers[0], + last: numbers.at(-1), + count: numbers.length, + ascending: numbers.every( + (number, index) => + index === 0 || number === numbers[index - 1] + 1, + ), + time: document.getElementById('test-output-time').textContent, + title: document.title, + region: output.getAttribute('aria-label'), + inspector: mutedLine('test-output-inspector'), + cut: mutedLine('test-output-cut'), + placeholder: mutedLine('test-output-placeholder'), + end: mutedLine('test-output-end'), + atEnd: + output.scrollHeight - output.scrollTop - output.clientHeight <= 2, + } + }) + const atTheResult = options.testReplayViews.result + const withoutEnd = { ...atTheResult } + delete withoutEnd.end + const earlierName = `Recorded test output · ${atTheResult.test} · 0.05 s` + /** The view of an earlier moment: jobs `from` to 25 of the output, with the lines it leaves out named. */ + const earlierWindow = (from) => ({ + ...withoutEnd, + time: 'Recorded test output · 0.05 s', + name: earlierName, + region: 'Recorded test output up to 0.05 s', + lines: Array.from({ length: 26 - from }, (_, index) => ({ + channel: 'stdout', + text: `job ${from + index} scheduled`, + number: from + index, + })), + cut: '… 120 earlier characters not shown', + inspector: '2 Node inspector lines hidden', + }) + // Act: the document follows the output to the result; the host then moves back to an earlier window of the output, widens it at its start and moves to a moment with no output yet. + await postOutputView(2, atTheResult) + const followedToResult = await readOutputUpdate() + await postOutputView(3, earlierWindow(10)) + const windowFromTen = await readOutputUpdate() + await postOutputView(4, earlierWindow(5)) + const windowFromFive = await readOutputUpdate() + await postOutputView(5, { + ...withoutEnd, + time: 'Recorded test output · 0.05 s', + name: earlierName, + region: 'Recorded test output up to 0.05 s', + lines: [], + placeholder: 'No test output recorded up to this moment.', + }) + const noOutputYet = await readOutputUpdate() + // Assert + const mutedWhenShown = { + inspector: '2 Node inspector lines hidden', + cut: '… 120 earlier characters not shown', + placeholder: 'hidden', + end: 'hidden', + atEnd: true, + } + // Value: protects=the Application document's in-place update: while the learner follows the output it ends at the latest line, an update to an earlier moment drops the later lines and the verdict, inserts lines at the start, and shows then hides the lines for what the output leaves out and for no output yet, with the header, the title and the region name following the moment; fails_when=a verdict, a later line or a muted line stays after the move back, the lines come out of order, the document stops following the end, or a name keeps the earlier moment's time; why_new=PR-B1 posts every later moment into one document and no other case posts a view that shortens or restarts the output, shows the cut and inspector lines or hides the verdict; seam=none; no_claim=the host's choice of views (the workspace and projection tests) or VS Code's own webview (stand-ins: the page's message event as the host posts it and a stub of the webview API) + cases.push({ + name: 'an in-place update of the Application document follows the output to the result, then at an earlier moment drops the later lines and the verdict, shows what the output leaves out and hides it again (Design 8, EN3, DS20)', + actual: { + followedToResult, + windowFromTen, + windowFromFive, + noOutputYet, + }, + passed: + JSON.stringify({ + followedToResult, + windowFromTen, + windowFromFive, + noOutputYet, + }) === + JSON.stringify({ + followedToResult: { + first: 0, + last: 40, + count: 41, + ascending: true, + time: 'Recorded test output · 0.90 s', + title: atTheResult.name, + region: 'Recorded test output up to 0.90 s', + inspector: 'hidden', + cut: 'hidden', + placeholder: 'hidden', + end: 'Test passed · exit code 0', + atEnd: true, + }, + windowFromTen: { + first: 10, + last: 25, + count: 16, + ascending: true, + time: 'Recorded test output · 0.05 s', + title: earlierName, + region: 'Recorded test output up to 0.05 s', + ...mutedWhenShown, + }, + windowFromFive: { + first: 5, + last: 25, + count: 21, + ascending: true, + time: 'Recorded test output · 0.05 s', + title: earlierName, + region: 'Recorded test output up to 0.05 s', + ...mutedWhenShown, + }, + noOutputYet: { + count: 0, + ascending: true, + time: 'Recorded test output · 0.05 s', + title: earlierName, + region: 'Recorded test output up to 0.05 s', + inspector: 'hidden', + cut: 'hidden', + placeholder: 'No test output recorded up to this moment.', + end: 'hidden', + atEnd: true, + }, + }), + }) + // Act: a line the test was still printing at one moment is finished at the next, and a later line holds markup as a repository's test may print it. + const markup = '' + const runLine = { + channel: 'stdout', + text: 'RUN src/jobs.test.ts', + number: 0, + } + await postOutputView(6, { + ...withoutEnd, + lines: [runLine, { channel: 'stdout', text: 'job 1 sch', number: 1 }], + }) + await updatePage.evaluate(() => { + window.runNode = document.querySelector('#test-output [data-line="0"]') + window.unfinishedNode = document.querySelector( + '#test-output [data-line="1"]', + ) + }) + await postOutputView(7, { + ...withoutEnd, + lines: [ + runLine, + { channel: 'stdout', text: 'job 1 scheduled', number: 1 }, + { channel: 'stderr', text: markup, number: 2 }, + ], + }) + const finishedLine = await updatePage.evaluate(() => { + const output = document.getElementById('test-output') + return { + unchangedLineKeepsItsNode: + output.querySelector('[data-line="0"]') === window.runNode, + finishedText: output.querySelector('[data-line="1"] .output-text') + .textContent, + unfinishedNodeLeft: !window.unfinishedNode.isConnected, + markupText: output.querySelector('[data-line="2"] .output-text') + .textContent, + markupMark: output.querySelector('[data-line="2"] .stderr-mark') + ?.textContent, + elementsInOutput: output.querySelectorAll('img, script, [onerror]') + .length, + scriptRan: window.invented === true, + } + }) + // Value: protects=a move back to a shorter moment while the learner has stopped following leaves following off: the browser's clamp of the scroll position to the shorter output is the document's change, not the learner's scroll back to the end; fails_when=the clamp turns following on, hides Jump to latest and saves that choice, so the next moment scrolls the learner to the end although they never scrolled back or pressed Jump to latest (DS20); why_new=Step 9 pass 2's state-machines review (wrong row: not following × a newer view whose output is shorter); seam=none; no_claim=a scroll the learner makes back to the end, which turns following on + // Arrange: the result's whole output, which the learner scrolls to its end and then just above it. + const settleUpdatePage = () => + updatePage.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ) + const readFollow = () => + updatePage.evaluate(() => ({ + jumpShown: !document.getElementById('test-output-jump').hidden, + follow: window.savedState?.testOutputFollow, + })) + await postOutputView(8, atTheResult) + await updatePage.evaluate(() => { + const output = document.getElementById('test-output') + output.scrollTop = output.scrollHeight + }) + await settleUpdatePage() + await updatePage.evaluate(() => { + const output = document.getElementById('test-output') + output.scrollTop = output.scrollHeight - output.clientHeight - 40 + }) + await settleUpdatePage() + const stoppedFollowing = await readFollow() + // Act: an earlier moment whose output is shorter than the scroll position. + await postOutputView(9, earlierWindow(20)) + const afterShorter = await readFollow() + // Assert + cases.push({ + name: 'a move back to a shorter moment leaves the Application document not following when the learner stopped following (DS20)', + actual: { stoppedFollowing, afterShorter }, + passed: + JSON.stringify({ stoppedFollowing, afterShorter }) === + JSON.stringify({ + stoppedFollowing: { jumpShown: true, follow: false }, + afterShorter: { jumpShown: true, follow: false }, + }), + }) + await updatePage.close() + // Assert + // Value: protects=the in-place update replaces exactly the line whose text changed (a line finished at the next moment), keeps the nodes of the lines that did not change, and shows output that holds markup as text, never as elements; fails_when=a finished line keeps its earlier text, an unchanged line's node is replaced (the learner's selection and focus there are lost), or markup in recorded output becomes an element; why_new=PR-B1's update path builds the line nodes in the browser, which the escaping test of the first document does not cover; seam=none; no_claim=the document's Content Security Policy, which the browser enforces (stand-ins: the page's message event and a stub of the webview API) + cases.push({ + name: 'an in-place update replaces a line whose text changed, keeps the nodes of the other lines and shows output that holds markup as text (Design 8, EN3, S3-1)', + actual: finishedLine, + passed: + JSON.stringify(finishedLine) === + JSON.stringify({ + unchangedLineKeepsItsNode: true, + finishedText: 'job 1 scheduled', + unfinishedNodeLeft: true, + markupText: markup, + markupMark: '[stderr] ', + elementsInOutput: 0, + scriptRan: false, + }), + }) await publish({ ...replay, recording: { @@ -1807,6 +3191,15 @@ async function check(page, options, collected) { diagram.links === 2 && diagram.summary.includes('', number: 0 }, + { channel: 'stderr', text: '">x', number: 1 }, + ], + end: 'Test passed · exit code 0', +} + +// Value: protects=test output and test titles that hold markup render as text in the Application pane, in its body, its title and its attributes, so a repository's test cannot run script or add elements there; fails_when=a `` + const content = failedRecording + ? `

${STUDY_UI_OPTIONS.playbackFailureRecovery}

Recording error details

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

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

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

` + return `

${escapeHtml(label)}

${content}

Historical observation. Reading it does not resume execution.

` } diff --git a/extension/src/utils/cutTextToTail.ts b/extension/src/utils/cutTextToTail.ts new file mode 100644 index 000000000..ad5d3e040 --- /dev/null +++ b/extension/src/utils/cutTextToTail.ts @@ -0,0 +1,20 @@ +/** + * Keeps the end of a text, so a reader of recorded output sees what came last before the selected moment; the counterpart of {@link cutTextToFit}, which keeps a text's beginning. Called by {@link accumulateNodeTestOutput} for the first line it keeps when that line does not fit whole. + * The text is cut between characters (code points), never inside a surrogate pair, and only its end is read. + * @param text - The text to cut. + * @param keep - How many code points of its end to keep; zero keeps nothing. + * @returns + * - `text` itself when it has at most `keep` code points + * - Otherwise its last `keep` code points + * @example + * cutTextToTail('stdout line', 4) // => 'line' + * cutTextToTail('ok', 10) // => 'ok' + */ +export function cutTextToTail(text: string, keep: number): string { + if (keep <= 0) return '' + // Only the end is read: a code point is at most two UTF-16 units, so the last 2 × keep units hold the code points kept, and a long text costs its tail, not its length. + const end = text.length > keep * 2 ? text.slice(-keep * 2) : text + const characters = Array.from(end) + if (end === text && characters.length <= keep) return text + return characters.slice(-keep).join('') +} diff --git a/extension/src/utils/describeNodeTestReplay.ts b/extension/src/utils/describeNodeTestReplay.ts index da5e9fb10..6c5808860 100644 --- a/extension/src/utils/describeNodeTestReplay.ts +++ b/extension/src/utils/describeNodeTestReplay.ts @@ -1,6 +1,48 @@ +import { MILLISECONDS_PER_SECOND } from '../constants' import type { NodeTestHistorySummary } from '../nodeTestHistoryStore' -/** What a test replay shows the learner outside its Save, keyed by surface (the String catalog of plan version 7). */ +/** + * The fixed words of a test replay's surfaces, the String catalog of plan version 7 (DS18); the Study pane receives them through {@link STUDY_UI_OPTIONS}, as its client code imports nothing, and the Application pane through {@link projectNodeTestApplication} and {@link testOutputHtml}. + * Words that hold a value are worded by {@link describeNodeTestReplay}. + */ +export const NODE_TEST_REPLAY_TEXT = { + /** Starts a line the test wrote to standard error; a color never says it alone. */ + stderrMark: '[stderr] ', + loading: 'Loading recorded test output…', + nothingYet: 'No test output recorded up to this moment.', + noEntries: 'No test output recorded.', + endedEarly: 'Recording ended before the test result.', + jumpToLatest: 'Jump to latest', + applicationFooter: + 'Historical test output. Reading it does not rerun the target.', + heading: 'Test replay', + noStepForStop: 'No saved explanation for this stop.', + noStepForResult: 'No saved explanation for the result.', + notRecorded: 'Source and debugger not recorded at this moment.', + unverifiedStop: 'Recorded source unavailable for this stop.', + outputPointer: 'Output up to this moment is in the Application pane.', + applicationClosed: 'The Application pane is closed.', + showRecordedOutput: 'Show recorded output', + noMaterial: 'No learning summary was saved for this test replay.', + sameMoment: 'Same recorded moment', + previousStop: 'Previous stop', + nextStop: 'Next stop', + previousEvent: 'Previous event', + nextEvent: 'Next event', + play: 'Play', + pause: 'Pause', + stepDetails: 'Step details', + noSelection: 'Select an event to move through the recorded test history.', + chooseReplay: + 'Choose a test replay from Saved studies to step through its output and debugger stops.', + noReplay: 'No test replay has been saved in this workspace.', + noLinkedEvent: 'No recorded event is linked to this step.', + eventOutput: 'Output', + eventStop: 'Stop', + eventResult: 'Result', +} as const + +/** What a test replay shows the learner outside its Save, keyed by surface and value (the String catalog of plan version 7). */ export type NodeTestReplayMessage = | { kind: 'saved-row' @@ -13,9 +55,48 @@ export type NodeTestReplayMessage = | 'explainedStopCount' > } - | { kind: 'application-interim' } + | { + kind: 'study-status' + summary: Omit + } + | { kind: 'application-time'; elapsedMs: number } + | { kind: 'application-test'; test: NodeTestReplayTestName } + | { + kind: 'application-name' + test: NodeTestReplayTestName + elapsedMs?: number + } + | { kind: 'application-region'; elapsedMs: number } + | { kind: 'application-cut'; characters: number; startsMidLine: boolean } + | { kind: 'application-inspector'; lines: number } + | { + kind: 'position' + event: + | { type: 'stop'; index: number; stopCount: number } + | { type: 'output' } + | { type: 'result'; verdict: string } + elapsedMs: number + } + | { + kind: 'step-action' + event: { type: 'stop'; index: number } | { type: 'output' | 'result' } + elapsedMs: number + } + | { kind: 'events'; count: number } -/** The capture word of a sealed or interrupted history in its Saved studies row. */ +/** The parts of a history's summary its Study status line names. */ +type NodeTestReplayMessageSummary = Pick< + NodeTestHistorySummary, + 'status' | 'verification' | 'verdict' | 'stopCount' | 'explainedStopCount' +> + +/** The cited test: its file and, in a history written since `testTitles`, the title path; a retired history named its test otherwise and shows its file alone. */ +export type NodeTestReplayTestName = { + testFile: string + testTitles?: readonly string[] +} + +/** The capture word of a sealed or interrupted history. */ const CAPTURE_WORDS = { complete: 'complete', partial: 'partial', @@ -23,19 +104,116 @@ const CAPTURE_WORDS = { } as const /** - * Words what a test replay shows the learner outside its Save, so Saved studies and the Application pane read as the String catalog writes them; called by the Study workspace for each Saved studies row and by {@link capturedAppHtml} for a selected test replay. - * A history this window still writes reads `recording`; one that fails verification reads `unavailable` beside its reason; a sealed or interrupted one names its verdict, its capture and how many of its stops the learning material explains. - * @param message - The surface and what it shows. + * Words what a test replay shows the learner outside its Save, so Saved studies, the Study pane and the Application pane read as the String catalog writes them; called by the Study workspace for each Saved studies row, by {@link projectNodeTestSection} for the Study pane's test replay section, and by {@link projectNodeTestApplication} for the Application pane. + * `` is the event's time since the run started, paused time included, in seconds with two decimals. + * @param message - The surface, and the value it shows. * @returns The words. * @example * describeNodeTestReplay({ kind: 'saved-row', summary: { status: 'complete', verification: 'verified', verdict: 'Test passed · exit code 0', stopCount: 2, explainedStopCount: 1 } }) * // => 'Test replay · Test passed · exit code 0 · capture complete · 1 of 2 stops explained' + * describeNodeTestReplay({ kind: 'position', event: { type: 'stop', index: 0, stopCount: 2 }, elapsedMs: 420 }) + * // => 'Stop 1 of 2 · 0.42 s' */ export function describeNodeTestReplay(message: NodeTestReplayMessage): string { - if (message.kind === 'application-interim') - return 'Recorded test output for this test replay is in the Study pane.' - const { summary } = message - if (summary.verification === 'unavailable') return 'Test replay · unavailable' - if (summary.status === 'recording') return 'Test replay · recording' - return `Test replay · ${summary.verdict} · capture ${CAPTURE_WORDS[summary.status]} · ${summary.explainedStopCount} of ${summary.stopCount} stops explained` + switch (message.kind) { + case 'saved-row': + return message.summary.verification === 'unavailable' + ? 'Test replay · unavailable' + : message.summary.status === 'recording' + ? 'Test replay · recording' + : `Test replay · ${studyStatus({ ...message.summary, status: message.summary.status })}` + case 'study-status': + return message.summary.status === 'recording' + ? 'Test replay · recording' + : studyStatus({ ...message.summary, status: message.summary.status }) + case 'position': + return positionWords(message.event, message.elapsedMs) + case 'step-action': + return message.event.type === 'stop' + ? `Inspect stop ${message.event.index + 1} · ${seconds(message.elapsedMs)} s` + : `Inspect ${message.event.type} · ${seconds(message.elapsedMs)} s` + case 'events': + return `Events (${message.count})` + default: + return applicationWords(message) + } +} + +/** + * Words the Application pane's lines of a test replay; called by {@link describeNodeTestReplay}. + * @param message - An Application pane surface and its value. + * @returns The words. + * @example applicationWords({ kind: 'application-time', elapsedMs: 420 }) // => 'Recorded test output · 0.42 s' + */ +function applicationWords( + message: Extract, +): string { + switch (message.kind) { + case 'application-time': + return `Recorded test output · ${seconds(message.elapsedMs)} s` + case 'application-test': + return testName(message.test) + case 'application-name': + return message.elapsedMs === undefined + ? `Recorded test output · ${testName(message.test)}` + : `Recorded test output · ${testName(message.test)} · ${seconds(message.elapsedMs)} s` + case 'application-region': + return `Recorded test output up to ${seconds(message.elapsedMs)} s` + case 'application-cut': + return `… ${message.characters} earlier characters not shown${message.startsMidLine ? '; the first line below is cut' : ''}` + case 'application-inspector': + return `${message.lines} Node inspector lines hidden` + } +} + +/** + * Words the Study section's locator of the selected event; called by {@link describeNodeTestReplay}. + * @param event - The selected event: a stop with its number and the replay's stop count, an output, or the result with its verdict. + * @param elapsedMs - The event's time. + * @returns `Stop of · s`, `Output · s` or `Result · · s`. + * @example positionWords({ type: 'stop', index: 0, stopCount: 2 }, 420) // => 'Stop 1 of 2 · 0.42 s' + */ +function positionWords( + event: Extract['event'], + elapsedMs: number, +): string { + if (event.type === 'stop') + return `Stop ${event.index + 1} of ${event.stopCount} · ${seconds(elapsedMs)} s` + return event.type === 'result' + ? `Result · ${event.verdict} · ${seconds(elapsedMs)} s` + : `Output · ${seconds(elapsedMs)} s` +} + +/** + * Words a sealed or interrupted history's verdict, capture and explained stops, the Saved studies row after `Test replay · ` and the Study pane's status line; called by {@link describeNodeTestReplay}. + * @param summary - The summary of a history that is no longer recording. + * @returns The status words. + * @example studyStatus({ status: 'partial', verdict: 'Test failed · exit code 1', stopCount: 1, explainedStopCount: 0 }) // => 'Test failed · exit code 1 · capture partial · 0 of 1 stops explained' + */ +function studyStatus( + summary: Omit & { + status: keyof typeof CAPTURE_WORDS + }, +): string { + return `${summary.verdict} · capture ${CAPTURE_WORDS[summary.status]} · ${summary.explainedStopCount} of ${summary.stopCount} stops explained` +} + +/** + * Names the cited test as the Application pane's second header line does: the file, then each title of its path after ` › `. + * @param test - The cited test. + * @returns ` › › …`, or the file alone without titles. + * @example testName({ testFile: 'src/jobs.test.ts', testTitles: ['job scheduler', 'runs a delayed job'] }) // => 'src/jobs.test.ts › job scheduler › runs a delayed job' + */ +function testName(test: NodeTestReplayTestName): string { + return [test.testFile, ...(test.testTitles ?? [])].join(' › ') +} + +/** + * Writes an event's time as the String catalog's `<t>`: seconds since the run started, with two decimals. + * @param elapsedMs - The event's elapsed milliseconds. + * @returns The seconds. + * @example seconds(1234) // => '1.23' + */ +function seconds(elapsedMs: number): string { + return (elapsedMs / MILLISECONDS_PER_SECOND).toFixed(2) } diff --git a/extension/src/utils/describeNodeTestResult.ts b/extension/src/utils/describeNodeTestResult.ts index dd01aa4d0..b0cdc1cf6 100644 --- a/extension/src/utils/describeNodeTestResult.ts +++ b/extension/src/utils/describeNodeTestResult.ts @@ -11,7 +11,7 @@ export type NodeTestVerdict = | 'No result recorded' /** - * Words a test run's result for the learner, so the status item's tooltips, the partial-save notification, Saved studies rows and the `HISTORY_SEAL_FAILED` message say the same thing about one result; called by {@link describeNodeLearningSave}, {@link NodeLearningSave} and {@link StudyService}. + * Words a test run's result for the learner, so the status item's tooltips, the partial-save notification, Saved studies rows, the `HISTORY_SEAL_FAILED` message, the Application pane's last line at the result and the Study section's status and Result locator say the same thing about one result; called by {@link describeNodeLearningSave}, {@link NodeLearningSave}, {@link StudyService}, {@link projectNodeTestApplication} and {@link projectNodeTestSection}. * @param result - The result's process facts; `undefined` when the run recorded no result. * @returns * - `'Test passed · exit code 0'` for a passing exit diff --git a/extension/src/utils/escapeHtml.ts b/extension/src/utils/escapeHtml.ts index 1bf4b2c35..4faa75e08 100644 --- a/extension/src/utils/escapeHtml.ts +++ b/extension/src/utils/escapeHtml.ts @@ -8,7 +8,7 @@ const HTML_ENTITIES: Readonly<Record<string, string>> = { } /** - * Makes a text safe to print into HTML, the way the Wiki publisher escapes what it prints; called by {@link rebindDeepWikiEditorLinks} when it prints a source path into a served page, by {@link withServedAddresses} when it writes a Codebook address anew, by {@link servedWikiPageHtml} for the reader's script tag, and by {@link capturedAppHtml} for every text of the captured application page. + * Makes a text safe to print into HTML, the way the Wiki publisher escapes what it prints; called by {@link rebindDeepWikiEditorLinks} when it prints a source path into a served page, by {@link withServedAddresses} when it writes a Codebook address anew, by {@link servedWikiPageHtml} for the reader's script tag, and by {@link capturedAppHtml} and {@link testOutputHtml} for every text of the Application pane's page. * @param text - The plain text. * @returns The text with `&`, `<`, `>`, `"` and `'` replaced by their entities; any other character unchanged. * @example diff --git a/extension/src/utils/indexNodeTestOutput.ts b/extension/src/utils/indexNodeTestOutput.ts new file mode 100644 index 000000000..cbdae04e0 --- /dev/null +++ b/extension/src/utils/indexNodeTestOutput.ts @@ -0,0 +1,141 @@ +import type { NodeTestHistoryIndex } from '../nodeTestHistorySchemas' + +import { nodeTestOutputLineCost } from './nodeTestOutputLineCost' +import { stripTerminalControls } from './stripTerminalControls' + +/** One line of a test's recorded output, as the Application pane shows it: the channel that wrote it and its text without a line end. */ +export type NodeTestOutputLine = { + channel: 'stdout' | 'stderr' + text: string +} + +/** + * 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. + */ +export type NodeTestOutputPendingLine = { + channel: NodeTestOutputLine['channel'] + chunks: readonly string[] + chunkCount: number + codePoints: number +} + +/** What one output entry leaves behind once it is read: the complete lines so far, the line it left unfinished and the Node inspector lines so far. */ +export type NodeTestOutputAfterEntry = { + lineCount: number + pending?: NodeTestOutputPendingLine + inspectorLines: number +} + +/** + * A test replay's recorded output read once into lines, so that showing the output up to any event costs a lookup and its tail ({@link accumulateNodeTestOutput}). + * `lineTotals[i]` is how many code points `lines[0..i)` take as shown (the `[stderr] ` mark and a line feed included); it has one more element than `lines`. + */ +export type NodeTestOutputIndex = { + sequences: number[] + lines: NodeTestOutputLine[] + lineTotals: number[] + after: NodeTestOutputAfterEntry[] +} + +/** The Node inspector's own lines on standard error; the pane folds them into one muted line with their count instead of dropping them silently. */ +const INSPECTOR_LINES = [ + /^Debugger listening on \S+$/, + // Node's help address changed (Node 24: https://nodejs.org/learn/getting-started/debugging), so any nodejs.org address counts. + /^For help, see: https:\/\/nodejs\.org\/\S+$/, + /^Debugger ending on \S+$/, + /^Debugger attached\.$/, + /^Waiting for the debugger to disconnect\.\.\.$/, +] + +/** + * Reads a test replay's output entries once into lines, so the Application pane can show the output up to any selected event without reading every entry again (S1-2); called by the Study workspace once per opened test replay. + * A line-state machine joins the entries: a chunk of the same channel continues an unfinished line, a chunk of the other channel ends it first, terminal controls are removed ({@link stripTerminalControls}) and the Node inspector's own standard-error lines are counted instead of shown. + * @param entries - The history's events in sequence order; only output entries are read. + * @returns The index: the output entries' sequences, the complete lines, their running totals and what each output entry leaves behind. + * @example + * indexNodeTestOutput([{ type: 'output', sequence: 1, elapsedMs: 5, channel: 'stdout', text: 'RUN\nok' }]).after[0] + * // => { lineCount: 1, pending: { channel: 'stdout', chunks: ['ok'], chunkCount: 1, codePoints: 2 }, inspectorLines: 0 } + */ +export function indexNodeTestOutput( + entries: readonly NodeTestHistoryIndex['entries'][number][], +): NodeTestOutputIndex { + const index: NodeTestOutputIndex = { + sequences: [], + lines: [], + 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, + inspectorLines = 0 + const complete = (line: NodeTestOutputLine): void => { + if ( + line.channel === 'stderr' && + INSPECTOR_LINES.some((pattern) => pattern.test(line.text)) + ) { + inspectorLines++ + return + } + index.lines.push(line) + index.lineTotals.push( + index.lineTotals.at(-1)! + nodeTestOutputLineCost(line, true), + ) + } + /** Ends the unfinished line: its chunks are joined once, when the line is complete. */ + const completePending = (): void => { + if (pending) + complete({ channel: pending.channel, text: pending.chunks.join('') }) + pending = undefined + } + /** Adds a part to the unfinished line of a channel, starting the line when there is none. */ + const continuePending = ( + channel: NodeTestOutputLine['channel'], + part: string, + ): void => { + pending ??= { channel, chunks: [], 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 + } + /** Joins one output chunk to the lines: a chunk of the other channel ends the unfinished line first, so no line mixes the two channels; the first part continues the unfinished line and each later part starts a new one after a line feed. */ + const read = (channel: NodeTestOutputLine['channel'], text: string): void => { + if (pending && pending.channel !== channel) completePending() + const parts = text.split('\n') + continuePending(channel, parts[0]!) + for (const part of parts.slice(1)) { + completePending() + continuePending(channel, part) + } + if (pending && !pending.chunks.length) pending = undefined + } + for (const entry of entries) { + if (entry.type !== 'output') continue + const text = stripTerminalControls(entry.text) + // A chunk that held only controls changes no line, not even the channel of an unfinished one. + if (text) read(entry.channel, text) + index.sequences.push(entry.sequence) + index.after.push({ + lineCount: index.lines.length, + ...(pending + ? { + pending: { + channel: pending.channel, + chunks: pending.chunks, + chunkCount: pending.chunks.length, + codePoints: pending.codePoints, + }, + } + : {}), + inspectorLines, + }) + } + return index +} diff --git a/extension/src/utils/installNodeTestOutput.ts b/extension/src/utils/installNodeTestOutput.ts new file mode 100644 index 000000000..30e5a245f --- /dev/null +++ b/extension/src/utils/installNodeTestOutput.ts @@ -0,0 +1,248 @@ +import type { NodeTestApplicationView } from './projectNodeTestApplication' + +/** 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). */ + 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 +} + +/** The message the Study workspace posts to the Application pane for each later event of the same test replay. */ +export type NodeTestOutputUpdate = { + type: 'node-history-output' + generation: number + view: NodeTestApplicationView +} + +/** + * Applies each later event of a test replay to the Application pane's one document in place (EN3), so moving through the replay never reloads the page: unchanged lines keep their nodes, so the learner's text selection, the scroll position and the keyboard focus survive, and an update older than the one shown is ignored. It follows the latest output until the learner scrolls up, and `Jump to latest` follows again (DS20); called by the document {@link 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. + * @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 }) + */ +export function installNodeTestOutput( + api: { + getState(): unknown + setState(state: unknown): void + }, + generation: number, + options: NodeTestOutputOptions, +): void { + const element = (id: string): HTMLElement => { + const found = document.getElementById(id) + if (!found) throw new Error(`Test output element ${id} is missing.`) + return found + } + const output = element('test-output'), + jump = element('test-output-jump') + /** The webview's state as an object, or none. */ + const savedState = (): Record<string, unknown> | 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 + 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, + } + } + const kept = keptState() + let shown = generation, + follow = kept.follow, + scrolledByDocument = false, + latestView: NodeTestApplicationView | null = null, + writeTimer: ReturnType<typeof setTimeout> | 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, + }) + } + /** 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 + 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. + window.addEventListener('pagehide', () => { + if (writeDue) writeState() + }) + /** Shows a muted line with its text, or leaves it out without text. */ + const line = (id: string, value: string | undefined): void => { + const target = element(id) + target.textContent = value ?? '' + target.hidden = value === undefined + } + /** Makes the node of one output line: the `[stderr] ` mark for a standard-error line that is not cut, its text and a line feed, all as text. */ + const lineNode = ( + view: NodeTestApplicationView['lines'][number], + ): HTMLSpanElement => { + const node = document.createElement('span') + node.className = 'output-line' + node.dataset.line = String(view.number) + node.dataset.channel = view.channel + if (view.cut) node.dataset.cut = 'true' + if (view.channel === 'stderr' && !view.cut) { + const mark = document.createElement('span') + mark.className = 'stderr-mark' + mark.textContent = options.stderrMark + node.append(mark) + } + const body = document.createElement('span') + body.className = 'output-text' + body.textContent = view.text + node.append(body, '\n') + return node + } + const atEnd = (): boolean => + output.scrollHeight - output.scrollTop - output.clientHeight <= + options.endTolerancePx + const toEnd = (): void => { + scrolledByDocument = true + // Instant, never smooth: the learner's reduced-motion choice needs no separate path. + output.scrollTop = output.scrollHeight + } + /** Removes the lines a view no longer holds and lists the kept ones by their number, so each line is found without searching the whole output. */ + const keptLines = ( + lines: NodeTestApplicationView['lines'], + ): Map<number, HTMLElement> => { + const wanted = new Set(lines.map((value) => value.number)), + kept = new Map<number, HTMLElement>() + 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 + } + /** 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 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 + const placed = lineNode(value) + if (node) node.replaceWith(placed) + else if (previous) previous.after(placed) + else output.prepend(placed) + return placed + } + /** Brings the output region's lines to a view's in place, in time linear in the lines. */ + const placeLines = (lines: NodeTestApplicationView['lines']): void => { + const kept = keptLines(lines) + let previous: Element | undefined + for (const value of lines) + previous = placeLine(value, kept.get(value.number), previous) + } + const apply = (view: NodeTestApplicationView): void => { + document.title = view.name + element('test-output-main').setAttribute('aria-label', view.name) + element('test-output-time').textContent = view.time ?? '' + const test = element('test-output-test') + test.textContent = view.test + test.title = view.test + output.setAttribute('aria-label', view.region ?? view.name) + line('test-output-inspector', view.inspector) + line('test-output-cut', view.cut) + line('test-output-placeholder', view.placeholder) + line('test-output-end', view.end) + 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() + jump.hidden = follow + } + output.addEventListener('scroll', () => { + // The document's own scroll to the end is no choice of the learner's. + if (scrolledByDocument) { + scrolledByDocument = false + return + } + const following = atEnd() + if (following === follow) return + follow = following + jump.hidden = follow + // The learner's own choice is written at once; only the stream of moments is joined. + writeState() + }) + jump.addEventListener('click', () => { + follow = true + writeState() + toEnd() + jump.hidden = true + // The button leaves the page, so the keyboard stays in the output it led to. + output.focus() + }) + window.addEventListener( + 'message', + (event: MessageEvent<Partial<NodeTestOutputUpdate> | null>) => { + const data = event.data + if ( + data?.type !== 'node-history-output' || + typeof data.generation !== 'number' || + !data.view + ) + return + // An update older than the one shown came late: the newer selection stands. + if (data.generation < shown) return + shown = data.generation + apply(data.view) + latestView = data.view + remember() + }, + ) + // 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 +} diff --git a/extension/src/utils/nextNodeHistoryStepId.ts b/extension/src/utils/nextNodeHistoryStepId.ts new file mode 100644 index 000000000..a780940e8 --- /dev/null +++ b/extension/src/utils/nextNodeHistoryStepId.ts @@ -0,0 +1,20 @@ +/** + * Decides which learning step a test replay shows once an event selection commits (DS21): the step a summary step's action chose, the event's first step for Next and Previous, and, for a selection that made no choice (a seek, Play, Inspect), the learner's chosen step only while the selection stays on its moment. Called by the Study workspace when it commits a test replay's selected event. + * @param choice - The selection's own step choice, absent when it made none; `stepId` is `undefined` for the event's first step. + * @param current - The event selected before and the step chosen on it. + * @param sequence - The event the selection commits. + * @returns The chosen step's id, or `undefined` for the event's first step. + * @example + * nextNodeHistoryStepId({ stepId: 'keep' }, { sequence: 4 }, 2) // => 'keep' + * nextNodeHistoryStepId(undefined, { sequence: 2, stepId: 'keep' }, 2) // => 'keep' + * nextNodeHistoryStepId(undefined, { sequence: 2, stepId: 'keep' }, 4) // => undefined + */ +export function nextNodeHistoryStepId( + choice: { stepId: string | undefined } | undefined, + current: { sequence: number | undefined; stepId: string | undefined }, + sequence: number, +): string | undefined { + if (choice) return choice.stepId + // A chosen step stays only with its own moment. + return sequence === current.sequence ? current.stepId : undefined +} diff --git a/extension/src/utils/nodeHistoryStepTarget.ts b/extension/src/utils/nodeHistoryStepTarget.ts new file mode 100644 index 000000000..254aac952 --- /dev/null +++ b/extension/src/utils/nodeHistoryStepTarget.ts @@ -0,0 +1,29 @@ +import type { NodeTestHistoryIndex } from '../nodeTestHistorySchemas' + +/** + * Finds the event a test replay's Previous or Next moves to, by event or by stop (DS12: Previous stop and Next stop are the transport's primary buttons, Previous event and Next event its secondary ones); called by the Study workspace when the Study pane sends `node-history-step`. + * Without a selected event, Next moves to the first candidate and Previous to the last. + * @param entries - The history's events in sequence order. + * @param current - The selected event's sequence, if any. + * @param direction - Which way to move. + * @param unit - `event` moves to any event, `stop` only to stops. + * @returns The sequence of the event to select, or `undefined` when there is none that way. + * @example + * nodeHistoryStepTarget(history.entries, 4, 'next', 'stop') // => 7, the next stop after event 4 + */ +export function nodeHistoryStepTarget( + entries: readonly NodeTestHistoryIndex['entries'][number][], + current: number | undefined, + direction: 'previous' | 'next', + unit: 'event' | 'stop' = 'event', +): number | undefined { + const candidates = + unit === 'stop' ? entries.filter((entry) => entry.type === 'stop') : entries + if (current === undefined) + return (direction === 'next' ? candidates[0] : candidates.at(-1))?.sequence + return ( + direction === 'next' + ? candidates.find((entry) => entry.sequence > current) + : candidates.findLast((entry) => entry.sequence < current) + )?.sequence +} diff --git a/extension/src/utils/nodeStopSourceMatches.ts b/extension/src/utils/nodeStopSourceMatches.ts index de81417e9..91070263f 100644 --- a/extension/src/utils/nodeStopSourceMatches.ts +++ b/extension/src/utils/nodeStopSourceMatches.ts @@ -11,8 +11,8 @@ const verdicts = new WeakMap<NodeTestHistoryStop, boolean>() * Tells whether a saved Node stop's captured source is verified and matches where it stopped, independently of whether the native session shows it. * * {@link StudyWorkspace.selectNodeHistoryEvent} requires it before presenting the stop natively, and the reader payload uses - * it so "captured source unavailable" appears only for a real mismatch, never because the session ended or another - * recording took the debugger. + * it so `Recorded source unavailable for this stop.` appears only for a real mismatch, never because the session ended + * or another recording took the debugger. * * @param entry - The saved stop from the Node test journal. * @returns diff --git a/extension/src/utils/nodeTestOutputLineCost.ts b/extension/src/utils/nodeTestOutputLineCost.ts new file mode 100644 index 000000000..7d6f8998c --- /dev/null +++ b/extension/src/utils/nodeTestOutputLineCost.ts @@ -0,0 +1,20 @@ +import { NODE_TEST_REPLAY_TEXT } from './describeNodeTestReplay' +import type { NodeTestOutputLine } from './indexNodeTestOutput' + +/** + * How many code points a line takes as the Application pane shows it; called by {@link indexNodeTestOutput} and {@link accumulateNodeTestOutput}. + * @param line - The line. + * @param lineFeed - Whether a line feed ends it (every line but an unfinished last one). + * @returns Its text's code points, plus the `[stderr] ` mark for a standard-error line and one for the line feed. + * @example nodeTestOutputLineCost({ channel: 'stderr', text: 'warn' }, true) // => 14 + */ +export function nodeTestOutputLineCost( + line: NodeTestOutputLine, + lineFeed: boolean, +): number { + return ( + Array.from(line.text).length + + (line.channel === 'stderr' ? NODE_TEST_REPLAY_TEXT.stderrMark.length : 0) + + (lineFeed ? 1 : 0) + ) +} diff --git a/extension/src/utils/projectNodeLearningSummary.ts b/extension/src/utils/projectNodeLearningSummary.ts new file mode 100644 index 000000000..848e5f484 --- /dev/null +++ b/extension/src/utils/projectNodeLearningSummary.ts @@ -0,0 +1,48 @@ +import type { + NodeTestHistoryIndex, + NodeTestLearningMaterial, +} from '../nodeTestHistorySchemas' + +/** Where a step of a test replay's learning material leads: the event its reference names, by sequence, with that event's time. */ +export type NodeLearningStepPosition = { + stepId: string + elapsedMs: number + sequence: number +} + +/** A test replay's learning material as the Study pane's summary renderer takes it, the browser summary's shape, with each referenced step's event. */ +export type NodeLearningSummaryProjection = { + content: NodeTestLearningMaterial['content'] + positions: NodeLearningStepPosition[] +} + +/** + * Projects a test replay's saved learning material for the one summary renderer the browser replay also uses (Design 5), so each step can select the event it references; called by the Study workspace for each Study message while a test replay with material is selected. + * A position names its event by sequence, so a step finds its own event even when several events share a time. + * @param material - The saved learning material, absent when none was saved. + * @param history - The selected history, all its entries. + * @returns The material's content and, for each step whose reference names an event of the history, that event's sequence and time; `undefined` without material. + * @example + * projectNodeLearningSummary(material, history)?.positions // => [{ stepId: 'stop-1', elapsedMs: 420, sequence: 3 }] + */ +export function projectNodeLearningSummary( + material: NodeTestLearningMaterial | undefined, + history: Pick<NodeTestHistoryIndex, 'entries'>, +): NodeLearningSummaryProjection | undefined { + if (!material) return + const events = new Map( + history.entries.map((entry) => [entry.sequence, entry] as const), + ) + const positions: NodeLearningStepPosition[] = [] + for (const step of material.content.steps) { + const event = step.reference && events.get(step.reference.sequence) + // A step without a reference, or one whose event this history lacks, leads nowhere and gets no position. + if (event) + positions.push({ + stepId: step.id, + elapsedMs: event.elapsedMs, + sequence: event.sequence, + }) + } + return { content: material.content, positions } +} diff --git a/extension/src/utils/projectNodeTestApplication.ts b/extension/src/utils/projectNodeTestApplication.ts new file mode 100644 index 000000000..48adc7fe8 --- /dev/null +++ b/extension/src/utils/projectNodeTestApplication.ts @@ -0,0 +1,158 @@ +import type { NodeTestHistoryIndex } from '../nodeTestHistorySchemas' + +import { + accumulateNodeTestOutput, + type NodeTestOutputTail, + type NodeTestOutputViewLine, +} from './accumulateNodeTestOutput' +import { + describeNodeTestReplay, + NODE_TEST_REPLAY_TEXT, +} from './describeNodeTestReplay' +import { describeNodeTestResult } from './describeNodeTestResult' +import type { NodeTestOutputIndex } from './indexNodeTestOutput' + +/** + * What the Application pane shows of a test replay at one moment (EN14's narrow view model): sent once with the document and then, for each later event, as an in-place update that a newer `generation` supersedes. + * Every field is text; the document renders each as text, never as markup. + */ +export type NodeTestApplicationView = { + generation: number + /** Header line 1 (`Recorded test output · <t> s`), absent without a selected event. */ + time?: string + /** Header line 2: the cited test, shown in at most two lines with this whole text in its tooltip. */ + test: string + /** The pane's accessible name. */ + name: string + /** The output region's accessible name, `Recorded test output up to <t> s`. */ + region?: string + /** The kept end of the output up to the event ({@link accumulateNodeTestOutput}). */ + lines: NodeTestOutputViewLine[] + /** One muted line for each part left out: the cut and the Node inspector lines. */ + cut?: string + inspector?: string + /** The line that stands for the output when there is none to show: loading, nothing yet or no entries. */ + placeholder?: string + /** The last line: the verdict at the result, or the ended-early line at a last event that is no result. */ + end?: string +} + +/** The event the learner selected in a test replay. */ +type SelectedEvent = NodeTestHistoryIndex['entries'][number] + +/** + * Projects what the Application pane shows of a test replay at the selected moment, from every entry of the history (not the Study pane's page) and its output read once; called by the Study workspace for each Study message while a test replay is selected. + * @param history - The selected history, all its entries, and its status: a history still recording has no early end yet. + * @param index - Its output read once ({@link indexNodeTestOutput}). + * @param selected - The selected event, absent before one is selected. + * @param generation - The Application view's generation, which changes only when the shown moment (the history, its entry count, the selected event or the opening state) does, so a document ignores an update older than the one it shows. + * @param loading - Whether the replay is still opening. + * @returns The view. + * @example + * projectNodeTestApplication(history, indexNodeTestOutput(history.entries), history.entries[0], 1, false).time // => 'Recorded test output · 0.12 s' + */ +export function projectNodeTestApplication( + history: Pick<NodeTestHistoryIndex, 'header' | 'entries' | 'status'>, + index: NodeTestOutputIndex, + selected: SelectedEvent | undefined, + generation: number, + loading: boolean, +): NodeTestApplicationView { + const configuration = history.header.configuration + const test = { + testFile: configuration.testFile, + ...(configuration.testTitles + ? { testTitles: configuration.testTitles } + : {}), + } + const testLine = describeNodeTestReplay({ kind: 'application-test', test }) + // A history without entries has no time to name; neither has one still opening or one without a selected event. + if (loading || !history.entries.length || !selected) + return { + generation, + test: testLine, + name: describeNodeTestReplay({ kind: 'application-name', test }), + lines: [], + placeholder: loading + ? NODE_TEST_REPLAY_TEXT.loading + : history.entries.length + ? NODE_TEST_REPLAY_TEXT.nothingYet + : NODE_TEST_REPLAY_TEXT.noEntries, + } + const tail = accumulateNodeTestOutput(index, selected.sequence) + const end = outputEnd(history, selected) + return { + generation, + time: describeNodeTestReplay({ + kind: 'application-time', + elapsedMs: selected.elapsedMs, + }), + test: testLine, + name: describeNodeTestReplay({ + kind: 'application-name', + test, + elapsedMs: selected.elapsedMs, + }), + region: describeNodeTestReplay({ + kind: 'application-region', + elapsedMs: selected.elapsedMs, + }), + lines: tail.lines, + ...leftOutLines(tail), + ...(tail.hasOutput + ? {} + : { placeholder: NODE_TEST_REPLAY_TEXT.nothingYet }), + ...(end === undefined ? {} : { end }), + } +} + +/** + * Words the muted lines for what the kept output leaves out: the earlier characters cut and the Node inspector lines folded away; called by {@link projectNodeTestApplication}. + * @param tail - The kept output at the selected event. + * @returns `cut` and `inspector`, each only when something was left out. + * @example leftOutLines({ omittedCharacters: 120, startsMidLine: false, inspectorLines: 0, … }) // => { cut: '… 120 earlier characters not shown' } + */ +function leftOutLines( + tail: NodeTestOutputTail, +): Pick<NodeTestApplicationView, 'cut' | 'inspector'> { + return { + ...(tail.omittedCharacters > 0 + ? { + cut: describeNodeTestReplay({ + kind: 'application-cut', + characters: tail.omittedCharacters, + startsMidLine: tail.startsMidLine, + }), + } + : {}), + ...(tail.inspectorLines > 0 + ? { + inspector: describeNodeTestReplay({ + kind: 'application-inspector', + lines: tail.inspectorLines, + }), + } + : {}), + } +} + +/** + * Gives the line that ends the shown output: the verdict at the result, the ended-early line at a last event that is no result (DS2, DS7); called by {@link projectNodeTestApplication}. + * A history this window is still recording has not ended: its last event so far gets no ended-early line. + * @param history - The history's events in sequence order and its status. + * @param selected - The selected event. + * @returns The line, or `undefined` at any other event. + * @example outputEnd(history, result) // => 'Test passed · exit code 0' + */ +function outputEnd( + history: Pick<NodeTestHistoryIndex, 'entries' | 'status'>, + selected: SelectedEvent, +): string | undefined { + if (selected.type === 'result') return describeNodeTestResult(selected) + const last = history.entries.at(-1) + return history.status !== 'recording' && + last?.sequence === selected.sequence && + last.type !== 'result' + ? NODE_TEST_REPLAY_TEXT.endedEarly + : undefined +} diff --git a/extension/src/utils/projectNodeTestSection.ts b/extension/src/utils/projectNodeTestSection.ts new file mode 100644 index 000000000..db698dd20 --- /dev/null +++ b/extension/src/utils/projectNodeTestSection.ts @@ -0,0 +1,254 @@ +import type { NodeTestHistoryIndex } from '../nodeTestHistorySchemas' + +import { + describeNodeTestReplay, + NODE_TEST_REPLAY_TEXT, +} from './describeNodeTestReplay' +import { + describeNodeTestResult, + type NodeTestVerdict, +} from './describeNodeTestResult' +import type { NodeLearningSummaryProjection } from './projectNodeLearningSummary' + +/** + * What the Study pane's test replay section shows beside the selected event (Designs 5 and 6, DS3, DS21): every line already worded, so the pane's client code only places them. + */ +export type NodeTestSectionView = { + status: string + /** The selected event's locator (`Stop <n> of <m> · <t> s`, `Output · <t> s`, `Result · <verdict> · <t> s`), absent before a selection. */ + position?: string + /** The selected step's explanation, or the line that no step explains the selected stop or result; absent for an output without a step. */ + explanation?: string + /** The 4A line at an output, the result and a stop whose source does not verify. */ + notRecorded?: string + /** The line for a selected stop whose recorded source does not verify. */ + unverified?: string + /** Where the output up to the selected moment is, or that its pane is closed. */ + pointer: string + applicationClosed: boolean + /** The step shown with the selected event: the one the learner chose, else the event's first. */ + selectedStepId?: string + /** The selected event's other steps, which read `Same recorded moment`. */ + sameMomentStepIds: string[] + /** Each step's action label by step id, for the steps whose reference names an event of the history. */ + stepActions: Record<string, string> + /** The collapsed event list's summary, `Events (<count>)`. */ + events: string + /** The history's verdict, which the result's row in the event list leads with. */ + verdict: string + /** Whether a stop lies before and after the selected event, for Previous stop and Next stop. */ + previousStop: boolean + nextStop: boolean +} + +/** The inputs of the section's view. */ +export type NodeTestSectionInput = { + history: Pick<NodeTestHistoryIndex, 'entries' | 'status'> + selected?: NodeTestHistoryIndex['entries'][number] + /** Whether the selected event is a stop whose recorded source verifies. */ + selectedStopVerified: boolean + learning?: NodeLearningSummaryProjection + /** The step committed with the selected event (DS21); one that names no step of the event falls back to the event's first step. */ + chosenStepId?: string + applicationClosed: boolean +} + +/** + * Words the Study pane's test replay section for the selected event: status, locator, the selected step and its explanation, the not-recorded lines and the steps that share the moment; called by the Study workspace for each Study message while a test replay is selected. + * The selected step is the one the learner chose when it references the selected event, else the event's first step (Next and Previous select the event's first step). + * The status line counts the stops a step of the material references as explained, as Saved studies does. + * @param input - The history, the selected event, its learning material and the learner's chosen step. + * @returns The section's view. + * @example + * projectNodeTestSection({ history, selected: stop, selectedStopVerified: true, learning, applicationClosed: false }).position // => 'Stop 1 of 2 · 0.42 s' + */ +export function projectNodeTestSection( + input: NodeTestSectionInput, +): NodeTestSectionView { + const { history, selected, learning } = input + const stops = history.entries.filter((entry) => entry.type === 'stop') + const verdict = historyVerdict(history) + const moment = momentSteps(selected, learning, input.chosenStepId) + const explanation = momentExplanation(selected, moment.selectedStep) + const unverifiedStop = + selected?.type === 'stop' && !input.selectedStopVerified + return { + status: sectionStatus(history, stops, verdict, learning), + ...(selected ? { position: positionLine(selected, stops.length) } : {}), + ...(explanation === undefined ? {} : { explanation }), + // The 4A line stands at an output, the result and a stop whose source does not verify. + ...(selected && (selected.type !== 'stop' || unverifiedStop) + ? { notRecorded: NODE_TEST_REPLAY_TEXT.notRecorded } + : {}), + ...(unverifiedStop + ? { unverified: NODE_TEST_REPLAY_TEXT.unverifiedStop } + : {}), + pointer: input.applicationClosed + ? NODE_TEST_REPLAY_TEXT.applicationClosed + : NODE_TEST_REPLAY_TEXT.outputPointer, + applicationClosed: input.applicationClosed, + ...(moment.selectedStepId ? { selectedStepId: moment.selectedStepId } : {}), + sameMomentStepIds: moment.sameMomentStepIds, + stepActions: stepActionLabels(history, learning), + events: describeNodeTestReplay({ + kind: 'events', + count: history.entries.length, + }), + verdict, + previousStop: stops.some( + (stop) => selected !== undefined && stop.sequence < selected.sequence, + ), + nextStop: stops.some( + (stop) => selected === undefined || stop.sequence > selected.sequence, + ), + } +} + +/** An event of a test replay. */ +type HistoryEvent = NodeTestHistoryIndex['entries'][number] + +/** + * Words the verdict of a test replay from its result entry; called by {@link projectNodeTestSection}. + * @param history - The test replay. + * @returns The verdict, `No result recorded` without a result. + * @example historyVerdict(history) // => 'Test passed · exit code 0' + */ +function historyVerdict( + history: Pick<NodeTestHistoryIndex, 'entries'>, +): NodeTestVerdict { + const result = history.entries.find((entry) => entry.type === 'result') + return describeNodeTestResult(result?.type === 'result' ? result : undefined) +} + +/** + * Words the section's status line, counting as explained the stops a step of the material references, as Saved studies does; called by {@link projectNodeTestSection}. + * @param history - The test replay. + * @param stops - Its stops. + * @param verdict - Its verdict. + * @param learning - Its projected material, if any. + * @returns `<verdict> · capture <…> · <k> of <m> stops explained`. + * @example sectionStatus(history, stops, 'Test passed · exit code 0', learning) // => 'Test passed · exit code 0 · capture complete · 1 of 2 stops explained' + */ +function sectionStatus( + history: Pick<NodeTestHistoryIndex, 'status'>, + stops: readonly HistoryEvent[], + verdict: NodeTestVerdict, + learning: NodeLearningSummaryProjection | undefined, +): string { + const referenced = new Set( + (learning?.positions ?? []).map((position) => position.sequence), + ) + return describeNodeTestReplay({ + kind: 'study-status', + summary: { + status: history.status, + verdict, + stopCount: stops.length, + explainedStopCount: stops.filter((stop) => referenced.has(stop.sequence)) + .length, + }, + }) +} + +/** + * Words the selected event's locator; called by {@link projectNodeTestSection}. + * @param selected - The selected event. + * @param stopCount - How many stops the replay holds. + * @returns `Stop <n> of <m> · <t> s`, `Output · <t> s` or `Result · <verdict> · <t> s`. + * @example positionLine(stop, 2) // => 'Stop 1 of 2 · 0.42 s' + */ +function positionLine(selected: HistoryEvent, stopCount: number): string { + return describeNodeTestReplay({ + kind: 'position', + event: + selected.type === 'stop' + ? { type: 'stop', index: selected.index, stopCount } + : selected.type === 'result' + ? { type: 'result', verdict: describeNodeTestResult(selected) } + : { type: 'output' }, + elapsedMs: selected.elapsedMs, + }) +} + +/** + * Finds the steps of the selected event: the one shown, the learner's choice when it references the event, else the event's first, and the others, which read `Same recorded moment` (DS21); called by {@link projectNodeTestSection}. + * @param selected - The selected event, if any. + * @param learning - The projected material, if any. + * @param chosenStepId - The step committed with the selected event. + * @returns The shown step's id and content, and the other steps' ids. + * @example momentSteps(stop, learning, 'keep').selectedStepId // => 'keep' + */ +function momentSteps( + selected: HistoryEvent | undefined, + learning: NodeLearningSummaryProjection | undefined, + chosenStepId: string | undefined, +): { + selectedStepId?: string + selectedStep?: NodeLearningSummaryProjection['content']['steps'][number] + sameMomentStepIds: string[] +} { + const stepIds = (learning?.positions ?? []) + .filter((position) => position.sequence === selected?.sequence) + .map((position) => position.stepId) + const selectedStepId = + chosenStepId && stepIds.includes(chosenStepId) ? chosenStepId : stepIds[0] + const selectedStep = learning?.content.steps.find( + (step) => step.id === selectedStepId, + ) + return { + ...(selectedStepId ? { selectedStepId } : {}), + ...(selectedStep ? { selectedStep } : {}), + sameMomentStepIds: stepIds.filter((stepId) => stepId !== selectedStepId), + } +} + +/** + * Gives the explanation shown under the locator: the shown step's, else the line that no step explains a selected stop or the result; an output without a step needs no line. Called by {@link projectNodeTestSection}. + * @param selected - The selected event, if any. + * @param step - The shown step, if any. + * @returns The explanation, or `undefined` for none. + * @example momentExplanation(stop, undefined) // => 'No saved explanation for this stop.' + */ +function momentExplanation( + selected: HistoryEvent | undefined, + step: { explanation: string } | undefined, +): string | undefined { + if (step) return step.explanation + if (selected?.type === 'stop') return NODE_TEST_REPLAY_TEXT.noStepForStop + return selected?.type === 'result' + ? NODE_TEST_REPLAY_TEXT.noStepForResult + : undefined +} + +/** + * Words each step's action by the event its reference names, for the steps whose event the history holds; called by {@link projectNodeTestSection}. + * The labels are own data properties, so a step id such as `__proto__` keeps its label through the message to the Study pane, where an assignment would hit the prototype's setter and drop it. + * @param history - The test replay. + * @param learning - Its projected material, if any. + * @returns The action labels by step id. + * @example stepActionLabels(history, learning) // => { read: 'Inspect stop 1 · 0.42 s' } + */ +function stepActionLabels( + history: Pick<NodeTestHistoryIndex, 'entries'>, + learning: NodeLearningSummaryProjection | undefined, +): Record<string, string> { + const events = new Map( + history.entries.map((entry) => [entry.sequence, entry] as const), + ) + return Object.fromEntries( + (learning?.positions ?? []).flatMap((position) => { + const event = events.get(position.sequence) + // A step whose event this history lacks gets no action. + if (!event) return [] + const label = describeNodeTestReplay({ + kind: 'step-action', + event: + event.type === 'stop' + ? { type: 'stop', index: event.index } + : { type: event.type }, + elapsedMs: event.elapsedMs, + }) + return [[position.stepId, label] as const] + }), + ) +} diff --git a/extension/src/utils/stripTerminalControls.ts b/extension/src/utils/stripTerminalControls.ts new file mode 100644 index 000000000..69a904e1b --- /dev/null +++ b/extension/src/utils/stripTerminalControls.ts @@ -0,0 +1,21 @@ +/** 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 + +/** 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. + * 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. + * @example + * stripTerminalControls('\u001b[32m✓\u001b[39m passes\r\n') // => '✓ passes\n' + * stripTerminalControls('total\t3\n') // => 'total\t3\n' + */ +export function stripTerminalControls(text: string): string { + // Escapes go first: their ESC is itself a C0 control, and removing it alone would leave `[32m` behind. + return text.replace(ANSI_ESCAPES, '').replace(UNREADABLE_CONTROLS, '') +} diff --git a/extension/src/utils/testOutputHtml.ts b/extension/src/utils/testOutputHtml.ts new file mode 100644 index 000000000..5a166ede0 --- /dev/null +++ b/extension/src/utils/testOutputHtml.ts @@ -0,0 +1,43 @@ +import { + TEST_OUTPUT_END_TOLERANCE_PX, + TEST_OUTPUT_STATE_WRITE_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 type { NodeTestApplicationView } from './projectNodeTestApplication' + +/** + * Writes the Application pane's document for a test replay (Design 3): the two-line header pinned above the output region, the output up to the selected event, the lines for what was left out, the verdict or the ended-early line, and the footer; {@link installNodeTestOutput} then applies each later event in place. Every text from the history is printed escaped, so test output and test titles that hold markup render as text. + * @param view - What the pane shows ({@link projectNodeTestApplication}). + * @param nonce - The document's script and style nonce. + * @param history - The shown test replay's study id, which the learner's follow choice belongs to (DS20). + * @returns The document. + * Called by {@link capturedAppHtml} for a selected test replay. + * @example testOutputHtml(view, randomUUID(), 'study_1') + */ +export function testOutputHtml( + view: NodeTestApplicationView, + nonce: string, + history: string, +): 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, + }).replaceAll('<', '\\u003c') + const muted = (id: string, value: string | undefined): string => + `<p id="${id}" class="muted"${value === undefined ? ' hidden' : ''}>${escapeHtml(value ?? '')}</p>` + const lines = view.lines + .map( + (line) => + `<span class="output-line" data-line="${line.number}" data-channel="${line.channel}"${line.cut ? ' data-cut="true"' : ''}>${line.channel === 'stderr' && !line.cut ? `<span class="stderr-mark">${escapeHtml(NODE_TEST_REPLAY_TEXT.stderrMark)}</span>` : ''}<span class="output-text">${escapeHtml(line.text)}</span>\n</span>`, + ) + .join('') + return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'nonce-${nonce}'; script-src 'nonce-${nonce}'"><style nonce="${nonce}">${STUDY_STYLES}</style><title>${escapeHtml(view.name)}

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

${escapeHtml(view.test)}

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

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

` +}