From eefbd8d9a6575b322a10463a8606601cc0ada32f Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Sat, 31 Oct 2026 20:11:21 +0100 Subject: [PATCH 01/85] WIP.ShotReview.md: the plan for the screenshot review tool (pages, queue, remembered overrides, manifest, shared modules with shoot_docs and replay) --- WIP.Screenshots.md | 1 + WIP.ShotReview.md | 265 +++++++++++++++++++++++++++++++++++++++++++++ WIP.md | 1 + 3 files changed, 267 insertions(+) create mode 100644 WIP.ShotReview.md diff --git a/WIP.Screenshots.md b/WIP.Screenshots.md index b9c48b5ed..bd1ef7ff5 100644 --- a/WIP.Screenshots.md +++ b/WIP.Screenshots.md @@ -3,6 +3,7 @@ The documentation's IDE screenshots go stale with every beta. `scripts/shoot_docs.mjs` retakes them from a live IDE: every picture of the IDE the documentation holds, annotations included, and the program windows and web pages that sit beside them. The owner's decisions behind it are marked (owner). +A tool for reviewing a run and overriding its decisions is planned in [WIP.ShotReview.md](WIP.ShotReview.md). ## What there is to retake diff --git a/WIP.ShotReview.md b/WIP.ShotReview.md new file mode 100644 index 000000000..95192689d --- /dev/null +++ b/WIP.ShotReview.md @@ -0,0 +1,265 @@ +# Screenshot review — the plan + +A localhost tool for reviewing what `shoot_docs.mjs` and `replay.mjs` decided about each +picture, overriding it, starting retakes and replays, and committing the result. **Design +agreed with the owner on 2026-10-31; nothing is implemented yet.** The mockups it was agreed +on were inline in that session and are not kept: the pages are described below instead. + +The tool covers everything the command-line screenshot tools do, interactively, with a person +deciding where a threshold cannot (owner). The command-line tools stay, and both reach the +same result for the same inputs: see [Factoring](#factoring). + +## Why + +Two decisions are made by fixed thresholds, and neither threshold holds for every picture: + +- **Did the PNG change?** `withinNoise` (`scripts/lib/shot-change.mjs`): no channel more than + 16 levels off, and no more than 0.5% of the pixels off at all. A pixel more than 16 levels + off fails it at once, so a real change ("Foo" to "Bar") is always caught, at any size. It + fails the other way: one caret or clock pixel in a large window makes it "changed". So the + usual override here is *take new → keep old*; *keep old → take new* is for a faint change + (a tint of 16 levels or less). +- **Is the SVG close enough?** `SVG_FAITHFUL` (`scripts/svgshot/keep.mjs`): no more than + 0.25% of the pixels 96 levels or more off. This one depends on size. The pictures are 2x: + + | Picture | Device pixels | 0.25% is | A missing word (about 1,000 strongly-off px) is | + |---|---|---|---| + | Options_Editor, 1280 x 880 | 4.5 M | 11,264 px | 0.02%: accepted | + | Close_Question, 397 x 161.5 | 256 k | 641 px | 0.4%: rejected | + + Both directions need an override: a missing word accepted in a large picture, a half-pixel + fringe rejected in a small one. + +## Decisions (owner) + +- **A separate localhost tool**, not part of `serve.bat`: it can show the Windows user name + and works on files that are not under `docs/`. +- **Two decisions per picture, each theme on its own**: did the PNG change enough, and is the + SVG close enough. +- **An override is remembered, as a decision or as an allowance.** Either the decision itself + is overridden, or the parameters it was made on. See [Remembered overrides](#remembered-overrides). +- **The decision rules keep the parameters they have today** (levels, shares, rectangles left + out). The region measure of [the queue](#the-queue) chooses what is shown first and decides + nothing; adding it to the rules is a later enhancement, not part of this work. +- **The queue shows pictures by regions, fringe against block** ("option 3"), not by a band + of percentages round the bar, which has the size dependence of the bar itself. +- **A capture that differs from one already decided goes back to the queue**, showing the + last decision, instead of having that decision applied to it. +- **The queue is persistent**, with backward and forward navigation; an overview page shows + every picture at once, as the `sheet.mjs --against` page does, and opens a detail page for + any of them; the detail page moves backward and forward through the queue. +- **The tool starts and stops its own retakes and replays**, and their results show as they + arrive. A picture can be decided as soon as it arrives, while the run goes on. +- **Which build a picture was taken on is recorded**, in a committed manifest. +- **The tool commits**, with a default message that can be edited and a button that puts the + default back. +- **One session at a time.** +- **No code is duplicated between the tool and the command-line tools** (owner): what they + share is moved into modules both call. See [Factoring](#factoring). + +## The pages + +Every page and every picture has its own URL, so the browser's back and forward buttons work, +and a picture's address can be kept or passed on. Each change made on a page is shown as it +happens: a status chip for the picture goes from the tool's verdict, to the change in progress +("take new → keep old"), to the result ("keep old"), or to a failure with a retry. + +### Overview + +Every picture of the session at once, as cards: a thumbnail, its path, and a chip for each of +its two decisions (the tool's verdict, an override, an allowance, or *retaking* while a run +has it). Grouped, in this order: + +1. **In progress**: pictures a run is taking or drawing now. +2. **Needs a look**: the queue's rules, below. +3. **Changed**, most changed first. +4. **Only noise**: the capture was within noise of `HEAD`'s and the file stays. +5. **Unchanged**, collapsed. +6. **Added**, **deleted**, **failed**. + +A row of counts at the top, filters (all, needs a look, overridden, no SVG), and **Resume at +*n* of *m*** to go back to where the queue was left. The two pages of `sheet.mjs` become views +of this one: `--against` is the overview itself, the pictures with no SVG a filter. + +### Detail + +One picture: previous and next (in the order of the overview, within the filter it was opened +from), its place ("4 of 17 · Needs a look"), and the keys j/k. + +- **The comparison**: `HEAD` and the new capture side by side, a swipe, blinking, the + difference map, and the SVG against the new PNG with its own map. Below them, a **loupe** + for each region of change: the region cropped and enlarged, old and new, so that a small + change in a large window is as easy to see as in a small dialog. +- **The PNG row**: the tool's verdict and its reason, the choice *keep old / take new*, and + the parameters: noise levels, share, and the rectangles left out (drawn on the picture). + Changing a parameter judges the picture again at once and shows the verdict it gives. +- **The SVG row**: the share strongly off, the verdict, the choice *accept / no SVG*, and the + parameters: levels, share, rectangles left out. When the PNG goes back to `HEAD`'s, the row + is greyed out and reads that the SVG goes back with it (an SVG changes with its PNG, owner, + [WIP.Screenshots.md](WIP.Screenshots.md#pictures-as-svg)). +- **Retake this picture** and **Replay its SVG**: a run of one picture. +- **Back to the tool's verdicts**. + +### Runs + +The session's runs, newest first, each with its kind, its options, the command line it is the +same as (so it can be run again from a terminal), and while it runs a progress bar per job +(`help`, `no-project`, ...) and **Stop**. Below, a form for a new run: + +- **Retake**: `shoot_docs.mjs`, with the IDE chosen from the installs found (the newest + offered when it is newer than the session's baseline: "BETA 1501 found"), the pictures + (`--only`, or the pictures selected on the overview), SVG on or off, and the jobs. +- **Replay**: `replay.mjs`, with `--only` and `--recheck`. +- **Clean up**: `--cleanup`, its `--dry-run` list shown first. + +### Commit + +What the session changed, counted: PNGs updated by the tool's verdict, kept at `HEAD` against +it, taken against it; SVGs written (by allowance among them) and removed; the manifest. Other +changes in the working tree are listed as left out. The message is one line by default +("Screenshots retaken on BETA 1501: 39 pictures changed, 3 kept at HEAD by review; 35 SVGs +written (2 by allowance), 1 removed"), can be edited, and **Back to the default message** is +enabled once it differs. **Commit** commits the session's own files and nothing else, named +one by one: added files are added first, a removed SVG is committed as a deletion. Never +`--no-verify`, and no trailers (WIP.md, Don'ts). A commit closes the session: the new commit +is the next session's baseline, and the old session is archived and can still be opened. + +## The queue + +The changed pixels are grouped into connected regions, and every region is eroded by 2 device +pixels. A fringe (a line 1 to 3 px thick along an edge, antialiasing or ClearType) is gone +after the erosion; text that changed, or a part the SVG lacks, is a block, and part of it +survives. A picture is in **Needs a look** when: + +- **PNG**: the tool took the new capture, but nothing survives (most likely noise: a caret, a + clock, a fringe), or what survives is small (a block or two the size of a word: a real + change small enough to miss). +- **SVG**: the tool accepted it, but something survives (a part missing, whatever the share); + or the tool rejected it, and nothing survives (fringes only, most likely acceptable). + +The same regions give the loupes. The erosion and "small" are constants of the tool, not of +the decisions. + +## Remembered overrides + +Two kinds, which last for different times: + +- **A decision override** ("keep old", "accept this SVG") is tied to the exact files it was + made on: the capture's hash for the PNG, the SVG's and its PNG's hashes for the SVG. A later + capture with other bytes goes back to the queue, showing the decision. Captures are rarely + byte-identical from run to run, so a PNG decision mostly lasts within a session; an SVG + decision lasts through replays, which draw the same SVG from the same bundle. +- **An allowance** changes a parameter for one picture and one theme: the PNG's noise levels, + share or rectangles left out; the SVG's levels, share or rectangles left out. It stays across + retakes until it is removed. A rectangle is in the picture's device pixels, drawn by hand, + and is shown on the picture, so that a layout that moved shows it misplaced. It is the + manual counterpart of a shot's `unsteady` regions, which are anchored to page elements in + the shot table and resolved to rectangles at the capture; both reach `withinNoise` as its + `rects`. + +Both are kept in **the manifest**, with the build each picture was taken on. **The +command-line tools read it too**: `shoot_docs.mjs` and `replay.mjs` judge with each picture's +allowances and honour its decisions, so that a run from a terminal and a run from the tool +come out the same. Proposed shape, outside `docs/` so the publish allowlist never meets it +(the path is open): + +```json +{ + "format": 1, + "pictures": { + "IDE/Images/Options_Editor.png": { + "build": 1501, + "png": { "noise": { "levels": 16, "share": 0.5 }, "ignore": [[812, 40, 900, 64]], + "decision": { "keep": "old", "capture": "" } }, + "svg": { "bar": { "levels": 96, "share": 0.35 }, "ignore": [], + "decision": { "accept": true, "svg": "", "png": "" } } + } + } +} +``` + +`build` is written when a capture is written to `docs/`, and stays when the file stays (noise, +restored). A picture with no entry shows "build unknown" until it is next written. Only what +differs from the defaults is stored. + +## Sessions + +A session is a folder in the gitignored `.shot-review/` (beside `.svgshot-bundles/`; it can +hold the user name): `session.json` (the baseline commit and its build, the runs, every +decision with its time, the queue's place) and a folder per run with what the run produced +(below). Closing the tool loses nothing; opening it again opens the session. A commit +archives it. + +## Runs + +**The tool runs the command-line tools as child processes**, never their code in its own +process: `shoot_docs.mjs` is a script with effects at the top level (the registry tidy, +`exitOnCrash`, exit codes), and one code path for both is the point. Two options are added to +`shoot_docs.mjs` and `replay.mjs` for it: + +- **`--stage `**: write nothing under `docs/` and nothing to `.svgshot-bundles/`; write + each candidate into `` instead: the capture, its SVG (accepted or not), its bundle, its + difference maps, its strength map and its verdict. **The tool is then the only writer under + `docs/`**: it applies each verdict as it arrives, and each override, through the same code + the command-line tools use (`applyPicture`, below), one picture at a time, so an override + and a run never write one picture at once. +- **`--events`**: a line per event on stdout, `@@shot-event {json}` (run started, job started, + picture verdict, SVG verdict, job done, summary), beside the lines meant for a person. The + tool reads them and sends them to the pages over server-sent events. + +**Stop** is a line on the child's stdin: the child takes no further shot, finishes the one in +hand, ends its IDEs and puts the registry back, as at its normal end. Killing it would skip +that, so the tool offers a kill only after a stop has not ended it, with that warning. + +## Factoring + +The rule: **the tool decides nothing and writes nothing by code of its own.** It runs the +command-line tools, and reads and writes through modules they call too. Today the decisions +and the writes are inline in `shoot_docs.mjs` (`keep`, `keepSvg`, `restoreSvg`, the +`--cleanup` block) and the SVG half is repeated in `replay.mjs`'s `replay`. + +| What | Today | After | +|---|---|---| +| The noise rule | `withinNoise(A, B, rects)`, its limits constants | `withinNoise(A, B, {levels, share, rects})`, defaulting to `NOISE_LEVELS` and `NOISE_SHARE` | +| A PNG's verdict | `pictureFate` (pure), wrapped in `keep()` in `shoot_docs.mjs` with the light-against-dark rule, `SIZE_SLACK`, the writes and the diffs | `decidePicture` (pure): the light rule, `pictureFate`, the manifest's allowance and decision; in a new `scripts/lib/shot-policy.mjs` | +| An SVG's verdict | `judgeSvg`'s `faithful` against `SVG_FAITHFUL`, acted on in `keepSvg` and again in `replay` | `decideSvg` (pure) in `shot-policy.mjs`, from the strength map and the allowance | +| Writing a verdict | `keep`, `keepSvg`, `restoreSvg`, `dropLight` in `shoot_docs.mjs`; `replay` in `replay.mjs` | `applyPicture` / `applySvg` in a new `scripts/lib/shot-apply.mjs` (PNG, SVG, bundle, the SVG going back with its PNG through `svgFate`), called by `shoot_docs.mjs`, `replay.mjs`, `--cleanup` and the tool | +| How far an SVG is off | `diff.mjs` counts pixels at 8, 32 and 96 levels in the page | also returns the strength map (a byte per pixel, the one-pixel shift allowed), from which any level's share, rectangles left out and regions are worked out in Node, without drawing again; `pct8`/`pct32`/`pct96` derived from it | +| Regions | none | `changeRegions(strength, w, h, {level, erode})` in `shot-change.mjs`, for PNG and SVG alike | +| The sheets' HTML | `sheetHtml`, `changeSheetHtml` and their pieces in `redraw.mjs` | the pieces (card, figure, meter, page) in a views module shared by `sheet.mjs` and the tool; a static sheet is the overview without its actions | +| `redraw.mjs`'s other contents | git change listing (`changedPictures`, `parseNameStatus`, ...) beside drawing | the git listing to `shot-revision.mjs`; `redraw.mjs` keeps drawing and the shards | +| A child's report | `reportShard` / `runShards`, the `@@svgshot-result` line | one module for the prefixed-line protocol, used by the shards and by `--events` | +| Server-sent events | `sseHandler` inside `builder/serve.mjs` | lifted to `lib/`, used by `serve.mjs` and the tool | +| The installs | `findIde` (the newest) | `listIdes()` (each at or above `MIN_BUILD`), which `findIde` uses | +| Commit | none | a commit helper beside `runGit` in `shot-revision.mjs` | + +Each move is made with no change in behaviour first, in commits of its own, before the tool +uses it. `shoot_docs.mjs` cannot run in a gate, so a move is checked by its tests in +`test/svgshot.test.mjs` (pure functions) and by a retake of a few pictures before and after +(`--only`, `--svg`), which must report the same states. + +## Order of work + +Each step ends tested and usable on its own. + +1. **The moves**, no behaviour changed: the table's *After* column, with `shoot_docs.mjs`, + `replay.mjs` and `sheet.mjs` calling the shared modules. +2. **The manifest**: read and written by one module; `shoot_docs.mjs` and `replay.mjs` judge + with its allowances, honour its decisions and record `build`. +3. **The strength map, the regions and the queue's rules**, tested on fixtures. +4. **`--stage`, `--events` and stop** in `shoot_docs.mjs` and `replay.mjs`. +5. **The server and the session store**, the overview and the detail page read-only, live. +6. **Decisions and allowances** applied through `shot-apply.mjs`, judged again at once. +7. **The runs page**: retake, replay, clean up, stop. +8. **The commit panel**. +9. **`sheet.mjs` on the shared views**; the tool's entry in `Tools-IDE.md`, its node in + `Tools-Groups.dot`, its command-line cases in `scripts/lib/cli-cases.mjs`, its tests in + `test.bat` (and so in Tools.md's list and the `run-gates` action), and the row in + WIP.md's *Which file to open*. + +## Open + +- **The manifest's path.** Outside `docs/`; beside `shoot_docs.mjs` is one choice. +- **The server's port.** A stable default with `--port`, as bookmarks need, or a port from + `tbbroker`, which today leases one only with a desktop. +- **Allowances in the static sheets**: whether `sheet.mjs` shows them, as the overview does. diff --git a/WIP.md b/WIP.md index 243ffc521..bdf6991b6 100644 --- a/WIP.md +++ b/WIP.md @@ -24,6 +24,7 @@ one covering what you are about to change. | write, run or change a contributed package's test cases, or the tools behind them | [test/package-tests/README.md](test/package-tests/README.md) --- how a case is written, marked and run; [WIP.PackageTests.md](WIP.PackageTests.md) --- why the design is what it is | | run or change Wisdom, the Discord harvester | [WIP.Wisdom.md](WIP.Wisdom.md) --- the three-phase pipeline and the Phase 3 extract flow | | retake the documentation's IDE screenshots, or change the tool that takes them | [WIP.Screenshots.md](WIP.Screenshots.md) --- `shoot_docs.mjs`: the setups, cut-outs, the annotation overlay and the owner's decisions | +| review a screenshot run, override what `shoot_docs` or `replay` decided, or change how they decide what to keep | [WIP.ShotReview.md](WIP.ShotReview.md) --- the plan for the review tool: its pages, the queue, remembered overrides and the manifest, and what moves out of `shoot_docs.mjs` so that the tool and the command-line tools share one implementation | | work on the IDE help add-in, or on testing IDE add-ins by machine | [WIP.HelpAddin.md](WIP.HelpAddin.md) --- the plan, the IDE facts it rests on, and the probes still open | | find the documentation work a new twinBASIC build leaves, or change the package indexer (`indexer/`) | [WIP.Indexer.md](WIP.Indexer.md) --- the store and the snapshots, the one shared scanner, `api.json` format 3, the owner's decisions and the open items | | add or check the run-time errors a procedure, statement or operator raises | [WIP.Errors.md](WIP.Errors.md) --- the owner's decisions, the data-driven design, and the batches with their status | From 95378e8f1e87b1b4005ffb0c7ffd56a828148c4d Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Sat, 31 Oct 2026 20:15:08 +0100 Subject: [PATCH 02/85] withinNoise takes its limits and rectangles as options (levels, share, rects), defaulting to NOISE_LEVELS and NOISE_SHARE --- scripts/lib/shot-change.mjs | 10 +++++----- scripts/shoot_docs.mjs | 2 +- test/svgshot.test.mjs | 7 +++++-- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/scripts/lib/shot-change.mjs b/scripts/lib/shot-change.mjs index 73e3d8f10..f98e281ae 100644 --- a/scripts/lib/shot-change.mjs +++ b/scripts/lib/shot-change.mjs @@ -24,21 +24,21 @@ const pixelDiff = (A, i, B, j) => { /** * Whether two images are the same picture but for a capture's noise: one size, no - * channel of any pixel more than NOISE_LEVELS grey levels apart, and no more than - * NOISE_SHARE of the pixels apart at all (at least one). The noise seen: 1 to 14 + * channel of any pixel more than `levels` grey levels apart (NOISE_LEVELS), and no + * more than `share` of the pixels apart at all (NOISE_SHARE; at least one pixel). The noise seen: 1 to 14 * levels, along a shadow's or an arrow's edge, at a rounded corner, in a scaled * bitmap (34 pixels; 81 on About's Close button; the most, 563 along a dialog's top * edge, 0.13% of it). A change the IDE draws moves text or colour by far more than * 16 levels. The pixels inside `rects` are left out. Identical images are within it. */ -export function withinNoise(A, B, rects = []) { +export function withinNoise(A, B, { levels = NOISE_LEVELS, share = NOISE_SHARE, rects = [] } = {}) { if (A.width !== B.width || A.height !== B.height) return false; - const most = Math.max(1, Math.floor(A.width * A.height * NOISE_SHARE)); + const most = Math.max(1, Math.floor(A.width * A.height * share)); let apart = 0; for (let i = 0; i < A.rgba.length; i += 4) { const d = pixelDiff(A, i, B, i); if (!d || inRects(rects, (i / 4) % A.width, Math.floor(i / 4 / A.width))) continue; - if (d > NOISE_LEVELS || ++apart > most) return false; + if (d > levels || ++apart > most) return false; } return true; } diff --git a/scripts/shoot_docs.mjs b/scripts/shoot_docs.mjs index 097de1362..27ec460cc 100644 --- a/scripts/shoot_docs.mjs +++ b/scripts/shoot_docs.mjs @@ -673,7 +673,7 @@ function writeDiff(name, out, before, after, { suffix = "", labels } = {}) { // Whether two captures (PNG files) are the same picture but for a capture's noise // (withinNoise, scripts/lib/shot-change.mjs, which holds the rule and what it rests on). // The pixels inside `rects` (unsteadyRects) are left out. -const nearly = (a, b, rects = []) => withinNoise(decodePng(a), decodePng(b), rects); +const nearly = (a, b, rects = []) => withinNoise(decodePng(a), decodePng(b), { rects }); // Whether two captures are the same picture outside `rects`: byte for byte when there are none. function same(a, b, rects) { diff --git a/test/svgshot.test.mjs b/test/svgshot.test.mjs index a914f3a92..378d3eaa7 100644 --- a/test/svgshot.test.mjs +++ b/test/svgshot.test.mjs @@ -1517,8 +1517,11 @@ describe("the change sheet", () => { // Pictures of two sizes are never the same picture. assert.equal(withinNoise(a, image(100, 101)), false); // A rectangle left out is not looked at: row 0 holds all 80 changed pixels, a smaller one not. - assert.equal(withinNoise(a, patch(80, 200), [{ x0: 0, y0: 0, x1: 100, y1: 1 }]), true); - assert.equal(withinNoise(a, patch(80, 200), [{ x0: 0, y0: 0, x1: 79, y1: 1 }]), false); + assert.equal(withinNoise(a, patch(80, 200), { rects: [{ x0: 0, y0: 0, x1: 100, y1: 1 }] }), true); + assert.equal(withinNoise(a, patch(80, 200), { rects: [{ x0: 0, y0: 0, x1: 79, y1: 1 }] }), false); + // Other limits: 17 levels within 17, 51 pixels within a share of 0.51%. + assert.equal(withinNoise(a, patch(1, 117), { levels: 17 }), true); + assert.equal(withinNoise(a, patch(51, 116), { share: 0.0051 }), true); }); test("compares two pictures of one size over their pixels", () => { From 227e77f2822669928567100dd326518ad775ea4c Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Sat, 31 Oct 2026 20:15:55 +0100 Subject: [PATCH 03/85] tb-install: listIdes lists every install at or above MIN_BUILD, newest first; findIde takes the first --- scripts/lib/tb-install.mjs | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/scripts/lib/tb-install.mjs b/scripts/lib/tb-install.mjs index a590712e4..248d72976 100644 --- a/scripts/lib/tb-install.mjs +++ b/scripts/lib/tb-install.mjs @@ -23,21 +23,14 @@ import path from "node:path"; export const MIN_BUILD = 1007; /** - * The twinBASIC IDE executable, or null. - * - * Without a path from --ide or TB_IDE, the newest twinBASIC_IDE_BETA_ on the - * Desktop that holds a twinBASIC.exe and is not older than MIN_BUILD. The - * Desktop is under USERPROFILE, else under the home folder: without the - * fallback, a missing USERPROFILE searched a `Desktop` folder relative to the - * working directory. - * - * @param {string} [explicit] a path from --ide, which wins outright + * Every twinBASIC_IDE_BETA_ on the Desktop that holds a twinBASIC.exe and is + * not older than MIN_BUILD, newest first: [{build, exe}]. The Desktop is under + * USERPROFILE, else under the home folder: without the fallback, a missing + * USERPROFILE searched a `Desktop` folder relative to the working directory. */ -export function findIde(explicit) { - const named = explicit ?? process.env.TB_IDE; - if (named) return named; +export function listIdes() { const desktop = path.join(process.env.USERPROFILE || os.homedir(), "Desktop"); - let best = null; + const found = []; try { for (const name of readdirSync(desktop)) { const m = /^twinBASIC_IDE_BETA_(\d+)$/.exec(name); @@ -45,13 +38,24 @@ export function findIde(explicit) { const exe = path.join(desktop, name, "twinBASIC.exe"); if (!existsSync(exe)) continue; const build = Number(m[1]); - if (build < MIN_BUILD) continue; - if (!best || build > best.build) best = { build, exe }; + if (build >= MIN_BUILD) found.push({ build, exe }); } } catch { /* no Desktop, or unreadable */ } - return best?.exe ?? null; + return found.sort((a, b) => b.build - a.build); +} + +/** + * The twinBASIC IDE executable, or null: without a path from --ide or TB_IDE, + * the newest install listIdes finds. + * + * @param {string} [explicit] a path from --ide, which wins outright + */ +export function findIde(explicit) { + const named = explicit ?? process.env.TB_IDE; + if (named) return named; + return listIdes()[0]?.exe ?? null; } /** From b74bae52777363cb60bbb8464f5c937b5037d791 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Sat, 31 Oct 2026 20:17:06 +0100 Subject: [PATCH 04/85] The git change listing (changedPictures, parseNameStatus, parseUntracked) moves from svgshot/redraw.mjs to lib/shot-revision.mjs --- scripts/lib/shot-revision.mjs | 58 ++++++++++++++++++++++++++++++-- scripts/shoot_docs.mjs | 10 ++++-- scripts/svgshot/redraw.mjs | 62 ++++------------------------------- scripts/svgshot/sheet.mjs | 3 +- test/svgshot.test.mjs | 6 ++-- 5 files changed, 74 insertions(+), 65 deletions(-) diff --git a/scripts/lib/shot-revision.mjs b/scripts/lib/shot-revision.mjs index c2967d460..b874919d9 100644 --- a/scripts/lib/shot-revision.mjs +++ b/scripts/lib/shot-revision.mjs @@ -3,14 +3,16 @@ // (or another revision) before the file on disk, so that a picture does not drift from the // repository's over several runs, each step within a capture's noise of the file before it; // and its --cleanup puts back every picture git reports modified that is within that noise of -// the revision's. svgshot/sheet.mjs --against reads the revision's pictures here too. The -// noise rule itself is withinNoise, in shot-change.mjs: every caller passes it in. +// the revision's. svgshot/sheet.mjs --against lists the pictures that differ from a revision +// and reads the revision's pictures here too. The noise rule itself is withinNoise, in +// shot-change.mjs: every caller passes it in. // // Git is run in the docs folder, so that the repository that holds it answers, whichever // checkout the script is in. The reads are batched: one `git cat-file --batch` process for // any number of files. import { execFileSync } from "node:child_process"; +import { isOutputTree } from "../../lib/markdown-files.mjs"; /** * Runs git in `cwd` and returns {status, stdout}: status 0 and its output as text, or its @@ -27,6 +29,58 @@ export function runGit(args, cwd) { } } +/** + * The pictures git reports changed, from the text of `git diff --name-status + * --no-renames -z` (status, path, status, path, ... separated by NUL): [{out, + * status}], status "modified", "added" or "deleted", the PNGs only, the build's output + * trees left out, sorted by path. + */ +export function parseNameStatus(text) { + const parts = text.split("\0"); + const found = []; + for (let i = 0; i + 1 < parts.length; i += 2) { + const letter = parts[i].trim()[0]; + const status = letter === "A" ? "added" : letter === "D" ? "deleted" : "modified"; + found.push({ out: parts[i + 1], status }); + } + return keepPictures(found); +} + +/** The PNGs of a list of paths from `git ls-files -z` (NUL separated), as [{out, status: "added"}]. */ +export const parseUntracked = (text) => + keepPictures( + text + .split("\0") + .filter(Boolean) + .map((out) => ({ out, status: "added" })), + ); + +function keepPictures(list) { + const seen = new Set(); + return list + .filter(({ out }) => /\.png$/i.test(out) && !isOutputTree(out.split("/")[0]) && !seen.has(out) && seen.add(out)) + .sort((a, b) => (a.out < b.out ? -1 : a.out > b.out ? 1 : 0)); +} + +/** + * The PNGs under `docsRoot` that differ from the revision `rev`, read with git run in + * `docsRoot` (so its own repository answers, whichever checkout this script is in): + * {hash, short, prefix, pictures}, `prefix` the folder's path from the repository's root + * with a closing slash (empty at the root), `pictures` as parseNameStatus gives them, + * with the PNGs git does not track yet, which are added. The working tree is compared, + * not the index. Throws an Error that says why when `rev` is not a commit there or the + * folder is not in a git working tree. `git` stands in for running git in the probes. + */ +export function changedPictures(docsRoot, rev, git = runGit) { + const { hash: id, short, prefix } = revisionOf(docsRoot, rev, git); + const diff = git(["diff", "--name-status", "--no-renames", "--relative", "-z", id, "--", "."], docsRoot); + if (diff.status !== 0) throw new Error(`git diff against ${rev} failed in ${docsRoot}`); + const untracked = git(["ls-files", "-z", "--others", "--exclude-standard", "--", "."], docsRoot); + if (untracked.status !== 0) throw new Error(`git ls-files failed in ${docsRoot}`); + const pictures = keepPictures([...parseNameStatus(diff.stdout), ...parseUntracked(untracked.stdout)]); + return { hash: id, short, prefix, pictures }; +} + /** * The commit `rev` names in the git repository of the folder `root`: {hash, short, * prefix}, `prefix` the folder's path from the repository's root with a closing slash diff --git a/scripts/shoot_docs.mjs b/scripts/shoot_docs.mjs index 27ec460cc..3d4488db8 100644 --- a/scripts/shoot_docs.mjs +++ b/scripts/shoot_docs.mjs @@ -164,7 +164,14 @@ import { composite, uncomposite } from "./lib/shot-composite.mjs"; import { decodePng, encodePng } from "./lib/png.mjs"; import { iconGroup, iconImage, manifestResource, resFile } from "./lib/res-file.mjs"; import { inRects, withinNoise } from "./lib/shot-change.mjs"; -import { openRevision, pictureFate, planCleanup, restoreFromRevision, svgFate } from "./lib/shot-revision.mjs"; +import { + changedPictures, + openRevision, + pictureFate, + planCleanup, + restoreFromRevision, + svgFate, +} from "./lib/shot-revision.mjs"; import { diffPicture } from "./lib/shot-diff.mjs"; import { attach } from "./lib/tb-cdp.mjs"; import { consoleMark, linesSince } from "./lib/tb-ide-console.mjs"; @@ -186,7 +193,6 @@ import { } from "./lib/tb-ide.mjs"; import { dropBundle, writeBundle } from "./svgshot/bundle.mjs"; import { svgOfPage } from "./svgshot/capture.mjs"; -import { changedPictures } from "./svgshot/redraw.mjs"; import { useSubsetCache } from "./svgshot/fonts.mjs"; import { holdsUserName, judgeSvg, SVG_FAITHFUL, writeSvg } from "./svgshot/keep.mjs"; import { stageProject, unpackProject } from "./lib/tb-project.mjs"; diff --git a/scripts/svgshot/redraw.mjs b/scripts/svgshot/redraw.mjs index d93a3efef..d57f39d74 100644 --- a/scripts/svgshot/redraw.mjs +++ b/scripts/svgshot/redraw.mjs @@ -3,9 +3,10 @@ // sheet of them to look at. Finding the pictures and their bundles, drawing one again // and judging it (keep.mjs's bar), writing what the judge made into a folder, running // the pictures in processes of their own, and the sheet's HTML. And what sheet.mjs --against -// uses to show the pictures that differ from a git revision: the change list from git, the -// comparison of each picture (scripts/lib/shot-change.mjs), their order and the page, which -// shares its skeleton with the other sheet. +// uses to show the pictures that differ from a git revision (the list of them is +// changedPictures, in scripts/lib/shot-revision.mjs): the comparison of each picture +// (scripts/lib/shot-change.mjs), their order and the page, which shares its skeleton with +// the other sheet. // // Nothing here writes under docs/: each writer is given its folder. @@ -17,7 +18,7 @@ import { isOutputTree } from "../../lib/markdown-files.mjs"; import { LAUNCH_ARGS, launchBrowser } from "../lib/browser.mjs"; import { decodePng } from "../lib/png.mjs"; import { changeMap, compareImages, NOISE_LEVELS, NOISE_SHARE } from "../lib/shot-change.mjs"; -import { revisionOf, runGit } from "../lib/shot-revision.mjs"; +import { runGit } from "../lib/shot-revision.mjs"; import { BUNDLE_VERSION, pngHash, readBundle, renderBundle } from "./bundle.mjs"; import { SNAPSHOT_STYLES } from "./capture.mjs"; import { holdsUserName } from "./keep.mjs"; @@ -403,58 +404,7 @@ ${thumbs} } // ---- the sheet of changes against a git revision ---------------------------------------- - -/** - * The pictures git reports changed, from the text of `git diff --name-status - * --no-renames -z` (status, path, status, path, ... separated by NUL): [{out, - * status}], status "modified", "added" or "deleted", the PNGs only, the build's output - * trees left out, sorted by path. - */ -export function parseNameStatus(text) { - const parts = text.split("\0"); - const found = []; - for (let i = 0; i + 1 < parts.length; i += 2) { - const letter = parts[i].trim()[0]; - const status = letter === "A" ? "added" : letter === "D" ? "deleted" : "modified"; - found.push({ out: parts[i + 1], status }); - } - return keepPictures(found); -} - -/** The PNGs of a list of paths from `git ls-files -z` (NUL separated), as [{out, status: "added"}]. */ -export const parseUntracked = (text) => - keepPictures( - text - .split("\0") - .filter(Boolean) - .map((out) => ({ out, status: "added" })), - ); - -function keepPictures(list) { - const seen = new Set(); - return list - .filter(({ out }) => /\.png$/i.test(out) && !isOutputTree(out.split("/")[0]) && !seen.has(out) && seen.add(out)) - .sort((a, b) => (a.out < b.out ? -1 : a.out > b.out ? 1 : 0)); -} - -/** - * The PNGs under `docsRoot` that differ from the revision `rev`, read with git run in - * `docsRoot` (so its own repository answers, whichever checkout this script is in): - * {hash, short, prefix, pictures}, `prefix` the folder's path from the repository's root - * with a closing slash (empty at the root), `pictures` as parseNameStatus gives them, - * with the PNGs git does not track yet, which are added. The working tree is compared, - * not the index. Throws an Error that says why when `rev` is not a commit there or the - * folder is not in a git working tree. `git` stands in for running git in the probes. - */ -export function changedPictures(docsRoot, rev, git = runGit) { - const { hash: id, short, prefix } = revisionOf(docsRoot, rev, git); - const diff = git(["diff", "--name-status", "--no-renames", "--relative", "-z", id, "--", "."], docsRoot); - if (diff.status !== 0) throw new Error(`git diff against ${rev} failed in ${docsRoot}`); - const untracked = git(["ls-files", "-z", "--others", "--exclude-standard", "--", "."], docsRoot); - if (untracked.status !== 0) throw new Error(`git ls-files failed in ${docsRoot}`); - const pictures = keepPictures([...parseNameStatus(diff.stdout), ...parseUntracked(untracked.stdout)]); - return { hash: id, short, prefix, pictures }; -} +// The change list itself is changedPictures, in shot-revision.mjs. /** The files a picture of the change sheet can have in a flat folder: the old PNG, the new one and the map. */ export const changeFiles = (out) => { diff --git a/scripts/svgshot/sheet.mjs b/scripts/svgshot/sheet.mjs index 4021059f1..bd6ed8e0d 100644 --- a/scripts/svgshot/sheet.mjs +++ b/scripts/svgshot/sheet.mjs @@ -28,14 +28,13 @@ import { withUsageError, } from "../../lib/cli.mjs"; import { DOCS_DIR, REPO_ROOT } from "../../lib/repo-paths.mjs"; -import { readRevisionFiles } from "../lib/shot-revision.mjs"; +import { changedPictures, readRevisionFiles } from "../lib/shot-revision.mjs"; import { listBundles } from "./bundle.mjs"; import { useSubsetCache } from "./fonts.mjs"; import { judgeSvg, SVG_FAITHFUL } from "./keep.mjs"; import { changeFiles, changeSheetHtml, - changedPictures, compareRevisionPicture, defaultJobs, inBrowser, diff --git a/test/svgshot.test.mjs b/test/svgshot.test.mjs index 378d3eaa7..cbd453354 100644 --- a/test/svgshot.test.mjs +++ b/test/svgshot.test.mjs @@ -46,8 +46,11 @@ import { SNAPSHOT_STYLES, webFontFiles } from "../scripts/svgshot/capture.mjs"; import { decodePng, encodePng } from "../scripts/lib/png.mjs"; import { changeMap, compareImages, withinNoise } from "../scripts/lib/shot-change.mjs"; import { + changedPictures, openRevision, parseCatFileBatch, + parseNameStatus, + parseUntracked, pictureFate, planCleanup, restoreFromRevision, @@ -55,15 +58,12 @@ import { svgFate, } from "../scripts/lib/shot-revision.mjs"; import { - changedPictures, cleanupCommand, changeFiles, changeSheetHtml, compareRevisionPicture, judgedFiles, listPictures, - parseNameStatus, - parseUntracked, restoreCommand, sortChangeRows, mayWriteSheet, From 4e94dd7130f295b41ffc62b19b67b8e81f8bc902 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Sat, 31 Oct 2026 20:18:43 +0100 Subject: [PATCH 05/85] lib/record-lines.mjs: the @@ record lines a child writes among its own, which redraw.mjs's runShards and reportShard now use; a last line with no line ending is passed on rather than dropped --- scripts/lib/record-lines.mjs | 44 ++++++++++++++++++++++++++++++++++++ scripts/svgshot/redraw.mjs | 19 ++++++++-------- test/svgshot.test.mjs | 37 +++++++++++++++++++++++++++++- 3 files changed, 89 insertions(+), 11 deletions(-) create mode 100644 scripts/lib/record-lines.mjs diff --git a/scripts/lib/record-lines.mjs b/scripts/lib/record-lines.mjs new file mode 100644 index 000000000..a10a8bb3f --- /dev/null +++ b/scripts/lib/record-lines.mjs @@ -0,0 +1,44 @@ +// Records a child process writes on its stdout among the lines meant for a person, and +// the reading of them in its parent: a record is a line of its own, `@@ `. +// svgshot/redraw.mjs's shards end their output with one (`svgshot-result`); the +// screenshot review tool reads a run's events the same way. + +/** The line that carries `value` as a record of `kind`. */ +export const recordLine = (kind, value) => `@@${kind} ${JSON.stringify(value)}`; + +/** Writes `value` as a record of `kind`, on a line of its own on stdout. */ +export const writeRecord = (kind, value) => console.log(recordLine(kind, value)); + +/** + * The record a line holds, as {kind, value}, when it is one of `kinds`; else null (a + * line meant for a person). A record whose JSON does not parse throws. + */ +export function parseRecord(line, kinds) { + const m = /^@@([\w-]+) /.exec(line); + if (!m || !kinds.includes(m[1])) return null; + return { kind: m[1], value: JSON.parse(line.slice(m[0].length)) }; +} + +/** + * Reads `stream` (a child's stdout) line by line, each without its line ending: + * `onRecord(kind, value)` for a record of one of `kinds`, `onLine(line)` for any other. + * A last line with no line ending is read when the stream ends. + */ +export function readRecords(stream, kinds, { onRecord, onLine }) { + let rest = ""; + const take = (line) => { + const text = line.replace(/\r$/, ""); + const record = parseRecord(text, kinds); + if (record) onRecord(record.kind, record.value); + else onLine(text); + }; + stream.on("data", (d) => { + const lines = (rest + d).split("\n"); + rest = lines.pop(); + for (const line of lines) take(line); + }); + stream.on("end", () => { + if (rest) take(rest); + rest = ""; + }); +} diff --git a/scripts/svgshot/redraw.mjs b/scripts/svgshot/redraw.mjs index d57f39d74..22e158d6a 100644 --- a/scripts/svgshot/redraw.mjs +++ b/scripts/svgshot/redraw.mjs @@ -17,6 +17,7 @@ import path from "node:path"; import { isOutputTree } from "../../lib/markdown-files.mjs"; import { LAUNCH_ARGS, launchBrowser } from "../lib/browser.mjs"; import { decodePng } from "../lib/png.mjs"; +import { readRecords, writeRecord } from "../lib/record-lines.mjs"; import { changeMap, compareImages, NOISE_LEVELS, NOISE_SHARE } from "../lib/shot-change.mjs"; import { runGit } from "../lib/shot-revision.mjs"; import { BUNDLE_VERSION, pngHash, readBundle, renderBundle } from "./bundle.mjs"; @@ -140,7 +141,8 @@ export function writeJudged(dir, out, diff, { render = false, svg = null } = {}) /** How many processes draw at once by default: half the logical processors, at most 8. */ export const defaultJobs = () => Math.min(8, Math.max(1, Math.floor(availableParallelism() / 2))); -const RESULT = "@@svgshot-result "; +// the kind of the record a child ends its output with (lib/record-lines.mjs) +const RESULT = "svgshot-result"; /** * This process's share of the pictures when it is a child runShards started, as @@ -177,7 +179,7 @@ export async function inBrowser(items, fn) { } /** Ends a child's output with what it found, on a line of its own that runShards reads. */ -export const reportShard = (result) => console.log(`${RESULT}${JSON.stringify(result)}`); +export const reportShard = (result) => writeRecord(RESULT, result); /** * Starts `jobs` children of this script, with its arguments, each told its share in @@ -197,14 +199,11 @@ export function runShards(envName, jobs) { stdio: ["ignore", "pipe", "inherit"], }); let result = null; - let rest = ""; - child.stdout.on("data", (d) => { - const lines = (rest + d).split("\n"); - rest = lines.pop(); - for (const line of lines) { - if (line.startsWith(RESULT)) result = JSON.parse(line.slice(RESULT.length)); - else console.log(line.replace(/\r$/, "")); - } + readRecords(child.stdout, [RESULT], { + onRecord: (_, value) => { + result = value; + }, + onLine: (line) => console.log(line), }); child.on("close", (code) => resolve({ result, code })); }), diff --git a/test/svgshot.test.mjs b/test/svgshot.test.mjs index cbd453354..b357837c2 100644 --- a/test/svgshot.test.mjs +++ b/test/svgshot.test.mjs @@ -19,7 +19,8 @@ // user name unless its caller turns the check off; the pictures listed; the folders a sheet // is refused; and the sheet's page. And the sheet of changes against a git revision // (sheet.mjs --against): git's change list, the comparison of two pictures over two sizes, -// the noise rule, the order and the page. +// the noise rule, the order and the page. And lib/record-lines.mjs, the records a child +// process writes among its lines, which runShards reads. // // Runs with a bare `node --test test/svgshot.test.mjs`: no tree, no build. @@ -28,6 +29,7 @@ import { execFileSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { PassThrough } from "node:stream"; import { describe, test } from "node:test"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; import { @@ -44,6 +46,7 @@ import { } from "../scripts/svgshot/bundle.mjs"; import { SNAPSHOT_STYLES, webFontFiles } from "../scripts/svgshot/capture.mjs"; import { decodePng, encodePng } from "../scripts/lib/png.mjs"; +import { parseRecord, readRecords, recordLine } from "../scripts/lib/record-lines.mjs"; import { changeMap, compareImages, withinNoise } from "../scripts/lib/shot-change.mjs"; import { changedPictures, @@ -1864,3 +1867,35 @@ describe("the picture at a git revision", () => { assert.equal(git("status", "--porcelain").toString(), ""); }); }); + +describe("the records a child writes among its lines (record-lines.mjs)", () => { + test("reads a record of a kind it is told, and passes any other line on", () => { + const line = recordLine("svgshot-result", { counts: { new: 2 }, why: "a b" }); + assert.equal(line, '@@svgshot-result {"counts":{"new":2},"why":"a b"}'); + assert.deepEqual(parseRecord(line, ["svgshot-result"]), { + kind: "svgshot-result", + value: { counts: { new: 2 }, why: "a b" }, + }); + assert.equal(parseRecord(line, ["shot-event"]), null); + assert.equal(parseRecord("a/b.svg: unchanged", ["svgshot-result"]), null); + assert.throws(() => parseRecord("@@svgshot-result {", ["svgshot-result"]), SyntaxError); + }); + + test("splits a stream into lines across its chunks, CRLF or LF, the last one too", async () => { + const stream = new PassThrough(); + const lines = []; + const records = []; + readRecords(stream, ["k"], { + onRecord: (kind, value) => records.push([kind, value]), + onLine: (text) => lines.push(text), + }); + const ended = new Promise((resolve) => stream.on("end", resolve)); + stream.write("one\r\ntw"); + stream.write('o\n@@k {"n":'); + stream.write("1}\n@@other 2\nlast"); + stream.end(); + await ended; + assert.deepEqual(lines, ["one", "two", "@@other 2", "last"]); + assert.deepEqual(records, [["k", { n: 1 }]]); + }); +}); From 97c23377ee8c9bbf1051189131424fcaefea5be8 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Sat, 31 Oct 2026 20:21:22 +0100 Subject: [PATCH 06/85] lib/sse.mjs: a channel of server-sent events (createSseChannel), lifted from builder/serve.mjs's live-reload, which now uses it --- builder/serve.mjs | 40 ++++++-------------------------------- lib/README.md | 5 +++++ lib/sse.mjs | 49 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 34 deletions(-) create mode 100644 lib/sse.mjs diff --git a/builder/serve.mjs b/builder/serve.mjs index 562ce983a..0037dc6ab 100644 --- a/builder/serve.mjs +++ b/builder/serve.mjs @@ -14,6 +14,7 @@ import { watch } from "node:fs/promises"; import { existsSync } from "node:fs"; import path from "node:path"; import { isOutputTree } from "../lib/markdown-files.mjs"; +import { createSseChannel } from "../lib/sse.mjs"; import { runBuild, createWorkerPool, EXIT_ERROR } from "./tbdocs.mjs"; import { createStaticHandler } from "./static-files.mjs"; @@ -26,35 +27,11 @@ function injectReloadScript(html) { return html.slice(0, idx) + RELOAD_SCRIPT + html.slice(idx); } -// §C — SSE endpoint -const sseClients = new Set(); - -function sseHandler(req, res) { - res.statusCode = 200; - res.setHeader("content-type", "text/event-stream"); - res.setHeader("cache-control", "no-store"); - res.setHeader("connection", "keep-alive"); - res.write(": connected\n\n"); - sseClients.add(res); - - const keepalive = setInterval(() => { - try { - res.write(": keepalive\n\n"); - } catch {} - }, 30000); - - req.on("close", () => { - clearInterval(keepalive); - sseClients.delete(res); - }); -} +// §C — SSE endpoint (lib/sse.mjs) +const reloads = createSseChannel(); function notifyReload() { - for (const res of sseClients) { - try { - res.write("event: reload\ndata: 1\n\n"); - } catch {} - } + reloads.send("reload", "1"); } // §A — Static file handler factory @@ -145,7 +122,7 @@ export async function runServe(opts) { const server = createServer(async (req, res) => { const url = req.url ?? "/"; if (url === "/_tbdocs/reload" || url.startsWith("/_tbdocs/reload?")) { - sseHandler(req, res); + reloads.handle(req, res); return; } await staticHandler(req, res); @@ -216,12 +193,7 @@ export async function runServe(opts) { process.on("SIGINT", () => { console.log("serve: shutting down."); ac.abort(); - for (const res of sseClients) { - try { - res.end(); - } catch {} - } - sseClients.clear(); + reloads.close(); pool.destroy(); server.close(() => process.exit(0)); setTimeout(() => process.exit(0), 100).unref(); diff --git a/lib/README.md b/lib/README.md index 106490dae..028a5faf7 100644 --- a/lib/README.md +++ b/lib/README.md @@ -39,6 +39,11 @@ given code (`die`), and makes a crash exit 2 (`exitOnCrash`). `scripts/check_cli.mjs` carries its probes and the tools' recorded command-line cases. +[sse.mjs](sse.mjs) is a channel of server-sent events (`createSseChannel`): +it answers a page's `EventSource` request, keeps the stream open, and writes +each event to every page holding it. `builder/serve.mjs` sends its +live-reload over one. + `scripts/check_tree_fresh.mjs` counts `lib/` among the inputs that decide the built bytes, beside `docs/` and `builder/`, so an edit here marks every built tree stale, and `check.bat` and `book.bat` refuse it until the next build. diff --git a/lib/sse.mjs b/lib/sse.mjs new file mode 100644 index 000000000..fe436988d --- /dev/null +++ b/lib/sse.mjs @@ -0,0 +1,49 @@ +// Server-sent events: a channel that pages hold open with an EventSource, and that the +// server writes events to. builder/serve.mjs sends its live-reload over one; the +// screenshot review tool sends a run's progress over another. + +/** + * A new channel: {handle(req, res), send(event, data), close(), size}. `handle` answers a + * request with the event stream and keeps it until the page goes; `send` writes one event + * to every page holding the channel, `data` a string (one line) or any value, sent as its + * JSON; `close` ends every stream. A comment goes to each page every 30 seconds, so that + * nothing between the two ends it as idle. + */ +export function createSseChannel() { + const clients = new Set(); + const write = (res, text) => { + try { + res.write(text); + } catch {} + }; + return { + handle(req, res) { + res.statusCode = 200; + res.setHeader("content-type", "text/event-stream"); + res.setHeader("cache-control", "no-store"); + res.setHeader("connection", "keep-alive"); + res.write(": connected\n\n"); + clients.add(res); + const keepalive = setInterval(() => write(res, ": keepalive\n\n"), 30000); + req.on("close", () => { + clearInterval(keepalive); + clients.delete(res); + }); + }, + send(event, data) { + const text = typeof data === "string" ? data : JSON.stringify(data); + for (const res of clients) write(res, `event: ${event}\ndata: ${text}\n\n`); + }, + close() { + for (const res of clients) { + try { + res.end(); + } catch {} + } + clients.clear(); + }, + get size() { + return clients.size; + }, + }; +} From 291e628d1302039ef5821c9827e932b8eb9b1e68 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Sat, 31 Oct 2026 20:34:43 +0100 Subject: [PATCH 07/85] lib/shot-policy.mjs (decidePicture, decideSvg, SIZE_SLACK, SVG_FAITHFUL) and lib/shot-apply.mjs (applyPicture, applySvg, restorePictures, dropPicture, writeSvg): the decisions and writes of shoot_docs.mjs's keep, keepSvg, restoreSvg, dropLight and --cleanup, and of replay.mjs, moved into modules both call --- WIP.Screenshots.md | 6 +- scripts/lib/shot-apply.mjs | 107 ++++++++++++++++++++ scripts/lib/shot-policy.mjs | 56 +++++++++++ scripts/shoot_docs.mjs | 191 +++++++++++++----------------------- scripts/svgshot/keep.mjs | 33 ++----- scripts/svgshot/redraw.mjs | 2 +- scripts/svgshot/replay.mjs | 22 ++--- scripts/svgshot/sheet.mjs | 5 +- test/svgshot.test.mjs | 144 ++++++++++++++++++++++++++- 9 files changed, 399 insertions(+), 167 deletions(-) create mode 100644 scripts/lib/shot-apply.mjs create mode 100644 scripts/lib/shot-policy.mjs diff --git a/WIP.Screenshots.md b/WIP.Screenshots.md index bd1ef7ff5..44e5271e7 100644 --- a/WIP.Screenshots.md +++ b/WIP.Screenshots.md @@ -150,7 +150,7 @@ Measured: `capture` stops at a capture equal to `HEAD`'s picture or the file's, and `keep` compares with `HEAD`'s first: within noise of it (or equal), the file becomes `HEAD`'s bytes, reported `restored as HEAD has it` when it held anything else, and its SVG goes back to `HEAD`'s, or - is removed when `HEAD` has none, so the pair stays matched (`restoreSvg`, the SVG written by + is removed when `HEAD` has none, so the pair stays matched (`restorePictures` in `scripts/lib/shot-apply.mjs`, the SVG written by `git restore --worktree`, so that its line endings are a checkout's; no SVG is drawn and the bundle stays as it is). Not near `HEAD`'s but within noise of the file: an earlier run of the retake wrote that change, unchanged. Else written. A picture `HEAD` lacks (a new shot, an @@ -991,7 +991,7 @@ the PNG was just captured from, and the build shows it in place of the PNG cat-file --batch` (`readRevisionFiles`, `scripts/lib/shot-revision.mjs`). PNG against PNG, no browser: 337 pictures in about 30 s. On the BETA 1033 retake none was within noise. -- **The bar** (`SVG_FAITHFUL` in `shoot_docs.mjs`): an SVG with more than 0.25% of its pixels 96 +- **The bar** (`SVG_FAITHFUL` in `scripts/lib/shot-policy.mjs`): an SVG with more than 0.25% of its pixels 96 grey levels or more off is not written, and an older one is removed. Anything not drawn falls to it. - **An SVG changes with its PNG** (owner). The SVG of a picture whose PNG is not rewritten @@ -1005,7 +1005,7 @@ the PNG was just captured from, and the build shows it in place of the PNG so, since no `.gitattributes` rule can tell it from a diagram's SVG. An SVG icon embedded as text and the annotation layer's XML hold line breaks; so each is written as the space an XML parser makes of a break inside a tag, or ` ` in text, the same character. A checkout then - changes only the last line ending, which `keepSvg` ignores when it compares. + changes only the last line ending, which `writeSvg` (`scripts/lib/shot-apply.mjs`) ignores when it compares. **Scrollbars.** The IDE styles every scrollbar with `::-webkit-scrollbar` rules (`ide/styles.css`, coloured by theme variables), a second family under `.modalDialogContainer`. diff --git a/scripts/lib/shot-apply.mjs b/scripts/lib/shot-apply.mjs new file mode 100644 index 000000000..7144e94de --- /dev/null +++ b/scripts/lib/shot-apply.mjs @@ -0,0 +1,107 @@ +// Writing what was decided about a picture and its SVG (shot-policy.mjs) into the folder the +// pictures are in: the one place shoot_docs.mjs, its --cleanup, replay.mjs and the screenshot +// review tool write a picture, an SVG or a bundle from, so that each writes the same files +// for the same verdict. Paths are under `outRoot` with forward slashes, as a shot's `out`. + +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { dropBundle } from "../svgshot/bundle.mjs"; +import { restoreFromRevision, svgFate } from "./shot-revision.mjs"; + +/** The SVG beside the picture `out`. */ +export const svgOf = (out) => out.replace(/\.png$/i, ".svg"); + +/** + * Writes `svg` to `file` unless it holds it already: "new", "updated" or + * "unchanged". The file may have been checked out with CRLF line endings, on its + * last line alone (the SVG is on one line, oneLine in snapshot-svg.mjs). + */ +export function writeSvg(file, svg) { + let state = "new"; + if (existsSync(file)) state = readFileSync(file, "utf8").replace(/\r\n/g, "\n") === svg ? "unchanged" : "updated"; + if (state !== "unchanged") writeFileSync(file, svg); + return state; +} + +/** + * Puts the pictures `outs` back as `revision` (openRevision) has them, each with its SVG: + * the revision's SVG goes in its place, or the file's is removed when the revision has none + * (svgFate), so that the pair stays matched. The SVGs are written by git, as a checkout + * writes them (line endings), in one process. With `dryRun` nothing is written. Returns a + * Map from each picture to {fate, atRevision}: svgFate's answer, and whether the revision + * has an SVG. Throws when the revision lacks a picture or git cannot write the SVGs. + */ +export function restorePictures(outRoot, revision, outs, { dryRun = false } = {}) { + const pngs = revision.read(outs); + const svgs = revision.read(outs.map(svgOf)); + const fates = new Map(); + const writes = []; + for (const out of outs) { + const svgOut = svgOf(out); + const svgFile = path.join(outRoot, svgOut); + const atRevision = svgs.get(svgOut); + const fate = svgFate(atRevision, existsSync(svgFile) ? readFileSync(svgFile) : null); + fates.set(out, { fate, atRevision: !!atRevision }); + if (dryRun) continue; + const png = pngs.get(out); + if (!png) throw new Error(`${revision.short} has no ${out}`); + const file = path.join(outRoot, out); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, png); + if (fate === "write") writes.push(svgOut); + if (fate === "remove") rmSync(svgFile); + } + try { + restoreFromRevision(outRoot, revision.hash, writes); + } catch (e) { + throw new Error(`git could not put the SVGs back: ${e.message}`); + } + return fates; +} + +/** + * Writes the verdict on a capture of the picture `out` (decidePicture): the bytes the file is + * to hold, if any; for "dark", the light file removed; for "restored", the picture and its SVG + * put back as `revision` has them (restorePictures). Returns {removed, svg}: whether a file + * was removed, and for "restored" restorePictures' answer for the picture, else null. + */ +export function applyPicture(outRoot, out, decision, revision) { + const file = path.join(outRoot, out); + if (decision.state === "dark") { + const had = existsSync(file); + if (had) rmSync(file); + return { removed: had, svg: null }; + } + if (decision.state === "restored") return { removed: false, svg: restorePictures(outRoot, revision, [out]).get(out) }; + if (decision.write) { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, decision.write); + } + return { removed: false, svg: null }; +} + +/** + * Writes the SVG of a picture (`svgOut` its path) when it was found faithful, or removes the + * one there when it was not (`svg` null): writeSvg's state, else "removed" or "not written". + */ +export function applySvg(outRoot, svgOut, svg) { + const file = path.join(outRoot, svgOut); + if (svg !== null) return writeSvg(file, svg); + if (!existsSync(file)) return "not written"; + rmSync(file); + return "removed"; +} + +/** + * Removes the picture `out`, its SVG and, when there is a bundle cache (`bundlesRoot`), its + * bundle. Returns whether the picture's file was there. + */ +export function dropPicture(outRoot, out, bundlesRoot) { + const svg = path.join(outRoot, svgOf(out)); + if (existsSync(svg)) rmSync(svg); + if (bundlesRoot) dropBundle(bundlesRoot, out); + const file = path.join(outRoot, out); + if (!existsSync(file)) return false; + rmSync(file); + return true; +} diff --git a/scripts/lib/shot-policy.mjs b/scripts/lib/shot-policy.mjs new file mode 100644 index 000000000..31b4de962 --- /dev/null +++ b/scripts/lib/shot-policy.mjs @@ -0,0 +1,56 @@ +// What becomes of a picture and of its SVG: the decisions shoot_docs.mjs, replay.mjs and the +// screenshot review tool make the same way, as pure functions. They read nothing and write +// nothing: the caller passes in the files and the noise rule, and acts on the verdict +// through shot-apply.mjs. + +import { pictureFate } from "./shot-revision.mjs"; + +// How far, in device pixels, a light picture's width or height may be from the dark one's. +// The Light theme lays the window out a little differently (its toolbar is 4 CSS pixels +// shorter, a panel's title bar 3, some buttons narrower), so a clip worked out from the page's +// elements comes out up to a few pixels off, and the page shows each picture at its own size +// (builder/theme-pictures.mjs). A larger difference is not the theme's: most likely the IDE +// was not in the state the dark picture shows. +export const SIZE_SLACK = 32; + +// The most an SVG may differ from its PNG and still be written: the share of +// its pixels (percent) more than 96 grey levels off the PNG's, a shift of one +// pixel allowed. Past it something is drawn wrongly or not at all -- a part +// the converter does not draw, the help pane's page from another origin -- +// and the page goes on showing the PNG. +export const SVG_FAITHFUL = 0.25; + +/** A PNG's width and height, from its header: {w, h}. */ +export const pngSize = (png) => ({ w: png.readUInt32BE(16), h: png.readUInt32BE(20) }); + +/** + * What becomes of a new capture `png` (PNG bytes) of the picture in `theme`. `dark` is the + * dark picture (this run's, or the file's) when `theme` is "light", else null; `revision`, + * `disk` and `near(a, b)` are pictureFate's. A light picture is compared with the dark one + * first. Returns {state, write, size, dark}, `size` the capture's {w, h} and `dark` the dark + * picture's when it is another size, else null: + * + * too-far a light picture more than SIZE_SLACK off the dark one's size: a failure + * dark a light picture with the dark one's pixels, but for a capture's noise: no + * light file is kept, and one there is removed (the page shows X.png in both) + * restored, unchanged, noise, updated, new + * as pictureFate says, `write` the bytes the file is to hold or null + */ +export function decidePicture({ png, theme, dark, revision, disk, near }) { + const size = pngSize(png); + let other = null; + if (theme === "light" && dark) { + const d = pngSize(dark); + if (Math.abs(d.w - size.w) > SIZE_SLACK || Math.abs(d.h - size.h) > SIZE_SLACK) + return { state: "too-far", write: null, size, dark: d }; + if (d.w !== size.w || d.h !== size.h) other = d; + if (near(dark, png)) return { state: "dark", write: null, size, dark: other }; + } + return { ...pictureFate(png, revision, disk, near), size, dark: other }; +} + +/** + * Whether an SVG is close enough to its PNG to be written, from the shares diffSvg reports + * (`stats.pct96`, percent): {faithful}. `bar` is the share it must stay below. + */ +export const decideSvg = (stats, { bar = SVG_FAITHFUL } = {}) => ({ faithful: stats.pct96 < bar }); diff --git a/scripts/shoot_docs.mjs b/scripts/shoot_docs.mjs index 3d4488db8..c57c26dd1 100644 --- a/scripts/shoot_docs.mjs +++ b/scripts/shoot_docs.mjs @@ -164,14 +164,9 @@ import { composite, uncomposite } from "./lib/shot-composite.mjs"; import { decodePng, encodePng } from "./lib/png.mjs"; import { iconGroup, iconImage, manifestResource, resFile } from "./lib/res-file.mjs"; import { inRects, withinNoise } from "./lib/shot-change.mjs"; -import { - changedPictures, - openRevision, - pictureFate, - planCleanup, - restoreFromRevision, - svgFate, -} from "./lib/shot-revision.mjs"; +import { changedPictures, openRevision, planCleanup } from "./lib/shot-revision.mjs"; +import { applyPicture, applySvg, dropPicture, restorePictures, svgOf } from "./lib/shot-apply.mjs"; +import { decidePicture, SVG_FAITHFUL } from "./lib/shot-policy.mjs"; import { diffPicture } from "./lib/shot-diff.mjs"; import { attach } from "./lib/tb-cdp.mjs"; import { consoleMark, linesSince } from "./lib/tb-ide-console.mjs"; @@ -191,10 +186,10 @@ import { sleep, waitForCompile, } from "./lib/tb-ide.mjs"; -import { dropBundle, writeBundle } from "./svgshot/bundle.mjs"; +import { writeBundle } from "./svgshot/bundle.mjs"; import { svgOfPage } from "./svgshot/capture.mjs"; import { useSubsetCache } from "./svgshot/fonts.mjs"; -import { holdsUserName, judgeSvg, SVG_FAITHFUL, writeSvg } from "./svgshot/keep.mjs"; +import { holdsUserName, judgeSvg } from "./svgshot/keep.mjs"; import { stageProject, unpackProject } from "./lib/tb-project.mjs"; import { holdLlvmCache } from "./lib/tb-llvm-cache.mjs"; import { guard, presentKeys, press, serverRequests } from "../test/ide/building-kit.mjs"; @@ -460,33 +455,21 @@ if (values.cleanup) { (out) => readFileSync(path.join(outRoot, out)), (a, b) => withinNoise(decoded(a, AGAINST), decoded(b, "the working tree")), ); - const svgOf = (out) => out.replace(/\.png$/i, ".svg"); - const svgBefore = revision.read(plan.reset.map(svgOf)); const verb = values.dryRun ? "would be put back" : "put back"; - // An SVG is text, written as a checkout writes it (line endings), by git, all in one process. - const svgWrites = []; + let fates; + try { + fates = restorePictures(outRoot, revision, plan.reset, { dryRun: values.dryRun }); + } catch (e) { + die(2, e.message); + } for (const out of plan.reset) { - const svgFile = path.join(outRoot, svgOf(out)); - const svgAtRevision = svgBefore.get(svgOf(out)); - const svgNow = existsSync(svgFile) ? readFileSync(svgFile) : null; - const svg = svgFate(svgAtRevision, svgNow); - if (!values.dryRun) { - writeFileSync(path.join(outRoot, out), before.get(out)); - if (svg === "write") svgWrites.push(svgOf(out)); - if (svg === "remove") rmSync(svgFile); - } const svgText = { keep: "", write: ", and its SVG", remove: `; its SVG ${values.dryRun ? "would be removed" : "removed"}, as ${found.short} has none`, - }[svg]; + }[fates.get(out).fate]; console.log(`${out}: ${verb} as ${found.short} has it${svgText}`); } - try { - restoreFromRevision(outRoot, found.hash, svgWrites); - } catch (e) { - die(2, `git could not put the SVGs back: ${e.message}`); - } for (const { out, error } of plan.unreadable) console.error(`${out}: not compared: ${error}`); const n = (list, one, many = `${one}s`) => `${list.length} ${list.length === 1 ? one : many}`; console.log( @@ -13292,62 +13275,49 @@ const tally = { dark: {}, light: {} }; const count = (theme, state) => { tally[theme][state] = (tally[theme][state] ?? 0) + 1; }; -const pngSize = (png) => ({ w: png.readUInt32BE(16), h: png.readUInt32BE(20) }); - -// How far, in device pixels, a light picture's width or height may be from the dark one's. -// The Light theme lays the window out a little differently (its toolbar is 4 CSS pixels -// shorter, a panel's title bar 3, some buttons narrower), so a clip worked out from the page's -// elements comes out up to a few pixels off, and the page shows each picture at its own size -// (builder/theme-pictures.mjs). A larger difference is not the theme's: most likely the IDE -// was not in the state the dark picture shows. -const SIZE_SLACK = 32; - // Writes a picture when its bytes differ from the file's; says which. `out` is the dark -// picture's path. A light picture is compared with the dark one (`dark`, this run's, or the -// file's): one with the same pixels, but for a capture's noise (`nearly`), is not kept, and a -// light file there already is removed, since the page shows X.png in both themes then; one -// more than SIZE_SLACK off its size fails. A picture within a capture's noise of the revision's -// (--against) leaves the file as the revision has it, with its SVG (pictureFate, -// lib/shot-revision.mjs); one within a capture's noise of its file is not written either. -// Returns whether the picture was written, "restored" for one put back as the revision has -// it, or null for a light picture that is the dark one. +// picture's path. What becomes of it is decidePicture's (lib/shot-policy.mjs): a light picture +// is compared with the dark one (`dark`, this run's, or the file's), and one with the same +// pixels, but for a capture's noise (`nearly`), is not kept, and a light file there already is +// removed, since the page shows X.png in both themes then; one more than SIZE_SLACK off its +// size fails. A picture within a capture's noise of the revision's (--against) leaves the file +// as the revision has it, with its SVG (pictureFate, lib/shot-revision.mjs); one within a +// capture's noise of its file is not written either. The verdict is written by applyPicture +// (lib/shot-apply.mjs). Returns whether the picture was written, "restored" for one put back +// as the revision has it, or null for a light picture that is the dark one. function keep(name, out, png, theme, dark, scale = SCALE) { - const file = path.join(outRoot, themedOut(out, theme)); - const { w, h } = pngSize(png); - let other = ""; - if (theme === "light" && dark) { - const d = pngSize(dark); - if (Math.abs(d.w - w) > SIZE_SLACK || Math.abs(d.h - h) > SIZE_SLACK) { - writeDiff(name, themedOut(out, theme), dark, png, { - suffix: ".size", - labels: { before: "DARK", after: "LIGHT" }, - }); - throw new Error( - `the light picture is ${w}x${h} px and the dark one ${d.w}x${d.h} px: more than the theme's layout moves`, - ); - } - if (d.w !== w || d.h !== h) { - count(theme, "of another size than the dark picture"); - other = `; the dark picture is ${d.w}x${d.h} px`; - } - if (nearly(dark, png)) { - const had = existsSync(file); - if (had) rmSync(file); - count(theme, "the same as the dark picture"); - say( - name, - `${themedOut(out, theme)}: the dark picture's pixels, but for a capture's noise: ${had ? "removed" : "not written"}`, - ); - return null; - } - } - mkdirSync(path.dirname(file), { recursive: true }); + const themed = themedOut(out, theme); + const file = path.join(outRoot, themed); // The picture as the revision has it comes first (--against): a capture within a capture's // noise of it puts the file back as the revision has it, so that a picture does not drift // from the repository's over several runs, each within noise of the file before it. const was = existsSync(file) ? readFileSync(file) : null; - const atRevision = revision ? revision.bytes(themedOut(out, theme)) : null; - const fate = pictureFate(png, atRevision, was, nearly); + const atRevision = revision ? revision.bytes(themed) : null; + const fate = decidePicture({ png, theme, dark, revision: atRevision, disk: was, near: nearly }); + const { w, h } = fate.size; + if (fate.state === "too-far") { + writeDiff(name, themed, dark, png, { + suffix: ".size", + labels: { before: "DARK", after: "LIGHT" }, + }); + throw new Error( + `the light picture is ${w}x${h} px and the dark one ${fate.dark.w}x${fate.dark.h} px: more than the theme's layout moves`, + ); + } + let other = ""; + if (fate.dark) { + count(theme, "of another size than the dark picture"); + other = `; the dark picture is ${fate.dark.w}x${fate.dark.h} px`; + } + if (fate.state === "dark") { + const { removed } = applyPicture(outRoot, themed, fate, revision); + count(theme, "the same as the dark picture"); + say( + name, + `${themed}: the dark picture's pixels, but for a capture's noise: ${removed ? "removed" : "not written"}`, + ); + return null; + } // a run now and then settles on the other version of a capture's noise for every // capture of a state (WIP.Screenshots.md, Capture): the file then stays as it is const STATES = { @@ -13358,53 +13328,38 @@ function keep(name, out, png, theme, dark, scale = SCALE) { restored: `restored as ${AGAINST} has it`, }; const state = STATES[fate.state]; - if (fate.state === "noise") writeDiff(name, themedOut(out, theme), was ?? atRevision, png, { suffix: ".noise" }); - if (fate.state === "updated") writeDiff(name, themedOut(out, theme), was, png); + if (fate.state === "noise") writeDiff(name, themed, was ?? atRevision, png, { suffix: ".noise" }); + if (fate.state === "updated") writeDiff(name, themed, was, png); if (fate.state === "restored" && was) - writeDiff(name, themedOut(out, theme), was, atRevision, { + writeDiff(name, themed, was, atRevision, { suffix: ".restored", labels: { before: "FILE", after: AGAINST.toUpperCase() }, }); - if (fate.write) writeFileSync(file, fate.write); + // A picture put back as the revision has it takes the revision's SVG with it, or loses its + // SVG when the revision has none, so that the pair stays matched. + const applied = applyPicture(outRoot, themed, fate, revision); count(theme, state); say( name, - `${themedOut(out, theme)}: ${state} (${w}x${h} px, ${png.length} bytes; shown at {:width="${Math.round(w / scale)}" height="${Math.round(h / scale)}"}${other})`, + `${themed}: ${state} (${w}x${h} px, ${png.length} bytes; shown at {:width="${Math.round(w / scale)}" height="${Math.round(h / scale)}"}${other})`, ); if (fate.state === "restored") { - restoreSvg(name, themedOut(out, theme)); + const text = { + keep: `as ${AGAINST} has it`, + write: `restored as ${AGAINST} has it`, + remove: `removed, as ${AGAINST} has none`, + }; + const { fate: svg, atRevision: svgAtRevision } = applied.svg; + if (svg !== "keep" || svgAtRevision) say(name, `${svgOf(themed)}: ${text[svg]}`); return "restored"; } return fate.write !== null; } -// A picture put back as the revision has it takes the revision's SVG with it, or loses its -// SVG when the revision has none, so that the pair stays matched. -function restoreSvg(name, out) { - const svgOut = out.replace(/\.png$/i, ".svg"); - const file = path.join(outRoot, svgOut); - const atRevision = revision.bytes(svgOut); - const fate = svgFate(atRevision, existsSync(file) ? readFileSync(file) : null); - // as a checkout writes it (line endings), by git - if (fate === "write") restoreFromRevision(outRoot, revision.hash, [svgOut]); - if (fate === "remove") rmSync(file); - const text = { - keep: `as ${AGAINST} has it`, - write: `restored as ${AGAINST} has it`, - remove: `removed, as ${AGAINST} has none`, - }; - if (fate !== "keep" || atRevision) say(name, `${svgOut}: ${text[fate]}`); -} - // A `once` picture has no light file: one left from before is removed, and its SVG. function dropLight(name, out) { - const svg = path.join(outRoot, themedOut(out, "light").replace(/\.png$/i, ".svg")); - if (existsSync(svg)) rmSync(svg); - if (bundlesRoot) dropBundle(bundlesRoot, themedOut(out, "light")); - const file = path.join(outRoot, themedOut(out, "light")); - if (!existsSync(file)) return; - rmSync(file); - say(name, `${themedOut(out, "light")}: removed (the picture is taken once, in no theme)`); + if (dropPicture(outRoot, themedOut(out, "light"), bundlesRoot)) + say(name, `${themedOut(out, "light")}: removed (the picture is taken once, in no theme)`); } // With --svg: one headless browser for the whole run, which renders each SVG to @@ -13419,7 +13374,7 @@ const svgResults = []; // picture's bundle goes to the bundle cache whether or not its SVG is written, // so that replay.mjs can draw it again once the converter draws it better. async function keepSvg(name, out, png, picture, written) { - const svgOut = out.replace(/\.png$/i, ".svg"); + const svgOut = svgOf(out); const file = path.join(outRoot, svgOut); // An SVG is drawn from the whole page, layers the picture does not show included, and those // can differ from run to run (a designer still drawing under a floating panel); and one that @@ -13430,11 +13385,7 @@ async function keepSvg(name, out, png, picture, written) { say(name, `${svgOut}: unchanged, as its PNG is`); return; } - const drop = (why) => { - const had = existsSync(file); - if (had) rmSync(file); - say(name, `${svgOut}: ${had ? "removed" : "not written"}, the PNG is kept (${why})`); - }; + const drop = (why) => say(name, `${svgOut}: ${applySvg(outRoot, svgOut, null)}, the PNG is kept (${why})`); if (!picture) { drop("the picture is not a single capture"); return; @@ -13463,7 +13414,7 @@ async function keepSvg(name, out, png, picture, written) { writeFileSync(path.join(diffsRoot, `${out.replace(/[\\/]/g, "__").replace(/\.png$/i, "")}.svg`), svg); return; } - const state = writeSvg(file, svg); + const state = applySvg(outRoot, svgOut, svg); say( name, `${svgOut}: ${state} (${(bytes / 1024).toFixed(0)} KB against the PNG's ${(png.length / 1024).toFixed(0)} KB; ${measures})`, @@ -13584,12 +13535,8 @@ async function runJob(job) { if (shot.once) dropLight(job.name, shot.out); // A light picture that is the dark one is not kept, and neither is its SVG or bundle. // One put back as the revision has it has had its SVG put back with it. - const svgFile = path.join(outRoot, out.replace(/\.png$/i, ".svg")); if (values.svg && kept !== null && kept !== "restored") await keepSvg(job.name, out, png, state.svg, kept); - else if (values.svg && kept === null) { - if (existsSync(svgFile)) rmSync(svgFile); - dropBundle(bundlesRoot, out); - } + else if (values.svg && kept === null) dropPicture(outRoot, out, bundlesRoot); } catch (e) { complain(job.name, `${out}: FAILED: ${e.message}`); count(theme, "failed"); diff --git a/scripts/svgshot/keep.mjs b/scripts/svgshot/keep.mjs index 5a32cd241..bad2af74b 100644 --- a/scripts/svgshot/keep.mjs +++ b/scripts/svgshot/keep.mjs @@ -1,16 +1,11 @@ -// Whether a picture's SVG is written beside its PNG, shared by shoot_docs.mjs --svg, -// which draws it from the live page, and replay.mjs, which draws it from its bundle. +// How far a picture's SVG is from its PNG, shared by shoot_docs.mjs --svg, which draws it +// from the live page, and replay.mjs, which draws it from its bundle. Whether that is close +// enough is decideSvg's (lib/shot-policy.mjs), and the SVG is written by applySvg +// (lib/shot-apply.mjs). -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { decideSvg } from "../lib/shot-policy.mjs"; import { diffSvg, svgClip } from "./diff.mjs"; -// The most an SVG may differ from its PNG and still be written: the share of -// its pixels (percent) more than 96 grey levels off the PNG's, a shift of one -// pixel allowed. Past it something is drawn wrongly or not at all -- a part -// the converter does not draw, the help pane's page from another origin -- -// and the page goes on showing the PNG. -export const SVG_FAITHFUL = 0.25; - /** * Whether `svg` holds `user` (the Windows user name), ignoring case. The search * skips base64 payloads (fonts, images), in which four given letters turn up by @@ -24,8 +19,8 @@ export const holdsUserName = (svg, user) => /** * `svg` against the PNG it was taken with, in a page of `browser`: - * {diff, faithful, measures}, `measures` the line that says how far off it is - * and what the converter did not draw (`stats.unsupported`). + * {diff, faithful, measures}, `faithful` decideSvg's verdict and `measures` the line + * that says how far off it is and what the converter did not draw (`stats.unsupported`). */ export async function judgeSvg(browser, svg, png, stats) { const page = await browser.newPage(); @@ -40,17 +35,5 @@ export async function judgeSvg(browser, svg, png, stats) { const measures = `${diff.stats.pct32}% of pixels differ, ${diff.stats.pct96}% strongly` + `${unsupported.length ? `; not drawn: ${unsupported.join(", ")}` : ""}`; - return { diff, faithful: diff.stats.pct96 < SVG_FAITHFUL, measures }; -} - -/** - * Writes `svg` to `file` unless it holds it already: "new", "updated" or - * "unchanged". The file may have been checked out with CRLF line endings, on its - * last line alone (the SVG is on one line, oneLine in snapshot-svg.mjs). - */ -export function writeSvg(file, svg) { - let state = "new"; - if (existsSync(file)) state = readFileSync(file, "utf8").replace(/\r\n/g, "\n") === svg ? "unchanged" : "updated"; - if (state !== "unchanged") writeFileSync(file, svg); - return state; + return { diff, faithful: decideSvg(diff.stats).faithful, measures }; } diff --git a/scripts/svgshot/redraw.mjs b/scripts/svgshot/redraw.mjs index 22e158d6a..22484b737 100644 --- a/scripts/svgshot/redraw.mjs +++ b/scripts/svgshot/redraw.mjs @@ -1,7 +1,7 @@ // What the tools that draw a picture's SVG again from its bundle have in common: // replay.mjs, which writes the SVGs beside their PNGs, and sheet.mjs, which writes a // sheet of them to look at. Finding the pictures and their bundles, drawing one again -// and judging it (keep.mjs's bar), writing what the judge made into a folder, running +// and judging it (keep.mjs, against lib/shot-policy.mjs's bar), writing what the judge made into a folder, running // the pictures in processes of their own, and the sheet's HTML. And what sheet.mjs --against // uses to show the pictures that differ from a git revision (the list of them is // changedPictures, in scripts/lib/shot-revision.mjs): the comparison of each picture diff --git a/scripts/svgshot/replay.mjs b/scripts/svgshot/replay.mjs index 9962315a8..591256542 100644 --- a/scripts/svgshot/replay.mjs +++ b/scripts/svgshot/replay.mjs @@ -1,7 +1,8 @@ // Draws each picture's SVG again from its bundle (bundle.mjs), the converter as it // is now, without taking the picture again: what to run after a change to the // converter, in place of shoot_docs.mjs --svg. Each SVG is judged against its PNG -// and written or removed as shoot_docs.mjs does (keep.mjs). +// and written or removed as shoot_docs.mjs does (keep.mjs, lib/shot-policy.mjs's decideSvg, +// lib/shot-apply.mjs's applySvg). // // A bundle answers only what the converter asked when the picture was taken. A // picture is left as it is, and listed as needing a retake, when its PNG is no @@ -12,14 +13,16 @@ // has no answer for (a miss the capture did not have). The list ends with the // shoot_docs.mjs command that takes them again. -import { existsSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { userInfo } from "node:os"; import path from "node:path"; import { exitOnCrash, numberOption, parseCli, printHelpAndExit, regexOption, withUsageError } from "../../lib/cli.mjs"; import { REPO_ROOT } from "../../lib/repo-paths.mjs"; import { listBundles } from "./bundle.mjs"; import { useSubsetCache } from "./fonts.mjs"; -import { judgeSvg, SVG_FAITHFUL, writeSvg } from "./keep.mjs"; +import { applySvg, svgOf } from "../lib/shot-apply.mjs"; +import { SVG_FAITHFUL } from "../lib/shot-policy.mjs"; +import { judgeSvg } from "./keep.mjs"; import { defaultJobs, inBrowser, @@ -115,7 +118,7 @@ const retakes = []; let failed = 0; const say = (out, line) => console.log(`${out.replace(/\.png$/i, ".svg")}: ${line}`); async function replay(out, browser) { - const svgFile = path.join(outRoot, out).replace(/\.png$/i, ".svg"); + const svgFile = path.join(outRoot, svgOf(out)); const drawn = await redraw({ bundlesRoot, docsRoot: outRoot, out, user: USER }); if (drawn.retake) { const why = retakeText(drawn); @@ -134,15 +137,8 @@ async function replay(out, browser) { } const { diff, faithful, measures } = await judgeSvg(browser, svg, png, stats); if (diffsRoot) writeJudged(diffsRoot, out, diff); - let state; - if (!faithful) { - state = existsSync(svgFile) ? "removed" : "not written"; - if (state === "removed") rmSync(svgFile); - say(out, `${state}, the PNG is kept (${measures})`); - } else { - state = writeSvg(svgFile, svg); - say(out, `${state} (${measures})`); - } + const state = applySvg(outRoot, svgOf(out), faithful ? svg : null); + say(out, faithful ? `${state} (${measures})` : `${state}, the PNG is kept (${measures})`); counts[state] = (counts[state] ?? 0) + 1; } diff --git a/scripts/svgshot/sheet.mjs b/scripts/svgshot/sheet.mjs index bd6ed8e0d..dbf48e126 100644 --- a/scripts/svgshot/sheet.mjs +++ b/scripts/svgshot/sheet.mjs @@ -1,6 +1,6 @@ // Writes a sheet to look at: an HTML page of the pictures under docs/ that have no SVG // beside them, each drawn again from its bundle with the converter as it is now and -// judged against its PNG as replay.mjs and shoot_docs.mjs --svg judge it (keep.mjs's +// judged against its PNG as replay.mjs and shoot_docs.mjs --svg judge it (lib/shot-policy.mjs's // bar), worst first, with the PNG, the SVG's render and the difference map side by // side; then the pictures with no bundle, as thumbnails. With --all, every picture. // @@ -31,7 +31,8 @@ import { DOCS_DIR, REPO_ROOT } from "../../lib/repo-paths.mjs"; import { changedPictures, readRevisionFiles } from "../lib/shot-revision.mjs"; import { listBundles } from "./bundle.mjs"; import { useSubsetCache } from "./fonts.mjs"; -import { judgeSvg, SVG_FAITHFUL } from "./keep.mjs"; +import { SVG_FAITHFUL } from "../lib/shot-policy.mjs"; +import { judgeSvg } from "./keep.mjs"; import { changeFiles, changeSheetHtml, diff --git a/test/svgshot.test.mjs b/test/svgshot.test.mjs index b357837c2..2cfd04eda 100644 --- a/test/svgshot.test.mjs +++ b/test/svgshot.test.mjs @@ -20,7 +20,9 @@ // is refused; and the sheet's page. And the sheet of changes against a git revision // (sheet.mjs --against): git's change list, the comparison of two pictures over two sizes, // the noise rule, the order and the page. And lib/record-lines.mjs, the records a child -// process writes among its lines, which runShards reads. +// process writes among its lines, which runShards reads. And what becomes of a picture and +// its SVG: lib/shot-policy.mjs's decisions, and lib/shot-apply.mjs's writing of them, in a +// scratch folder and a scratch repository. // // Runs with a bare `node --test test/svgshot.test.mjs`: no tree, no build. @@ -47,6 +49,8 @@ import { import { SNAPSHOT_STYLES, webFontFiles } from "../scripts/svgshot/capture.mjs"; import { decodePng, encodePng } from "../scripts/lib/png.mjs"; import { parseRecord, readRecords, recordLine } from "../scripts/lib/record-lines.mjs"; +import { applyPicture, applySvg, dropPicture, restorePictures } from "../scripts/lib/shot-apply.mjs"; +import { decidePicture, decideSvg, SIZE_SLACK, SVG_FAITHFUL } from "../scripts/lib/shot-policy.mjs"; import { changeMap, compareImages, withinNoise } from "../scripts/lib/shot-change.mjs"; import { changedPictures, @@ -1899,3 +1903,141 @@ describe("the records a child writes among its lines (record-lines.mjs)", () => assert.deepEqual(records, [["k", { n: 1 }]]); }); }); + +describe("what becomes of a picture and its SVG (shot-policy.mjs, shot-apply.mjs)", () => { + // A PNG's header with its size, and a tag that tells two of one size apart. + const shot = (w, h, tag) => { + const b = Buffer.alloc(24); + b.writeUInt32BE(w, 16); + b.writeUInt32BE(h, 20); + return Buffer.concat([b, Buffer.from(tag)]); + }; + const DARK = shot(100, 50, "dark"); + const NEAR = shot(100, 50, "near"); // within noise of DARK + const SHIFTED = shot(103, 50, "shifted"); // within noise of DARK, a few pixels wider + const OTHER = shot(100, 50, "other"); // near nothing + const isNear = (x) => x.equals(NEAR) || x.equals(SHIFTED); + const near = (a, b) => a.equals(b) || (a.equals(DARK) && isNear(b)) || (b.equals(DARK) && isNear(a)); + const decide = (png, theme, dark, revision = null, disk = null) => + decidePicture({ png, theme, dark, revision, disk, near }); + + test("a dark picture is pictureFate's, with its size", () => { + assert.deepEqual(decide(OTHER, "dark", null, DARK, DARK), { + state: "updated", + write: OTHER, + size: { w: 100, h: 50 }, + dark: null, + }); + assert.deepEqual(decide(NEAR, "dark", null, DARK, OTHER).state, "restored"); + }); + + test("a light picture is compared with the dark one first", () => { + // the dark one's pixels: no light file + assert.deepEqual(decide(NEAR, "light", DARK, null, OTHER), { + state: "dark", + write: null, + size: { w: 100, h: 50 }, + dark: null, + }); + // of another size, within the theme's slack: named, then judged + const wider = decide(SHIFTED, "light", DARK); + assert.equal(wider.state, "dark"); + assert.deepEqual(wider.dark, { w: 100, h: 50 }); + assert.deepEqual(decide(shot(103, 50, "x"), "light", DARK).state, "new"); + // beyond it: a failure, whatever the files hold + const far = decide(shot(100 + SIZE_SLACK + 1, 50, "x"), "light", DARK, DARK, DARK); + assert.deepEqual([far.state, far.write, far.dark], ["too-far", null, { w: 100, h: 50 }]); + assert.equal(decide(shot(100 + SIZE_SLACK, 50, "x"), "light", DARK).state, "new"); + // with no dark picture, as a dark one + assert.equal(decide(NEAR, "light", null, null, DARK).state, "noise"); + }); + + test("an SVG is faithful below the bar, the default one or another", () => { + assert.equal(decideSvg({ pct96: SVG_FAITHFUL - 0.001 }).faithful, true); + assert.equal(decideSvg({ pct96: SVG_FAITHFUL }).faithful, false); + assert.equal(decideSvg({ pct96: 0.3 }, { bar: 0.35 }).faithful, true); + }); + + test("writes a verdict into the folder, and removes what is not kept", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "shot-apply-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const at = (out) => path.join(root, out); + // a new picture in a folder not there yet; a picture within noise writes nothing + assert.deepEqual(applyPicture(root, "a/X.png", { state: "new", write: OTHER }, null), { + removed: false, + svg: null, + }); + assert.ok(fs.readFileSync(at("a/X.png")).equals(OTHER)); + applyPicture(root, "a/X.png", { state: "noise", write: null }, null); + assert.ok(fs.readFileSync(at("a/X.png")).equals(OTHER)); + // a light picture that is the dark one: its file removed, if there + fs.writeFileSync(at("a/X.light.png"), NEAR); + assert.equal(applyPicture(root, "a/X.light.png", { state: "dark", write: null }, null).removed, true); + assert.equal(fs.existsSync(at("a/X.light.png")), false); + assert.equal(applyPicture(root, "a/X.light.png", { state: "dark", write: null }, null).removed, false); + // an SVG: written, the same but for a checkout's last line ending, changed, then dropped + assert.equal(applySvg(root, "a/X.svg", ""), "new"); + fs.writeFileSync(at("a/X.svg"), "\r\n"); + assert.equal(applySvg(root, "a/X.svg", "\n"), "unchanged"); + assert.equal(applySvg(root, "a/X.svg", "x"), "updated"); + assert.equal(applySvg(root, "a/X.svg", null), "removed"); + assert.equal(applySvg(root, "a/X.svg", null), "not written"); + // a picture dropped: its PNG, its SVG and its bundle, the other theme's bundle kept + const bundles = path.join(root, "bundles"); + writeBundle(bundles, "a/X.png", { v: 1 }, OTHER, []); + writeBundle(bundles, "a/X.light.png", { v: 2 }, OTHER, []); + fs.writeFileSync(at("a/X.light.png"), OTHER); + fs.writeFileSync(at("a/X.light.svg"), ""); + assert.equal(dropPicture(root, "a/X.light.png", bundles), true); + assert.equal(fs.existsSync(at("a/X.light.png")) || fs.existsSync(at("a/X.light.svg")), false); + assert.equal(readBundle(bundles, "a/X.light.png"), null); + assert.equal(readBundle(bundles, "a/X.png").v, 1); + assert.equal(dropPicture(root, "a/X.light.png", null), false); + }); + + test("puts pictures back as a revision has them, each with its SVG", (t) => { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), "shot-apply-git-")); + t.after(() => fs.rmSync(repo, { recursive: true, force: true })); + const git = (...args) => execFileSync("git", args, { cwd: repo, stdio: ["ignore", "pipe", "ignore"] }); + try { + git("init", "-q"); + } catch { + t.skip("no git"); + return; + } + git("config", "core.autocrlf", "false"); + const docs = path.join(repo, "docs"); + const at = (out) => path.join(docs, out); + fs.mkdirSync(docs); + // a.png has an SVG at the revision, b.png has none + fs.writeFileSync(at("a.png"), DARK); + fs.writeFileSync(at("a.svg"), "\n"); + fs.writeFileSync(at("b.png"), DARK); + git("add", "."); + git("-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", "commit", "-q", "-m", "x"); + const rev = openRevision(docs, "HEAD"); + // a.png drifted and its SVG changed; b.png drifted and gained an SVG + fs.writeFileSync(at("a.png"), NEAR); + fs.writeFileSync(at("a.svg"), "x\n"); + fs.writeFileSync(at("b.png"), NEAR); + fs.writeFileSync(at("b.svg"), "\n"); + const expected = new Map([ + ["a.png", { fate: "write", atRevision: true }], + ["b.png", { fate: "remove", atRevision: false }], + ]); + // a dry run says what it would do and writes nothing + assert.deepEqual(restorePictures(docs, rev, ["a.png", "b.png"], { dryRun: true }), expected); + assert.ok(fs.readFileSync(at("a.png")).equals(NEAR)); + assert.equal(fs.existsSync(at("b.svg")), true); + assert.deepEqual(restorePictures(docs, rev, ["a.png", "b.png"]), expected); + assert.equal(git("status", "--porcelain").toString(), ""); + // through applyPicture, for a capture it put back + fs.writeFileSync(at("a.png"), NEAR); + assert.deepEqual(applyPicture(docs, "a.png", { state: "restored", write: DARK }, rev), { + removed: false, + svg: { fate: "keep", atRevision: true }, + }); + assert.equal(git("status", "--porcelain").toString(), ""); + assert.throws(() => restorePictures(docs, rev, ["none.png"]), /has no none\.png/); + }); +}); From e814d69e8482672be7012ba2822b4fafbd50ff8c Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Sat, 31 Oct 2026 20:39:26 +0100 Subject: [PATCH 08/85] shoot_docs: closeTabs waits, with a MutationObserver, for a tab's close icon while the tab still shows the IDE's loading spinner (a module just added, as Close_Question's take adds one), instead of editorTabs throwing on the missing icon --- scripts/shoot_docs.mjs | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/scripts/shoot_docs.mjs b/scripts/shoot_docs.mjs index c57c26dd1..e8fd415a2 100644 --- a/scripts/shoot_docs.mjs +++ b/scripts/shoot_docs.mjs @@ -2453,13 +2453,33 @@ const TABS_BUTTON = ".codicon-triangle-down[title='Tabs List']"; const SAMPLE_FILE = (rest) => `/SampleProject/${rest}`; const RESOURCES = ["Resources/MANIFEST/#1.xml", "Resources/STRING/Strings.json", "Resources/MESSAGETABLE/Strings.json"]; -// The tabs of the editor, left to right: name and the boxes of the tab and its close icon. +// The tabs of the editor, left to right: name and the boxes of the tab and its close icon. A tab +// whose file is loading or being saved shows a spinner in place of its close icon (the IDE gives +// the button the class progressSpinnerIcon, and its close icon's classes back once done): its +// `close` is null then. A module just added is such a tab for a moment. const editorTabs = (c) => c.evaluate(`(() => [...document.querySelectorAll(".tabItem")].map((t) => { - const box = (e) => { const r = e.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height }; }; + const box = (e) => e && (({ x, y, width, height }) => ({ x, y, width, height }))(e.getBoundingClientRect()); return { name: t.querySelector(".tabItemText").textContent.trim(), tab: box(t), close: box(t.querySelector(".tabItemIconRight")) }; }).filter((t) => t.tab.width))()`); +// Waits, in the page, for the tab named `name` to show its close icon: a MutationObserver on the +// tab bar settles the moment the IDE gives the button its classes back, whether a load or a save +// cleared the spinner. True once it shows it, or the tab is gone; false after `ms`. +const tabCloseIcon = (c, name, ms = 5000) => + c.evaluate( + `new Promise((resolve) => { + const tab = [...document.querySelectorAll(".tabItem")].find((t) => t.getBoundingClientRect().width && t.querySelector(".tabItemText")?.textContent.trim() === ${JSON.stringify(name)}); + const ready = () => !tab || !tab.isConnected || !!tab.querySelector(".tabItemIconRight"); + if (ready()) return resolve(true); + const done = (value) => { obs.disconnect(); clearTimeout(timer); resolve(value); }; + const obs = new MutationObserver(() => { if (ready()) done(true); }); + obs.observe(tab.parentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ["class"] }); + const timer = setTimeout(() => done(false), ${ms}); +})`, + { awaitPromise: true, timeout: ms + 5000 }, + ); + // Closes the tabs whose names are not in `keep`, each with a real click on its close icon // (a tab that is closed any other way is not in the Tabs List's Recently Closed). With // `discard`, a tab that asks whether to save its changes is closed with Discard Changes: the @@ -2470,6 +2490,12 @@ async function closeTabs(c, keep = [], { discard = false } = {}) { const tabs = await editorTabs(c); const t = tabs.find((x) => !keep.includes(x.name)); if (!t) return; + // a tab still loading or saving has no close icon yet: the tabs are read again once it has + if (!t.close) { + if (!(await tabCloseIcon(c, t.name))) + throw new Error(`the tab ${t.name} showed no close icon in 5 s (still loading or saving)`); + continue; + } await clickAt(c, t.close.x + t.close.width / 2, t.close.y + t.close.height / 2); const closed = async () => (await editorTabs(c)).length < tabs.length; const asked = async () => (await modals(c)).some((m) => m.title === "twinBASIC"); From ae3d6043f86b247f9c47a3d335580c65399f7e4d Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Sat, 31 Oct 2026 20:44:42 +0100 Subject: [PATCH 09/85] WIP.ShotReview.md: step 1's moves done and left --- WIP.ShotReview.md | 72 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 3 deletions(-) diff --git a/WIP.ShotReview.md b/WIP.ShotReview.md index 95192689d..b13e820ee 100644 --- a/WIP.ShotReview.md +++ b/WIP.ShotReview.md @@ -2,8 +2,9 @@ A localhost tool for reviewing what `shoot_docs.mjs` and `replay.mjs` decided about each picture, overriding it, starting retakes and replays, and committing the result. **Design -agreed with the owner on 2026-10-31; nothing is implemented yet.** The mockups it was agreed -on were inline in that session and are not kept: the pages are described below instead. +agreed with the owner on 2026-10-31; step 1 of [the order of work](#order-of-work) is under +way, and the tool itself is not started.** The mockups it was agreed on were inline in that +session and are not kept: the pages are described below instead. The tool covers everything the command-line screenshot tools do, interactively, with a person deciding where a threshold cannot (owner). The command-line tools stay, and both reach the @@ -243,7 +244,8 @@ uses it. `shoot_docs.mjs` cannot run in a gate, so a move is checked by its test Each step ends tested and usable on its own. 1. **The moves**, no behaviour changed: the table's *After* column, with `shoot_docs.mjs`, - `replay.mjs` and `sheet.mjs` calling the shared modules. + `replay.mjs` and `sheet.mjs` calling the shared modules. Mostly done: see + [Where the work stands](#where-the-work-stands). 2. **The manifest**: read and written by one module; `shoot_docs.mjs` and `replay.mjs` judge with its allowances, honour its decisions and record `build`. 3. **The strength map, the regions and the queue's rules**, tested on fixtures. @@ -257,6 +259,70 @@ Each step ends tested and usable on its own. `test.bat` (and so in Tools.md's list and the `run-gates` action), and the row in WIP.md's *Which file to open*. +## Where the work stands + +On `staging` since 2026-10-31 (the commits from `95378e8f` to `c833323a`), step 1 but for the +rows below. Each move kept the command-line tools' output as it was, checked as +[below](#checking-a-move). + +| Row of the factoring table | Where it is now | +|---|---| +| The noise rule | `withinNoise(A, B, {levels, share, rects})`, `scripts/lib/shot-change.mjs` | +| A PNG's verdict | `decidePicture({png, theme, dark, revision, disk, near})` in `scripts/lib/shot-policy.mjs`, with `SIZE_SLACK`: pictureFate's states plus `too-far` and `dark` (a light picture with the dark one's pixels). It does not read the manifest yet (step 2) | +| An SVG's verdict | `decideSvg(stats, {bar})` in `shot-policy.mjs`, with `SVG_FAITHFUL` (moved from `keep.mjs`); `judgeSvg` asks it. It reads `stats.pct96` until the strength map exists (step 3) | +| Writing a verdict | `scripts/lib/shot-apply.mjs`: `applyPicture(outRoot, out, decision, revision)`, `applySvg(outRoot, svgOut, svg or null)`, `restorePictures(outRoot, revision, outs, {dryRun})` (the PNGs and their SVGs, the SVGs by one `git restore`; `--cleanup` and a `restored` picture both use it), `dropPicture(outRoot, out, bundlesRoot)`, `writeSvg`, `svgOf`. What stays in `shoot_docs.mjs`'s `keep` and `keepSvg`: the counts, the lines it prints, the `--diffs` pictures, the bundle written, the user-name check, and the rule that an SVG stays as it is when its PNG was not written | +| The git listing | `changedPictures`, `parseNameStatus`, `parseUntracked` in `scripts/lib/shot-revision.mjs` | +| A child's report | `scripts/lib/record-lines.mjs`: `recordLine`, `writeRecord`, `parseRecord`, `readRecords(stream, kinds, {onRecord, onLine})`; `runShards` reads `svgshot-result` records with it. `--events` is to write `shot-event` records the same way | +| Server-sent events | `createSseChannel()` in `lib/sse.mjs`: `handle(req, res)`, `send(event, data)`, `close()`; `builder/serve.mjs` uses one | +| The installs | `listIdes()` in `scripts/lib/tb-install.mjs`, newest first, `[{build, exe}]` | + +**Next: the views module**, the last move. The sheets' HTML is in `scripts/svgshot/redraw.mjs` +from `SHEET_GENERATOR` to the end: the private pieces `esc`, `anchor`, `sizeText`, `pageHtml` +(the page skeleton both sheets share), `figuresIn`, `meterHtml`, `pctText`, `shellQuote`, and +the two pages `sheetHtml` (the pictures with no SVG) and `changeSheetHtml` (`--against`), +with their row helpers (`sortRows`, `sortChangeRows`, `restoreCommand`, `cleanupCommand`, +`changeFiles`, `compareRevisionPicture`) and the output-folder rules (`outputRefusal`, +`mayWriteSheet`). The pieces go to a module of their own that the tool will import too (a +card, a figure, a meter, the page); `test/svgshot.test.mjs` tests both pages through their +exports, so the tests show whether the move changed anything. The tool's own pages will add +their actions to the same pieces; until then nothing calls them but `sheet.mjs`. + +**Then step 2**, the manifest. The three rows left in the table are new code, made in the +steps that need them: the strength map and `changeRegions` in step 3, the commit helper in +step 8. + +**A small cleanup, in no step:** a PNG's size is read from its header by four copies of one +line (`pngSize` in `shot-policy.mjs` as `{w, h}`, in `redraw.mjs` as `[w, h]` or null, in +`svgshot/probe.mjs` as `{width, height}`, and the test's `PNG` helper writes one); one reader +in `scripts/lib/png.mjs` would replace the first three. + +### Checking a move + +`shoot_docs.mjs` cannot run in a gate. A move that touches what it decides or writes is +checked by its unit tests in `test/svgshot.test.mjs` and by these runs, before the change and +after, in a worktree (they write under `docs/`; `git checkout -- docs` after each). Every +state line must be the same, bytes and percentages aside. The three setups they use +(`no-project`, `projects`, `project`) do not need the built site. + +```sh +node scripts/shoot_docs.mjs --svg --only "Menu_Tools|Close_Question|Menu_File" --jobs 2 +``` + +All `unchanged` (one light picture may be `unchanged but for a capture's noise`). Then, with +`Menu_Tools_IDEOptions` alone (`--only "Menu_Tools_IDEOptions" --jobs 1`): + +- `Menu_File.png` copied over `Menu_Tools_IDEOptions.png` and `Menu_Tools_IDEOptions.light.svg` + deleted: the dark picture `restored as HEAD has it`, its SVG `as HEAD has it`, the light SVG + `new`. +- The same copy, with `--against-disk`: the dark picture `updated` and its SVG `updated`. + +Read the whole log, not lines filtered out of it: an error raised in the page arrives with +the page's own stack on the lines after `FAILED`, and its line and column inside the evaluated +script name the helper that raised it. That is how the `Close_Question` crash was found: a +tab the IDE is still loading or saving shows a spinner in place of its close icon (the button's +class is `progressSpinnerIcon`, `ide/main.js`), and `closeTabs` now waits for the icon with a +MutationObserver (`tabCloseIcon`). + ## Open - **The manifest's path.** Outside `docs/`; beside `shoot_docs.mjs` is one choice. From 089e2542b9d74ca22043d54ddd3a97a677d8086d Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Sat, 31 Oct 2026 20:55:51 +0100 Subject: [PATCH 10/85] lib/shot-views.mjs (pageHtml, cardHtml, figuresIn, meterHtml, tocItem, esc, anchor, sizeText, pctText) and svgshot/sheets.mjs (the two sheets, their rows and the output-folder rules), moved out of redraw.mjs; the pages come out byte for byte the same --- WIP.Screenshots.md | 3 +- WIP.ShotReview.md | 27 +- WIP.md | 4 +- docs/Documentation/Tools-Toolchain-Tests.md | 2 +- scripts/lib/shot-views.mjs | 99 +++++ scripts/svgshot/redraw.mjs | 398 +------------------- scripts/svgshot/sheet.mjs | 16 +- scripts/svgshot/sheets.mjs | 325 ++++++++++++++++ test/svgshot.test.mjs | 27 +- 9 files changed, 466 insertions(+), 435 deletions(-) create mode 100644 scripts/lib/shot-views.mjs create mode 100644 scripts/svgshot/sheets.mjs diff --git a/WIP.Screenshots.md b/WIP.Screenshots.md index 44e5271e7..f6f2158aa 100644 --- a/WIP.Screenshots.md +++ b/WIP.Screenshots.md @@ -974,7 +974,8 @@ the PNG was just captured from, and the build shows it in place of the PNG user name (owner); `outputRefusal` keeps it out of `docs/` and out of any path git does not ignore, and an existing folder must hold an earlier sheet. What it shares with replay is `scripts/svgshot/redraw.mjs` (`redraw`, `writeJudged`, `runShards`/`inBrowser`, the - `judgedFiles` names: `.svg-diff.png` and `.svg` as `--diffs` names them). + `judgedFiles` names: `.svg-diff.png` and `.svg` as `--diffs` names them); its pages are + `scripts/svgshot/sheets.mjs`, made of the pieces in `scripts/lib/shot-views.mjs`. `shoot_docs.mjs --diffs` is to call `sheetHtml` over its diffs folder in a later commit. A sheet of the pictures with no SVG takes seconds; `--all`, over some 550 pictures, about a minute. diff --git a/WIP.ShotReview.md b/WIP.ShotReview.md index b13e820ee..0bce4e667 100644 --- a/WIP.ShotReview.md +++ b/WIP.ShotReview.md @@ -2,8 +2,8 @@ A localhost tool for reviewing what `shoot_docs.mjs` and `replay.mjs` decided about each picture, overriding it, starting retakes and replays, and committing the result. **Design -agreed with the owner on 2026-10-31; step 1 of [the order of work](#order-of-work) is under -way, and the tool itself is not started.** The mockups it was agreed on were inline in that +agreed with the owner on 2026-10-31; step 1 of [the order of work](#order-of-work) is done +but for three rows that later steps make, and the tool itself is not started.** The mockups it was agreed on were inline in that session and are not kept: the pages are described below instead. The tool covers everything the command-line screenshot tools do, interactively, with a person @@ -244,7 +244,7 @@ uses it. `shoot_docs.mjs` cannot run in a gate, so a move is checked by its test Each step ends tested and usable on its own. 1. **The moves**, no behaviour changed: the table's *After* column, with `shoot_docs.mjs`, - `replay.mjs` and `sheet.mjs` calling the shared modules. Mostly done: see + `replay.mjs` and `sheet.mjs` calling the shared modules. Done but for three rows: see [Where the work stands](#where-the-work-stands). 2. **The manifest**: read and written by one module; `shoot_docs.mjs` and `replay.mjs` judge with its allowances, honour its decisions and record `build`. @@ -261,8 +261,8 @@ Each step ends tested and usable on its own. ## Where the work stands -On `staging` since 2026-10-31 (the commits from `95378e8f` to `c833323a`), step 1 but for the -rows below. Each move kept the command-line tools' output as it was, checked as +On `staging` since 2026-10-31, step 1 but for the three rows that are new code (below). Each +move kept the command-line tools' output as it was, checked as [below](#checking-a-move). | Row of the factoring table | Where it is now | @@ -273,23 +273,14 @@ rows below. Each move kept the command-line tools' output as it was, checked as | Writing a verdict | `scripts/lib/shot-apply.mjs`: `applyPicture(outRoot, out, decision, revision)`, `applySvg(outRoot, svgOut, svg or null)`, `restorePictures(outRoot, revision, outs, {dryRun})` (the PNGs and their SVGs, the SVGs by one `git restore`; `--cleanup` and a `restored` picture both use it), `dropPicture(outRoot, out, bundlesRoot)`, `writeSvg`, `svgOf`. What stays in `shoot_docs.mjs`'s `keep` and `keepSvg`: the counts, the lines it prints, the `--diffs` pictures, the bundle written, the user-name check, and the rule that an SVG stays as it is when its PNG was not written | | The git listing | `changedPictures`, `parseNameStatus`, `parseUntracked` in `scripts/lib/shot-revision.mjs` | | A child's report | `scripts/lib/record-lines.mjs`: `recordLine`, `writeRecord`, `parseRecord`, `readRecords(stream, kinds, {onRecord, onLine})`; `runShards` reads `svgshot-result` records with it. `--events` is to write `shot-event` records the same way | +| The sheets' HTML | the pieces in `scripts/lib/shot-views.mjs`: `pageHtml({generator, title, intro, summary, contents, body})`, `cardHtml({out, meta, meter, figures})`, `figuresIn(folder)` (`href`, `figure`, `thumb`), `meterHtml`, `tocItem`, `esc`, `anchor`, `sizeText`, `pctText`. The two pages, their rows, their order, their commands and the output-folder rules (`outputRefusal`, `mayWriteSheet`, `SHEET_GENERATOR`) are in `scripts/svgshot/sheets.mjs`; `redraw.mjs` keeps drawing and the shards | | Server-sent events | `createSseChannel()` in `lib/sse.mjs`: `handle(req, res)`, `send(event, data)`, `close()`; `builder/serve.mjs` uses one | | The installs | `listIdes()` in `scripts/lib/tb-install.mjs`, newest first, `[{build, exe}]` | -**Next: the views module**, the last move. The sheets' HTML is in `scripts/svgshot/redraw.mjs` -from `SHEET_GENERATOR` to the end: the private pieces `esc`, `anchor`, `sizeText`, `pageHtml` -(the page skeleton both sheets share), `figuresIn`, `meterHtml`, `pctText`, `shellQuote`, and -the two pages `sheetHtml` (the pictures with no SVG) and `changeSheetHtml` (`--against`), -with their row helpers (`sortRows`, `sortChangeRows`, `restoreCommand`, `cleanupCommand`, -`changeFiles`, `compareRevisionPicture`) and the output-folder rules (`outputRefusal`, -`mayWriteSheet`). The pieces go to a module of their own that the tool will import too (a -card, a figure, a meter, the page); `test/svgshot.test.mjs` tests both pages through their -exports, so the tests show whether the move changed anything. The tool's own pages will add -their actions to the same pieces; until then nothing calls them but `sheet.mjs`. - -**Then step 2**, the manifest. The three rows left in the table are new code, made in the +**Next: step 2**, the manifest. The three rows left in the table are new code, made in the steps that need them: the strength map and `changeRegions` in step 3, the commit helper in -step 8. +step 8. The tool's pages will add their actions to the pieces in `shot-views.mjs` (step 5); +until then only `sheets.mjs` calls them. **A small cleanup, in no step:** a PNG's size is read from its header by four copies of one line (`pngSize` in `shot-policy.mjs` as `{w, h}`, in `redraw.mjs` as `[w, h]` or null, in diff --git a/WIP.md b/WIP.md index bdf6991b6..3cbe9cf9d 100644 --- a/WIP.md +++ b/WIP.md @@ -585,7 +585,7 @@ Why the report separates the wedged task from the merely blocked ones, and why - `addin-test.bat` — tests IDE add-ins by operating an IDE: every lane in `test/addin/lanes.mjs` builds the add-ins it tests into a private copy of the install, opens a project and checks what the add-in does. Outside every gate and outside CI for the same reasons as `examples.bat`; the seventeen lanes: Samples 10 and 15, the thirteen probe lanes behind Stage 2's answers in [WIP.HelpAddin.md](WIP.HelpAddin.md), and the help add-in in `add-in/` twice, built with its committed copy of the symbol index, `add-in/Resources/SYMBOLS/symbols.json`, which a local build rewrites whenever the index changes. The `help` lane serves the built `docs/_site` on localhost to the add-in's pane; `help-offline` builds the add-in with a help archive of `docs/_site-offline` and the pane's pages come from the add-in's own server. So run `build.bat` first. Exit 0 every lane passed and the registry is as it was found, 1 a lane failed, 2 the harness failed, 3 the registry or a work folder was not put back. See [Driving the twinBASIC compiler](#driving-the-twinbasic-compiler) for its rules. - `ide-test.bat` --- the same runner for scenarios that operate the IDE itself rather than an add-in: every lane in `test/ide/lanes.mjs`. Same exit codes, same standing outside every gate and outside CI, same rules. - `try-help-addin.bat` --- opens one IDE on the user's desktop with the help add-in built and loaded, to try it by hand, and puts the registry back once it is closed (see [Driving the twinBASIC compiler](#driving-the-twinbasic-compiler)). Needs `build.bat` first. Exit 0 closed and put back, 1 the add-in or the project did not compile, 2 the tool could not run, 3 not put back. -- `node scripts/shoot_docs.mjs` --- writes the documentation's IDE screenshots (plan and owner's decisions: [WIP.Screenshots.md](WIP.Screenshots.md)). Each picture is a shot in a table, `out` its path under `docs/` (`--only` matches it), in a setup (the main three below; the full list is in WIP.Screenshots.md), each one IDE on a **private** desktop started only when a shot in it is selected: `help` (builds the add-in as `try-help-addin.bat` does, opens `test/addin/helpdemo`, `TB_DOCS_HELP_REAL_TITLEBAR=0`: the eight `docs/IDE/AddIns/Images/Help_.png`), `no-project` (`Lane.openNoProject` with `includeProjects`: the menus, cut out with alpha, the dialogs that need no project, and the empty panels, each floated on its own, after the dialogs) and `project` (the demo without the add-in: About, which needs the compiler's licence answer, and IDE Options, whose font lists need a project). One setup is not an IDE: `web` starts puppeteer's Chrome through `launchOnDesktop` on a private desktop of its own (never headless, never on the user's desktop; native-window occlusion and occluded-window backgrounding switched off, or a capture waits forever or comes back blank) for the two public pages the FAQ shows, taken once; it needs the network, as do `webpage` (the Webpage pane on `https://docs.twinbasic.com/`, its page captured from the pane's own DevTools target and pasted into the pane's place) and the package-server setups, which never publish. Every IDE gets `--force-device-scale-factor=1` (`Lane`'s `browserArgs`), else the page follows the display's scaling; the page is overridden to 1280x880 at 2x, and `PAGE_DEFAULTS` puts the IDE's options, panel layouts and keyboard groups to their defaults and the recent lists to the tool's own **in the page only**, nothing written because the IDE has `IDE_FLAGS` (the one recent-list call they do not guard, `RemoveRecentsList`, is replaced first). Each picture is a `Page.captureScreenshot` with a `clip`; a page showing it gives it `{:width height}` of half its pixel size. **Every picture is taken in two themes by one IDE**: dark as `X.png`, then, after `backToPrepared` and `tbTheme_SwitchToLightMode`, Light (not Classic) as `X.light.png` beside it, which the build pairs by name ([WIP.Build.md](WIP.Build.md#pictures-in-two-themes)); a light picture with the dark one's pixels is not kept, and one more than 32 device pixels off the dark one's size fails (a wrong state, not the theme). A label's pill follows the theme unless the shot names a `tone`. A shot's `annotate` list (arrows, rings, badges...) is drawn by `scripts/lib/shot-annotate.mjs` as one SVG layer over the page before the capture and removed after it, the clip growing to hold it. A picture is rewritten only when its bytes differ, and a second run reports every one `unchanged`; a capture is not byte-stable, so `capture` captures back to back, with no rest, until one equals the file on disk or two agree, and, when the two that agree are not the file, goes on capturing further apart, for 5.5 s at most, until one is, keeping the last two that agreed, and a picture that differs from the file only by a capture's noise (16 grey levels, 0.5% of its pixels) is left as it is. **The reference is the picture at git `HEAD` first, then the file** (owner): compared with the file alone, a picture drifted from `HEAD` over the runs of one retake, each step within noise of the file before it. A capture equal to or within noise of `HEAD`'s picture leaves the file as `HEAD` has it, its SVG too (`HEAD`'s, or none), reported `restored` when it held anything else; one that is not, but is within noise of the file, leaves the file (an earlier run's real change); a picture `HEAD` lacks is compared with the file only (`pictureFate`, `scripts/lib/shot-revision.mjs`; the run's pictures at `HEAD` read in one `git cat-file --batch`). `--against ` takes another revision, `--against-disk` the file only. **`--cleanup`** (`--dry-run` to list) starts no IDE: it resets every PNG git reports modified that is within noise of `HEAD`'s, with its SVG, so that only real changes stay; one noise rule, `withinNoise` in `scripts/lib/shot-change.mjs`, for the capture, `--cleanup` and `sheet.mjs --against`. `--jobs N` (default 6) runs the setups at once, each in a slot of its own from `tbbroker`, a full run with `--svg` in about 12 minutes on BETA 1010 (`--jobs 1`: one at a time); `--diffs ` writes the old picture, the new one and an amplified difference map for each that changed, named with its theme. Settle by a condition, never a fixed sleep, and a picture that shows some state sets it itself rather than relying on the shots before it. The page's animations and carets are switched off through a style sheet put into the document and every shadow root; a picture that holds DEBUG CONSOLE lines has their times (and a CodeLens run's time taken) set in the page once the real ones show. **A running program's window** (setup `programs`, fixture `test/shots/programs`) is taken **once** (`once: true`, the dark pass only) with `captureWindow`, cropped to its visible frame, at the display's DPI, shown at its size at 96 DPI. **The Windows user name must never be in a published picture**: the visible text of the page, text boxes included, is searched for it before the first capture and again when a capture is chosen, with the picture's SVG under `--svg` (a page can draw again in between: Project Settings puts a real path back over the one the shot set), and for a program's window the texts of the window and its children, and the run fails when it is there. The tool finds each name it presses F1 on by searching the demo's source, not by line. The add-in's detached window has a port from the broker (the help setup's slot carries one), since the add-in is told it before it binds it. **`--svg`** also draws each picture as SVG from the page as it stands at the capture (`scripts/svgshot/`: a DOM snapshot converted, its text set in per-picture subsets of the fonts the IDE drew it with, cut by `scripts/subset_font.py`, which needs Python with fontTools), compares it with the PNG, and writes `X.svg` or `X.light.svg` beside it only when no more than 0.25% of its pixels are far off; otherwise an older SVG is removed and the page shows the PNG; the SVG of a picture whose PNG is not rewritten stays as it is, with its bundle (owner) ([WIP.Build.md](WIP.Build.md#pictures-as-svg)). With `--svg` each picture's *bundle*, what its SVG is drawn from, is saved in the gitignored `.svgshot-bundles/` (`--bundles`; never under `docs/`, a bundle holds the user name), and **after a change to the converter, `node scripts/svgshot/replay.mjs` draws every SVG again from the bundles, with no IDE**, in about 20 s for the 370 (an SVG the same as its file is not compared again, `--recheck` compares all; the fonts' cuts are kept in `.svgshot-bundles/subsets/`; one browser per process, GPU off); it lists the pictures whose bundle cannot answer what the converter now asks, with the `--only` command that retakes them ([WIP.Screenshots.md](WIP.Screenshots.md#pictures-as-svg)). `node scripts/svgshot/sheet.mjs --out ` writes a page of the pictures with no SVG (`--all`: every picture), each drawn again from its bundle beside its PNG and difference map, worst first; it is drawn without the user-name check (owner: the sheet is the owner's alone), so `--out` is refused under `docs/` and anywhere git does not ignore; what it and replay share is `scripts/svgshot/redraw.mjs`. `node scripts/svgshot/bench.mjs` tests the converter's replicas of what Chromium draws outside the DOM (scrollbars, drop-down lists) in Edge, with no IDE ([WIP.Screenshots.md](WIP.Screenshots.md#pictures-as-svg)). No wrapper; outside every gate and outside CI, as `try-help-addin.bat` is. Exit 0 every picture written or unchanged, 1 a picture failed, 2 the tool could not run, 3 not put back. Run `build.bat` first. +- `node scripts/shoot_docs.mjs` --- writes the documentation's IDE screenshots (plan and owner's decisions: [WIP.Screenshots.md](WIP.Screenshots.md)). Each picture is a shot in a table, `out` its path under `docs/` (`--only` matches it), in a setup (the main three below; the full list is in WIP.Screenshots.md), each one IDE on a **private** desktop started only when a shot in it is selected: `help` (builds the add-in as `try-help-addin.bat` does, opens `test/addin/helpdemo`, `TB_DOCS_HELP_REAL_TITLEBAR=0`: the eight `docs/IDE/AddIns/Images/Help_.png`), `no-project` (`Lane.openNoProject` with `includeProjects`: the menus, cut out with alpha, the dialogs that need no project, and the empty panels, each floated on its own, after the dialogs) and `project` (the demo without the add-in: About, which needs the compiler's licence answer, and IDE Options, whose font lists need a project). One setup is not an IDE: `web` starts puppeteer's Chrome through `launchOnDesktop` on a private desktop of its own (never headless, never on the user's desktop; native-window occlusion and occluded-window backgrounding switched off, or a capture waits forever or comes back blank) for the two public pages the FAQ shows, taken once; it needs the network, as do `webpage` (the Webpage pane on `https://docs.twinbasic.com/`, its page captured from the pane's own DevTools target and pasted into the pane's place) and the package-server setups, which never publish. Every IDE gets `--force-device-scale-factor=1` (`Lane`'s `browserArgs`), else the page follows the display's scaling; the page is overridden to 1280x880 at 2x, and `PAGE_DEFAULTS` puts the IDE's options, panel layouts and keyboard groups to their defaults and the recent lists to the tool's own **in the page only**, nothing written because the IDE has `IDE_FLAGS` (the one recent-list call they do not guard, `RemoveRecentsList`, is replaced first). Each picture is a `Page.captureScreenshot` with a `clip`; a page showing it gives it `{:width height}` of half its pixel size. **Every picture is taken in two themes by one IDE**: dark as `X.png`, then, after `backToPrepared` and `tbTheme_SwitchToLightMode`, Light (not Classic) as `X.light.png` beside it, which the build pairs by name ([WIP.Build.md](WIP.Build.md#pictures-in-two-themes)); a light picture with the dark one's pixels is not kept, and one more than 32 device pixels off the dark one's size fails (a wrong state, not the theme). A label's pill follows the theme unless the shot names a `tone`. A shot's `annotate` list (arrows, rings, badges...) is drawn by `scripts/lib/shot-annotate.mjs` as one SVG layer over the page before the capture and removed after it, the clip growing to hold it. A picture is rewritten only when its bytes differ, and a second run reports every one `unchanged`; a capture is not byte-stable, so `capture` captures back to back, with no rest, until one equals the file on disk or two agree, and, when the two that agree are not the file, goes on capturing further apart, for 5.5 s at most, until one is, keeping the last two that agreed, and a picture that differs from the file only by a capture's noise (16 grey levels, 0.5% of its pixels) is left as it is. **The reference is the picture at git `HEAD` first, then the file** (owner): compared with the file alone, a picture drifted from `HEAD` over the runs of one retake, each step within noise of the file before it. A capture equal to or within noise of `HEAD`'s picture leaves the file as `HEAD` has it, its SVG too (`HEAD`'s, or none), reported `restored` when it held anything else; one that is not, but is within noise of the file, leaves the file (an earlier run's real change); a picture `HEAD` lacks is compared with the file only (`pictureFate`, `scripts/lib/shot-revision.mjs`; the run's pictures at `HEAD` read in one `git cat-file --batch`). `--against ` takes another revision, `--against-disk` the file only. **`--cleanup`** (`--dry-run` to list) starts no IDE: it resets every PNG git reports modified that is within noise of `HEAD`'s, with its SVG, so that only real changes stay; one noise rule, `withinNoise` in `scripts/lib/shot-change.mjs`, for the capture, `--cleanup` and `sheet.mjs --against`. `--jobs N` (default 6) runs the setups at once, each in a slot of its own from `tbbroker`, a full run with `--svg` in about 12 minutes on BETA 1010 (`--jobs 1`: one at a time); `--diffs ` writes the old picture, the new one and an amplified difference map for each that changed, named with its theme. Settle by a condition, never a fixed sleep, and a picture that shows some state sets it itself rather than relying on the shots before it. The page's animations and carets are switched off through a style sheet put into the document and every shadow root; a picture that holds DEBUG CONSOLE lines has their times (and a CodeLens run's time taken) set in the page once the real ones show. **A running program's window** (setup `programs`, fixture `test/shots/programs`) is taken **once** (`once: true`, the dark pass only) with `captureWindow`, cropped to its visible frame, at the display's DPI, shown at its size at 96 DPI. **The Windows user name must never be in a published picture**: the visible text of the page, text boxes included, is searched for it before the first capture and again when a capture is chosen, with the picture's SVG under `--svg` (a page can draw again in between: Project Settings puts a real path back over the one the shot set), and for a program's window the texts of the window and its children, and the run fails when it is there. The tool finds each name it presses F1 on by searching the demo's source, not by line. The add-in's detached window has a port from the broker (the help setup's slot carries one), since the add-in is told it before it binds it. **`--svg`** also draws each picture as SVG from the page as it stands at the capture (`scripts/svgshot/`: a DOM snapshot converted, its text set in per-picture subsets of the fonts the IDE drew it with, cut by `scripts/subset_font.py`, which needs Python with fontTools), compares it with the PNG, and writes `X.svg` or `X.light.svg` beside it only when no more than 0.25% of its pixels are far off; otherwise an older SVG is removed and the page shows the PNG; the SVG of a picture whose PNG is not rewritten stays as it is, with its bundle (owner) ([WIP.Build.md](WIP.Build.md#pictures-as-svg)). With `--svg` each picture's *bundle*, what its SVG is drawn from, is saved in the gitignored `.svgshot-bundles/` (`--bundles`; never under `docs/`, a bundle holds the user name), and **after a change to the converter, `node scripts/svgshot/replay.mjs` draws every SVG again from the bundles, with no IDE**, in about 20 s for the 370 (an SVG the same as its file is not compared again, `--recheck` compares all; the fonts' cuts are kept in `.svgshot-bundles/subsets/`; one browser per process, GPU off); it lists the pictures whose bundle cannot answer what the converter now asks, with the `--only` command that retakes them ([WIP.Screenshots.md](WIP.Screenshots.md#pictures-as-svg)). `node scripts/svgshot/sheet.mjs --out ` writes a page of the pictures with no SVG (`--all`: every picture), each drawn again from its bundle beside its PNG and difference map, worst first; it is drawn without the user-name check (owner: the sheet is the owner's alone), so `--out` is refused under `docs/` and anywhere git does not ignore; what it and replay share is `scripts/svgshot/redraw.mjs`, and its pages are `scripts/svgshot/sheets.mjs`, made of the pieces in `scripts/lib/shot-views.mjs`. `node scripts/svgshot/bench.mjs` tests the converter's replicas of what Chromium draws outside the DOM (scrollbars, drop-down lists) in Edge, with no IDE ([WIP.Screenshots.md](WIP.Screenshots.md#pictures-as-svg)). No wrapper; outside every gate and outside CI, as `try-help-addin.bat` is. Exit 0 every picture written or unchanged, 1 a picture failed, 2 the tool could not run, 3 not put back. Run `build.bat` first. - **The help add-in's project file is written by every build, CI's included.** The `addinProject` task (`builder/addin-project.mjs`) packs `add-in/` into `tB/IDE/AddIns/downloads/tbDocsHelp.twinproj` in the **online tree only**, which the Help Add-In page offers as a download; `_config.yml`'s `addin_project` declares both ends, so a fixture config gets none. It waits for the `helpArchive` task. It packs the files `git ls-files` lists there, read from the working folder, never a `Resources/HELP/` file from the disk, with CRLF made LF outside `Resources/` (so a Windows and a Linux build write the same bytes), the `tB/symbols.json` the same build wrote in place of the committed copy, and **the zip that build's archive task wrote** as `Resources/HELP/site.zip`, passed in memory (a build that wrote none --- `--no-help-archive`, `--serve`, another `--dest`, a fixture --- packs without it, and the summary line says `without the offline archive`). The download is about 61 MB with the archive, 1 MB without. The offline tree holds no copy (the project carries that tree's archive, so a copy there would be circular), and its links to the file are rewritten to the website by the general rule in `offline-rewrite.mjs`: a URL the offline tree lacks but the online tree has becomes `site.url` + path, and is no miss (`computeWebsiteUrl`; the offline link check's `forbidExempt` excuses exactly those links, `isWebsiteOnlyLink`). The pack is `packFiles` from `scripts/impexp.mjs`, in memory (never staged through `%TEMP%`), the one module under `scripts/` that `builder/` may import (`biome.jsonc`); `check_tree_fresh.mjs` watches it and `add-in/` for that reason. The publish allowlist exempts the one path in the trees only (`publishPolicyFor`'s `generated`); a `.twinproj` anywhere else, or in `docs/`, is refused. `--serve` writes it into its one tree; `--dry-run` writes nothing. A failure (no git) fails the build with exit 1 and the index audit names the missing file. **Don't build the DLLs in CI from it**: that needs a twinBASIC install; the download is the project, and the reader builds it. Why each of these: [WIP.Build.md](WIP.Build.md#the-help-add-ins-project-file); `test/addin-project.test.mjs` is the gate. - **The help archive is the `helpArchive` task of the build's graph.** `builder/help-archive-step.mjs` writes `add-in/Resources/HELP/site.zip` from `docs/_site-offline` through `lib/help-archive.mjs`, once every task that writes into the offline tree has finished (`flushJoin`, `scss`, `searchIndex`, `writeOffline`; the archive task lists exactly those) and **before** the Gantt injection. **The archive leaves out `HELP_ARCHIVE_SKIP`** (`builder/help-archive-step.mjs`): the Documentation Development section (`BuildInfo.html` and `assets/images/gantt.svg` with it, so the chart never reaches the zip), Videos and their poster frames, Challenges, and **every download, which stays online** (owner): a file readers download goes in a `downloads` folder beside its page, and the step fails the build when the Downloads page offers a file the archive would hold. The add-in's server redirects any file the archive lacks to the website, because pages link to all of these; a new entry needs nothing else. It reads the file back to verify it. A mismatch fails the build (exit 1). After the Gantt injection `recheckHelpArchive` checks that the offline tree on disk lists the same files as the archive and differs from it only in `BuildInfo.html` and `gantt.svg`; a task that wrote into the offline tree late fails the build there. The help add-in embeds the zip as a resource and serves the pages from it with its own server; a build of the add-in without it uses the live site. The file is gitignored (about 60 MB, 10 MB of it the search index's `search-index.js`, deflated), so a build of the add-in from a clean clone is the online one until `build.bat` has run. The download carries the same zip (previous bullet). **`--no-help-archive` turns the step off, and neither CI workflow may pass it** (`check_ci_workflows.mjs` refuses it, in `ALLOWED.forbiddenBuildFlags`), because CI's published download would then hold no archive. It runs only for the guarded source tree (`docs`) built into the default `docs/_site-offline` (not another `--dest`, so `compare_trees` and `check_links_diff` leave it alone), never under `--serve` or `--dry-run`, never without an offline tree, and not keyed on `$CI`. There is no command-line tool for the archive: the `help-offline` lane makes its own with `writeHelpArchive`. The zip's format is fixed by the add-in's reader, which does no inflating ([WIP.HelpAddin.md](WIP.HelpAddin.md), Stage 4, increment 5). - `node scripts/bug_repro.mjs` --- the reproducer projects under `bugs//` for the entries of [BUGS-TO-REPORT.md](BUGS-TO-REPORT.md): `new`, `pack` (impexp, then the zip, written in Node), `compile`, `build` and `run` through `tbbuild` and `tbrun`, `vb6` (builds the optional `vb6/` VB6 project beside the twinBASIC one in a temp copy, runs its exe on a private desktop, and prints its `out.txt`; a project with a form or a user control is built without Unattended Execution, which VB6 refuses for it; `pack` zips its sources into `-vb6.zip`; VB6 is started only by `scripts/lib/vb6.mjs`, never from a shell; a graphical defect's `images` key makes `run` and `vb6` keep what the `PngDump` modules of `test/repro-templates/png/` draw as `images/-tb.png` and `-vb6.png`, with a `-compare.png` of both, tested by `test/png.test.mjs`), `file` (moves a filed entry and its reproducer to `bugs/filed//`; `file --marked` does every marked entry), and `verify`, which reads each `bugs/*/repro.json` and `bugs/filed/*/repro.json` and says whether the bug still reproduces on the newest beta; a `lane` reproducer names the `ide-test` or `addin-test` lane and tests that assert its bug, which `verify` runs last through that runner, a `probe` one a script under `scripts/`, and a `test` one its own `[TestCase]` Subs and compile cases through `tbrun --tests`, judged by the `PASS` lines of what is fixed and the `FAIL` lines of the bug (`new --template tests` starts one). **No reproducer should need a person at the IDE** (owner): a bug the IDE has to be operated for gets a lane test, and `manual` is left for what cannot be automated at all. It has no wrapper. **`verify` is run by a person only**, never by a gate or CI: it needs a twinBASIC install, like `examples.bat`. Each `tbbuild` and `tbrun` it starts takes a slot of its own from `tbbroker`; `--jobs N` runs N at once. Its command-line cases are in `scripts/lib/cli-cases.mjs`, and need no IDE. @@ -663,7 +663,7 @@ wrapper: | `test.bat` | `test/render.test.mjs` | `builder/render.mjs`'s markdown-it plugins, one at a time, on inputs no page holds --- today the ellipsis plugin's dot counting. Run by `node --test` | | `test.bat` | `test/strftime.test.mjs` | `builder/strftime.mjs`'s `formatDate`, which no build calls because no page sets `last_modified_date`. Run by `node --test` | | `test.bat` | `test/compact-image.test.mjs` | what an embedded image keeps (`compactImage` and `stripFile` in `scripts/lib/compact-image.mjs`): an editor's metadata goes, a notice of whose the picture is and on what terms stays (the IDE's lock icon carries a CC BY-NC-SA attribution), an sRGB profile goes and any other stays, a JPEG or a GIF is labelled by its bytes, and an image inside an SVG inside a CSS file is reached and keeps its encoding. Its second half runs `scripts/imagestrip/` over a CSS file and SVG files of every image the test builds and over the IDE's own stylesheets, and compares output, summary and reports with `stripFile` byte for byte; it is skipped unless `IMAGESTRIP_EXE` names the built exe. Run by `node --test` | -| `test.bat` | `test/svgshot.test.mjs` | an SVG screenshot is written on one line (`oneLine` in `scripts/svgshot/snapshot-svg.mjs`), a break in a tag as a space and one in text as a character reference, so that a CRLF checkout changes only its last line ending; an underline is placed and sized by the figures fitted in Edge (`underlineBand`) under the boxes that set one (`decoratingBoxes`); a background image is sized by its bytes, a JPEG the IDE calls `image/png` included (`naturalSize`); an iframe's document is decoded and placed at the iframe's content box, less its scroll, scaled when zoomed, over its canvas colour (`frameGeometry`, `frameView`, `unzoom`, `viewportScroller`, `canvasColor`); a run's web font is the `@font-face` rule CSS matching picks (`webFontFiles`), and a variable WOFF2 is read and cut per weight; a family split by `unicode-range` gives each character to the rule whose range holds it (`parseUnicodeRange`, `webRulesFor`); an inline `` is found and its ids scoped (`inlineSvgsIn`, `scopeIds`); a filter of colour functions is baked into the colours under it, in order and clamped, its pictures wrapped in a real filter and its opacity kept apart (`colour-filter.mjs`); a transformed element's boxes and text are put back from the snapshot's after-transform boxes (`elementTransform`, `untransformBox`); a closed select shows its option's whole text (`selectLabel`); a bundle draws the same SVG from its file as in memory, and counts each question it cannot answer (`renderBundle`); its web font rules and files draw each character from the file whose range holds it, at the weight its rule allows, and its inline `` markup is drawn in its box, from the file and when trimmed (`drawBundle`); a bundle of version 1 is refused; one trimmed to its view draws the same (`trimSnapshot`); a font's cut comes from the subset cache with no Python run (`subsetKey`, `useSubsetCache`); `redraw` draws a picture again from its bundle or names its retake, and fails on the user name unless `checkUserName: false`; `listPictures` leaves the output trees out; `outputRefusal` refuses a sheet under docs or where git would take it, and `sheetHtml` puts the worst picture first (`scripts/svgshot/redraw.mjs`); a capture's fate against the picture at a git revision and the file, the SVG of one put back, what `--cleanup` resets, and a revision's files read in one git process and a text file put back as a checkout writes it, in a scratch repository (`pictureFate`, `svgFate`, `planCleanup`, `readRevisionFiles`, `restoreFromRevision`, `scripts/lib/shot-revision.mjs`). What it keeps of an image it embeds is `compactImage`'s, which the row above tests. Run by `node --test` | +| `test.bat` | `test/svgshot.test.mjs` | an SVG screenshot is written on one line (`oneLine` in `scripts/svgshot/snapshot-svg.mjs`), a break in a tag as a space and one in text as a character reference, so that a CRLF checkout changes only its last line ending; an underline is placed and sized by the figures fitted in Edge (`underlineBand`) under the boxes that set one (`decoratingBoxes`); a background image is sized by its bytes, a JPEG the IDE calls `image/png` included (`naturalSize`); an iframe's document is decoded and placed at the iframe's content box, less its scroll, scaled when zoomed, over its canvas colour (`frameGeometry`, `frameView`, `unzoom`, `viewportScroller`, `canvasColor`); a run's web font is the `@font-face` rule CSS matching picks (`webFontFiles`), and a variable WOFF2 is read and cut per weight; a family split by `unicode-range` gives each character to the rule whose range holds it (`parseUnicodeRange`, `webRulesFor`); an inline `` is found and its ids scoped (`inlineSvgsIn`, `scopeIds`); a filter of colour functions is baked into the colours under it, in order and clamped, its pictures wrapped in a real filter and its opacity kept apart (`colour-filter.mjs`); a transformed element's boxes and text are put back from the snapshot's after-transform boxes (`elementTransform`, `untransformBox`); a closed select shows its option's whole text (`selectLabel`); a bundle draws the same SVG from its file as in memory, and counts each question it cannot answer (`renderBundle`); its web font rules and files draw each character from the file whose range holds it, at the weight its rule allows, and its inline `` markup is drawn in its box, from the file and when trimmed (`drawBundle`); a bundle of version 1 is refused; one trimmed to its view draws the same (`trimSnapshot`); a font's cut comes from the subset cache with no Python run (`subsetKey`, `useSubsetCache`); `redraw` draws a picture again from its bundle or names its retake, and fails on the user name unless `checkUserName: false`; `listPictures` leaves the output trees out; `outputRefusal` refuses a sheet under docs or where git would take it, and `sheetHtml` puts the worst picture first (`scripts/svgshot/sheets.mjs`); a capture's fate against the picture at a git revision and the file, the SVG of one put back, what `--cleanup` resets, and a revision's files read in one git process and a text file put back as a checkout writes it, in a scratch repository (`pictureFate`, `svgFate`, `planCleanup`, `readRevisionFiles`, `restoreFromRevision`, `scripts/lib/shot-revision.mjs`). What it keeps of an image it embeds is `compactImage`'s, which the row above tests. Run by `node --test` | | `test.bat` | `test/example-batches.test.mjs` | `check_examples.mjs`'s probes, from `scripts/lib/example-batches.mjs`: batching, crash isolation through a fake lane, the canaries, the fence classifier and `check_run`'s refusals, expected output, dispatcher and marker reading, with no IDE. Run by `node --test` | | `test.bat` | `test/ide-folders.test.mjs` | `shutdownIde` and `shutdownIdeAsync` remove the IDE's temp folder and profile once it is gone, and no other folder. Run by `node --test` | | `test.bat` | `test/broker.test.mjs` | `tbbroker` and its client (`scripts/lib/tb-broker.mjs`): one broker per pipe, leases, held processes, waiting, ports, a desktop left with windows on it, a restarted broker finding a desktop in use. Skipped unless `TBBROKER_EXE` names a built broker. Run by `node --test` | diff --git a/docs/Documentation/Tools-Toolchain-Tests.md b/docs/Documentation/Tools-Toolchain-Tests.md index 99318f538..af28a6fd4 100644 --- a/docs/Documentation/Tools-Toolchain-Tests.md +++ b/docs/Documentation/Tools-Toolchain-Tests.md @@ -274,7 +274,7 @@ Unit tests for the SVG screenshot converter. They cover: - Web fonts: the `@font-face` rule that CSS font matching picks for the text, read from a WOFF2 file and cut at each weight a variable font is drawn at. When a font is split into files by `unicode-range`, each character is drawn from the file whose range holds it. - How the `` icons inside a page are found and kept apart. - `scripts/svgshot/colour-filter.mjs`, which turns a CSS filter such as `brightness()` or `invert()` into the colours it produces, so the picture needs no SVG filter (a PDF viewer would show one as a bitmap). The tests check the colours step by step, and that an embedded image keeps a real filter. -- `scripts/svgshot/redraw.mjs`, shared by [`replay.mjs`](Tools-IDE#replay) and [`sheet.mjs`](Tools-IDE#svg-sheet): a picture drawn again from its bundle, or listed as needing a retake, and failed when its SVG holds the Windows user name unless the caller turns that check off; the list of pictures, with the build's output trees left out; the folders a sheet may not be written to; and the sheet's page, worst picture first. The sheet of changes against a git revision (`sheet.mjs --against`) is tested the same way, with no git and no browser: the change list read from git's output (the PNGs only, untracked ones added), the calls made to git, the noise rule of `scripts/lib/shot-change.mjs` and its comparison of pictures of one size and of two, the difference map's colours, the order of the rows, the commands that restore the pictures within noise, and the page. +- `scripts/svgshot/redraw.mjs`, shared by [`replay.mjs`](Tools-IDE#replay) and [`sheet.mjs`](Tools-IDE#svg-sheet): a picture drawn again from its bundle, or listed as needing a retake, and failed when its SVG holds the Windows user name unless the caller turns that check off; and the list of pictures, with the build's output trees left out. `scripts/svgshot/sheets.mjs`, built from the pieces in `scripts/lib/shot-views.mjs`: the folders a sheet may not be written to, and the sheet's page, worst picture first. The sheet of changes against a git revision (`sheet.mjs --against`) is tested the same way, with no git and no browser: the change list read from git's output (the PNGs only, untracked ones added), the calls made to git, the noise rule of `scripts/lib/shot-change.mjs` and its comparison of pictures of one size and of two, the difference map's colours, the order of the rows, the commands that restore the pictures within noise, and the page. - `scripts/lib/shot-revision.mjs`, which [`shoot_docs.mjs`](Tools-IDE#shoot-docs) compares a new capture with: what becomes of a capture with the picture at a git revision or without one, within the noise of it, of the file on disk or of neither; the SVG that goes with a picture put back as the revision has it; which modified pictures `--cleanup` puts back; git's batch output, a name with spaces and a missing one included; and, in a scratch git repository, a revision's files read in one process and a text file put back as a checkout writes it, with the index left alone. What the SVG keeps of an image it embeds is `compactImage`'s, from `scripts/lib/compact-image.mjs`, which [`compact-image.test.mjs`](#compact-image-test) tests. No browser, no built tree, well under a second. diff --git a/scripts/lib/shot-views.mjs b/scripts/lib/shot-views.mjs new file mode 100644 index 000000000..f1ae3b19b --- /dev/null +++ b/scripts/lib/shot-views.mjs @@ -0,0 +1,99 @@ +// The HTML pieces the pages about screenshots are made of: the page itself, a picture's +// card, a figure, a thumbnail, a line of the contents and a meter. scripts/svgshot/sheets.mjs +// builds the two static sheets from them, and the screenshot review tool is to build its +// pages from the same pieces, with its actions added (WIP.ShotReview.md). +// +// Each takes text where it says text, and escapes it, and HTML where it says HTML, which +// it puts in as it is. Nothing here reads or writes a file. + +/** `s` as text in HTML, in an element or in an attribute's quotes. */ +export const esc = (s) => + String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + +/** The id of a picture's card, from its path. */ +export const anchor = (out) => `p-${out.replace(/[^A-Za-z0-9]+/g, "-")}`; + +/** A picture's size ([w, h] in device pixels, or null) as text. */ +export const sizeText = (size) => (size ? `${size[0]} x ${size[1]} px` : "size unknown"); + +/** A share of the pixels, as text: two decimals, and "under 0.01" for what would round to none. */ +export const pctText = (pct) => (pct === 0 ? "0" : pct < 0.01 ? "under 0.01" : pct.toFixed(2)); + +const PAGE_STYLE = `:root{color-scheme:light dark;--bg:#fff;--fg:#1b1b1b;--mut:#666;--line:#ccc;--card:#f6f6f6;--bar:#d33;--lim:#080;--link:#2a6ed8} +@media (prefers-color-scheme:dark){:root{--bg:#1b1b1b;--fg:#e6e6e6;--mut:#a0a0a0;--line:#444;--card:#262626;--bar:#e55;--lim:#4c4;--link:#7aaaff}} +body{margin:0 auto;max-width:1500px;padding:16px;background:var(--bg);color:var(--fg);font:15px/1.45 system-ui,Segoe UI,sans-serif} +h1{margin-top:0}h3{margin:0 0 4px;word-break:break-all}a{color:var(--link)} +section{border:1px solid var(--line);background:var(--card);border-radius:6px;padding:12px;margin:16px 0} +.meta{margin:0 0 6px;color:var(--mut)} +.trio{display:flex;flex-wrap:wrap;gap:12px;align-items:flex-start} +.trio figure{margin:0;flex:1 1 30%;min-width:260px} +.trio img{max-width:100%;max-height:520px;border:1px solid var(--line);background:repeating-conic-gradient(#8884 0 25%,#0000 0 50%) 0 0/16px 16px;display:block} +figcaption{font-size:12px;color:var(--mut);margin-top:2px;word-break:break-all} +.bar{position:relative;display:block;height:10px;max-width:500px;border:1px solid var(--line);margin:2px 0 8px} +.bar i{position:absolute;left:0;top:0;bottom:0;background:var(--bar)} +.bar b{position:absolute;top:-2px;bottom:-2px;width:2px;background:var(--lim)} +.pct{color:var(--mut)} +.thumbs{display:flex;flex-wrap:wrap;gap:12px} +.thumb{margin:0;width:230px}.thumb img{max-width:230px;max-height:150px;border:1px solid var(--line);background:repeating-conic-gradient(#8884 0 25%,#0000 0 50%) 0 0/16px 16px} +table{border-collapse:collapse}td,th{border:1px solid var(--line);padding:3px 10px;text-align:left} +`; + +/** + * A page: the head with `generator` in its (by which a folder is + * known to hold a page of that tool), the `title` (text) and `intro` (HTML), the + * `summary` table ([[label, value]], text), the `contents` (the
  • s of the list, HTML), + * and `body` (HTML). + */ +export function pageHtml({ generator, title, intro, summary, contents, body }) { + return ` + + +${esc(title)} + +

    ${esc(title)}

    +

    ${intro}

    + +${summary.map(([k, v]) => ``).join("\n")} +
    ${esc(k)}${esc(v)}
    +

    Contents

    +
      +${contents} +
    +${body} + +`; +} + +/** + * For files in `folder`, relative to the page: `href`, a file's link; `figure`, a linked + * picture with its caption (text), for a card; `thumb`, a linked thumbnail with its alt + * text and caption (text). + */ +export function figuresIn(folder) { + const href = (name) => `${folder}/${encodeURIComponent(name)}`; + const figure = (name, caption) => + `
    ${esc(caption)}
    ${esc(caption)}
    `; + const thumb = (name, alt, caption) => + `
    ${esc(alt)}
    ${esc(caption)}
    `; + return { href, figure, thumb }; +} + +/** A bar `pct` of the way to `full` percent, with a mark at `mark` percent; `title` is its tooltip (text). */ +export const meterHtml = (pct, full, mark, title) => + ``; + +/** + * A picture's card, with the id `anchor(out)`: its path `out` (text) as the heading, the + * line of facts `meta` (HTML), a `meter` (HTML, or "") and the `figures` (HTML, one each). + */ +export const cardHtml = ({ out, meta, meter = "", figures }) => `
    +

    ${esc(out)}

    +

    ${meta}

    +${meter} +
    ${figures.join("\n")}
    +
    `; + +/** A line of the contents: a link to the card of `out`, and `note` (text) after it. */ +export const tocItem = (out, note) => + `
  • ${esc(out)} ${esc(note)}
  • `; diff --git a/scripts/svgshot/redraw.mjs b/scripts/svgshot/redraw.mjs index 22484b737..655fa111b 100644 --- a/scripts/svgshot/redraw.mjs +++ b/scripts/svgshot/redraw.mjs @@ -1,25 +1,19 @@ // What the tools that draw a picture's SVG again from its bundle have in common: // replay.mjs, which writes the SVGs beside their PNGs, and sheet.mjs, which writes a // sheet of them to look at. Finding the pictures and their bundles, drawing one again -// and judging it (keep.mjs, against lib/shot-policy.mjs's bar), writing what the judge made into a folder, running -// the pictures in processes of their own, and the sheet's HTML. And what sheet.mjs --against -// uses to show the pictures that differ from a git revision (the list of them is -// changedPictures, in scripts/lib/shot-revision.mjs): the comparison of each picture -// (scripts/lib/shot-change.mjs), their order and the page, which shares its skeleton with -// the other sheet. +// and judging it (keep.mjs, against lib/shot-policy.mjs's bar), writing what the judge made +// into a folder, and running the pictures in processes of their own. The sheets' pages are +// in sheets.mjs. // // Nothing here writes under docs/: each writer is given its folder. import { spawn } from "node:child_process"; -import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { availableParallelism } from "node:os"; import path from "node:path"; import { isOutputTree } from "../../lib/markdown-files.mjs"; import { LAUNCH_ARGS, launchBrowser } from "../lib/browser.mjs"; -import { decodePng } from "../lib/png.mjs"; import { readRecords, writeRecord } from "../lib/record-lines.mjs"; -import { changeMap, compareImages, NOISE_LEVELS, NOISE_SHARE } from "../lib/shot-change.mjs"; -import { runGit } from "../lib/shot-revision.mjs"; import { BUNDLE_VERSION, pngHash, readBundle, renderBundle } from "./bundle.mjs"; import { SNAPSHOT_STYLES } from "./capture.mjs"; import { holdsUserName } from "./keep.mjs"; @@ -210,387 +204,3 @@ export function runShards(envName, jobs) { ), ); } - -// ---- the sheet ------------------------------------------------------------------------ - -/** The generator a sheet names in its , by which a folder is known to hold one. */ -export const SHEET_GENERATOR = "scripts/svgshot/sheet.mjs"; - -const inside = (dir, root) => { - const rel = path.relative(root, dir); - return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); -}; - -/** - * Why `dir` may not take a sheet, or null: it is under one of `docsRoots`, or inside a - * git working tree at a path git does not ignore: a sheet and its pictures may hold the - * Windows user name (sheet.mjs draws them without the check), so they must never be - * committed or published. `git` stands in for running git in the probes: - * git(args, cwd) returns its exit code and output, {status, stdout}. - */ -export function outputRefusal(dir, docsRoots, git = runGit) { - const abs = path.resolve(dir); - for (const root of docsRoots) if (inside(abs, path.resolve(root))) return `${abs} is under ${path.resolve(root)}`; - let near = abs; - while (!existsSync(near) || !statSync(near).isDirectory()) { - const up = path.dirname(near); - if (up === near) return null; - near = up; - } - const top = git(["rev-parse", "--show-toplevel"], near); - if (top.status !== 0) return null; - const root = path.resolve(top.stdout.trim()); - const rel = path.relative(root, abs).replace(/\\/g, "/"); - if (rel === "") return `${abs} is the root of the git working tree`; - const ignored = git(["check-ignore", "-q", "--", rel], root); - if (ignored.status === 0) return null; - if (ignored.status === 1) return `${abs} is inside the git working tree ${root}, at a path git does not ignore`; - return `git check-ignore could not say whether ${abs} is ignored`; -} - -/** - * Whether the folder `dir` may be written over: it does not exist, is empty, or - * holds a sheet (its index.html names SHEET_GENERATOR). - */ -export function mayWriteSheet(dir) { - if (!existsSync(dir)) return true; - if (!statSync(dir).isDirectory()) return false; - if (readdirSync(dir).length === 0) return true; - const index = path.join(dir, "index.html"); - return ( - existsSync(index) && readFileSync(index, "utf8").includes(``) - ); -} - -/** What the converter reports it did not draw (stats.unsupported), as [[kind, count]] sorted by kind. */ -export const notDrawn = (unsupported) => - Object.entries(unsupported ?? {}).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); - -/** The judged rows worst first; a row that could not be judged after them; ties by path. */ -export const sortRows = (rows) => - [...rows].sort((a, b) => (b.pct96 ?? -1) - (a.pct96 ?? -1) || (a.out < b.out ? -1 : a.out > b.out ? 1 : 0)); - -const esc = (s) => String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); -const anchor = (out) => `p-${out.replace(/[^A-Za-z0-9]+/g, "-")}`; -const sizeText = (size) => (size ? `${size[0]} x ${size[1]} px` : "size unknown"); - -const SHEET_STYLE = `:root{color-scheme:light dark;--bg:#fff;--fg:#1b1b1b;--mut:#666;--line:#ccc;--card:#f6f6f6;--bar:#d33;--lim:#080;--link:#2a6ed8} -@media (prefers-color-scheme:dark){:root{--bg:#1b1b1b;--fg:#e6e6e6;--mut:#a0a0a0;--line:#444;--card:#262626;--bar:#e55;--lim:#4c4;--link:#7aaaff}} -body{margin:0 auto;max-width:1500px;padding:16px;background:var(--bg);color:var(--fg);font:15px/1.45 system-ui,Segoe UI,sans-serif} -h1{margin-top:0}h3{margin:0 0 4px;word-break:break-all}a{color:var(--link)} -section{border:1px solid var(--line);background:var(--card);border-radius:6px;padding:12px;margin:16px 0} -.meta{margin:0 0 6px;color:var(--mut)} -.trio{display:flex;flex-wrap:wrap;gap:12px;align-items:flex-start} -.trio figure{margin:0;flex:1 1 30%;min-width:260px} -.trio img{max-width:100%;max-height:520px;border:1px solid var(--line);background:repeating-conic-gradient(#8884 0 25%,#0000 0 50%) 0 0/16px 16px;display:block} -figcaption{font-size:12px;color:var(--mut);margin-top:2px;word-break:break-all} -.bar{position:relative;display:block;height:10px;max-width:500px;border:1px solid var(--line);margin:2px 0 8px} -.bar i{position:absolute;left:0;top:0;bottom:0;background:var(--bar)} -.bar b{position:absolute;top:-2px;bottom:-2px;width:2px;background:var(--lim)} -.pct{color:var(--mut)} -.thumbs{display:flex;flex-wrap:wrap;gap:12px} -.thumb{margin:0;width:230px}.thumb img{max-width:230px;max-height:150px;border:1px solid var(--line);background:repeating-conic-gradient(#8884 0 25%,#0000 0 50%) 0 0/16px 16px} -table{border-collapse:collapse}td,th{border:1px solid var(--line);padding:3px 10px;text-align:left} -`; - -/** - * The page both sheets share: the head with the generator mark that identifies a sheet, - * the title and `intro` (HTML), the `summary` table ([[label, value]], text), the - * `contents` (the
  • s of the list), and `body` (HTML). - */ -function pageHtml({ title, intro, summary, contents, body }) { - return ` - - -${esc(title)} - -

    ${esc(title)}

    -

    ${intro}

    - -${summary.map(([k, v]) => ``).join("\n")} -
    ${esc(k)}${esc(v)}
    -

    Contents

    -
      -${contents} -
    -${body} - -`; -} - -/** The link and the figure (a linked picture with its caption) for files in `folder`, relative to the page. */ -function figuresIn(folder) { - const href = (name) => `${folder}/${encodeURIComponent(name)}`; - const figure = (name, caption) => - `
    ${esc(caption)}
    ${esc(caption)}
    `; - return { href, figure }; -} - -/** A bar `pct` of the way to `full` percent, with a mark at `mark` percent; `title` is its tooltip (text). */ -const meterHtml = (pct, full, mark, title) => - ``; - -/** - * The sheet, a page that needs no other file but the pictures it links: `rows` the - * judged pictures, each {out, theme, size, pct96, pct32, pct8, faithful, source - * ("bundle" or "file"), notes, notDrawn, files: {png, render, diff, svg}} or {out, - * theme, size, error, files: {png}}; `plain` the pictures with no drawing, each - * {out, size, files: {png}}; `bar` SVG_FAITHFUL; `all` whether pictures with an SVG - * are included; `folder` the folder the files are in, relative to the page. - */ -export function sheetHtml({ rows, plain, bar, all, folder = "pictures" }) { - const { href, figure } = figuresIn(folder); - const sorted = sortRows(rows); - const judged = sorted.filter((r) => r.error === undefined); - const title = all ? "Every picture against its SVG" : "Pictures with no SVG"; - const meter = (pct) => meterHtml(pct, 10, bar, `${pct}% strongly off; the bar is ${bar}%`); - const card = (r) => { - // Each fact is HTML: text from elsewhere is escaped as it is put in. - const facts = [`${r.theme} theme`, sizeText(r.size)]; - if (r.error === undefined) { - facts.push( - `${r.pct96}% strongly off (the bar is ${bar}%), ${r.pct32}% off by 32 levels or more, ${r.pct8}% by 8 or more`, - ); - facts.push(r.source === "file" ? "the SVG file beside the PNG" : "drawn again from its bundle"); - if (r.notDrawn?.length) - facts.push( - `the converter did not draw: ${esc(r.notDrawn.map(([k, n]) => (n > 1 ? `${k} x${n}` : k)).join(", "))}`, - ); - } else facts.push(`not drawn: ${esc(r.error)}`); - if (r.notes?.length) facts.push(`note: ${esc(r.notes.join("; "))}`); - const figures = [figure(r.files.png, "PNG")]; - if (r.files.render) figures.push(figure(r.files.render, "SVG render")); - if (r.files.diff) - figures.push(figure(r.files.diff, "Difference (red: 96 levels or more, orange: 32 or more, yellow: 8 or more)")); - return `
    -

    ${esc(r.out)}

    -

    ${facts.join(", ")}${r.files.svg ? ` (the SVG)` : ""}

    -${r.error === undefined ? meter(r.pct96) : ""} -
    ${figures.join("\n")}
    -
    `; - }; - const thumbs = plain - .map( - (p) => - `
    ${esc(p.out)}
    ${esc(p.out)}, ${sizeText(p.size)}
    `, - ) - .join("\n"); - const toc = sorted - .map( - (r) => - `
  • ${esc(r.out)} ${r.pct96 ?? "not drawn"}${r.pct96 === undefined ? "" : "%"}
  • `, - ) - .join("\n"); - return pageHtml({ - title, - intro: `${all ? "Every PNG under docs" : "Every PNG under docs with no SVG of the same name beside it"}, the build's output trees left out. A picture with a bundle is drawn again from it with the converter as it is now, and judged as the tools judge an SVG: the share of its pixels more than 96 grey levels off the PNG's, a shift of one pixel allowed. An SVG is kept only below ${bar}% (the green mark on each bar, whose full width is 10%). Select a picture to open it full size.`, - summary: [ - [all ? "PNGs" : "PNGs with no SVG", rows.length + plain.length], - ["drawn and judged", judged.length], - ["could not be drawn", rows.length - judged.length], - ["with nothing to draw from", plain.length], - ], - contents: `${toc} -
  • Pictures with nothing to draw from
  • `, - body: `

    Drawn, worst first

    -${sorted.map(card).join("\n")} -

    With nothing to draw from (no bundle${all ? " and no SVG" : ""})

    -
    -${thumbs} -
    `, - }); -} - -// ---- the sheet of changes against a git revision ---------------------------------------- -// The change list itself is changedPictures, in shot-revision.mjs. - -/** The files a picture of the change sheet can have in a flat folder: the old PNG, the new one and the map. */ -export const changeFiles = (out) => { - const s = slugOf(out); - return { old: `${s}.old.png`, new: `${s}.new.png`, map: `${s}.change.png` }; -}; - -/** - * Compares the picture `item` ({out, status}) as the revision has it (`before`, bytes, or - * null) and as it is now (`after`). Returns {row, map}: the row of the change sheet -- - * {out, theme, status, kind, sizeOld, sizeNew, stats, error, files} -- and the difference - * map as PNG bytes, null for an added or deleted picture or one that could not be read. - * `kind` is "changed", "noise" (the same picture but for a capture's noise, which - * shoot_docs.mjs never writes over a file, and puts back as HEAD has it; withinNoise, as - * its --cleanup applies it), "added", "deleted" or "error" (a file this - * tool cannot read as a PNG, with `error` the reason). - */ -export function compareRevisionPicture(item, before, after) { - const { out, status } = item; - const names = changeFiles(out); - const row = { out, theme: themeOf(out), status, files: {} }; - if (before) { - row.sizeOld = pngSize(before); - row.files.old = names.old; - } - if (after) { - row.sizeNew = pngSize(after); - row.files.new = names.new; - } - if (status === "added") return { row: { ...row, kind: "added" }, map: null }; - if (status === "deleted") return { row: { ...row, kind: "deleted" }, map: null }; - try { - const A = decodePng(before); - const B = decodePng(after); - const stats = compareImages(A, B); - row.files.map = names.map; - return { row: { ...row, stats, kind: stats.noiseOnly ? "noise" : "changed" }, map: changeMap(A, B) }; - } catch (e) { - return { row: { ...row, kind: "error", error: e.message }, map: null }; - } -} - -const KIND_RANK = { error: 0, changed: 0, noise: 1, added: 2, deleted: 3 }; - -/** - * The change sheet's rows in the order it shows them: the changed pictures, most changed - * first (the share changed beyond noise, then the share that differs at all, then the - * path), those that could not be read after them; then the pictures that differ only by - * a capture's noise, in the same order; then the added; then the deleted, by path. - */ -export const sortChangeRows = (rows) => - [...rows].sort((a, b) => { - const k = KIND_RANK[a.kind] - KIND_RANK[b.kind]; - if (k) return k; - const share = (r) => (r.stats ? [r.stats.pctBeyond, r.stats.pctDiffering] : [-1, -1]); - const [ab, ad] = share(a); - const [bb, bd] = share(b); - return bb - ab || bd - ad || (a.out < b.out ? -1 : a.out > b.out ? 1 : 0); - }); - -const shellQuote = (p) => (/[^A-Za-z0-9_./:@%+=,~^{}-]/.test(p) ? `"${p}"` : p); - -/** - * The command that puts the pictures `outs` (paths under the docs folder, which is - * `prefix` from the repository's root) back as the revision `rev` has them, to be run - * in the repository's root. The tool only shows it. - */ -export const restoreCommand = (rev, prefix, outs) => - `git checkout ${shellQuote(rev)} -- ${outs.map((o) => shellQuote(prefix + o)).join(" ")}`; - -/** - * The command that puts back, with no IDE, every picture that differs from the revision - * `rev` only within the noise rule, its SVG with it (shoot_docs.mjs --cleanup), to be run in - * the repository's root: `only` the sheet's --only, `docs` its --docs, each as given, or - * undefined. The tool only shows it. - */ -export const cleanupCommand = (rev, { only, docs } = {}) => - [ - "node scripts/shoot_docs.mjs --cleanup", - rev === "HEAD" ? "" : ` --against ${shellQuote(rev)}`, - only === undefined ? "" : ` --only ${shellQuote(only)}`, - docs === undefined ? "" : ` --out ${shellQuote(docs)}`, - ].join(""); - -// A share of the pixels, as text: two decimals, and "under 0.01" for what would round to none. -const pctText = (pct) => (pct === 0 ? "0" : pct < 0.01 ? "under 0.01" : pct.toFixed(2)); - -/** - * The change sheet, a page that needs no other file but the pictures it links: `rows` - * from compareRevisionPicture; `rev` the revision as the command line gave it, `hash` - * and `short` the commit it names, `prefix` the docs folder's path from the repository's - * root; `folder` the folder the files are in, relative to the page. - */ -export function changeSheetHtml({ rows, rev, hash, short, prefix, folder = "pictures", only, docs }) { - const { figure } = figuresIn(folder); - const sorted = sortChangeRows(rows); - const kinds = (...wanted) => sorted.filter((r) => wanted.includes(r.kind)); - const changed = kinds("changed", "error"); - const noise = kinds("noise"); - const added = kinds("added"); - const deleted = kinds("deleted"); - const title = `Pictures changed since ${rev} (${short})`; - const sizes = (r) => - r.sizeOld && r.sizeNew && (r.sizeOld[0] !== r.sizeNew[0] || r.sizeOld[1] !== r.sizeNew[1]) - ? `size changed from ${sizeText(r.sizeOld)} to ${sizeText(r.sizeNew)}` - : sizeText(r.sizeNew ?? r.sizeOld); - const card = (r) => { - // Each fact is HTML: text from elsewhere is escaped as it is put in. - const facts = [`${r.theme} theme`, esc(sizes(r))]; - if (r.error !== undefined) facts.push(`not compared: ${esc(r.error)}`); - const st = r.stats; - if (st) { - if (!st.sameSize) - facts.push( - `compared aligned at the top left over the common ${st.common[0]} x ${st.common[1]} px; the map shows the whole ${st.canvas[0]} x ${st.canvas[1]} px, a pixel only the old picture has cyan, one only the new picture has magenta`, - ); - facts.push( - st.differing === 0 - ? "the same pixels in a different file" - : `${pctText(st.pctBeyond)}% changed beyond noise (by more than ${NOISE_LEVELS} grey levels${st.sameSize ? "" : ", or in one picture only"}), ${pctText((st.faint / st.total) * 100)}% by ${NOISE_LEVELS} or fewer`, - ); - } - const figures = []; - if (r.files.old) figures.push(figure(r.files.old, `As ${short} has it`)); - if (r.files.new) figures.push(figure(r.files.new, "As it is now")); - if (r.files.map) - figures.push( - figure(r.files.map, "Difference (yellow: 1 grey level, shading to red at 128 or more; dark grey: the same)"), - ); - const tip = st - ? `${pctText(st.pctBeyond)}% changed beyond noise; the mark is ${NOISE_SHARE * 100}%, the share of the pixels the noise rule lets differ` - : ""; - return `
    -

    ${esc(r.out)}

    -

    ${facts.join(", ")}

    -${st ? meterHtml(st.pctBeyond, 10, NOISE_SHARE * 100, tip) : ""} -
    ${figures.join("\n")}
    -
    `; - }; - const toc = (list) => - list - .map( - (r) => - `
  • ${esc(r.out)} ${r.stats ? `${pctText(r.stats.pctBeyond)}%` : esc(r.kind)}
  • `, - ) - .join("\n"); - const group = (id, heading, list, note = "") => - `

    ${esc(heading)} (${list.length})

    -${note}${list.length ? list.map(card).join("\n") : "

    None.

    "}`; - const noiseNote = `

    These differ from ${esc(short)} within the noise rule the screenshotter applies (no pixel more than ${NOISE_LEVELS} grey levels off, and at most ${NOISE_SHARE * 100}% of the pixels off at all), and it never writes a capture that does: a run that kept the other version of the noise, or drift over several runs, left them.

    -${ - noise.length - ? `

    To put them all back as ${esc(short)} has them, run this in the repository's root (this page only shows it; nothing has been run):

    -
    ${esc(
    -        restoreCommand(
    -          rev,
    -          prefix,
    -          noise.map((r) => r.out),
    -        ),
    -      )}
    -

    Or this, which also puts back each one's SVG as ${esc(short)} has it (removed where it has none) and starts no IDE; with --dry-run it only lists them:

    -
    ${esc(cleanupCommand(rev, { only, docs }))}
    -` - : "" -}`; - const section = (id, heading, list) => - `
  • ${esc(heading)} (${list.length})\n
      \n${toc(list)}\n
  • `; - return pageHtml({ - title, - intro: `Every PNG under docs that differs from the commit ${esc(hash)} (${esc(rev)}): the picture as the commit has it, as it is now, and the difference between them, aligned at the top left. The change is the share of pixels that differ by more than ${NOISE_LEVELS} grey levels (or exist in one picture only). Select a picture to open it full size.`, - summary: [ - ["changed", changed.length], - ["differ only by capture noise", noise.length], - ["added", added.length], - ["deleted", deleted.length], - ], - contents: [ - section("changed", "Changed, most changed first", changed), - section("noise", "Differ only by capture noise", noise), - section("added", "Added", added), - section("deleted", "Deleted", deleted), - ].join("\n"), - body: [ - group("changed", "Changed, most changed first", changed), - group("noise", "Differ only by capture noise, most likely rewritten in error", noise, noiseNote), - group("added", "Added", added), - group("deleted", "Deleted", deleted), - ].join("\n"), - }); -} diff --git a/scripts/svgshot/sheet.mjs b/scripts/svgshot/sheet.mjs index dbf48e126..71212a948 100644 --- a/scripts/svgshot/sheet.mjs +++ b/scripts/svgshot/sheet.mjs @@ -34,27 +34,29 @@ import { useSubsetCache } from "./fonts.mjs"; import { SVG_FAITHFUL } from "../lib/shot-policy.mjs"; import { judgeSvg } from "./keep.mjs"; import { - changeFiles, - changeSheetHtml, - compareRevisionPicture, defaultJobs, inBrowser, judgedFiles, listPictures, - mayWriteSheet, - notDrawn, - outputRefusal, pngSize, redraw, reportShard, retakeText, runShards, - sheetHtml, shardOf, shareOf, themeOf, writeJudged, } from "./redraw.mjs"; +import { + changeFiles, + changeSheetHtml, + compareRevisionPicture, + mayWriteSheet, + notDrawn, + outputRefusal, + sheetHtml, +} from "./sheets.mjs"; exitOnCrash(); diff --git a/scripts/svgshot/sheets.mjs b/scripts/svgshot/sheets.mjs new file mode 100644 index 000000000..4bc6414c6 --- /dev/null +++ b/scripts/svgshot/sheets.mjs @@ -0,0 +1,325 @@ +// The two sheets sheet.mjs writes, and where it may write them. The sheet of the pictures +// with no SVG (sheetHtml): each drawn again from its bundle (redraw.mjs) and judged, worst +// first. The sheet of changes against a git revision (changeSheetHtml, sheet.mjs --against): +// the comparison of each picture (compareRevisionPicture, scripts/lib/shot-change.mjs), their +// order and the commands that put the ones within noise back; the list of them is +// changedPictures, in scripts/lib/shot-revision.mjs. Both pages are made of the pieces in +// scripts/lib/shot-views.mjs. +// +// Nothing here writes a file: sheet.mjs writes the page and its pictures. + +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import { decodePng } from "../lib/png.mjs"; +import { changeMap, compareImages, NOISE_LEVELS, NOISE_SHARE } from "../lib/shot-change.mjs"; +import { runGit } from "../lib/shot-revision.mjs"; +import { cardHtml, esc, figuresIn, meterHtml, pageHtml, pctText, sizeText, tocItem } from "../lib/shot-views.mjs"; +import { pngSize, slugOf, themeOf } from "./redraw.mjs"; + +/** The generator a sheet names in its , by which a folder is known to hold one. */ +export const SHEET_GENERATOR = "scripts/svgshot/sheet.mjs"; + +const inside = (dir, root) => { + const rel = path.relative(root, dir); + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); +}; + +/** + * Why `dir` may not take a sheet, or null: it is under one of `docsRoots`, or inside a + * git working tree at a path git does not ignore: a sheet and its pictures may hold the + * Windows user name (sheet.mjs draws them without the check), so they must never be + * committed or published. `git` stands in for running git in the probes: + * git(args, cwd) returns its exit code and output, {status, stdout}. + */ +export function outputRefusal(dir, docsRoots, git = runGit) { + const abs = path.resolve(dir); + for (const root of docsRoots) if (inside(abs, path.resolve(root))) return `${abs} is under ${path.resolve(root)}`; + let near = abs; + while (!existsSync(near) || !statSync(near).isDirectory()) { + const up = path.dirname(near); + if (up === near) return null; + near = up; + } + const top = git(["rev-parse", "--show-toplevel"], near); + if (top.status !== 0) return null; + const root = path.resolve(top.stdout.trim()); + const rel = path.relative(root, abs).replace(/\\/g, "/"); + if (rel === "") return `${abs} is the root of the git working tree`; + const ignored = git(["check-ignore", "-q", "--", rel], root); + if (ignored.status === 0) return null; + if (ignored.status === 1) return `${abs} is inside the git working tree ${root}, at a path git does not ignore`; + return `git check-ignore could not say whether ${abs} is ignored`; +} + +/** + * Whether the folder `dir` may be written over: it does not exist, is empty, or + * holds a sheet (its index.html names SHEET_GENERATOR). + */ +export function mayWriteSheet(dir) { + if (!existsSync(dir)) return true; + if (!statSync(dir).isDirectory()) return false; + if (readdirSync(dir).length === 0) return true; + const index = path.join(dir, "index.html"); + return ( + existsSync(index) && readFileSync(index, "utf8").includes(``) + ); +} + +// ---- the sheet of the pictures with no SVG ---------------------------------------------- + +/** What the converter reports it did not draw (stats.unsupported), as [[kind, count]] sorted by kind. */ +export const notDrawn = (unsupported) => + Object.entries(unsupported ?? {}).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); + +/** The judged rows worst first; a row that could not be judged after them; ties by path. */ +export const sortRows = (rows) => + [...rows].sort((a, b) => (b.pct96 ?? -1) - (a.pct96 ?? -1) || (a.out < b.out ? -1 : a.out > b.out ? 1 : 0)); + +/** + * The sheet, a page that needs no other file but the pictures it links: `rows` the + * judged pictures, each {out, theme, size, pct96, pct32, pct8, faithful, source + * ("bundle" or "file"), notes, notDrawn, files: {png, render, diff, svg}} or {out, + * theme, size, error, files: {png}}; `plain` the pictures with no drawing, each + * {out, size, files: {png}}; `bar` SVG_FAITHFUL; `all` whether pictures with an SVG + * are included; `folder` the folder the files are in, relative to the page. + */ +export function sheetHtml({ rows, plain, bar, all, folder = "pictures" }) { + const { href, figure, thumb } = figuresIn(folder); + const sorted = sortRows(rows); + const judged = sorted.filter((r) => r.error === undefined); + const title = all ? "Every picture against its SVG" : "Pictures with no SVG"; + const meter = (pct) => meterHtml(pct, 10, bar, `${pct}% strongly off; the bar is ${bar}%`); + const card = (r) => { + // Each fact is HTML: text from elsewhere is escaped as it is put in. + const facts = [`${r.theme} theme`, sizeText(r.size)]; + if (r.error === undefined) { + facts.push( + `${r.pct96}% strongly off (the bar is ${bar}%), ${r.pct32}% off by 32 levels or more, ${r.pct8}% by 8 or more`, + ); + facts.push(r.source === "file" ? "the SVG file beside the PNG" : "drawn again from its bundle"); + if (r.notDrawn?.length) + facts.push( + `the converter did not draw: ${esc(r.notDrawn.map(([k, n]) => (n > 1 ? `${k} x${n}` : k)).join(", "))}`, + ); + } else facts.push(`not drawn: ${esc(r.error)}`); + if (r.notes?.length) facts.push(`note: ${esc(r.notes.join("; "))}`); + const figures = [figure(r.files.png, "PNG")]; + if (r.files.render) figures.push(figure(r.files.render, "SVG render")); + if (r.files.diff) + figures.push(figure(r.files.diff, "Difference (red: 96 levels or more, orange: 32 or more, yellow: 8 or more)")); + return cardHtml({ + out: r.out, + meta: `${facts.join(", ")}${r.files.svg ? ` (the SVG)` : ""}`, + meter: r.error === undefined ? meter(r.pct96) : "", + figures, + }); + }; + const thumbs = plain.map((p) => thumb(p.files.png, p.out, `${p.out}, ${sizeText(p.size)}`)).join("\n"); + const toc = sorted + .map((r) => tocItem(r.out, `${r.pct96 ?? "not drawn"}${r.pct96 === undefined ? "" : "%"}`)) + .join("\n"); + return pageHtml({ + generator: SHEET_GENERATOR, + title, + intro: `${all ? "Every PNG under docs" : "Every PNG under docs with no SVG of the same name beside it"}, the build's output trees left out. A picture with a bundle is drawn again from it with the converter as it is now, and judged as the tools judge an SVG: the share of its pixels more than 96 grey levels off the PNG's, a shift of one pixel allowed. An SVG is kept only below ${bar}% (the green mark on each bar, whose full width is 10%). Select a picture to open it full size.`, + summary: [ + [all ? "PNGs" : "PNGs with no SVG", rows.length + plain.length], + ["drawn and judged", judged.length], + ["could not be drawn", rows.length - judged.length], + ["with nothing to draw from", plain.length], + ], + contents: `${toc} +
  • Pictures with nothing to draw from
  • `, + body: `

    Drawn, worst first

    +${sorted.map(card).join("\n")} +

    With nothing to draw from (no bundle${all ? " and no SVG" : ""})

    +
    +${thumbs} +
    `, + }); +} + +// ---- the sheet of changes against a git revision ---------------------------------------- +// The change list itself is changedPictures, in shot-revision.mjs. + +/** The files a picture of the change sheet can have in a flat folder: the old PNG, the new one and the map. */ +export const changeFiles = (out) => { + const s = slugOf(out); + return { old: `${s}.old.png`, new: `${s}.new.png`, map: `${s}.change.png` }; +}; + +/** + * Compares the picture `item` ({out, status}) as the revision has it (`before`, bytes, or + * null) and as it is now (`after`). Returns {row, map}: the row of the change sheet -- + * {out, theme, status, kind, sizeOld, sizeNew, stats, error, files} -- and the difference + * map as PNG bytes, null for an added or deleted picture or one that could not be read. + * `kind` is "changed", "noise" (the same picture but for a capture's noise, which + * shoot_docs.mjs never writes over a file, and puts back as HEAD has it; withinNoise, as + * its --cleanup applies it), "added", "deleted" or "error" (a file this + * tool cannot read as a PNG, with `error` the reason). + */ +export function compareRevisionPicture(item, before, after) { + const { out, status } = item; + const names = changeFiles(out); + const row = { out, theme: themeOf(out), status, files: {} }; + if (before) { + row.sizeOld = pngSize(before); + row.files.old = names.old; + } + if (after) { + row.sizeNew = pngSize(after); + row.files.new = names.new; + } + if (status === "added") return { row: { ...row, kind: "added" }, map: null }; + if (status === "deleted") return { row: { ...row, kind: "deleted" }, map: null }; + try { + const A = decodePng(before); + const B = decodePng(after); + const stats = compareImages(A, B); + row.files.map = names.map; + return { row: { ...row, stats, kind: stats.noiseOnly ? "noise" : "changed" }, map: changeMap(A, B) }; + } catch (e) { + return { row: { ...row, kind: "error", error: e.message }, map: null }; + } +} + +const KIND_RANK = { error: 0, changed: 0, noise: 1, added: 2, deleted: 3 }; + +/** + * The change sheet's rows in the order it shows them: the changed pictures, most changed + * first (the share changed beyond noise, then the share that differs at all, then the + * path), those that could not be read after them; then the pictures that differ only by + * a capture's noise, in the same order; then the added; then the deleted, by path. + */ +export const sortChangeRows = (rows) => + [...rows].sort((a, b) => { + const k = KIND_RANK[a.kind] - KIND_RANK[b.kind]; + if (k) return k; + const share = (r) => (r.stats ? [r.stats.pctBeyond, r.stats.pctDiffering] : [-1, -1]); + const [ab, ad] = share(a); + const [bb, bd] = share(b); + return bb - ab || bd - ad || (a.out < b.out ? -1 : a.out > b.out ? 1 : 0); + }); + +const shellQuote = (p) => (/[^A-Za-z0-9_./:@%+=,~^{}-]/.test(p) ? `"${p}"` : p); + +/** + * The command that puts the pictures `outs` (paths under the docs folder, which is + * `prefix` from the repository's root) back as the revision `rev` has them, to be run + * in the repository's root. The tool only shows it. + */ +export const restoreCommand = (rev, prefix, outs) => + `git checkout ${shellQuote(rev)} -- ${outs.map((o) => shellQuote(prefix + o)).join(" ")}`; + +/** + * The command that puts back, with no IDE, every picture that differs from the revision + * `rev` only within the noise rule, its SVG with it (shoot_docs.mjs --cleanup), to be run in + * the repository's root: `only` the sheet's --only, `docs` its --docs, each as given, or + * undefined. The tool only shows it. + */ +export const cleanupCommand = (rev, { only, docs } = {}) => + [ + "node scripts/shoot_docs.mjs --cleanup", + rev === "HEAD" ? "" : ` --against ${shellQuote(rev)}`, + only === undefined ? "" : ` --only ${shellQuote(only)}`, + docs === undefined ? "" : ` --out ${shellQuote(docs)}`, + ].join(""); + +/** + * The change sheet, a page that needs no other file but the pictures it links: `rows` + * from compareRevisionPicture; `rev` the revision as the command line gave it, `hash` + * and `short` the commit it names, `prefix` the docs folder's path from the repository's + * root; `folder` the folder the files are in, relative to the page. + */ +export function changeSheetHtml({ rows, rev, hash, short, prefix, folder = "pictures", only, docs }) { + const { figure } = figuresIn(folder); + const sorted = sortChangeRows(rows); + const kinds = (...wanted) => sorted.filter((r) => wanted.includes(r.kind)); + const changed = kinds("changed", "error"); + const noise = kinds("noise"); + const added = kinds("added"); + const deleted = kinds("deleted"); + const title = `Pictures changed since ${rev} (${short})`; + const sizes = (r) => + r.sizeOld && r.sizeNew && (r.sizeOld[0] !== r.sizeNew[0] || r.sizeOld[1] !== r.sizeNew[1]) + ? `size changed from ${sizeText(r.sizeOld)} to ${sizeText(r.sizeNew)}` + : sizeText(r.sizeNew ?? r.sizeOld); + const card = (r) => { + // Each fact is HTML: text from elsewhere is escaped as it is put in. + const facts = [`${r.theme} theme`, esc(sizes(r))]; + if (r.error !== undefined) facts.push(`not compared: ${esc(r.error)}`); + const st = r.stats; + if (st) { + if (!st.sameSize) + facts.push( + `compared aligned at the top left over the common ${st.common[0]} x ${st.common[1]} px; the map shows the whole ${st.canvas[0]} x ${st.canvas[1]} px, a pixel only the old picture has cyan, one only the new picture has magenta`, + ); + facts.push( + st.differing === 0 + ? "the same pixels in a different file" + : `${pctText(st.pctBeyond)}% changed beyond noise (by more than ${NOISE_LEVELS} grey levels${st.sameSize ? "" : ", or in one picture only"}), ${pctText((st.faint / st.total) * 100)}% by ${NOISE_LEVELS} or fewer`, + ); + } + const figures = []; + if (r.files.old) figures.push(figure(r.files.old, `As ${short} has it`)); + if (r.files.new) figures.push(figure(r.files.new, "As it is now")); + if (r.files.map) + figures.push( + figure(r.files.map, "Difference (yellow: 1 grey level, shading to red at 128 or more; dark grey: the same)"), + ); + const tip = st + ? `${pctText(st.pctBeyond)}% changed beyond noise; the mark is ${NOISE_SHARE * 100}%, the share of the pixels the noise rule lets differ` + : ""; + return cardHtml({ + out: r.out, + meta: facts.join(", "), + meter: st ? meterHtml(st.pctBeyond, 10, NOISE_SHARE * 100, tip) : "", + figures, + }); + }; + const toc = (list) => list.map((r) => tocItem(r.out, r.stats ? `${pctText(r.stats.pctBeyond)}%` : r.kind)).join("\n"); + const group = (id, heading, list, note = "") => + `

    ${esc(heading)} (${list.length})

    +${note}${list.length ? list.map(card).join("\n") : "

    None.

    "}`; + const noiseNote = `

    These differ from ${esc(short)} within the noise rule the screenshotter applies (no pixel more than ${NOISE_LEVELS} grey levels off, and at most ${NOISE_SHARE * 100}% of the pixels off at all), and it never writes a capture that does: a run that kept the other version of the noise, or drift over several runs, left them.

    +${ + noise.length + ? `

    To put them all back as ${esc(short)} has them, run this in the repository's root (this page only shows it; nothing has been run):

    +
    ${esc(
    +        restoreCommand(
    +          rev,
    +          prefix,
    +          noise.map((r) => r.out),
    +        ),
    +      )}
    +

    Or this, which also puts back each one's SVG as ${esc(short)} has it (removed where it has none) and starts no IDE; with --dry-run it only lists them:

    +
    ${esc(cleanupCommand(rev, { only, docs }))}
    +` + : "" +}`; + const section = (id, heading, list) => + `
  • ${esc(heading)} (${list.length})\n
      \n${toc(list)}\n
  • `; + return pageHtml({ + generator: SHEET_GENERATOR, + title, + intro: `Every PNG under docs that differs from the commit ${esc(hash)} (${esc(rev)}): the picture as the commit has it, as it is now, and the difference between them, aligned at the top left. The change is the share of pixels that differ by more than ${NOISE_LEVELS} grey levels (or exist in one picture only). Select a picture to open it full size.`, + summary: [ + ["changed", changed.length], + ["differ only by capture noise", noise.length], + ["added", added.length], + ["deleted", deleted.length], + ], + contents: [ + section("changed", "Changed, most changed first", changed), + section("noise", "Differ only by capture noise", noise), + section("added", "Added", added), + section("deleted", "Deleted", deleted), + ].join("\n"), + body: [ + group("changed", "Changed, most changed first", changed), + group("noise", "Differ only by capture noise, most likely rewritten in error", noise, noiseNote), + group("added", "Added", added), + group("deleted", "Deleted", deleted), + ].join("\n"), + }); +} diff --git a/test/svgshot.test.mjs b/test/svgshot.test.mjs index 2cfd04eda..70621d3fa 100644 --- a/test/svgshot.test.mjs +++ b/test/svgshot.test.mjs @@ -16,8 +16,9 @@ // measured by the bench pages. And colour-filter.mjs, which bakes a filter of colour functions // into the colours its group paints. And redraw.mjs, which replay.mjs and sheet.mjs share: // redraw, which draws a picture again from its bundle or names its retake, and fails on the -// user name unless its caller turns the check off; the pictures listed; the folders a sheet -// is refused; and the sheet's page. And the sheet of changes against a git revision +// user name unless its caller turns the check off; and the pictures listed. And sheets.mjs: +// the folders a sheet is refused, and the sheet's page, made of lib/shot-views.mjs's pieces. +// And the sheet of changes against a git revision // (sheet.mjs --against): git's change list, the comparison of two pictures over two sizes, // the noise rule, the order and the page. And lib/record-lines.mjs, the records a child // process writes among its lines, which runShards reads. And what becomes of a picture and @@ -64,28 +65,30 @@ import { revisionOf, svgFate, } from "../scripts/lib/shot-revision.mjs"; +import { + judgedFiles, + listPictures, + pngSize, + redraw, + retakeText, + shareOf, + slugOf, + themeOf, +} from "../scripts/svgshot/redraw.mjs"; import { cleanupCommand, changeFiles, changeSheetHtml, compareRevisionPicture, - judgedFiles, - listPictures, restoreCommand, sortChangeRows, mayWriteSheet, notDrawn, outputRefusal, - pngSize, - redraw, - retakeText, SHEET_GENERATOR, sheetHtml, - shareOf, - slugOf, sortRows, - themeOf, -} from "../scripts/svgshot/redraw.mjs"; +} from "../scripts/svgshot/sheets.mjs"; import { bakeable, bakeColour, @@ -1279,7 +1282,7 @@ describe("web fonts as WOFF2", () => { }); }); -// redraw.mjs's parts that need no browser: what sheet.mjs lists, where it may write, and the page. +// redraw.mjs's parts that need no browser, what sheet.mjs lists, and sheets.mjs: where it may write, and the page. describe("the sheet", () => { const PNG = (w, h) => { const b = Buffer.alloc(33); From 51e1164b4346b6eaf2cd64bda467edd565ad360d Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Sat, 31 Oct 2026 21:13:46 +0100 Subject: [PATCH 11/85] The screenshot manifest, test/shots/manifest.json (lib/shot-manifest.mjs): each picture's build, recorded by shoot_docs.mjs, and its allowances and a review's decisions, which decidePicture, decideSvg, --cleanup and replay.mjs judge with --- WIP.ShotReview.md | 55 ++-- WIP.md | 4 +- docs/Documentation/Tools-IDE.md | 4 +- docs/Documentation/Tools-Toolchain-Tests.md | 1 + scripts/lib/shot-manifest.mjs | 225 +++++++++++++++++ scripts/lib/shot-policy.mjs | 82 +++++- scripts/lib/shot-revision.mjs | 11 +- scripts/shoot_docs.mjs | 121 +++++++-- scripts/svgshot/keep.mjs | 19 +- scripts/svgshot/replay.mjs | 31 ++- test/shots/manifest.json | 4 + test/svgshot.test.mjs | 265 +++++++++++++++++++- 12 files changed, 742 insertions(+), 80 deletions(-) create mode 100644 scripts/lib/shot-manifest.mjs create mode 100644 test/shots/manifest.json diff --git a/WIP.ShotReview.md b/WIP.ShotReview.md index 0bce4e667..12e59e076 100644 --- a/WIP.ShotReview.md +++ b/WIP.ShotReview.md @@ -2,8 +2,8 @@ A localhost tool for reviewing what `shoot_docs.mjs` and `replay.mjs` decided about each picture, overriding it, starting retakes and replays, and committing the result. **Design -agreed with the owner on 2026-10-31; step 1 of [the order of work](#order-of-work) is done -but for three rows that later steps make, and the tool itself is not started.** The mockups it was agreed on were inline in that +agreed with the owner on 2026-10-31; steps 1 and 2 of [the order of work](#order-of-work) are +done (step 1 but for three rows that later steps make), and the tool itself is not started.** The mockups it was agreed on were inline in that session and are not kept: the pages are described below instead. The tool covers everything the command-line screenshot tools do, interactively, with a person @@ -161,8 +161,9 @@ Two kinds, which last for different times: Both are kept in **the manifest**, with the build each picture was taken on. **The command-line tools read it too**: `shoot_docs.mjs` and `replay.mjs` judge with each picture's allowances and honour its decisions, so that a run from a terminal and a run from the tool -come out the same. Proposed shape, outside `docs/` so the publish allowlist never meets it -(the path is open): +come out the same. It is **`test/shots/manifest.json`** (owner, 2026-10-31), outside `docs/` +so the publish allowlist never meets it, read and written by `scripts/lib/shot-manifest.mjs` +alone (its header has the format): ```json { @@ -172,16 +173,24 @@ come out the same. Proposed shape, outside `docs/` so the publish allowlist neve "build": 1501, "png": { "noise": { "levels": 16, "share": 0.5 }, "ignore": [[812, 40, 900, 64]], "decision": { "keep": "old", "capture": "" } }, - "svg": { "bar": { "levels": 96, "share": 0.35 }, "ignore": [], + "svg": { "bar": { "levels": 96, "share": 0.35 }, "decision": { "accept": true, "svg": "", "png": "" } } } } } ``` -`build` is written when a capture is written to `docs/`, and stays when the file stays (noise, -restored). A picture with no entry shows "build unknown" until it is next written. Only what -differs from the defaults is stored. +A picture of each theme is an entry of its own (`X.light.png`). Both shares are percentages, +as the rules are stated (0.5% of the pixels, 0.25%). `build` is written when a capture is +written to `docs/` (new, updated), stays when the file stays (noise, restored), and goes when +the file is removed (a light picture with the dark one's pixels, a `once` picture's light +file). A picture with no entry shows "build unknown" until it is next written. Only what +differs from the defaults is stored, and the file is written only when what it holds changes, +in a fixed order (pictures by path), so its diffs are small. Until the strength map exists +(step 3) an SVG's bar is at 8, 32 or 96 levels, the three `diff.mjs` counts, and `svg.ignore` +is refused. The manifest is read and written only for the default `--out`: its paths are under +`docs/`. A light picture decided "new" is kept even with the dark one's pixels; one decided +"old" is still found to be the dark one by the rule. ## Sessions @@ -247,7 +256,7 @@ Each step ends tested and usable on its own. `replay.mjs` and `sheet.mjs` calling the shared modules. Done but for three rows: see [Where the work stands](#where-the-work-stands). 2. **The manifest**: read and written by one module; `shoot_docs.mjs` and `replay.mjs` judge - with its allowances, honour its decisions and record `build`. + with its allowances, honour its decisions and record `build`. Done. 3. **The strength map, the regions and the queue's rules**, tested on fixtures. 4. **`--stage`, `--events` and stop** in `shoot_docs.mjs` and `replay.mjs`. 5. **The server and the session store**, the overview and the detail page read-only, live. @@ -261,15 +270,16 @@ Each step ends tested and usable on its own. ## Where the work stands -On `staging` since 2026-10-31, step 1 but for the three rows that are new code (below). Each -move kept the command-line tools' output as it was, checked as +On `staging` since 2026-10-31, step 1 but for the three rows that are new code (below), and +step 2. Each move kept the command-line tools' output as it was, checked as [below](#checking-a-move). | Row of the factoring table | Where it is now | |---|---| | The noise rule | `withinNoise(A, B, {levels, share, rects})`, `scripts/lib/shot-change.mjs` | -| A PNG's verdict | `decidePicture({png, theme, dark, revision, disk, near})` in `scripts/lib/shot-policy.mjs`, with `SIZE_SLACK`: pictureFate's states plus `too-far` and `dark` (a light picture with the dark one's pixels). It does not read the manifest yet (step 2) | -| An SVG's verdict | `decideSvg(stats, {bar})` in `shot-policy.mjs`, with `SVG_FAITHFUL` (moved from `keep.mjs`); `judgeSvg` asks it. It reads `stats.pct96` until the strength map exists (step 3) | +| A PNG's verdict | `decidePicture({png, theme, dark, revision, disk, near, entry})` in `scripts/lib/shot-policy.mjs`, with `SIZE_SLACK`: pictureFate's states plus `too-far` and `dark` (a light picture with the dark one's pixels), and `by` ("decision" or "rule") and `earlier` (a decision about other bytes). `near(a, b, rule)` takes `pngRule(entry)`; `cleanupResets` is `--cleanup`'s verdict, with the path passed through `planCleanup` | +| An SVG's verdict | `decideSvg(stats, {entry, svg, png})` in `shot-policy.mjs`, with `SVG_FAITHFUL` and `svgRule(entry)`: `{faithful, by}`. `judgeSvg(browser, svg, png, stats, entry)` asks it. It reads `stats.pct8`, `pct32` or `pct96` until the strength map exists (step 3) | +| The manifest | `scripts/lib/shot-manifest.mjs`: `MANIFEST`, `openManifest(file)` (`entry`, `setBuild`, `clearBuild`, `save`, `text`), `parseManifest`, `manifestText`, `pngDecision`, `earlierPngDecision`, `svgDecision`, `hashOf`. `shoot_docs.mjs` passes each picture's rectangles to `capture` as `c.shot.ignore`, left out as its unsteady regions are, and records `build` (`buildNumber(ide)`) in `keep` and `dropLight` | | Writing a verdict | `scripts/lib/shot-apply.mjs`: `applyPicture(outRoot, out, decision, revision)`, `applySvg(outRoot, svgOut, svg or null)`, `restorePictures(outRoot, revision, outs, {dryRun})` (the PNGs and their SVGs, the SVGs by one `git restore`; `--cleanup` and a `restored` picture both use it), `dropPicture(outRoot, out, bundlesRoot)`, `writeSvg`, `svgOf`. What stays in `shoot_docs.mjs`'s `keep` and `keepSvg`: the counts, the lines it prints, the `--diffs` pictures, the bundle written, the user-name check, and the rule that an SVG stays as it is when its PNG was not written | | The git listing | `changedPictures`, `parseNameStatus`, `parseUntracked` in `scripts/lib/shot-revision.mjs` | | A child's report | `scripts/lib/record-lines.mjs`: `recordLine`, `writeRecord`, `parseRecord`, `readRecords(stream, kinds, {onRecord, onLine})`; `runShards` reads `svgshot-result` records with it. `--events` is to write `shot-event` records the same way | @@ -277,10 +287,20 @@ move kept the command-line tools' output as it was, checked as | Server-sent events | `createSseChannel()` in `lib/sse.mjs`: `handle(req, res)`, `send(event, data)`, `close()`; `builder/serve.mjs` uses one | | The installs | `listIdes()` in `scripts/lib/tb-install.mjs`, newest first, `[{build, exe}]` | -**Next: step 2**, the manifest. The three rows left in the table are new code, made in the -steps that need them: the strength map and `changeRegions` in step 3, the commit helper in -step 8. The tool's pages will add their actions to the pieces in `shot-views.mjs` (step 5); -until then only `sheets.mjs` calls them. +**Next: step 3**, the strength map, the regions and the queue's rules: `diff.mjs` returns the +strength map, `pct8`/`pct32`/`pct96` derived from it, `changeRegions` in `shot-change.mjs`, and +then an SVG's bar at any level and `svg.ignore` in the manifest (`checkEntry` refuses it +today). The other row left in the table, the commit helper, is step 8. The tool's pages will +add their actions to the pieces in `shot-views.mjs` (step 5); until then only `sheets.mjs` +calls them. + +Step 2 was checked with the runs [below](#checking-a-move), whose states it left as they were +with an empty manifest, and with an allowance and a decision on `Menu_Tools_IDEOptions`: the +light picture copied over the dark one (the same size; `withinNoise` never matches two sizes) +and `png.noise` at 255 levels and 100% leave it `unchanged but for a capture's noise` under +`--against-disk`, and `--cleanup --dry-run` puts it back; a `keep: "new"` decision on the +file's hash makes it a real change; and `svg.bar` at 8 levels and 0.001% has `replay.mjs` +remove the light SVG. **A small cleanup, in no step:** a PNG's size is read from its header by four copies of one line (`pngSize` in `shot-policy.mjs` as `{w, h}`, in `redraw.mjs` as `[w, h]` or null, in @@ -316,7 +336,6 @@ MutationObserver (`tabCloseIcon`). ## Open -- **The manifest's path.** Outside `docs/`; beside `shoot_docs.mjs` is one choice. - **The server's port.** A stable default with `--port`, as bookmarks need, or a port from `tbbroker`, which today leases one only with a desktop. - **Allowances in the static sheets**: whether `sheet.mjs` shows them, as the overview does. diff --git a/WIP.md b/WIP.md index 3cbe9cf9d..3f7b97626 100644 --- a/WIP.md +++ b/WIP.md @@ -585,7 +585,7 @@ Why the report separates the wedged task from the merely blocked ones, and why - `addin-test.bat` — tests IDE add-ins by operating an IDE: every lane in `test/addin/lanes.mjs` builds the add-ins it tests into a private copy of the install, opens a project and checks what the add-in does. Outside every gate and outside CI for the same reasons as `examples.bat`; the seventeen lanes: Samples 10 and 15, the thirteen probe lanes behind Stage 2's answers in [WIP.HelpAddin.md](WIP.HelpAddin.md), and the help add-in in `add-in/` twice, built with its committed copy of the symbol index, `add-in/Resources/SYMBOLS/symbols.json`, which a local build rewrites whenever the index changes. The `help` lane serves the built `docs/_site` on localhost to the add-in's pane; `help-offline` builds the add-in with a help archive of `docs/_site-offline` and the pane's pages come from the add-in's own server. So run `build.bat` first. Exit 0 every lane passed and the registry is as it was found, 1 a lane failed, 2 the harness failed, 3 the registry or a work folder was not put back. See [Driving the twinBASIC compiler](#driving-the-twinbasic-compiler) for its rules. - `ide-test.bat` --- the same runner for scenarios that operate the IDE itself rather than an add-in: every lane in `test/ide/lanes.mjs`. Same exit codes, same standing outside every gate and outside CI, same rules. - `try-help-addin.bat` --- opens one IDE on the user's desktop with the help add-in built and loaded, to try it by hand, and puts the registry back once it is closed (see [Driving the twinBASIC compiler](#driving-the-twinbasic-compiler)). Needs `build.bat` first. Exit 0 closed and put back, 1 the add-in or the project did not compile, 2 the tool could not run, 3 not put back. -- `node scripts/shoot_docs.mjs` --- writes the documentation's IDE screenshots (plan and owner's decisions: [WIP.Screenshots.md](WIP.Screenshots.md)). Each picture is a shot in a table, `out` its path under `docs/` (`--only` matches it), in a setup (the main three below; the full list is in WIP.Screenshots.md), each one IDE on a **private** desktop started only when a shot in it is selected: `help` (builds the add-in as `try-help-addin.bat` does, opens `test/addin/helpdemo`, `TB_DOCS_HELP_REAL_TITLEBAR=0`: the eight `docs/IDE/AddIns/Images/Help_.png`), `no-project` (`Lane.openNoProject` with `includeProjects`: the menus, cut out with alpha, the dialogs that need no project, and the empty panels, each floated on its own, after the dialogs) and `project` (the demo without the add-in: About, which needs the compiler's licence answer, and IDE Options, whose font lists need a project). One setup is not an IDE: `web` starts puppeteer's Chrome through `launchOnDesktop` on a private desktop of its own (never headless, never on the user's desktop; native-window occlusion and occluded-window backgrounding switched off, or a capture waits forever or comes back blank) for the two public pages the FAQ shows, taken once; it needs the network, as do `webpage` (the Webpage pane on `https://docs.twinbasic.com/`, its page captured from the pane's own DevTools target and pasted into the pane's place) and the package-server setups, which never publish. Every IDE gets `--force-device-scale-factor=1` (`Lane`'s `browserArgs`), else the page follows the display's scaling; the page is overridden to 1280x880 at 2x, and `PAGE_DEFAULTS` puts the IDE's options, panel layouts and keyboard groups to their defaults and the recent lists to the tool's own **in the page only**, nothing written because the IDE has `IDE_FLAGS` (the one recent-list call they do not guard, `RemoveRecentsList`, is replaced first). Each picture is a `Page.captureScreenshot` with a `clip`; a page showing it gives it `{:width height}` of half its pixel size. **Every picture is taken in two themes by one IDE**: dark as `X.png`, then, after `backToPrepared` and `tbTheme_SwitchToLightMode`, Light (not Classic) as `X.light.png` beside it, which the build pairs by name ([WIP.Build.md](WIP.Build.md#pictures-in-two-themes)); a light picture with the dark one's pixels is not kept, and one more than 32 device pixels off the dark one's size fails (a wrong state, not the theme). A label's pill follows the theme unless the shot names a `tone`. A shot's `annotate` list (arrows, rings, badges...) is drawn by `scripts/lib/shot-annotate.mjs` as one SVG layer over the page before the capture and removed after it, the clip growing to hold it. A picture is rewritten only when its bytes differ, and a second run reports every one `unchanged`; a capture is not byte-stable, so `capture` captures back to back, with no rest, until one equals the file on disk or two agree, and, when the two that agree are not the file, goes on capturing further apart, for 5.5 s at most, until one is, keeping the last two that agreed, and a picture that differs from the file only by a capture's noise (16 grey levels, 0.5% of its pixels) is left as it is. **The reference is the picture at git `HEAD` first, then the file** (owner): compared with the file alone, a picture drifted from `HEAD` over the runs of one retake, each step within noise of the file before it. A capture equal to or within noise of `HEAD`'s picture leaves the file as `HEAD` has it, its SVG too (`HEAD`'s, or none), reported `restored` when it held anything else; one that is not, but is within noise of the file, leaves the file (an earlier run's real change); a picture `HEAD` lacks is compared with the file only (`pictureFate`, `scripts/lib/shot-revision.mjs`; the run's pictures at `HEAD` read in one `git cat-file --batch`). `--against ` takes another revision, `--against-disk` the file only. **`--cleanup`** (`--dry-run` to list) starts no IDE: it resets every PNG git reports modified that is within noise of `HEAD`'s, with its SVG, so that only real changes stay; one noise rule, `withinNoise` in `scripts/lib/shot-change.mjs`, for the capture, `--cleanup` and `sheet.mjs --against`. `--jobs N` (default 6) runs the setups at once, each in a slot of its own from `tbbroker`, a full run with `--svg` in about 12 minutes on BETA 1010 (`--jobs 1`: one at a time); `--diffs ` writes the old picture, the new one and an amplified difference map for each that changed, named with its theme. Settle by a condition, never a fixed sleep, and a picture that shows some state sets it itself rather than relying on the shots before it. The page's animations and carets are switched off through a style sheet put into the document and every shadow root; a picture that holds DEBUG CONSOLE lines has their times (and a CodeLens run's time taken) set in the page once the real ones show. **A running program's window** (setup `programs`, fixture `test/shots/programs`) is taken **once** (`once: true`, the dark pass only) with `captureWindow`, cropped to its visible frame, at the display's DPI, shown at its size at 96 DPI. **The Windows user name must never be in a published picture**: the visible text of the page, text boxes included, is searched for it before the first capture and again when a capture is chosen, with the picture's SVG under `--svg` (a page can draw again in between: Project Settings puts a real path back over the one the shot set), and for a program's window the texts of the window and its children, and the run fails when it is there. The tool finds each name it presses F1 on by searching the demo's source, not by line. The add-in's detached window has a port from the broker (the help setup's slot carries one), since the add-in is told it before it binds it. **`--svg`** also draws each picture as SVG from the page as it stands at the capture (`scripts/svgshot/`: a DOM snapshot converted, its text set in per-picture subsets of the fonts the IDE drew it with, cut by `scripts/subset_font.py`, which needs Python with fontTools), compares it with the PNG, and writes `X.svg` or `X.light.svg` beside it only when no more than 0.25% of its pixels are far off; otherwise an older SVG is removed and the page shows the PNG; the SVG of a picture whose PNG is not rewritten stays as it is, with its bundle (owner) ([WIP.Build.md](WIP.Build.md#pictures-as-svg)). With `--svg` each picture's *bundle*, what its SVG is drawn from, is saved in the gitignored `.svgshot-bundles/` (`--bundles`; never under `docs/`, a bundle holds the user name), and **after a change to the converter, `node scripts/svgshot/replay.mjs` draws every SVG again from the bundles, with no IDE**, in about 20 s for the 370 (an SVG the same as its file is not compared again, `--recheck` compares all; the fonts' cuts are kept in `.svgshot-bundles/subsets/`; one browser per process, GPU off); it lists the pictures whose bundle cannot answer what the converter now asks, with the `--only` command that retakes them ([WIP.Screenshots.md](WIP.Screenshots.md#pictures-as-svg)). `node scripts/svgshot/sheet.mjs --out ` writes a page of the pictures with no SVG (`--all`: every picture), each drawn again from its bundle beside its PNG and difference map, worst first; it is drawn without the user-name check (owner: the sheet is the owner's alone), so `--out` is refused under `docs/` and anywhere git does not ignore; what it and replay share is `scripts/svgshot/redraw.mjs`, and its pages are `scripts/svgshot/sheets.mjs`, made of the pieces in `scripts/lib/shot-views.mjs`. `node scripts/svgshot/bench.mjs` tests the converter's replicas of what Chromium draws outside the DOM (scrollbars, drop-down lists) in Edge, with no IDE ([WIP.Screenshots.md](WIP.Screenshots.md#pictures-as-svg)). No wrapper; outside every gate and outside CI, as `try-help-addin.bat` is. Exit 0 every picture written or unchanged, 1 a picture failed, 2 the tool could not run, 3 not put back. Run `build.bat` first. +- `node scripts/shoot_docs.mjs` --- writes the documentation's IDE screenshots (plan and owner's decisions: [WIP.Screenshots.md](WIP.Screenshots.md)). Each picture is a shot in a table, `out` its path under `docs/` (`--only` matches it), in a setup (the main three below; the full list is in WIP.Screenshots.md), each one IDE on a **private** desktop started only when a shot in it is selected: `help` (builds the add-in as `try-help-addin.bat` does, opens `test/addin/helpdemo`, `TB_DOCS_HELP_REAL_TITLEBAR=0`: the eight `docs/IDE/AddIns/Images/Help_.png`), `no-project` (`Lane.openNoProject` with `includeProjects`: the menus, cut out with alpha, the dialogs that need no project, and the empty panels, each floated on its own, after the dialogs) and `project` (the demo without the add-in: About, which needs the compiler's licence answer, and IDE Options, whose font lists need a project). One setup is not an IDE: `web` starts puppeteer's Chrome through `launchOnDesktop` on a private desktop of its own (never headless, never on the user's desktop; native-window occlusion and occluded-window backgrounding switched off, or a capture waits forever or comes back blank) for the two public pages the FAQ shows, taken once; it needs the network, as do `webpage` (the Webpage pane on `https://docs.twinbasic.com/`, its page captured from the pane's own DevTools target and pasted into the pane's place) and the package-server setups, which never publish. Every IDE gets `--force-device-scale-factor=1` (`Lane`'s `browserArgs`), else the page follows the display's scaling; the page is overridden to 1280x880 at 2x, and `PAGE_DEFAULTS` puts the IDE's options, panel layouts and keyboard groups to their defaults and the recent lists to the tool's own **in the page only**, nothing written because the IDE has `IDE_FLAGS` (the one recent-list call they do not guard, `RemoveRecentsList`, is replaced first). Each picture is a `Page.captureScreenshot` with a `clip`; a page showing it gives it `{:width height}` of half its pixel size. **Every picture is taken in two themes by one IDE**: dark as `X.png`, then, after `backToPrepared` and `tbTheme_SwitchToLightMode`, Light (not Classic) as `X.light.png` beside it, which the build pairs by name ([WIP.Build.md](WIP.Build.md#pictures-in-two-themes)); a light picture with the dark one's pixels is not kept, and one more than 32 device pixels off the dark one's size fails (a wrong state, not the theme). A label's pill follows the theme unless the shot names a `tone`. A shot's `annotate` list (arrows, rings, badges...) is drawn by `scripts/lib/shot-annotate.mjs` as one SVG layer over the page before the capture and removed after it, the clip growing to hold it. A picture is rewritten only when its bytes differ, and a second run reports every one `unchanged`; a capture is not byte-stable, so `capture` captures back to back, with no rest, until one equals the file on disk or two agree, and, when the two that agree are not the file, goes on capturing further apart, for 5.5 s at most, until one is, keeping the last two that agreed, and a picture that differs from the file only by a capture's noise (16 grey levels, 0.5% of its pixels) is left as it is. **The reference is the picture at git `HEAD` first, then the file** (owner): compared with the file alone, a picture drifted from `HEAD` over the runs of one retake, each step within noise of the file before it. A capture equal to or within noise of `HEAD`'s picture leaves the file as `HEAD` has it, its SVG too (`HEAD`'s, or none), reported `restored` when it held anything else; one that is not, but is within noise of the file, leaves the file (an earlier run's real change); a picture `HEAD` lacks is compared with the file only (`pictureFate`, `scripts/lib/shot-revision.mjs`; the run's pictures at `HEAD` read in one `git cat-file --batch`). `--against ` takes another revision, `--against-disk` the file only. **`--cleanup`** (`--dry-run` to list) starts no IDE: it resets every PNG git reports modified that is within noise of `HEAD`'s, with its SVG, so that only real changes stay; one noise rule, `withinNoise` in `scripts/lib/shot-change.mjs`, for the capture, `--cleanup` and `sheet.mjs --against`. **The manifest, `test/shots/manifest.json`** (`scripts/lib/shot-manifest.mjs`; [WIP.ShotReview.md](WIP.ShotReview.md#remembered-overrides)), records the build of each picture a run writes, and holds each picture's allowances (its noise rule's limits and rectangles left out, its SVG's bar) and a review's decisions, each tied to the bytes it was made on; `decidePicture` and `decideSvg` (`scripts/lib/shot-policy.mjs`) judge with them, in a run, in `--cleanup` and in `replay.mjs`, so a run from a terminal and one from the review tool come out the same. It is read and written only for the default `--out`. `--jobs N` (default 6) runs the setups at once, each in a slot of its own from `tbbroker`, a full run with `--svg` in about 12 minutes on BETA 1010 (`--jobs 1`: one at a time); `--diffs ` writes the old picture, the new one and an amplified difference map for each that changed, named with its theme. Settle by a condition, never a fixed sleep, and a picture that shows some state sets it itself rather than relying on the shots before it. The page's animations and carets are switched off through a style sheet put into the document and every shadow root; a picture that holds DEBUG CONSOLE lines has their times (and a CodeLens run's time taken) set in the page once the real ones show. **A running program's window** (setup `programs`, fixture `test/shots/programs`) is taken **once** (`once: true`, the dark pass only) with `captureWindow`, cropped to its visible frame, at the display's DPI, shown at its size at 96 DPI. **The Windows user name must never be in a published picture**: the visible text of the page, text boxes included, is searched for it before the first capture and again when a capture is chosen, with the picture's SVG under `--svg` (a page can draw again in between: Project Settings puts a real path back over the one the shot set), and for a program's window the texts of the window and its children, and the run fails when it is there. The tool finds each name it presses F1 on by searching the demo's source, not by line. The add-in's detached window has a port from the broker (the help setup's slot carries one), since the add-in is told it before it binds it. **`--svg`** also draws each picture as SVG from the page as it stands at the capture (`scripts/svgshot/`: a DOM snapshot converted, its text set in per-picture subsets of the fonts the IDE drew it with, cut by `scripts/subset_font.py`, which needs Python with fontTools), compares it with the PNG, and writes `X.svg` or `X.light.svg` beside it only when no more than 0.25% of its pixels are far off; otherwise an older SVG is removed and the page shows the PNG; the SVG of a picture whose PNG is not rewritten stays as it is, with its bundle (owner) ([WIP.Build.md](WIP.Build.md#pictures-as-svg)). With `--svg` each picture's *bundle*, what its SVG is drawn from, is saved in the gitignored `.svgshot-bundles/` (`--bundles`; never under `docs/`, a bundle holds the user name), and **after a change to the converter, `node scripts/svgshot/replay.mjs` draws every SVG again from the bundles, with no IDE**, in about 20 s for the 370 (an SVG the same as its file is not compared again, `--recheck` compares all; the fonts' cuts are kept in `.svgshot-bundles/subsets/`; one browser per process, GPU off); it lists the pictures whose bundle cannot answer what the converter now asks, with the `--only` command that retakes them ([WIP.Screenshots.md](WIP.Screenshots.md#pictures-as-svg)). `node scripts/svgshot/sheet.mjs --out ` writes a page of the pictures with no SVG (`--all`: every picture), each drawn again from its bundle beside its PNG and difference map, worst first; it is drawn without the user-name check (owner: the sheet is the owner's alone), so `--out` is refused under `docs/` and anywhere git does not ignore; what it and replay share is `scripts/svgshot/redraw.mjs`, and its pages are `scripts/svgshot/sheets.mjs`, made of the pieces in `scripts/lib/shot-views.mjs`. `node scripts/svgshot/bench.mjs` tests the converter's replicas of what Chromium draws outside the DOM (scrollbars, drop-down lists) in Edge, with no IDE ([WIP.Screenshots.md](WIP.Screenshots.md#pictures-as-svg)). No wrapper; outside every gate and outside CI, as `try-help-addin.bat` is. Exit 0 every picture written or unchanged, 1 a picture failed, 2 the tool could not run, 3 not put back. Run `build.bat` first. - **The help add-in's project file is written by every build, CI's included.** The `addinProject` task (`builder/addin-project.mjs`) packs `add-in/` into `tB/IDE/AddIns/downloads/tbDocsHelp.twinproj` in the **online tree only**, which the Help Add-In page offers as a download; `_config.yml`'s `addin_project` declares both ends, so a fixture config gets none. It waits for the `helpArchive` task. It packs the files `git ls-files` lists there, read from the working folder, never a `Resources/HELP/` file from the disk, with CRLF made LF outside `Resources/` (so a Windows and a Linux build write the same bytes), the `tB/symbols.json` the same build wrote in place of the committed copy, and **the zip that build's archive task wrote** as `Resources/HELP/site.zip`, passed in memory (a build that wrote none --- `--no-help-archive`, `--serve`, another `--dest`, a fixture --- packs without it, and the summary line says `without the offline archive`). The download is about 61 MB with the archive, 1 MB without. The offline tree holds no copy (the project carries that tree's archive, so a copy there would be circular), and its links to the file are rewritten to the website by the general rule in `offline-rewrite.mjs`: a URL the offline tree lacks but the online tree has becomes `site.url` + path, and is no miss (`computeWebsiteUrl`; the offline link check's `forbidExempt` excuses exactly those links, `isWebsiteOnlyLink`). The pack is `packFiles` from `scripts/impexp.mjs`, in memory (never staged through `%TEMP%`), the one module under `scripts/` that `builder/` may import (`biome.jsonc`); `check_tree_fresh.mjs` watches it and `add-in/` for that reason. The publish allowlist exempts the one path in the trees only (`publishPolicyFor`'s `generated`); a `.twinproj` anywhere else, or in `docs/`, is refused. `--serve` writes it into its one tree; `--dry-run` writes nothing. A failure (no git) fails the build with exit 1 and the index audit names the missing file. **Don't build the DLLs in CI from it**: that needs a twinBASIC install; the download is the project, and the reader builds it. Why each of these: [WIP.Build.md](WIP.Build.md#the-help-add-ins-project-file); `test/addin-project.test.mjs` is the gate. - **The help archive is the `helpArchive` task of the build's graph.** `builder/help-archive-step.mjs` writes `add-in/Resources/HELP/site.zip` from `docs/_site-offline` through `lib/help-archive.mjs`, once every task that writes into the offline tree has finished (`flushJoin`, `scss`, `searchIndex`, `writeOffline`; the archive task lists exactly those) and **before** the Gantt injection. **The archive leaves out `HELP_ARCHIVE_SKIP`** (`builder/help-archive-step.mjs`): the Documentation Development section (`BuildInfo.html` and `assets/images/gantt.svg` with it, so the chart never reaches the zip), Videos and their poster frames, Challenges, and **every download, which stays online** (owner): a file readers download goes in a `downloads` folder beside its page, and the step fails the build when the Downloads page offers a file the archive would hold. The add-in's server redirects any file the archive lacks to the website, because pages link to all of these; a new entry needs nothing else. It reads the file back to verify it. A mismatch fails the build (exit 1). After the Gantt injection `recheckHelpArchive` checks that the offline tree on disk lists the same files as the archive and differs from it only in `BuildInfo.html` and `gantt.svg`; a task that wrote into the offline tree late fails the build there. The help add-in embeds the zip as a resource and serves the pages from it with its own server; a build of the add-in without it uses the live site. The file is gitignored (about 60 MB, 10 MB of it the search index's `search-index.js`, deflated), so a build of the add-in from a clean clone is the online one until `build.bat` has run. The download carries the same zip (previous bullet). **`--no-help-archive` turns the step off, and neither CI workflow may pass it** (`check_ci_workflows.mjs` refuses it, in `ALLOWED.forbiddenBuildFlags`), because CI's published download would then hold no archive. It runs only for the guarded source tree (`docs`) built into the default `docs/_site-offline` (not another `--dest`, so `compare_trees` and `check_links_diff` leave it alone), never under `--serve` or `--dry-run`, never without an offline tree, and not keyed on `$CI`. There is no command-line tool for the archive: the `help-offline` lane makes its own with `writeHelpArchive`. The zip's format is fixed by the add-in's reader, which does no inflating ([WIP.HelpAddin.md](WIP.HelpAddin.md), Stage 4, increment 5). - `node scripts/bug_repro.mjs` --- the reproducer projects under `bugs//` for the entries of [BUGS-TO-REPORT.md](BUGS-TO-REPORT.md): `new`, `pack` (impexp, then the zip, written in Node), `compile`, `build` and `run` through `tbbuild` and `tbrun`, `vb6` (builds the optional `vb6/` VB6 project beside the twinBASIC one in a temp copy, runs its exe on a private desktop, and prints its `out.txt`; a project with a form or a user control is built without Unattended Execution, which VB6 refuses for it; `pack` zips its sources into `-vb6.zip`; VB6 is started only by `scripts/lib/vb6.mjs`, never from a shell; a graphical defect's `images` key makes `run` and `vb6` keep what the `PngDump` modules of `test/repro-templates/png/` draw as `images/-tb.png` and `-vb6.png`, with a `-compare.png` of both, tested by `test/png.test.mjs`), `file` (moves a filed entry and its reproducer to `bugs/filed//`; `file --marked` does every marked entry), and `verify`, which reads each `bugs/*/repro.json` and `bugs/filed/*/repro.json` and says whether the bug still reproduces on the newest beta; a `lane` reproducer names the `ide-test` or `addin-test` lane and tests that assert its bug, which `verify` runs last through that runner, a `probe` one a script under `scripts/`, and a `test` one its own `[TestCase]` Subs and compile cases through `tbrun --tests`, judged by the `PASS` lines of what is fixed and the `FAIL` lines of the bug (`new --template tests` starts one). **No reproducer should need a person at the IDE** (owner): a bug the IDE has to be operated for gets a lane test, and `manual` is left for what cannot be automated at all. It has no wrapper. **`verify` is run by a person only**, never by a gate or CI: it needs a twinBASIC install, like `examples.bat`. Each `tbbuild` and `tbrun` it starts takes a slot of its own from `tbbroker`; `--jobs N` runs N at once. Its command-line cases are in `scripts/lib/cli-cases.mjs`, and need no IDE. @@ -663,7 +663,7 @@ wrapper: | `test.bat` | `test/render.test.mjs` | `builder/render.mjs`'s markdown-it plugins, one at a time, on inputs no page holds --- today the ellipsis plugin's dot counting. Run by `node --test` | | `test.bat` | `test/strftime.test.mjs` | `builder/strftime.mjs`'s `formatDate`, which no build calls because no page sets `last_modified_date`. Run by `node --test` | | `test.bat` | `test/compact-image.test.mjs` | what an embedded image keeps (`compactImage` and `stripFile` in `scripts/lib/compact-image.mjs`): an editor's metadata goes, a notice of whose the picture is and on what terms stays (the IDE's lock icon carries a CC BY-NC-SA attribution), an sRGB profile goes and any other stays, a JPEG or a GIF is labelled by its bytes, and an image inside an SVG inside a CSS file is reached and keeps its encoding. Its second half runs `scripts/imagestrip/` over a CSS file and SVG files of every image the test builds and over the IDE's own stylesheets, and compares output, summary and reports with `stripFile` byte for byte; it is skipped unless `IMAGESTRIP_EXE` names the built exe. Run by `node --test` | -| `test.bat` | `test/svgshot.test.mjs` | an SVG screenshot is written on one line (`oneLine` in `scripts/svgshot/snapshot-svg.mjs`), a break in a tag as a space and one in text as a character reference, so that a CRLF checkout changes only its last line ending; an underline is placed and sized by the figures fitted in Edge (`underlineBand`) under the boxes that set one (`decoratingBoxes`); a background image is sized by its bytes, a JPEG the IDE calls `image/png` included (`naturalSize`); an iframe's document is decoded and placed at the iframe's content box, less its scroll, scaled when zoomed, over its canvas colour (`frameGeometry`, `frameView`, `unzoom`, `viewportScroller`, `canvasColor`); a run's web font is the `@font-face` rule CSS matching picks (`webFontFiles`), and a variable WOFF2 is read and cut per weight; a family split by `unicode-range` gives each character to the rule whose range holds it (`parseUnicodeRange`, `webRulesFor`); an inline `` is found and its ids scoped (`inlineSvgsIn`, `scopeIds`); a filter of colour functions is baked into the colours under it, in order and clamped, its pictures wrapped in a real filter and its opacity kept apart (`colour-filter.mjs`); a transformed element's boxes and text are put back from the snapshot's after-transform boxes (`elementTransform`, `untransformBox`); a closed select shows its option's whole text (`selectLabel`); a bundle draws the same SVG from its file as in memory, and counts each question it cannot answer (`renderBundle`); its web font rules and files draw each character from the file whose range holds it, at the weight its rule allows, and its inline `` markup is drawn in its box, from the file and when trimmed (`drawBundle`); a bundle of version 1 is refused; one trimmed to its view draws the same (`trimSnapshot`); a font's cut comes from the subset cache with no Python run (`subsetKey`, `useSubsetCache`); `redraw` draws a picture again from its bundle or names its retake, and fails on the user name unless `checkUserName: false`; `listPictures` leaves the output trees out; `outputRefusal` refuses a sheet under docs or where git would take it, and `sheetHtml` puts the worst picture first (`scripts/svgshot/sheets.mjs`); a capture's fate against the picture at a git revision and the file, the SVG of one put back, what `--cleanup` resets, and a revision's files read in one git process and a text file put back as a checkout writes it, in a scratch repository (`pictureFate`, `svgFate`, `planCleanup`, `readRevisionFiles`, `restoreFromRevision`, `scripts/lib/shot-revision.mjs`). What it keeps of an image it embeds is `compactImage`'s, which the row above tests. Run by `node --test` | +| `test.bat` | `test/svgshot.test.mjs` | an SVG screenshot is written on one line (`oneLine` in `scripts/svgshot/snapshot-svg.mjs`), a break in a tag as a space and one in text as a character reference, so that a CRLF checkout changes only its last line ending; an underline is placed and sized by the figures fitted in Edge (`underlineBand`) under the boxes that set one (`decoratingBoxes`); a background image is sized by its bytes, a JPEG the IDE calls `image/png` included (`naturalSize`); an iframe's document is decoded and placed at the iframe's content box, less its scroll, scaled when zoomed, over its canvas colour (`frameGeometry`, `frameView`, `unzoom`, `viewportScroller`, `canvasColor`); a run's web font is the `@font-face` rule CSS matching picks (`webFontFiles`), and a variable WOFF2 is read and cut per weight; a family split by `unicode-range` gives each character to the rule whose range holds it (`parseUnicodeRange`, `webRulesFor`); an inline `` is found and its ids scoped (`inlineSvgsIn`, `scopeIds`); a filter of colour functions is baked into the colours under it, in order and clamped, its pictures wrapped in a real filter and its opacity kept apart (`colour-filter.mjs`); a transformed element's boxes and text are put back from the snapshot's after-transform boxes (`elementTransform`, `untransformBox`); a closed select shows its option's whole text (`selectLabel`); a bundle draws the same SVG from its file as in memory, and counts each question it cannot answer (`renderBundle`); its web font rules and files draw each character from the file whose range holds it, at the weight its rule allows, and its inline `` markup is drawn in its box, from the file and when trimmed (`drawBundle`); a bundle of version 1 is refused; one trimmed to its view draws the same (`trimSnapshot`); a font's cut comes from the subset cache with no Python run (`subsetKey`, `useSubsetCache`); `redraw` draws a picture again from its bundle or names its retake, and fails on the user name unless `checkUserName: false`; `listPictures` leaves the output trees out; `outputRefusal` refuses a sheet under docs or where git would take it, and `sheetHtml` puts the worst picture first (`scripts/svgshot/sheets.mjs`); a capture's verdict with a picture's allowances and a review's decisions, and the manifest written, read back and refused when malformed (`decidePicture`, `decideSvg`, `cleanupResets`, `scripts/lib/shot-policy.mjs`; `parseManifest`, `manifestText`, `openManifest`, `scripts/lib/shot-manifest.mjs`); a capture's fate against the picture at a git revision and the file, the SVG of one put back, what `--cleanup` resets, and a revision's files read in one git process and a text file put back as a checkout writes it, in a scratch repository (`pictureFate`, `svgFate`, `planCleanup`, `readRevisionFiles`, `restoreFromRevision`, `scripts/lib/shot-revision.mjs`). What it keeps of an image it embeds is `compactImage`'s, which the row above tests. Run by `node --test` | | `test.bat` | `test/example-batches.test.mjs` | `check_examples.mjs`'s probes, from `scripts/lib/example-batches.mjs`: batching, crash isolation through a fake lane, the canaries, the fence classifier and `check_run`'s refusals, expected output, dispatcher and marker reading, with no IDE. Run by `node --test` | | `test.bat` | `test/ide-folders.test.mjs` | `shutdownIde` and `shutdownIdeAsync` remove the IDE's temp folder and profile once it is gone, and no other folder. Run by `node --test` | | `test.bat` | `test/broker.test.mjs` | `tbbroker` and its client (`scripts/lib/tb-broker.mjs`): one broker per pipe, leases, held processes, waiting, ports, a desktop left with windows on it, a restarted broker finding a desktop in use. Skipped unless `TBBROKER_EXE` names a built broker. Run by `node --test` | diff --git a/docs/Documentation/Tools-IDE.md b/docs/Documentation/Tools-IDE.md index ff0c8caa9..d55f0070e 100644 --- a/docs/Documentation/Tools-IDE.md +++ b/docs/Documentation/Tools-IDE.md @@ -127,6 +127,8 @@ A picture is written only when it differs by more than the noise two captures of The capture is then compared with the picture at `HEAD` first. One that is that picture, or within the noise of it, leaves the file as `HEAD` has it, with its SVG (the one at `HEAD`, or none when `HEAD` has none), and is reported `restored` when the file held anything else. Comparing with the file alone, a retake over several runs could drift from the repository's picture in steps each within the noise of the file before it, or keep the other version of the noise. A capture that is not near the picture at `HEAD` is compared with the file: within the noise of it, the file stays, since an earlier run of the same retake wrote that change; else the file is written. A picture `HEAD` does not have is compared with the file only. Each picture is reported as `new`, `updated`, `restored` or `unchanged`; a second run with nothing changed reports every picture `unchanged`. +Each picture can have an entry in `test/shots/manifest.json`, keyed by its path under `docs` (a light picture's with `.light`). A run records there the twinBASIC build of each picture it writes. An entry can also change how its picture is judged. It can give the picture a noise rule of its own, with other limits or with rectangles, in the picture's pixels, left out of the comparison, and it can give the picture's SVG another bar. It can also hold a decision made in a review: to keep the old picture or take the new one, for one capture, or to accept or refuse one SVG compared with one PNG. A decision applies only to exactly the bytes it was made on, and any other capture is judged by the rule. `--cleanup` follows the same entries. A run into a folder other than `docs` neither reads the file nor writes it. + The Windows user name must never be in a published picture. Before each one is kept, the visible text of its page is searched for the name, and a picture whose page holds it is refused. Every picture is twice the size it is shown at. A page that shows one gives it a `{:width @@ -155,7 +157,7 @@ Exit codes: **0** every picture was written or was unchanged (with `--cleanup`, Draws each picture's SVG again, with the SVG converter as it is now, from the bundle that [`shoot_docs.mjs --svg`](#shoot-docs) saved when it took the picture. It starts no IDE and takes no picture, so a change to the converter under `scripts/svgshot/` is tried on every picture in about 20 seconds. -Each SVG is compared with its PNG and written, left unchanged or removed as `shoot_docs.mjs` does. An SVG that comes out the same as the file already there is not compared again, so a replay with the converter unchanged reports every picture `unchanged`. +Each SVG is compared with its PNG and written, left unchanged or removed as `shoot_docs.mjs` does. An SVG that comes out the same as the file already there is not compared again, so a replay with the converter unchanged reports every picture `unchanged`. A picture whose entry in `test/shots/manifest.json` names its SVG is compared again all the same, and judged by that entry as `shoot_docs.mjs` judges it. Cutting an SVG's fonts down to the characters it draws takes most of the time, so each cut is kept in the `subsets` folder of the bundle folder, named by a hash of the font, the characters and the cutting script. [`shoot_docs.mjs --svg`](#shoot-docs) keeps and reuses them the same way. diff --git a/docs/Documentation/Tools-Toolchain-Tests.md b/docs/Documentation/Tools-Toolchain-Tests.md index af28a6fd4..4a25c899f 100644 --- a/docs/Documentation/Tools-Toolchain-Tests.md +++ b/docs/Documentation/Tools-Toolchain-Tests.md @@ -276,6 +276,7 @@ Unit tests for the SVG screenshot converter. They cover: - `scripts/svgshot/colour-filter.mjs`, which turns a CSS filter such as `brightness()` or `invert()` into the colours it produces, so the picture needs no SVG filter (a PDF viewer would show one as a bitmap). The tests check the colours step by step, and that an embedded image keeps a real filter. - `scripts/svgshot/redraw.mjs`, shared by [`replay.mjs`](Tools-IDE#replay) and [`sheet.mjs`](Tools-IDE#svg-sheet): a picture drawn again from its bundle, or listed as needing a retake, and failed when its SVG holds the Windows user name unless the caller turns that check off; and the list of pictures, with the build's output trees left out. `scripts/svgshot/sheets.mjs`, built from the pieces in `scripts/lib/shot-views.mjs`: the folders a sheet may not be written to, and the sheet's page, worst picture first. The sheet of changes against a git revision (`sheet.mjs --against`) is tested the same way, with no git and no browser: the change list read from git's output (the PNGs only, untracked ones added), the calls made to git, the noise rule of `scripts/lib/shot-change.mjs` and its comparison of pictures of one size and of two, the difference map's colours, the order of the rows, the commands that restore the pictures within noise, and the page. - `scripts/lib/shot-revision.mjs`, which [`shoot_docs.mjs`](Tools-IDE#shoot-docs) compares a new capture with: what becomes of a capture with the picture at a git revision or without one, within the noise of it, of the file on disk or of neither; the SVG that goes with a picture put back as the revision has it; which modified pictures `--cleanup` puts back; git's batch output, a name with spaces and a missing one included; and, in a scratch git repository, a revision's files read in one process and a text file put back as a checkout writes it, with the index left alone. +- `scripts/lib/shot-policy.mjs`, which decides what becomes of a capture and of its SVG, and `scripts/lib/shot-manifest.mjs`, which reads and writes `test/shots/manifest.json`: a light picture compared with the dark one first; a picture's own noise rule and SVG bar passed to the comparison; a review's decision used for the bytes it was made on and for no others, in a run and in `--cleanup`; the manifest written in a fixed order and read back the same, from an LF or a CRLF checkout; every malformed entry refused with its key named; a build recorded and the file saved only when it changes; and the repository's own manifest read. What the SVG keeps of an image it embeds is `compactImage`'s, from `scripts/lib/compact-image.mjs`, which [`compact-image.test.mjs`](#compact-image-test) tests. No browser, no built tree, well under a second. diff --git a/scripts/lib/shot-manifest.mjs b/scripts/lib/shot-manifest.mjs new file mode 100644 index 000000000..a54115c53 --- /dev/null +++ b/scripts/lib/shot-manifest.mjs @@ -0,0 +1,225 @@ +// The screenshot manifest, test/shots/manifest.json: for each picture under docs/, the build +// it was taken on and what a review remembered about it (WIP.ShotReview.md, Remembered +// overrides). shoot_docs.mjs, its --cleanup, replay.mjs and the screenshot review tool read +// it, so that a picture is judged the same way from a terminal and from the tool, and +// shoot_docs.mjs records the build of each capture it writes. +// +// A picture's entry, keyed by its path under docs/ (a light picture's with .light: each +// theme is a picture of its own), holds only what differs from the defaults: +// +// build the twinBASIC build the file was taken on +// png.noise {levels, share}: the noise rule's limits for this picture (withinNoise; +// NOISE_LEVELS, and NOISE_SHARE as a percentage) +// png.ignore [[x0, y0, x1, y1], ...]: rectangles left out of the comparison, in the +// picture's device pixels (x1 and y1 not included) +// png.decision {keep: "old" | "new", capture}: what a review decided about the capture +// whose SHA-1 is `capture`; any other capture is judged by the rule +// svg.bar {levels, share}: the SVG's bar (SVG_FAITHFUL at 96 levels) +// svg.decision {accept, svg, png}: whether the SVG whose SHA-1 is `svg`, compared with +// the PNG whose SHA-1 is `png`, is written whatever the bar says +// +// An allowance (noise, ignore, bar) lasts until it is removed; a decision lasts as long as +// the bytes it was made on. + +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { REPO_ROOT } from "../../lib/repo-paths.mjs"; + +/** The repository's manifest, for the pictures under docs/. */ +export const MANIFEST = path.join(REPO_ROOT, "test", "shots", "manifest.json"); + +export const MANIFEST_FORMAT = 1; + +/** The levels at which an SVG's difference from its PNG is counted (diff.mjs): its bar's `levels` is one of them. */ +export const SVG_LEVELS = [8, 32, 96]; + +/** The SHA-1 of a file's bytes, or of a text as UTF-8 with its line endings LF, as hex. */ +export const hashOf = (data) => + createHash("sha1") + .update(typeof data === "string" ? data.replace(/\r\n/g, "\n") : data) + .digest("hex"); + +const HASH = /^[0-9a-f]{40}$/; + +const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v); + +function checkKeys(where, value, keys) { + if (!isObject(value)) throw new Error(`${where} is not an object`); + for (const k of Object.keys(value)) if (!keys.includes(k)) throw new Error(`${where} has an unknown key, ${k}`); +} + +function checkShare(where, share) { + if (typeof share !== "number" || !(share >= 0 && share <= 100)) throw new Error(`${where} is not a percentage`); +} + +function checkRects(where, rects) { + if (!Array.isArray(rects)) throw new Error(`${where} is not a list`); + rects.forEach((r, k) => { + const ok = + Array.isArray(r) && r.length === 4 && r.every((n) => Number.isInteger(n) && n >= 0) && r[0] < r[2] && r[1] < r[3]; + if (!ok) throw new Error(`${where}[${k}] is not [x0, y0, x1, y1] with x0 < x1 and y0 < y1`); + }); +} + +function checkEntry(where, e) { + checkKeys(where, e, ["build", "png", "svg"]); + if (e.build !== undefined && !(Number.isInteger(e.build) && e.build > 0)) + throw new Error(`${where}.build is not a build number`); + if (e.png !== undefined) { + const w = `${where}.png`; + checkKeys(w, e.png, ["noise", "ignore", "decision"]); + const { noise, ignore, decision } = e.png; + if (noise !== undefined) { + checkKeys(`${w}.noise`, noise, ["levels", "share"]); + if (noise.levels !== undefined && !(Number.isInteger(noise.levels) && noise.levels >= 0 && noise.levels <= 255)) + throw new Error(`${w}.noise.levels is not a number of grey levels, 0 to 255`); + if (noise.share !== undefined) checkShare(`${w}.noise.share`, noise.share); + } + if (ignore !== undefined) checkRects(`${w}.ignore`, ignore); + if (decision !== undefined) { + checkKeys(`${w}.decision`, decision, ["keep", "capture"]); + if (decision.keep !== "old" && decision.keep !== "new") + throw new Error(`${w}.decision.keep is not "old" or "new"`); + if (!HASH.test(decision.capture ?? "")) throw new Error(`${w}.decision.capture is not a SHA-1`); + } + } + if (e.svg !== undefined) { + const w = `${where}.svg`; + // A rectangle left out of an SVG's comparison needs the strength map, which diff.mjs + // does not return yet (WIP.ShotReview.md, step 3): svg.ignore is refused until it does. + checkKeys(w, e.svg, ["bar", "decision"]); + const { bar, decision } = e.svg; + if (bar !== undefined) { + checkKeys(`${w}.bar`, bar, ["levels", "share"]); + if (bar.levels !== undefined && !SVG_LEVELS.includes(bar.levels)) + throw new Error(`${w}.bar.levels is not one of ${SVG_LEVELS.join(", ")}`); + if (bar.share !== undefined) checkShare(`${w}.bar.share`, bar.share); + } + if (decision !== undefined) { + checkKeys(`${w}.decision`, decision, ["accept", "svg", "png"]); + if (typeof decision.accept !== "boolean") throw new Error(`${w}.decision.accept is not true or false`); + if (!HASH.test(decision.svg ?? "")) throw new Error(`${w}.decision.svg is not a SHA-1`); + if (!HASH.test(decision.png ?? "")) throw new Error(`${w}.decision.png is not a SHA-1`); + } + } +} + +/** The manifest in `text`, checked: {format, pictures}. Throws, naming `name` and the key, on anything else. */ +export function parseManifest(text, name = "the manifest") { + let m; + try { + m = JSON.parse(text); + } catch (e) { + throw new Error(`${name} is not JSON: ${e.message}`); + } + checkKeys(name, m, ["format", "pictures"]); + if (m.format !== MANIFEST_FORMAT) throw new Error(`${name} is of format ${m.format}, not ${MANIFEST_FORMAT}`); + if (m.pictures !== undefined && !isObject(m.pictures)) throw new Error(`${name}'s pictures is not an object`); + for (const [out, e] of Object.entries(m.pictures ?? {})) { + if (!/^[^\\]+\.png$/i.test(out) || out.startsWith("/") || out.split("/").includes("..")) + throw new Error(`${name} has a picture that is not a path under docs with forward slashes: ${out}`); + checkEntry(`${name}'s ${out}`, e); + } + return { format: m.format, pictures: m.pictures ?? {} }; +} + +// An object with its empty objects and lists left out, and its keys in `order` first. +function pruned(value, order = []) { + if (Array.isArray(value)) return value.length ? value : undefined; + if (!isObject(value)) return value; + const keys = [ + ...order.filter((k) => k in value), + ...Object.keys(value) + .filter((k) => !order.includes(k)) + .sort(), + ]; + const out = {}; + for (const k of keys) { + const v = pruned(value[k], ENTRY_ORDER[k]); + if (v !== undefined) out[k] = v; + } + return Object.keys(out).length ? out : undefined; +} +const ENTRY_ORDER = { + png: ["noise", "ignore", "decision"], + svg: ["bar", "decision"], + noise: ["levels", "share"], + bar: ["levels", "share"], + decision: ["keep", "capture", "accept", "svg", "png"], +}; + +/** + * The manifest as its file holds it: the pictures by path, each entry's keys in a fixed + * order, empty parts left out, two spaces of indent, a rectangle on one line, LF. + */ +export function manifestText(m) { + const pictures = {}; + for (const out of Object.keys(m.pictures).sort()) { + const e = pruned(m.pictures[out], ["build", "png", "svg"]); + if (e) pictures[out] = e; + } + const text = JSON.stringify({ format: m.format, pictures }, null, 2); + // [x0, y0, x1, y1] on one line: JSON.stringify puts each number on a line of its own. + return `${text.replace(/\[\s+(\d+),\s+(\d+),\s+(\d+),\s+(\d+)\s+\]/g, "[$1, $2, $3, $4]")}\n`; +} + +/** + * The decision a review made about the capture `png` (bytes): "old", "new", or null when it + * made none, or made one about other bytes. + */ +export function pngDecision(entry, png) { + const d = entry?.png?.decision; + return d && png && d.capture === hashOf(png) ? d.keep : null; +} + +/** The decision a review made about a capture other than `png`: "old", "new", or null. */ +export function earlierPngDecision(entry, png) { + const d = entry?.png?.decision; + return d && png && d.capture !== hashOf(png) ? d.keep : null; +} + +/** + * The decision a review made about the SVG `svg` (text) compared with the PNG `png` (bytes): + * true (written), false (not written), or null when it made none about this pair. + */ +export function svgDecision(entry, svg, png) { + const d = entry?.svg?.decision; + if (!d || svg === null || png === null) return null; + return d.svg === hashOf(svg) && d.png === hashOf(png) ? d.accept : null; +} + +/** + * The manifest in `file`, or an empty one when there is none: `entry(out)` a picture's entry + * or null; `setBuild(out, build)` and `clearBuild(out)`, for a capture written and a file + * removed; and `save()`, which writes the file when what it holds has changed (a CRLF + * checkout of the same text is the same), through a file beside it renamed over it. + */ +export function openManifest(file = MANIFEST) { + const had = existsSync(file) ? readFileSync(file, "utf8") : null; + const m = had === null ? { format: MANIFEST_FORMAT, pictures: {} } : parseManifest(had, file); + const entry = (out) => m.pictures[out] ?? null; + return { + file, + entry, + setBuild(out, build) { + if (build === null || build === undefined) return; + m.pictures[out] = { ...entry(out), build }; + }, + clearBuild(out) { + if (!entry(out)) return; + const { build: _, ...rest } = entry(out); + m.pictures[out] = rest; + }, + text: () => manifestText(m), + save() { + const text = manifestText(m); + const now = existsSync(file) ? readFileSync(file, "utf8").replace(/\r\n/g, "\n") : null; + if (now === text) return false; + const temp = `${file}.${process.pid}.tmp`; + writeFileSync(temp, text); + renameSync(temp, file); + return true; + }, + }; +} diff --git a/scripts/lib/shot-policy.mjs b/scripts/lib/shot-policy.mjs index 31b4de962..4c998090d 100644 --- a/scripts/lib/shot-policy.mjs +++ b/scripts/lib/shot-policy.mjs @@ -1,8 +1,12 @@ // What becomes of a picture and of its SVG: the decisions shoot_docs.mjs, replay.mjs and the // screenshot review tool make the same way, as pure functions. They read nothing and write -// nothing: the caller passes in the files and the noise rule, and acts on the verdict -// through shot-apply.mjs. +// nothing: the caller passes in the files, the noise rule and the picture's entry in the +// manifest (shot-manifest.mjs), whose allowances change the rule and whose decisions, made +// by a review on the same bytes, take its place; and acts on the verdict through +// shot-apply.mjs. +import { NOISE_LEVELS, NOISE_SHARE } from "./shot-change.mjs"; +import { earlierPngDecision, pngDecision, svgDecision } from "./shot-manifest.mjs"; import { pictureFate } from "./shot-revision.mjs"; // How far, in device pixels, a light picture's width or height may be from the dark one's. @@ -20,15 +24,45 @@ export const SIZE_SLACK = 32; // and the page goes on showing the PNG. export const SVG_FAITHFUL = 0.25; +/** The level at which the SVG's bar counts its pixels off by default (diff.mjs's pct96). */ +const SVG_BAR_LEVELS = 96; + +/** + * The noise rule for a picture, from its entry in the manifest (or null): {levels, share, + * rects}, as withinNoise takes it (`share` a fraction, `rects` {x0, y0, x1, y1}). + */ +export function pngRule(entry) { + const noise = entry?.png?.noise ?? {}; + return { + levels: noise.levels ?? NOISE_LEVELS, + share: noise.share === undefined ? NOISE_SHARE : noise.share / 100, + rects: (entry?.png?.ignore ?? []).map(([x0, y0, x1, y1]) => ({ x0, y0, x1, y1 })), + }; +} + +/** The SVG's bar for a picture, from its entry (or null): {levels, share}, `share` a percentage. */ +export function svgRule(entry) { + const bar = entry?.svg?.bar ?? {}; + return { levels: bar.levels ?? SVG_BAR_LEVELS, share: bar.share ?? SVG_FAITHFUL }; +} + /** A PNG's width and height, from its header: {w, h}. */ export const pngSize = (png) => ({ w: png.readUInt32BE(16), h: png.readUInt32BE(20) }); /** * What becomes of a new capture `png` (PNG bytes) of the picture in `theme`. `dark` is the - * dark picture (this run's, or the file's) when `theme` is "light", else null; `revision`, - * `disk` and `near(a, b)` are pictureFate's. A light picture is compared with the dark one - * first. Returns {state, write, size, dark}, `size` the capture's {w, h} and `dark` the dark - * picture's when it is another size, else null: + * dark picture (this run's, or the file's) when `theme` is "light", else null; `revision` and + * `disk` are pictureFate's, and `near(a, b, rule)` whether two PNG files are within the noise + * rule `rule` of each other (pngRule). `entry` is the picture's entry in the manifest, or + * null. A light picture is compared with the dark one first. + * + * A review's decision about these bytes takes the place of the rule: "new" counts the capture + * as differing from every picture it is compared with, "old" as within noise of the one it + * would go back to (the revision's, else the file); a light picture with the dark one's + * pixels is still found by the rule. Returns {state, write, size, dark, by, earlier}, `size` + * the capture's {w, h}, `dark` the dark picture's when it is another size, else null, `by` + * "decision" or "rule", and `earlier` a review's decision about other bytes ("old" or + * "new"), which the rule judged this capture without, else null: * * too-far a light picture more than SIZE_SLACK off the dark one's size: a failure * dark a light picture with the dark one's pixels, but for a capture's noise: no @@ -36,21 +70,45 @@ export const pngSize = (png) => ({ w: png.readUInt32BE(16), h: png.readUInt32BE( * restored, unchanged, noise, updated, new * as pictureFate says, `write` the bytes the file is to hold or null */ -export function decidePicture({ png, theme, dark, revision, disk, near }) { +export function decidePicture({ png, theme, dark, revision, disk, near, entry = null }) { const size = pngSize(png); + const decided = pngDecision(entry, png); + const why = { by: decided ? "decision" : "rule", earlier: earlierPngDecision(entry, png) }; + const rule = pngRule(entry); + const byRule = (a, b) => near(a, b, rule); let other = null; if (theme === "light" && dark) { const d = pngSize(dark); if (Math.abs(d.w - size.w) > SIZE_SLACK || Math.abs(d.h - size.h) > SIZE_SLACK) - return { state: "too-far", write: null, size, dark: d }; + return { state: "too-far", write: null, size, dark: d, ...why }; if (d.w !== size.w || d.h !== size.h) other = d; - if (near(dark, png)) return { state: "dark", write: null, size, dark: other }; + if (decided !== "new" && byRule(dark, png)) return { state: "dark", write: null, size, dark: other, ...why }; } - return { ...pictureFate(png, revision, disk, near), size, dark: other }; + const nearBy = decided === "new" ? () => false : decided === "old" ? () => true : byRule; + return { ...pictureFate(png, revision, disk, nearBy), size, dark: other, ...why }; +} + +/** + * Whether --cleanup puts the file `after` (bytes) back as the revision has it (`before`): + * a review's decision about these bytes ("old" puts it back, "new" leaves it), else + * whether it is within the picture's noise rule of the revision's (`near` as decidePicture's). + */ +export function cleanupResets({ before, after, near, entry = null }) { + const decided = pngDecision(entry, after); + if (decided) return decided === "old"; + return near(before, after, pngRule(entry)); } /** * Whether an SVG is close enough to its PNG to be written, from the shares diffSvg reports - * (`stats.pct96`, percent): {faithful}. `bar` is the share it must stay below. + * (`stats.pct8`, `pct32`, `pct96`, percent): the share at the bar's levels must stay below + * its share (svgRule, from the picture's `entry` in the manifest, or null). A review's + * decision about this SVG (`svg`, its text) against this PNG (`png`, bytes) takes the bar's + * place. Returns {faithful, by}, `by` "decision" or "rule". */ -export const decideSvg = (stats, { bar = SVG_FAITHFUL } = {}) => ({ faithful: stats.pct96 < bar }); +export function decideSvg(stats, { entry = null, svg = null, png = null } = {}) { + const decided = svgDecision(entry, svg, png); + if (decided !== null) return { faithful: decided, by: "decision" }; + const { levels, share } = svgRule(entry); + return { faithful: stats[`pct${levels}`] < share, by: "rule" }; +} diff --git a/scripts/lib/shot-revision.mjs b/scripts/lib/shot-revision.mjs index b874919d9..def6c3bc3 100644 --- a/scripts/lib/shot-revision.mjs +++ b/scripts/lib/shot-revision.mjs @@ -219,10 +219,11 @@ export function svgFate(revision, disk) { /** * Sorts the pictures git reports changed against a revision (`pictures`, [{out, status}] as * changedPictures gives them) into what shoot_docs.mjs --cleanup does with each: `before(out)` - * the revision's PNG, `after(out)` the file's, `near(a, b)` the noise rule over two PNG files. - * Returns {reset, real, added, deleted, unreadable}, each a list of paths but `unreadable`, - * [{out, error}]: a modified picture within noise of the revision's is reset, any other is a - * real change, and an added or deleted one is a real change that is not compared. + * the revision's PNG, `after(out)` the file's, `near(a, b, out)` whether the file goes back to + * the revision's (the noise rule over two PNG files, and the picture's entry in the manifest: + * shot-policy.mjs's cleanupResets). Returns {reset, real, added, deleted, unreadable}, each a + * list of paths but `unreadable`, [{out, error}]: a modified picture near the revision's is + * reset, any other is a real change, and an added or deleted one is a real change that is not compared. */ export function planCleanup(pictures, before, after, near) { const plan = { reset: [], real: [], added: [], deleted: [], unreadable: [] }; @@ -232,7 +233,7 @@ export function planCleanup(pictures, before, after, near) { continue; } try { - plan[near(before(out), after(out)) ? "reset" : "real"].push(out); + plan[near(before(out), after(out), out) ? "reset" : "real"].push(out); } catch (e) { plan.unreadable.push({ out, error: e.message }); } diff --git a/scripts/shoot_docs.mjs b/scripts/shoot_docs.mjs index e8fd415a2..ef1229a5e 100644 --- a/scripts/shoot_docs.mjs +++ b/scripts/shoot_docs.mjs @@ -166,7 +166,8 @@ import { iconGroup, iconImage, manifestResource, resFile } from "./lib/res-file. import { inRects, withinNoise } from "./lib/shot-change.mjs"; import { changedPictures, openRevision, planCleanup } from "./lib/shot-revision.mjs"; import { applyPicture, applySvg, dropPicture, restorePictures, svgOf } from "./lib/shot-apply.mjs"; -import { decidePicture, SVG_FAITHFUL } from "./lib/shot-policy.mjs"; +import { cleanupResets, decidePicture, pngRule, SVG_FAITHFUL } from "./lib/shot-policy.mjs"; +import { MANIFEST, openManifest } from "./lib/shot-manifest.mjs"; import { diffPicture } from "./lib/shot-diff.mjs"; import { attach } from "./lib/tb-cdp.mjs"; import { consoleMark, linesSince } from "./lib/tb-ide-console.mjs"; @@ -194,7 +195,7 @@ import { stageProject, unpackProject } from "./lib/tb-project.mjs"; import { holdLlvmCache } from "./lib/tb-llvm-cache.mjs"; import { guard, presentKeys, press, serverRequests } from "../test/ide/building-kit.mjs"; import { checkHarness } from "./lib/harness-settings.mjs"; -import { findIde, unsupportedIde } from "./lib/tb-install.mjs"; +import { buildNumber, findIde, unsupportedIde } from "./lib/tb-install.mjs"; import { Lane } from "./lib/tb-lane.mjs"; import { answerMessageBox, @@ -300,6 +301,12 @@ A picture is refused, and the run fails, when the visible text of the page it was taken from, or a text of the program window it is of, holds the Windows user name. +With the default --out, test/shots/manifest.json is read and written: each +picture written gets the build it was taken on there, and a picture's entry can +change its noise rule (levels, share, rectangles left out) and its SVG's bar, +and hold a review's decision about one capture, or one SVG against one PNG, +which takes the rule's place for those bytes only (--cleanup too). + --only only the pictures whose path under the output folder (IDE/Menu/Images/Menu_File.png, or its .light.png) matches; each is taken in both themes, a program's window once @@ -426,6 +433,18 @@ if (bundlesRoot) useSubsetCache(path.join(bundlesRoot, "subsets")); // none with --against-disk), and the one --cleanup puts pictures back to. const AGAINST = values.against ?? "HEAD"; +// The manifest (lib/shot-manifest.mjs): its entries are for the pictures under docs/, so a run +// into another folder neither reads it nor writes it. +let manifest = null; +if (outRoot === path.resolve(DEFAULT_OUT)) { + try { + manifest = openManifest(MANIFEST); + } catch (e) { + die(2, e.message); + } +} +const entryOf = (out) => manifest?.entry(out) ?? null; + // --cleanup: no IDE. Every PNG git reports modified against the revision that is within a // capture's noise of the revision's goes back to the revision's bytes, with its SVG. if (values.cleanup) { @@ -453,7 +472,13 @@ if (values.cleanup) { pictures, (out) => before.get(out), (out) => readFileSync(path.join(outRoot, out)), - (a, b) => withinNoise(decoded(a, AGAINST), decoded(b, "the working tree")), + (a, b, out) => + cleanupResets({ + before: a, + after: b, + entry: entryOf(out), + near: (x, y, rule) => withinNoise(decoded(x, AGAINST), decoded(y, "the working tree"), rule), + }), ); const verb = values.dryRun ? "would be put back" : "put back"; let fates; @@ -488,6 +513,8 @@ try { } const ide = findIde(values.ide || undefined); if (unsupportedIde(ide)) die(2, unsupportedIde(ide)); +// The build the manifest records for each picture written, or null when it cannot be read. +const BUILD = buildNumber(ide); if (!ide || !existsSync(ide)) { die( 2, @@ -636,8 +663,18 @@ async function cutoutOff(conn) { // it, a list of those there are (or one, or null), which `capture` prefers the version of a // picture that equals; `editedOriginal` the module of the sample that a shot has replaced in // the page, to put back; for a light picture, `prefer` the dark picture when no light file is -// there yet; with --svg, `svg` the SVG of the picture `capture` returned last. -const newShotState = (name) => ({ name, out: null, reference: null, prefer: null, editedOriginal: null, svg: null }); +// there yet; `ignore` the rectangles the picture's entry in the manifest leaves out of its +// comparison (pngRule), which `capture` leaves out as it does the shot's unsteady regions; +// with --svg, `svg` the SVG of the picture `capture` returned last. +const newShotState = (name) => ({ + name, + out: null, + reference: null, + prefer: null, + ignore: null, + editedOriginal: null, + svg: null, +}); // A line of output, prefixed with the setup it is from (they run at once). const say = (name, text) => console.log(`[${name}] ${text}`); @@ -661,8 +698,9 @@ function writeDiff(name, out, before, after, { suffix = "", labels } = {}) { // Whether two captures (PNG files) are the same picture but for a capture's noise // (withinNoise, scripts/lib/shot-change.mjs, which holds the rule and what it rests on). -// The pixels inside `rects` (unsteadyRects) are left out. -const nearly = (a, b, rects = []) => withinNoise(decodePng(a), decodePng(b), { rects }); +// `rule` is withinNoise's: its limits, and `rects` the pixels left out (unsteadyRects, or a +// picture's entry in the manifest, pngRule). +const nearly = (a, b, rule = {}) => withinNoise(decodePng(a), decodePng(b), rule); // Whether two captures are the same picture outside `rects`: byte for byte when there are none. function same(a, b, rects) { @@ -780,7 +818,7 @@ async function capture(conn, name, clip, { frame, away = null, keep = null, soli // Outside its unsteady regions a capture is compared byte for byte, as everywhere else; // one that matches the file or the dark picture there keeps that picture's bytes, so a // pixel that flickers does not rewrite the file on every run. - const masks = await unsteadyRects(conn, clip, unsteady); + const masks = [...(await unsteadyRects(conn, clip, unsteady)), ...(conn.shot.ignore ?? [])]; const t0 = Date.now(); let last = null; let agreed = null; @@ -790,7 +828,7 @@ async function capture(conn, name, clip, { frame, away = null, keep = null, soli const png = Buffer.from(data, "base64"); const equal = wants.find((w) => same(w, png, masks)); if (equal) return await picked(equal); - if (prefer && i === 0 && !nearly(prefer, png, masks)) prefer = null; + if (prefer && i === 0 && !nearly(prefer, png, { rects: masks })) prefer = null; if (prefer && same(prefer, png, masks)) return await picked(prefer); if (last && !same(last, png, masks) && conn.shot.out) { writeDiff(conn.shot.name, conn.shot.out, last, png, { @@ -7838,13 +7876,13 @@ function ccShot(name, parts, { before = null, after = null } = {}) { try { if (before) await before(c); // the parts are not the picture: capture keeps the first two captures that agree - const { reference, prefer } = c.shot; - Object.assign(c.shot, { reference: null, prefer: null }); + const { reference, prefer, ignore } = c.shot; + Object.assign(c.shot, { reference: null, prefer: null, ignore: null }); let made; try { made = await parts(c); } finally { - Object.assign(c.shot, { reference, prefer }); + Object.assign(c.shot, { reference, prefer, ignore }); } try { const { box } = await composite( @@ -9234,8 +9272,8 @@ const codePasteAsCommentShot = { setup: "code-chapter", async take({ c }) { await chapterTabs(c, ["Helpers.twin"]); - const { reference, prefer } = c.shot; - Object.assign(c.shot, { reference: null, prefer: null }); + const { reference, prefer, ignore } = c.shot; + Object.assign(c.shot, { reference: null, prefer: null, ignore: null }); let made; try { made = await editedThenUndone(c, async () => { @@ -9268,7 +9306,7 @@ const codePasteAsCommentShot = { return [before, { ...after, column: 1 }]; }); } finally { - Object.assign(c.shot, { reference, prefer }); + Object.assign(c.shot, { reference, prefer, ignore }); } try { const { box } = await composite(c, made, [ @@ -13308,9 +13346,11 @@ const count = (theme, state) => { // removed, since the page shows X.png in both themes then; one more than SIZE_SLACK off its // size fails. A picture within a capture's noise of the revision's (--against) leaves the file // as the revision has it, with its SVG (pictureFate, lib/shot-revision.mjs); one within a -// capture's noise of its file is not written either. The verdict is written by applyPicture -// (lib/shot-apply.mjs). Returns whether the picture was written, "restored" for one put back -// as the revision has it, or null for a light picture that is the dark one. +// capture's noise of its file is not written either. The picture's entry in the manifest +// can change the noise rule, and a review's decision about these bytes takes its place. The +// verdict is written by applyPicture (lib/shot-apply.mjs), and the manifest records the +// build of a capture written. Returns whether the picture was written, "restored" for one +// put back as the revision has it, or null for a light picture that is the dark one. function keep(name, out, png, theme, dark, scale = SCALE) { const themed = themedOut(out, theme); const file = path.join(outRoot, themed); @@ -13319,7 +13359,21 @@ function keep(name, out, png, theme, dark, scale = SCALE) { // from the repository's over several runs, each within noise of the file before it. const was = existsSync(file) ? readFileSync(file) : null; const atRevision = revision ? revision.bytes(themed) : null; - const fate = decidePicture({ png, theme, dark, revision: atRevision, disk: was, near: nearly }); + const fate = decidePicture({ + png, + theme, + dark, + revision: atRevision, + disk: was, + near: nearly, + entry: entryOf(themed), + }); + const review = + fate.by === "decision" + ? "; as a review decided" + : fate.earlier + ? `; a review decided "${fate.earlier}" about another capture, and this one is judged by the rule` + : ""; const { w, h } = fate.size; if (fate.state === "too-far") { writeDiff(name, themed, dark, png, { @@ -13337,10 +13391,11 @@ function keep(name, out, png, theme, dark, scale = SCALE) { } if (fate.state === "dark") { const { removed } = applyPicture(outRoot, themed, fate, revision); + if (removed) recordBuild(themed, null); count(theme, "the same as the dark picture"); say( name, - `${themed}: the dark picture's pixels, but for a capture's noise: ${removed ? "removed" : "not written"}`, + `${themed}: the dark picture's pixels, but for a capture's noise: ${removed ? "removed" : "not written"}${review}`, ); return null; } @@ -13364,10 +13419,11 @@ function keep(name, out, png, theme, dark, scale = SCALE) { // A picture put back as the revision has it takes the revision's SVG with it, or loses its // SVG when the revision has none, so that the pair stays matched. const applied = applyPicture(outRoot, themed, fate, revision); + if (fate.state === "new" || fate.state === "updated") recordBuild(themed, BUILD); count(theme, state); say( name, - `${themed}: ${state} (${w}x${h} px, ${png.length} bytes; shown at {:width="${Math.round(w / scale)}" height="${Math.round(h / scale)}"}${other})`, + `${themed}: ${state} (${w}x${h} px, ${png.length} bytes; shown at {:width="${Math.round(w / scale)}" height="${Math.round(h / scale)}"}${other}${review})`, ); if (fate.state === "restored") { const text = { @@ -13382,10 +13438,22 @@ function keep(name, out, png, theme, dark, scale = SCALE) { return fate.write !== null; } +// The manifest's record of the build a picture's file was taken on: `build` for a capture +// written, null for a file removed. A picture whose file stays (unchanged, noise, restored) +// keeps the build it has. Saved at once: the jobs share the manifest, and keep is synchronous. +function recordBuild(out, build) { + if (!manifest) return; + if (build === null) manifest.clearBuild(out); + else manifest.setBuild(out, build); + manifest.save(); +} + // A `once` picture has no light file: one left from before is removed, and its SVG. function dropLight(name, out) { - if (dropPicture(outRoot, themedOut(out, "light"), bundlesRoot)) + if (dropPicture(outRoot, themedOut(out, "light"), bundlesRoot)) { + recordBuild(themedOut(out, "light"), null); say(name, `${themedOut(out, "light")}: removed (the picture is taken once, in no theme)`); + } } // With --svg: one headless browser for the whole run, which renders each SVG to @@ -13423,7 +13491,8 @@ async function keepSvg(name, out, png, picture, written) { const pngFile = path.join(outRoot, out); if (bundlesRoot) writeBundle(bundlesRoot, out, bundle, existsSync(pngFile) ? readFileSync(pngFile) : png, misses); svgBrowser ??= launchBrowser(); - const { diff, faithful, measures } = await judgeSvg(await svgBrowser, svg, png, stats); + const { diff, faithful, by, measures } = await judgeSvg(await svgBrowser, svg, png, stats, entryOf(out)); + const review = by === "decision" ? `; ${faithful ? "accepted" : "refused"} by a review` : ""; if (diffsRoot) { mkdirSync(diffsRoot, { recursive: true }); writeFileSync( @@ -13434,7 +13503,7 @@ async function keepSvg(name, out, png, picture, written) { const bytes = Buffer.byteLength(svg); svgResults.push({ out: svgOut, bytes, png: png.length, faithful, ...diff.stats }); if (!faithful) { - drop(measures); + drop(`${measures}${review}`); // To see what went wrong, the SVG goes beside its difference map. if (diffsRoot) writeFileSync(path.join(diffsRoot, `${out.replace(/[\\/]/g, "__").replace(/\.png$/i, "")}.svg`), svg); @@ -13443,7 +13512,7 @@ async function keepSvg(name, out, png, picture, written) { const state = applySvg(outRoot, svgOut, svg); say( name, - `${svgOut}: ${state} (${(bytes / 1024).toFixed(0)} KB against the PNG's ${(png.length / 1024).toFixed(0)} KB; ${measures})`, + `${svgOut}: ${state} (${(bytes / 1024).toFixed(0)} KB against the PNG's ${(png.length / 1024).toFixed(0)} KB; ${measures}${review})`, ); } @@ -13550,6 +13619,7 @@ async function runJob(job) { theme === "dark" ? null : (darks.get(shot.out) ?? (existsSync(darkFile) ? readFileSync(darkFile) : null)); state.reference = [revision?.bytes(out), existsSync(file) ? readFileSync(file) : null].filter(Boolean); state.prefer = dark; + state.ignore = pngRule(entryOf(out)).rects; state.out = out; state.svg = null; try { @@ -13570,6 +13640,7 @@ async function runJob(job) { } finally { state.reference = null; state.prefer = null; + state.ignore = null; state.out = null; } } diff --git a/scripts/svgshot/keep.mjs b/scripts/svgshot/keep.mjs index bad2af74b..96e65cd65 100644 --- a/scripts/svgshot/keep.mjs +++ b/scripts/svgshot/keep.mjs @@ -3,7 +3,7 @@ // enough is decideSvg's (lib/shot-policy.mjs), and the SVG is written by applySvg // (lib/shot-apply.mjs). -import { decideSvg } from "../lib/shot-policy.mjs"; +import { decideSvg, svgRule } from "../lib/shot-policy.mjs"; import { diffSvg, svgClip } from "./diff.mjs"; /** @@ -19,10 +19,11 @@ export const holdsUserName = (svg, user) => /** * `svg` against the PNG it was taken with, in a page of `browser`: - * {diff, faithful, measures}, `faithful` decideSvg's verdict and `measures` the line - * that says how far off it is and what the converter did not draw (`stats.unsupported`). + * {diff, faithful, by, measures}, `faithful` and `by` decideSvg's verdict, with the + * picture's `entry` in the manifest (or null), and `measures` the line that says how far + * off it is and what the converter did not draw (`stats.unsupported`). */ -export async function judgeSvg(browser, svg, png, stats) { +export async function judgeSvg(browser, svg, png, stats, entry = null) { const page = await browser.newPage(); let diff; try { @@ -32,8 +33,14 @@ export async function judgeSvg(browser, svg, png, stats) { await page.close(); } const unsupported = Object.entries(stats.unsupported).map(([k, n]) => `${k} x${n}`); + // A picture with a bar of its own says so, with the share at that bar's levels. + const rule = svgRule(entry); + const own = entry?.svg?.bar + ? `; its own bar is ${rule.share}% at ${rule.levels} levels, and ${diff.stats[`pct${rule.levels}`]}% are off by that` + : ""; const measures = - `${diff.stats.pct32}% of pixels differ, ${diff.stats.pct96}% strongly` + + `${diff.stats.pct32}% of pixels differ, ${diff.stats.pct96}% strongly${own}` + `${unsupported.length ? `; not drawn: ${unsupported.join(", ")}` : ""}`; - return { diff, faithful: decideSvg(diff.stats).faithful, measures }; + const { faithful, by } = decideSvg(diff.stats, { entry, svg, png }); + return { diff, faithful, by, measures }; } diff --git a/scripts/svgshot/replay.mjs b/scripts/svgshot/replay.mjs index 591256542..00428e870 100644 --- a/scripts/svgshot/replay.mjs +++ b/scripts/svgshot/replay.mjs @@ -21,6 +21,7 @@ import { REPO_ROOT } from "../../lib/repo-paths.mjs"; import { listBundles } from "./bundle.mjs"; import { useSubsetCache } from "./fonts.mjs"; import { applySvg, svgOf } from "../lib/shot-apply.mjs"; +import { MANIFEST, openManifest } from "../lib/shot-manifest.mjs"; import { SVG_FAITHFUL } from "../lib/shot-policy.mjs"; import { judgeSvg } from "./keep.mjs"; import { @@ -46,6 +47,10 @@ are far off the PNG's and its bytes differ from the file's, else not written and an older one removed. Takes no picture and starts no IDE; needs Python with fontTools, as --svg does. +A picture's entry in test/shots/manifest.json changes its bar, and a review's +decision about the same SVG against the same PNG takes the bar's place, as in +shoot_docs.mjs. The manifest is read only for the default --out. + A picture whose PNG is not the one its bundle was taken with, whose bundle is of another format than the converter reads, whose bundle lacks a computed style the converter now reads, or for which the converter asks something its bundle cannot @@ -97,6 +102,16 @@ const jobs = withUsageError(() => const bundlesRoot = path.resolve(values.bundles ?? path.join(REPO_ROOT, ".svgshot-bundles")); const outRoot = path.resolve(values.out ?? path.join(REPO_ROOT, "docs")); const diffsRoot = values.diffs === undefined ? null : path.resolve(values.diffs); +// The manifest's entries are for the pictures under docs/: another folder is judged without them. +let manifest = null; +if (outRoot === path.resolve(REPO_ROOT, "docs")) { + try { + manifest = openManifest(MANIFEST); + } catch (e) { + console.error(`replay: ${e.message}`); + process.exit(2); + } +} if (!existsSync(bundlesRoot)) { console.error(`replay: no bundle cache at ${bundlesRoot}; shoot_docs.mjs --svg writes it`); process.exit(2); @@ -128,17 +143,25 @@ async function replay(out, browser) { return; } const { png, svg, stats } = drawn; + const entry = manifest?.entry(out) ?? null; // The same bytes as the file, beside the PNG its bundle was taken with: the bar passed this - // SVG against that PNG when it was written, and would again. Most of a replay is this. - if (!values.recheck && existsSync(svgFile) && readFileSync(svgFile, "utf8").replace(/\r\n/g, "\n") === svg) { + // SVG against that PNG when it was written, and would again. Most of a replay is this. A + // picture whose entry names its SVG is judged again: its bar or its decision may have changed. + if ( + !values.recheck && + !entry?.svg && + existsSync(svgFile) && + readFileSync(svgFile, "utf8").replace(/\r\n/g, "\n") === svg + ) { say(out, "unchanged (the same SVG as the file)"); counts.unchanged = (counts.unchanged ?? 0) + 1; return; } - const { diff, faithful, measures } = await judgeSvg(browser, svg, png, stats); + const { diff, faithful, by, measures } = await judgeSvg(browser, svg, png, stats, entry); if (diffsRoot) writeJudged(diffsRoot, out, diff); const state = applySvg(outRoot, svgOf(out), faithful ? svg : null); - say(out, faithful ? `${state} (${measures})` : `${state}, the PNG is kept (${measures})`); + const how = by === "decision" ? `; ${faithful ? "accepted" : "refused"} by a review` : ""; + say(out, faithful ? `${state} (${measures}${how})` : `${state}, the PNG is kept (${measures}${how})`); counts[state] = (counts[state] ?? 0) + 1; } diff --git a/test/shots/manifest.json b/test/shots/manifest.json new file mode 100644 index 000000000..2e7bf8c83 --- /dev/null +++ b/test/shots/manifest.json @@ -0,0 +1,4 @@ +{ + "format": 1, + "pictures": {} +} diff --git a/test/svgshot.test.mjs b/test/svgshot.test.mjs index 70621d3fa..71c1d0ef2 100644 --- a/test/svgshot.test.mjs +++ b/test/svgshot.test.mjs @@ -22,8 +22,10 @@ // (sheet.mjs --against): git's change list, the comparison of two pictures over two sizes, // the noise rule, the order and the page. And lib/record-lines.mjs, the records a child // process writes among its lines, which runShards reads. And what becomes of a picture and -// its SVG: lib/shot-policy.mjs's decisions, and lib/shot-apply.mjs's writing of them, in a -// scratch folder and a scratch repository. +// its SVG: lib/shot-policy.mjs's decisions, with a picture's allowances and a review's +// decisions, and lib/shot-apply.mjs's writing of them, in a scratch folder and a scratch +// repository. And lib/shot-manifest.mjs, the manifest those come from: its text, what it +// refuses, and its saving. // // Runs with a bare `node --test test/svgshot.test.mjs`: no tree, no build. @@ -51,8 +53,25 @@ import { SNAPSHOT_STYLES, webFontFiles } from "../scripts/svgshot/capture.mjs"; import { decodePng, encodePng } from "../scripts/lib/png.mjs"; import { parseRecord, readRecords, recordLine } from "../scripts/lib/record-lines.mjs"; import { applyPicture, applySvg, dropPicture, restorePictures } from "../scripts/lib/shot-apply.mjs"; -import { decidePicture, decideSvg, SIZE_SLACK, SVG_FAITHFUL } from "../scripts/lib/shot-policy.mjs"; -import { changeMap, compareImages, withinNoise } from "../scripts/lib/shot-change.mjs"; +import { + cleanupResets, + decidePicture, + decideSvg, + pngRule, + SIZE_SLACK, + SVG_FAITHFUL, + svgRule, +} from "../scripts/lib/shot-policy.mjs"; +import { + earlierPngDecision, + hashOf, + manifestText, + openManifest, + parseManifest, + pngDecision, + svgDecision, +} from "../scripts/lib/shot-manifest.mjs"; +import { changeMap, compareImages, NOISE_LEVELS, NOISE_SHARE, withinNoise } from "../scripts/lib/shot-change.mjs"; import { changedPictures, openRevision, @@ -1930,6 +1949,8 @@ describe("what becomes of a picture and its SVG (shot-policy.mjs, shot-apply.mjs write: OTHER, size: { w: 100, h: 50 }, dark: null, + by: "rule", + earlier: null, }); assert.deepEqual(decide(NEAR, "dark", null, DARK, OTHER).state, "restored"); }); @@ -1941,6 +1962,8 @@ describe("what becomes of a picture and its SVG (shot-policy.mjs, shot-apply.mjs write: null, size: { w: 100, h: 50 }, dark: null, + by: "rule", + earlier: null, }); // of another size, within the theme's slack: named, then judged const wider = decide(SHIFTED, "light", DARK); @@ -1955,10 +1978,127 @@ describe("what becomes of a picture and its SVG (shot-policy.mjs, shot-apply.mjs assert.equal(decide(NEAR, "light", null, null, DARK).state, "noise"); }); - test("an SVG is faithful below the bar, the default one or another", () => { - assert.equal(decideSvg({ pct96: SVG_FAITHFUL - 0.001 }).faithful, true); + test("an SVG is faithful below the bar, the default one or a picture's own", () => { + assert.deepEqual(decideSvg({ pct96: SVG_FAITHFUL - 0.001 }), { faithful: true, by: "rule" }); assert.equal(decideSvg({ pct96: SVG_FAITHFUL }).faithful, false); - assert.equal(decideSvg({ pct96: 0.3 }, { bar: 0.35 }).faithful, true); + const wider = { svg: { bar: { share: 0.35 } } }; + assert.equal(decideSvg({ pct96: 0.3 }, { entry: wider }).faithful, true); + // a bar at another level counts the pixels off at that level + const at32 = { svg: { bar: { levels: 32, share: 1 } } }; + assert.equal(decideSvg({ pct96: 0, pct32: 1.5 }, { entry: at32 }).faithful, false); + assert.equal(decideSvg({ pct96: 5, pct32: 0.5 }, { entry: at32 }).faithful, true); + assert.deepEqual(svgRule(null), { levels: 96, share: SVG_FAITHFUL }); + }); + + test("a review's decision takes the rule's place for the bytes it was made on, and no others", () => { + const decision = (keep, png) => ({ png: { decision: { keep, capture: hashOf(png) } } }); + // "old": within noise of what it would go back to; "new": unlike everything + assert.equal( + decidePicture({ + png: OTHER, + theme: "dark", + dark: null, + revision: DARK, + disk: DARK, + near, + entry: decision("old", OTHER), + }).state, + "noise", + ); + const old = decidePicture({ + png: OTHER, + theme: "dark", + dark: null, + revision: DARK, + disk: NEAR, + near, + entry: decision("old", OTHER), + }); + assert.deepEqual([old.state, old.write, old.by], ["restored", DARK, "decision"]); + const fresh = decidePicture({ + png: NEAR, + theme: "dark", + dark: null, + revision: DARK, + disk: DARK, + near, + entry: decision("new", NEAR), + }); + assert.deepEqual([fresh.state, fresh.write, fresh.by], ["updated", NEAR, "decision"]); + // a light picture kept "new" is kept even with the dark one's pixels; "old" is still found to be the dark one + assert.equal( + decidePicture({ + png: NEAR, + theme: "light", + dark: DARK, + revision: null, + disk: null, + near, + entry: decision("new", NEAR), + }).state, + "new", + ); + assert.equal( + decidePicture({ + png: NEAR, + theme: "light", + dark: DARK, + revision: null, + disk: null, + near, + entry: decision("old", NEAR), + }).state, + "dark", + ); + // a decision about other bytes: judged by the rule, and named + const other = decidePicture({ + png: NEAR, + theme: "dark", + dark: null, + revision: DARK, + disk: DARK, + near, + entry: decision("new", OTHER), + }); + assert.deepEqual([other.state, other.by, other.earlier], ["noise", "rule", "new"]); + assert.equal(pngDecision(decision("old", OTHER), OTHER), "old"); + assert.equal(pngDecision(decision("old", OTHER), NEAR), null); + assert.equal(earlierPngDecision(decision("old", OTHER), NEAR), "old"); + assert.equal(earlierPngDecision(decision("old", OTHER), OTHER), null); + assert.equal(pngDecision(decision("old", OTHER), null), null); + // an SVG's: the SVG's text, line endings aside, against the same PNG + const svgEntry = { svg: { decision: { accept: true, svg: hashOf(""), png: hashOf(OTHER) } } }; + assert.equal(svgDecision(svgEntry, "", OTHER), true); + assert.equal(svgDecision(svgEntry, "", NEAR), null); + assert.equal(svgDecision(svgEntry, "", OTHER), null); + assert.deepEqual(decideSvg({ pct96: 50 }, { entry: svgEntry, svg: "", png: OTHER }), { + faithful: true, + by: "decision", + }); + assert.deepEqual(decideSvg({ pct96: 50 }, { entry: svgEntry, svg: "", png: NEAR }), { + faithful: false, + by: "rule", + }); + }); + + test("an allowance changes the noise rule the picture is judged with", () => { + assert.deepEqual(pngRule(null), { levels: NOISE_LEVELS, share: NOISE_SHARE, rects: [] }); + const entry = { png: { noise: { levels: 40, share: 1.5 }, ignore: [[1, 2, 30, 40]] } }; + assert.deepEqual(pngRule(entry), { levels: 40, share: 0.015, rects: [{ x0: 1, y0: 2, x1: 30, y1: 40 }] }); + // decidePicture hands the rule to near + const seen = []; + const spy = (_a, _b, rule) => { + seen.push(rule); + return false; + }; + decidePicture({ png: OTHER, theme: "dark", dark: null, revision: DARK, disk: DARK, near: spy, entry }); + assert.ok(seen.length > 0 && seen.every((r) => r.levels === 40 && r.rects.length === 1)); + // --cleanup: the decision about the file's bytes, else the rule + const rule = (_a, _b, r) => r.levels === 40; + assert.equal(cleanupResets({ before: DARK, after: OTHER, near: rule, entry }), true); + assert.equal(cleanupResets({ before: DARK, after: OTHER, near: rule, entry: null }), false); + const kept = { ...entry, png: { ...entry.png, decision: { keep: "new", capture: hashOf(OTHER) } } }; + assert.equal(cleanupResets({ before: DARK, after: OTHER, near: rule, entry: kept }), false); }); test("writes a verdict into the folder, and removes what is not kept", (t) => { @@ -2044,3 +2184,114 @@ describe("what becomes of a picture and its SVG (shot-policy.mjs, shot-apply.mjs assert.throws(() => restorePictures(docs, rev, ["none.png"]), /has no none\.png/); }); }); + +describe("the screenshot manifest (shot-manifest.mjs)", () => { + const H = "a".repeat(40); + const full = { + format: 1, + pictures: { + "IDE/Images/B.png": { build: 1010 }, + "IDE/Images/A.light.png": { + svg: { decision: { accept: true, svg: H, png: H }, bar: { share: 0.35 } }, + png: { decision: { keep: "old", capture: H }, ignore: [[812, 40, 900, 64]], noise: { share: 0.5 } }, + build: 1501, + }, + }, + }; + + test("is written by path, each entry's keys in order, a rectangle on one line, and read back the same", () => { + const text = manifestText(full); + assert.equal( + text, + `{ + "format": 1, + "pictures": { + "IDE/Images/A.light.png": { + "build": 1501, + "png": { + "noise": { + "share": 0.5 + }, + "ignore": [ + [812, 40, 900, 64] + ], + "decision": { + "keep": "old", + "capture": "${H}" + } + }, + "svg": { + "bar": { + "share": 0.35 + }, + "decision": { + "accept": true, + "svg": "${H}", + "png": "${H}" + } + } + }, + "IDE/Images/B.png": { + "build": 1010 + } + } +} +`, + ); + assert.equal(manifestText(parseManifest(text)), text); + assert.equal(manifestText(parseManifest(text.replace(/\n/g, "\r\n"))), text); + // an entry left with nothing in it goes + assert.equal( + manifestText({ format: 1, pictures: { "a.png": { png: { ignore: [] } } } }), + manifestText({ format: 1, pictures: {} }), + ); + }); + + test("refuses what it cannot read, naming the key", () => { + const bad = (pictures, why) => + assert.throws(() => parseManifest(JSON.stringify({ format: 1, pictures }), "m"), why); + assert.throws(() => parseManifest("{", "m"), /m is not JSON/); + assert.throws(() => parseManifest('{"format": 2, "pictures": {}}', "m"), /m is of format 2, not 1/); + assert.throws(() => parseManifest('{"format": 1, "extra": 0}', "m"), /m has an unknown key, extra/); + bad({ "a\\b.png": {} }, /not a path under docs with forward slashes/); + bad({ "../a.png": {} }, /not a path under docs/); + bad({ "a.jpg": {} }, /not a path under docs/); + bad({ "a.png": { build: 0 } }, /m's a\.png\.build is not a build number/); + bad({ "a.png": { png: { noise: { levels: 300 } } } }, /png\.noise\.levels/); + bad({ "a.png": { png: { noise: { share: 101 } } } }, /png\.noise\.share is not a percentage/); + bad({ "a.png": { png: { ignore: [[5, 0, 5, 9]] } } }, /png\.ignore\[0\] is not \[x0, y0, x1, y1\]/); + bad({ "a.png": { png: { decision: { keep: "both", capture: H } } } }, /png\.decision\.keep/); + bad({ "a.png": { png: { decision: { keep: "old", capture: "x" } } } }, /png\.decision\.capture is not a SHA-1/); + bad({ "a.png": { svg: { bar: { levels: 50 } } } }, /svg\.bar\.levels is not one of 8, 32, 96/); + bad({ "a.png": { svg: { ignore: [] } } }, /svg has an unknown key, ignore/); + bad({ "a.png": { svg: { decision: { accept: "yes", svg: H, png: H } } } }, /svg\.decision\.accept/); + }); + + test("records a build, and is saved only when what it holds changes", (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "shot-manifest-")); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const file = path.join(dir, "manifest.json"); + const m = openManifest(file); + assert.equal(m.entry("a.png"), null); + assert.equal(m.save(), true); // a new file, with no pictures + assert.equal(m.save(), false); + m.setBuild("a.png", 1010); + m.setBuild("b.png", null); // a build that could not be read records nothing + assert.equal(m.save(), true); + assert.deepEqual(openManifest(file).entry("a.png"), { build: 1010 }); + // a CRLF checkout of the same text is the same + fs.writeFileSync(file, fs.readFileSync(file, "utf8").replace(/\n/g, "\r\n")); + const again = openManifest(file); + assert.equal(again.save(), false); + again.clearBuild("a.png"); + again.clearBuild("c.png"); + assert.equal(again.save(), true); + assert.equal(fs.readFileSync(file, "utf8"), manifestText({ format: 1, pictures: {} })); + assert.deepEqual(fs.readdirSync(dir), ["manifest.json"]); + }); + + test("the repository's own manifest reads", () => { + const m = openManifest(); + assert.equal(m.text(), fs.readFileSync(m.file, "utf8").replace(/\r\n/g, "\n")); + }); +}); From f26ba0e3800f7b61d4a5c0780ba63dc1bd70211f Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Sat, 31 Oct 2026 21:36:24 +0100 Subject: [PATCH 12/85] Step 3 of the screenshot review: diffSvg's strength map (pct8/32/96 from it), shareOff and changeRegions (closing, then erosion) in shot-change.mjs, the queue's rules in shot-queue.mjs, and an SVG bar at any level with svg.ignore in the manifest --- WIP.Screenshots.md | 4 +- WIP.ShotReview.md | 75 ++++-- WIP.md | 2 +- docs/Documentation/Tools-Toolchain-Tests.md | 1 + scripts/lib/shot-change.mjs | 130 ++++++++++ scripts/lib/shot-manifest.mjs | 30 ++- scripts/lib/shot-policy.mjs | 39 ++- scripts/lib/shot-queue.mjs | 101 ++++++++ scripts/svgshot/diff.mjs | 42 ++- scripts/svgshot/keep.mjs | 13 +- test/svgshot.test.mjs | 269 +++++++++++++++++++- 11 files changed, 624 insertions(+), 82 deletions(-) create mode 100644 scripts/lib/shot-queue.mjs diff --git a/WIP.Screenshots.md b/WIP.Screenshots.md index f6f2158aa..2d255efaa 100644 --- a/WIP.Screenshots.md +++ b/WIP.Screenshots.md @@ -994,7 +994,9 @@ the PNG was just captured from, and the build shows it in place of the PNG noise. - **The bar** (`SVG_FAITHFUL` in `scripts/lib/shot-policy.mjs`): an SVG with more than 0.25% of its pixels 96 grey levels or more off is not written, and an older one is removed. Anything not drawn falls - to it. + to it. A picture's entry in `test/shots/manifest.json` can move it, to any level and share, + and leave rectangles out (`svgRule`): it is counted on `diffSvg`'s strength map, a byte per + pixel, so no level needs the picture drawn again. - **An SVG changes with its PNG** (owner). The SVG of a picture whose PNG is not rewritten (unchanged, or within a capture's noise) stays as it is, with its bundle, when there is one: it is drawn from the whole page, layers the picture does not show included, and those diff --git a/WIP.ShotReview.md b/WIP.ShotReview.md index 12e59e076..dc2d55d25 100644 --- a/WIP.ShotReview.md +++ b/WIP.ShotReview.md @@ -2,8 +2,8 @@ A localhost tool for reviewing what `shoot_docs.mjs` and `replay.mjs` decided about each picture, overriding it, starting retakes and replays, and committing the result. **Design -agreed with the owner on 2026-10-31; steps 1 and 2 of [the order of work](#order-of-work) are -done (step 1 but for three rows that later steps make), and the tool itself is not started.** The mockups it was agreed on were inline in that +agreed with the owner on 2026-10-31; steps 1 to 3 of [the order of work](#order-of-work) are +done (step 1 but for the commit helper, which step 8 makes), and the tool itself is not started.** The mockups it was agreed on were inline in that session and are not kept: the pages are described below instead. The tool covers everything the command-line screenshot tools do, interactively, with a person @@ -127,10 +127,19 @@ is the next session's baseline, and the old session is archived and can still be ## The queue -The changed pixels are grouped into connected regions, and every region is eroded by 2 device -pixels. A fringe (a line 1 to 3 px thick along an edge, antialiasing or ClearType) is gone -after the erosion; text that changed, or a part the SVG lacks, is a block, and part of it -survives. A picture is in **Needs a look** when: +The changed pixels are grouped into connected regions, their gaps of up to 4 device pixels +closed first, and every region is eroded by 2 device pixels. A fringe (a line 1 to 3 px thick +along an edge, antialiasing or ClearType) or a caret is gone after the erosion; text that +changed, or a part the SVG lacks, is a block, and part of it survives. The closing is what +makes text a block: a stroke of the IDE's text at 2x is 2 or 3 px thick, so the erosion alone +takes every stroke of a word (Designer_Zoom50's SVG, its text drawn at the wrong size, keeps +no core without it and ten regions with it). A region survives with a core of 16 px or more +(`SURVIVES`): where two fringes cross, the closing leaves 1 to 15. A PNG's regions count every +pixel that differs at all, not only those beyond noise: a retake on a new build often shifts a +colour by a few levels over a whole window, which counted beyond noise leaves nothing and reads +as noise (294 of the 361 pictures the BETA 1010 retake changed at their old size; 23 at any +difference). An SVG's count the pixels at its bar's levels. A picture is in **Needs a look** +when: - **PNG**: the tool took the new capture, but nothing survives (most likely noise: a caret, a clock, a fringe), or what survives is small (a block or two the size of a word: a real @@ -138,8 +147,15 @@ survives. A picture is in **Needs a look** when: - **SVG**: the tool accepted it, but something survives (a part missing, whatever the share); or the tool rejected it, and nothing survives (fringes only, most likely acceptable). -The same regions give the loupes. The erosion and "small" are constants of the tool, not of -the decisions. +A decision a review made about other bytes of the picture sends it back too, whatever the +regions. "Small" is at most two surviving regions (`WORD_BLOCKS`) of at most 1,200 px of core +each (`WORD_CORE`): a version number changing in the call stack leaves 224 to 265, a name 127 px +wide 781. On the SVGs of the 476 bundles of 2026-10-31 the rules put 10 in the queue: 8 +accepted with a part surviving, among them two that are wrong (Menu_Help's SVG lacks the +menu's cup icon, IDE-FeatureMap.light's draws in full a text the IDE cuts off with +an ellipsis), and tB-Blue's two, refused for 4 pixels of 1,024. The same regions give the +loupes. The closing, the erosion and "small" are constants of the tool (`scripts/lib/shot-queue.mjs`), +not of the decisions. ## Remembered overrides @@ -186,9 +202,9 @@ written to `docs/` (new, updated), stays when the file stays (noise, restored), the file is removed (a light picture with the dark one's pixels, a `once` picture's light file). A picture with no entry shows "build unknown" until it is next written. Only what differs from the defaults is stored, and the file is written only when what it holds changes, -in a fixed order (pictures by path), so its diffs are small. Until the strength map exists -(step 3) an SVG's bar is at 8, 32 or 96 levels, the three `diff.mjs` counts, and `svg.ignore` -is refused. The manifest is read and written only for the default `--out`: its paths are under +in a fixed order (pictures by path), so its diffs are small. An SVG's bar is at any level +from 1 to 255, and `svg.ignore` takes rectangles as `png.ignore` does, both counted on the +strength map. The manifest is read and written only for the default `--out`: its paths are under `docs/`. A light picture decided "new" is kept even with the dark one's pixels; one decided "old" is still found to be the dark one by the rule. @@ -235,7 +251,7 @@ and the writes are inline in `shoot_docs.mjs` (`keep`, `keepSvg`, `restoreSvg`, | An SVG's verdict | `judgeSvg`'s `faithful` against `SVG_FAITHFUL`, acted on in `keepSvg` and again in `replay` | `decideSvg` (pure) in `shot-policy.mjs`, from the strength map and the allowance | | Writing a verdict | `keep`, `keepSvg`, `restoreSvg`, `dropLight` in `shoot_docs.mjs`; `replay` in `replay.mjs` | `applyPicture` / `applySvg` in a new `scripts/lib/shot-apply.mjs` (PNG, SVG, bundle, the SVG going back with its PNG through `svgFate`), called by `shoot_docs.mjs`, `replay.mjs`, `--cleanup` and the tool | | How far an SVG is off | `diff.mjs` counts pixels at 8, 32 and 96 levels in the page | also returns the strength map (a byte per pixel, the one-pixel shift allowed), from which any level's share, rectangles left out and regions are worked out in Node, without drawing again; `pct8`/`pct32`/`pct96` derived from it | -| Regions | none | `changeRegions(strength, w, h, {level, erode})` in `shot-change.mjs`, for PNG and SVG alike | +| Regions | none | `changeRegions(strength, {level, close, erode, rects})` in `shot-change.mjs`, for PNG and SVG alike | | The sheets' HTML | `sheetHtml`, `changeSheetHtml` and their pieces in `redraw.mjs` | the pieces (card, figure, meter, page) in a views module shared by `sheet.mjs` and the tool; a static sheet is the overview without its actions | | `redraw.mjs`'s other contents | git change listing (`changedPictures`, `parseNameStatus`, ...) beside drawing | the git listing to `shot-revision.mjs`; `redraw.mjs` keeps drawing and the shards | | A child's report | `reportShard` / `runShards`, the `@@svgshot-result` line | one module for the prefixed-line protocol, used by the shards and by `--events` | @@ -253,11 +269,11 @@ uses it. `shoot_docs.mjs` cannot run in a gate, so a move is checked by its test Each step ends tested and usable on its own. 1. **The moves**, no behaviour changed: the table's *After* column, with `shoot_docs.mjs`, - `replay.mjs` and `sheet.mjs` calling the shared modules. Done but for three rows: see + `replay.mjs` and `sheet.mjs` calling the shared modules. Done but for the commit helper: see [Where the work stands](#where-the-work-stands). 2. **The manifest**: read and written by one module; `shoot_docs.mjs` and `replay.mjs` judge with its allowances, honour its decisions and record `build`. Done. -3. **The strength map, the regions and the queue's rules**, tested on fixtures. +3. **The strength map, the regions and the queue's rules**, tested on fixtures. Done. 4. **`--stage`, `--events` and stop** in `shoot_docs.mjs` and `replay.mjs`. 5. **The server and the session store**, the overview and the detail page read-only, live. 6. **Decisions and allowances** applied through `shot-apply.mjs`, judged again at once. @@ -270,15 +286,17 @@ Each step ends tested and usable on its own. ## Where the work stands -On `staging` since 2026-10-31, step 1 but for the three rows that are new code (below), and -step 2. Each move kept the command-line tools' output as it was, checked as +On `staging` since 2026-10-31, step 1 but for the commit helper (step 8), and steps 2 and 3. Each move kept the command-line tools' output as it was, checked as [below](#checking-a-move). | Row of the factoring table | Where it is now | |---|---| | The noise rule | `withinNoise(A, B, {levels, share, rects})`, `scripts/lib/shot-change.mjs` | | A PNG's verdict | `decidePicture({png, theme, dark, revision, disk, near, entry})` in `scripts/lib/shot-policy.mjs`, with `SIZE_SLACK`: pictureFate's states plus `too-far` and `dark` (a light picture with the dark one's pixels), and `by` ("decision" or "rule") and `earlier` (a decision about other bytes). `near(a, b, rule)` takes `pngRule(entry)`; `cleanupResets` is `--cleanup`'s verdict, with the path passed through `planCleanup` | -| An SVG's verdict | `decideSvg(stats, {entry, svg, png})` in `shot-policy.mjs`, with `SVG_FAITHFUL` and `svgRule(entry)`: `{faithful, by}`. `judgeSvg(browser, svg, png, stats, entry)` asks it. It reads `stats.pct8`, `pct32` or `pct96` until the strength map exists (step 3) | +| An SVG's verdict | `decideSvg(strength, {entry, svg, png})` in `shot-policy.mjs`, with `SVG_FAITHFUL` and `svgRule(entry)` (`{levels, share, rects}`): `{faithful, by, share, earlier}`, `share` counted on the strength map at the bar's levels with its rectangles left out, `earlier` a review's decision about another SVG or PNG (`earlierSvgDecision`). `judgeSvg(browser, svg, png, stats, entry)` asks it | +| How far an SVG is off | `diffSvg` returns `strength`, `{width, height, data}`, a byte per device pixel of the clip (the distance with the one-pixel shift allowed, in whole levels, so a level's count is the same as from the distance); `stats.pct8`, `pct32` and `pct96` are `shareOff` of it, the same to the third place (a replay of the 476 bundles printed the same lines before and after) | +| Regions | `scripts/lib/shot-change.mjs`: `strengthOf(A, B)` (two captures' map, withinNoise's distance), `shareOff(strength, {level, rects})`, `changeRegions(strength, {level, close, erode, rects})` (`[{x0, y0, x1, y1, pixels, core}]`, most core first) and `regionMasks`, its three masks, for drawing them | +| The queue's rules | `scripts/lib/shot-queue.mjs`: `pngRegions(strength, pngRule)`, `svgRegions(strength, entry)`, `surviving`, `pngLook(verdict, regions)` and `svgLook(verdict, regions)`, each `{why, text}` or null; the constants `QUEUE_CLOSE`, `QUEUE_ERODE`, `SURVIVES`, `WORD_CORE`, `WORD_BLOCKS` | | The manifest | `scripts/lib/shot-manifest.mjs`: `MANIFEST`, `openManifest(file)` (`entry`, `setBuild`, `clearBuild`, `save`, `text`), `parseManifest`, `manifestText`, `pngDecision`, `earlierPngDecision`, `svgDecision`, `hashOf`. `shoot_docs.mjs` passes each picture's rectangles to `capture` as `c.shot.ignore`, left out as its unsteady regions are, and records `build` (`buildNumber(ide)`) in `keep` and `dropLight` | | Writing a verdict | `scripts/lib/shot-apply.mjs`: `applyPicture(outRoot, out, decision, revision)`, `applySvg(outRoot, svgOut, svg or null)`, `restorePictures(outRoot, revision, outs, {dryRun})` (the PNGs and their SVGs, the SVGs by one `git restore`; `--cleanup` and a `restored` picture both use it), `dropPicture(outRoot, out, bundlesRoot)`, `writeSvg`, `svgOf`. What stays in `shoot_docs.mjs`'s `keep` and `keepSvg`: the counts, the lines it prints, the `--diffs` pictures, the bundle written, the user-name check, and the rule that an SVG stays as it is when its PNG was not written | | The git listing | `changedPictures`, `parseNameStatus`, `parseUntracked` in `scripts/lib/shot-revision.mjs` | @@ -287,12 +305,23 @@ step 2. Each move kept the command-line tools' output as it was, checked as | Server-sent events | `createSseChannel()` in `lib/sse.mjs`: `handle(req, res)`, `send(event, data)`, `close()`; `builder/serve.mjs` uses one | | The installs | `listIdes()` in `scripts/lib/tb-install.mjs`, newest first, `[{build, exe}]` | -**Next: step 3**, the strength map, the regions and the queue's rules: `diff.mjs` returns the -strength map, `pct8`/`pct32`/`pct96` derived from it, `changeRegions` in `shot-change.mjs`, and -then an SVG's bar at any level and `svg.ignore` in the manifest (`checkEntry` refuses it -today). The other row left in the table, the commit helper, is step 8. The tool's pages will -add their actions to the pieces in `shot-views.mjs` (step 5); until then only `sheets.mjs` -calls them. +**Next: step 4**, `--stage`, `--events` and stop in `shoot_docs.mjs` and `replay.mjs`. A staged +candidate's verdict carries its look (`pngLook`, `svgLook`) and its strength map, which the +PNG's needs from `strengthOf` of the capture and the picture it was judged against (the +revision's, else the file); `--events` writes `shot-event` records with +`scripts/lib/record-lines.mjs`. The tool's pages will add their actions to the pieces in +`shot-views.mjs` (step 5); until then only `sheets.mjs` calls them. + +Step 3 was checked with a replay of every bundle (`--recheck`), whose lines, sorted, were the +same before and after, and with `svg.ignore` on Properties_ColourPicker.light (a rectangle over +the strip at x 740 to 760 takes its 0.268% strongly off to 0.001%, and the SVG is kept) and a bar +at 200 levels. The constants were chosen from the pictures: each stage drawn over 8 SVGs and 5 +retaken PNGs (the changed pixels, what the closing adds, the core, the boxes), and the counts +over every SVG and the PNGs of the BETA 1010 and 1033 retakes. + +**Not in the rules, to ask the owner:** a capture within noise (kept as the file) whose faint +difference is a block that survives: a tint over less than 0.5% of the picture, which *keep old +→ take new* is for. The rules send to the queue only a capture the tool took. Step 2 was checked with the runs [below](#checking-a-move), whose states it left as they were with an empty manifest, and with an allowance and a decision on `Menu_Tools_IDEOptions`: the diff --git a/WIP.md b/WIP.md index 3f7b97626..934fabcb2 100644 --- a/WIP.md +++ b/WIP.md @@ -663,7 +663,7 @@ wrapper: | `test.bat` | `test/render.test.mjs` | `builder/render.mjs`'s markdown-it plugins, one at a time, on inputs no page holds --- today the ellipsis plugin's dot counting. Run by `node --test` | | `test.bat` | `test/strftime.test.mjs` | `builder/strftime.mjs`'s `formatDate`, which no build calls because no page sets `last_modified_date`. Run by `node --test` | | `test.bat` | `test/compact-image.test.mjs` | what an embedded image keeps (`compactImage` and `stripFile` in `scripts/lib/compact-image.mjs`): an editor's metadata goes, a notice of whose the picture is and on what terms stays (the IDE's lock icon carries a CC BY-NC-SA attribution), an sRGB profile goes and any other stays, a JPEG or a GIF is labelled by its bytes, and an image inside an SVG inside a CSS file is reached and keeps its encoding. Its second half runs `scripts/imagestrip/` over a CSS file and SVG files of every image the test builds and over the IDE's own stylesheets, and compares output, summary and reports with `stripFile` byte for byte; it is skipped unless `IMAGESTRIP_EXE` names the built exe. Run by `node --test` | -| `test.bat` | `test/svgshot.test.mjs` | an SVG screenshot is written on one line (`oneLine` in `scripts/svgshot/snapshot-svg.mjs`), a break in a tag as a space and one in text as a character reference, so that a CRLF checkout changes only its last line ending; an underline is placed and sized by the figures fitted in Edge (`underlineBand`) under the boxes that set one (`decoratingBoxes`); a background image is sized by its bytes, a JPEG the IDE calls `image/png` included (`naturalSize`); an iframe's document is decoded and placed at the iframe's content box, less its scroll, scaled when zoomed, over its canvas colour (`frameGeometry`, `frameView`, `unzoom`, `viewportScroller`, `canvasColor`); a run's web font is the `@font-face` rule CSS matching picks (`webFontFiles`), and a variable WOFF2 is read and cut per weight; a family split by `unicode-range` gives each character to the rule whose range holds it (`parseUnicodeRange`, `webRulesFor`); an inline `` is found and its ids scoped (`inlineSvgsIn`, `scopeIds`); a filter of colour functions is baked into the colours under it, in order and clamped, its pictures wrapped in a real filter and its opacity kept apart (`colour-filter.mjs`); a transformed element's boxes and text are put back from the snapshot's after-transform boxes (`elementTransform`, `untransformBox`); a closed select shows its option's whole text (`selectLabel`); a bundle draws the same SVG from its file as in memory, and counts each question it cannot answer (`renderBundle`); its web font rules and files draw each character from the file whose range holds it, at the weight its rule allows, and its inline `` markup is drawn in its box, from the file and when trimmed (`drawBundle`); a bundle of version 1 is refused; one trimmed to its view draws the same (`trimSnapshot`); a font's cut comes from the subset cache with no Python run (`subsetKey`, `useSubsetCache`); `redraw` draws a picture again from its bundle or names its retake, and fails on the user name unless `checkUserName: false`; `listPictures` leaves the output trees out; `outputRefusal` refuses a sheet under docs or where git would take it, and `sheetHtml` puts the worst picture first (`scripts/svgshot/sheets.mjs`); a capture's verdict with a picture's allowances and a review's decisions, and the manifest written, read back and refused when malformed (`decidePicture`, `decideSvg`, `cleanupResets`, `scripts/lib/shot-policy.mjs`; `parseManifest`, `manifestText`, `openManifest`, `scripts/lib/shot-manifest.mjs`); a capture's fate against the picture at a git revision and the file, the SVG of one put back, what `--cleanup` resets, and a revision's files read in one git process and a text file put back as a checkout writes it, in a scratch repository (`pictureFate`, `svgFate`, `planCleanup`, `readRevisionFiles`, `restoreFromRevision`, `scripts/lib/shot-revision.mjs`). What it keeps of an image it embeds is `compactImage`'s, which the row above tests. Run by `node --test` | +| `test.bat` | `test/svgshot.test.mjs` | an SVG screenshot is written on one line (`oneLine` in `scripts/svgshot/snapshot-svg.mjs`), a break in a tag as a space and one in text as a character reference, so that a CRLF checkout changes only its last line ending; an underline is placed and sized by the figures fitted in Edge (`underlineBand`) under the boxes that set one (`decoratingBoxes`); a background image is sized by its bytes, a JPEG the IDE calls `image/png` included (`naturalSize`); an iframe's document is decoded and placed at the iframe's content box, less its scroll, scaled when zoomed, over its canvas colour (`frameGeometry`, `frameView`, `unzoom`, `viewportScroller`, `canvasColor`); a run's web font is the `@font-face` rule CSS matching picks (`webFontFiles`), and a variable WOFF2 is read and cut per weight; a family split by `unicode-range` gives each character to the rule whose range holds it (`parseUnicodeRange`, `webRulesFor`); an inline `` is found and its ids scoped (`inlineSvgsIn`, `scopeIds`); a filter of colour functions is baked into the colours under it, in order and clamped, its pictures wrapped in a real filter and its opacity kept apart (`colour-filter.mjs`); a transformed element's boxes and text are put back from the snapshot's after-transform boxes (`elementTransform`, `untransformBox`); a closed select shows its option's whole text (`selectLabel`); a bundle draws the same SVG from its file as in memory, and counts each question it cannot answer (`renderBundle`); its web font rules and files draw each character from the file whose range holds it, at the weight its rule allows, and its inline `` markup is drawn in its box, from the file and when trimmed (`drawBundle`); a bundle of version 1 is refused; one trimmed to its view draws the same (`trimSnapshot`); a font's cut comes from the subset cache with no Python run (`subsetKey`, `useSubsetCache`); `redraw` draws a picture again from its bundle or names its retake, and fails on the user name unless `checkUserName: false`; `listPictures` leaves the output trees out; `outputRefusal` refuses a sheet under docs or where git would take it, and `sheetHtml` puts the worst picture first (`scripts/svgshot/sheets.mjs`); a strength map's share at a level and its regions of change, a word's strokes closed into one block and a line up to 4 pixels thick eroded away, and which pictures need a look (`shareOff`, `changeRegions`, `scripts/lib/shot-change.mjs`; `pngLook`, `svgLook`, `scripts/lib/shot-queue.mjs`); a capture's verdict with a picture's allowances and a review's decisions, and the manifest written, read back and refused when malformed (`decidePicture`, `decideSvg`, `cleanupResets`, `scripts/lib/shot-policy.mjs`; `parseManifest`, `manifestText`, `openManifest`, `scripts/lib/shot-manifest.mjs`); a capture's fate against the picture at a git revision and the file, the SVG of one put back, what `--cleanup` resets, and a revision's files read in one git process and a text file put back as a checkout writes it, in a scratch repository (`pictureFate`, `svgFate`, `planCleanup`, `readRevisionFiles`, `restoreFromRevision`, `scripts/lib/shot-revision.mjs`). What it keeps of an image it embeds is `compactImage`'s, which the row above tests. Run by `node --test` | | `test.bat` | `test/example-batches.test.mjs` | `check_examples.mjs`'s probes, from `scripts/lib/example-batches.mjs`: batching, crash isolation through a fake lane, the canaries, the fence classifier and `check_run`'s refusals, expected output, dispatcher and marker reading, with no IDE. Run by `node --test` | | `test.bat` | `test/ide-folders.test.mjs` | `shutdownIde` and `shutdownIdeAsync` remove the IDE's temp folder and profile once it is gone, and no other folder. Run by `node --test` | | `test.bat` | `test/broker.test.mjs` | `tbbroker` and its client (`scripts/lib/tb-broker.mjs`): one broker per pipe, leases, held processes, waiting, ports, a desktop left with windows on it, a restarted broker finding a desktop in use. Skipped unless `TBBROKER_EXE` names a built broker. Run by `node --test` | diff --git a/docs/Documentation/Tools-Toolchain-Tests.md b/docs/Documentation/Tools-Toolchain-Tests.md index 4a25c899f..dd647372a 100644 --- a/docs/Documentation/Tools-Toolchain-Tests.md +++ b/docs/Documentation/Tools-Toolchain-Tests.md @@ -277,6 +277,7 @@ Unit tests for the SVG screenshot converter. They cover: - `scripts/svgshot/redraw.mjs`, shared by [`replay.mjs`](Tools-IDE#replay) and [`sheet.mjs`](Tools-IDE#svg-sheet): a picture drawn again from its bundle, or listed as needing a retake, and failed when its SVG holds the Windows user name unless the caller turns that check off; and the list of pictures, with the build's output trees left out. `scripts/svgshot/sheets.mjs`, built from the pieces in `scripts/lib/shot-views.mjs`: the folders a sheet may not be written to, and the sheet's page, worst picture first. The sheet of changes against a git revision (`sheet.mjs --against`) is tested the same way, with no git and no browser: the change list read from git's output (the PNGs only, untracked ones added), the calls made to git, the noise rule of `scripts/lib/shot-change.mjs` and its comparison of pictures of one size and of two, the difference map's colours, the order of the rows, the commands that restore the pictures within noise, and the page. - `scripts/lib/shot-revision.mjs`, which [`shoot_docs.mjs`](Tools-IDE#shoot-docs) compares a new capture with: what becomes of a capture with the picture at a git revision or without one, within the noise of it, of the file on disk or of neither; the SVG that goes with a picture put back as the revision has it; which modified pictures `--cleanup` puts back; git's batch output, a name with spaces and a missing one included; and, in a scratch git repository, a revision's files read in one process and a text file put back as a checkout writes it, with the index left alone. - `scripts/lib/shot-policy.mjs`, which decides what becomes of a capture and of its SVG, and `scripts/lib/shot-manifest.mjs`, which reads and writes `test/shots/manifest.json`: a light picture compared with the dark one first; a picture's own noise rule and SVG bar passed to the comparison; a review's decision used for the bytes it was made on and for no others, in a run and in `--cleanup`; the manifest written in a fixed order and read back the same, from an LF or a CRLF checkout; every malformed entry refused with its key named; a build recorded and the file saved only when it changes; and the repository's own manifest read. +- The strength map, a byte per pixel saying how far one picture is from another, which `scripts/lib/shot-change.mjs` makes of two captures and `scripts/svgshot/diff.mjs` of an SVG against its PNG: the share of its pixels at a level, with rectangles left out, and its regions of change. The tests draw small maps by hand: a line up to 4 pixels thick leaves no core after the erosion and a block keeps its middle, the strokes of a word are joined into one block, every changed pixel is kept along the picture's edge, and regions that touch at a corner are one. `scripts/lib/shot-queue.mjs` then says which pictures the review tool shows first: a capture taken with nothing surviving or only a word or two, an SVG accepted with a part surviving or refused with nothing. What the SVG keeps of an image it embeds is `compactImage`'s, from `scripts/lib/compact-image.mjs`, which [`compact-image.test.mjs`](#compact-image-test) tests. No browser, no built tree, well under a second. diff --git a/scripts/lib/shot-change.mjs b/scripts/lib/shot-change.mjs index f98e281ae..acc5a8f61 100644 --- a/scripts/lib/shot-change.mjs +++ b/scripts/lib/shot-change.mjs @@ -43,6 +43,136 @@ export function withinNoise(A, B, { levels = NOISE_LEVELS, share = NOISE_SHARE, return true; } +// ---- the strength map and its regions --------------------------------------------------- +// +// A strength map is how far each pixel of one picture is from another's, a byte per pixel, +// row by row: {width, height, data}. strengthOf makes one of two PNG captures; diff.mjs's +// diffSvg returns one of an SVG against its PNG. The share at any level and the regions of +// change are worked out from it without comparing the pictures again. + +/** + * The strength map of two images of one size: each pixel's largest difference of any + * channel, alpha included, as withinNoise measures it. + */ +export function strengthOf(A, B) { + if (A.width !== B.width || A.height !== B.height) + throw new Error(`the pictures are of two sizes, ${A.width} x ${A.height} and ${B.width} x ${B.height}`); + const data = new Uint8Array(A.width * A.height); + for (let i = 0; i < data.length; i++) data[i] = pixelDiff(A, i * 4, B, i * 4); + return { width: A.width, height: A.height, data }; +} + +// A mask of the pixels of `strength` at `level` or more, those inside `rects` left out. +function maskAt({ width, data }, level, rects) { + const mask = new Uint8Array(data.length); + for (let i = 0; i < data.length; i++) + if (data[i] >= level && !(rects.length && inRects(rects, i % width, Math.floor(i / width)))) mask[i] = 1; + return mask; +} + +/** + * The share of the pixels of `strength` at `level` or more, as a percentage rounded to + * three places, as diffSvg reports it. The pixels inside `rects` ({x0, y0, x1, y1}) are not + * counted, and still count in the whole, as in withinNoise. + */ +export function shareOff(strength, { level, rects = [] }) { + const mask = maskAt(strength, level, rects); + let n = 0; + for (let i = 0; i < mask.length; i++) n += mask[i]; + return +((100 * n) / mask.length).toFixed(3); +} + +// One pass of a square window 2r + 1 wide along rows (dx 1) or columns: a pixel is set when +// every pixel of the window is (`all`, an erosion) or any is (a dilation). Outside the +// picture counts as not set. +function windowPass(mask, w, h, r, along, all) { + const out = new Uint8Array(mask.length); + const [lines, len] = along === "rows" ? [h, w] : [w, h]; + const at = along === "rows" ? (line, k) => line * w + k : (line, k) => k * w + line; + const width = 2 * r + 1; + for (let line = 0; line < lines; line++) { + let n = 0; + for (let k = 0; k < Math.min(r, len); k++) n += mask[at(line, k)]; + for (let k = 0; k < len; k++) { + if (k + r < len) n += mask[at(line, k + r)]; + if (k - r - 1 >= 0) n -= mask[at(line, k - r - 1)]; + if (all ? n === width : n > 0) out[at(line, k)] = 1; + } + } + return out; +} +const erodeMask = (mask, w, h, r) => + r ? windowPass(windowPass(mask, w, h, r, "rows", true), w, h, r, "cols", true) : mask; +const dilateMask = (mask, w, h, r) => + r ? windowPass(windowPass(mask, w, h, r, "rows", false), w, h, r, "cols", false) : mask; + +/** + * The three masks changeRegions works from, a byte per pixel (1 set, 0 not): `mask` the + * pixels at `level` or more, `closed` with the gaps filled, and `core` what the erosion + * leaves of `closed`. Options as changeRegions'. + */ +export function regionMasks(strength, { level, erode = 2, close = 2, rects = [] }) { + const { width: w, height: h } = strength; + const mask = maskAt(strength, level, rects); + // Closing, the original pixels kept: the erosion after the dilation counts outside the + // picture as not set, so it would take a closed region's pixels along the edge. + const closed = close ? erodeMask(dilateMask(mask, w, h, close), w, h, close) : mask; + if (close) for (let i = 0; i < mask.length; i++) closed[i] |= mask[i]; + return { mask, closed, core: erodeMask(closed, w, h, erode) }; +} + +/** + * The regions of change in `strength`: its pixels at `level` or more (those inside `rects` + * left out), with gaps of up to 2 * `close` pixels between them filled, grouped into + * connected regions (a pixel touching another at a corner is connected to it). Each region + * is eroded by `erode` pixels: a line up to 2 * `erode` pixels thick is gone after the + * erosion, and a block keeps its middle. Returns [{x0, y0, x1, y1, pixels, core}], most core + * first: the box of the region's changed pixels (x1 and y1 not included), how many there + * are, and how many pixels of the region the erosion left. + */ +export function changeRegions(strength, options) { + const { width: w, height: h } = strength; + const { mask, closed, core } = regionMasks(strength, options); + const label = new Int32Array(mask.length); + const stack = new Int32Array(mask.length); + const regions = []; + for (let start = 0; start < mask.length; start++) { + if (!closed[start] || label[start]) continue; + const region = { x0: w, y0: h, x1: 0, y1: 0, pixels: 0, core: 0 }; + regions.push(region); + label[start] = regions.length; + let top = 0; + stack[top++] = start; + while (top) { + const i = stack[--top]; + const x = i % w; + const y = (i - x) / w; + if (mask[i]) { + region.pixels++; + if (x < region.x0) region.x0 = x; + if (y < region.y0) region.y0 = y; + if (x + 1 > region.x1) region.x1 = x + 1; + if (y + 1 > region.y1) region.y1 = y + 1; + } + region.core += core[i]; + for (let dy = -1; dy <= 1; dy++) { + const yy = y + dy; + if (yy < 0 || yy >= h) continue; + for (let dx = -1; dx <= 1; dx++) { + const xx = x + dx; + if (xx < 0 || xx >= w) continue; + const j = yy * w + xx; + if (closed[j] && !label[j]) { + label[j] = regions.length; + stack[top++] = j; + } + } + } + } + } + return regions.sort((a, b) => b.core - a.core || b.pixels - a.pixels || a.y0 - b.y0 || a.x0 - b.x0); +} + /** * Compares two images aligned at the top left, over the area they share; a pixel only * one of them has counts as changed beyond noise. Returns diff --git a/scripts/lib/shot-manifest.mjs b/scripts/lib/shot-manifest.mjs index a54115c53..b058361c5 100644 --- a/scripts/lib/shot-manifest.mjs +++ b/scripts/lib/shot-manifest.mjs @@ -14,7 +14,11 @@ // picture's device pixels (x1 and y1 not included) // png.decision {keep: "old" | "new", capture}: what a review decided about the capture // whose SHA-1 is `capture`; any other capture is judged by the rule -// svg.bar {levels, share}: the SVG's bar (SVG_FAITHFUL at 96 levels) +// svg.bar {levels, share}: the SVG's bar (SVG_FAITHFUL at 96 levels): the share +// of its pixels at least `levels` off the PNG's (1 to 255) must stay below +// `share` +// svg.ignore [[x0, y0, x1, y1], ...]: rectangles left out of the SVG's comparison, +// as png.ignore // svg.decision {accept, svg, png}: whether the SVG whose SHA-1 is `svg`, compared with // the PNG whose SHA-1 is `png`, is written whatever the bar says // @@ -31,9 +35,6 @@ export const MANIFEST = path.join(REPO_ROOT, "test", "shots", "manifest.json"); export const MANIFEST_FORMAT = 1; -/** The levels at which an SVG's difference from its PNG is counted (diff.mjs): its bar's `levels` is one of them. */ -export const SVG_LEVELS = [8, 32, 96]; - /** The SHA-1 of a file's bytes, or of a text as UTF-8 with its line endings LF, as hex. */ export const hashOf = (data) => createHash("sha1") @@ -86,16 +87,16 @@ function checkEntry(where, e) { } if (e.svg !== undefined) { const w = `${where}.svg`; - // A rectangle left out of an SVG's comparison needs the strength map, which diff.mjs - // does not return yet (WIP.ShotReview.md, step 3): svg.ignore is refused until it does. - checkKeys(w, e.svg, ["bar", "decision"]); - const { bar, decision } = e.svg; + checkKeys(w, e.svg, ["bar", "ignore", "decision"]); + const { bar, ignore, decision } = e.svg; if (bar !== undefined) { checkKeys(`${w}.bar`, bar, ["levels", "share"]); - if (bar.levels !== undefined && !SVG_LEVELS.includes(bar.levels)) - throw new Error(`${w}.bar.levels is not one of ${SVG_LEVELS.join(", ")}`); + // At 0 levels every pixel is off: a bar counts the pixels at least one level off. + if (bar.levels !== undefined && !(Number.isInteger(bar.levels) && bar.levels >= 1 && bar.levels <= 255)) + throw new Error(`${w}.bar.levels is not a number of grey levels, 1 to 255`); if (bar.share !== undefined) checkShare(`${w}.bar.share`, bar.share); } + if (ignore !== undefined) checkRects(`${w}.ignore`, ignore); if (decision !== undefined) { checkKeys(`${w}.decision`, decision, ["accept", "svg", "png"]); if (typeof decision.accept !== "boolean") throw new Error(`${w}.decision.accept is not true or false`); @@ -143,7 +144,7 @@ function pruned(value, order = []) { } const ENTRY_ORDER = { png: ["noise", "ignore", "decision"], - svg: ["bar", "decision"], + svg: ["bar", "ignore", "decision"], noise: ["levels", "share"], bar: ["levels", "share"], decision: ["keep", "capture", "accept", "svg", "png"], @@ -189,6 +190,13 @@ export function svgDecision(entry, svg, png) { return d.svg === hashOf(svg) && d.png === hashOf(png) ? d.accept : null; } +/** The decision a review made about another SVG, or about this one against another PNG: true, false, or null. */ +export function earlierSvgDecision(entry, svg, png) { + const d = entry?.svg?.decision; + if (!d || svg === null || png === null) return null; + return d.svg === hashOf(svg) && d.png === hashOf(png) ? null : d.accept; +} + /** * The manifest in `file`, or an empty one when there is none: `entry(out)` a picture's entry * or null; `setBuild(out, build)` and `clearBuild(out)`, for a capture written and a file diff --git a/scripts/lib/shot-policy.mjs b/scripts/lib/shot-policy.mjs index 4c998090d..b3e89540c 100644 --- a/scripts/lib/shot-policy.mjs +++ b/scripts/lib/shot-policy.mjs @@ -5,8 +5,8 @@ // by a review on the same bytes, take its place; and acts on the verdict through // shot-apply.mjs. -import { NOISE_LEVELS, NOISE_SHARE } from "./shot-change.mjs"; -import { earlierPngDecision, pngDecision, svgDecision } from "./shot-manifest.mjs"; +import { NOISE_LEVELS, NOISE_SHARE, shareOff } from "./shot-change.mjs"; +import { earlierPngDecision, earlierSvgDecision, pngDecision, svgDecision } from "./shot-manifest.mjs"; import { pictureFate } from "./shot-revision.mjs"; // How far, in device pixels, a light picture's width or height may be from the dark one's. @@ -27,6 +27,9 @@ export const SVG_FAITHFUL = 0.25; /** The level at which the SVG's bar counts its pixels off by default (diff.mjs's pct96). */ const SVG_BAR_LEVELS = 96; +// A manifest's rectangles, [x0, y0, x1, y1], as withinNoise and shareOff take them. +const rectsOf = (list) => (list ?? []).map(([x0, y0, x1, y1]) => ({ x0, y0, x1, y1 })); + /** * The noise rule for a picture, from its entry in the manifest (or null): {levels, share, * rects}, as withinNoise takes it (`share` a fraction, `rects` {x0, y0, x1, y1}). @@ -36,14 +39,17 @@ export function pngRule(entry) { return { levels: noise.levels ?? NOISE_LEVELS, share: noise.share === undefined ? NOISE_SHARE : noise.share / 100, - rects: (entry?.png?.ignore ?? []).map(([x0, y0, x1, y1]) => ({ x0, y0, x1, y1 })), + rects: rectsOf(entry?.png?.ignore), }; } -/** The SVG's bar for a picture, from its entry (or null): {levels, share}, `share` a percentage. */ +/** + * The SVG's bar for a picture, from its entry (or null): {levels, share, rects}, `share` a + * percentage and `rects` the rectangles left out ({x0, y0, x1, y1}). + */ export function svgRule(entry) { const bar = entry?.svg?.bar ?? {}; - return { levels: bar.levels ?? SVG_BAR_LEVELS, share: bar.share ?? SVG_FAITHFUL }; + return { levels: bar.levels ?? SVG_BAR_LEVELS, share: bar.share ?? SVG_FAITHFUL, rects: rectsOf(entry?.svg?.ignore) }; } /** A PNG's width and height, from its header: {w, h}. */ @@ -100,15 +106,20 @@ export function cleanupResets({ before, after, near, entry = null }) { } /** - * Whether an SVG is close enough to its PNG to be written, from the shares diffSvg reports - * (`stats.pct8`, `pct32`, `pct96`, percent): the share at the bar's levels must stay below - * its share (svgRule, from the picture's `entry` in the manifest, or null). A review's - * decision about this SVG (`svg`, its text) against this PNG (`png`, bytes) takes the bar's - * place. Returns {faithful, by}, `by` "decision" or "rule". + * Whether an SVG is close enough to its PNG to be written, from the strength map diffSvg + * returns: the share of its pixels at the bar's levels or more, its rectangles left out + * (shareOff), must stay below the bar's share (svgRule, from the picture's `entry` in the + * manifest, or null). A review's decision about this SVG (`svg`, its text) against this PNG + * (`png`, bytes) takes the bar's place. Returns {faithful, by, share, earlier}, `by` + * "decision" or "rule", `share` the share at the bar's levels (percent), and `earlier` a + * review's decision about another SVG or PNG (true or false), which the bar judged this one + * without, else null. */ -export function decideSvg(stats, { entry = null, svg = null, png = null } = {}) { +export function decideSvg(strength, { entry = null, svg = null, png = null } = {}) { + const { levels, share: bar, rects } = svgRule(entry); + const share = shareOff(strength, { level: levels, rects }); + const earlier = earlierSvgDecision(entry, svg, png); const decided = svgDecision(entry, svg, png); - if (decided !== null) return { faithful: decided, by: "decision" }; - const { levels, share } = svgRule(entry); - return { faithful: stats[`pct${levels}`] < share, by: "rule" }; + if (decided !== null) return { faithful: decided, by: "decision", share, earlier }; + return { faithful: share < bar, by: "rule", share, earlier }; } diff --git a/scripts/lib/shot-queue.mjs b/scripts/lib/shot-queue.mjs new file mode 100644 index 000000000..7ed9a761f --- /dev/null +++ b/scripts/lib/shot-queue.mjs @@ -0,0 +1,101 @@ +// Which pictures the screenshot review tool shows first, in Needs a look (WIP.ShotReview.md, +// The queue): those whose verdict the rule may well have got wrong. The changed pixels are +// grouped into regions (shot-change.mjs's changeRegions) and eroded: a fringe or a caret, +// a line a few pixels thick, is gone after the erosion; text that changed, or a part the SVG +// lacks, is a block, part of which survives. The measure chooses what is shown first and +// decides nothing: the verdicts are shot-policy.mjs's. + +import { changeRegions } from "./shot-change.mjs"; +import { svgRule } from "./shot-policy.mjs"; + +// The pixels a region's gaps are closed by and it is eroded by. Closing first joins a word's +// strokes into one block: a stroke of the IDE's text at 2x is 2 or 3 pixels thick, and the +// erosion alone takes every one of them (Designer_Zoom50's SVG, its text drawn at the wrong +// size, keeps no core without the closing, and ten regions with it). +export const QUEUE_CLOSE = 2; +export const QUEUE_ERODE = 2; + +/** + * The fewest pixels of core that make a region survive: a block of 4 by 4 device pixels, 2 by + * 2 CSS pixels. Where two fringes cross, the closing leaves a few pixels of core (1 to 15 + * in the SVGs of the 476 bundles of 2026-10-31, none of them a part missing). + */ +export const SURVIVES = 16; + +/** + * The most core a surviving region of a small change may have: a word of the IDE's text. + * A version number changing in the call stack or a title bar leaves 224 to 265 pixels of + * core, a name 127 pixels wide 781. + */ +export const WORD_CORE = 1200; + +/** How many surviving regions a small change may have: "a block or two the size of a word". */ +export const WORD_BLOCKS = 2; + +/** + * The regions of change between a new capture and the picture it was judged against, from + * their strength map (strengthOf): every pixel that differs at all counts, those inside the + * noise rule's `rects` (pngRule) left out. Not the pixels beyond noise: a retake on a new + * build often shifts a colour by a few levels over a whole window, which withinNoise rightly + * calls a change and which, counted beyond noise, leaves nothing and would read as noise (in + * the retake on BETA 1010, 294 of the 361 pictures it changed at their old size; counted at + * any difference, 23). + */ +export const pngRegions = (strength, rule) => + changeRegions(strength, { level: 1, close: QUEUE_CLOSE, erode: QUEUE_ERODE, rects: rule?.rects ?? [] }); + +/** + * The regions where an SVG is off its PNG, from diffSvg's strength map: the pixels at the + * bar's levels or more, its rectangles left out (svgRule, from the picture's `entry`). + */ +export function svgRegions(strength, entry = null) { + const { levels, rects } = svgRule(entry); + return changeRegions(strength, { level: levels, close: QUEUE_CLOSE, erode: QUEUE_ERODE, rects }); +} + +/** The regions of `regions` that survive the erosion. */ +export const surviving = (regions) => regions.filter((r) => r.core >= SURVIVES); + +// Whether the surviving regions are a small change: a block or two the size of a word. +const small = (kept) => kept.length <= WORD_BLOCKS && kept.every((r) => r.core <= WORD_CORE); + +/** + * Why a picture's PNG needs a look, or null: the first argument is decidePicture's verdict + * ({state, by, earlier}), `regions` pngRegions' for the capture against the picture it was + * judged against. + * Returns {why, text}, `why` one of: + * + * earlier a review decided about other bytes of this picture; the rule judged these + * noise the capture was taken, and nothing survives: most likely a caret, a clock or + * a fringe + * small the capture was taken, and what survives is a block or two the size of a word: + * a real change small enough to miss + */ +export function pngLook({ state, by, earlier }, regions) { + if (by === "decision") return null; + if (earlier) return { why: "earlier", text: `a review kept the ${earlier} picture of other bytes` }; + if (state !== "updated") return null; + const kept = surviving(regions); + if (!kept.length) return { why: "noise", text: "taken, but nothing survives the erosion: most likely noise" }; + if (small(kept)) return { why: "small", text: "taken, and the change is a word or two: small enough to miss" }; + return null; +} + +/** + * Why a picture's SVG needs a look, or null: the first argument is decideSvg's verdict + * ({faithful, by, earlier}), `regions` svgRegions'. Returns {why, text}, `why` one of: + * + * earlier a review decided about another SVG or PNG of this picture + * part accepted, but something survives: a part drawn wrongly or not at all, + * whatever the share + * fringe refused, and nothing survives: fringes only, most likely acceptable + */ +export function svgLook({ faithful, by, earlier }, regions) { + if (by === "decision") return null; + if (earlier !== null && earlier !== undefined) + return { why: "earlier", text: `a review ${earlier ? "accepted" : "refused"} an SVG of other bytes` }; + const kept = surviving(regions); + if (faithful && kept.length) return { why: "part", text: "accepted, but a part of it survives the erosion" }; + if (!faithful && !kept.length) return { why: "fringe", text: "refused, but nothing survives the erosion: fringes" }; + return null; +} diff --git a/scripts/svgshot/diff.mjs b/scripts/svgshot/diff.mjs index f438873aa..e4c199ed3 100644 --- a/scripts/svgshot/diff.mjs +++ b/scripts/svgshot/diff.mjs @@ -7,6 +7,8 @@ // glyph placed a fraction of a pixel differently is not a difference, a // missing one is. +import { shareOff } from "../lib/shot-change.mjs"; + /** The clip an SVG picture names on its root (`data-clip`, page CSS px), else its own size at 0,0. */ export function svgClip(svg) { const root = /]*>/.exec(svg)?.[0] ?? ""; @@ -23,7 +25,11 @@ export function svgClip(svg) { * px. Returns the statistics -- the share of pixels (percent) differing by at * least 8, 32 and 96 levels, the same for 32 without the one-pixel tolerance, * the mean, and the `top` worst `tile`-px tiles in page CSS px -- with the - * render, the reference cropped to the clip, and the difference map, as PNGs. + * render, the reference cropped to the clip, and the difference map, as PNGs, + * and the strength map: {width, height, data}, a byte per device pixel of the + * clip, row by row, the distance with the one-pixel tolerance in whole levels + * (0 to 255), from which the share at any level, with rectangles left out, and the + * regions of change are worked out (shot-change.mjs's shareOff and changeRegions). */ export async function diffSvg(page, svg, ref, { refOrigin = { x: 0, y: 0 }, scale = 2, tile = 16, top = 12 } = {}) { const clip = svgClip(svg); @@ -106,9 +112,7 @@ export async function diffSvg(page, svg, ref, { refOrigin = { x: 0, y: 0 }, scal const T = Math.round(tile * scale); const tilesX = Math.ceil(W / T); const tiles = new Map(); - let over8 = 0; - let over32 = 0; - let over96 = 0; + const strength = new Uint8Array(W * H); let strict32 = 0; let sum = 0; // Luminance and alpha, not each channel: ClearType draws a glyph's edge @@ -140,18 +144,16 @@ export async function diffSvg(page, svg, ref, { refOrigin = { x: 0, y: 0 }, scal const pyl = Math.floor(i / W); const diff = Math.max(near(a, b, p, pxl, pyl), near(b, a, p, pxl, pyl)); sum += diff; + // Whole levels: a level's count (diff >= n) is the same from the byte as from the distance. + strength[i] = diff >= 255 ? 255 : Math.floor(diff); const lum = (a[p] * 0.3 + a[p + 1] * 0.59 + a[p + 2] * 0.11) * 0.25 + 180; let rgb = [lum, lum, lum]; - if (diff >= 8) over8++; if (diff >= 32) { - over32++; const key = Math.floor(i / W / T) * tilesX + Math.floor((i % W) / T); tiles.set(key, (tiles.get(key) ?? 0) + 1); } - if (diff >= 96) { - over96++; - rgb = [230, 0, 0]; - } else if (diff >= 32) rgb = [255, 140, 0]; + if (diff >= 96) rgb = [230, 0, 0]; + else if (diff >= 32) rgb = [255, 140, 0]; else if (diff >= 8) rgb = [220, 200, 0]; o[p] = rgb[0]; o[p + 1] = rgb[1]; @@ -168,12 +170,14 @@ export async function diffSvg(page, svg, ref, { refOrigin = { x: 0, y: 0 }, scal y: clip.y + Math.floor(key / tilesX) * tile, share: +(n / (T * T)).toFixed(3), })); + // The map as base64, in pieces: String.fromCharCode takes only so many arguments. + let bytes = ""; + for (let k = 0; k < strength.length; k += 0x8000) + bytes += String.fromCharCode.apply(null, strength.subarray(k, k + 0x8000)); return { + strength: btoa(bytes), stats: { size: [W, H], - pct8: +((100 * over8) / pixels).toFixed(3), - pct32: +((100 * over32) / pixels).toFixed(3), - pct96: +((100 * over96) / pixels).toFixed(3), strictPct32: +((100 * strict32) / pixels).toFixed(3), mean: +(sum / pixels).toFixed(3), worst, @@ -193,7 +197,17 @@ export async function diffSvg(page, svg, ref, { refOrigin = { x: 0, y: 0 }, scal }, ); const png = (url) => Buffer.from(url.slice(url.indexOf(",") + 1), "base64"); - return { clip, stats: out.stats, render: Buffer.from(render), diff: png(out.diffUrl), refCrop: png(out.refUrl) }; + const [w, h] = out.stats.size; + const strength = { width: w, height: h, data: new Uint8Array(Buffer.from(out.strength, "base64")) }; + const { size, ...rest } = out.stats; + const stats = { + size, + pct8: shareOff(strength, { level: 8 }), + pct32: shareOff(strength, { level: 32 }), + pct96: shareOff(strength, { level: 96 }), + ...rest, + }; + return { clip, stats, strength, render: Buffer.from(render), diff: png(out.diffUrl), refCrop: png(out.refUrl) }; } /** Reference above render above difference, magnified without smoothing, for the page area `zoom`. */ diff --git a/scripts/svgshot/keep.mjs b/scripts/svgshot/keep.mjs index 96e65cd65..43a9470e7 100644 --- a/scripts/svgshot/keep.mjs +++ b/scripts/svgshot/keep.mjs @@ -19,7 +19,8 @@ export const holdsUserName = (svg, user) => /** * `svg` against the PNG it was taken with, in a page of `browser`: - * {diff, faithful, by, measures}, `faithful` and `by` decideSvg's verdict, with the + * {diff, faithful, by, measures}, `diff` diffSvg's (its strength map among it), `faithful` + * and `by` decideSvg's verdict, with the * picture's `entry` in the manifest (or null), and `measures` the line that says how far * off it is and what the converter did not draw (`stats.unsupported`). */ @@ -33,14 +34,16 @@ export async function judgeSvg(browser, svg, png, stats, entry = null) { await page.close(); } const unsupported = Object.entries(stats.unsupported).map(([k, n]) => `${k} x${n}`); + const { faithful, by, share } = decideSvg(diff.strength, { entry, svg, png }); // A picture with a bar of its own says so, with the share at that bar's levels. const rule = svgRule(entry); - const own = entry?.svg?.bar - ? `; its own bar is ${rule.share}% at ${rule.levels} levels, and ${diff.stats[`pct${rule.levels}`]}% are off by that` - : ""; + const left = rule.rects.length ? `, ${rule.rects.length} rectangle${rule.rects.length > 1 ? "s" : ""} left out` : ""; + const own = + entry?.svg?.bar || rule.rects.length + ? `; its own bar is ${rule.share}% at ${rule.levels} levels${left}, and ${share}% are off by that` + : ""; const measures = `${diff.stats.pct32}% of pixels differ, ${diff.stats.pct96}% strongly${own}` + `${unsupported.length ? `; not drawn: ${unsupported.join(", ")}` : ""}`; - const { faithful, by } = decideSvg(diff.stats, { entry, svg, png }); return { diff, faithful, by, measures }; } diff --git a/test/svgshot.test.mjs b/test/svgshot.test.mjs index 71c1d0ef2..3341d7279 100644 --- a/test/svgshot.test.mjs +++ b/test/svgshot.test.mjs @@ -64,6 +64,7 @@ import { } from "../scripts/lib/shot-policy.mjs"; import { earlierPngDecision, + earlierSvgDecision, hashOf, manifestText, openManifest, @@ -71,7 +72,26 @@ import { pngDecision, svgDecision, } from "../scripts/lib/shot-manifest.mjs"; -import { changeMap, compareImages, NOISE_LEVELS, NOISE_SHARE, withinNoise } from "../scripts/lib/shot-change.mjs"; +import { + changeMap, + changeRegions, + compareImages, + NOISE_LEVELS, + NOISE_SHARE, + regionMasks, + shareOff, + strengthOf, + withinNoise, +} from "../scripts/lib/shot-change.mjs"; +import { + pngLook, + pngRegions, + SURVIVES, + surviving, + svgLook, + svgRegions, + WORD_CORE, +} from "../scripts/lib/shot-queue.mjs"; import { changedPictures, openRevision, @@ -1926,6 +1946,185 @@ describe("the records a child writes among its lines (record-lines.mjs)", () => }); }); +// A strength map `w` by `h` whose pixels, in reading order, are the [count, level] runs of +// `runs`, the rest 0. +function strengthMap(runs, w = 100, h = 100) { + const data = new Uint8Array(w * h); + let at = 0; + for (const [n, level] of runs) { + data.fill(level, at, at + n); + at += n; + } + return { width: w, height: h, data }; +} + +// A strength map `w` by `h` with `level(x, y)` at each pixel (0, or undefined, for none). +function paintMap(w, h, level) { + const data = new Uint8Array(w * h); + for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) data[y * w + x] = level(x, y) ?? 0; + return { width: w, height: h, data }; +} + +// A level of 200 inside each of `rects`, [x0, y0, x1, y1] (x1 and y1 not included). +const blocks = + (...rects) => + (x, y) => + rects.some(([x0, y0, x1, y1]) => x >= x0 && x < x1 && y >= y0 && y < y1) ? 200 : 0; + +// Five strokes 2 pixels thick and 3 apart, 14 high: what a word of the IDE's text at 2x leaves. +const word = (x0, y0) => (x, y) => (y >= y0 && y < y0 + 14 && x >= x0 && x < x0 + 25 && (x - x0) % 5 < 2 ? 200 : 0); + +describe("the strength map and its regions (shot-change.mjs)", () => { + test("a strength map of two pictures is each pixel's largest difference of a channel", () => { + const A = { width: 2, height: 1, rgba: new Uint8Array([10, 10, 10, 255, 0, 0, 0, 255]) }; + const B = { width: 2, height: 1, rgba: new Uint8Array([10, 40, 10, 255, 0, 0, 0, 200]) }; + assert.deepEqual([...strengthOf(A, B).data], [30, 55]); + assert.throws(() => strengthOf(A, { width: 1, height: 2, rgba: A.rgba }), /two sizes/); + }); + + test("the share at a level counts the pixels at it or more, rounded as diffSvg rounds", () => { + const m = strengthMap([ + [3, 8], + [2, 32], + [1, 96], + ]); + assert.deepEqual( + [8, 32, 96, 97].map((level) => shareOff(m, { level })), + [0.06, 0.03, 0.01, 0], + ); + // a rectangle left out still counts in the whole + assert.equal(shareOff(m, { level: 8, rects: [{ x0: 0, y0: 0, x1: 3, y1: 1 }] }), 0.03); + assert.equal(shareOff(strengthMap([[1, 50]], 3, 1), { level: 1 }), 33.333); + }); + + test("a line up to 4 pixels thick leaves no core, a block keeps its middle", () => { + const one = (level) => changeRegions(paintMap(60, 40, level), { level: 1 }); + // a fringe, a caret, a line 4 thick + assert.deepEqual(one(blocks([5, 10, 55, 11])), [{ x0: 5, y0: 10, x1: 55, y1: 11, pixels: 50, core: 0 }]); + assert.equal(one(blocks([20, 5, 22, 35]))[0].core, 0); + assert.equal(one(blocks([5, 10, 55, 14]))[0].core, 0); + // 5 thick is the thinnest that survives, as a line 1 pixel thick + assert.equal(one(blocks([5, 10, 55, 15]))[0].core, 46); + // a block 10 by 10 keeps 6 by 6 + assert.deepEqual(one(blocks([10, 10, 20, 20])), [{ x0: 10, y0: 10, x1: 20, y1: 20, pixels: 100, core: 36 }]); + }); + + test("closing joins a word's strokes into one block, which survives", () => { + const m = paintMap(60, 40, word(10, 10)); + const open = changeRegions(m, { level: 1, close: 0 }); + assert.equal(open.length, 5); + assert.ok(open.every((r) => r.core === 0)); + const closed = changeRegions(m, { level: 1 }); + assert.equal(closed.length, 1); + // the box and the count are the changed pixels', the core the closed region's + assert.deepEqual(closed[0], { x0: 10, y0: 10, x1: 32, y1: 24, pixels: 140, core: 180 }); + // a gap of 5 is not closed + const apart = paintMap(60, 40, blocks([10, 10, 20, 20], [25, 10, 35, 20])); + assert.equal(changeRegions(apart, { level: 1 }).length, 2); + assert.equal(changeRegions(paintMap(60, 40, blocks([10, 10, 20, 20], [24, 10, 34, 20])), { level: 1 }).length, 1); + }); + + test("closing keeps every changed pixel, along the picture's edge too", () => { + const m = paintMap(20, 20, blocks([0, 0, 20, 6])); + const { mask, closed, core } = regionMasks(m, { level: 1 }); + for (let i = 0; i < mask.length; i++) assert.ok(closed[i] >= mask[i]); + // outside the picture counts as unchanged: the erosion takes 2 rows from the edge + assert.equal( + core.reduce((a, b) => a + b, 0), + 16 * 2, + ); + assert.deepEqual(changeRegions(m, { level: 1 }), [{ x0: 0, y0: 0, x1: 20, y1: 6, pixels: 120, core: 32 }]); + }); + + test("regions touching at a corner are one, and the most core comes first", () => { + const corner = paintMap(20, 20, (x, y) => ((x === 3 && y === 3) || (x === 4 && y === 4) ? 99 : 0)); + assert.equal(changeRegions(corner, { level: 1, close: 0 }).length, 1); + const two = paintMap(80, 40, blocks([2, 2, 4, 30], [40, 5, 60, 25])); + assert.deepEqual( + changeRegions(two, { level: 1 }).map((r) => [r.x0, r.core]), + [ + [40, 256], + [2, 0], + ], + ); + }); + + test("counts only the pixels at the level, and leaves the rectangles out", () => { + const m = paintMap(60, 40, (x, y) => (x >= 10 && x < 20 && y >= 10 && y < 20 ? (x < 15 ? 50 : 150) : 0)); + assert.deepEqual( + changeRegions(m, { level: 100 }).map((r) => [r.x0, r.x1, r.pixels]), + [[15, 20, 50]], + ); + assert.deepEqual(changeRegions(m, { level: 1, rects: [{ x0: 0, y0: 0, x1: 60, y1: 40 }] }), []); + assert.deepEqual(changeRegions(strengthMap([]), { level: 1 }), []); + }); +}); + +describe("which pictures need a look (shot-queue.mjs)", () => { + const region = (core) => ({ x0: 0, y0: 0, x1: 10, y1: 10, pixels: 100, core }); + const taken = { state: "updated", by: "rule", earlier: null }; + + test("a region survives with a core of SURVIVES pixels", () => { + assert.deepEqual(surviving([region(SURVIVES), region(SURVIVES - 1), region(0)]), [region(SURVIVES)]); + }); + + test("a capture taken needs a look when nothing survives, or a word or two does", () => { + assert.equal(pngLook(taken, []).why, "noise"); + assert.equal(pngLook(taken, [region(SURVIVES - 1), region(3)]).why, "noise"); + assert.equal(pngLook(taken, [region(WORD_CORE), region(SURVIVES)]).why, "small"); + assert.equal(pngLook(taken, [region(WORD_CORE + 1)]), null); + assert.equal(pngLook(taken, [region(100), region(100), region(100)]), null); + // a picture not taken, or decided by a review, does not + for (const state of ["new", "noise", "restored", "unchanged", "dark"]) + assert.equal(pngLook({ ...taken, state }, []), null); + assert.equal(pngLook({ ...taken, by: "decision" }, []), null); + // a review's decision about other bytes sends it back, whatever the regions + assert.equal(pngLook({ ...taken, state: "noise", earlier: "new" }, [region(5000)]).why, "earlier"); + }); + + test("an SVG needs a look when accepted with something surviving, or refused with nothing", () => { + const rule = { faithful: true, by: "rule", earlier: null }; + assert.equal(svgLook(rule, [region(SURVIVES)]).why, "part"); + assert.equal(svgLook(rule, [region(SURVIVES - 1)]), null); + assert.equal(svgLook({ ...rule, faithful: false }, [region(3)]).why, "fringe"); + assert.equal(svgLook({ ...rule, faithful: false }, [region(SURVIVES)]), null); + assert.equal(svgLook({ ...rule, by: "decision" }, [region(5000)]), null); + assert.equal(svgLook({ ...rule, earlier: false }, []).why, "earlier"); + }); + + test("from the strength maps: a word changed, a caret, a part missing and a fringe", () => { + const rule = pngRule(null); + assert.equal(pngLook(taken, pngRegions(paintMap(200, 100, word(20, 20)), rule)).why, "small"); + assert.equal(pngLook(taken, pngRegions(paintMap(200, 100, blocks([50, 10, 52, 40])), rule)).why, "noise"); + // a whole window a few levels off is a change, not noise + assert.equal( + pngLook( + taken, + pngRegions( + paintMap(200, 100, () => 3), + rule, + ), + ), + null, + ); + // the caret inside a rectangle left out leaves nothing at all + const caret = paintMap(200, 100, (x, y) => blocks([50, 10, 52, 40])(x, y) || word(100, 20)(x, y)); + const left = pngRule({ png: { ignore: [[40, 0, 60, 100]] } }); + assert.equal(pngRegions(caret, left).length, 1); + // an SVG at its bar's level: an icon missing survives, a fringe at 95 levels is not counted + const icon = paintMap(200, 100, blocks([20, 20, 52, 52])); + assert.equal(svgLook({ faithful: true, by: "rule", earlier: null }, svgRegions(icon)).why, "part"); + const faint = paintMap(200, 100, (x, y) => (blocks([20, 20, 52, 52])(x, y) ? 95 : 0)); + assert.deepEqual(svgRegions(faint), []); + assert.equal(svgRegions(faint, { svg: { bar: { levels: 32 } } }).length, 1); + assert.equal( + svgLook({ faithful: false, by: "rule", earlier: null }, svgRegions(paintMap(200, 100, blocks([0, 50, 200, 51])))) + .why, + "fringe", + ); + }); +}); + describe("what becomes of a picture and its SVG (shot-policy.mjs, shot-apply.mjs)", () => { // A PNG's header with its size, and a tag that tells two of one size apart. const shot = (w, h, tag) => { @@ -1979,15 +2178,43 @@ describe("what becomes of a picture and its SVG (shot-policy.mjs, shot-apply.mjs }); test("an SVG is faithful below the bar, the default one or a picture's own", () => { - assert.deepEqual(decideSvg({ pct96: SVG_FAITHFUL - 0.001 }), { faithful: true, by: "rule" }); - assert.equal(decideSvg({ pct96: SVG_FAITHFUL }).faithful, false); + // 10,000 pixels: SVG_FAITHFUL (0.25%) is 25 of them + assert.deepEqual(decideSvg(strengthMap([[24, 96]])), { faithful: true, by: "rule", share: 0.24, earlier: null }); + assert.equal(decideSvg(strengthMap([[25, 255]])).faithful, false); + assert.equal(decideSvg(strengthMap([[1000, 95]])).faithful, true); const wider = { svg: { bar: { share: 0.35 } } }; - assert.equal(decideSvg({ pct96: 0.3 }, { entry: wider }).faithful, true); - // a bar at another level counts the pixels off at that level + assert.equal(decideSvg(strengthMap([[30, 96]]), { entry: wider }).faithful, true); + // a bar at another level counts the pixels at that level or more, at any level const at32 = { svg: { bar: { levels: 32, share: 1 } } }; - assert.equal(decideSvg({ pct96: 0, pct32: 1.5 }, { entry: at32 }).faithful, false); - assert.equal(decideSvg({ pct96: 5, pct32: 0.5 }, { entry: at32 }).faithful, true); - assert.deepEqual(svgRule(null), { levels: 96, share: SVG_FAITHFUL }); + assert.equal(decideSvg(strengthMap([[150, 40]]), { entry: at32 }).faithful, false); + assert.equal( + decideSvg( + strengthMap([ + [50, 31], + [50, 200], + ]), + { entry: at32 }, + ).share, + 0.5, + ); + const at50 = { svg: { bar: { levels: 50, share: 0.5 } } }; + assert.equal( + decideSvg( + strengthMap([ + [30, 60], + [100, 49], + ]), + { entry: at50 }, + ).faithful, + true, + ); + assert.equal(decideSvg(strengthMap([[60, 50]]), { entry: at50 }).faithful, false); + // the pixels inside a rectangle left out are not counted: the first 100 are row 0 + const row0 = { svg: { ignore: [[0, 0, 100, 1]] } }; + assert.deepEqual(decideSvg(strengthMap([[100, 255]]), { entry: row0 }).share, 0); + assert.deepEqual(decideSvg(strengthMap([[101, 255]]), { entry: row0 }).share, 0.01); + assert.deepEqual(svgRule(null), { levels: 96, share: SVG_FAITHFUL, rects: [] }); + assert.deepEqual(svgRule(row0).rects, [{ x0: 0, y0: 0, x1: 100, y1: 1 }]); }); test("a review's decision takes the rule's place for the bytes it was made on, and no others", () => { @@ -2071,14 +2298,24 @@ describe("what becomes of a picture and its SVG (shot-policy.mjs, shot-apply.mjs assert.equal(svgDecision(svgEntry, "", OTHER), true); assert.equal(svgDecision(svgEntry, "", NEAR), null); assert.equal(svgDecision(svgEntry, "", OTHER), null); - assert.deepEqual(decideSvg({ pct96: 50 }, { entry: svgEntry, svg: "", png: OTHER }), { + const half = strengthMap([[5000, 255]]); + assert.deepEqual(decideSvg(half, { entry: svgEntry, svg: "", png: OTHER }), { faithful: true, by: "decision", + share: 50, + earlier: null, }); - assert.deepEqual(decideSvg({ pct96: 50 }, { entry: svgEntry, svg: "", png: NEAR }), { + // the same SVG against another PNG: judged by the bar, the decision shown as an earlier one + assert.deepEqual(decideSvg(half, { entry: svgEntry, svg: "", png: NEAR }), { faithful: false, by: "rule", + share: 50, + earlier: true, }); + assert.equal(earlierSvgDecision(svgEntry, "", OTHER), true); + assert.equal(earlierSvgDecision(svgEntry, "", OTHER), null); + assert.equal(earlierSvgDecision(null, "", OTHER), null); + assert.equal(decideSvg(half, { svg: "", png: OTHER }).earlier, null); }); test("an allowance changes the noise rule the picture is judged with", () => { @@ -2192,7 +2429,7 @@ describe("the screenshot manifest (shot-manifest.mjs)", () => { pictures: { "IDE/Images/B.png": { build: 1010 }, "IDE/Images/A.light.png": { - svg: { decision: { accept: true, svg: H, png: H }, bar: { share: 0.35 } }, + svg: { decision: { accept: true, svg: H, png: H }, ignore: [[0, 0, 8, 8]], bar: { levels: 50, share: 0.35 } }, png: { decision: { keep: "old", capture: H }, ignore: [[812, 40, 900, 64]], noise: { share: 0.5 } }, build: 1501, }, @@ -2222,8 +2459,12 @@ describe("the screenshot manifest (shot-manifest.mjs)", () => { }, "svg": { "bar": { + "levels": 50, "share": 0.35 }, + "ignore": [ + [0, 0, 8, 8] + ], "decision": { "accept": true, "svg": "${H}", @@ -2262,8 +2503,10 @@ describe("the screenshot manifest (shot-manifest.mjs)", () => { bad({ "a.png": { png: { ignore: [[5, 0, 5, 9]] } } }, /png\.ignore\[0\] is not \[x0, y0, x1, y1\]/); bad({ "a.png": { png: { decision: { keep: "both", capture: H } } } }, /png\.decision\.keep/); bad({ "a.png": { png: { decision: { keep: "old", capture: "x" } } } }, /png\.decision\.capture is not a SHA-1/); - bad({ "a.png": { svg: { bar: { levels: 50 } } } }, /svg\.bar\.levels is not one of 8, 32, 96/); - bad({ "a.png": { svg: { ignore: [] } } }, /svg has an unknown key, ignore/); + bad({ "a.png": { svg: { bar: { levels: 0 } } } }, /svg\.bar\.levels is not a number of grey levels, 1 to 255/); + bad({ "a.png": { svg: { bar: { levels: 32.5 } } } }, /svg\.bar\.levels/); + bad({ "a.png": { svg: { ignore: [[0, 9, 4, 3]] } } }, /svg\.ignore\[0\] is not \[x0, y0, x1, y1\]/); + bad({ "a.png": { svg: { other: [] } } }, /svg has an unknown key, other/); bad({ "a.png": { svg: { decision: { accept: "yes", svg: H, png: H } } } }, /svg\.decision\.accept/); }); From 4f05d0229cc5154866edfcef4bd5400c8d91d4b4 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Sat, 31 Oct 2026 21:38:39 +0100 Subject: [PATCH 13/85] WIP.ShotReview.md: a capture kept as within noise with a surviving block goes to the queue (owner); pngLook to follow before step 4 --- WIP.ShotReview.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/WIP.ShotReview.md b/WIP.ShotReview.md index dc2d55d25..726158c07 100644 --- a/WIP.ShotReview.md +++ b/WIP.ShotReview.md @@ -143,7 +143,10 @@ when: - **PNG**: the tool took the new capture, but nothing survives (most likely noise: a caret, a clock, a fringe), or what survives is small (a block or two the size of a word: a real - change small enough to miss). + change small enough to miss); or the tool kept the old picture as within noise (`noise`, or + `restored` to `HEAD`'s), but something survives (owner, 2026-10-31): a faint block, a tint over + less than 0.5% of the picture, which *keep old → take new* is for. Its regions are those of the + capture against the picture it was found within noise of. - **SVG**: the tool accepted it, but something survives (a part missing, whatever the share); or the tool rejected it, and nothing survives (fringes only, most likely acceptable). @@ -319,9 +322,12 @@ at 200 levels. The constants were chosen from the pictures: each stage drawn ove retaken PNGs (the changed pixels, what the closing adds, the core, the boxes), and the counts over every SVG and the PNGs of the BETA 1010 and 1033 retakes. -**Not in the rules, to ask the owner:** a capture within noise (kept as the file) whose faint -difference is a block that survives: a tint over less than 0.5% of the picture, which *keep old -→ take new* is for. The rules send to the queue only a capture the tool took. +**Before step 4: the PNG rule for a capture kept as within noise** (owner, 2026-10-31; [the +queue](#the-queue)) is not in `shot-queue.mjs` yet. `pngLook` returns null for every state but +`updated`; it is to return `{why: "faint", ...}` for `noise` and `restored` when a region +survives, with a test beside the others in `test/svgshot.test.mjs` ("which pictures need a +look"), and the line on [Tools-Toolchain-Tests](docs/Documentation/Tools-Toolchain-Tests.md) +that lists what the queue sends. Step 2 was checked with the runs [below](#checking-a-move), whose states it left as they were with an empty manifest, and with an allowance and a decision on `Menu_Tools_IDEOptions`: the From 52ca8d76c2bfbceb7c6b5f88fc22b8193edc77b9 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Sat, 31 Oct 2026 21:39:48 +0100 Subject: [PATCH 14/85] shot-queue.mjs: pngLook's faint, a capture kept as within noise (noise, restored) with a region surviving the erosion --- WIP.ShotReview.md | 9 +++------ docs/Documentation/Tools-Toolchain-Tests.md | 2 +- scripts/lib/shot-queue.mjs | 8 +++++++- test/svgshot.test.mjs | 17 ++++++++++++++++- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/WIP.ShotReview.md b/WIP.ShotReview.md index 726158c07..d03058f9f 100644 --- a/WIP.ShotReview.md +++ b/WIP.ShotReview.md @@ -322,12 +322,9 @@ at 200 levels. The constants were chosen from the pictures: each stage drawn ove retaken PNGs (the changed pixels, what the closing adds, the core, the boxes), and the counts over every SVG and the PNGs of the BETA 1010 and 1033 retakes. -**Before step 4: the PNG rule for a capture kept as within noise** (owner, 2026-10-31; [the -queue](#the-queue)) is not in `shot-queue.mjs` yet. `pngLook` returns null for every state but -`updated`; it is to return `{why: "faint", ...}` for `noise` and `restored` when a region -survives, with a test beside the others in `test/svgshot.test.mjs` ("which pictures need a -look"), and the line on [Tools-Toolchain-Tests](docs/Documentation/Tools-Toolchain-Tests.md) -that lists what the queue sends. +The PNG rule for a capture kept as within noise (owner, 2026-10-31; [the queue](#the-queue)) +is `pngLook`'s `faint`: for `noise` and `restored`, when a region survives. Its regions are +the caller's to give, from the capture against the picture it was found within noise of. Step 2 was checked with the runs [below](#checking-a-move), whose states it left as they were with an empty manifest, and with an allowance and a decision on `Menu_Tools_IDEOptions`: the diff --git a/docs/Documentation/Tools-Toolchain-Tests.md b/docs/Documentation/Tools-Toolchain-Tests.md index dd647372a..31527b519 100644 --- a/docs/Documentation/Tools-Toolchain-Tests.md +++ b/docs/Documentation/Tools-Toolchain-Tests.md @@ -277,7 +277,7 @@ Unit tests for the SVG screenshot converter. They cover: - `scripts/svgshot/redraw.mjs`, shared by [`replay.mjs`](Tools-IDE#replay) and [`sheet.mjs`](Tools-IDE#svg-sheet): a picture drawn again from its bundle, or listed as needing a retake, and failed when its SVG holds the Windows user name unless the caller turns that check off; and the list of pictures, with the build's output trees left out. `scripts/svgshot/sheets.mjs`, built from the pieces in `scripts/lib/shot-views.mjs`: the folders a sheet may not be written to, and the sheet's page, worst picture first. The sheet of changes against a git revision (`sheet.mjs --against`) is tested the same way, with no git and no browser: the change list read from git's output (the PNGs only, untracked ones added), the calls made to git, the noise rule of `scripts/lib/shot-change.mjs` and its comparison of pictures of one size and of two, the difference map's colours, the order of the rows, the commands that restore the pictures within noise, and the page. - `scripts/lib/shot-revision.mjs`, which [`shoot_docs.mjs`](Tools-IDE#shoot-docs) compares a new capture with: what becomes of a capture with the picture at a git revision or without one, within the noise of it, of the file on disk or of neither; the SVG that goes with a picture put back as the revision has it; which modified pictures `--cleanup` puts back; git's batch output, a name with spaces and a missing one included; and, in a scratch git repository, a revision's files read in one process and a text file put back as a checkout writes it, with the index left alone. - `scripts/lib/shot-policy.mjs`, which decides what becomes of a capture and of its SVG, and `scripts/lib/shot-manifest.mjs`, which reads and writes `test/shots/manifest.json`: a light picture compared with the dark one first; a picture's own noise rule and SVG bar passed to the comparison; a review's decision used for the bytes it was made on and for no others, in a run and in `--cleanup`; the manifest written in a fixed order and read back the same, from an LF or a CRLF checkout; every malformed entry refused with its key named; a build recorded and the file saved only when it changes; and the repository's own manifest read. -- The strength map, a byte per pixel saying how far one picture is from another, which `scripts/lib/shot-change.mjs` makes of two captures and `scripts/svgshot/diff.mjs` of an SVG against its PNG: the share of its pixels at a level, with rectangles left out, and its regions of change. The tests draw small maps by hand: a line up to 4 pixels thick leaves no core after the erosion and a block keeps its middle, the strokes of a word are joined into one block, every changed pixel is kept along the picture's edge, and regions that touch at a corner are one. `scripts/lib/shot-queue.mjs` then says which pictures the review tool shows first: a capture taken with nothing surviving or only a word or two, an SVG accepted with a part surviving or refused with nothing. +- The strength map, a byte per pixel saying how far one picture is from another, which `scripts/lib/shot-change.mjs` makes of two captures and `scripts/svgshot/diff.mjs` of an SVG against its PNG: the share of its pixels at a level, with rectangles left out, and its regions of change. The tests draw small maps by hand: a line up to 4 pixels thick leaves no core after the erosion and a block keeps its middle, the strokes of a word are joined into one block, every changed pixel is kept along the picture's edge, and regions that touch at a corner are one. `scripts/lib/shot-queue.mjs` then says which pictures the review tool shows first: a capture taken with nothing surviving or only a word or two, a capture kept as within noise with something surviving, an SVG accepted with a part surviving or refused with nothing. What the SVG keeps of an image it embeds is `compactImage`'s, from `scripts/lib/compact-image.mjs`, which [`compact-image.test.mjs`](#compact-image-test) tests. No browser, no built tree, well under a second. diff --git a/scripts/lib/shot-queue.mjs b/scripts/lib/shot-queue.mjs index 7ed9a761f..ba75181b7 100644 --- a/scripts/lib/shot-queue.mjs +++ b/scripts/lib/shot-queue.mjs @@ -62,7 +62,7 @@ const small = (kept) => kept.length <= WORD_BLOCKS && kept.every((r) => r.core < /** * Why a picture's PNG needs a look, or null: the first argument is decidePicture's verdict * ({state, by, earlier}), `regions` pngRegions' for the capture against the picture it was - * judged against. + * judged against: for `noise` and `restored`, the one it was found within noise of. * Returns {why, text}, `why` one of: * * earlier a review decided about other bytes of this picture; the rule judged these @@ -70,10 +70,16 @@ const small = (kept) => kept.length <= WORD_BLOCKS && kept.every((r) => r.core < * a fringe * small the capture was taken, and what survives is a block or two the size of a word: * a real change small enough to miss + * faint the old picture was kept as within noise, but something survives: a tint of + * a few levels, or a change over less than the noise rule's share */ export function pngLook({ state, by, earlier }, regions) { if (by === "decision") return null; if (earlier) return { why: "earlier", text: `a review kept the ${earlier} picture of other bytes` }; + if (state === "noise" || state === "restored") + return surviving(regions).length + ? { why: "faint", text: "kept as within noise, but a part of the change survives the erosion" } + : null; if (state !== "updated") return null; const kept = surviving(regions); if (!kept.length) return { why: "noise", text: "taken, but nothing survives the erosion: most likely noise" }; diff --git a/test/svgshot.test.mjs b/test/svgshot.test.mjs index 3341d7279..4a4d2b88f 100644 --- a/test/svgshot.test.mjs +++ b/test/svgshot.test.mjs @@ -2075,9 +2075,24 @@ describe("which pictures need a look (shot-queue.mjs)", () => { assert.equal(pngLook(taken, [region(WORD_CORE + 1)]), null); assert.equal(pngLook(taken, [region(100), region(100), region(100)]), null); // a picture not taken, or decided by a review, does not - for (const state of ["new", "noise", "restored", "unchanged", "dark"]) + for (const state of ["new", "noise", "restored", "unchanged", "dark", "too-far"]) assert.equal(pngLook({ ...taken, state }, []), null); assert.equal(pngLook({ ...taken, by: "decision" }, []), null); + }); + + test("a capture kept as within noise needs a look when something survives", () => { + for (const state of ["noise", "restored"]) { + assert.equal(pngLook({ ...taken, state }, [region(SURVIVES)]).why, "faint"); + assert.equal(pngLook({ ...taken, state }, [region(SURVIVES - 1), region(3)]), null); + assert.equal(pngLook({ ...taken, state, by: "decision" }, [region(5000)]), null); + } + for (const state of ["unchanged", "dark", "new"]) assert.equal(pngLook({ ...taken, state }, [region(5000)]), null); + // a tint of 3 levels over a block, within noise, survives; the same over a 2-pixel line does not + const rule = pngRule(null); + const tint = paintMap(200, 100, (x, y) => (blocks([20, 20, 60, 40])(x, y) ? 3 : 0)); + assert.equal(pngLook({ ...taken, state: "noise" }, pngRegions(tint, rule)).why, "faint"); + const line = paintMap(200, 100, (x, y) => (blocks([20, 20, 120, 22])(x, y) ? 3 : 0)); + assert.equal(pngLook({ ...taken, state: "noise" }, pngRegions(line, rule)), null); // a review's decision about other bytes sends it back, whatever the regions assert.equal(pngLook({ ...taken, state: "noise", earlier: "new" }, [region(5000)]).why, "earlier"); }); From 1c05bb92781a99fe8423f154b1eb4f24f401b6ad Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Sat, 31 Oct 2026 21:43:22 +0100 Subject: [PATCH 15/85] outputRefusal moved from svgshot/sheets.mjs to lib/shot-revision.mjs, for the review's stage to share --- WIP.ShotReview.md | 2 +- WIP.md | 2 +- scripts/lib/shot-revision.mjs | 37 ++++++++++++++++++++++++++++++++++- scripts/svgshot/sheet.mjs | 12 ++---------- scripts/svgshot/sheets.mjs | 33 ------------------------------- test/svgshot.test.mjs | 2 +- 6 files changed, 41 insertions(+), 47 deletions(-) diff --git a/WIP.ShotReview.md b/WIP.ShotReview.md index d03058f9f..a1771f1c3 100644 --- a/WIP.ShotReview.md +++ b/WIP.ShotReview.md @@ -304,7 +304,7 @@ On `staging` since 2026-10-31, step 1 but for the commit helper (step 8), and st | Writing a verdict | `scripts/lib/shot-apply.mjs`: `applyPicture(outRoot, out, decision, revision)`, `applySvg(outRoot, svgOut, svg or null)`, `restorePictures(outRoot, revision, outs, {dryRun})` (the PNGs and their SVGs, the SVGs by one `git restore`; `--cleanup` and a `restored` picture both use it), `dropPicture(outRoot, out, bundlesRoot)`, `writeSvg`, `svgOf`. What stays in `shoot_docs.mjs`'s `keep` and `keepSvg`: the counts, the lines it prints, the `--diffs` pictures, the bundle written, the user-name check, and the rule that an SVG stays as it is when its PNG was not written | | The git listing | `changedPictures`, `parseNameStatus`, `parseUntracked` in `scripts/lib/shot-revision.mjs` | | A child's report | `scripts/lib/record-lines.mjs`: `recordLine`, `writeRecord`, `parseRecord`, `readRecords(stream, kinds, {onRecord, onLine})`; `runShards` reads `svgshot-result` records with it. `--events` is to write `shot-event` records the same way | -| The sheets' HTML | the pieces in `scripts/lib/shot-views.mjs`: `pageHtml({generator, title, intro, summary, contents, body})`, `cardHtml({out, meta, meter, figures})`, `figuresIn(folder)` (`href`, `figure`, `thumb`), `meterHtml`, `tocItem`, `esc`, `anchor`, `sizeText`, `pctText`. The two pages, their rows, their order, their commands and the output-folder rules (`outputRefusal`, `mayWriteSheet`, `SHEET_GENERATOR`) are in `scripts/svgshot/sheets.mjs`; `redraw.mjs` keeps drawing and the shards | +| The sheets' HTML | the pieces in `scripts/lib/shot-views.mjs`: `pageHtml({generator, title, intro, summary, contents, body})`, `cardHtml({out, meta, meter, figures})`, `figuresIn(folder)` (`href`, `figure`, `thumb`), `meterHtml`, `tocItem`, `esc`, `anchor`, `sizeText`, `pctText`. The two pages, their rows, their order, their commands and the output-folder rules (`mayWriteSheet`, `SHEET_GENERATOR`) are in `scripts/svgshot/sheets.mjs`, and `outputRefusal`, which the stage shares, in `shot-revision.mjs`; `redraw.mjs` keeps drawing and the shards | | Server-sent events | `createSseChannel()` in `lib/sse.mjs`: `handle(req, res)`, `send(event, data)`, `close()`; `builder/serve.mjs` uses one | | The installs | `listIdes()` in `scripts/lib/tb-install.mjs`, newest first, `[{build, exe}]` | diff --git a/WIP.md b/WIP.md index 934fabcb2..035027186 100644 --- a/WIP.md +++ b/WIP.md @@ -663,7 +663,7 @@ wrapper: | `test.bat` | `test/render.test.mjs` | `builder/render.mjs`'s markdown-it plugins, one at a time, on inputs no page holds --- today the ellipsis plugin's dot counting. Run by `node --test` | | `test.bat` | `test/strftime.test.mjs` | `builder/strftime.mjs`'s `formatDate`, which no build calls because no page sets `last_modified_date`. Run by `node --test` | | `test.bat` | `test/compact-image.test.mjs` | what an embedded image keeps (`compactImage` and `stripFile` in `scripts/lib/compact-image.mjs`): an editor's metadata goes, a notice of whose the picture is and on what terms stays (the IDE's lock icon carries a CC BY-NC-SA attribution), an sRGB profile goes and any other stays, a JPEG or a GIF is labelled by its bytes, and an image inside an SVG inside a CSS file is reached and keeps its encoding. Its second half runs `scripts/imagestrip/` over a CSS file and SVG files of every image the test builds and over the IDE's own stylesheets, and compares output, summary and reports with `stripFile` byte for byte; it is skipped unless `IMAGESTRIP_EXE` names the built exe. Run by `node --test` | -| `test.bat` | `test/svgshot.test.mjs` | an SVG screenshot is written on one line (`oneLine` in `scripts/svgshot/snapshot-svg.mjs`), a break in a tag as a space and one in text as a character reference, so that a CRLF checkout changes only its last line ending; an underline is placed and sized by the figures fitted in Edge (`underlineBand`) under the boxes that set one (`decoratingBoxes`); a background image is sized by its bytes, a JPEG the IDE calls `image/png` included (`naturalSize`); an iframe's document is decoded and placed at the iframe's content box, less its scroll, scaled when zoomed, over its canvas colour (`frameGeometry`, `frameView`, `unzoom`, `viewportScroller`, `canvasColor`); a run's web font is the `@font-face` rule CSS matching picks (`webFontFiles`), and a variable WOFF2 is read and cut per weight; a family split by `unicode-range` gives each character to the rule whose range holds it (`parseUnicodeRange`, `webRulesFor`); an inline `` is found and its ids scoped (`inlineSvgsIn`, `scopeIds`); a filter of colour functions is baked into the colours under it, in order and clamped, its pictures wrapped in a real filter and its opacity kept apart (`colour-filter.mjs`); a transformed element's boxes and text are put back from the snapshot's after-transform boxes (`elementTransform`, `untransformBox`); a closed select shows its option's whole text (`selectLabel`); a bundle draws the same SVG from its file as in memory, and counts each question it cannot answer (`renderBundle`); its web font rules and files draw each character from the file whose range holds it, at the weight its rule allows, and its inline `` markup is drawn in its box, from the file and when trimmed (`drawBundle`); a bundle of version 1 is refused; one trimmed to its view draws the same (`trimSnapshot`); a font's cut comes from the subset cache with no Python run (`subsetKey`, `useSubsetCache`); `redraw` draws a picture again from its bundle or names its retake, and fails on the user name unless `checkUserName: false`; `listPictures` leaves the output trees out; `outputRefusal` refuses a sheet under docs or where git would take it, and `sheetHtml` puts the worst picture first (`scripts/svgshot/sheets.mjs`); a strength map's share at a level and its regions of change, a word's strokes closed into one block and a line up to 4 pixels thick eroded away, and which pictures need a look (`shareOff`, `changeRegions`, `scripts/lib/shot-change.mjs`; `pngLook`, `svgLook`, `scripts/lib/shot-queue.mjs`); a capture's verdict with a picture's allowances and a review's decisions, and the manifest written, read back and refused when malformed (`decidePicture`, `decideSvg`, `cleanupResets`, `scripts/lib/shot-policy.mjs`; `parseManifest`, `manifestText`, `openManifest`, `scripts/lib/shot-manifest.mjs`); a capture's fate against the picture at a git revision and the file, the SVG of one put back, what `--cleanup` resets, and a revision's files read in one git process and a text file put back as a checkout writes it, in a scratch repository (`pictureFate`, `svgFate`, `planCleanup`, `readRevisionFiles`, `restoreFromRevision`, `scripts/lib/shot-revision.mjs`). What it keeps of an image it embeds is `compactImage`'s, which the row above tests. Run by `node --test` | +| `test.bat` | `test/svgshot.test.mjs` | an SVG screenshot is written on one line (`oneLine` in `scripts/svgshot/snapshot-svg.mjs`), a break in a tag as a space and one in text as a character reference, so that a CRLF checkout changes only its last line ending; an underline is placed and sized by the figures fitted in Edge (`underlineBand`) under the boxes that set one (`decoratingBoxes`); a background image is sized by its bytes, a JPEG the IDE calls `image/png` included (`naturalSize`); an iframe's document is decoded and placed at the iframe's content box, less its scroll, scaled when zoomed, over its canvas colour (`frameGeometry`, `frameView`, `unzoom`, `viewportScroller`, `canvasColor`); a run's web font is the `@font-face` rule CSS matching picks (`webFontFiles`), and a variable WOFF2 is read and cut per weight; a family split by `unicode-range` gives each character to the rule whose range holds it (`parseUnicodeRange`, `webRulesFor`); an inline `` is found and its ids scoped (`inlineSvgsIn`, `scopeIds`); a filter of colour functions is baked into the colours under it, in order and clamped, its pictures wrapped in a real filter and its opacity kept apart (`colour-filter.mjs`); a transformed element's boxes and text are put back from the snapshot's after-transform boxes (`elementTransform`, `untransformBox`); a closed select shows its option's whole text (`selectLabel`); a bundle draws the same SVG from its file as in memory, and counts each question it cannot answer (`renderBundle`); its web font rules and files draw each character from the file whose range holds it, at the weight its rule allows, and its inline `` markup is drawn in its box, from the file and when trimmed (`drawBundle`); a bundle of version 1 is refused; one trimmed to its view draws the same (`trimSnapshot`); a font's cut comes from the subset cache with no Python run (`subsetKey`, `useSubsetCache`); `redraw` draws a picture again from its bundle or names its retake, and fails on the user name unless `checkUserName: false`; `listPictures` leaves the output trees out; `outputRefusal` refuses a sheet under docs or where git would take it (`scripts/lib/shot-revision.mjs`), and `sheetHtml` puts the worst picture first (`scripts/svgshot/sheets.mjs`); a strength map's share at a level and its regions of change, a word's strokes closed into one block and a line up to 4 pixels thick eroded away, and which pictures need a look (`shareOff`, `changeRegions`, `scripts/lib/shot-change.mjs`; `pngLook`, `svgLook`, `scripts/lib/shot-queue.mjs`); a capture's verdict with a picture's allowances and a review's decisions, and the manifest written, read back and refused when malformed (`decidePicture`, `decideSvg`, `cleanupResets`, `scripts/lib/shot-policy.mjs`; `parseManifest`, `manifestText`, `openManifest`, `scripts/lib/shot-manifest.mjs`); a capture's fate against the picture at a git revision and the file, the SVG of one put back, what `--cleanup` resets, and a revision's files read in one git process and a text file put back as a checkout writes it, in a scratch repository (`pictureFate`, `svgFate`, `planCleanup`, `readRevisionFiles`, `restoreFromRevision`, `scripts/lib/shot-revision.mjs`). What it keeps of an image it embeds is `compactImage`'s, which the row above tests. Run by `node --test` | | `test.bat` | `test/example-batches.test.mjs` | `check_examples.mjs`'s probes, from `scripts/lib/example-batches.mjs`: batching, crash isolation through a fake lane, the canaries, the fence classifier and `check_run`'s refusals, expected output, dispatcher and marker reading, with no IDE. Run by `node --test` | | `test.bat` | `test/ide-folders.test.mjs` | `shutdownIde` and `shutdownIdeAsync` remove the IDE's temp folder and profile once it is gone, and no other folder. Run by `node --test` | | `test.bat` | `test/broker.test.mjs` | `tbbroker` and its client (`scripts/lib/tb-broker.mjs`): one broker per pipe, leases, held processes, waiting, ports, a desktop left with windows on it, a restarted broker finding a desktop in use. Skipped unless `TBBROKER_EXE` names a built broker. Run by `node --test` | diff --git a/scripts/lib/shot-revision.mjs b/scripts/lib/shot-revision.mjs index def6c3bc3..b209031d5 100644 --- a/scripts/lib/shot-revision.mjs +++ b/scripts/lib/shot-revision.mjs @@ -5,13 +5,16 @@ // and its --cleanup puts back every picture git reports modified that is within that noise of // the revision's. svgshot/sheet.mjs --against lists the pictures that differ from a revision // and reads the revision's pictures here too. The noise rule itself is withinNoise, in -// shot-change.mjs: every caller passes it in. +// shot-change.mjs: every caller passes it in. And where a folder of pictures that may hold the +// Windows user name may be written (outputRefusal): a sheet, or a run's stage. // // Git is run in the docs folder, so that the repository that holds it answers, whichever // checkout the script is in. The reads are batched: one `git cat-file --batch` process for // any number of files. import { execFileSync } from "node:child_process"; +import { existsSync, statSync } from "node:fs"; +import path from "node:path"; import { isOutputTree } from "../../lib/markdown-files.mjs"; /** @@ -240,3 +243,35 @@ export function planCleanup(pictures, before, after, near) { } return plan; } + +const inside = (dir, root) => { + const rel = path.relative(root, dir); + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); +}; + +/** + * Why `dir` may not take a folder of pictures that may hold the Windows user name, or + * null: it is under one of `docsRoots`, or inside a git working tree at a path git does + * not ignore. A sheet (sheet.mjs draws its pictures without the check) and a run's stage + * (shot-stage.mjs: its bundles hold the page's text) must never be committed or published. `git` stands in for running git in the probes: + * git(args, cwd) returns its exit code and output, {status, stdout}. + */ +export function outputRefusal(dir, docsRoots, git = runGit) { + const abs = path.resolve(dir); + for (const root of docsRoots) if (inside(abs, path.resolve(root))) return `${abs} is under ${path.resolve(root)}`; + let near = abs; + while (!existsSync(near) || !statSync(near).isDirectory()) { + const up = path.dirname(near); + if (up === near) return null; + near = up; + } + const top = git(["rev-parse", "--show-toplevel"], near); + if (top.status !== 0) return null; + const root = path.resolve(top.stdout.trim()); + const rel = path.relative(root, abs).replace(/\\/g, "/"); + if (rel === "") return `${abs} is the root of the git working tree`; + const ignored = git(["check-ignore", "-q", "--", rel], root); + if (ignored.status === 0) return null; + if (ignored.status === 1) return `${abs} is inside the git working tree ${root}, at a path git does not ignore`; + return `git check-ignore could not say whether ${abs} is ignored`; +} diff --git a/scripts/svgshot/sheet.mjs b/scripts/svgshot/sheet.mjs index 71212a948..ac9e5c555 100644 --- a/scripts/svgshot/sheet.mjs +++ b/scripts/svgshot/sheet.mjs @@ -28,7 +28,7 @@ import { withUsageError, } from "../../lib/cli.mjs"; import { DOCS_DIR, REPO_ROOT } from "../../lib/repo-paths.mjs"; -import { changedPictures, readRevisionFiles } from "../lib/shot-revision.mjs"; +import { changedPictures, outputRefusal, readRevisionFiles } from "../lib/shot-revision.mjs"; import { listBundles } from "./bundle.mjs"; import { useSubsetCache } from "./fonts.mjs"; import { SVG_FAITHFUL } from "../lib/shot-policy.mjs"; @@ -48,15 +48,7 @@ import { themeOf, writeJudged, } from "./redraw.mjs"; -import { - changeFiles, - changeSheetHtml, - compareRevisionPicture, - mayWriteSheet, - notDrawn, - outputRefusal, - sheetHtml, -} from "./sheets.mjs"; +import { changeFiles, changeSheetHtml, compareRevisionPicture, mayWriteSheet, notDrawn, sheetHtml } from "./sheets.mjs"; exitOnCrash(); diff --git a/scripts/svgshot/sheets.mjs b/scripts/svgshot/sheets.mjs index 4bc6414c6..2771ea722 100644 --- a/scripts/svgshot/sheets.mjs +++ b/scripts/svgshot/sheets.mjs @@ -12,45 +12,12 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import path from "node:path"; import { decodePng } from "../lib/png.mjs"; import { changeMap, compareImages, NOISE_LEVELS, NOISE_SHARE } from "../lib/shot-change.mjs"; -import { runGit } from "../lib/shot-revision.mjs"; import { cardHtml, esc, figuresIn, meterHtml, pageHtml, pctText, sizeText, tocItem } from "../lib/shot-views.mjs"; import { pngSize, slugOf, themeOf } from "./redraw.mjs"; /** The generator a sheet names in its , by which a folder is known to hold one. */ export const SHEET_GENERATOR = "scripts/svgshot/sheet.mjs"; -const inside = (dir, root) => { - const rel = path.relative(root, dir); - return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); -}; - -/** - * Why `dir` may not take a sheet, or null: it is under one of `docsRoots`, or inside a - * git working tree at a path git does not ignore: a sheet and its pictures may hold the - * Windows user name (sheet.mjs draws them without the check), so they must never be - * committed or published. `git` stands in for running git in the probes: - * git(args, cwd) returns its exit code and output, {status, stdout}. - */ -export function outputRefusal(dir, docsRoots, git = runGit) { - const abs = path.resolve(dir); - for (const root of docsRoots) if (inside(abs, path.resolve(root))) return `${abs} is under ${path.resolve(root)}`; - let near = abs; - while (!existsSync(near) || !statSync(near).isDirectory()) { - const up = path.dirname(near); - if (up === near) return null; - near = up; - } - const top = git(["rev-parse", "--show-toplevel"], near); - if (top.status !== 0) return null; - const root = path.resolve(top.stdout.trim()); - const rel = path.relative(root, abs).replace(/\\/g, "/"); - if (rel === "") return `${abs} is the root of the git working tree`; - const ignored = git(["check-ignore", "-q", "--", rel], root); - if (ignored.status === 0) return null; - if (ignored.status === 1) return `${abs} is inside the git working tree ${root}, at a path git does not ignore`; - return `git check-ignore could not say whether ${abs} is ignored`; -} - /** * Whether the folder `dir` may be written over: it does not exist, is empty, or * holds a sheet (its index.html names SHEET_GENERATOR). diff --git a/test/svgshot.test.mjs b/test/svgshot.test.mjs index 4a4d2b88f..8e5a92aef 100644 --- a/test/svgshot.test.mjs +++ b/test/svgshot.test.mjs @@ -95,6 +95,7 @@ import { import { changedPictures, openRevision, + outputRefusal, parseCatFileBatch, parseNameStatus, parseUntracked, @@ -123,7 +124,6 @@ import { sortChangeRows, mayWriteSheet, notDrawn, - outputRefusal, SHEET_GENERATOR, sheetHtml, sortRows, From 41082965ee9dc6e8682cfd2ad404998d06ed438a Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Sat, 31 Oct 2026 21:44:33 +0100 Subject: [PATCH 16/85] pictureFate and decidePicture name the picture a capture was judged against (against); shot-queue.mjs's reviewPicture and reviewSvg give the strength map, the regions and the look from it --- scripts/lib/shot-policy.mjs | 8 ++-- scripts/lib/shot-queue.mjs | 44 +++++++++++++++++++-- scripts/lib/shot-revision.mjs | 16 +++++--- test/svgshot.test.mjs | 73 +++++++++++++++++++++++++++-------- 4 files changed, 113 insertions(+), 28 deletions(-) diff --git a/scripts/lib/shot-policy.mjs b/scripts/lib/shot-policy.mjs index b3e89540c..1d64bfdb4 100644 --- a/scripts/lib/shot-policy.mjs +++ b/scripts/lib/shot-policy.mjs @@ -65,7 +65,8 @@ export const pngSize = (png) => ({ w: png.readUInt32BE(16), h: png.readUInt32BE( * A review's decision about these bytes takes the place of the rule: "new" counts the capture * as differing from every picture it is compared with, "old" as within noise of the one it * would go back to (the revision's, else the file); a light picture with the dark one's - * pixels is still found by the rule. Returns {state, write, size, dark, by, earlier}, `size` + * pixels is still found by the rule. Returns {state, write, against, size, dark, by, earlier}, + * `against` pictureFate's, or "dark" for a light picture judged against the dark one, `size` * the capture's {w, h}, `dark` the dark picture's when it is another size, else null, `by` * "decision" or "rule", and `earlier` a review's decision about other bytes ("old" or * "new"), which the rule judged this capture without, else null: @@ -86,9 +87,10 @@ export function decidePicture({ png, theme, dark, revision, disk, near, entry = if (theme === "light" && dark) { const d = pngSize(dark); if (Math.abs(d.w - size.w) > SIZE_SLACK || Math.abs(d.h - size.h) > SIZE_SLACK) - return { state: "too-far", write: null, size, dark: d, ...why }; + return { state: "too-far", write: null, against: "dark", size, dark: d, ...why }; if (d.w !== size.w || d.h !== size.h) other = d; - if (decided !== "new" && byRule(dark, png)) return { state: "dark", write: null, size, dark: other, ...why }; + if (decided !== "new" && byRule(dark, png)) + return { state: "dark", write: null, against: "dark", size, dark: other, ...why }; } const nearBy = decided === "new" ? () => false : decided === "old" ? () => true : byRule; return { ...pictureFate(png, revision, disk, nearBy), size, dark: other, ...why }; diff --git a/scripts/lib/shot-queue.mjs b/scripts/lib/shot-queue.mjs index ba75181b7..0405d7fa9 100644 --- a/scripts/lib/shot-queue.mjs +++ b/scripts/lib/shot-queue.mjs @@ -5,8 +5,9 @@ // lacks, is a block, part of which survives. The measure chooses what is shown first and // decides nothing: the verdicts are shot-policy.mjs's. -import { changeRegions } from "./shot-change.mjs"; -import { svgRule } from "./shot-policy.mjs"; +import { decodePng } from "./png.mjs"; +import { changeRegions, strengthOf } from "./shot-change.mjs"; +import { pngRule, svgRule } from "./shot-policy.mjs"; // The pixels a region's gaps are closed by and it is eroded by. Closing first joins a word's // strokes into one block: a stroke of the IDE's text at 2x is 2 or 3 pixels thick, and the @@ -62,7 +63,9 @@ const small = (kept) => kept.length <= WORD_BLOCKS && kept.every((r) => r.core < /** * Why a picture's PNG needs a look, or null: the first argument is decidePicture's verdict * ({state, by, earlier}), `regions` pngRegions' for the capture against the picture it was - * judged against: for `noise` and `restored`, the one it was found within noise of. + * judged against: for `noise` and `restored`, the one it was found within noise of. Null + * `regions`, for a capture with nothing to measure it against (a new picture, or one of + * another size), needs a look only for an earlier decision. * Returns {why, text}, `why` one of: * * earlier a review decided about other bytes of this picture; the rule judged these @@ -76,6 +79,7 @@ const small = (kept) => kept.length <= WORD_BLOCKS && kept.every((r) => r.core < export function pngLook({ state, by, earlier }, regions) { if (by === "decision") return null; if (earlier) return { why: "earlier", text: `a review kept the ${earlier} picture of other bytes` }; + if (!regions) return null; if (state === "noise" || state === "restored") return surviving(regions).length ? { why: "faint", text: "kept as within noise, but a part of the change survives the erosion" } @@ -105,3 +109,37 @@ export function svgLook({ faithful, by, earlier }, regions) { if (!faithful && !kept.length) return { why: "fringe", text: "refused, but nothing survives the erosion: fringes" }; return null; } + +/** + * What the review shows of a capture `png` (PNG bytes) that decidePicture judged (`verdict`): + * its strength map and its regions of change against `reference`, the picture + * `verdict.against` names (the revision's, the file or the dark picture, as PNG bytes; null + * for a new picture), with the picture's noise rule (pngRule of its `entry`), and why it + * needs a look (pngLook). Returns {strength, regions, look}: `strength` is null when there is + * nothing to measure (no reference, one of another size, or the same bytes), and `regions` + * null when the capture cannot be measured, [] when it is the reference. + */ +export function reviewPicture(verdict, png, reference, entry = null) { + let strength = null; + let regions = null; + if (reference?.equals(png)) regions = []; + else if (reference) { + const A = decodePng(reference); + const B = decodePng(png); + if (A.width === B.width && A.height === B.height) { + strength = strengthOf(A, B); + regions = pngRegions(strength, pngRule(entry)); + } + } + return { strength, regions, look: pngLook(verdict, regions) }; +} + +/** + * What the review shows of an SVG that decideSvg judged (`verdict`), from diffSvg's + * `strength` map: its regions at the bar's levels (svgRegions, with the picture's `entry`) + * and why it needs a look (svgLook). Returns {regions, look}. + */ +export function reviewSvg(verdict, strength, entry = null) { + const regions = svgRegions(strength, entry); + return { regions, look: svgLook(verdict, regions) }; +} diff --git a/scripts/lib/shot-revision.mjs b/scripts/lib/shot-revision.mjs index b209031d5..43ea2c22e 100644 --- a/scripts/lib/shot-revision.mjs +++ b/scripts/lib/shot-revision.mjs @@ -185,7 +185,10 @@ export function openRevision(root, rev, git = runGit) { * What becomes of a new capture `png` of a picture (PNG bytes): `revision` the picture as the * revision has it, or null (a new shot, a file git does not track, or the reference turned * off), `disk` the file as it is, or null, and `near(a, b)` the noise rule over two PNG files. - * Returns {state, write}, `write` the bytes the file is to hold, null when it stays as it is: + * Returns {state, write, against}, `write` the bytes the file is to hold, null when it stays as + * it is, and `against` the picture the capture was found equal to, near or beyond noise of: + * "revision", "disk", or null for a new picture. The review's regions of change are worked + * out against it (shot-queue.mjs's reviewPicture). * * restored the capture is the revision's picture, or within noise of it, and the file * is not (an earlier run's drift, or none): the file becomes the revision's @@ -197,12 +200,13 @@ export function openRevision(root, rev, git = runGit) { */ export function pictureFate(png, revision, disk, near) { if (revision && (revision.equals(png) || near(revision, png))) { - if (disk?.equals(revision)) return { state: revision.equals(png) ? "unchanged" : "noise", write: null }; - return { state: "restored", write: revision }; + const against = "revision"; + if (disk?.equals(revision)) return { state: revision.equals(png) ? "unchanged" : "noise", write: null, against }; + return { state: "restored", write: revision, against }; } - if (disk?.equals(png)) return { state: "unchanged", write: null }; - if (disk && near(disk, png)) return { state: "noise", write: null }; - return { state: disk ? "updated" : "new", write: png }; + if (disk?.equals(png)) return { state: "unchanged", write: null, against: "disk" }; + if (disk && near(disk, png)) return { state: "noise", write: null, against: "disk" }; + return { state: disk ? "updated" : "new", write: png, against: disk ? "disk" : null }; } // An SVG's text with CRLF made LF: git counts a checkout under core.autocrlf as the same file. diff --git a/test/svgshot.test.mjs b/test/svgshot.test.mjs index 8e5a92aef..be5760464 100644 --- a/test/svgshot.test.mjs +++ b/test/svgshot.test.mjs @@ -86,6 +86,8 @@ import { import { pngLook, pngRegions, + reviewPicture, + reviewSvg, SURVIVES, surviving, svgLook, @@ -1780,29 +1782,29 @@ describe("the picture at a git revision", () => { test("a capture within noise of the revision's picture leaves the file as the revision has it", () => { // the file drifted, or is gone: put back - assert.deepEqual(fate(HEAD, HEAD, DRIFT), { state: "restored", write: HEAD }); - assert.deepEqual(fate(DRIFT, HEAD, DRIFT), { state: "restored", write: HEAD }); - assert.deepEqual(fate(DRIFT, HEAD, null), { state: "restored", write: HEAD }); + assert.deepEqual(fate(HEAD, HEAD, DRIFT), { state: "restored", write: HEAD, against: "revision" }); + assert.deepEqual(fate(DRIFT, HEAD, DRIFT), { state: "restored", write: HEAD, against: "revision" }); + assert.deepEqual(fate(DRIFT, HEAD, null), { state: "restored", write: HEAD, against: "revision" }); // an earlier run's real change goes too, when the IDE draws the revision's picture again - assert.deepEqual(fate(HEAD, HEAD, CHANGE), { state: "restored", write: HEAD }); + assert.deepEqual(fate(HEAD, HEAD, CHANGE), { state: "restored", write: HEAD, against: "revision" }); // the file is the revision's already - assert.deepEqual(fate(HEAD, HEAD, HEAD), { state: "unchanged", write: null }); - assert.deepEqual(fate(DRIFT, HEAD, HEAD), { state: "noise", write: null }); + assert.deepEqual(fate(HEAD, HEAD, HEAD), { state: "unchanged", write: null, against: "revision" }); + assert.deepEqual(fate(DRIFT, HEAD, HEAD), { state: "noise", write: null, against: "revision" }); }); test("a capture beyond noise of the revision's picture is compared with the file", () => { - assert.deepEqual(fate(CHANGE, HEAD, CHANGE), { state: "unchanged", write: null }); - assert.deepEqual(fate(CHANGE2, HEAD, CHANGE), { state: "noise", write: null }); - assert.deepEqual(fate(OTHER, HEAD, CHANGE), { state: "updated", write: OTHER }); - assert.deepEqual(fate(OTHER, HEAD, HEAD), { state: "updated", write: OTHER }); - assert.deepEqual(fate(OTHER, HEAD, null), { state: "new", write: OTHER }); + assert.deepEqual(fate(CHANGE, HEAD, CHANGE), { state: "unchanged", write: null, against: "disk" }); + assert.deepEqual(fate(CHANGE2, HEAD, CHANGE), { state: "noise", write: null, against: "disk" }); + assert.deepEqual(fate(OTHER, HEAD, CHANGE), { state: "updated", write: OTHER, against: "disk" }); + assert.deepEqual(fate(OTHER, HEAD, HEAD), { state: "updated", write: OTHER, against: "disk" }); + assert.deepEqual(fate(OTHER, HEAD, null), { state: "new", write: OTHER, against: null }); }); test("with no picture at the revision, the file is the only reference", () => { - assert.deepEqual(fate(DRIFT, null, DRIFT), { state: "unchanged", write: null }); - assert.deepEqual(fate(DRIFT, null, HEAD), { state: "noise", write: null }); - assert.deepEqual(fate(OTHER, null, HEAD), { state: "updated", write: OTHER }); - assert.deepEqual(fate(OTHER, null, null), { state: "new", write: OTHER }); + assert.deepEqual(fate(DRIFT, null, DRIFT), { state: "unchanged", write: null, against: "disk" }); + assert.deepEqual(fate(DRIFT, null, HEAD), { state: "noise", write: null, against: "disk" }); + assert.deepEqual(fate(OTHER, null, HEAD), { state: "updated", write: OTHER, against: "disk" }); + assert.deepEqual(fate(OTHER, null, null), { state: "new", write: OTHER, against: null }); }); test("a picture put back takes the revision's SVG, or loses its own when the revision has none", () => { @@ -2107,6 +2109,43 @@ describe("which pictures need a look (shot-queue.mjs)", () => { assert.equal(svgLook({ ...rule, earlier: false }, []).why, "earlier"); }); + test("a capture is measured against the picture its verdict names, when there is one of its size", () => { + // a picture 60 by 40, grey, with a block of 12 by 12 a level or many off + const image = (level, w = 60, h = 40) => { + const rgba = new Uint8Array(w * h * 4).fill(128); + for (let y = 10; y < 22; y++) + for (let x = 10; x < 22; x++) rgba.fill(128 + level, (y * w + x) * 4, (y * w + x) * 4 + 3); + return encodePng({ width: w, height: h, rgba }); + }; + const base = image(0); + const faint = reviewPicture({ state: "noise", by: "rule", earlier: null }, image(3), base); + assert.equal(faint.strength.data.length, 60 * 40); + assert.equal(faint.regions.length, 1); + assert.equal(faint.look.why, "faint"); + // the same bytes: nothing to measure, and no region + assert.deepEqual(reviewPicture({ ...taken, state: "unchanged" }, base, base), { + strength: null, + regions: [], + look: null, + }); + // a new picture, or one of another size: nothing measured, and no look but an earlier decision's + assert.deepEqual(reviewPicture({ ...taken, state: "new" }, base, null), { + strength: null, + regions: null, + look: null, + }); + assert.equal(reviewPicture(taken, image(0, 61), base).look, null); + assert.equal(reviewPicture({ ...taken, earlier: "old" }, image(0, 61), base).look.why, "earlier"); + // the picture's rectangles left out + const left = reviewPicture({ state: "noise", by: "rule", earlier: null }, image(3), base, { + png: { ignore: [[0, 0, 30, 30]] }, + }); + assert.deepEqual(left.regions, []); + // an SVG's from its strength map + const icon = paintMap(200, 100, blocks([20, 20, 52, 52])); + assert.equal(reviewSvg({ faithful: true, by: "rule", earlier: null }, icon).look.why, "part"); + }); + test("from the strength maps: a word changed, a caret, a part missing and a fringe", () => { const rule = pngRule(null); assert.equal(pngLook(taken, pngRegions(paintMap(200, 100, word(20, 20)), rule)).why, "small"); @@ -2161,6 +2200,7 @@ describe("what becomes of a picture and its SVG (shot-policy.mjs, shot-apply.mjs assert.deepEqual(decide(OTHER, "dark", null, DARK, DARK), { state: "updated", write: OTHER, + against: "disk", size: { w: 100, h: 50 }, dark: null, by: "rule", @@ -2174,6 +2214,7 @@ describe("what becomes of a picture and its SVG (shot-policy.mjs, shot-apply.mjs assert.deepEqual(decide(NEAR, "light", DARK, null, OTHER), { state: "dark", write: null, + against: "dark", size: { w: 100, h: 50 }, dark: null, by: "rule", @@ -2186,7 +2227,7 @@ describe("what becomes of a picture and its SVG (shot-policy.mjs, shot-apply.mjs assert.deepEqual(decide(shot(103, 50, "x"), "light", DARK).state, "new"); // beyond it: a failure, whatever the files hold const far = decide(shot(100 + SIZE_SLACK + 1, 50, "x"), "light", DARK, DARK, DARK); - assert.deepEqual([far.state, far.write, far.dark], ["too-far", null, { w: 100, h: 50 }]); + assert.deepEqual([far.state, far.write, far.against, far.dark], ["too-far", null, "dark", { w: 100, h: 50 }]); assert.equal(decide(shot(100 + SIZE_SLACK, 50, "x"), "light", DARK).state, "new"); // with no dark picture, as a dark one assert.equal(decide(NEAR, "light", null, null, DARK).state, "noise"); From e0fe1ff34c6e30d2caa7a76ed35b96b4c32d9fdd Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Sat, 31 Oct 2026 21:46:42 +0100 Subject: [PATCH 17/85] lib/shot-stage.mjs, a run's stage (folders, verdicts, strength maps, stageRefusal); record-lines.mjs's stop line (STOP_LINE, onStop, sendStop); slugOf moved to shot-apply.mjs --- scripts/lib/record-lines.mjs | 32 +++++++++- scripts/lib/shot-apply.mjs | 7 +++ scripts/lib/shot-stage.mjs | 114 +++++++++++++++++++++++++++++++++++ scripts/svgshot/redraw.mjs | 8 +-- scripts/svgshot/sheets.mjs | 3 +- test/svgshot.test.mjs | 88 ++++++++++++++++++++++++++- 6 files changed, 240 insertions(+), 12 deletions(-) create mode 100644 scripts/lib/shot-stage.mjs diff --git a/scripts/lib/record-lines.mjs b/scripts/lib/record-lines.mjs index a10a8bb3f..3e4357f96 100644 --- a/scripts/lib/record-lines.mjs +++ b/scripts/lib/record-lines.mjs @@ -1,7 +1,8 @@ // Records a child process writes on its stdout among the lines meant for a person, and // the reading of them in its parent: a record is a line of its own, `@@ `. // svgshot/redraw.mjs's shards end their output with one (`svgshot-result`); the -// screenshot review tool reads a run's events the same way. +// screenshot review tool reads a run's events the same way (`shot-event`, --events). The +// other way, a parent stops a child with a line on its stdin (STOP_LINE). /** The line that carries `value` as a record of `kind`. */ export const recordLine = (kind, value) => `@@${kind} ${JSON.stringify(value)}`; @@ -42,3 +43,32 @@ export function readRecords(stream, kinds, { onRecord, onLine }) { rest = ""; }); } + +/** + * The line a parent writes on a child's stdin to stop it: the child takes nothing further, + * finishes what it has in hand, and ends as it would at its normal end. + */ +export const STOP_LINE = "stop"; + +/** + * Calls `fn` once, when a line STOP_LINE arrives on `stream` (this process's stdin, as a + * rule). Any other line is ignored. Returns a function that stops listening. + */ +export function onStop(stream, fn) { + let rest = ""; + let done = false; + const data = (d) => { + const lines = (rest + d).split("\n"); + rest = lines.pop(); + if (done || !lines.some((line) => line.replace(/\r$/, "").trim() === STOP_LINE)) return; + done = true; + fn(); + }; + stream.on("data", data); + return () => stream.off("data", data); +} + +/** Asks the child process `child`, started with a piped stdin, to stop (onStop). */ +export function sendStop(child) { + if (child.stdin?.writable) child.stdin.write(`${STOP_LINE}\n`); +} diff --git a/scripts/lib/shot-apply.mjs b/scripts/lib/shot-apply.mjs index 7144e94de..feab42c5d 100644 --- a/scripts/lib/shot-apply.mjs +++ b/scripts/lib/shot-apply.mjs @@ -11,6 +11,13 @@ import { restoreFromRevision, svgFate } from "./shot-revision.mjs"; /** The SVG beside the picture `out`. */ export const svgOf = (out) => out.replace(/\.png$/i, ".svg"); +/** + * A picture's name in a flat folder: its path under docs/ with the folders joined by + * `__` and no `.png` (IDE__Menu__Images__Menu_File.light), as shoot_docs.mjs --diffs + * and replay.mjs --diffs name their files, and a stage its maps and verdicts (shot-stage.mjs). + */ +export const slugOf = (out) => out.replace(/[\\/]/g, "__").replace(/\.png$/i, ""); + /** * Writes `svg` to `file` unless it holds it already: "new", "updated" or * "unchanged". The file may have been checked out with CRLF line endings, on its diff --git a/scripts/lib/shot-stage.mjs b/scripts/lib/shot-stage.mjs new file mode 100644 index 000000000..0b2587b3e --- /dev/null +++ b/scripts/lib/shot-stage.mjs @@ -0,0 +1,114 @@ +// A run's stage (WIP.ShotReview.md, Runs). With --stage, shoot_docs.mjs and replay.mjs write +// nothing under docs/, nothing to the bundle cache and nothing to the manifest: each +// candidate goes into the stage instead, with what was decided about it, and the screenshot +// review tool applies the verdicts (shot-apply.mjs), so that it is the only writer under +// docs/. A stage is a folder of its own for one run: +// +// pictures/ the capture, at its path under docs/ (X.png, X.light.png) +// pictures/ its SVG, written whether it was found faithful or not +// bundles/ the bundles, laid out as the bundle cache is (bundle.mjs) +// diffs/ the difference pictures, named as --diffs names them +// maps/.png.strength the strength map of the capture against the picture it was +// judged against (reviewPicture), and of the SVG against its +// maps/.svg.strength PNG (diffSvg): see writeStrength +// verdicts/.json what was decided, {out, png, svg}: each part as the run's +// event about it carries it (shoot_docs.mjs, replay.mjs) +// +// `` is the picture's path with its folders joined by __ (slugOf, shot-apply.mjs). A +// stage may hold the Windows user name (a bundle holds the page's text), so it is refused +// under docs/ and wherever git would take it (outputRefusal). + +import { deflateRawSync, inflateRawSync } from "node:zlib"; +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { slugOf, svgOf } from "./shot-apply.mjs"; +import { outputRefusal } from "./shot-revision.mjs"; + +/** The folders of the stage `dir`. */ +export const stageFolders = (dir) => ({ + pictures: path.join(dir, "pictures"), + bundles: path.join(dir, "bundles"), + diffs: path.join(dir, "diffs"), + maps: path.join(dir, "maps"), + verdicts: path.join(dir, "verdicts"), +}); + +/** + * The files of the picture `out` (its path under docs/) in the stage `dir`: {capture, svg, + * pngStrength, svgStrength, verdict}, absolute. + */ +export function stagedFiles(dir, out) { + const f = stageFolders(dir); + const s = slugOf(out); + return { + capture: path.join(f.pictures, out), + svg: path.join(f.pictures, svgOf(out)), + pngStrength: path.join(f.maps, `${s}.png.strength`), + svgStrength: path.join(f.maps, `${s}.svg.strength`), + verdict: path.join(f.verdicts, `${s}.json`), + }; +} + +/** + * Why `dir` may not be a stage, or null: under one of `docsRoots` or where git would take + * it (outputRefusal), or a file, or a folder that holds anything: a stage is one run's. + */ +export function stageRefusal(dir, docsRoots) { + const refused = outputRefusal(dir, docsRoots); + if (refused) return refused; + if (!existsSync(dir)) return null; + if (!statSync(dir).isDirectory()) return `${path.resolve(dir)} is a file`; + if (readdirSync(dir).length) return `${path.resolve(dir)} is not empty: a stage is one run's`; + return null; +} + +/** Writes `bytes` (a Buffer or text) to `file`, making its folder. */ +export function writeStaged(file, bytes) { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, bytes); +} + +/** + * Writes a strength map ({width, height, data}, a byte per pixel: shot-change.mjs) to + * `file`: its width and height, 4 bytes each, big-endian, then its bytes, deflated. Most of + * a map is 0, so it comes to a few kilobytes. + */ +export function writeStrength(file, { width, height, data }) { + const head = Buffer.alloc(8); + head.writeUInt32BE(width, 0); + head.writeUInt32BE(height, 4); + writeStaged(file, Buffer.concat([head, deflateRawSync(data)])); +} + +/** The strength map writeStrength wrote to `file`. Throws when it is not one. */ +export function readStrength(file) { + const bytes = readFileSync(file); + const width = bytes.readUInt32BE(0); + const height = bytes.readUInt32BE(4); + const data = new Uint8Array(inflateRawSync(bytes.subarray(8))); + if (data.length !== width * height) throw new Error(`${file} holds ${data.length} bytes, not ${width} by ${height}`); + return { width, height, data }; +} + +/** + * Sets the part `part` ("png" or "svg") of the verdict on the picture `out` in the stage + * `dir` to `value`, keeping the other part, and returns the whole verdict, {out, png, svg}. + * The jobs of a run share one process, and each write is synchronous. + */ +export function writeVerdict(dir, out, part, value) { + const file = stagedFiles(dir, out).verdict; + const verdict = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : { out, png: null, svg: null }; + verdict[part] = value; + writeStaged(file, `${JSON.stringify(verdict, null, 2)}\n`); + return verdict; +} + +/** Every verdict in the stage `dir`, sorted by path: [{out, png, svg}]. */ +export function readVerdicts(dir) { + const folder = stageFolders(dir).verdicts; + if (!existsSync(folder)) return []; + return readdirSync(folder) + .filter((name) => name.endsWith(".json")) + .map((name) => JSON.parse(readFileSync(path.join(folder, name), "utf8"))) + .sort((a, b) => (a.out < b.out ? -1 : a.out > b.out ? 1 : 0)); +} diff --git a/scripts/svgshot/redraw.mjs b/scripts/svgshot/redraw.mjs index 655fa111b..f06020757 100644 --- a/scripts/svgshot/redraw.mjs +++ b/scripts/svgshot/redraw.mjs @@ -17,17 +17,11 @@ import { readRecords, writeRecord } from "../lib/record-lines.mjs"; import { BUNDLE_VERSION, pngHash, readBundle, renderBundle } from "./bundle.mjs"; import { SNAPSHOT_STYLES } from "./capture.mjs"; import { holdsUserName } from "./keep.mjs"; +import { slugOf } from "../lib/shot-apply.mjs"; /** A picture's theme from its path under docs/: "light" for X.light.png, else "dark". */ export const themeOf = (out) => (/\.light\.png$/i.test(out) ? "light" : "dark"); -/** - * A picture's name in a flat folder: its path under docs/ with the folders joined by - * `__` and no `.png` (IDE__Menu__Images__Menu_File.light), as shoot_docs.mjs --diffs - * and replay.mjs --diffs name their files. - */ -export const slugOf = (out) => out.replace(/[\\/]/g, "__").replace(/\.png$/i, ""); - /** * The files a judged picture can have in a flat folder, relative to it: the PNG's * copy, the SVG, the SVG's render and the difference map. The SVG and the diff --git a/scripts/svgshot/sheets.mjs b/scripts/svgshot/sheets.mjs index 2771ea722..f5fefdcde 100644 --- a/scripts/svgshot/sheets.mjs +++ b/scripts/svgshot/sheets.mjs @@ -13,7 +13,8 @@ import path from "node:path"; import { decodePng } from "../lib/png.mjs"; import { changeMap, compareImages, NOISE_LEVELS, NOISE_SHARE } from "../lib/shot-change.mjs"; import { cardHtml, esc, figuresIn, meterHtml, pageHtml, pctText, sizeText, tocItem } from "../lib/shot-views.mjs"; -import { pngSize, slugOf, themeOf } from "./redraw.mjs"; +import { slugOf } from "../lib/shot-apply.mjs"; +import { pngSize, themeOf } from "./redraw.mjs"; /** The generator a sheet names in its , by which a folder is known to hold one. */ export const SHEET_GENERATOR = "scripts/svgshot/sheet.mjs"; diff --git a/test/svgshot.test.mjs b/test/svgshot.test.mjs index be5760464..952da051a 100644 --- a/test/svgshot.test.mjs +++ b/test/svgshot.test.mjs @@ -51,8 +51,16 @@ import { } from "../scripts/svgshot/bundle.mjs"; import { SNAPSHOT_STYLES, webFontFiles } from "../scripts/svgshot/capture.mjs"; import { decodePng, encodePng } from "../scripts/lib/png.mjs"; -import { parseRecord, readRecords, recordLine } from "../scripts/lib/record-lines.mjs"; -import { applyPicture, applySvg, dropPicture, restorePictures } from "../scripts/lib/shot-apply.mjs"; +import { onStop, parseRecord, readRecords, recordLine, sendStop, STOP_LINE } from "../scripts/lib/record-lines.mjs"; +import { + readStrength, + readVerdicts, + stagedFiles, + stageRefusal, + writeStrength, + writeVerdict, +} from "../scripts/lib/shot-stage.mjs"; +import { applyPicture, applySvg, dropPicture, restorePictures, slugOf } from "../scripts/lib/shot-apply.mjs"; import { cleanupResets, decidePicture, @@ -114,7 +122,6 @@ import { redraw, retakeText, shareOf, - slugOf, themeOf, } from "../scripts/svgshot/redraw.mjs"; import { @@ -1946,6 +1953,81 @@ describe("the records a child writes among its lines (record-lines.mjs)", () => assert.deepEqual(lines, ["one", "two", "@@other 2", "last"]); assert.deepEqual(records, [["k", { n: 1 }]]); }); + + test("a child is stopped once by a line on its stdin, across chunks, and by nothing else", () => { + const stream = new PassThrough(); + let stops = 0; + onStop(stream, () => stops++); + stream.write("go\nsto"); + assert.equal(stops, 0); + stream.write("p\r\nstop\n"); + assert.equal(stops, 1); + // a child started with a piped stdin is sent the line; one without is not + const sent = []; + sendStop({ stdin: { writable: true, write: (text) => sent.push(text) } }); + sendStop({ stdin: null }); + assert.deepEqual(sent, [`${STOP_LINE}\n`]); + }); +}); + +describe("a run's stage (shot-stage.mjs)", () => { + test("names each picture's files, and keeps both parts of its verdict", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "shot-stage-")); + try { + const out = "IDE/Images/Menu_File.light.png"; + const files = stagedFiles(dir, out); + assert.equal(files.capture, path.join(dir, "pictures", "IDE", "Images", "Menu_File.light.png")); + assert.equal(files.svg, path.join(dir, "pictures", "IDE", "Images", "Menu_File.light.svg")); + assert.equal(files.verdict, path.join(dir, "verdicts", "IDE__Images__Menu_File.light.json")); + assert.equal(files.pngStrength, path.join(dir, "maps", "IDE__Images__Menu_File.light.png.strength")); + assert.deepEqual(writeVerdict(dir, out, "png", { state: "updated" }), { + out, + png: { state: "updated" }, + svg: null, + }); + writeVerdict(dir, out, "svg", { faithful: true }); + writeVerdict(dir, "A/b.png", "png", { state: "new" }); + assert.deepEqual(readVerdicts(dir), [ + { out: "A/b.png", png: { state: "new" }, svg: null }, + { out, png: { state: "updated" }, svg: { faithful: true } }, + ]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test("a strength map is written and read back, and a file that is not one refused", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "shot-stage-")); + try { + const map = paintMap(300, 200, blocks([10, 10, 40, 30])); + const file = path.join(dir, "maps", "x.png.strength"); + writeStrength(file, map); + assert.ok(fs.statSync(file).size < 1000); + assert.deepEqual(readStrength(file), map); + const bad = Buffer.from(fs.readFileSync(file)); + bad.writeUInt32BE(301, 0); + fs.writeFileSync(file, bad); + assert.throws(() => readStrength(file), /not 301 by 200/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test("is one run's: never under docs, where git would take it, or in a folder that holds anything", () => { + const docs = path.join(REPO_ROOT, "docs"); + assert.match(stageRefusal(path.join(docs, "stage"), [docs]), /is under/); + assert.match(stageRefusal(path.join(REPO_ROOT, "scripts", "stage"), [docs]), /does not ignore/); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "shot-stage-")); + try { + assert.equal(stageRefusal(path.join(dir, "new"), [docs]), null); + assert.equal(stageRefusal(dir, [docs]), null); + fs.writeFileSync(path.join(dir, "x"), ""); + assert.match(stageRefusal(dir, [docs]), /is not empty/); + assert.match(stageRefusal(path.join(dir, "x"), [docs]), /is a file/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); }); // A strength map `w` by `h` whose pixels, in reading order, are the [count, level] runs of From 8728cd6f661fda0c6c6c6e414f542d377d4b7e4e Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Sat, 31 Oct 2026 21:56:32 +0100 Subject: [PATCH 18/85] Step 4 of the screenshot review: --stage and --events in shoot_docs.mjs and replay.mjs, a run stopped by a line on stdin; dryRun in shot-apply.mjs, verdict shapes in shot-stage.mjs, judgeSvg returns decideSvg's verdict, runShards and inBrowser stop --- scripts/lib/cli-cases.mjs | 8 ++ scripts/lib/shot-apply.mjs | 40 +++--- scripts/lib/shot-stage.mjs | 92 ++++++++++++ scripts/shoot_docs.mjs | 278 +++++++++++++++++++++++++++++-------- scripts/svgshot/keep.mjs | 13 +- scripts/svgshot/redraw.mjs | 25 ++-- scripts/svgshot/replay.mjs | 136 ++++++++++++++---- 7 files changed, 477 insertions(+), 115 deletions(-) diff --git a/scripts/lib/cli-cases.mjs b/scripts/lib/cli-cases.mjs index 5dcc82a07..fb8ed8bde 100644 --- a/scripts/lib/cli-cases.mjs +++ b/scripts/lib/cli-cases.mjs @@ -884,6 +884,14 @@ bad("scripts/shoot_docs.mjs", ["--jobs=17"], "--jobs expects a whole number from { const underDocs = path.join(REPO_ROOT, "docs", "x"); bad("scripts/shoot_docs.mjs", ["--diffs", underDocs], `--diffs must not be under docs: ${underDocs}\n`); + // A stage (lib/shot-stage.mjs) holds the difference pictures and the bundles, and is never under docs. + const docs = path.join(REPO_ROOT, "docs"); + bad("scripts/shoot_docs.mjs", ["--stage", underDocs], `--stage: ${underDocs} is under ${docs}\n`); + bad("scripts/shoot_docs.mjs", ["--stage", "s", "--diffs", "d"], "--stage and --diffs cannot be given together\n"); + bad("scripts/shoot_docs.mjs", ["--stage", "s", "--bundles", "b"], "--stage and --bundles cannot be given together\n"); + bad("scripts/shoot_docs.mjs", ["--cleanup", "--events"], "--cleanup and --events cannot be given together\n"); + bad("scripts/svgshot/replay.mjs", ["--stage", underDocs], `replay: --stage: ${underDocs} is under ${docs}\n`); + bad("scripts/svgshot/replay.mjs", ["--stage", "s", "--diffs", "d"], "--stage and --diffs cannot be given together\n"); } // check_examples prints "check_examples: " before the message. diff --git a/scripts/lib/shot-apply.mjs b/scripts/lib/shot-apply.mjs index feab42c5d..ec6bdc271 100644 --- a/scripts/lib/shot-apply.mjs +++ b/scripts/lib/shot-apply.mjs @@ -21,12 +21,13 @@ export const slugOf = (out) => out.replace(/[\\/]/g, "__").replace(/\.png$/i, "" /** * Writes `svg` to `file` unless it holds it already: "new", "updated" or * "unchanged". The file may have been checked out with CRLF line endings, on its - * last line alone (the SVG is on one line, oneLine in snapshot-svg.mjs). + * last line alone (the SVG is on one line, oneLine in snapshot-svg.mjs). With `dryRun` + * nothing is written, and the state is what would be. */ -export function writeSvg(file, svg) { +export function writeSvg(file, svg, { dryRun = false } = {}) { let state = "new"; if (existsSync(file)) state = readFileSync(file, "utf8").replace(/\r\n/g, "\n") === svg ? "unchanged" : "updated"; - if (state !== "unchanged") writeFileSync(file, svg); + if (state !== "unchanged" && !dryRun) writeFileSync(file, svg); return state; } @@ -70,17 +71,20 @@ export function restorePictures(outRoot, revision, outs, { dryRun = false } = {} * Writes the verdict on a capture of the picture `out` (decidePicture): the bytes the file is * to hold, if any; for "dark", the light file removed; for "restored", the picture and its SVG * put back as `revision` has them (restorePictures). Returns {removed, svg}: whether a file - * was removed, and for "restored" restorePictures' answer for the picture, else null. + * was removed, and for "restored" restorePictures' answer for the picture, else null. With + * `dryRun` nothing is written, and the answer is what would be done (a run's stage, + * shot-stage.mjs, whose verdicts the review tool applies). */ -export function applyPicture(outRoot, out, decision, revision) { +export function applyPicture(outRoot, out, decision, revision, { dryRun = false } = {}) { const file = path.join(outRoot, out); if (decision.state === "dark") { const had = existsSync(file); - if (had) rmSync(file); + if (had && !dryRun) rmSync(file); return { removed: had, svg: null }; } - if (decision.state === "restored") return { removed: false, svg: restorePictures(outRoot, revision, [out]).get(out) }; - if (decision.write) { + if (decision.state === "restored") + return { removed: false, svg: restorePictures(outRoot, revision, [out], { dryRun }).get(out) }; + if (decision.write && !dryRun) { mkdirSync(path.dirname(file), { recursive: true }); writeFileSync(file, decision.write); } @@ -90,25 +94,27 @@ export function applyPicture(outRoot, out, decision, revision) { /** * Writes the SVG of a picture (`svgOut` its path) when it was found faithful, or removes the * one there when it was not (`svg` null): writeSvg's state, else "removed" or "not written". + * With `dryRun` nothing is written, and the state is what would be. */ -export function applySvg(outRoot, svgOut, svg) { +export function applySvg(outRoot, svgOut, svg, { dryRun = false } = {}) { const file = path.join(outRoot, svgOut); - if (svg !== null) return writeSvg(file, svg); + if (svg !== null) return writeSvg(file, svg, { dryRun }); if (!existsSync(file)) return "not written"; - rmSync(file); + if (!dryRun) rmSync(file); return "removed"; } /** * Removes the picture `out`, its SVG and, when there is a bundle cache (`bundlesRoot`), its - * bundle. Returns whether the picture's file was there. + * bundle. Returns whether the picture's file was there. With `dryRun` nothing is removed. */ -export function dropPicture(outRoot, out, bundlesRoot) { +export function dropPicture(outRoot, out, bundlesRoot, { dryRun = false } = {}) { + const file = path.join(outRoot, out); + const had = existsSync(file); + if (dryRun) return had; const svg = path.join(outRoot, svgOf(out)); if (existsSync(svg)) rmSync(svg); if (bundlesRoot) dropBundle(bundlesRoot, out); - const file = path.join(outRoot, out); - if (!existsSync(file)) return false; - rmSync(file); - return true; + if (had) rmSync(file); + return had; } diff --git a/scripts/lib/shot-stage.mjs b/scripts/lib/shot-stage.mjs index 0b2587b3e..99c80ac56 100644 --- a/scripts/lib/shot-stage.mjs +++ b/scripts/lib/shot-stage.mjs @@ -68,6 +68,98 @@ export function writeStaged(file, bytes) { writeFileSync(file, bytes); } +// A staged file's path as a verdict names it: relative to the stage, with forward slashes. +const named = (dir, file) => path.relative(dir, file).replace(/\\/g, "/"); + +/** + * Puts a capture of the picture `out` into the stage `dir`, with its strength map against + * the picture it was judged against when there is one (reviewPicture). Returns the names of + * the files written, {capture, strength}, `strength` null when there is no map. + */ +export function stageCapture(dir, out, png, strength) { + const files = stagedFiles(dir, out); + writeStaged(files.capture, png); + if (strength) writeStrength(files.pngStrength, strength); + return { capture: named(dir, files.capture), strength: strength ? named(dir, files.pngStrength) : null }; +} + +/** + * Puts the SVG of the picture `out` into the stage `dir`, with its strength map against its + * PNG (diffSvg), whether or not it was found faithful. Returns the names of the files + * written, {svg, strength}. + */ +export function stageSvg(dir, out, svg, strength) { + const files = stagedFiles(dir, out); + writeStaged(files.svg, svg); + writeStrength(files.svgStrength, strength); + return { svg: named(dir, files.svg), strength: named(dir, files.svgStrength) }; +} + +/** + * The most regions a verdict lists, the largest cores first: the loupes show the first few, + * and a change over a whole window can make thousands. The rest are in the strength map. + */ +export const STAGED_REGIONS = 64; + +// A verdict's regions: the first STAGED_REGIONS, and how many there are (null for none measured). +const regionsOf = (regions) => + regions + ? { regions: regions.slice(0, STAGED_REGIONS), regionCount: regions.length } + : { regions: null, regionCount: null }; + +/** + * The png part of a verdict, as a --stage file and an --events record carry it: what + * decidePicture decided (`decision`, its `write` left out), the regions and the look + * reviewPicture found (`review`), the build the capture was taken on, the difference + * pictures written (their names in the diffs folder), and the files staged (stageCapture), + * or null. + */ +export function pngVerdict({ decision, review, build, diffs = [], staged = null }) { + const { write, ...rest } = decision; + return { + ...rest, + writes: write !== null && write !== undefined, + build, + look: review?.look ?? null, + ...regionsOf(review?.regions ?? null), + diffs, + ...(staged ?? {}), + }; +} + +/** + * The svg part of a verdict: `state` what is done with the file (applySvg's, or "unchanged" + * when it stays with its PNG), `why` the reason it is not written, or null; `verdict` + * decideSvg's and `review` reviewSvg's, null for an SVG not judged; `stats` diffSvg's + * shares (pct8, pct32, pct96); `measures` the line a person reads; `diffs` the difference + * pictures written; and the files staged (stageSvg), or null. + */ +export function svgVerdict({ + state, + why = null, + verdict = null, + review = null, + stats = null, + measures = null, + diffs = [], + staged = null, +}) { + return { + state, + why, + faithful: verdict?.faithful ?? null, + by: verdict?.by ?? null, + share: verdict?.share ?? null, + earlier: verdict?.earlier ?? null, + stats: stats && { pct8: stats.pct8, pct32: stats.pct32, pct96: stats.pct96 }, + measures, + look: review?.look ?? null, + ...regionsOf(review?.regions ?? null), + diffs, + ...(staged ?? {}), + }; +} + /** * Writes a strength map ({width, height, data}, a byte per pixel: shot-change.mjs) to * `file`: its width and height, 4 bytes each, big-endian, then its bytes, deflated. Most of diff --git a/scripts/shoot_docs.mjs b/scripts/shoot_docs.mjs index ef1229a5e..411944582 100644 --- a/scripts/shoot_docs.mjs +++ b/scripts/shoot_docs.mjs @@ -168,6 +168,18 @@ import { changedPictures, openRevision, planCleanup } from "./lib/shot-revision. import { applyPicture, applySvg, dropPicture, restorePictures, svgOf } from "./lib/shot-apply.mjs"; import { cleanupResets, decidePicture, pngRule, SVG_FAITHFUL } from "./lib/shot-policy.mjs"; import { MANIFEST, openManifest } from "./lib/shot-manifest.mjs"; +import { reviewPicture, reviewSvg } from "./lib/shot-queue.mjs"; +import { + pngVerdict, + stageCapture, + stageFolders, + stageRefusal, + stageSvg, + svgVerdict, + writeVerdict, +} from "./lib/shot-stage.mjs"; +import { onStop, writeRecord } from "./lib/record-lines.mjs"; +import { writeJudged } from "./svgshot/redraw.mjs"; import { diffPicture } from "./lib/shot-diff.mjs"; import { attach } from "./lib/tb-cdp.mjs"; import { consoleMark, linesSince } from "./lib/tb-ide-console.mjs"; @@ -251,7 +263,7 @@ const SCALE = 2; const LCD_TEXT = "--disable-lcd-text"; const BROWSER_ARGS = `--force-device-scale-factor=1 ${LCD_TEXT}`; -const USAGE = `usage: node scripts/shoot_docs.mjs [--only ] [--out ] [--diffs ] [--jobs N] [--ide ] [--svg] [--against | --against-disk] [-h, --help] +const USAGE = `usage: node scripts/shoot_docs.mjs [--only ] [--out ] [--diffs | --stage ] [--events] [--jobs N] [--ide ] [--svg] [--against | --against-disk] [-h, --help] node scripts/shoot_docs.mjs --cleanup [--dry-run] [--only ] [--out ] [--against ] Takes the pictures of the IDE that the documentation shows, from IDEs on a @@ -366,11 +378,26 @@ which takes the rule's place for those bytes only (--cleanup too). chosen when the regex matches its dark path or its .light.png), --out and --against, and none of the options that need an IDE --dry-run with --cleanup, list what it would put back and change nothing + --stage write nothing under the output folder, to the bundle cache or + to the manifest: put each picture's capture, its SVG (faithful + or not), its bundle, its difference pictures, its strength maps + and what was decided about it into instead, for the + screenshot review tool to apply (scripts/lib/shot-stage.mjs). + must be new or empty, and neither under docs nor where + git would take it. The lines printed say what was decided, as + without it. Not with --diffs, which it holds, or --bundles + --events also write a line per event on stdout, @@shot-event {json} + (the run, each job started and done, each picture's verdict, + each SVG's, a failure, the summary), among the lines meant for + a person; and stop when a line "stop" arrives on stdin: no + further picture is taken, the IDEs end and the registry is put + back, as at the normal end -h, --help print this text and exit Exit codes: 0 every picture was written or was unchanged (with --cleanup: every modified - picture was compared) + picture was compared); a run stopped (--events) exits as for the pictures + it took 1 a picture failed: an element was not found, the page showed the user name, a light picture's size is more than 32 pixels off the dark one's (the IDE was not in the state the dark picture shows), the add-in did not build, or @@ -395,6 +422,8 @@ const { values } = withUsageError(() => "against-disk": { type: "boolean", default: false }, cleanup: { type: "boolean", default: false }, "dry-run": { type: "boolean", default: false }, + stage: { type: "string" }, + events: { type: "boolean", default: false }, help: { type: "boolean", short: "h", default: false }, }, stopAt: ["help"], @@ -403,8 +432,9 @@ const { values } = withUsageError(() => if (values.help) printHelpAndExit(USAGE); withUsageError(() => { refuseTogether(values, ["against", "against-disk"]); + for (const other of ["diffs", "bundles"]) refuseTogether(values, ["stage", other]); if (values.cleanup) - for (const other of ["against-disk", "diffs", "bundles", "jobs", "ide", "svg"]) + for (const other of ["against-disk", "diffs", "bundles", "jobs", "ide", "svg", "stage", "events"]) refuseTogether(values, ["cleanup", other]); }); if (values.dryRun && !values.cleanup) die(2, "--dry-run is for --cleanup"); @@ -413,10 +443,21 @@ const jobCount = withUsageError(() => numberOption(values.jobs ?? "6", { option: "--jobs", integer: true, min: 1, max: 16 }), ); const outRoot = path.resolve(values.out ?? DEFAULT_OUT); -const diffsRoot = values.diffs === undefined ? null : path.resolve(values.diffs); +// With --stage, the run's stage (lib/shot-stage.mjs): it holds the difference pictures and the +// bundles, and nothing is written under the output folder, to the bundle cache or to the +// manifest. The review tool applies its verdicts. +const stageRoot = values.stage === undefined ? null : path.resolve(values.stage); +if (stageRoot) { + const refused = stageRefusal(stageRoot, [DEFAULT_OUT, outRoot]); + if (refused) die(2, `--stage: ${refused}`); + mkdirSync(stageRoot, { recursive: true }); +} +const staged = stageRoot ? stageFolders(stageRoot) : null; +const diffsRoot = staged ? staged.diffs : values.diffs === undefined ? null : path.resolve(values.diffs); // With --svg, each picture's bundle (svgshot/bundle.mjs), for replay.mjs: never under docs, // since a bundle holds the Windows user name. -const bundlesRoot = values.svg ? path.resolve(values.bundles ?? DEFAULT_BUNDLES) : null; +const bundleCache = path.resolve(values.bundles ?? DEFAULT_BUNDLES); +const bundlesRoot = !values.svg ? null : staged ? staged.bundles : bundleCache; // path.relative gives an absolute path across drives, which is outside docs and does not start with "..". for (const [option, root] of [ ["--diffs", diffsRoot], @@ -426,8 +467,14 @@ for (const [option, root] of [ const rel = path.relative(path.join(REPO_ROOT, "docs"), root); if (!rel.startsWith("..") && !path.isAbsolute(rel)) die(2, `${option} must not be under docs: ${root}`); } -// The fonts' cuts are kept beside the bundles, as replay.mjs keeps them (fonts.mjs). -if (bundlesRoot) useSubsetCache(path.join(bundlesRoot, "subsets")); +// The fonts' cuts are kept beside the bundles, as replay.mjs keeps them (fonts.mjs): in the +// bundle cache with --stage too, since a cut depends on the font and its characters alone. +if (values.svg) useSubsetCache(path.join(bundleCache, "subsets")); + +// --events: a record per event on stdout (lib/record-lines.mjs), for the review tool. +const event = (type, value = {}) => { + if (values.events) writeRecord("shot-event", { type, ...value }); +}; // The revision a capture is compared with before the file on disk (--against, HEAD by default; // none with --against-disk), and the one --cleanup puts pictures back to. @@ -682,17 +729,20 @@ const complain = (name, text) => console.error(`[${name}] ${text}`); // With --diffs, the difference picture of `before` and `after` (PNG files) for the picture // `out`, as ..png, the -// theme dark or light. A failure to write it is said, and fails nothing. +// theme dark or light (--stage: in its diffs folder). A failure to write it is said, and fails +// nothing. Returns the name written, or null. function writeDiff(name, out, before, after, { suffix = "", labels } = {}) { - if (!diffsRoot) return; + if (!diffsRoot) return null; const theme = /\.light\.png$/i.test(out) ? "light" : "dark"; const base = out.replace(/[\\/]/g, "__").replace(/(\.light)?\.png$/i, ""); const file = path.join(diffsRoot, `${base}.${theme}${suffix}.png`); try { mkdirSync(diffsRoot, { recursive: true }); writeFileSync(file, diffPicture(before, after, labels)); + return path.basename(file); } catch (e) { complain(name, `the difference picture ${file} was not written: ${e.message}`); + return null; } } @@ -13286,6 +13336,37 @@ function planJobs() { } const jobs = planJobs(); +// The pictures a job takes in each theme: a `once` shot in the dark pass only. +const passes = (job) => THEMES.map((theme) => [theme, theme === "dark" ? job.shots : job.shots.filter((s) => !s.once)]); +if (stageRoot) console.log(`staging every picture in ${stageRoot}: nothing is written under ${outRoot}`); +event("run", { + tool: "shoot_docs", + build: BUILD, + against: revision ? revision.short : null, + svg: values.svg, + stage: stageRoot, + jobs: jobs.map((job) => ({ + name: job.name, + setup: job.setup, + pictures: passes(job).flatMap(([theme, shots]) => shots.map((s) => themedOut(s.out, theme))), + })), +}); + +// --events: a line "stop" on stdin stops the run (lib/record-lines.mjs). No further picture +// is taken and no further job started; each job's IDE ends as at the run's normal end. +let stopping = false; +if (values.events) + onStop(process.stdin, () => { + stopping = true; + console.log("stopping: no further picture is taken"); + event("stopping"); + }); +// A picture a stopped run does not take. +const notTaken = (name, out, theme) => { + count(theme, "not taken: the run was stopped"); + event("skipped", { job: name, out }); +}; + // The run's folder is named for this process, so that runs at once never share one; each job // takes its slot from tbbroker as it starts (runJob). A folder an earlier run left, whose // process has ended, goes. @@ -13349,8 +13430,10 @@ const count = (theme, state) => { // capture's noise of its file is not written either. The picture's entry in the manifest // can change the noise rule, and a review's decision about these bytes takes its place. The // verdict is written by applyPicture (lib/shot-apply.mjs), and the manifest records the -// build of a capture written. Returns whether the picture was written, "restored" for one -// put back as the revision has it, or null for a light picture that is the dark one. +// build of a capture written; with --stage nothing is written but the stage (reportPicture), +// and the lines say what was decided. Returns {kept, holds}: `kept` whether the picture was +// written, "restored" for one put back as the revision has it, or null for a light picture +// that is the dark one; `holds` the bytes the file holds once the verdict is applied. function keep(name, out, png, theme, dark, scale = SCALE) { const themed = themedOut(out, theme); const file = path.join(outRoot, themed); @@ -13384,20 +13467,24 @@ function keep(name, out, png, theme, dark, scale = SCALE) { `the light picture is ${w}x${h} px and the dark one ${fate.dark.w}x${fate.dark.h} px: more than the theme's layout moves`, ); } + // the picture the capture was judged against, which its regions of change are measured on + const reference = { revision: atRevision, disk: was, dark }[fate.against] ?? null; + const dryRun = !!stageRoot; let other = ""; if (fate.dark) { count(theme, "of another size than the dark picture"); other = `; the dark picture is ${fate.dark.w}x${fate.dark.h} px`; } if (fate.state === "dark") { - const { removed } = applyPicture(outRoot, themed, fate, revision); + const { removed } = applyPicture(outRoot, themed, fate, revision, { dryRun }); if (removed) recordBuild(themed, null); count(theme, "the same as the dark picture"); say( name, `${themed}: the dark picture's pixels, but for a capture's noise: ${removed ? "removed" : "not written"}${review}`, ); - return null; + reportPicture(name, themed, png, fate, reference, []); + return { kept: null, holds: null }; } // a run now and then settles on the other version of a capture's noise for every // capture of a state (WIP.Screenshots.md, Capture): the file then stays as it is @@ -13409,22 +13496,27 @@ function keep(name, out, png, theme, dark, scale = SCALE) { restored: `restored as ${AGAINST} has it`, }; const state = STATES[fate.state]; - if (fate.state === "noise") writeDiff(name, themed, was ?? atRevision, png, { suffix: ".noise" }); - if (fate.state === "updated") writeDiff(name, themed, was, png); + const diffs = []; + if (fate.state === "noise") diffs.push(writeDiff(name, themed, was ?? atRevision, png, { suffix: ".noise" })); + if (fate.state === "updated") diffs.push(writeDiff(name, themed, was, png)); if (fate.state === "restored" && was) - writeDiff(name, themed, was, atRevision, { - suffix: ".restored", - labels: { before: "FILE", after: AGAINST.toUpperCase() }, - }); + diffs.push( + writeDiff(name, themed, was, atRevision, { + suffix: ".restored", + labels: { before: "FILE", after: AGAINST.toUpperCase() }, + }), + ); // A picture put back as the revision has it takes the revision's SVG with it, or loses its // SVG when the revision has none, so that the pair stays matched. - const applied = applyPicture(outRoot, themed, fate, revision); + const applied = applyPicture(outRoot, themed, fate, revision, { dryRun }); if (fate.state === "new" || fate.state === "updated") recordBuild(themed, BUILD); count(theme, state); say( name, `${themed}: ${state} (${w}x${h} px, ${png.length} bytes; shown at {:width="${Math.round(w / scale)}" height="${Math.round(h / scale)}"}${other}${review})`, ); + reportPicture(name, themed, png, fate, reference, diffs.filter(Boolean), applied.svg); + const holds = fate.write ?? was; if (fate.state === "restored") { const text = { keep: `as ${AGAINST} has it`, @@ -13433,27 +13525,49 @@ function keep(name, out, png, theme, dark, scale = SCALE) { }; const { fate: svg, atRevision: svgAtRevision } = applied.svg; if (svg !== "keep" || svgAtRevision) say(name, `${svgOf(themed)}: ${text[svg]}`); - return "restored"; + return { kept: "restored", holds }; } - return fate.write !== null; + return { kept: fate.write !== null, holds }; +} + +// With --stage or --events, the verdict on the capture `png` of the picture `themed` +// (decidePicture's `fate`): its regions of change against `reference`, the picture it was +// judged against, and why it needs a look (reviewPicture, lib/shot-queue.mjs); with --stage +// the capture and its strength map are staged and the verdict written (lib/shot-stage.mjs). +// `diffs` names the difference pictures written, and `restoredSvg` is what becomes of the SVG +// of a picture put back as the revision has it (restorePictures). +function reportPicture(name, themed, png, fate, reference, diffs, restoredSvg = null) { + if (!stageRoot && !values.events) return; + const review = reviewPicture(fate, png, reference, entryOf(themed)); + const files = stageRoot ? stageCapture(stageRoot, themed, png, review.strength) : null; + const part = { ...pngVerdict({ decision: fate, review, build: BUILD, diffs, staged: files }), restoredSvg }; + if (stageRoot) writeVerdict(stageRoot, themed, "png", part); + event("picture", { job: name, out: themed, ...part }); } // The manifest's record of the build a picture's file was taken on: `build` for a capture // written, null for a file removed. A picture whose file stays (unchanged, noise, restored) // keeps the build it has. Saved at once: the jobs share the manifest, and keep is synchronous. +// With --stage the verdict carries the build, and the review tool records it. function recordBuild(out, build) { - if (!manifest) return; + if (!manifest || stageRoot) return; if (build === null) manifest.clearBuild(out); else manifest.setBuild(out, build); manifest.save(); } -// A `once` picture has no light file: one left from before is removed, and its SVG. +// A `once` picture has no light file: one left from before is removed, and its SVG. With +// --stage it is left, and the verdict "once" says it goes. function dropLight(name, out) { - if (dropPicture(outRoot, themedOut(out, "light"), bundlesRoot)) { - recordBuild(themedOut(out, "light"), null); - say(name, `${themedOut(out, "light")}: removed (the picture is taken once, in no theme)`); - } + const light = themedOut(out, "light"); + if (!dropPicture(outRoot, light, bundlesRoot, { dryRun: !!stageRoot })) return; + recordBuild(light, null); + say(name, `${light}: removed (the picture is taken once, in no theme)`); + if (!stageRoot && !values.events) return; + const decision = { state: "once", write: null, against: null, size: null, dark: null, by: "rule", earlier: null }; + const part = pngVerdict({ decision, review: null, build: null }); + if (stageRoot) writeVerdict(stageRoot, light, "png", part); + event("picture", { job: name, out: light, ...part }); } // With --svg: one headless browser for the whole run, which renders each SVG to @@ -13466,10 +13580,18 @@ const svgResults = []; // holds the user name; an SVG that is not faithful is not written, and one // written before is removed. Says how much of it differs from the PNG. The // picture's bundle goes to the bundle cache whether or not its SVG is written, -// so that replay.mjs can draw it again once the converter draws it better. -async function keepSvg(name, out, png, picture, written) { +// so that replay.mjs can draw it again once the converter draws it better. `holds` is +// the PNG the file holds once its verdict is applied, which the bundle names. With +// --stage the SVG, its strength map and its bundle go to the stage, whether or not it is +// faithful, and nothing else is written. +async function keepSvg(name, out, png, picture, written, holds) { const svgOut = svgOf(out); const file = path.join(outRoot, svgOut); + const dryRun = !!stageRoot; + const report = (part) => { + if (stageRoot) writeVerdict(stageRoot, out, "svg", part); + event("svg", { job: name, out, ...part }); + }; // An SVG is drawn from the whole page, layers the picture does not show included, and those // can differ from run to run (a designer still drawing under a floating panel); and one that // sits on SVG_FAITHFUL's bar is written in one run and removed in the next. So the SVG of a @@ -13477,9 +13599,14 @@ async function keepSvg(name, out, png, picture, written) { // with its PNG, or when replay.mjs draws it again after a change to the converter. if (!written && existsSync(file)) { say(name, `${svgOut}: unchanged, as its PNG is`); + report(svgVerdict({ state: "unchanged", why: "its PNG is not written" })); return; } - const drop = (why) => say(name, `${svgOut}: ${applySvg(outRoot, svgOut, null)}, the PNG is kept (${why})`); + const drop = (why, judged = {}) => { + const state = applySvg(outRoot, svgOut, null, { dryRun }); + say(name, `${svgOut}: ${state}, the PNG is kept (${why})`); + report(svgVerdict({ state, why, ...judged })); + }; if (!picture) { drop("the picture is not a single capture"); return; @@ -13488,32 +13615,33 @@ async function keepSvg(name, out, png, picture, written) { if (holdsUserName(svg, USER)) throw new Error(`the SVG of ${out} holds the Windows user name`); // The bundle names the PNG as the file holds it: a capture within the noise of the file // leaves the file as it was, and that file is what replay.mjs compares with. - const pngFile = path.join(outRoot, out); - if (bundlesRoot) writeBundle(bundlesRoot, out, bundle, existsSync(pngFile) ? readFileSync(pngFile) : png, misses); + if (bundlesRoot) writeBundle(bundlesRoot, out, bundle, holds ?? png, misses); svgBrowser ??= launchBrowser(); - const { diff, faithful, by, measures } = await judgeSvg(await svgBrowser, svg, png, stats, entryOf(out)); - const review = by === "decision" ? `; ${faithful ? "accepted" : "refused"} by a review` : ""; - if (diffsRoot) { - mkdirSync(diffsRoot, { recursive: true }); - writeFileSync( - path.join(diffsRoot, `${out.replace(/[\\/]/g, "__").replace(/\.png$/i, "")}.svg-diff.png`), - diff.diff, - ); - } + const entry = entryOf(out); + const { diff, verdict, faithful, by, measures } = await judgeSvg(await svgBrowser, svg, png, stats, entry); + const how = by === "decision" ? `; ${faithful ? "accepted" : "refused"} by a review` : ""; + // To see what went wrong, an SVG not written goes beside its difference map. + const diffs = diffsRoot ? Object.values(writeJudged(diffsRoot, out, diff, { svg: faithful ? null : svg })) : []; + const judged = { + verdict, + review: stageRoot || values.events ? reviewSvg(verdict, diff.strength, entry) : null, + stats: diff.stats, + measures, + diffs, + staged: stageRoot ? stageSvg(stageRoot, out, svg, diff.strength) : null, + }; const bytes = Buffer.byteLength(svg); svgResults.push({ out: svgOut, bytes, png: png.length, faithful, ...diff.stats }); if (!faithful) { - drop(`${measures}${review}`); - // To see what went wrong, the SVG goes beside its difference map. - if (diffsRoot) - writeFileSync(path.join(diffsRoot, `${out.replace(/[\\/]/g, "__").replace(/\.png$/i, "")}.svg`), svg); + drop(`${measures}${how}`, judged); return; } - const state = applySvg(outRoot, svgOut, svg); + const state = applySvg(outRoot, svgOut, svg, { dryRun }); say( name, - `${svgOut}: ${state} (${(bytes / 1024).toFixed(0)} KB against the PNG's ${(png.length / 1024).toFixed(0)} KB; ${measures}${review})`, + `${svgOut}: ${state} (${(bytes / 1024).toFixed(0)} KB against the PNG's ${(png.length / 1024).toFixed(0)} KB; ${measures}${how})`, ); + report(svgVerdict({ state, ...judged })); } // Ends a job's IDE and what it served. @@ -13560,6 +13688,7 @@ async function runJob(job) { const setup = SETUPS[job.setup]; const started = Date.now(); say(job.name, `starting (${job.shots.length} pictures)`); + event("job", { job: job.name, state: "started" }); const run = { name: job.name, step: "start", @@ -13601,18 +13730,22 @@ async function runJob(job) { // does not change comes out identical to it rather than a capture's noise away. // A `once` shot, a picture the IDE's theme has no part in (a running program's window), is // taken in the dark pass only; a job of them has no light pass. + // A run stopped (--events) takes no further picture, and switches no theme. const darks = new Map(); const prepared = await setupState(run.c, ctx); - for (const theme of THEMES) { - const shots = theme === "dark" ? job.shots : job.shots.filter((s) => !s.once); + for (const [theme, shots] of passes(job)) { if (!shots.length) continue; - if (theme !== "dark") { + if (theme !== "dark" && !stopping) { await backToPrepared(run.c, ctx, prepared); await ensureTheme(run.c, theme); say(job.name, `the IDE in its ${THEME_NAME[theme]} theme`); } for (const shot of shots) { const out = themedOut(shot.out, theme); + if (stopping) { + notTaken(job.name, out, theme); + continue; + } const file = path.join(outRoot, out); const darkFile = path.join(outRoot, shot.out); const dark = @@ -13627,16 +13760,27 @@ async function runJob(job) { const taken = await shot.take(ctx); const png = Buffer.isBuffer(taken) ? taken : taken.png; if (theme === "dark") darks.set(shot.out, png); - const kept = keep(job.name, shot.out, png, theme, dark, Buffer.isBuffer(taken) ? SCALE : taken.scale); + const { kept, holds } = keep( + job.name, + shot.out, + png, + theme, + dark, + Buffer.isBuffer(taken) ? SCALE : taken.scale, + ); if (shot.once) dropLight(job.name, shot.out); // A light picture that is the dark one is not kept, and neither is its SVG or bundle. - // One put back as the revision has it has had its SVG put back with it. - if (values.svg && kept !== null && kept !== "restored") await keepSvg(job.name, out, png, state.svg, kept); - else if (values.svg && kept === null) dropPicture(outRoot, out, bundlesRoot); + // One put back as the revision has it has had its SVG put back with it. With --stage + // the verdict "dark" says so, and nothing is removed. + if (values.svg && kept !== null && kept !== "restored") + await keepSvg(job.name, out, png, state.svg, kept, holds); + else if (values.svg && kept === null && !stageRoot) dropPicture(outRoot, out, bundlesRoot); } catch (e) { complain(job.name, `${out}: FAILED: ${e.message}`); count(theme, "failed"); failed = 1; + if (stageRoot) writeVerdict(stageRoot, out, "png", { state: "failed", error: e.message }); + event("failed", { job: job.name, out, error: e.message }); } finally { state.reference = null; state.prefer = null; @@ -13654,6 +13798,7 @@ async function runJob(job) { (run.step === "open" && / does not compile\n/.test(e.message)) || run.step === "shoot"; failed = Math.max(failed, own ? 1 : 2); + event("job", { job: job.name, state: "failed", error: e.message }); } await endRun(run); // what a setup checks or gives back once its IDE has ended @@ -13665,14 +13810,22 @@ async function runJob(job) { failed = Math.max(failed, 1); } } - say(job.name, `done in ${((Date.now() - started) / 1000).toFixed(1)} s`); + const seconds = (Date.now() - started) / 1000; + say(job.name, `done in ${seconds.toFixed(1)} s`); + event("job", { job: job.name, state: "done", seconds }); } -// --jobs IDEs at a time, each taking the next of the queue when its job ends. +// --jobs IDEs at a time, each taking the next of the queue when its job ends. Once the run is +// stopped, a job not started starts no IDE, and its pictures are not taken. { const queue = [...jobs]; const worker = async () => { - for (let job = queue.shift(); job; job = queue.shift()) await runJob(job); + for (let job = queue.shift(); job; job = queue.shift()) { + if (!stopping) await runJob(job); + else + for (const [theme, shots] of passes(job)) + for (const s of shots) notTaken(job.name, themedOut(s.out, theme), theme); + } }; await Promise.all(Array.from({ length: Math.min(jobCount, jobs.length) }, worker)); } @@ -13712,6 +13865,15 @@ try { } catch (e) { problems.push(`${PACKAGE_ROOT} could not be removed (${e.code})`); } +const exitCode = problems.length ? 3 : failed; +event("summary", { + tally, + svg: { written: svgResults.filter((r) => r.faithful).length, left: svgResults.filter((r) => !r.faithful).length }, + seconds: (Date.now() - t0) / 1000, + stopped: stopping, + problems, + exit: exitCode, +}); if (problems.length) { console.error(problems.join("\n")); process.exit(3); diff --git a/scripts/svgshot/keep.mjs b/scripts/svgshot/keep.mjs index 43a9470e7..afd3e0213 100644 --- a/scripts/svgshot/keep.mjs +++ b/scripts/svgshot/keep.mjs @@ -19,10 +19,10 @@ export const holdsUserName = (svg, user) => /** * `svg` against the PNG it was taken with, in a page of `browser`: - * {diff, faithful, by, measures}, `diff` diffSvg's (its strength map among it), `faithful` - * and `by` decideSvg's verdict, with the - * picture's `entry` in the manifest (or null), and `measures` the line that says how far - * off it is and what the converter did not draw (`stats.unsupported`). + * {diff, verdict, faithful, by, measures}, `diff` diffSvg's (its strength map among it), + * `verdict` decideSvg's, with the picture's `entry` in the manifest (or null), `faithful` + * and `by` from it, and `measures` the line that says how far off it is and what the + * converter did not draw (`stats.unsupported`). */ export async function judgeSvg(browser, svg, png, stats, entry = null) { const page = await browser.newPage(); @@ -34,7 +34,8 @@ export async function judgeSvg(browser, svg, png, stats, entry = null) { await page.close(); } const unsupported = Object.entries(stats.unsupported).map(([k, n]) => `${k} x${n}`); - const { faithful, by, share } = decideSvg(diff.strength, { entry, svg, png }); + const verdict = decideSvg(diff.strength, { entry, svg, png }); + const { faithful, by, share } = verdict; // A picture with a bar of its own says so, with the share at that bar's levels. const rule = svgRule(entry); const left = rule.rects.length ? `, ${rule.rects.length} rectangle${rule.rects.length > 1 ? "s" : ""} left out` : ""; @@ -45,5 +46,5 @@ export async function judgeSvg(browser, svg, png, stats, entry = null) { const measures = `${diff.stats.pct32}% of pixels differ, ${diff.stats.pct96}% strongly${own}` + `${unsupported.length ? `; not drawn: ${unsupported.join(", ")}` : ""}`; - return { diff, faithful, by, measures }; + return { diff, verdict, faithful, by, measures }; } diff --git a/scripts/svgshot/redraw.mjs b/scripts/svgshot/redraw.mjs index f06020757..c7c32bfa6 100644 --- a/scripts/svgshot/redraw.mjs +++ b/scripts/svgshot/redraw.mjs @@ -13,7 +13,7 @@ import { availableParallelism } from "node:os"; import path from "node:path"; import { isOutputTree } from "../../lib/markdown-files.mjs"; import { LAUNCH_ARGS, launchBrowser } from "../lib/browser.mjs"; -import { readRecords, writeRecord } from "../lib/record-lines.mjs"; +import { readRecords, sendStop, writeRecord } from "../lib/record-lines.mjs"; import { BUNDLE_VERSION, pngHash, readBundle, renderBundle } from "./bundle.mjs"; import { SNAPSHOT_STYLES } from "./capture.mjs"; import { holdsUserName } from "./keep.mjs"; @@ -150,20 +150,22 @@ export const shareOf = (items, shard) => items.filter((_, i) => i % shard.of === * own errors. Without the GPU: the browsers of every process share one, which held a * replay to the speed of four processes; drawn in software, eight go faster. An SVG's * bytes do not depend on it, only how close its render comes to the PNG, which the - * bar judges. + * bar judges. Once `stopped()` is true no further item is started. Resolves to the items + * not started. */ -export async function inBrowser(items, fn) { +export async function inBrowser(items, fn, { stopped = () => false } = {}) { const browser = await launchBrowser({ args: [...LAUNCH_ARGS, "--disable-gpu"] }); + let next = 0; try { - let next = 0; await Promise.all( Array.from({ length: Math.min(2, items.length) }, async () => { - while (next < items.length) await fn(items[next++], browser); + while (next < items.length && !stopped()) await fn(items[next++], browser); }), ); } finally { await browser.close(); } + return items.slice(next); } /** Ends a child's output with what it found, on a line of its own that runShards reads. */ @@ -174,9 +176,10 @@ export const reportShard = (result) => writeRecord(RESULT, result); * the variable `envName`, since drawing a picture is JavaScript that one process runs * one at a time. Their output is passed on, line by line, but for the line each ends * with (reportShard). Resolves to [{result, code}] in the children's order, `result` - * null for a child that reported nothing. + * null for a child that reported nothing. When `signal` (an AbortSignal) aborts, each + * child is sent the stop line on its stdin (lib/record-lines.mjs's sendStop). */ -export function runShards(envName, jobs) { +export function runShards(envName, jobs, { signal = null } = {}) { return Promise.all( Array.from( { length: jobs }, @@ -184,8 +187,14 @@ export function runShards(envName, jobs) { new Promise((resolve) => { const child = spawn(process.execPath, process.argv.slice(1), { env: { ...process.env, [envName]: `${k}/${jobs}` }, - stdio: ["ignore", "pipe", "inherit"], + stdio: [signal ? "pipe" : "ignore", "pipe", "inherit"], }); + if (signal) { + const stop = () => sendStop(child); + if (signal.aborted) stop(); + else signal.addEventListener("abort", stop, { once: true }); + child.on("close", () => signal.removeEventListener("abort", stop)); + } let result = null; readRecords(child.stdout, [RESULT], { onRecord: (_, value) => { diff --git a/scripts/svgshot/replay.mjs b/scripts/svgshot/replay.mjs index 00428e870..228c34b5c 100644 --- a/scripts/svgshot/replay.mjs +++ b/scripts/svgshot/replay.mjs @@ -13,16 +13,27 @@ // has no answer for (a miss the capture did not have). The list ends with the // shoot_docs.mjs command that takes them again. -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync } from "node:fs"; import { userInfo } from "node:os"; import path from "node:path"; -import { exitOnCrash, numberOption, parseCli, printHelpAndExit, regexOption, withUsageError } from "../../lib/cli.mjs"; +import { + exitOnCrash, + numberOption, + parseCli, + printHelpAndExit, + refuseTogether, + regexOption, + withUsageError, +} from "../../lib/cli.mjs"; import { REPO_ROOT } from "../../lib/repo-paths.mjs"; import { listBundles } from "./bundle.mjs"; import { useSubsetCache } from "./fonts.mjs"; import { applySvg, svgOf } from "../lib/shot-apply.mjs"; import { MANIFEST, openManifest } from "../lib/shot-manifest.mjs"; import { SVG_FAITHFUL } from "../lib/shot-policy.mjs"; +import { reviewSvg } from "../lib/shot-queue.mjs"; +import { stageFolders, stageRefusal, stageSvg, svgVerdict, writeVerdict } from "../lib/shot-stage.mjs"; +import { onStop, writeRecord } from "../lib/record-lines.mjs"; import { judgeSvg } from "./keep.mjs"; import { defaultJobs, @@ -38,7 +49,7 @@ import { exitOnCrash(); -const USAGE = `usage: node scripts/svgshot/replay.mjs [--only ] [--bundles ] [--out ] [--diffs ] [--jobs N] [-h, --help] +const USAGE = `usage: node scripts/svgshot/replay.mjs [--only ] [--bundles ] [--out ] [--diffs | --stage ] [--events] [--recheck] [--jobs N] [-h, --help] Draws each picture's SVG again from the bundle shoot_docs.mjs --svg saved when it took the picture, with the converter as it is now, and writes it beside its PNG @@ -64,12 +75,23 @@ command that takes them again. --diffs write each SVG's difference map there, as .svg-diff.png --recheck compare every SVG with its PNG, also one that is the same bytes as the file (which the bar passed when it was written) + --stage write nothing under the output folder: put each SVG drawn + (faithful or not), its difference map, its strength map and + what was decided about it into instead, for the + screenshot review tool to apply (scripts/lib/shot-stage.mjs). + must be new or empty, and neither under docs nor where + git would take it. Not with --diffs, which it holds + --events also write a line per event on stdout, @@shot-event {json} + (the run, each SVG's verdict, a retake, a failure, the + summary), among the lines meant for a person; and stop when a + line "stop" arrives on stdin: no further SVG is drawn --jobs N processes drawing at once, each with a browser of its own (default: half the logical processors, at most 8) -h, --help print this text and exit Exit codes: - 0 every picture was drawn again: written, unchanged, or left as a PNG + 0 every picture was drawn again: written, unchanged, or left as a PNG; a run + stopped (--events) exits as for the pictures it drew 1 a picture needs a retake, could not be drawn, or its SVG holds the Windows user name 2 the tool could not run: a refused command line, no bundle cache, no browser, @@ -83,6 +105,8 @@ const { values } = withUsageError(() => out: { type: "string" }, diffs: { type: "string" }, recheck: { type: "boolean", default: false }, + stage: { type: "string" }, + events: { type: "boolean", default: false }, jobs: { type: "string" }, help: { type: "boolean", short: "h", default: false }, }, @@ -90,6 +114,7 @@ const { values } = withUsageError(() => }), ); if (values.help) printHelpAndExit(USAGE); +withUsageError(() => refuseTogether(values, ["stage", "diffs"])); const only = values.only === undefined ? null : withUsageError(() => regexOption(values.only, { option: "--only" })); const jobs = withUsageError(() => numberOption(values.jobs ?? String(defaultJobs()), { @@ -101,7 +126,30 @@ const jobs = withUsageError(() => ); const bundlesRoot = path.resolve(values.bundles ?? path.join(REPO_ROOT, ".svgshot-bundles")); const outRoot = path.resolve(values.out ?? path.join(REPO_ROOT, "docs")); -const diffsRoot = values.diffs === undefined ? null : path.resolve(values.diffs); +// A child process the parent started (below) has its share of the pictures in this +// variable, "k/n" (redraw.mjs runShards). +const SHARD_ENV = "SVGSHOT_REPLAY_SHARD"; +const SHARD = shardOf(SHARD_ENV); +// With --stage, the run's stage (lib/shot-stage.mjs), which holds the difference maps: nothing +// is written under the output folder. The parent checks it and makes it; its children share it. +const stageRoot = values.stage === undefined ? null : path.resolve(values.stage); +if (stageRoot && !SHARD) { + const refused = stageRefusal(stageRoot, [path.join(REPO_ROOT, "docs"), outRoot]); + if (refused) { + console.error(`replay: --stage: ${refused}`); + process.exit(2); + } + mkdirSync(stageRoot, { recursive: true }); +} +const diffsRoot = stageRoot + ? stageFolders(stageRoot).diffs + : values.diffs === undefined + ? null + : path.resolve(values.diffs); +// --events: a record per event on stdout (lib/record-lines.mjs); a child's pass through its parent. +const event = (type, value = {}) => { + if (values.events) writeRecord("shot-event", { type, ...value }); +}; // The manifest's entries are for the pictures under docs/: another folder is judged without them. let manifest = null; if (outRoot === path.resolve(REPO_ROOT, "docs")) { @@ -120,11 +168,6 @@ const USER = userInfo().username; // The fonts' cuts are kept beside the bundles: a picture drawn again mostly cuts what it did. useSubsetCache(path.join(bundlesRoot, "subsets")); -// A child process the parent started (below) has its share of the pictures in this -// variable, "k/n" (redraw.mjs runShards). -const SHARD_ENV = "SVGSHOT_REPLAY_SHARD"; -const SHARD = shardOf(SHARD_ENV); - // Every picture with a bundle under the cache, as its path under docs with forward slashes. const outs = listBundles(bundlesRoot).filter((out) => !only || only.test(out)); @@ -132,6 +175,11 @@ const counts = {}; const retakes = []; let failed = 0; const say = (out, line) => console.log(`${out.replace(/\.png$/i, ".svg")}: ${line}`); +// The verdict on a picture's SVG, as --stage keeps it and --events says it (lib/shot-stage.mjs). +const report = (out, part) => { + if (stageRoot) writeVerdict(stageRoot, out, "svg", part); + event("svg", { out, ...part }); +}; async function replay(out, browser) { const svgFile = path.join(outRoot, svgOf(out)); const drawn = await redraw({ bundlesRoot, docsRoot: outRoot, out, user: USER }); @@ -140,6 +188,7 @@ async function replay(out, browser) { retakes.push({ out, why }); counts["need a retake"] = (counts["need a retake"] ?? 0) + 1; say(out, `left as it is, needs a retake (${why})`); + event("retake", { out, why }); return; } const { png, svg, stats } = drawn; @@ -155,35 +204,68 @@ async function replay(out, browser) { ) { say(out, "unchanged (the same SVG as the file)"); counts.unchanged = (counts.unchanged ?? 0) + 1; + report(out, svgVerdict({ state: "unchanged", why: "the same SVG as the file" })); return; } - const { diff, faithful, by, measures } = await judgeSvg(browser, svg, png, stats, entry); - if (diffsRoot) writeJudged(diffsRoot, out, diff); - const state = applySvg(outRoot, svgOf(out), faithful ? svg : null); + const { diff, verdict, faithful, by, measures } = await judgeSvg(browser, svg, png, stats, entry); + const diffs = diffsRoot ? Object.values(writeJudged(diffsRoot, out, diff)) : []; + const state = applySvg(outRoot, svgOf(out), faithful ? svg : null, { dryRun: !!stageRoot }); const how = by === "decision" ? `; ${faithful ? "accepted" : "refused"} by a review` : ""; say(out, faithful ? `${state} (${measures}${how})` : `${state}, the PNG is kept (${measures}${how})`); counts[state] = (counts[state] ?? 0) + 1; + if (!stageRoot && !values.events) return; + report( + out, + svgVerdict({ + state, + why: faithful ? null : measures, + verdict, + review: reviewSvg(verdict, diff.strength, entry), + stats: diff.stats, + measures, + diffs, + staged: stageRoot ? stageSvg(stageRoot, out, svg, diff.strength) : null, + }), + ); } +// --events: a line "stop" on stdin stops the run (lib/record-lines.mjs). The parent passes it on +// to its children, and each draws no further picture. +const stop = new AbortController(); +if (values.events) onStop(process.stdin, () => stop.abort()); + if (SHARD) { // A child: its share of the pictures, then what it found, on a line of its own. - await inBrowser(shareOf(outs, SHARD), async (out, browser) => { - try { - await replay(out, browser); - } catch (e) { - failed++; - counts.failed = (counts.failed ?? 0) + 1; - say(out, `FAILED: ${e.message}`); - } - }); + const left = await inBrowser( + shareOf(outs, SHARD), + async (out, browser) => { + try { + await replay(out, browser); + } catch (e) { + failed++; + counts.failed = (counts.failed ?? 0) + 1; + say(out, `FAILED: ${e.message}`); + event("failed", { out, error: e.message }); + } + }, + { stopped: () => stop.signal.aborted }, + ); + for (const out of left) event("skipped", { out }); + if (left.length) counts["not drawn: the run was stopped"] = left.length; reportShard({ counts, retakes, failed }); process.exit(0); } // The parent: `jobs` children, each with a browser of its own. -const children = (await runShards(SHARD_ENV, Math.min(jobs, outs.length))).map( - ({ result, code }) => result ?? { counts: { failed: 1 }, retakes: [], failed: code || 1 }, -); +event("run", { tool: "replay", pictures: outs, stage: stageRoot, recheck: values.recheck }); +if (values.events) + stop.signal.addEventListener("abort", () => { + console.log("stopping: no further SVG is drawn"); + event("stopping"); + }); +const children = ( + await runShards(SHARD_ENV, Math.min(jobs, outs.length), { signal: values.events ? stop.signal : null }) +).map(({ result, code }) => result ?? { counts: { failed: 1 }, retakes: [], failed: code || 1 }); for (const c of children) { for (const [k, n] of Object.entries(c.counts)) counts[k] = (counts[k] ?? 0) + n; retakes.push(...c.retakes); @@ -204,4 +286,6 @@ if (retakes.length) { const regex = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"); console.log(`to take them again: node scripts/shoot_docs.mjs --svg --only "${regex}"`); } -process.exit(failed || retakes.length ? 1 : 0); +const exitCode = failed || retakes.length ? 1 : 0; +event("summary", { counts, retakes, stopped: stop.signal.aborted, exit: exitCode }); +process.exit(exitCode); From 81949120f809153bf7a4f0afa7abac5cf478aed5 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Sat, 31 Oct 2026 21:57:12 +0100 Subject: [PATCH 19/85] shot-queue.mjs: faint counts the pixels FAINT_LEVEL (2) levels off or more (owner), since a capture's own noise is often a row of text one level off --- scripts/lib/shot-queue.mjs | 30 ++++++++++++++++++++++-------- test/svgshot.test.mjs | 8 ++++++++ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/scripts/lib/shot-queue.mjs b/scripts/lib/shot-queue.mjs index 0405d7fa9..9b2a2cea0 100644 --- a/scripts/lib/shot-queue.mjs +++ b/scripts/lib/shot-queue.mjs @@ -40,10 +40,20 @@ export const WORD_BLOCKS = 2; * build often shifts a colour by a few levels over a whole window, which withinNoise rightly * calls a change and which, counted beyond noise, leaves nothing and would read as noise (in * the retake on BETA 1010, 294 of the 361 pictures it changed at their old size; counted at - * any difference, 23). + * any difference, 23). With `level`, only the pixels that many levels off or more count + * (FAINT_LEVEL, for a capture kept as within noise). */ -export const pngRegions = (strength, rule) => - changeRegions(strength, { level: 1, close: QUEUE_CLOSE, erode: QUEUE_ERODE, rects: rule?.rects ?? [] }); +export const pngRegions = (strength, rule, level = 1) => + changeRegions(strength, { level, close: QUEUE_CLOSE, erode: QUEUE_ERODE, rects: rule?.rects ?? [] }); + +/** + * The fewest levels a pixel of a capture kept as within noise (noise, restored) must be off to + * count towards a faint change (owner, 2026-10-31). A capture's own noise is often a whole row + * of text one level off: Menu_File_Project.light, kept as noise on BETA 1034, had 1,258 pixels + * (0.49%) off, every one by exactly 1 level, and its menu text survived the erosion with cores + * of 37 to 96 pixels. A tint of 2 to 16 levels still counts. + */ +export const FAINT_LEVEL = 2; /** * The regions where an SVG is off its PNG, from diffSvg's strength map: the pixels at the @@ -60,10 +70,14 @@ export const surviving = (regions) => regions.filter((r) => r.core >= SURVIVES); // Whether the surviving regions are a small change: a block or two the size of a word. const small = (kept) => kept.length <= WORD_BLOCKS && kept.every((r) => r.core <= WORD_CORE); +/** Whether decidePicture's `state` keeps the old picture as within noise of the capture. */ +export const keptAsNoise = (state) => state === "noise" || state === "restored"; + /** * Why a picture's PNG needs a look, or null: the first argument is decidePicture's verdict * ({state, by, earlier}), `regions` pngRegions' for the capture against the picture it was - * judged against: for `noise` and `restored`, the one it was found within noise of. Null + * judged against: for `noise` and `restored`, the one it was found within noise of, at + * FAINT_LEVEL (keptAsNoise; reviewPicture passes the level). Null * `regions`, for a capture with nothing to measure it against (a new picture, or one of * another size), needs a look only for an earlier decision. * Returns {why, text}, `why` one of: @@ -80,7 +94,7 @@ export function pngLook({ state, by, earlier }, regions) { if (by === "decision") return null; if (earlier) return { why: "earlier", text: `a review kept the ${earlier} picture of other bytes` }; if (!regions) return null; - if (state === "noise" || state === "restored") + if (keptAsNoise(state)) return surviving(regions).length ? { why: "faint", text: "kept as within noise, but a part of the change survives the erosion" } : null; @@ -114,8 +128,8 @@ export function svgLook({ faithful, by, earlier }, regions) { * What the review shows of a capture `png` (PNG bytes) that decidePicture judged (`verdict`): * its strength map and its regions of change against `reference`, the picture * `verdict.against` names (the revision's, the file or the dark picture, as PNG bytes; null - * for a new picture), with the picture's noise rule (pngRule of its `entry`), and why it - * needs a look (pngLook). Returns {strength, regions, look}: `strength` is null when there is + * for a new picture), with the picture's noise rule (pngRule of its `entry`), counted at + * FAINT_LEVEL for a capture kept as within noise, and why it needs a look (pngLook). Returns {strength, regions, look}: `strength` is null when there is * nothing to measure (no reference, one of another size, or the same bytes), and `regions` * null when the capture cannot be measured, [] when it is the reference. */ @@ -128,7 +142,7 @@ export function reviewPicture(verdict, png, reference, entry = null) { const B = decodePng(png); if (A.width === B.width && A.height === B.height) { strength = strengthOf(A, B); - regions = pngRegions(strength, pngRule(entry)); + regions = pngRegions(strength, pngRule(entry), keptAsNoise(verdict.state) ? FAINT_LEVEL : 1); } } return { strength, regions, look: pngLook(verdict, regions) }; diff --git a/test/svgshot.test.mjs b/test/svgshot.test.mjs index 952da051a..2ccc2d1ce 100644 --- a/test/svgshot.test.mjs +++ b/test/svgshot.test.mjs @@ -92,6 +92,7 @@ import { withinNoise, } from "../scripts/lib/shot-change.mjs"; import { + FAINT_LEVEL, pngLook, pngRegions, reviewPicture, @@ -2204,6 +2205,13 @@ describe("which pictures need a look (shot-queue.mjs)", () => { assert.equal(faint.strength.data.length, 60 * 40); assert.equal(faint.regions.length, 1); assert.equal(faint.look.why, "faint"); + // kept as within noise, a pixel counts from FAINT_LEVEL: a block one level off is a capture's noise + assert.equal(FAINT_LEVEL, 2); + const one = reviewPicture({ state: "restored", by: "rule", earlier: null }, image(1), base); + assert.deepEqual([one.regions, one.look], [[], null]); + assert.equal(reviewPicture({ state: "restored", by: "rule", earlier: null }, image(2), base).look.why, "faint"); + // a capture taken counts every pixel off: the same block one level off is a change, not noise + assert.equal(reviewPicture(taken, image(1), base).regions.length, 1); // the same bytes: nothing to measure, and no region assert.deepEqual(reviewPicture({ ...taken, state: "unchanged" }, base, base), { strength: null, From 5fc04cb3ce8c632ebb706496c05644048fde1c0d Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Sat, 31 Oct 2026 22:01:08 +0100 Subject: [PATCH 20/85] WIP.ShotReview.md: step 4 done, FAINT_LEVEL (owner), step 5 next; --stage and --events on Tools-IDE, the stage's tests on Tools-Toolchain-Tests, WIP.md's shoot_docs bullet --- WIP.ShotReview.md | 50 ++++++++++++++------- WIP.md | 4 +- docs/Documentation/Tools-IDE.md | 10 +++-- docs/Documentation/Tools-Toolchain-Tests.md | 3 +- 4 files changed, 44 insertions(+), 23 deletions(-) diff --git a/WIP.ShotReview.md b/WIP.ShotReview.md index a1771f1c3..2736da42f 100644 --- a/WIP.ShotReview.md +++ b/WIP.ShotReview.md @@ -2,7 +2,7 @@ A localhost tool for reviewing what `shoot_docs.mjs` and `replay.mjs` decided about each picture, overriding it, starting retakes and replays, and committing the result. **Design -agreed with the owner on 2026-10-31; steps 1 to 3 of [the order of work](#order-of-work) are +agreed with the owner on 2026-10-31; steps 1 to 4 of [the order of work](#order-of-work) are done (step 1 but for the commit helper, which step 8 makes), and the tool itself is not started.** The mockups it was agreed on were inline in that session and are not kept: the pages are described below instead. @@ -138,8 +138,11 @@ no core without it and ten regions with it). A region survives with a core of 16 pixel that differs at all, not only those beyond noise: a retake on a new build often shifts a colour by a few levels over a whole window, which counted beyond noise leaves nothing and reads as noise (294 of the 361 pictures the BETA 1010 retake changed at their old size; 23 at any -difference). An SVG's count the pixels at its bar's levels. A picture is in **Needs a look** -when: +difference). A capture kept as within noise counts the pixels 2 levels off or more instead +(`FAINT_LEVEL`, owner, 2026-10-31): its own noise is often a row of text one level off +(Menu_File_Project.light on BETA 1034: every one of its 1,258 differing pixels 1 level off, and +cores of 37 to 96 px surviving at level 1, none at 2). An SVG's count the pixels at its bar's +levels. A picture is in **Needs a look** when: - **PNG**: the tool took the new capture, but nothing survives (most likely noise: a caret, a clock, a fringe), or what survives is small (a block or two the size of a word: a real @@ -226,7 +229,9 @@ process: `shoot_docs.mjs` is a script with effects at the top level (the registr `exitOnCrash`, exit codes), and one code path for both is the point. Two options are added to `shoot_docs.mjs` and `replay.mjs` for it: -- **`--stage `**: write nothing under `docs/` and nothing to `.svgshot-bundles/`; write +- **`--stage `**: write nothing under `docs/`, nothing to the manifest and nothing to + `.svgshot-bundles/` but the fonts' cuts in its `subsets` (a cache, which depends on the font + and its characters alone); write each candidate into `` instead: the capture, its SVG (accepted or not), its bundle, its difference maps, its strength map and its verdict. **The tool is then the only writer under `docs/`**: it applies each verdict as it arrives, and each override, through the same code @@ -277,7 +282,7 @@ Each step ends tested and usable on its own. 2. **The manifest**: read and written by one module; `shoot_docs.mjs` and `replay.mjs` judge with its allowances, honour its decisions and record `build`. Done. 3. **The strength map, the regions and the queue's rules**, tested on fixtures. Done. -4. **`--stage`, `--events` and stop** in `shoot_docs.mjs` and `replay.mjs`. +4. **`--stage`, `--events` and stop** in `shoot_docs.mjs` and `replay.mjs`. Done. 5. **The server and the session store**, the overview and the detail page read-only, live. 6. **Decisions and allowances** applied through `shot-apply.mjs`, judged again at once. 7. **The runs page**: retake, replay, clean up, stop. @@ -289,7 +294,7 @@ Each step ends tested and usable on its own. ## Where the work stands -On `staging` since 2026-10-31, step 1 but for the commit helper (step 8), and steps 2 and 3. Each move kept the command-line tools' output as it was, checked as +On `staging` since 2026-10-31, step 1 but for the commit helper (step 8), and steps 2 to 4. Each move kept the command-line tools' output as it was, checked as [below](#checking-a-move). | Row of the factoring table | Where it is now | @@ -299,21 +304,34 @@ On `staging` since 2026-10-31, step 1 but for the commit helper (step 8), and st | An SVG's verdict | `decideSvg(strength, {entry, svg, png})` in `shot-policy.mjs`, with `SVG_FAITHFUL` and `svgRule(entry)` (`{levels, share, rects}`): `{faithful, by, share, earlier}`, `share` counted on the strength map at the bar's levels with its rectangles left out, `earlier` a review's decision about another SVG or PNG (`earlierSvgDecision`). `judgeSvg(browser, svg, png, stats, entry)` asks it | | How far an SVG is off | `diffSvg` returns `strength`, `{width, height, data}`, a byte per device pixel of the clip (the distance with the one-pixel shift allowed, in whole levels, so a level's count is the same as from the distance); `stats.pct8`, `pct32` and `pct96` are `shareOff` of it, the same to the third place (a replay of the 476 bundles printed the same lines before and after) | | Regions | `scripts/lib/shot-change.mjs`: `strengthOf(A, B)` (two captures' map, withinNoise's distance), `shareOff(strength, {level, rects})`, `changeRegions(strength, {level, close, erode, rects})` (`[{x0, y0, x1, y1, pixels, core}]`, most core first) and `regionMasks`, its three masks, for drawing them | -| The queue's rules | `scripts/lib/shot-queue.mjs`: `pngRegions(strength, pngRule)`, `svgRegions(strength, entry)`, `surviving`, `pngLook(verdict, regions)` and `svgLook(verdict, regions)`, each `{why, text}` or null; the constants `QUEUE_CLOSE`, `QUEUE_ERODE`, `SURVIVES`, `WORD_CORE`, `WORD_BLOCKS` | +| The queue's rules | `scripts/lib/shot-queue.mjs`: `pngRegions(strength, pngRule, level)`, `svgRegions(strength, entry)`, `surviving`, `keptAsNoise(state)`, `pngLook(verdict, regions)` and `svgLook(verdict, regions)`, each `{why, text}` or null (`why` for a PNG: `earlier`, `noise`, `small`, `faint`); `reviewPicture(verdict, png, reference, entry)` (`{strength, regions, look}`, measured against the picture `verdict.against` names: decidePicture and pictureFate return `against`, "revision", "disk", "dark" or null) and `reviewSvg(verdict, strength, entry)`; the constants `QUEUE_CLOSE`, `QUEUE_ERODE`, `SURVIVES`, `WORD_CORE`, `WORD_BLOCKS`, `FAINT_LEVEL` | +| A run's stage and events | `scripts/lib/shot-stage.mjs`: `stageFolders(dir)` (`pictures`, `bundles`, `diffs`, `maps`, `verdicts`), `stagedFiles(dir, out)`, `stageRefusal(dir, docsRoots)` (outputRefusal, and a stage must be new or empty), `stageCapture`, `stageSvg`, `writeStrength` / `readStrength` (8 bytes of size, then the map deflated), `writeVerdict(dir, out, part, value)` / `readVerdicts(dir)`, and the verdict shapes `pngVerdict` and `svgVerdict`, which the `picture` and `svg` events carry too (at most `STAGED_REGIONS`, 64, regions, and `regionCount`). `shoot_docs.mjs` and `replay.mjs` write nothing else with `--stage`: `applyPicture`, `applySvg`, `writeSvg`, `dropPicture` and `restorePictures` take `{dryRun}`, and say what would be done. Events: `run`, `job` (`started`, `done`, `failed`), `picture`, `svg`, `failed`, `skipped`, `retake` (replay), `stopping`, `summary`. Stop: `STOP_LINE`, `onStop(stream, fn)`, `sendStop(child)` in `record-lines.mjs`; `runShards(env, jobs, {signal})` passes it to each child, `inBrowser(items, fn, {stopped})` returns the items not started | | The manifest | `scripts/lib/shot-manifest.mjs`: `MANIFEST`, `openManifest(file)` (`entry`, `setBuild`, `clearBuild`, `save`, `text`), `parseManifest`, `manifestText`, `pngDecision`, `earlierPngDecision`, `svgDecision`, `hashOf`. `shoot_docs.mjs` passes each picture's rectangles to `capture` as `c.shot.ignore`, left out as its unsteady regions are, and records `build` (`buildNumber(ide)`) in `keep` and `dropLight` | | Writing a verdict | `scripts/lib/shot-apply.mjs`: `applyPicture(outRoot, out, decision, revision)`, `applySvg(outRoot, svgOut, svg or null)`, `restorePictures(outRoot, revision, outs, {dryRun})` (the PNGs and their SVGs, the SVGs by one `git restore`; `--cleanup` and a `restored` picture both use it), `dropPicture(outRoot, out, bundlesRoot)`, `writeSvg`, `svgOf`. What stays in `shoot_docs.mjs`'s `keep` and `keepSvg`: the counts, the lines it prints, the `--diffs` pictures, the bundle written, the user-name check, and the rule that an SVG stays as it is when its PNG was not written | | The git listing | `changedPictures`, `parseNameStatus`, `parseUntracked` in `scripts/lib/shot-revision.mjs` | -| A child's report | `scripts/lib/record-lines.mjs`: `recordLine`, `writeRecord`, `parseRecord`, `readRecords(stream, kinds, {onRecord, onLine})`; `runShards` reads `svgshot-result` records with it. `--events` is to write `shot-event` records the same way | +| A child's report | `scripts/lib/record-lines.mjs`: `recordLine`, `writeRecord`, `parseRecord`, `readRecords(stream, kinds, {onRecord, onLine})`; `runShards` reads `svgshot-result` records with it, and `--events` writes `shot-event` records the same way (a shard's pass through its parent as lines) | | The sheets' HTML | the pieces in `scripts/lib/shot-views.mjs`: `pageHtml({generator, title, intro, summary, contents, body})`, `cardHtml({out, meta, meter, figures})`, `figuresIn(folder)` (`href`, `figure`, `thumb`), `meterHtml`, `tocItem`, `esc`, `anchor`, `sizeText`, `pctText`. The two pages, their rows, their order, their commands and the output-folder rules (`mayWriteSheet`, `SHEET_GENERATOR`) are in `scripts/svgshot/sheets.mjs`, and `outputRefusal`, which the stage shares, in `shot-revision.mjs`; `redraw.mjs` keeps drawing and the shards | | Server-sent events | `createSseChannel()` in `lib/sse.mjs`: `handle(req, res)`, `send(event, data)`, `close()`; `builder/serve.mjs` uses one | | The installs | `listIdes()` in `scripts/lib/tb-install.mjs`, newest first, `[{build, exe}]` | -**Next: step 4**, `--stage`, `--events` and stop in `shoot_docs.mjs` and `replay.mjs`. A staged -candidate's verdict carries its look (`pngLook`, `svgLook`) and its strength map, which the -PNG's needs from `strengthOf` of the capture and the picture it was judged against (the -revision's, else the file); `--events` writes `shot-event` records with -`scripts/lib/record-lines.mjs`. The tool's pages will add their actions to the pieces in -`shot-views.mjs` (step 5); until then only `sheets.mjs` calls them. +**Next: step 5**, the server and the session store, with the overview and the detail page +read-only and live. A run is a child process with `--stage /runs/` and `--events`; +the tool reads its records with `readRecords`, passes them to the pages through +`createSseChannel`, and after a restart rebuilds a run's pictures from `readVerdicts` and +`readStrength`. Still to write for step 6, where the tool applies a verdict: moving a staged +bundle into the cache (`readBundle` from the stage, `writeBundle` into `.svgshot-bundles`), and +the "dark" verdict's two calls (`applyPicture`, then `dropPicture` for its SVG and bundle) as +one function in `shot-apply.mjs`, so that `shoot_docs.mjs` and the tool make the same calls. The +tool's pages will add their actions to the pieces in `shot-views.mjs`; until then only +`sheets.mjs` calls them. + +Step 4 was checked with the runs [below](#checking-a-move), staged and not, which printed the +same lines, the staged ones writing nothing under `docs/` (`git status` clean) and their +verdicts saying the same (the restore case: `restored`, its SVG `keep` as HEAD has it, the light +SVG `new`); a staged `replay.mjs --recheck` of six pictures; and a stop sent after a few seconds +to `replay.mjs` over the 476 bundles (8 drawn, 467 `skipped`, its four children each stopped) and +to `shoot_docs.mjs --jobs 1` (one job finished, the two not started skipped, the registry put +back, exit 0). Step 3 was checked with a replay of every bundle (`--recheck`), whose lines, sorted, were the same before and after, and with `svg.ignore` on Properties_ColourPicker.light (a rectangle over @@ -323,8 +341,8 @@ retaken PNGs (the changed pixels, what the closing adds, the core, the boxes), a over every SVG and the PNGs of the BETA 1010 and 1033 retakes. The PNG rule for a capture kept as within noise (owner, 2026-10-31; [the queue](#the-queue)) -is `pngLook`'s `faint`: for `noise` and `restored`, when a region survives. Its regions are -the caller's to give, from the capture against the picture it was found within noise of. +is `pngLook`'s `faint`: for `noise` and `restored`, when a region survives at `FAINT_LEVEL`. +`reviewPicture` measures it against the picture it was found within noise of. Step 2 was checked with the runs [below](#checking-a-move), whose states it left as they were with an empty manifest, and with an allowance and a decision on `Menu_Tools_IDEOptions`: the diff --git a/WIP.md b/WIP.md index 035027186..7ed423a72 100644 --- a/WIP.md +++ b/WIP.md @@ -585,7 +585,7 @@ Why the report separates the wedged task from the merely blocked ones, and why - `addin-test.bat` — tests IDE add-ins by operating an IDE: every lane in `test/addin/lanes.mjs` builds the add-ins it tests into a private copy of the install, opens a project and checks what the add-in does. Outside every gate and outside CI for the same reasons as `examples.bat`; the seventeen lanes: Samples 10 and 15, the thirteen probe lanes behind Stage 2's answers in [WIP.HelpAddin.md](WIP.HelpAddin.md), and the help add-in in `add-in/` twice, built with its committed copy of the symbol index, `add-in/Resources/SYMBOLS/symbols.json`, which a local build rewrites whenever the index changes. The `help` lane serves the built `docs/_site` on localhost to the add-in's pane; `help-offline` builds the add-in with a help archive of `docs/_site-offline` and the pane's pages come from the add-in's own server. So run `build.bat` first. Exit 0 every lane passed and the registry is as it was found, 1 a lane failed, 2 the harness failed, 3 the registry or a work folder was not put back. See [Driving the twinBASIC compiler](#driving-the-twinbasic-compiler) for its rules. - `ide-test.bat` --- the same runner for scenarios that operate the IDE itself rather than an add-in: every lane in `test/ide/lanes.mjs`. Same exit codes, same standing outside every gate and outside CI, same rules. - `try-help-addin.bat` --- opens one IDE on the user's desktop with the help add-in built and loaded, to try it by hand, and puts the registry back once it is closed (see [Driving the twinBASIC compiler](#driving-the-twinbasic-compiler)). Needs `build.bat` first. Exit 0 closed and put back, 1 the add-in or the project did not compile, 2 the tool could not run, 3 not put back. -- `node scripts/shoot_docs.mjs` --- writes the documentation's IDE screenshots (plan and owner's decisions: [WIP.Screenshots.md](WIP.Screenshots.md)). Each picture is a shot in a table, `out` its path under `docs/` (`--only` matches it), in a setup (the main three below; the full list is in WIP.Screenshots.md), each one IDE on a **private** desktop started only when a shot in it is selected: `help` (builds the add-in as `try-help-addin.bat` does, opens `test/addin/helpdemo`, `TB_DOCS_HELP_REAL_TITLEBAR=0`: the eight `docs/IDE/AddIns/Images/Help_.png`), `no-project` (`Lane.openNoProject` with `includeProjects`: the menus, cut out with alpha, the dialogs that need no project, and the empty panels, each floated on its own, after the dialogs) and `project` (the demo without the add-in: About, which needs the compiler's licence answer, and IDE Options, whose font lists need a project). One setup is not an IDE: `web` starts puppeteer's Chrome through `launchOnDesktop` on a private desktop of its own (never headless, never on the user's desktop; native-window occlusion and occluded-window backgrounding switched off, or a capture waits forever or comes back blank) for the two public pages the FAQ shows, taken once; it needs the network, as do `webpage` (the Webpage pane on `https://docs.twinbasic.com/`, its page captured from the pane's own DevTools target and pasted into the pane's place) and the package-server setups, which never publish. Every IDE gets `--force-device-scale-factor=1` (`Lane`'s `browserArgs`), else the page follows the display's scaling; the page is overridden to 1280x880 at 2x, and `PAGE_DEFAULTS` puts the IDE's options, panel layouts and keyboard groups to their defaults and the recent lists to the tool's own **in the page only**, nothing written because the IDE has `IDE_FLAGS` (the one recent-list call they do not guard, `RemoveRecentsList`, is replaced first). Each picture is a `Page.captureScreenshot` with a `clip`; a page showing it gives it `{:width height}` of half its pixel size. **Every picture is taken in two themes by one IDE**: dark as `X.png`, then, after `backToPrepared` and `tbTheme_SwitchToLightMode`, Light (not Classic) as `X.light.png` beside it, which the build pairs by name ([WIP.Build.md](WIP.Build.md#pictures-in-two-themes)); a light picture with the dark one's pixels is not kept, and one more than 32 device pixels off the dark one's size fails (a wrong state, not the theme). A label's pill follows the theme unless the shot names a `tone`. A shot's `annotate` list (arrows, rings, badges...) is drawn by `scripts/lib/shot-annotate.mjs` as one SVG layer over the page before the capture and removed after it, the clip growing to hold it. A picture is rewritten only when its bytes differ, and a second run reports every one `unchanged`; a capture is not byte-stable, so `capture` captures back to back, with no rest, until one equals the file on disk or two agree, and, when the two that agree are not the file, goes on capturing further apart, for 5.5 s at most, until one is, keeping the last two that agreed, and a picture that differs from the file only by a capture's noise (16 grey levels, 0.5% of its pixels) is left as it is. **The reference is the picture at git `HEAD` first, then the file** (owner): compared with the file alone, a picture drifted from `HEAD` over the runs of one retake, each step within noise of the file before it. A capture equal to or within noise of `HEAD`'s picture leaves the file as `HEAD` has it, its SVG too (`HEAD`'s, or none), reported `restored` when it held anything else; one that is not, but is within noise of the file, leaves the file (an earlier run's real change); a picture `HEAD` lacks is compared with the file only (`pictureFate`, `scripts/lib/shot-revision.mjs`; the run's pictures at `HEAD` read in one `git cat-file --batch`). `--against ` takes another revision, `--against-disk` the file only. **`--cleanup`** (`--dry-run` to list) starts no IDE: it resets every PNG git reports modified that is within noise of `HEAD`'s, with its SVG, so that only real changes stay; one noise rule, `withinNoise` in `scripts/lib/shot-change.mjs`, for the capture, `--cleanup` and `sheet.mjs --against`. **The manifest, `test/shots/manifest.json`** (`scripts/lib/shot-manifest.mjs`; [WIP.ShotReview.md](WIP.ShotReview.md#remembered-overrides)), records the build of each picture a run writes, and holds each picture's allowances (its noise rule's limits and rectangles left out, its SVG's bar) and a review's decisions, each tied to the bytes it was made on; `decidePicture` and `decideSvg` (`scripts/lib/shot-policy.mjs`) judge with them, in a run, in `--cleanup` and in `replay.mjs`, so a run from a terminal and one from the review tool come out the same. It is read and written only for the default `--out`. `--jobs N` (default 6) runs the setups at once, each in a slot of its own from `tbbroker`, a full run with `--svg` in about 12 minutes on BETA 1010 (`--jobs 1`: one at a time); `--diffs ` writes the old picture, the new one and an amplified difference map for each that changed, named with its theme. Settle by a condition, never a fixed sleep, and a picture that shows some state sets it itself rather than relying on the shots before it. The page's animations and carets are switched off through a style sheet put into the document and every shadow root; a picture that holds DEBUG CONSOLE lines has their times (and a CodeLens run's time taken) set in the page once the real ones show. **A running program's window** (setup `programs`, fixture `test/shots/programs`) is taken **once** (`once: true`, the dark pass only) with `captureWindow`, cropped to its visible frame, at the display's DPI, shown at its size at 96 DPI. **The Windows user name must never be in a published picture**: the visible text of the page, text boxes included, is searched for it before the first capture and again when a capture is chosen, with the picture's SVG under `--svg` (a page can draw again in between: Project Settings puts a real path back over the one the shot set), and for a program's window the texts of the window and its children, and the run fails when it is there. The tool finds each name it presses F1 on by searching the demo's source, not by line. The add-in's detached window has a port from the broker (the help setup's slot carries one), since the add-in is told it before it binds it. **`--svg`** also draws each picture as SVG from the page as it stands at the capture (`scripts/svgshot/`: a DOM snapshot converted, its text set in per-picture subsets of the fonts the IDE drew it with, cut by `scripts/subset_font.py`, which needs Python with fontTools), compares it with the PNG, and writes `X.svg` or `X.light.svg` beside it only when no more than 0.25% of its pixels are far off; otherwise an older SVG is removed and the page shows the PNG; the SVG of a picture whose PNG is not rewritten stays as it is, with its bundle (owner) ([WIP.Build.md](WIP.Build.md#pictures-as-svg)). With `--svg` each picture's *bundle*, what its SVG is drawn from, is saved in the gitignored `.svgshot-bundles/` (`--bundles`; never under `docs/`, a bundle holds the user name), and **after a change to the converter, `node scripts/svgshot/replay.mjs` draws every SVG again from the bundles, with no IDE**, in about 20 s for the 370 (an SVG the same as its file is not compared again, `--recheck` compares all; the fonts' cuts are kept in `.svgshot-bundles/subsets/`; one browser per process, GPU off); it lists the pictures whose bundle cannot answer what the converter now asks, with the `--only` command that retakes them ([WIP.Screenshots.md](WIP.Screenshots.md#pictures-as-svg)). `node scripts/svgshot/sheet.mjs --out ` writes a page of the pictures with no SVG (`--all`: every picture), each drawn again from its bundle beside its PNG and difference map, worst first; it is drawn without the user-name check (owner: the sheet is the owner's alone), so `--out` is refused under `docs/` and anywhere git does not ignore; what it and replay share is `scripts/svgshot/redraw.mjs`, and its pages are `scripts/svgshot/sheets.mjs`, made of the pieces in `scripts/lib/shot-views.mjs`. `node scripts/svgshot/bench.mjs` tests the converter's replicas of what Chromium draws outside the DOM (scrollbars, drop-down lists) in Edge, with no IDE ([WIP.Screenshots.md](WIP.Screenshots.md#pictures-as-svg)). No wrapper; outside every gate and outside CI, as `try-help-addin.bat` is. Exit 0 every picture written or unchanged, 1 a picture failed, 2 the tool could not run, 3 not put back. Run `build.bat` first. +- `node scripts/shoot_docs.mjs` --- writes the documentation's IDE screenshots (plan and owner's decisions: [WIP.Screenshots.md](WIP.Screenshots.md)). Each picture is a shot in a table, `out` its path under `docs/` (`--only` matches it), in a setup (the main three below; the full list is in WIP.Screenshots.md), each one IDE on a **private** desktop started only when a shot in it is selected: `help` (builds the add-in as `try-help-addin.bat` does, opens `test/addin/helpdemo`, `TB_DOCS_HELP_REAL_TITLEBAR=0`: the eight `docs/IDE/AddIns/Images/Help_.png`), `no-project` (`Lane.openNoProject` with `includeProjects`: the menus, cut out with alpha, the dialogs that need no project, and the empty panels, each floated on its own, after the dialogs) and `project` (the demo without the add-in: About, which needs the compiler's licence answer, and IDE Options, whose font lists need a project). One setup is not an IDE: `web` starts puppeteer's Chrome through `launchOnDesktop` on a private desktop of its own (never headless, never on the user's desktop; native-window occlusion and occluded-window backgrounding switched off, or a capture waits forever or comes back blank) for the two public pages the FAQ shows, taken once; it needs the network, as do `webpage` (the Webpage pane on `https://docs.twinbasic.com/`, its page captured from the pane's own DevTools target and pasted into the pane's place) and the package-server setups, which never publish. Every IDE gets `--force-device-scale-factor=1` (`Lane`'s `browserArgs`), else the page follows the display's scaling; the page is overridden to 1280x880 at 2x, and `PAGE_DEFAULTS` puts the IDE's options, panel layouts and keyboard groups to their defaults and the recent lists to the tool's own **in the page only**, nothing written because the IDE has `IDE_FLAGS` (the one recent-list call they do not guard, `RemoveRecentsList`, is replaced first). Each picture is a `Page.captureScreenshot` with a `clip`; a page showing it gives it `{:width height}` of half its pixel size. **Every picture is taken in two themes by one IDE**: dark as `X.png`, then, after `backToPrepared` and `tbTheme_SwitchToLightMode`, Light (not Classic) as `X.light.png` beside it, which the build pairs by name ([WIP.Build.md](WIP.Build.md#pictures-in-two-themes)); a light picture with the dark one's pixels is not kept, and one more than 32 device pixels off the dark one's size fails (a wrong state, not the theme). A label's pill follows the theme unless the shot names a `tone`. A shot's `annotate` list (arrows, rings, badges...) is drawn by `scripts/lib/shot-annotate.mjs` as one SVG layer over the page before the capture and removed after it, the clip growing to hold it. A picture is rewritten only when its bytes differ, and a second run reports every one `unchanged`; a capture is not byte-stable, so `capture` captures back to back, with no rest, until one equals the file on disk or two agree, and, when the two that agree are not the file, goes on capturing further apart, for 5.5 s at most, until one is, keeping the last two that agreed, and a picture that differs from the file only by a capture's noise (16 grey levels, 0.5% of its pixels) is left as it is. **The reference is the picture at git `HEAD` first, then the file** (owner): compared with the file alone, a picture drifted from `HEAD` over the runs of one retake, each step within noise of the file before it. A capture equal to or within noise of `HEAD`'s picture leaves the file as `HEAD` has it, its SVG too (`HEAD`'s, or none), reported `restored` when it held anything else; one that is not, but is within noise of the file, leaves the file (an earlier run's real change); a picture `HEAD` lacks is compared with the file only (`pictureFate`, `scripts/lib/shot-revision.mjs`; the run's pictures at `HEAD` read in one `git cat-file --batch`). `--against ` takes another revision, `--against-disk` the file only. **`--cleanup`** (`--dry-run` to list) starts no IDE: it resets every PNG git reports modified that is within noise of `HEAD`'s, with its SVG, so that only real changes stay; one noise rule, `withinNoise` in `scripts/lib/shot-change.mjs`, for the capture, `--cleanup` and `sheet.mjs --against`. **The manifest, `test/shots/manifest.json`** (`scripts/lib/shot-manifest.mjs`; [WIP.ShotReview.md](WIP.ShotReview.md#remembered-overrides)), records the build of each picture a run writes, and holds each picture's allowances (its noise rule's limits and rectangles left out, its SVG's bar) and a review's decisions, each tied to the bytes it was made on; `decidePicture` and `decideSvg` (`scripts/lib/shot-policy.mjs`) judge with them, in a run, in `--cleanup` and in `replay.mjs`, so a run from a terminal and one from the review tool come out the same. It is read and written only for the default `--out`. **`--stage `** (a new or empty folder git ignores) writes nothing under `docs/`, to the bundle cache or to the manifest, and puts each capture, its SVG, bundle, difference pictures, strength maps and verdict there (`scripts/lib/shot-stage.mjs`), for the review tool to apply; **`--events`** prints a `@@shot-event` line per event, and then a line `stop` on stdin ends the run as at its normal end; `replay.mjs` takes both ([WIP.ShotReview.md](WIP.ShotReview.md#runs)). `--jobs N` (default 6) runs the setups at once, each in a slot of its own from `tbbroker`, a full run with `--svg` in about 12 minutes on BETA 1010 (`--jobs 1`: one at a time); `--diffs ` writes the old picture, the new one and an amplified difference map for each that changed, named with its theme. Settle by a condition, never a fixed sleep, and a picture that shows some state sets it itself rather than relying on the shots before it. The page's animations and carets are switched off through a style sheet put into the document and every shadow root; a picture that holds DEBUG CONSOLE lines has their times (and a CodeLens run's time taken) set in the page once the real ones show. **A running program's window** (setup `programs`, fixture `test/shots/programs`) is taken **once** (`once: true`, the dark pass only) with `captureWindow`, cropped to its visible frame, at the display's DPI, shown at its size at 96 DPI. **The Windows user name must never be in a published picture**: the visible text of the page, text boxes included, is searched for it before the first capture and again when a capture is chosen, with the picture's SVG under `--svg` (a page can draw again in between: Project Settings puts a real path back over the one the shot set), and for a program's window the texts of the window and its children, and the run fails when it is there. The tool finds each name it presses F1 on by searching the demo's source, not by line. The add-in's detached window has a port from the broker (the help setup's slot carries one), since the add-in is told it before it binds it. **`--svg`** also draws each picture as SVG from the page as it stands at the capture (`scripts/svgshot/`: a DOM snapshot converted, its text set in per-picture subsets of the fonts the IDE drew it with, cut by `scripts/subset_font.py`, which needs Python with fontTools), compares it with the PNG, and writes `X.svg` or `X.light.svg` beside it only when no more than 0.25% of its pixels are far off; otherwise an older SVG is removed and the page shows the PNG; the SVG of a picture whose PNG is not rewritten stays as it is, with its bundle (owner) ([WIP.Build.md](WIP.Build.md#pictures-as-svg)). With `--svg` each picture's *bundle*, what its SVG is drawn from, is saved in the gitignored `.svgshot-bundles/` (`--bundles`; never under `docs/`, a bundle holds the user name), and **after a change to the converter, `node scripts/svgshot/replay.mjs` draws every SVG again from the bundles, with no IDE**, in about 20 s for the 370 (an SVG the same as its file is not compared again, `--recheck` compares all; the fonts' cuts are kept in `.svgshot-bundles/subsets/`; one browser per process, GPU off); it lists the pictures whose bundle cannot answer what the converter now asks, with the `--only` command that retakes them ([WIP.Screenshots.md](WIP.Screenshots.md#pictures-as-svg)). `node scripts/svgshot/sheet.mjs --out ` writes a page of the pictures with no SVG (`--all`: every picture), each drawn again from its bundle beside its PNG and difference map, worst first; it is drawn without the user-name check (owner: the sheet is the owner's alone), so `--out` is refused under `docs/` and anywhere git does not ignore; what it and replay share is `scripts/svgshot/redraw.mjs`, and its pages are `scripts/svgshot/sheets.mjs`, made of the pieces in `scripts/lib/shot-views.mjs`. `node scripts/svgshot/bench.mjs` tests the converter's replicas of what Chromium draws outside the DOM (scrollbars, drop-down lists) in Edge, with no IDE ([WIP.Screenshots.md](WIP.Screenshots.md#pictures-as-svg)). No wrapper; outside every gate and outside CI, as `try-help-addin.bat` is. Exit 0 every picture written or unchanged, 1 a picture failed, 2 the tool could not run, 3 not put back. Run `build.bat` first. - **The help add-in's project file is written by every build, CI's included.** The `addinProject` task (`builder/addin-project.mjs`) packs `add-in/` into `tB/IDE/AddIns/downloads/tbDocsHelp.twinproj` in the **online tree only**, which the Help Add-In page offers as a download; `_config.yml`'s `addin_project` declares both ends, so a fixture config gets none. It waits for the `helpArchive` task. It packs the files `git ls-files` lists there, read from the working folder, never a `Resources/HELP/` file from the disk, with CRLF made LF outside `Resources/` (so a Windows and a Linux build write the same bytes), the `tB/symbols.json` the same build wrote in place of the committed copy, and **the zip that build's archive task wrote** as `Resources/HELP/site.zip`, passed in memory (a build that wrote none --- `--no-help-archive`, `--serve`, another `--dest`, a fixture --- packs without it, and the summary line says `without the offline archive`). The download is about 61 MB with the archive, 1 MB without. The offline tree holds no copy (the project carries that tree's archive, so a copy there would be circular), and its links to the file are rewritten to the website by the general rule in `offline-rewrite.mjs`: a URL the offline tree lacks but the online tree has becomes `site.url` + path, and is no miss (`computeWebsiteUrl`; the offline link check's `forbidExempt` excuses exactly those links, `isWebsiteOnlyLink`). The pack is `packFiles` from `scripts/impexp.mjs`, in memory (never staged through `%TEMP%`), the one module under `scripts/` that `builder/` may import (`biome.jsonc`); `check_tree_fresh.mjs` watches it and `add-in/` for that reason. The publish allowlist exempts the one path in the trees only (`publishPolicyFor`'s `generated`); a `.twinproj` anywhere else, or in `docs/`, is refused. `--serve` writes it into its one tree; `--dry-run` writes nothing. A failure (no git) fails the build with exit 1 and the index audit names the missing file. **Don't build the DLLs in CI from it**: that needs a twinBASIC install; the download is the project, and the reader builds it. Why each of these: [WIP.Build.md](WIP.Build.md#the-help-add-ins-project-file); `test/addin-project.test.mjs` is the gate. - **The help archive is the `helpArchive` task of the build's graph.** `builder/help-archive-step.mjs` writes `add-in/Resources/HELP/site.zip` from `docs/_site-offline` through `lib/help-archive.mjs`, once every task that writes into the offline tree has finished (`flushJoin`, `scss`, `searchIndex`, `writeOffline`; the archive task lists exactly those) and **before** the Gantt injection. **The archive leaves out `HELP_ARCHIVE_SKIP`** (`builder/help-archive-step.mjs`): the Documentation Development section (`BuildInfo.html` and `assets/images/gantt.svg` with it, so the chart never reaches the zip), Videos and their poster frames, Challenges, and **every download, which stays online** (owner): a file readers download goes in a `downloads` folder beside its page, and the step fails the build when the Downloads page offers a file the archive would hold. The add-in's server redirects any file the archive lacks to the website, because pages link to all of these; a new entry needs nothing else. It reads the file back to verify it. A mismatch fails the build (exit 1). After the Gantt injection `recheckHelpArchive` checks that the offline tree on disk lists the same files as the archive and differs from it only in `BuildInfo.html` and `gantt.svg`; a task that wrote into the offline tree late fails the build there. The help add-in embeds the zip as a resource and serves the pages from it with its own server; a build of the add-in without it uses the live site. The file is gitignored (about 60 MB, 10 MB of it the search index's `search-index.js`, deflated), so a build of the add-in from a clean clone is the online one until `build.bat` has run. The download carries the same zip (previous bullet). **`--no-help-archive` turns the step off, and neither CI workflow may pass it** (`check_ci_workflows.mjs` refuses it, in `ALLOWED.forbiddenBuildFlags`), because CI's published download would then hold no archive. It runs only for the guarded source tree (`docs`) built into the default `docs/_site-offline` (not another `--dest`, so `compare_trees` and `check_links_diff` leave it alone), never under `--serve` or `--dry-run`, never without an offline tree, and not keyed on `$CI`. There is no command-line tool for the archive: the `help-offline` lane makes its own with `writeHelpArchive`. The zip's format is fixed by the add-in's reader, which does no inflating ([WIP.HelpAddin.md](WIP.HelpAddin.md), Stage 4, increment 5). - `node scripts/bug_repro.mjs` --- the reproducer projects under `bugs//` for the entries of [BUGS-TO-REPORT.md](BUGS-TO-REPORT.md): `new`, `pack` (impexp, then the zip, written in Node), `compile`, `build` and `run` through `tbbuild` and `tbrun`, `vb6` (builds the optional `vb6/` VB6 project beside the twinBASIC one in a temp copy, runs its exe on a private desktop, and prints its `out.txt`; a project with a form or a user control is built without Unattended Execution, which VB6 refuses for it; `pack` zips its sources into `-vb6.zip`; VB6 is started only by `scripts/lib/vb6.mjs`, never from a shell; a graphical defect's `images` key makes `run` and `vb6` keep what the `PngDump` modules of `test/repro-templates/png/` draw as `images/-tb.png` and `-vb6.png`, with a `-compare.png` of both, tested by `test/png.test.mjs`), `file` (moves a filed entry and its reproducer to `bugs/filed//`; `file --marked` does every marked entry), and `verify`, which reads each `bugs/*/repro.json` and `bugs/filed/*/repro.json` and says whether the bug still reproduces on the newest beta; a `lane` reproducer names the `ide-test` or `addin-test` lane and tests that assert its bug, which `verify` runs last through that runner, a `probe` one a script under `scripts/`, and a `test` one its own `[TestCase]` Subs and compile cases through `tbrun --tests`, judged by the `PASS` lines of what is fixed and the `FAIL` lines of the bug (`new --template tests` starts one). **No reproducer should need a person at the IDE** (owner): a bug the IDE has to be operated for gets a lane test, and `manual` is left for what cannot be automated at all. It has no wrapper. **`verify` is run by a person only**, never by a gate or CI: it needs a twinBASIC install, like `examples.bat`. Each `tbbuild` and `tbrun` it starts takes a slot of its own from `tbbroker`; `--jobs N` runs N at once. Its command-line cases are in `scripts/lib/cli-cases.mjs`, and need no IDE. @@ -663,7 +663,7 @@ wrapper: | `test.bat` | `test/render.test.mjs` | `builder/render.mjs`'s markdown-it plugins, one at a time, on inputs no page holds --- today the ellipsis plugin's dot counting. Run by `node --test` | | `test.bat` | `test/strftime.test.mjs` | `builder/strftime.mjs`'s `formatDate`, which no build calls because no page sets `last_modified_date`. Run by `node --test` | | `test.bat` | `test/compact-image.test.mjs` | what an embedded image keeps (`compactImage` and `stripFile` in `scripts/lib/compact-image.mjs`): an editor's metadata goes, a notice of whose the picture is and on what terms stays (the IDE's lock icon carries a CC BY-NC-SA attribution), an sRGB profile goes and any other stays, a JPEG or a GIF is labelled by its bytes, and an image inside an SVG inside a CSS file is reached and keeps its encoding. Its second half runs `scripts/imagestrip/` over a CSS file and SVG files of every image the test builds and over the IDE's own stylesheets, and compares output, summary and reports with `stripFile` byte for byte; it is skipped unless `IMAGESTRIP_EXE` names the built exe. Run by `node --test` | -| `test.bat` | `test/svgshot.test.mjs` | an SVG screenshot is written on one line (`oneLine` in `scripts/svgshot/snapshot-svg.mjs`), a break in a tag as a space and one in text as a character reference, so that a CRLF checkout changes only its last line ending; an underline is placed and sized by the figures fitted in Edge (`underlineBand`) under the boxes that set one (`decoratingBoxes`); a background image is sized by its bytes, a JPEG the IDE calls `image/png` included (`naturalSize`); an iframe's document is decoded and placed at the iframe's content box, less its scroll, scaled when zoomed, over its canvas colour (`frameGeometry`, `frameView`, `unzoom`, `viewportScroller`, `canvasColor`); a run's web font is the `@font-face` rule CSS matching picks (`webFontFiles`), and a variable WOFF2 is read and cut per weight; a family split by `unicode-range` gives each character to the rule whose range holds it (`parseUnicodeRange`, `webRulesFor`); an inline `` is found and its ids scoped (`inlineSvgsIn`, `scopeIds`); a filter of colour functions is baked into the colours under it, in order and clamped, its pictures wrapped in a real filter and its opacity kept apart (`colour-filter.mjs`); a transformed element's boxes and text are put back from the snapshot's after-transform boxes (`elementTransform`, `untransformBox`); a closed select shows its option's whole text (`selectLabel`); a bundle draws the same SVG from its file as in memory, and counts each question it cannot answer (`renderBundle`); its web font rules and files draw each character from the file whose range holds it, at the weight its rule allows, and its inline `` markup is drawn in its box, from the file and when trimmed (`drawBundle`); a bundle of version 1 is refused; one trimmed to its view draws the same (`trimSnapshot`); a font's cut comes from the subset cache with no Python run (`subsetKey`, `useSubsetCache`); `redraw` draws a picture again from its bundle or names its retake, and fails on the user name unless `checkUserName: false`; `listPictures` leaves the output trees out; `outputRefusal` refuses a sheet under docs or where git would take it (`scripts/lib/shot-revision.mjs`), and `sheetHtml` puts the worst picture first (`scripts/svgshot/sheets.mjs`); a strength map's share at a level and its regions of change, a word's strokes closed into one block and a line up to 4 pixels thick eroded away, and which pictures need a look (`shareOff`, `changeRegions`, `scripts/lib/shot-change.mjs`; `pngLook`, `svgLook`, `scripts/lib/shot-queue.mjs`); a capture's verdict with a picture's allowances and a review's decisions, and the manifest written, read back and refused when malformed (`decidePicture`, `decideSvg`, `cleanupResets`, `scripts/lib/shot-policy.mjs`; `parseManifest`, `manifestText`, `openManifest`, `scripts/lib/shot-manifest.mjs`); a capture's fate against the picture at a git revision and the file, the SVG of one put back, what `--cleanup` resets, and a revision's files read in one git process and a text file put back as a checkout writes it, in a scratch repository (`pictureFate`, `svgFate`, `planCleanup`, `readRevisionFiles`, `restoreFromRevision`, `scripts/lib/shot-revision.mjs`). What it keeps of an image it embeds is `compactImage`'s, which the row above tests. Run by `node --test` | +| `test.bat` | `test/svgshot.test.mjs` | an SVG screenshot is written on one line (`oneLine` in `scripts/svgshot/snapshot-svg.mjs`), a break in a tag as a space and one in text as a character reference, so that a CRLF checkout changes only its last line ending; an underline is placed and sized by the figures fitted in Edge (`underlineBand`) under the boxes that set one (`decoratingBoxes`); a background image is sized by its bytes, a JPEG the IDE calls `image/png` included (`naturalSize`); an iframe's document is decoded and placed at the iframe's content box, less its scroll, scaled when zoomed, over its canvas colour (`frameGeometry`, `frameView`, `unzoom`, `viewportScroller`, `canvasColor`); a run's web font is the `@font-face` rule CSS matching picks (`webFontFiles`), and a variable WOFF2 is read and cut per weight; a family split by `unicode-range` gives each character to the rule whose range holds it (`parseUnicodeRange`, `webRulesFor`); an inline `` is found and its ids scoped (`inlineSvgsIn`, `scopeIds`); a filter of colour functions is baked into the colours under it, in order and clamped, its pictures wrapped in a real filter and its opacity kept apart (`colour-filter.mjs`); a transformed element's boxes and text are put back from the snapshot's after-transform boxes (`elementTransform`, `untransformBox`); a closed select shows its option's whole text (`selectLabel`); a bundle draws the same SVG from its file as in memory, and counts each question it cannot answer (`renderBundle`); its web font rules and files draw each character from the file whose range holds it, at the weight its rule allows, and its inline `` markup is drawn in its box, from the file and when trimmed (`drawBundle`); a bundle of version 1 is refused; one trimmed to its view draws the same (`trimSnapshot`); a font's cut comes from the subset cache with no Python run (`subsetKey`, `useSubsetCache`); `redraw` draws a picture again from its bundle or names its retake, and fails on the user name unless `checkUserName: false`; `listPictures` leaves the output trees out; `outputRefusal` refuses a sheet under docs or where git would take it (`scripts/lib/shot-revision.mjs`), and `sheetHtml` puts the worst picture first (`scripts/svgshot/sheets.mjs`); a strength map's share at a level and its regions of change, a word's strokes closed into one block and a line up to 4 pixels thick eroded away, and which pictures need a look, and a capture's regions against the picture it was compared with (`shareOff`, `changeRegions`, `scripts/lib/shot-change.mjs`; `pngLook`, `svgLook`, `reviewPicture`, `scripts/lib/shot-queue.mjs`); a run's stage, its files, verdicts and strength maps, refused under docs, where git would take it or when not empty, and the stop line (`stagedFiles`, `writeVerdict`, `writeStrength`, `stageRefusal`, `scripts/lib/shot-stage.mjs`; `onStop`, `scripts/lib/record-lines.mjs`); a capture's verdict with a picture's allowances and a review's decisions, and the manifest written, read back and refused when malformed (`decidePicture`, `decideSvg`, `cleanupResets`, `scripts/lib/shot-policy.mjs`; `parseManifest`, `manifestText`, `openManifest`, `scripts/lib/shot-manifest.mjs`); a capture's fate against the picture at a git revision and the file, the SVG of one put back, what `--cleanup` resets, and a revision's files read in one git process and a text file put back as a checkout writes it, in a scratch repository (`pictureFate`, `svgFate`, `planCleanup`, `readRevisionFiles`, `restoreFromRevision`, `scripts/lib/shot-revision.mjs`). What it keeps of an image it embeds is `compactImage`'s, which the row above tests. Run by `node --test` | | `test.bat` | `test/example-batches.test.mjs` | `check_examples.mjs`'s probes, from `scripts/lib/example-batches.mjs`: batching, crash isolation through a fake lane, the canaries, the fence classifier and `check_run`'s refusals, expected output, dispatcher and marker reading, with no IDE. Run by `node --test` | | `test.bat` | `test/ide-folders.test.mjs` | `shutdownIde` and `shutdownIdeAsync` remove the IDE's temp folder and profile once it is gone, and no other folder. Run by `node --test` | | `test.bat` | `test/broker.test.mjs` | `tbbroker` and its client (`scripts/lib/tb-broker.mjs`): one broker per pipe, leases, held processes, waiting, ports, a desktop left with windows on it, a restarted broker finding a desktop in use. Skipped unless `TBBROKER_EXE` names a built broker. Run by `node --test` | diff --git a/docs/Documentation/Tools-IDE.md b/docs/Documentation/Tools-IDE.md index d55f0070e..4dfdea927 100644 --- a/docs/Documentation/Tools-IDE.md +++ b/docs/Documentation/Tools-IDE.md @@ -100,7 +100,7 @@ Exit codes: **0** the IDE was closed, and the registry is as it was found; **1** ## shoot_docs.mjs {: #shoot-docs } - node scripts/shoot_docs.mjs [--only ] [--out ] [--diffs ] [--jobs N] [--ide ] [--svg] [--bundles ] [--against | --against-disk] + node scripts/shoot_docs.mjs [--only ] [--out ] [--diffs | --stage ] [--events] [--jobs N] [--ide ] [--svg] [--bundles ] [--against | --against-disk] node scripts/shoot_docs.mjs --cleanup [--dry-run] [--only ] [--out ] [--against ] Takes the documentation's screenshots of the IDE, so that one run regenerates them after a new BETA. Each picture belongs to a *setup*, one IDE brought to a known state. A setup's IDE is started only when one of its pictures is selected. Run `build.bat` first: the help setup's pane pages come from the built site, `docs/_site`, served on `localhost`. @@ -147,13 +147,15 @@ Flags: - `--jobs` is how many IDEs run at once, 6 by default and at most 16. Each runs on a private desktop (a slot from [`tbbroker`](Tools-Compiler#tbbroker)) with a work folder and an install copy of its own, and a DevTools port Windows picks. The setups wait in a queue, the longest first, and the three long ones (no-project, sample and customcontrols) are cut into three parts, each in an IDE of its own. Every line of output starts with the setup or part it is from. `--jobs 1` runs each setup whole, one after another. - The help setup's slot also carries a port from the broker for the add-in's detached window, which the add-in has to be told before it binds it. The add-in's saved settings (`tbDocsHelp`) are emptied for the run and, with the IDE's project state, put back at the end. - `--svg` also draws each picture as SVG, from the page as it stands at the capture, and writes it beside the PNG as `X.svg` when it is close enough to the PNG (see [A picture as SVG](Authoring#a-picture-as-svg)). An SVG already there is rewritten only when its PNG is. It needs Python with fontTools. What the SVG is drawn from --- the page's layout, styles and measurements --- is saved for each picture as a *bundle* in the folder `--bundles` names, `.svgshot-bundles` in the repository by default, so that [`replay.mjs`](#replay) can draw the SVG again without the IDE. Git ignores that folder, and it must stay outside `docs`: a bundle holds the Windows user name. +- `--stage` names a folder, new or empty, outside `docs` and at a path git ignores, that takes everything the run would write: nothing is written under the output folder, to the bundle folder or to `test/shots/manifest.json`. Each picture's capture, its SVG whether or not it is close enough, its bundle, its difference pictures, its *strength maps* (how many levels each pixel is off the picture it was compared with, and the SVG's off its PNG) and what was decided about it go there, laid out as `scripts/lib/shot-stage.mjs` describes, so that another program can apply the decisions afterwards. The lines printed say what was decided, as without it. It cannot be given with `--diffs` or `--bundles`. The fonts' cuts are still kept in the bundle folder's `subsets`, since a cut depends only on the font and its characters. +- `--events` also prints a line for each event, `@@shot-event` followed by JSON: the run with its jobs and pictures, each job started, done or failed, each picture's verdict, each SVG's, a picture that failed or was not taken, and the summary. A picture's verdict says what it was compared with, its regions of change and whether it needs a person to look at it (`scripts/lib/shot-queue.mjs`). With `--events`, a line `stop` on standard input stops the run: no further picture is taken and no further setup started, and the IDEs end and the registry is put back as at the end of a run. -Exit codes: **0** every picture was written or was unchanged (with `--cleanup`, every modified picture was compared); **1** a picture failed (an element was not found, the page showed the user name, or a light picture was more than 32 pixels off the dark one's size), the add-in did not build, or the demo project does not compile, or with `--cleanup` a picture could not be read as a PNG; **2** the tool could not run: a refused command line, no IDE, no built site, a registry it could not record, no such revision (or, with `--against` or `--cleanup`, no git repository), or a crash; **3** the registry or the work folder was not put back (see the lines above). +Exit codes: **0** every picture was written or was unchanged (with `--cleanup`, every modified picture was compared; a run stopped with `--events` exits as for the pictures it took); **1** a picture failed (an element was not found, the page showed the user name, or a light picture was more than 32 pixels off the dark one's size), the add-in did not build, or the demo project does not compile, or with `--cleanup` a picture could not be read as a PNG; **2** the tool could not run: a refused command line, no IDE, no built site, a registry it could not record, no such revision (or, with `--against` or `--cleanup`, no git repository), or a crash; **3** the registry or the work folder was not put back (see the lines above). ## replay.mjs {: #replay } - node scripts/svgshot/replay.mjs [--only ] [--bundles ] [--out ] [--diffs ] [--recheck] [--jobs N] + node scripts/svgshot/replay.mjs [--only ] [--bundles ] [--out ] [--diffs | --stage ] [--events] [--recheck] [--jobs N] Draws each picture's SVG again, with the SVG converter as it is now, from the bundle that [`shoot_docs.mjs --svg`](#shoot-docs) saved when it took the picture. It starts no IDE and takes no picture, so a change to the converter under `scripts/svgshot/` is tried on every picture in about 20 seconds. @@ -169,7 +171,7 @@ A bundle holds only what the converter asked for when the picture was taken. A p The list ends with the `shoot_docs.mjs --svg --only` command that takes those pictures again. -`--only` takes the pictures whose path under `docs` matches a regular expression, a light picture's path ending in `.light.png`. `--bundles` is the bundle folder, `.svgshot-bundles` by default, and `--out` the folder the pictures are in, `docs` by default. `--diffs` names a folder for each SVG's difference picture against its PNG. `--recheck` compares every SVG with its PNG, also one that is the same as the file, for when the comparison itself has changed. `--jobs` is how many processes draw at once, each with a browser of its own, by default half the logical processors and at most 8. +`--only` takes the pictures whose path under `docs` matches a regular expression, a light picture's path ending in `.light.png`. `--bundles` is the bundle folder, `.svgshot-bundles` by default, and `--out` the folder the pictures are in, `docs` by default. `--diffs` names a folder for each SVG's difference picture against its PNG. `--recheck` compares every SVG with its PNG, also one that is the same as the file, for when the comparison itself has changed. `--jobs` is how many processes draw at once, each with a browser of its own, by default half the logical processors and at most 8. `--stage` and `--events` work as for [`shoot_docs.mjs`](#shoot-docs): the stage takes each SVG drawn, its difference picture, its strength map and what was decided about it, and nothing is written under the output folder; `stop` on standard input is passed to every process, and each draws no further SVG. The tool needs Python with fontTools, as `--svg` does, for a cut that is not kept yet. diff --git a/docs/Documentation/Tools-Toolchain-Tests.md b/docs/Documentation/Tools-Toolchain-Tests.md index 31527b519..74e8f079e 100644 --- a/docs/Documentation/Tools-Toolchain-Tests.md +++ b/docs/Documentation/Tools-Toolchain-Tests.md @@ -277,7 +277,8 @@ Unit tests for the SVG screenshot converter. They cover: - `scripts/svgshot/redraw.mjs`, shared by [`replay.mjs`](Tools-IDE#replay) and [`sheet.mjs`](Tools-IDE#svg-sheet): a picture drawn again from its bundle, or listed as needing a retake, and failed when its SVG holds the Windows user name unless the caller turns that check off; and the list of pictures, with the build's output trees left out. `scripts/svgshot/sheets.mjs`, built from the pieces in `scripts/lib/shot-views.mjs`: the folders a sheet may not be written to, and the sheet's page, worst picture first. The sheet of changes against a git revision (`sheet.mjs --against`) is tested the same way, with no git and no browser: the change list read from git's output (the PNGs only, untracked ones added), the calls made to git, the noise rule of `scripts/lib/shot-change.mjs` and its comparison of pictures of one size and of two, the difference map's colours, the order of the rows, the commands that restore the pictures within noise, and the page. - `scripts/lib/shot-revision.mjs`, which [`shoot_docs.mjs`](Tools-IDE#shoot-docs) compares a new capture with: what becomes of a capture with the picture at a git revision or without one, within the noise of it, of the file on disk or of neither; the SVG that goes with a picture put back as the revision has it; which modified pictures `--cleanup` puts back; git's batch output, a name with spaces and a missing one included; and, in a scratch git repository, a revision's files read in one process and a text file put back as a checkout writes it, with the index left alone. - `scripts/lib/shot-policy.mjs`, which decides what becomes of a capture and of its SVG, and `scripts/lib/shot-manifest.mjs`, which reads and writes `test/shots/manifest.json`: a light picture compared with the dark one first; a picture's own noise rule and SVG bar passed to the comparison; a review's decision used for the bytes it was made on and for no others, in a run and in `--cleanup`; the manifest written in a fixed order and read back the same, from an LF or a CRLF checkout; every malformed entry refused with its key named; a build recorded and the file saved only when it changes; and the repository's own manifest read. -- The strength map, a byte per pixel saying how far one picture is from another, which `scripts/lib/shot-change.mjs` makes of two captures and `scripts/svgshot/diff.mjs` of an SVG against its PNG: the share of its pixels at a level, with rectangles left out, and its regions of change. The tests draw small maps by hand: a line up to 4 pixels thick leaves no core after the erosion and a block keeps its middle, the strokes of a word are joined into one block, every changed pixel is kept along the picture's edge, and regions that touch at a corner are one. `scripts/lib/shot-queue.mjs` then says which pictures the review tool shows first: a capture taken with nothing surviving or only a word or two, a capture kept as within noise with something surviving, an SVG accepted with a part surviving or refused with nothing. +- The strength map, a byte per pixel saying how far one picture is from another, which `scripts/lib/shot-change.mjs` makes of two captures and `scripts/svgshot/diff.mjs` of an SVG against its PNG: the share of its pixels at a level, with rectangles left out, and its regions of change. The tests draw small maps by hand: a line up to 4 pixels thick leaves no core after the erosion and a block keeps its middle, the strokes of a word are joined into one block, every changed pixel is kept along the picture's edge, and regions that touch at a corner are one. `scripts/lib/shot-queue.mjs` then says which pictures the review tool shows first: a capture taken with nothing surviving or only a word or two, a capture kept as within noise with something 2 grey levels off or more surviving, an SVG accepted with a part surviving or refused with nothing; and the regions of a capture measured against the picture it was compared with, when that is of its size. +- `scripts/lib/shot-stage.mjs`, the folder `--stage` writes into instead of `docs`: each picture's files named, both parts of its verdict kept, a strength map written and read back, and a folder refused under `docs`, where git would take it, or when it holds anything. And the line `stop` that ends a run with `--events`, read across chunks and acted on once (`scripts/lib/record-lines.mjs`). What the SVG keeps of an image it embeds is `compactImage`'s, from `scripts/lib/compact-image.mjs`, which [`compact-image.test.mjs`](#compact-image-test) tests. No browser, no built tree, well under a second. From fd13f85edd82e46948fb52ef1f981293d24485b6 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Sat, 31 Oct 2026 22:28:56 +0100 Subject: [PATCH 21/85] Step 5 of the screenshot review: scripts/shot_review.mjs, its session (shot-session.mjs), runs (shot-runs.mjs), model (shot-review.mjs), pages (shot-pages.mjs) and server (shot-server.mjs), read-only and live; strengthPicture; WIP.ShotReview.md: step 5 done, step 6 next --- .gitignore | 4 + WIP.ShotReview.md | 66 ++- WIP.md | 2 +- docs/Documentation/Tools-Toolchain-Tests.md | 1 + scripts/lib/cli-cases.mjs | 11 + scripts/lib/shot-change.mjs | 43 +- scripts/lib/shot-pages.mjs | 478 ++++++++++++++++++++ scripts/lib/shot-review.mjs | 210 +++++++++ scripts/lib/shot-runs.mjs | 85 ++++ scripts/lib/shot-server.mjs | 397 ++++++++++++++++ scripts/lib/shot-session.mjs | 184 ++++++++ scripts/lib/shot-views.mjs | 38 +- scripts/shot_review.mjs | 160 +++++++ test/svgshot.test.mjs | 451 ++++++++++++++++++ 14 files changed, 2088 insertions(+), 42 deletions(-) create mode 100644 scripts/lib/shot-pages.mjs create mode 100644 scripts/lib/shot-review.mjs create mode 100644 scripts/lib/shot-runs.mjs create mode 100644 scripts/lib/shot-server.mjs create mode 100644 scripts/lib/shot-session.mjs create mode 100644 scripts/shot_review.mjs diff --git a/.gitignore b/.gitignore index 64c9b43cd..5d7d6fe54 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,7 @@ indexer/.packages/ # shoot_docs.mjs --svg: each picture's bundle (scripts/svgshot/bundle.mjs), what its SVG # is drawn from, for scripts/svgshot/replay.mjs. Bundles hold the Windows user name. /.svgshot-bundles/ + +# scripts/shot_review.mjs: the screenshot review's session and its runs' stages +# (scripts/lib/shot-session.mjs), which hold the Windows user name. +/.shot-review/ diff --git a/WIP.ShotReview.md b/WIP.ShotReview.md index 2736da42f..7efc4e493 100644 --- a/WIP.ShotReview.md +++ b/WIP.ShotReview.md @@ -2,8 +2,9 @@ A localhost tool for reviewing what `shoot_docs.mjs` and `replay.mjs` decided about each picture, overriding it, starting retakes and replays, and committing the result. **Design -agreed with the owner on 2026-10-31; steps 1 to 4 of [the order of work](#order-of-work) are -done (step 1 but for the commit helper, which step 8 makes), and the tool itself is not started.** The mockups it was agreed on were inline in that +agreed with the owner on 2026-10-31; steps 1 to 5 of [the order of work](#order-of-work) are +done (step 1 but for the commit helper, which step 8 makes): the tool runs, read-only, as +`node scripts/shot_review.mjs`.** The mockups it was agreed on were inline in that session and are not kept: the pages are described below instead. The tool covers everything the command-line screenshot tools do, interactively, with a person @@ -217,10 +218,14 @@ strength map. The manifest is read and written only for the default `--out`: its ## Sessions A session is a folder in the gitignored `.shot-review/` (beside `.svgshot-bundles/`; it can -hold the user name): `session.json` (the baseline commit and its build, the runs, every -decision with its time, the queue's place) and a folder per run with what the run produced -(below). Closing the tool loses nothing; opening it again opens the session. A commit -archives it. +hold the user name): `session/session.json` (the baseline commit and its build, the runs, every +decision with its time, the queue's place), `session/lock` (the process id of the tool that +holds it), and `session/runs//` per run: `stage/` (the run's `--stage`), `events.jsonl` +(each event as it arrived, for what the stage does not hold: failures, pictures not taken, +the jobs) and `log.txt` (its lines for a person). Closing the tool loses nothing; opening it +again opens the session, a run that had not ended marked interrupted. A commit archives it +into `archive//`. The folder is under the repository's root, so a worktree has a session of +its own. ## Runs @@ -284,17 +289,18 @@ Each step ends tested and usable on its own. 3. **The strength map, the regions and the queue's rules**, tested on fixtures. Done. 4. **`--stage`, `--events` and stop** in `shoot_docs.mjs` and `replay.mjs`. Done. 5. **The server and the session store**, the overview and the detail page read-only, live. + Done. 6. **Decisions and allowances** applied through `shot-apply.mjs`, judged again at once. 7. **The runs page**: retake, replay, clean up, stop. 8. **The commit panel**. 9. **`sheet.mjs` on the shared views**; the tool's entry in `Tools-IDE.md`, its node in - `Tools-Groups.dot`, its command-line cases in `scripts/lib/cli-cases.mjs`, its tests in + `Tools-Groups.dot` (its command-line cases are in `scripts/lib/cli-cases.mjs` since step 5), its tests in `test.bat` (and so in Tools.md's list and the `run-gates` action), and the row in WIP.md's *Which file to open*. ## Where the work stands -On `staging` since 2026-10-31, step 1 but for the commit helper (step 8), and steps 2 to 4. Each move kept the command-line tools' output as it was, checked as +On `staging` since 2026-10-31, step 1 but for the commit helper (step 8), and steps 2 to 5. Each move kept the command-line tools' output as it was, checked as [below](#checking-a-move). | Row of the factoring table | Where it is now | @@ -314,16 +320,33 @@ On `staging` since 2026-10-31, step 1 but for the commit helper (step 8), and st | Server-sent events | `createSseChannel()` in `lib/sse.mjs`: `handle(req, res)`, `send(event, data)`, `close()`; `builder/serve.mjs` uses one | | The installs | `listIdes()` in `scripts/lib/tb-install.mjs`, newest first, `[{build, exe}]` | -**Next: step 5**, the server and the session store, with the overview and the detail page -read-only and live. A run is a child process with `--stage /runs/` and `--events`; -the tool reads its records with `readRecords`, passes them to the pages through -`createSseChannel`, and after a restart rebuilds a run's pictures from `readVerdicts` and -`readStrength`. Still to write for step 6, where the tool applies a verdict: moving a staged -bundle into the cache (`readBundle` from the stage, `writeBundle` into `.svgshot-bundles`), and -the "dark" verdict's two calls (`applyPicture`, then `dropPicture` for its SVG and bundle) as -one function in `shot-apply.mjs`, so that `shoot_docs.mjs` and the tool make the same calls. The -tool's pages will add their actions to the pieces in `shot-views.mjs`; until then only -`sheets.mjs` calls them. +What step 5 added, the tool itself: + +| Part | Where it is | +|---|---| +| The tool | `scripts/shot_review.mjs [--port N] [--retake \| --replay ] [--jobs N] [--ide ]`: takes the session's lock, makes the session with HEAD as its baseline (`build` the newest the manifest at HEAD records) when there is none, serves it on `127.0.0.1:4180`, and starts a run when asked (a retake is `shoot_docs.mjs --svg --only`). Until step 7's runs page, a run is started only from its command line. Ctrl+C stops the runs by their stdin and waits, a second Ctrl+C kills them. Its command-line cases are in `scripts/lib/cli-cases.mjs` already | +| The session | `scripts/lib/shot-session.mjs`: `REVIEW_ROOT`, `RUN_TOOLS`, `sessionPaths`, `runPaths`, `openSession(root, baseline)` (`addRun`, `endRun`, `setPlace`, `run`), `acquireLock(file)`, `parseSession`, `commandOf(run)` (the command line without `--stage` and `--events`), `newestBuild`, `appendEvent`, `readEvents` | +| A run | `startRun(session, {tool, args}, {onEvent, onLine, onExit})` in `scripts/lib/shot-runs.mjs`: the tool spawned from the repository's root in a process group of its own (`detached`, so that the Ctrl+C that closes the review tool does not end the run before it puts the registry back), its events appended to `events.jsonl` and its other lines, stdout and stderr, to `log.txt`; `{run, stop, kill, exited}` | +| The model | `scripts/lib/shot-review.mjs`, pure: `runFacts(tool, events)` (listed, jobs and `jobOf`, failed, skipped, retakes, stopping, summary), `verdictOfEvent`, `reviewPictures(runs, {exists, measure})` (each part from the newest run that gave one, a newer PNG verdict dropping an older SVG; `progress` while a live run owes a part; a picture whose job failed before reaching it failed with the job's error), `groupOf`, `GROUPS`, `FILTERS`, `filterPictures`, `hasSvg`, `isOverridden`, `neighbours` | +| The pages | `scripts/lib/shot-pages.mjs`: `overviewHtml`, `detailHtml`, `chipsOf`, `loupesHtml` (a region cropped by CSS from the whole picture, enlarged up to 4 times, at most 360 by 240 CSS px), `hrefs` (every address the server answers); built on `pageHtml` (which now takes `head`, and leaves out the table or the contents when it is given none) and `chipHtml` in `shot-views.mjs`. A page fetches itself again 400 ms after an `update` event that names a picture it shows, and replaces its body; the detail page's comparison mode is kept in `sessionStorage` | +| The server | `createReviewServer({session, revision, manifest, docsRoot, headNow, newestIde, onLine, command})` in `scripts/lib/shot-server.mjs`: the pages, `/events`, `/api/state`, `/log/`, the files (`/file/head`, `/file/docs`, `/file/run/`, `/file/ref/`, the picture the capture was judged against) and the difference maps drawn from a run's strength maps (`/map//png|svg/`, `strengthPicture` in `shot-change.mjs`, which colours as `changeMap` does, the last 16 kept). It answers only a request whose Host is the loopback address at its port; a run's verdicts are read back from `readVerdicts` and its other events from `readEvents` when the session is opened again. The Changed group is ordered by the share of a PNG's pixels beyond its noise levels, from its strength map, else the SVG's share off its PNG | + +**Next: step 6**, decisions and allowances. Still to write for it, where the tool applies a +verdict: moving a staged bundle into the cache (`readBundle` from the stage, `writeBundle` into +`.svgshot-bundles`), and the "dark" verdict's two calls (`applyPicture`, then `dropPicture` for +its SVG and bundle) as one function in `shot-apply.mjs`, so that `shoot_docs.mjs` and the tool +make the same calls. Today a run's verdicts are only shown: nothing is applied to `docs/`, so +until step 6 a retake through the tool changes nothing in the working tree. The actions go +into the detail page's PNG and SVG rows, and the server takes its first POST requests (it +answers 405 to any today). + +Step 5 was checked with the tests in `test/svgshot.test.mjs` and with three real runs started +by the tool in one session, the tool closed between them: a retake of `Close_Question` whose +job failed in the harness (`the IDE's settings were not made the defaults: its page ran no +hook, even after a reload`, which a second run did not meet: a flake of `launchIde`'s hook, not +the tool's), which showed that a job's failure must reach its pictures; the same retake again +(both `unchanged`); and `Menu_File|Menu_Tools` (`Menu_File_Project.light` `noise`, its map the +one-level text rows the queue's `FAINT_LEVEL` is for). `git status` stayed clean under `docs/`. Step 4 was checked with the runs [below](#checking-a-move), staged and not, which printed the same lines, the staged ones writing nothing under `docs/` (`git status` clean) and their @@ -387,5 +410,10 @@ MutationObserver (`tabCloseIcon`). ## Open - **The server's port.** A stable default with `--port`, as bookmarks need, or a port from - `tbbroker`, which today leases one only with a desktop. + `tbbroker`, which today leases one only with a desktop. Step 5 took the first, 4180, until + the owner decides. +- **Pictures changed outside the session.** The overview shows what the session's runs + reported; a picture under `docs/` that differs from the baseline for another reason (a run + from a terminal, an edit) is not on it, though `sheet.mjs --against` lists it. Whether the + overview lists those too, as the static sheet does, is not decided. - **Allowances in the static sheets**: whether `sheet.mjs` shows them, as the overview does. diff --git a/WIP.md b/WIP.md index 7ed423a72..b406c7922 100644 --- a/WIP.md +++ b/WIP.md @@ -663,7 +663,7 @@ wrapper: | `test.bat` | `test/render.test.mjs` | `builder/render.mjs`'s markdown-it plugins, one at a time, on inputs no page holds --- today the ellipsis plugin's dot counting. Run by `node --test` | | `test.bat` | `test/strftime.test.mjs` | `builder/strftime.mjs`'s `formatDate`, which no build calls because no page sets `last_modified_date`. Run by `node --test` | | `test.bat` | `test/compact-image.test.mjs` | what an embedded image keeps (`compactImage` and `stripFile` in `scripts/lib/compact-image.mjs`): an editor's metadata goes, a notice of whose the picture is and on what terms stays (the IDE's lock icon carries a CC BY-NC-SA attribution), an sRGB profile goes and any other stays, a JPEG or a GIF is labelled by its bytes, and an image inside an SVG inside a CSS file is reached and keeps its encoding. Its second half runs `scripts/imagestrip/` over a CSS file and SVG files of every image the test builds and over the IDE's own stylesheets, and compares output, summary and reports with `stripFile` byte for byte; it is skipped unless `IMAGESTRIP_EXE` names the built exe. Run by `node --test` | -| `test.bat` | `test/svgshot.test.mjs` | an SVG screenshot is written on one line (`oneLine` in `scripts/svgshot/snapshot-svg.mjs`), a break in a tag as a space and one in text as a character reference, so that a CRLF checkout changes only its last line ending; an underline is placed and sized by the figures fitted in Edge (`underlineBand`) under the boxes that set one (`decoratingBoxes`); a background image is sized by its bytes, a JPEG the IDE calls `image/png` included (`naturalSize`); an iframe's document is decoded and placed at the iframe's content box, less its scroll, scaled when zoomed, over its canvas colour (`frameGeometry`, `frameView`, `unzoom`, `viewportScroller`, `canvasColor`); a run's web font is the `@font-face` rule CSS matching picks (`webFontFiles`), and a variable WOFF2 is read and cut per weight; a family split by `unicode-range` gives each character to the rule whose range holds it (`parseUnicodeRange`, `webRulesFor`); an inline `` is found and its ids scoped (`inlineSvgsIn`, `scopeIds`); a filter of colour functions is baked into the colours under it, in order and clamped, its pictures wrapped in a real filter and its opacity kept apart (`colour-filter.mjs`); a transformed element's boxes and text are put back from the snapshot's after-transform boxes (`elementTransform`, `untransformBox`); a closed select shows its option's whole text (`selectLabel`); a bundle draws the same SVG from its file as in memory, and counts each question it cannot answer (`renderBundle`); its web font rules and files draw each character from the file whose range holds it, at the weight its rule allows, and its inline `` markup is drawn in its box, from the file and when trimmed (`drawBundle`); a bundle of version 1 is refused; one trimmed to its view draws the same (`trimSnapshot`); a font's cut comes from the subset cache with no Python run (`subsetKey`, `useSubsetCache`); `redraw` draws a picture again from its bundle or names its retake, and fails on the user name unless `checkUserName: false`; `listPictures` leaves the output trees out; `outputRefusal` refuses a sheet under docs or where git would take it (`scripts/lib/shot-revision.mjs`), and `sheetHtml` puts the worst picture first (`scripts/svgshot/sheets.mjs`); a strength map's share at a level and its regions of change, a word's strokes closed into one block and a line up to 4 pixels thick eroded away, and which pictures need a look, and a capture's regions against the picture it was compared with (`shareOff`, `changeRegions`, `scripts/lib/shot-change.mjs`; `pngLook`, `svgLook`, `reviewPicture`, `scripts/lib/shot-queue.mjs`); a run's stage, its files, verdicts and strength maps, refused under docs, where git would take it or when not empty, and the stop line (`stagedFiles`, `writeVerdict`, `writeStrength`, `stageRefusal`, `scripts/lib/shot-stage.mjs`; `onStop`, `scripts/lib/record-lines.mjs`); a capture's verdict with a picture's allowances and a review's decisions, and the manifest written, read back and refused when malformed (`decidePicture`, `decideSvg`, `cleanupResets`, `scripts/lib/shot-policy.mjs`; `parseManifest`, `manifestText`, `openManifest`, `scripts/lib/shot-manifest.mjs`); a capture's fate against the picture at a git revision and the file, the SVG of one put back, what `--cleanup` resets, and a revision's files read in one git process and a text file put back as a checkout writes it, in a scratch repository (`pictureFate`, `svgFate`, `planCleanup`, `readRevisionFiles`, `restoreFromRevision`, `scripts/lib/shot-revision.mjs`). What it keeps of an image it embeds is `compactImage`'s, which the row above tests. Run by `node --test` | +| `test.bat` | `test/svgshot.test.mjs` | an SVG screenshot is written on one line (`oneLine` in `scripts/svgshot/snapshot-svg.mjs`), a break in a tag as a space and one in text as a character reference, so that a CRLF checkout changes only its last line ending; an underline is placed and sized by the figures fitted in Edge (`underlineBand`) under the boxes that set one (`decoratingBoxes`); a background image is sized by its bytes, a JPEG the IDE calls `image/png` included (`naturalSize`); an iframe's document is decoded and placed at the iframe's content box, less its scroll, scaled when zoomed, over its canvas colour (`frameGeometry`, `frameView`, `unzoom`, `viewportScroller`, `canvasColor`); a run's web font is the `@font-face` rule CSS matching picks (`webFontFiles`), and a variable WOFF2 is read and cut per weight; a family split by `unicode-range` gives each character to the rule whose range holds it (`parseUnicodeRange`, `webRulesFor`); an inline `` is found and its ids scoped (`inlineSvgsIn`, `scopeIds`); a filter of colour functions is baked into the colours under it, in order and clamped, its pictures wrapped in a real filter and its opacity kept apart (`colour-filter.mjs`); a transformed element's boxes and text are put back from the snapshot's after-transform boxes (`elementTransform`, `untransformBox`); a closed select shows its option's whole text (`selectLabel`); a bundle draws the same SVG from its file as in memory, and counts each question it cannot answer (`renderBundle`); its web font rules and files draw each character from the file whose range holds it, at the weight its rule allows, and its inline `` markup is drawn in its box, from the file and when trimmed (`drawBundle`); a bundle of version 1 is refused; one trimmed to its view draws the same (`trimSnapshot`); a font's cut comes from the subset cache with no Python run (`subsetKey`, `useSubsetCache`); `redraw` draws a picture again from its bundle or names its retake, and fails on the user name unless `checkUserName: false`; `listPictures` leaves the output trees out; `outputRefusal` refuses a sheet under docs or where git would take it (`scripts/lib/shot-revision.mjs`), and `sheetHtml` puts the worst picture first (`scripts/svgshot/sheets.mjs`); a strength map's share at a level and its regions of change, a word's strokes closed into one block and a line up to 4 pixels thick eroded away, and which pictures need a look, and a capture's regions against the picture it was compared with (`shareOff`, `changeRegions`, `scripts/lib/shot-change.mjs`; `pngLook`, `svgLook`, `reviewPicture`, `scripts/lib/shot-queue.mjs`); a run's stage, its files, verdicts and strength maps, refused under docs, where git would take it or when not empty, and the stop line (`stagedFiles`, `writeVerdict`, `writeStrength`, `stageRefusal`, `scripts/lib/shot-stage.mjs`; `onStop`, `scripts/lib/record-lines.mjs`); a capture's verdict with a picture's allowances and a review's decisions, and the manifest written, read back and refused when malformed (`decidePicture`, `decideSvg`, `cleanupResets`, `scripts/lib/shot-policy.mjs`; `parseManifest`, `manifestText`, `openManifest`, `scripts/lib/shot-manifest.mjs`); a capture's fate against the picture at a git revision and the file, the SVG of one put back, what `--cleanup` resets, and a revision's files read in one git process and a text file put back as a checkout writes it, in a scratch repository (`pictureFate`, `svgFate`, `planCleanup`, `readRevisionFiles`, `restoreFromRevision`, `scripts/lib/shot-revision.mjs`); the screenshot review's session, its lock and its runs read back, which verdict a picture shows and its group, and the server following a stand-in run, answering the loopback host only and stopping the run (`openSession`, `acquireLock`, `reviewPictures`, `createReviewServer`, `scripts/lib/shot-session.mjs`, `shot-review.mjs`, `shot-server.mjs`). What it keeps of an image it embeds is `compactImage`'s, which the row above tests. Run by `node --test` | | `test.bat` | `test/example-batches.test.mjs` | `check_examples.mjs`'s probes, from `scripts/lib/example-batches.mjs`: batching, crash isolation through a fake lane, the canaries, the fence classifier and `check_run`'s refusals, expected output, dispatcher and marker reading, with no IDE. Run by `node --test` | | `test.bat` | `test/ide-folders.test.mjs` | `shutdownIde` and `shutdownIdeAsync` remove the IDE's temp folder and profile once it is gone, and no other folder. Run by `node --test` | | `test.bat` | `test/broker.test.mjs` | `tbbroker` and its client (`scripts/lib/tb-broker.mjs`): one broker per pipe, leases, held processes, waiting, ports, a desktop left with windows on it, a restarted broker finding a desktop in use. Skipped unless `TBBROKER_EXE` names a built broker. Run by `node --test` | diff --git a/docs/Documentation/Tools-Toolchain-Tests.md b/docs/Documentation/Tools-Toolchain-Tests.md index 74e8f079e..4bcb6dc76 100644 --- a/docs/Documentation/Tools-Toolchain-Tests.md +++ b/docs/Documentation/Tools-Toolchain-Tests.md @@ -279,6 +279,7 @@ Unit tests for the SVG screenshot converter. They cover: - `scripts/lib/shot-policy.mjs`, which decides what becomes of a capture and of its SVG, and `scripts/lib/shot-manifest.mjs`, which reads and writes `test/shots/manifest.json`: a light picture compared with the dark one first; a picture's own noise rule and SVG bar passed to the comparison; a review's decision used for the bytes it was made on and for no others, in a run and in `--cleanup`; the manifest written in a fixed order and read back the same, from an LF or a CRLF checkout; every malformed entry refused with its key named; a build recorded and the file saved only when it changes; and the repository's own manifest read. - The strength map, a byte per pixel saying how far one picture is from another, which `scripts/lib/shot-change.mjs` makes of two captures and `scripts/svgshot/diff.mjs` of an SVG against its PNG: the share of its pixels at a level, with rectangles left out, and its regions of change. The tests draw small maps by hand: a line up to 4 pixels thick leaves no core after the erosion and a block keeps its middle, the strokes of a word are joined into one block, every changed pixel is kept along the picture's edge, and regions that touch at a corner are one. `scripts/lib/shot-queue.mjs` then says which pictures the review tool shows first: a capture taken with nothing surviving or only a word or two, a capture kept as within noise with something 2 grey levels off or more surviving, an SVG accepted with a part surviving or refused with nothing; and the regions of a capture measured against the picture it was compared with, when that is of its size. - `scripts/lib/shot-stage.mjs`, the folder `--stage` writes into instead of `docs`: each picture's files named, both parts of its verdict kept, a strength map written and read back, and a folder refused under `docs`, where git would take it, or when it holds anything. And the line `stop` that ends a run with `--events`, read across chunks and acted on once (`scripts/lib/record-lines.mjs`). +- The screenshot review tool's session, the model of what its pages show, and its server (`scripts/lib/shot-session.mjs`, `shot-review.mjs`, `shot-pages.mjs` and `shot-server.mjs`). A session is made with its baseline, numbers its runs, and marks a run that was still going when the tool closed as interrupted. Its lock refuses a second tool while the first runs, and is taken over from one that ended. When several runs reported a picture, it shows the newest verdict on each part, and a newer run that takes the PNG again drops the older SVG. The tests also cover the overview's groups and their order, a picture whose job failed before reaching it, the filters, a loupe's crop, and a strength map drawn as the difference map of the two pictures it came from. The server is tested with a stand-in run that reports as `shoot_docs.mjs --stage --events` does: the server follows it, serves its pages and files, refuses a request addressed to any host but the loopback address, stops the run through its stdin, and reads the run back when the session is opened again. What the SVG keeps of an image it embeds is `compactImage`'s, from `scripts/lib/compact-image.mjs`, which [`compact-image.test.mjs`](#compact-image-test) tests. No browser, no built tree, well under a second. diff --git a/scripts/lib/cli-cases.mjs b/scripts/lib/cli-cases.mjs index fb8ed8bde..f486ec61e 100644 --- a/scripts/lib/cli-cases.mjs +++ b/scripts/lib/cli-cases.mjs @@ -444,6 +444,7 @@ const HELP_TOOLS = { "scripts/pick_a11y_sample.mjs": null, "scripts/probe_build_twice.mjs": null, "scripts/probe_theme_semicolon.mjs": null, + "scripts/shot_review.mjs": null, "scripts/survey_tooling.mjs": null, "scripts/sweep_a11y.mjs": null, "scripts/sweep_attributes.mjs": null, @@ -491,6 +492,7 @@ const REFUSALS = { "scripts/ide_test.mjs": ["ide"], "scripts/try_help_addin.mjs": ["ide"], "scripts/shoot_docs.mjs": ["ide"], + "scripts/shot_review.mjs": ["port"], "scripts/bug_repro.mjs": ["ide"], "scripts/build_dot_metrics.mjs": [null], "scripts/census_attributes.mjs": ["out"], @@ -892,6 +894,15 @@ bad("scripts/shoot_docs.mjs", ["--jobs=17"], "--jobs expects a whole number from bad("scripts/shoot_docs.mjs", ["--cleanup", "--events"], "--cleanup and --events cannot be given together\n"); bad("scripts/svgshot/replay.mjs", ["--stage", underDocs], `replay: --stage: ${underDocs} is under ${docs}\n`); bad("scripts/svgshot/replay.mjs", ["--stage", "s", "--diffs", "d"], "--stage and --diffs cannot be given together\n"); + // The screenshot review tool: a run is a retake or a replay, and its options are for one. + bad("scripts/shot_review.mjs", ["--port", "0"], "--port expects a whole number from 1 to 65535, got: 0\n"); + bad( + "scripts/shot_review.mjs", + ["--retake", "a", "--replay", "b"], + "--retake and --replay cannot be given together\n", + ); + bad("scripts/shot_review.mjs", ["--jobs", "2"], "shot_review: --jobs is for a run: --retake or --replay\n"); + bad("scripts/shot_review.mjs", ["--replay", "a", "--ide", "x"], "shot_review: --ide is for --retake\n"); } // check_examples prints "check_examples: " before the message. diff --git a/scripts/lib/shot-change.mjs b/scripts/lib/shot-change.mjs index acc5a8f61..15ab0e973 100644 --- a/scripts/lib/shot-change.mjs +++ b/scripts/lib/shot-change.mjs @@ -228,6 +228,18 @@ const overWhite = (img, i) => { return [over(img.rgba[i]), over(img.rgba[i + 1]), over(img.rgba[i + 2])]; }; +// A difference map's colour of a pixel of `B` (its offset `j`) that differs by `d` levels: a +// dark grey copy of it when `d` is 0, else yellow for one level, shading to red at 128 and more. +function mapColour(d, B, j) { + if (!d) { + const [r, g, b] = overWhite(B, j); + const v = 24 + Math.round((0.299 * r + 0.587 * g + 0.114 * b) * 0.25); + return [v, v, v]; + } + const t = Math.min(1, Math.log2(d) / 7); + return [255, Math.round(255 * (1 - t)), 0]; +} + /** The map's colour of a pixel only the old picture has, and of one only the new picture has. */ export const ONLY_OLD = [0, 190, 220]; export const ONLY_NEW = [220, 0, 220]; @@ -253,15 +265,7 @@ export function changeMap(A, B) { else if (!inA) colour = ONLY_NEW; else { const j = (y * B.width + x) * 4; - const d = pixelDiff(A, (y * A.width + x) * 4, B, j); - if (!d) { - const [r, g, b] = overWhite(B, j); - const v = 24 + Math.round((0.299 * r + 0.587 * g + 0.114 * b) * 0.25); - colour = [v, v, v]; - } else { - const t = Math.min(1, Math.log2(d) / 7); - colour = [255, Math.round(255 * (1 - t)), 0]; - } + colour = mapColour(pixelDiff(A, (y * A.width + x) * 4, B, j), B, j); } rgba[o] = colour[0]; rgba[o + 1] = colour[1]; @@ -271,3 +275,24 @@ export function changeMap(A, B) { } return encodePng({ width: w, height: h, rgba }); } + +/** + * A strength map (strengthOf, or diffSvg's) drawn as changeMap draws two pictures of one + * size, as a PNG file: `B` is the picture it was measured on (decodePng), of the map's size, + * whose dark grey copy shows where nothing differs. The screenshot review tool draws its + * difference maps from the strength maps a run stages. + */ +export function strengthPicture(strength, B) { + const { width: w, height: h, data } = strength; + if (B.width !== w || B.height !== h) + throw new Error(`the map is ${w} x ${h} px and the picture ${B.width} x ${B.height} px`); + const rgba = new Uint8Array(w * h * 4); + for (let i = 0; i < data.length; i++) { + const colour = mapColour(data[i], B, i * 4); + rgba[i * 4] = colour[0]; + rgba[i * 4 + 1] = colour[1]; + rgba[i * 4 + 2] = colour[2]; + rgba[i * 4 + 3] = 255; + } + return encodePng({ width: w, height: h, rgba }); +} diff --git a/scripts/lib/shot-pages.mjs b/scripts/lib/shot-pages.mjs new file mode 100644 index 000000000..48efe1884 --- /dev/null +++ b/scripts/lib/shot-pages.mjs @@ -0,0 +1,478 @@ +// The screenshot review tool's pages (WIP.ShotReview.md, The pages): the overview, every +// picture of the session as a card in its group, and the detail page of one picture, made of +// the pieces in shot-views.mjs. Each page is whole HTML; a page keeps itself current by +// fetching itself again when the server says over its event stream that something it shows +// has changed. Read-only: the actions come with the decisions (step 6 of the plan). +// +// Every address the pages link to is made here (hrefs), and the server answers each. + +import { anchor, chipHtml, esc, pageHtml, sizeText } from "./shot-views.mjs"; +import { FILTERS, GROUPS } from "./shot-review.mjs"; +import { SURVIVES } from "./shot-queue.mjs"; + +/** The generator the tool's pages name in their . */ +export const REVIEW_GENERATOR = "scripts/shot_review.mjs"; + +/** A path with forward slashes as a URL path, each segment encoded. */ +export const encodePath = (p) => p.split("/").map(encodeURIComponent).join("/"); + +const query = (filter) => (filter && filter !== "all" ? `?filter=${encodeURIComponent(filter)}` : ""); + +/** The addresses the server answers. */ +export const hrefs = { + overview: (filter) => `/${query(filter)}`, + picture: (out, filter) => `/picture/${encodePath(out)}${query(filter)}`, + /** A file as the session's baseline commit has it. */ + head: (out) => `/file/head/${encodePath(out)}`, + /** A file under docs/ as it is now. */ + docs: (out) => `/file/docs/${encodePath(out)}`, + /** A file of the stage of the run `n`, `rel` its name in the stage (as a verdict names it). */ + run: (n, rel) => `/file/run/${n}/${encodePath(rel)}`, + /** The picture the run `n` judged its capture of `out` against. */ + reference: (n, out) => `/file/ref/${n}/${encodePath(out)}`, + /** The difference map of the run `n`'s capture of `out` ("png") or of its SVG ("svg"). */ + map: (n, part, out) => `/map/${n}/${part}/${encodePath(out)}`, + log: (n) => `/log/${n}`, + events: "/events", +}; + +const REVIEW_STYLE = ``; + +// The page's script: the event stream, the page fetched again when something it shows has +// changed, the comparison's modes, and the keys j and k for the next and previous picture. +const REVIEW_SCRIPT = ``; + +const HEAD = `${REVIEW_STYLE}\n${REVIEW_SCRIPT}\n`; + +// ---- chips ---------------------------------------------------------------------------- + +const PNG_STATES = { + new: "new", + updated: "updated", + unchanged: "unchanged", + noise: "only noise", + restored: "restored as HEAD has it", + dark: "the dark picture's pixels", + once: "taken once: the light file goes", +}; + +/** What the tool decided about a picture's PNG, as a few words. */ +export const pngStateText = (png) => PNG_STATES[png.state] ?? png.state; + +/** What the tool decided about a picture's SVG, as a few words. */ +export function svgStateText(svg) { + if (svg.state === "not written") return svg.faithful === false ? "refused" : "none"; + if (svg.state === "removed") return "removed"; + if (svg.state === "unchanged") return "unchanged"; + return `${svg.state}${svg.faithful === null ? "" : ", accepted"}`; +} + +/** The chips of a picture `p` (reviewPictures'): its state, and why it needs a look. */ +export function chipsOf(p) { + const chips = []; + if (p.progress) chips.push(chipHtml("in progress", "progress")); + if (p.failed) chips.push(chipHtml("failed", "fail", p.failed.error)); + if (p.retake) chips.push(chipHtml("needs a retake", "fail", p.retake.why)); + if (!p.png && !p.svg && !p.failed && !p.retake && !p.progress) + chips.push( + p.skipped + ? chipHtml("not taken", "fail", `run ${p.skipped} was stopped first`) + : chipHtml("no verdict", "fail", "the run ended before it reported this picture"), + ); + if (p.png) { + const change = p.png.state === "updated" || p.png.state === "new"; + chips.push( + chipHtml( + `PNG: ${pngStateText(p.png)}`, + p.png.by === "decision" ? "review" : change ? "change" : "", + p.png.by === "decision" ? "as a review decided" : "", + ), + ); + if (p.png.look) chips.push(chipHtml(`PNG: ${p.png.look.why}`, "look", p.png.look.text)); + } + if (p.svg) { + chips.push( + chipHtml( + `SVG: ${svgStateText(p.svg)}`, + p.svg.by === "decision" ? "review" : "", + p.svg.why ?? p.svg.measures ?? "", + ), + ); + if (p.svg.look) chips.push(chipHtml(`SVG: ${p.svg.look.why}`, "look", p.svg.look.text)); + } + return chips.join(""); +} + +// ---- the overview --------------------------------------------------------------------- + +/** The thumbnail of a picture `p`: the run's capture when it staged one, else the file under docs/ when `exists`. */ +export function thumbOf(p, exists) { + if (p.png?.capture) return hrefs.run(p.png.run, p.png.capture); + return exists(p.out) ? hrefs.docs(p.out) : null; +} + +const cardOf = (p, filter, exists) => { + const thumb = thumbOf(p, exists); + const link = hrefs.picture(p.out, filter); + return ``; +}; + +const runState = (r) => { + if (r.live) return r.stopping ? "stopping" : "running"; + if (r.interrupted) return "interrupted: the tool closed while it ran"; + if (r.stopped) return `stopped, exit ${r.exit}`; + return `ended, exit ${r.exit}`; +}; + +const runsHtml = (runs) => + runs.length + ? `

    Runs

    + + +${[...runs] + .reverse() + .map( + (r) => + ``, + ) + .join("\n")} +
    RunStatePicturesThe same as
    ${r.n}${esc(runState(r))}${r.done} of ${r.listed}${esc(r.command)} (log)
    ` + : `

    No run yet. Start the tool with --retake <regex> or --replay <regex> to start one.

    `; + +/** + * The overview: `groups` reviewPictures' groups, with only the pictures the filter shows; + * `filter` its key; `counts` how many pictures each filter shows ({all, look, overridden, + * nosvg}); `runs` [{n, command, live, stopping, interrupted, stopped, exit, done, listed}], + * oldest first; `baseline` the session's ({hash, short, build}); `headNow` HEAD's short hash + * when it is no longer the baseline, else null; `newestIde` the newest install's build, or + * null; `resume` {index, total, out} for the queue's place, or null; `exists(path)` whether a + * file is under docs/. + */ +export function overviewHtml({ groups, filter, counts, runs, baseline, headNow, newestIde, resume, exists }) { + const shown = groups.filter((g) => g.pictures.length); + const filters = FILTERS.map( + ([key, label]) => + `${esc(label)} (${counts[key] ?? 0})`, + ).join(""); + const resumeLink = resume + ? `

    Resume at ${resume.index + 1} of ${resume.total} in Needs a look

    ` + : ""; + const notices = []; + if (headNow) + notices.push( + `HEAD is now ${esc(headNow)}: this session compares with its baseline, ${esc(baseline.short)}, until it is committed.`, + ); + if (newestIde && baseline.build && newestIde > baseline.build) + notices.push(`BETA ${newestIde} found: the baseline's pictures were taken on BETA ${baseline.build} at newest.`); + const group = (g) => { + const cards = `
    +${g.pictures.map((p) => cardOf(p, filter, exists)).join("\n")} +
    `; + const heading = `${esc(g.title)} (${g.pictures.length})`; + return g.key === "unchanged" + ? `

    ${heading}

    +${cards} +
    ` + : `

    ${heading}

    +${cards}`; + }; + return pageHtml({ + generator: REVIEW_GENERATOR, + title: "Screenshot review", + head: HEAD, + intro: `Every picture this session's runs took or drew, against the baseline ${esc(baseline.short)}${baseline.build ? ` (its pictures taken on BETA ${baseline.build} at newest)` : ""}. Nothing is written under docs until a verdict is applied.`, + summary: shown.map((g) => [g.title, g.pictures.length]), + contents: shown.map((g) => `
  • ${esc(g.title)} (${g.pictures.length})
  • `).join("\n"), + body: `${notices.map((n) => `

    ${n}

    `).join("\n")} + +${resumeLink}${runsHtml(runs)} +${shown.length ? shown.map(group).join("\n") : "

    No picture.

    "}`, + }); +} + +// ---- the detail page ------------------------------------------------------------------ + +const pct = (n) => `${+(n * 100).toFixed(3)}%`; + +// A box over a picture of `size` ([w, h] device pixels) at the rectangle {x0, y0, x1, y1}, in percent. +const boxStyle = (r, [w, h]) => + `left:${(r.x0 / w) * 100}%;top:${(r.y0 / h) * 100}%;width:${((r.x1 - r.x0) / w) * 100}%;height:${((r.y1 - r.y0) / h) * 100}%`; + +/** A picture at half its size in device pixels (the pictures are 2x), with `overlay` (HTML) over it. */ +const picHtml = (src, size, alt, overlay = "") => + `${esc(alt)}${overlay}`; + +const figureHtml = (src, size, caption, overlay = "") => + `
    ${picHtml(src, size, caption, overlay)}
    ${esc(caption)}
    `; + +/** The boxes of `regions` and the rectangles left out (`rects`) over a picture of `size`. */ +function overlayOf(regions, rects, size) { + if (!size) return ""; + const boxes = (regions ?? []).map( + (r) => + ``, + ); + const ignored = (rects ?? []).map((r) => ``); + return [...boxes, ...ignored].join(""); +} + +// How much a loupe enlarges: up to 4 times the picture's device pixels, at most 360 by 240 CSS px. +const LOUPE_PAD = 6; +const zoomOf = (w, h) => Math.max(0.5, Math.min(4, 360 / w, 240 / h)); + +/** + * A loupe for each region of `regions` that survives (or the first four when none does): the + * region cropped and enlarged from the picture `before` and the picture `after` ({src, + * label}), both of `size`. + */ +export function loupesHtml(regions, size, before, after) { + if (!regions?.length || !size) return ""; + const kept = regions.filter((r) => r.core >= SURVIVES); + const list = (kept.length ? kept : regions.slice(0, 4)).slice(0, 12); + const [W, H] = size; + const crop = (src, r, label) => { + const x0 = Math.max(0, r.x0 - LOUPE_PAD); + const y0 = Math.max(0, r.y0 - LOUPE_PAD); + const x1 = Math.min(W, r.x1 + LOUPE_PAD); + const y1 = Math.min(H, r.y1 + LOUPE_PAD); + const z = zoomOf(x1 - x0, y1 - y0); + const px = (n) => `${+(n * z).toFixed(2)}px`; + return ``; + }; + return `
    +${list + .map( + (r) => + `
    ${crop(before.src, r, before.label)}${crop(after.src, r, after.label)}
    ${esc(before.label)} | ${esc(after.label)}: ${r.x1 - r.x0} x ${r.y1 - r.y0} px at ${r.x0}, ${r.y0}; ${r.pixels} px changed, ${r.core} px of core${r.core >= SURVIVES ? "" : " (a fringe)"}
    `, + ) + .join("\n")} +
    `; +} + +const factsHtml = (facts) => + `
    ${facts + .filter(([, v]) => v !== null && v !== undefined && v !== "") + .map(([k, v]) => `
    ${esc(k)}
    ${v}
    `) + .join("")}
    `; + +const rectsText = (rects) => + rects.length ? rects.map((r) => `[${r.x0}, ${r.y0}, ${r.x1}, ${r.y1}]`).join(", ") : "none"; + +const AGAINST = { revision: "HEAD", disk: "the file", dark: "the dark picture" }; + +/** + * The detail page of one picture: `p` reviewPictures' picture; `filter` the filter it was + * opened from, `place` {index, total, group} its place in that filter's list, `prev` and + * `next` its neighbours there (pictures, or null); `files` {head, reference, capture, svgPng, + * svg}, each {src, size} or null, and `maps` {png, svg}, each an address or null; `rules` + * {png: pngRule, svg: svgRule} of its entry in the manifest, `build` the build the manifest + * records for its file, or null; `baseline` the session's. + */ +export function detailHtml({ p, filter, place, prev, next, files, maps, rules, build, baseline }) { + const filterLabel = (FILTERS.find(([k]) => k === filter) ?? FILTERS[0])[1]; + const groupTitle = (GROUPS.find(([k]) => k === p.group) ?? [null, p.group])[1]; + const nav = ``; + const png = p.png; + const svg = p.svg; + const size = files.capture?.size ?? files.head?.size ?? null; + const theme = /\.light\.png$/i.test(p.out) ? "light" : "dark"; + + // The comparison: the picture judged against (or HEAD's), the capture, and the map. + const before = files.reference ?? files.head; + const beforeLabel = files.reference ? (AGAINST[png?.against] ?? "before") : `HEAD (${baseline.short})`; + let compare = ""; + if (files.capture) { + const side = []; + if (files.head) side.push(figureHtml(files.head.src, files.head.size, `HEAD (${baseline.short})`)); + if (files.reference && png?.against !== "revision") + side.push(figureHtml(files.reference.src, files.reference.size, AGAINST[png.against] ?? "judged against")); + side.push(figureHtml(files.capture.src, files.capture.size, `New capture, run ${png.run}`)); + const stack = (cls) => + before + ? `
    ${esc(beforeLabel)}New capture${cls === "swipe" ? `` : ""}

    ${esc(beforeLabel)} ${cls === "swipe" ? "on the left, the new capture on the right" : "and the new capture, in turn"}

    ` + : ""; + const overlay = overlayOf(png.regions, rules.png.rects, size); + compare = `
    +
    ${[ + ["side", "Side by side"], + ["swipe", "Swipe"], + ["blink", "Blink"], + ["map", "Difference map"], + ] + .filter(([m]) => before || m === "side") + .filter(([m]) => maps.png || m !== "map") + .map(([m, label]) => ``) + .join("")}
    +
    ${side.join("\n")}
    +${stack("swipe")} +${stack("blink")} +${maps.png ? `
    ${figureHtml(maps.png, size, `Difference against ${beforeLabel} (yellow: 1 grey level, shading to red at 128 or more); boxes: the regions of change, dashed where nothing survives the erosion`, overlay)}
    ` : ""} +
    `; + } else if (files.head) + compare = `
    ${figureHtml(files.head.src, files.head.size, `HEAD (${baseline.short})`)}
    `; + + // The PNG row. + let pngRow = ""; + if (png) { + const rule = rules.png; + pngRow = `
    +

    PNG: ${esc(pngStateText(png))}

    +${png.look ? `

    Needs a look: ${esc(png.look.text)}.

    ` : ""}${factsHtml([ + ["Decided by", png.by === "decision" ? "a review's decision about these bytes" : "the rule"], + ["Earlier decision", png.earlier ? esc(`a review kept the ${png.earlier} picture of other bytes`) : null], + ["Judged against", png.against ? esc(AGAINST[png.against] ?? png.against) : "nothing: a new picture"], + [ + "Regions of change", + png.regionCount === null + ? "not measured" + : `${png.regionCount}, ${png.regions.filter((r) => r.core >= SURVIVES).length} surviving the erosion`, + ], + ["Taken on", png.build ? `BETA ${png.build}` : null], + ["The file's build", build ? `BETA ${build}` : "unknown"], + ["Noise levels", `${rule.levels}`], + ["Noise share", pct(rule.share)], + ["Left out", esc(rectsText(rule.rects))], +])} +${files.capture && before ? loupesHtml(png.regions, size, { src: before.src, label: beforeLabel }, { src: files.capture.src, label: "new" }) : ""} +
    `; + } + + // The SVG row. + let svgRow = ""; + if (svg) { + const rule = rules.svg; + const svgSize = files.svgPng?.size ?? size; + const restored = png?.state === "restored"; + const figs = []; + if (files.svgPng) figs.push(figureHtml(files.svgPng.src, files.svgPng.size, "The PNG it is compared with")); + if (files.svg) figs.push(figureHtml(files.svg.src, svgSize, "The SVG")); + if (maps.svg) + figs.push( + figureHtml( + maps.svg, + svgSize, + `Where the SVG is off the PNG; boxes: the regions at ${rule.levels} levels or more`, + overlayOf(svg.regions, rule.rects, svgSize), + ), + ); + svgRow = ` +

    SVG: ${esc(svgStateText(svg))}

    +${restored ? "

    The PNG goes back to HEAD's, and its SVG goes back with it.

    " : ""}${svg.look ? `

    Needs a look: ${esc(svg.look.text)}.

    ` : ""}${factsHtml( + [ + ["Run", `${svg.run}`], + ["Decided by", svg.by === "decision" ? "a review's decision about these bytes" : svg.by ? "the rule" : null], + [ + "Earlier decision", + svg.earlier === null || svg.earlier === undefined + ? null + : `a review ${svg.earlier ? "accepted" : "refused"} an SVG of other bytes`, + ], + ["Why", svg.why ? esc(svg.why) : null], + ["Off the PNG", svg.measures ? esc(svg.measures) : null], + ["Share counted", svg.share === null ? null : `${svg.share}%`], + ["Bar", `${rule.share}% of the pixels at ${rule.levels} levels or more`], + ["Left out", esc(rectsText(rule.rects))], + ], +)} +
    ${figs.join("\n")}
    +${files.svgPng && files.svg ? loupesHtml(svg.regions, svgSize, { src: files.svgPng.src, label: "PNG" }, { src: files.svg.src, label: "SVG" }) : ""} +`; + } + + const notes = []; + if (p.progress) notes.push("A run is taking or drawing this picture now."); + if (p.failed) notes.push(`Run ${p.failed.run} failed on it: ${esc(p.failed.error)}`); + if (p.retake) notes.push(`Run ${p.retake.run} could not draw it again: ${esc(p.retake.why)}`); + if (p.skipped && !png && !svg) notes.push(`Run ${p.skipped} was stopped before it took this picture.`); + const body = `${nav} +

    ${theme} theme, ${esc(sizeText(size))}; ${chipsOf(p)}

    +${notes.map((n) => `

    ${n}

    `).join("\n")} +${compare} +${pngRow} +${svgRow}`; + return pageHtml({ + generator: REVIEW_GENERATOR, + title: p.out, + head: HEAD, + intro: `In the group ${esc(groupTitle)}.`, + body, + }).replace("", ``); +} diff --git a/scripts/lib/shot-review.mjs b/scripts/lib/shot-review.mjs new file mode 100644 index 000000000..1cfa7063e --- /dev/null +++ b/scripts/lib/shot-review.mjs @@ -0,0 +1,210 @@ +// What the screenshot review tool shows of a session (WIP.ShotReview.md, The pages): every +// picture its runs reported, each with the newest verdict on its PNG and on its SVG, and the +// group of the overview it is in. Nothing here reads a file: the server reads each run's +// events (shot-session.mjs) and verdicts (shot-stage.mjs), and passes in what else the model +// needs to know, such as whether a file is under docs/. + +import { svgOf } from "./shot-apply.mjs"; + +/** The overview's groups, in its order: [key, heading]. */ +export const GROUPS = [ + ["progress", "In progress"], + ["look", "Needs a look"], + ["changed", "Changed, most changed first"], + ["noise", "Only noise"], + ["unchanged", "Unchanged"], + ["added", "Added"], + ["deleted", "Deleted"], + ["failed", "Failed or not taken"], +]; + +/** The overview's filters: [key, label]. */ +export const FILTERS = [ + ["all", "All"], + ["look", "Needs a look"], + ["overridden", "Overridden"], + ["nosvg", "No SVG"], +]; + +/** + * What the events of a run of `tool` ("shoot_docs" or "replay") say: {listed, svg, jobs, + * failed, skipped, retakes, stopping, summary}. `listed` the pictures the run is to take or + * draw, in its order; `svg` whether it gives each an SVG verdict (a replay's are SVG verdicts + * only); `jobs` a Map from a job's name to {pictures, state, error}, state "waiting", + * "started", "done" or "failed" (a job that failed is then reported done, and stays failed), + * and `jobOf` from a picture to its job's name; `failed` a Map from a picture to its error; + * `skipped` the pictures a stopped run did not take; `retakes` a Map from a picture replay + * could not draw to why; `summary` the run's last event, or null while it runs. + */ +export function runFacts(tool, events) { + const facts = { + listed: [], + svg: tool === "replay", + jobs: new Map(), + jobOf: new Map(), + failed: new Map(), + skipped: new Set(), + retakes: new Map(), + stopping: false, + summary: null, + }; + for (const e of events) { + if (e.type === "run") { + if (tool === "replay") facts.listed = e.pictures ?? []; + else { + facts.svg = !!e.svg; + facts.listed = (e.jobs ?? []).flatMap((j) => j.pictures); + for (const j of e.jobs ?? []) { + facts.jobs.set(j.name, { pictures: j.pictures.length, state: "waiting", error: null }); + for (const out of j.pictures) facts.jobOf.set(out, j.name); + } + } + } else if (e.type === "job" && facts.jobs.has(e.job)) { + const job = facts.jobs.get(e.job); + if (job.state !== "failed") job.state = e.state; + if (e.state === "failed") job.error = e.error ?? null; + } else if (e.type === "failed") facts.failed.set(e.out, e.error); + else if (e.type === "skipped") facts.skipped.add(e.out); + else if (e.type === "retake") facts.retakes.set(e.out, e.why); + else if (e.type === "stopping") facts.stopping = true; + else if (e.type === "summary") facts.summary = e; + } + return facts; +} + +/** + * The verdict a `picture` or `svg` event carries, as its part of a stage's verdict file: the + * event without its type, job and picture. Returns [part, value], part "png" or "svg". + */ +export function verdictOfEvent(e) { + const { type, job: _, out: __, ...value } = e; + return [type === "picture" ? "png" : "svg", value]; +} + +// The PNG verdicts after which a run draws no SVG. +const NO_SVG = new Set(["dark", "once"]); + +// Whether a run still owes the verdict `v` ({png, svg} or undefined) a part. +function owes(run, v) { + if (run.tool === "replay") return !v?.svg; + if (!v?.png) return true; + return run.facts.svg && !v.svg && !NO_SVG.has(v.png.state); +} + +/** + * The SVG states that change the file: written or removed. "unchanged" and "not written" + * leave it as it is. + */ +export const SVG_CHANGES = new Set(["new", "updated", "removed"]); + +/** + * The group of the overview the picture `p` is in (GROUPS): `exists(path)` says whether a + * file is under docs/. + */ +export function groupOf(p, exists) { + if (p.progress) return "progress"; + if (p.failed) return "failed"; + if (p.png?.look || p.svg?.look) return "look"; + if (!p.png && !p.svg) return "failed"; + switch (p.png?.state) { + case "updated": + return "changed"; + case "new": + return "added"; + case "noise": + case "restored": + return "noise"; + case "dark": + case "once": + return exists(p.out) ? "deleted" : "unchanged"; + } + return SVG_CHANGES.has(p.svg?.state) ? "changed" : "unchanged"; +} + +/** + * Every picture the session's `runs` reported, in the overview's order: `runs` is + * [{n, tool, live, facts, verdicts}], oldest first, `facts` runFacts', `verdicts` a Map from a + * picture to {png, svg}, each part null or as the stage holds it. A picture takes its PNG + * verdict from the newest run that gave it one, and its SVG verdict from the newest run since + * then: an SVG changes with its PNG, so a retake's PNG verdict drops an older SVG's. + * + * Returns {pictures, groups}: each picture {out, png, svg, failed, retake, skipped, progress, + * group, measure}, `png` and `svg` with the run that gave them as `run`, `failed` and `retake` + * {run, error | why} or null, `skipped` the run that did not take it, or null; `progress` + * whether a run that is running still owes it a verdict; `measure` how much it changed, from + * `measure(p)`, which orders the Changed group, most first. `groups` is GROUPS with each + * one's pictures, [{key, title, pictures}]. + */ +export function reviewPictures(runs, { exists = () => false, measure = () => 0 } = {}) { + const byOut = new Map(); + const at = (out) => { + if (!byOut.has(out)) + byOut.set(out, { out, png: null, svg: null, failed: null, retake: null, skipped: null, progress: false }); + return byOut.get(out); + }; + for (const run of [...runs].sort((a, b) => a.n - b.n)) { + const { facts, n } = run; + for (const [out, v] of run.verdicts) { + const p = at(out); + if (v.png) { + p.png = { run: n, ...v.png }; + p.svg = v.svg ? { run: n, ...v.svg } : null; + } else if (v.svg) p.svg = { run: n, ...v.svg }; + if (v.png || v.svg) Object.assign(p, { failed: null, retake: null, skipped: null }); + } + for (const [out, error] of facts.failed) at(out).failed = { run: n, error }; + for (const [out, why] of facts.retakes) at(out).retake = { run: n, why }; + for (const out of facts.skipped) if (!run.verdicts.has(out)) at(out).skipped = n; + for (const out of facts.listed) { + const p = at(out); + const owed = !facts.failed.has(out) && !facts.skipped.has(out) && owes(run, run.verdicts.get(out)); + p.progress = run.live && owed; + // a picture its job never reached, the job having failed first + const job = facts.jobs.get(facts.jobOf.get(out)); + if (owed && job?.state === "failed" && !run.verdicts.get(out)?.png) + p.failed = { run: n, error: `its job, ${facts.jobOf.get(out)}, failed: ${job.error}` }; + } + } + const pictures = [...byOut.values()].sort((a, b) => (a.out < b.out ? -1 : a.out > b.out ? 1 : 0)); + for (const p of pictures) { + p.group = groupOf(p, exists); + p.measure = p.group === "changed" ? measure(p) : 0; + } + const groups = GROUPS.map(([key, title]) => ({ key, title, pictures: pictures.filter((p) => p.group === key) })); + const changed = groups.find((g) => g.key === "changed"); + changed.pictures.sort((a, b) => b.measure - a.measure || (a.out < b.out ? -1 : 1)); + return { pictures: groups.flatMap((g) => g.pictures), groups }; +} + +/** + * Whether the picture `p` has an SVG once its verdicts are applied: written by this one, or a + * file there that it leaves. + */ +export function hasSvg(p, exists) { + const state = p.svg?.state; + if (state === "new" || state === "updated") return true; + if (state === "removed" || state === "not written") return false; + return exists(svgOf(p.out)); +} + +/** + * The pictures of `pictures` (in the overview's order) a filter of FILTERS shows: + * `overridden(out)` whether the manifest holds a decision or an allowance for one, + * `exists(path)` whether a file is under docs/. An unknown filter shows them all. + */ +export function filterPictures(pictures, filter, { overridden = () => false, exists = () => false } = {}) { + if (filter === "look") return pictures.filter((p) => p.group === "look"); + if (filter === "overridden") return pictures.filter((p) => overridden(p.out)); + if (filter === "nosvg") return pictures.filter((p) => !hasSvg(p, exists)); + return pictures; +} + +/** Whether a manifest entry holds a decision or an allowance: anything but the build. */ +export const isOverridden = (entry) => !!(entry?.png || entry?.svg); + +/** Where `out` is in `list` (pictures): {index, prev, next}, index -1 and no neighbours when it is not there. */ +export function neighbours(list, out) { + const index = list.findIndex((p) => p.out === out); + if (index < 0) return { index, prev: null, next: null }; + return { index, prev: list[index - 1] ?? null, next: list[index + 1] ?? null }; +} diff --git a/scripts/lib/shot-runs.mjs b/scripts/lib/shot-runs.mjs new file mode 100644 index 000000000..15a746326 --- /dev/null +++ b/scripts/lib/shot-runs.mjs @@ -0,0 +1,85 @@ +// A run of the screenshot review tool (WIP.ShotReview.md, Runs): shoot_docs.mjs or replay.mjs +// as a child process, never their code in the tool's process, given the run's stage +// (--stage) and asked for its events (--events). Its events are kept in the run's events +// file as they arrive and its other lines in its log, so that a session opened again finds +// them (shot-session.mjs). A run is stopped by a line on its stdin (record-lines.mjs), which +// lets it end its IDEs and put the registry back. + +import { spawn } from "node:child_process"; +import { appendFileSync, mkdirSync } from "node:fs"; +import path from "node:path"; +import { REPO_ROOT } from "../../lib/repo-paths.mjs"; +import { readRecords, sendStop } from "./record-lines.mjs"; +import { appendEvent, RUN_TOOLS, runPaths } from "./shot-session.mjs"; + +/** The kind of record a run writes its events as (--events). */ +export const EVENT_KIND = "shot-event"; + +/** + * The arguments a run of `tool` is started with, after the script: its own `args`, then the + * stage and --events. + */ +export const runArgs = (args, stage) => [...args, "--stage", stage, "--events"]; + +/** + * Starts a run of `tool` with `args` in `session` (openSession): adds it to the session and + * spawns the tool from the repository's root. `onEvent(run, value)` is called for each event, + * `onLine(run, line)` for each other line, stdout and stderr alike, and `onExit(run)` once + * it has ended and the session records how. `command` stands in for the script's path in the + * probes: {node, script}. Returns {run, stop(), kill(), exited}: `stop` asks the run to stop, + * `kill` ends its process (which skips putting the registry back), `exited` a promise of + * its exit code. + */ +export function startRun( + session, + { tool, args }, + { onEvent = () => {}, onLine = () => {}, onExit = () => {}, command } = {}, +) { + const run = session.addRun({ tool, args }); + const files = runPaths(session.root, run.n); + mkdirSync(files.dir, { recursive: true }); + const node = command?.node ?? process.execPath; + const script = command?.script ?? path.join(REPO_ROOT, RUN_TOOLS[tool]); + // A process group of its own, so that the Ctrl+C that closes the tool does not end the run + // before it has put the registry back: the tool stops it by its stdin instead. + const child = spawn(node, [script, ...runArgs(args, files.stage)], { + cwd: REPO_ROOT, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + detached: true, + }); + // A stop sent as the run ends meets a closed pipe. + child.stdin.on("error", () => {}); + let stopped = false; + const line = (text) => { + appendFileSync(files.log, `${text}\n`); + onLine(run, text); + }; + readRecords(child.stdout, [EVENT_KIND], { + onRecord: (_, value) => { + appendEvent(files.events, value); + if (value.type === "summary") stopped ||= !!value.stopped; + onEvent(run, value); + }, + onLine: line, + }); + readRecords(child.stderr, [], { onRecord: () => {}, onLine: line }); + const exited = new Promise((resolve) => { + child.on("error", (e) => line(`the run could not start: ${e.message}`)); + child.on("close", (code) => { + session.endRun(run.n, { exit: code, stopped }); + onExit(run); + resolve(code); + }); + }); + return { + run, + child, + stop() { + stopped = true; + sendStop(child); + }, + kill: () => child.kill(), + exited, + }; +} diff --git a/scripts/lib/shot-server.mjs b/scripts/lib/shot-server.mjs new file mode 100644 index 000000000..ed68da4f1 --- /dev/null +++ b/scripts/lib/shot-server.mjs @@ -0,0 +1,397 @@ +// The screenshot review tool's server (WIP.ShotReview.md): the pages of one session +// (shot-pages.mjs), the files they show, and the session's runs, whose events reach the pages +// over server-sent events (lib/sse.mjs) as they arrive. It listens on the loopback address +// only and answers a request only when it names the loopback address as its host, since +// its pages and pictures can hold the Windows user name. +// +// It decides nothing and writes nothing under docs/: the verdicts are the runs' (shot-policy.mjs, +// in shoot_docs.mjs and replay.mjs), read from their stages and events, and the model of what +// the pages show is shot-review.mjs's. The one thing it writes is the session (shot-session.mjs): +// its runs, and the queue's place. + +import { existsSync, readFileSync, statSync } from "node:fs"; +import { createServer } from "node:http"; +import path from "node:path"; +import { DOCS_DIR } from "../../lib/repo-paths.mjs"; +import { createSseChannel } from "../../lib/sse.mjs"; +import { decodePng } from "./png.mjs"; +import { svgOf } from "./shot-apply.mjs"; +import { shareOff, strengthPicture } from "./shot-change.mjs"; +import { detailHtml, hrefs, overviewHtml } from "./shot-pages.mjs"; +import { pngRule, pngSize, svgRule } from "./shot-policy.mjs"; +import { + FILTERS, + filterPictures, + isOverridden, + neighbours, + reviewPictures, + runFacts, + verdictOfEvent, +} from "./shot-review.mjs"; +import { startRun } from "./shot-runs.mjs"; +import { commandOf, readEvents, runPaths } from "./shot-session.mjs"; +import { readStrength, readVerdicts } from "./shot-stage.mjs"; + +const TYPES = { ".png": "image/png", ".svg": "image/svg+xml" }; + +// A picture's or an SVG's path under docs/, as a URL gives it: forward slashes, no empty or +// parent segment. +const isDocsPath = (p) => + /^[^\\]+\.(png|svg)$/i.test(p) && !p.startsWith("/") && !p.split("/").some((s) => s === "" || s === ".."); + +const readIf = (file) => (existsSync(file) && statSync(file).isFile() ? readFileSync(file) : null); + +const sizeOf = (png) => { + try { + const { w, h } = pngSize(png); + return [w, h]; + } catch { + return null; + } +}; + +// The most difference maps kept drawn: drawing one decodes a picture of up to 4.5 M pixels. +const MAPS_KEPT = 16; + +/** + * The server of the session `session` (openSession): `revision` the baseline as openRevision + * gives it, `manifest()` the manifest as it is now (openManifest), `docsRoot` the pictures' + * folder, `headNow` HEAD's short hash when it is no longer the baseline, `newestIde` the + * newest install's build, `onLine(run, line)` called for each line of a run meant for a + * person, and `command` the probes' stand-in for the tools a run starts (startRun). Returns {server, listen(port, host), start({tool, args}), live(), stopAll(), + * close(), model()}: `listen` resolves to the port it listens on; `start` starts a run, and + * returns startRun's handle; `live` the handles of the runs that are running. + */ +export function createReviewServer({ + session, + revision, + manifest, + docsRoot = DOCS_DIR, + headNow = null, + newestIde = null, + onLine, + command, +}) { + const sse = createSseChannel(); + // run n -> {record, live, handle, events, facts, verdicts} + const runs = new Map(); + for (const record of session.data.runs) { + const files = runPaths(session.root, record.n); + const events = readEvents(files.events); + const verdicts = new Map(readVerdicts(files.stage).map((v) => [v.out, { png: v.png, svg: v.svg }])); + runs.set(record.n, { record, live: false, handle: null, events, facts: runFacts(record.tool, events), verdicts }); + } + const stageFile = (n, rel) => path.join(runPaths(session.root, n).stage, rel); + const exists = (rel) => existsSync(path.join(docsRoot, rel)); + const entry = (out) => manifest().entry(out); + + // How much a changed picture changed, which orders the Changed group: a PNG's share of + // pixels beyond its noise levels, from its strength map; an SVG's share off its PNG. + const measures = new Map(); + const measure = (p) => { + const key = `${p.png?.run}:${p.svg?.run}:${p.out}`; + if (!measures.has(key)) { + let m = p.svg?.share ?? p.svg?.stats?.pct96 ?? 0; + if (p.png?.state === "updated" && p.png.strength) { + const rule = pngRule(entry(p.out)); + try { + m = shareOff(readStrength(stageFile(p.png.run, p.png.strength)), { + level: rule.levels + 1, + rects: rule.rects, + }); + } catch {} + } + measures.set(key, m); + } + return measures.get(key); + }; + const model = () => + reviewPictures( + [...runs.values()].map((r) => ({ + n: r.record.n, + tool: r.record.tool, + live: r.live, + facts: r.facts, + verdicts: r.verdicts, + })), + { exists, measure }, + ); + const filterOptions = { overridden: (out) => isOverridden(entry(out)), exists }; + + // ---- runs ---- + const listedOf = (r) => r.facts.listed; + const onEvent = (record, e) => { + const r = runs.get(record.n); + r.events.push(e); + r.facts = runFacts(record.tool, r.events); + if (e.type === "picture" || e.type === "svg") { + const [part, value] = verdictOfEvent(e); + const v = r.verdicts.get(e.out) ?? { png: null, svg: null }; + v[part] = value; + r.verdicts.set(e.out, v); + } + if (e.out) sse.send("update", { outs: [e.out] }); + else sse.send("update", { outs: e.type === "run" || e.type === "summary" ? listedOf(r) : [], runs: true }); + }; + const onExit = (record) => { + const r = runs.get(record.n); + r.live = false; + r.handle = null; + sse.send("update", { outs: listedOf(r), runs: true }); + }; + function start({ tool, args }) { + const handle = startRun(session, { tool, args }, { onEvent, onExit, onLine, command }); + runs.set(handle.run.n, { + record: handle.run, + live: true, + handle, + events: [], + facts: runFacts(tool, []), + verdicts: new Map(), + }); + sse.send("update", { outs: [], runs: true }); + return handle; + } + const live = () => [...runs.values()].filter((r) => r.live).map((r) => r.handle); + + // A run's summary line for the overview. + const runRow = (r) => { + const owed = r.record.tool === "replay" ? "svg" : "png"; + const done = r.facts.listed.filter( + (out) => r.verdicts.get(out)?.[owed] || r.facts.failed.has(out) || r.facts.skipped.has(out), + ).length; + return { + ...r.record, + command: commandOf(r.record), + live: r.live, + stopping: r.facts.stopping, + done, + listed: r.facts.listed.length, + }; + }; + + // ---- the files the pages show ---- + const verdictOf = (n, out) => runs.get(n)?.verdicts.get(out) ?? null; + // The picture the run `n` judged its capture of `out` against. + function referenceBytes(n, out) { + const against = verdictOf(n, out)?.png?.against; + if (against === "revision") return revision.bytes(out); + if (against === "disk") return readIf(path.join(docsRoot, out)); + if (against === "dark") { + const dark = out.replace(/\.light\.png$/i, ".png"); + const staged = verdictOf(n, dark)?.png?.capture; + return staged ? readIf(stageFile(n, staged)) : readIf(path.join(docsRoot, dark)); + } + return null; + } + // The PNG the run `n` compared its SVG of `out` with: its capture, else the file. + function svgPngBytes(n, out) { + const capture = verdictOf(n, out)?.png?.capture; + return capture ? readIf(stageFile(n, capture)) : readIf(path.join(docsRoot, out)); + } + const maps = new Map(); + function mapBytes(n, part, out) { + const key = `${n}/${part}/${out}`; + if (maps.has(key)) return maps.get(key); + const v = verdictOf(n, out)?.[part]; + if (!v?.strength) return null; + const base = part === "png" ? readIf(stageFile(n, v.capture)) : svgPngBytes(n, out); + if (!base) return null; + const bytes = strengthPicture(readStrength(stageFile(n, v.strength)), decodePng(base)); + maps.set(key, bytes); + if (maps.size > MAPS_KEPT) maps.delete(maps.keys().next().value); + return bytes; + } + + // ---- the pages ---- + function overview(filter) { + const m = model(); + const counts = Object.fromEntries(FILTERS.map(([k]) => [k, filterPictures(m.pictures, k, filterOptions).length])); + const shown = new Set(filterPictures(m.pictures, filter, filterOptions).map((p) => p.out)); + const look = filterPictures(m.pictures, "look", filterOptions); + const at = Math.max( + 0, + look.findIndex((p) => p.out === session.data.place), + ); + return overviewHtml({ + groups: m.groups.map((g) => ({ ...g, pictures: g.pictures.filter((p) => shown.has(p.out)) })), + filter, + counts, + runs: [...runs.values()].map(runRow), + baseline: session.data.baseline, + headNow, + newestIde, + resume: look.length ? { index: at, total: look.length, out: look[at].out } : null, + exists, + }); + } + function detail(out, wanted) { + const m = model(); + const p = m.pictures.find((q) => q.out === out); + if (!p) return null; + let filter = wanted; + let list = filterPictures(m.pictures, filter, filterOptions); + let place = neighbours(list, out); + if (place.index < 0) { + filter = "all"; + list = m.pictures; + place = neighbours(list, out); + } + if (filter === "look") session.setPlace(out); + const file = (src, bytes) => (bytes ? { src, size: sizeOf(bytes) } : null); + const n = p.png?.run; + const files = { + head: file(hrefs.head(out), revision.bytes(out)), + capture: p.png?.capture ? file(hrefs.run(n, p.png.capture), readIf(stageFile(n, p.png.capture))) : null, + reference: p.png?.against ? file(hrefs.reference(n, out), referenceBytes(n, out)) : null, + svgPng: null, + svg: null, + }; + if (p.svg) { + const s = p.svg.run; + const capture = verdictOf(s, out)?.png?.capture; + files.svgPng = capture + ? file(hrefs.run(s, capture), readIf(stageFile(s, capture))) + : file(hrefs.docs(out), readIf(path.join(docsRoot, out))); + if (p.svg.svg) files.svg = { src: hrefs.run(s, p.svg.svg), size: null }; + else if (exists(svgOf(out))) files.svg = { src: hrefs.docs(svgOf(out)), size: null }; + } + const e = entry(out); + return detailHtml({ + p, + filter, + place: { index: place.index, total: list.length }, + prev: place.prev, + next: place.next, + files, + maps: { + png: p.png?.strength ? hrefs.map(n, "png", out) : null, + svg: p.svg?.strength ? hrefs.map(p.svg.run, "svg", out) : null, + }, + rules: { png: pngRule(e), svg: svgRule(e) }, + build: e?.build ?? null, + baseline: session.data.baseline, + }); + } + + // ---- answering ---- + let port = null; + const LOOPBACK = new Set(["127.0.0.1", "localhost", "[::1]"]); + const hostAllowed = (host) => { + const m = /^(.+):(\d+)$/.exec(host ?? ""); + return !!m && LOOPBACK.has(m[1].toLowerCase()) && Number(m[2]) === port; + }; + const send = (res, status, type, body, headers = {}) => { + res.writeHead(status, { + "content-type": type, + "cache-control": "no-cache", + "x-content-type-options": "nosniff", + ...headers, + }); + res.end(body); + }; + const notFound = (res, what = "not found") => send(res, 404, "text/plain; charset=utf-8", `${what}\n`); + const sendFile = (req, res, name, bytes) => { + if (!bytes) return notFound(res); + const type = TYPES[path.extname(name).toLowerCase()]; + if (!type) return notFound(res); + send(res, 200, type, req.method === "HEAD" ? undefined : bytes, { + "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; font-src data:; img-src data:", + }); + }; + const page = (req, res, html) => send(res, 200, "text/html; charset=utf-8", req.method === "HEAD" ? undefined : html); + + function handle(req, res) { + if (!hostAllowed(req.headers.host)) return send(res, 403, "text/plain", "only the loopback address is answered\n"); + if (req.method !== "GET" && req.method !== "HEAD") return send(res, 405, "text/plain", "read-only\n"); + const url = new URL(req.url, "http://127.0.0.1"); + let segs; + try { + segs = url.pathname.split("/").slice(1).map(decodeURIComponent); + } catch { + return notFound(res); + } + const filter = FILTERS.some(([k]) => k === url.searchParams.get("filter")) ? url.searchParams.get("filter") : "all"; + const [first, ...rest] = segs; + if (url.pathname === "/") return page(req, res, overview(filter)); + if (url.pathname === hrefs.events) return sse.handle(req, res); + if (first === "picture") { + const out = rest.join("/"); + const html = isDocsPath(out) ? detail(out, filter) : null; + return html ? page(req, res, html) : notFound(res, `${out} is not a picture of this session`); + } + if (first === "log") { + const n = Number(rest[0]); + const log = runs.has(n) ? readIf(runPaths(session.root, n).log) : null; + return log ? send(res, 200, "text/plain; charset=utf-8", log) : notFound(res); + } + if (first === "api" && rest[0] === "state" && rest.length === 1) { + const m = model(); + return send( + res, + 200, + "application/json", + JSON.stringify({ baseline: session.data.baseline, runs: [...runs.values()].map(runRow), pictures: m.pictures }), + ); + } + if (first === "file") { + const [kind, ...more] = rest; + if (kind === "head" || kind === "docs") { + const out = more.join("/"); + if (!isDocsPath(out)) return notFound(res); + return sendFile(req, res, out, kind === "head" ? revision.bytes(out) : readIf(path.join(docsRoot, out))); + } + const n = Number(more[0]); + const rel = more.slice(1).join("/"); + if (!runs.has(n) || !rel || rel.split("/").some((s) => s === "" || s === ".." || s.includes("\\"))) + return notFound(res); + if (kind === "run") return sendFile(req, res, rel, readIf(stageFile(n, rel))); + if (kind === "ref" && isDocsPath(rel)) return sendFile(req, res, rel, referenceBytes(n, rel)); + return notFound(res); + } + if (first === "map") { + const [nText, part, ...more] = rest; + const out = more.join("/"); + if (!runs.has(Number(nText)) || (part !== "png" && part !== "svg") || !isDocsPath(out)) return notFound(res); + let bytes; + try { + bytes = mapBytes(Number(nText), part, out); + } catch (e) { + return send(res, 500, "text/plain; charset=utf-8", `the map could not be drawn: ${e.message}\n`); + } + return sendFile(req, res, "map.png", bytes); + } + return notFound(res); + } + + const server = createServer((req, res) => { + try { + handle(req, res); + } catch (e) { + if (!res.headersSent) send(res, 500, "text/plain; charset=utf-8", `${e.stack ?? e}\n`); + else res.end(); + } + }); + return { + server, + listen(wanted = 0, host = "127.0.0.1") { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(wanted, host, () => { + server.off("error", reject); + port = server.address().port; + resolve(port); + }); + }); + }, + start, + live, + model, + close() { + sse.close(); + const closed = new Promise((resolve) => server.close(() => resolve())); + server.closeAllConnections(); + return closed; + }, + }; +} diff --git a/scripts/lib/shot-session.mjs b/scripts/lib/shot-session.mjs new file mode 100644 index 000000000..c1cb8e0b7 --- /dev/null +++ b/scripts/lib/shot-session.mjs @@ -0,0 +1,184 @@ +// The screenshot review tool's session (WIP.ShotReview.md, Sessions): a folder in the +// gitignored .shot-review/, which can hold the Windows user name (a run's stage holds its +// bundles), so it is never under docs/ and never committed. One session at a time, held by +// one tool at a time (its lock). Closing the tool loses nothing: opening it again opens the +// session, and each run's pictures are read back from its stage. +// +// session/session.json the baseline commit and its build, the runs, the queue's place +// session/lock the process id of the tool that holds the session +// session/runs//stage/ the run's stage (shot-stage.mjs) +// session/runs//events.jsonl each event the run reported (--events), a JSON object a line +// session/runs//log.txt the run's lines meant for a person, stdout and stderr +// archive// a closed session: a commit closes one +// +// session.json is {format, created, baseline: {hash, short, build}, runs, place}: `build` the +// newest build the manifest at the baseline records, or null; each run {n, tool, args, +// started, ended, exit, stopped, interrupted}, `args` its options as the command line gives +// them, without --stage and --events, which the tool adds; `place` the picture the queue was +// left at, or null. + +import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { REPO_ROOT } from "../../lib/repo-paths.mjs"; + +/** The folder that holds the session and the archived ones. */ +export const REVIEW_ROOT = path.join(REPO_ROOT, ".shot-review"); + +export const SESSION_FORMAT = 1; + +/** The tools a session runs, by the name a run records, as their paths from the repository's root. */ +export const RUN_TOOLS = { + shoot_docs: "scripts/shoot_docs.mjs", + replay: "scripts/svgshot/replay.mjs", +}; + +/** The files of the session under `root` (REVIEW_ROOT). */ +export const sessionPaths = (root) => { + const dir = path.join(root, "session"); + return { + dir, + file: path.join(dir, "session.json"), + lock: path.join(dir, "lock"), + runs: path.join(dir, "runs"), + archive: path.join(root, "archive"), + }; +}; + +/** The files of the run `n` of the session under `root`. */ +export const runPaths = (root, n) => { + const dir = path.join(sessionPaths(root).runs, String(n)); + return { + dir, + stage: path.join(dir, "stage"), + events: path.join(dir, "events.jsonl"), + log: path.join(dir, "log.txt"), + }; +}; + +const shellQuote = (s) => (/^[A-Za-z0-9_./:=,+-]+$/.test(s) ? s : `"${s.replace(/(["\\$`])/g, "\\$1")}"`); + +/** The command line a run is the same as, to be run from the repository's root in a terminal. */ +export const commandOf = (run) => ["node", RUN_TOOLS[run.tool], ...run.args].map(shellQuote).join(" "); + +/** The newest build a manifest's `pictures` record, or null when none records one. */ +export function newestBuild(pictures) { + let newest = null; + for (const e of Object.values(pictures ?? {})) if (e.build && (newest === null || e.build > newest)) newest = e.build; + return newest; +} + +/** The session in `text`, checked: throws, naming `name`, on anything that is not one. */ +export function parseSession(text, name = "session.json") { + let s; + try { + s = JSON.parse(text); + } catch (e) { + throw new Error(`${name} is not JSON: ${e.message}`); + } + if (s?.format !== SESSION_FORMAT) throw new Error(`${name} is not a session of format ${SESSION_FORMAT}`); + if (!/^[0-9a-f]{40,64}$/.test(s.baseline?.hash ?? "")) throw new Error(`${name} has no baseline commit`); + if (!Array.isArray(s.runs)) throw new Error(`${name} has no list of runs`); + for (const r of s.runs) + if (!Number.isInteger(r.n) || !RUN_TOOLS[r.tool] || !Array.isArray(r.args)) + throw new Error(`${name} has a run that is not one: ${JSON.stringify(r)}`); + return s; +} + +// Whether the process `pid` is running: signal 0 tests it, EPERM meaning it runs as another user. +const isAlive = (pid) => { + try { + process.kill(pid, 0); + return true; + } catch (e) { + return e.code === "EPERM"; + } +}; + +/** + * Takes the lock `file` for the process `pid`: returns a function that gives it up. Throws + * when another process that is still running holds it; a lock left by one that ended is taken + * over. `alive(pid)` stands in for the test in the probes. + */ +export function acquireLock(file, pid = process.pid, alive = isAlive) { + mkdirSync(path.dirname(file), { recursive: true }); + try { + writeFileSync(file, String(pid), { flag: "wx" }); + } catch (e) { + if (e.code !== "EEXIST") throw e; + const holder = Number(readFileSync(file, "utf8").trim()); + if (holder !== pid && Number.isInteger(holder) && holder > 0 && alive(holder)) + throw new Error(`the session is open in another screenshot review tool, process ${holder}`); + writeFileSync(file, String(pid)); + } + return () => { + try { + if (readFileSync(file, "utf8").trim() === String(pid)) unlinkSync(file); + } catch {} + }; +} + +/** + * The session under `root`, created when there is none with the baseline `baseline()` returns + * ({hash, short, build}). Its runs that had not ended when the tool that started them stopped + * are marked `interrupted`. Returns {root, paths, data, save(), run(n), addRun({tool, args}), + * endRun(n, fields), setPlace(out)}; each change is saved at once. + */ +export function openSession(root, baseline, now = () => new Date().toISOString()) { + const paths = sessionPaths(root); + let data; + if (existsSync(paths.file)) { + data = parseSession(readFileSync(paths.file, "utf8"), paths.file); + for (const r of data.runs) if (r.ended === null) Object.assign(r, { ended: now(), interrupted: true }); + } else data = { format: SESSION_FORMAT, created: now(), baseline: baseline(), runs: [], place: null }; + const save = () => { + mkdirSync(paths.dir, { recursive: true }); + const temp = `${paths.file}.${process.pid}.tmp`; + writeFileSync(temp, `${JSON.stringify(data, null, 2)}\n`); + renameSync(temp, paths.file); + }; + save(); + const run = (n) => data.runs.find((r) => r.n === n) ?? null; + return { + root, + paths, + data, + save, + run, + addRun({ tool, args }) { + if (!RUN_TOOLS[tool]) throw new Error(`no such tool: ${tool}`); + const n = data.runs.reduce((m, r) => Math.max(m, r.n), 0) + 1; + const r = { n, tool, args, started: now(), ended: null, exit: null, stopped: false, interrupted: false }; + data.runs.push(r); + save(); + return r; + }, + endRun(n, fields) { + Object.assign(run(n), { ended: now(), ...fields }); + save(); + }, + setPlace(out) { + if (data.place === out) return; + data.place = out; + save(); + }, + }; +} + +/** Appends `value` as a line of JSON to the events file of a run. */ +export const appendEvent = (file, value) => appendFileSync(file, `${JSON.stringify(value)}\n`); + +/** + * The events a run reported, from its events file: a list, empty when there is none. A last + * line cut short (the tool stopped as it wrote it) is left out. + */ +export function readEvents(file) { + if (!existsSync(file)) return []; + const events = []; + for (const line of readFileSync(file, "utf8").split("\n")) { + if (!line.trim()) continue; + try { + events.push(JSON.parse(line)); + } catch {} + } + return events; +} diff --git a/scripts/lib/shot-views.mjs b/scripts/lib/shot-views.mjs index f1ae3b19b..0a25ecd96 100644 --- a/scripts/lib/shot-views.mjs +++ b/scripts/lib/shot-views.mjs @@ -40,31 +40,43 @@ table{border-collapse:collapse}td,th{border:1px solid var(--line);padding:3px 10 /** * A page: the head with `generator` in its (by which a folder is - * known to hold a page of that tool), the `title` (text) and `intro` (HTML), the - * `summary` table ([[label, value]], text), the `contents` (the
  • s of the list, HTML), - * and `body` (HTML). + * known to hold a page of that tool), and `head` (HTML) after its style, the `title` (text) + * and `intro` (HTML), the `summary` table ([[label, value]], text), the `contents` (the + *
  • s of the list, HTML), and `body` (HTML). A page with no `summary` has no table, and one + * with no `contents` no list. */ -export function pageHtml({ generator, title, intro, summary, contents, body }) { +export function pageHtml({ generator, title, intro, summary, contents, body, head = "" }) { + const table = summary + ? ` +${summary.map(([k, v]) => ``).join("\n")} +
    ${esc(k)}${esc(v)}
    +` + : ""; + const list = + contents === undefined + ? "" + : `

    Contents

    +
      +${contents} +
    +`; return ` ${esc(title)} +${PAGE_STYLE}${head}

    ${esc(title)}

    ${intro}

    - -${summary.map(([k, v]) => ``).join("\n")} -
    ${esc(k)}${esc(v)}
    -

    Contents

    -
      -${contents} -
    -${body} +${table}${list}${body} `; } +/** A chip: a short `text` (text) with the classes `classes` and the tooltip `title` (text). */ +export const chipHtml = (text, classes = "", title = "") => + `${esc(text)}`; + /** * For files in `folder`, relative to the page: `href`, a file's link; `figure`, a linked * picture with its caption (text), for a card; `thumb`, a linked thumbnail with its alt diff --git a/scripts/shot_review.mjs b/scripts/shot_review.mjs new file mode 100644 index 000000000..71068a588 --- /dev/null +++ b/scripts/shot_review.mjs @@ -0,0 +1,160 @@ +// The screenshot review tool (WIP.ShotReview.md): a server on the loopback address with a +// page of every picture a session's runs took or drew, grouped by what shoot_docs.mjs and +// replay.mjs decided about it, and a page for each picture, which show each run's verdicts as +// they arrive. The session is in the gitignored .shot-review/ (lib/shot-session.mjs), and a +// run is the command-line tool as a child process with --stage and --events +// (lib/shot-runs.mjs). The server is lib/shot-server.mjs, its pages lib/shot-pages.mjs. +// +// Read-only for now: nothing is written under docs/, to the manifest or to the bundle cache. +// Applying a verdict, overriding one, the runs page and the commit come in the plan's later +// steps. + +import path from "node:path"; +import { + die, + exitOnCrash, + numberOption, + parseCli, + printHelpAndExit, + refuseTogether, + regexOption, + withUsageError, +} from "../lib/cli.mjs"; +import { DOCS_DIR, REPO_ROOT } from "../lib/repo-paths.mjs"; +import { listIdes } from "./lib/tb-install.mjs"; +import { MANIFEST, openManifest, parseManifest } from "./lib/shot-manifest.mjs"; +import { openRevision, revisionOf } from "./lib/shot-revision.mjs"; +import { createReviewServer } from "./lib/shot-server.mjs"; +import { acquireLock, newestBuild, openSession, REVIEW_ROOT, sessionPaths } from "./lib/shot-session.mjs"; + +const DEFAULT_PORT = 4180; + +const USAGE = `usage: node scripts/shot_review.mjs [--port N] [--retake | --replay ] [--jobs N] [--ide ] [-h, --help] + +Serves the screenshot review of the session in .shot-review at +http://127.0.0.1:/: every picture the session's runs took or drew, +grouped by what was decided about it, with a page for each that compares it +with the picture at the session's baseline. The pages follow a run as its +verdicts arrive. The session is made when there is none, with HEAD as its +baseline, and one tool at a time holds it; closing the tool loses nothing. +Nothing is written under docs. + + --port N the port to listen on, on the loopback address only + (default ${DEFAULT_PORT}) + --retake start a run of shoot_docs.mjs --svg --only + --replay start a run of svgshot/replay.mjs --only + --jobs N the run's --jobs + --ide the retake's --ide + -h, --help print this text and exit + +A run is the command-line tool with --stage, a folder of the session, and +--events. Ctrl+C stops a run that is running as its own stop does (its IDEs end +and the registry is put back), then closes the tool; a second Ctrl+C ends the +run's process at once. + +Exit codes: + 0 the tool was closed + 2 the tool could not run: a refused command line, the port in use, the + session held by another tool, no git working tree, or a crash`; + +const { values } = withUsageError(() => + parseCli(process.argv.slice(2), { + options: { + port: { type: "string" }, + retake: { type: "string" }, + replay: { type: "string" }, + jobs: { type: "string" }, + ide: { type: "string" }, + help: { type: "boolean", short: "h", default: false }, + }, + stopAt: ["help"], + }), +); +if (values.help) printHelpAndExit(USAGE); +withUsageError(() => refuseTogether(values, ["retake", "replay"])); +const port = withUsageError(() => + numberOption(values.port ?? String(DEFAULT_PORT), { option: "--port", integer: true, min: 1, max: 65535 }), +); +for (const option of ["retake", "replay"]) + if (values[option] !== undefined) withUsageError(() => regexOption(values[option], { option: `--${option}` })); +if (values.jobs !== undefined) + withUsageError(() => numberOption(values.jobs, { option: "--jobs", integer: true, min: 1, max: 16 })); +if (values.jobs !== undefined && values.retake === undefined && values.replay === undefined) + die(2, "shot_review: --jobs is for a run: --retake or --replay"); +if (values.ide !== undefined && values.retake === undefined) die(2, "shot_review: --ide is for --retake"); + +let release = () => {}; +exitOnCrash(() => release()); + +// The session: held by this tool alone, made with HEAD as its baseline when there is none. +const paths = sessionPaths(REVIEW_ROOT); +try { + release = acquireLock(paths.lock); +} catch (e) { + die(2, `shot_review: ${e.message}`); +} +let head; +try { + head = revisionOf(DOCS_DIR, "HEAD"); +} catch (e) { + release(); + die(2, `shot_review: ${e.message}`); +} +const session = openSession(REVIEW_ROOT, () => { + const bytes = openRevision(REPO_ROOT, head.hash).bytes(path.relative(REPO_ROOT, MANIFEST).replace(/\\/g, "/")); + const pictures = bytes ? parseManifest(bytes.toString("utf8")).pictures : {}; + return { hash: head.hash, short: head.short, build: newestBuild(pictures) }; +}); +const { baseline } = session.data; + +const app = createReviewServer({ + session, + revision: openRevision(DOCS_DIR, baseline.hash), + manifest: () => openManifest(), + headNow: head.hash === baseline.hash ? null : head.short, + newestIde: listIdes()[0]?.build ?? null, + onLine: (run, line) => console.log(`run ${run.n}: ${line}`), +}); +let listening; +try { + listening = await app.listen(port); +} catch (e) { + release(); + die(2, `shot_review: port ${port} could not be used (${e.code ?? e.message}); choose another with --port`); +} +console.log( + `screenshot review: http://127.0.0.1:${listening}/ (the session of ${baseline.short}, ${session.data.runs.length} runs)`, +); + +const runOptions = () => [ + ...(values.jobs === undefined ? [] : ["--jobs", values.jobs]), + ...(values.ide === undefined ? [] : ["--ide", values.ide]), +]; +if (values.retake !== undefined) { + const { run } = app.start({ tool: "shoot_docs", args: ["--svg", "--only", values.retake, ...runOptions()] }); + console.log(`run ${run.n} started: a retake of ${values.retake}`); +} +if (values.replay !== undefined) { + const { run } = app.start({ tool: "replay", args: ["--only", values.replay, ...runOptions()] }); + console.log(`run ${run.n} started: a replay of ${values.replay}`); +} + +// Ctrl+C: the runs stopped as their own stop does, then the tool closed; a second one ends them at once. +let closing = false; +process.on("SIGINT", async () => { + const live = app.live(); + if (closing) { + for (const h of live) h.kill(); + release(); + process.exit(0); + } + closing = true; + if (live.length) { + console.log("stopping the runs: Ctrl+C again ends their processes at once, which skips putting the registry back"); + for (const h of live) h.stop(); + await Promise.all(live.map((h) => h.exited)); + } + await app.close(); + release(); + process.exit(0); +}); diff --git a/test/svgshot.test.mjs b/test/svgshot.test.mjs index 2ccc2d1ce..4a66b019b 100644 --- a/test/svgshot.test.mjs +++ b/test/svgshot.test.mjs @@ -36,6 +36,7 @@ import os from "node:os"; import path from "node:path"; import { PassThrough } from "node:stream"; import { describe, test } from "node:test"; +import { pathToFileURL } from "node:url"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; import { BUNDLE_VERSION, @@ -89,8 +90,30 @@ import { regionMasks, shareOff, strengthOf, + strengthPicture, withinNoise, } from "../scripts/lib/shot-change.mjs"; +import { + acquireLock, + commandOf, + newestBuild, + openSession, + parseSession, + readEvents, + runPaths, + sessionPaths, +} from "../scripts/lib/shot-session.mjs"; +import { + filterPictures, + groupOf, + hasSvg, + neighbours, + reviewPictures, + runFacts, + verdictOfEvent, +} from "../scripts/lib/shot-review.mjs"; +import { encodePath, hrefs, loupesHtml } from "../scripts/lib/shot-pages.mjs"; +import { createReviewServer } from "../scripts/lib/shot-server.mjs"; import { FAINT_LEVEL, pngLook, @@ -2684,3 +2707,431 @@ describe("the screenshot manifest (shot-manifest.mjs)", () => { assert.equal(m.text(), fs.readFileSync(m.file, "utf8").replace(/\r\n/g, "\n")); }); }); + +describe("the screenshot review's session (shot-session.mjs)", () => { + const baseline = () => ({ hash: "a".repeat(40), short: "aaaaaaaa", build: 1500 }); + + test("is made with its baseline, numbers its runs, and marks one left running as interrupted", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "shot-session-")); + try { + const s = openSession(root, baseline, () => "t0"); + assert.deepEqual(s.data, { format: 1, created: "t0", baseline: baseline(), runs: [], place: null }); + const one = s.addRun({ tool: "shoot_docs", args: ["--svg", "--only", "Menu_File|Close"] }); + const two = s.addRun({ tool: "replay", args: [] }); + assert.deepEqual([one.n, two.n], [1, 2]); + s.endRun(1, { exit: 0, stopped: false }); + s.setPlace("IDE/Images/A.png"); + assert.throws(() => s.addRun({ tool: "rm", args: [] }), /no such tool/); + const again = openSession( + root, + () => assert.fail("a session there is not made again"), + () => "t1", + ); + assert.deepEqual( + again.data.runs.map((r) => [r.n, r.ended, r.interrupted]), + [ + [1, "t0", false], + [2, "t1", true], + ], + ); + assert.equal(again.data.place, "IDE/Images/A.png"); + assert.equal(runPaths(root, 2).stage, path.join(root, "session", "runs", "2", "stage")); + assert.equal(commandOf(one), 'node scripts/shoot_docs.mjs --svg --only "Menu_File|Close"'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test("refuses a file that is not a session", () => { + assert.throws(() => parseSession("{"), /not JSON/); + assert.throws(() => parseSession('{"format":2}'), /format 1/); + assert.throws(() => parseSession(JSON.stringify({ format: 1, baseline: baseline(), runs: [{ n: 1 }] })), /a run/); + }); + + test("is held by one tool: a lock of a process still running refuses, one of a process ended is taken", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "shot-session-")); + try { + const lock = sessionPaths(root).lock; + const release = acquireLock(lock, 100, () => true); + assert.throws(() => acquireLock(lock, 200, () => true), /process 100/); + const taken = acquireLock(lock, 200, () => false); + release(); + assert.equal(fs.readFileSync(lock, "utf8"), "200"); + taken(); + assert.equal(fs.existsSync(lock), false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test("reads a run's events, a last line cut short left out, and the newest build", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "shot-session-")); + try { + const file = path.join(dir, "events.jsonl"); + assert.deepEqual(readEvents(file), []); + fs.writeFileSync(file, '{"type":"run"}\n{"type":"picture","out":"a.png"}\n{"type":"sv'); + assert.deepEqual( + readEvents(file).map((e) => e.type), + ["run", "picture"], + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + assert.equal(newestBuild({ "a.png": { build: 1010 }, "b.png": {}, "c.png": { build: 1034 } }), 1034); + assert.equal(newestBuild({}), null); + }); +}); + +describe("what the screenshot review shows (shot-review.mjs, shot-pages.mjs)", () => { + const A = "IDE/Images/A.png"; + const B = "IDE/Images/B.png"; + const C = "IDE/Images/C.png"; + const D = "IDE/Images/D.light.png"; + const E = "IDE/Images/E.png"; + const run = (n, tool, live, events, verdicts) => ({ + n, + tool, + live, + facts: runFacts(tool, events), + verdicts: new Map(Object.entries(verdicts).map(([out, v]) => [out, { png: null, svg: null, ...v }])), + }); + const jobs = (...pictures) => ({ type: "run", svg: true, jobs: [{ name: "j", pictures }] }); + + test("a run's events say what it lists, its jobs, its failures and what it did not take", () => { + const f = runFacts("shoot_docs", [ + jobs(A, B), + { type: "job", job: "j", state: "started" }, + { type: "failed", out: A, error: "boom" }, + { type: "stopping" }, + { type: "skipped", out: B }, + ]); + assert.deepEqual(f.listed, [A, B]); + assert.equal(f.svg, true); + assert.deepEqual(f.jobs.get("j"), { pictures: 2, state: "started", error: null }); + assert.equal(f.jobOf.get(B), "j"); + assert.equal(f.failed.get(A), "boom"); + assert.deepEqual([...f.skipped], [B]); + assert.equal(f.stopping, true); + const r = runFacts("replay", [ + { type: "run", pictures: [C] }, + { type: "retake", out: C, why: "no bundle" }, + ]); + assert.deepEqual([r.listed, r.svg, r.retakes.get(C)], [[C], true, "no bundle"]); + assert.deepEqual(verdictOfEvent({ type: "svg", job: "j", out: A, state: "new" }), ["svg", { state: "new" }]); + }); + + test("a job that fails before it reaches a picture fails the picture, with the job's error", () => { + const events = [ + jobs(A, B), + { type: "job", job: "j", state: "started" }, + { type: "job", job: "j", state: "failed", error: "no hook" }, + { type: "job", job: "j", state: "done" }, + ]; + assert.deepEqual(runFacts("shoot_docs", events).jobs.get("j"), { pictures: 2, state: "failed", error: "no hook" }); + const { pictures } = reviewPictures([run(1, "shoot_docs", false, events, { [A]: { png: { state: "new" } } })]); + const at = (out) => pictures.find((p) => p.out === out); + assert.deepEqual([at(A).group, at(A).failed], ["added", null]); + assert.deepEqual([at(B).group, at(B).failed], ["failed", { run: 1, error: "its job, j, failed: no hook" }]); + }); + + test("a picture takes the newest verdicts, and a retake's PNG drops an older run's SVG", () => { + const runs = [ + run(1, "shoot_docs", false, [jobs(A, B, C), { type: "failed", out: C, error: "boom" }], { + [A]: { png: { state: "updated" }, svg: { state: "new" } }, + [B]: { png: { state: "noise" }, svg: { state: "unchanged" } }, + }), + run(2, "replay", false, [{ type: "run", pictures: [A, D] }], { + [A]: { svg: { state: "updated" } }, + [D]: { svg: { state: "removed" } }, + }), + run(3, "shoot_docs", true, [jobs(A, E)], { [A]: { png: { state: "unchanged" } } }), + ]; + const { pictures, groups } = reviewPictures(runs); + const at = (out) => pictures.find((p) => p.out === out); + assert.deepEqual([at(A).png, at(A).svg, at(A).progress], [{ run: 3, state: "unchanged" }, null, true]); + assert.deepEqual([at(E).progress, at(E).group], [true, "progress"]); + assert.deepEqual( + [B, C, D].map((out) => at(out).group), + ["noise", "failed", "changed"], + ); + assert.equal(at(C).failed.error, "boom"); + assert.deepEqual( + groups.map((g) => g.key), + ["progress", "look", "changed", "noise", "unchanged", "added", "deleted", "failed"], + ); + assert.deepEqual( + pictures.map((p) => p.out), + [A, E, D, B, C], + ); + // the same runs once the third has ended: A's PNG is unchanged, and it has no SVG verdict + const ended = reviewPictures(runs.map((r) => ({ ...r, live: false }))); + assert.equal(ended.pictures.find((p) => p.out === A).group, "unchanged"); + }); + + test("the Changed group is ordered by how much each changed, and a look comes first", () => { + const runs = [ + run(1, "shoot_docs", false, [jobs(A, B, C)], { + [A]: { png: { state: "updated" } }, + [B]: { png: { state: "updated" } }, + [C]: { png: { state: "updated", look: { why: "small", text: "small" } } }, + }), + ]; + const share = { [A]: 0.5, [B]: 3 }; + const { groups } = reviewPictures(runs, { measure: (p) => share[p.out] }); + const by = Object.fromEntries(groups.map((g) => [g.key, g.pictures.map((p) => p.out)])); + assert.deepEqual([by.look, by.changed], [[C], [B, A]]); + }); + + test("a picture the run takes once, or the dark one's light picture, is deleted only when its file is there", () => { + const p = { out: D, png: { state: "dark" }, svg: null, progress: false, failed: null }; + assert.equal( + groupOf(p, () => true), + "deleted", + ); + assert.equal( + groupOf(p, () => false), + "unchanged", + ); + assert.equal( + groupOf({ ...p, png: { state: "new" } }, () => false), + "added", + ); + }); + + test("the filters: no SVG after the verdicts are applied, and a picture's neighbours in a list", () => { + const pictures = [ + { out: A, svg: { state: "new" } }, + { out: B, svg: { state: "not written" } }, + { out: C, svg: null }, + { out: E, svg: { state: "unchanged" }, group: "look" }, + ]; + const exists = (f) => f === "IDE/Images/E.svg"; + assert.deepEqual( + pictures.map((p) => hasSvg(p, exists)), + [true, false, false, true], + ); + assert.deepEqual( + filterPictures(pictures, "nosvg", { exists }).map((p) => p.out), + [B, C], + ); + assert.deepEqual( + filterPictures(pictures, "look").map((p) => p.out), + [E], + ); + assert.deepEqual( + filterPictures(pictures, "overridden", { overridden: (out) => out === A }).map((p) => p.out), + [A], + ); + assert.equal(filterPictures(pictures, "nonsense").length, 4); + const { index, prev, next } = neighbours(pictures, B); + assert.deepEqual([index, prev.out, next.out], [1, A, C]); + assert.deepEqual(neighbours(pictures, "x.png"), { index: -1, prev: null, next: null }); + }); + + test("an address encodes each segment of a path, and a loupe crops and enlarges its region", () => { + assert.equal(hrefs.picture("IDE/Images/A b#.png", "look"), "/picture/IDE/Images/A%20b%23.png?filter=look"); + assert.equal(hrefs.picture(A, "all"), `/picture/${A}`); + assert.equal(encodePath("a/b c"), "a/b%20c"); + const html = loupesHtml( + [{ x0: 100, y0: 50, x1: 130, y1: 60, pixels: 300, core: 40 }], + [400, 200], + { src: "/o", label: "HEAD" }, + { src: "/n", label: "new" }, + ); + // 30 x 10 px with 6 px round it, enlarged 4 times + assert.match( + html, + /width:168px;height:88px;background-image:url\('\/o'\);background-size:1600px 800px;background-position:-376px -176px/, + ); + assert.match(html, /HEAD \| new: 30 x 10 px at 100, 50/); + assert.equal(loupesHtml([], [400, 200], {}, {}), ""); + }); + + test("a strength map is drawn as changeMap draws the two pictures it was measured on", () => { + const img = (shade) => { + const rgba = new Uint8Array(30 * 20 * 4); + for (let i = 0; i < 600; i++) rgba.set([i % 7 === 0 ? shade : 40, 60, 80, 255], i * 4); + return decodePng(Buffer.from(encodePng({ width: 30, height: 20, rgba }))); + }; + const [a, b] = [img(40), img(200)]; + assert.deepEqual(Buffer.from(strengthPicture(strengthOf(a, b), b)), Buffer.from(changeMap(a, b))); + assert.throws(() => strengthPicture(strengthOf(a, b), { ...b, width: 29 }), /30 x 20 px/); + }); +}); + +describe("the screenshot review's server (shot-server.mjs)", () => { + const A = "IDE/Images/A.png"; + const B = "IDE/Images/B.light.png"; + // A flat grey picture 40 x 30, with a bright block in it when `block`: the capture of A, + // against HEAD's without it. The stand-in run below has its own copy, from its source. + const flat = (block) => { + const rgba = new Uint8Array(40 * 30 * 4); + for (let i = 0; i < 1200; i++) { + const [x, y] = [i % 40, Math.floor(i / 40)]; + const s = block && x >= 10 && x < 30 && y >= 8 && y < 22 ? 250 : 40; + rgba.set([s, s, s, 255], i * 4); + } + return Buffer.from(encodePng({ width: 40, height: 30, rgba })); + }; + const lib = (name) => JSON.stringify(pathToFileURL(path.join(REPO_ROOT, "scripts", "lib", name)).href); + // A run as shoot_docs.mjs --stage --events makes one: A taken and its SVG accepted, then a + // wait for the stop line, after which B is not taken. + const FAKE = `import { onStop, writeRecord } from ${lib("record-lines.mjs")}; +import { pngVerdict, stageCapture, stageSvg, svgVerdict, writeVerdict } from ${lib("shot-stage.mjs")}; +import { reviewPicture, reviewSvg } from ${lib("shot-queue.mjs")}; +import { encodePng } from ${lib("png.mjs")}; +const flat = ${flat.toString()}; +const args = process.argv.slice(2); +const stage = args[args.indexOf("--stage") + 1]; +if (args[0] !== "--only" || !args.includes("--events")) process.exit(2); +const ev = (type, v = {}) => writeRecord("shot-event", { type, ...v }); +const [A, B] = ${JSON.stringify([A, B])}; +ev("run", { tool: "shoot_docs", svg: true, jobs: [{ name: "j", setup: "s", pictures: [A, B] }] }); +ev("job", { job: "j", state: "started" }); +console.log("taking " + A); +const cap = flat(true); +const fate = { state: "updated", write: cap, against: "revision", size: { w: 40, h: 30 }, dark: null, by: "rule", earlier: null }; +const review = reviewPicture(fate, cap, flat(false), null); +const part = pngVerdict({ decision: fate, review, build: 1501, staged: stageCapture(stage, A, cap, review.strength) }); +writeVerdict(stage, A, "png", part); +ev("picture", { job: "j", out: A, ...part }); +const strength = { width: 40, height: 30, data: new Uint8Array(1200) }; +const judged = { faithful: true, by: "rule", share: 0, earlier: null }; +const svg = svgVerdict({ state: "new", verdict: judged, review: reviewSvg(judged, strength), stats: { pct8: 0, pct32: 0, pct96: 0 }, measures: "0% off", staged: stageSvg(stage, A, "", strength) }); +writeVerdict(stage, A, "svg", svg); +ev("svg", { job: "j", out: A, ...svg }); +onStop(process.stdin, () => { + ev("stopping"); + ev("skipped", { job: "j", out: B }); + ev("job", { job: "j", state: "done" }); + ev("summary", { stopped: true, exit: 0 }); + process.exit(0); +}); +`; + + const until = async (what, ok) => { + for (const t0 = Date.now(); !ok(); ) { + if (Date.now() - t0 > 15000) assert.fail(`timed out waiting for ${what}`); + await new Promise((r) => setTimeout(r, 20)); + } + }; + + test("follows a run as it reports, serves its pages and files to the loopback host only, and reads the run back", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "shot-review-")); + const docs = path.join(root, "docs"); + fs.mkdirSync(docs); + const script = path.join(root, "fake-run.mjs"); + fs.writeFileSync(script, FAKE); + const head = flat(false); + const revision = { hash: "a".repeat(40), short: "aaaaaaaa", bytes: (out) => (out === A ? head : null) }; + const manifest = () => ({ entry: () => null }); + const make = () => + createReviewServer({ + session: openSession(root, () => ({ hash: revision.hash, short: revision.short, build: 1500 })), + revision, + manifest, + docsRoot: docs, + command: { script }, + }); + const app = make(); + let app2 = null; + try { + const port = await app.listen(0); + const base = `http://127.0.0.1:${port}`; + const get = async (p) => { + const res = await fetch(base + p); + return { + status: res.status, + type: res.headers.get("content-type"), + body: Buffer.from(await res.arrayBuffer()), + }; + }; + const events = []; + const stream = await fetch(`${base}/events`); + const reader = stream.body.getReader(); + (async () => { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + events.push(Buffer.from(value).toString()); + } + })().catch(() => {}); + + const handle = app.start({ tool: "shoot_docs", args: ["--only", "A|B"] }); + const pic = (out) => app.model().pictures.find((p) => p.out === out); + await until("A's SVG verdict", () => pic(A)?.svg); + assert.equal(pic(A).group, "look"); + assert.equal(pic(A).png.look.why, "small"); + assert.equal(pic(B).group, "progress"); + + const overview = await get("/"); + assert.equal(overview.status, 200); + const html = overview.body.toString(); + assert.match(html, /In progress \(1\)/); + assert.match(html, /Needs a look \(1\)/); + assert.match(html, /Resume at 1 of 1/); + assert.match(html, /node scripts\/shoot_docs\.mjs --only "A\|B"<\/code>/); + const detail = await get(`/picture/${A}?filter=look`); + assert.equal(detail.status, 200); + assert.match(detail.body.toString(), //); + assert.match(detail.body.toString(), /1 of 1 · Needs a look/); + assert.equal(app.model().pictures.length, 2); + const capture = await get(hrefs.run(1, pic(A).png.capture)); + assert.deepEqual([capture.status, capture.type], [200, "image/png"]); + assert.deepEqual(decodePng(capture.body).width, 40); + assert.deepEqual((await get(hrefs.reference(1, A))).body, head); + assert.deepEqual((await get(hrefs.head(A))).body, head); + const map = await get(hrefs.map(1, "png", A)); + assert.deepEqual([map.status, decodePng(map.body).height], [200, 30]); + assert.equal((await get(hrefs.map(1, "svg", A))).status, 200); + assert.equal((await get(hrefs.run(1, pic(A).svg.svg))).type, "image/svg+xml"); + assert.equal((await get("/file/run/1/%2E%2E/%2E%2E/session.json")).status, 404); + assert.equal((await get("/picture/IDE/Images/Nothing.png")).status, 404); + assert.equal((await get("/file/docs/x.txt")).status, 404); + const forged = await new Promise((resolve, reject) => { + import("node:http").then(({ request }) => + request({ port, path: "/", headers: { host: `docs.example:${port}` } }, (res) => { + res.resume(); + resolve(res.statusCode); + }) + .on("error", reject) + .end(), + ); + }); + assert.equal(forged, 403); + + handle.stop(); + assert.equal(await handle.exited, 0); + await until("the run's end", () => !app.live().length); + assert.equal(pic(B).group, "failed"); + assert.equal(pic(B).skipped, 1); + assert.match(events.join(""), /event: update\ndata: \{"outs":\["IDE\/Images\/A\.png"\]\}/); + const files = runPaths(root, 1); + assert.match(fs.readFileSync(files.log, "utf8"), /^taking IDE\/Images\/A\.png$/m); + assert.equal(readEvents(files.events).at(-1).type, "summary"); + const state = JSON.parse((await get("/api/state")).body); + assert.deepEqual( + [state.runs[0].stopped, state.runs[0].exit, state.runs[0].done, state.runs[0].listed], + [true, 0, 2, 2], + ); + + // The session opened again: its run read back from the stage and the events. + await app.close(); + app2 = make(); + const again = app2.model().pictures; + assert.deepEqual( + again.map((p) => [p.out, p.group]), + [ + [A, "look"], + [B, "failed"], + ], + ); + } finally { + // a run left waiting for its stop line would keep the test's process alive + for (const h of app.live()) h.kill(); + await app.close(); + await app2?.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); From b7acad4bdb34e872d86b2811167676c7e4814b5c Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Sat, 31 Oct 2026 22:36:20 +0100 Subject: [PATCH 22/85] WIP.ShotReview.md: the owner's answers to step 5's open questions (port 4180, pictures changed outside the session, allowances on the sheets, moving a session to a new HEAD), placed in steps 6 and 9 --- WIP.ShotReview.md | 47 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/WIP.ShotReview.md b/WIP.ShotReview.md index 7efc4e493..8983e957d 100644 --- a/WIP.ShotReview.md +++ b/WIP.ShotReview.md @@ -58,6 +58,19 @@ Two decisions are made by fixed thresholds, and neither threshold holds for ever - **One session at a time.** - **No code is duplicated between the tool and the command-line tools** (owner): what they share is moved into modules both call. See [Factoring](#factoring). +- **The server listens on a fixed port, 4180, with `--port`** to choose another (owner, + 2026-10-31), so that a page's and a picture's address can be kept; not a port from `tbbroker`. +- **The overview also lists the pictures under `docs/` that differ from the baseline but that + no run of the session reported** (a retake from a terminal, an edit), as a group of their own + (owner, 2026-10-31): HEAD's picture against the file, with no verdict to override. The + overview then covers what `sheet.mjs --against` covers. +- **The static sheets show a picture's allowances** (owner, 2026-10-31), as the overview does: + `sheet.mjs` reads the manifest, as the command-line tools already do. +- **When HEAD moves under an open session** (a commit made outside the tool), the session keeps + its baseline and the overview warns, as step 5 does; and the overview offers to **move the + session to the new HEAD on demand** (owner, 2026-10-31). Moving makes the new HEAD the + baseline (and its build the baseline's build); a verdict judged against the old baseline + goes back to the queue, since the picture it was judged against has changed. ## The pages @@ -290,10 +303,12 @@ Each step ends tested and usable on its own. 4. **`--stage`, `--events` and stop** in `shoot_docs.mjs` and `replay.mjs`. Done. 5. **The server and the session store**, the overview and the detail page read-only, live. Done. -6. **Decisions and allowances** applied through `shot-apply.mjs`, judged again at once. +6. **Decisions and allowances** applied through `shot-apply.mjs`, judged again at once. With + them, the group of pictures changed outside the session's runs, and the action that moves + the session to a new HEAD ([Decisions](#decisions-owner)). 7. **The runs page**: retake, replay, clean up, stop. 8. **The commit panel**. -9. **`sheet.mjs` on the shared views**; the tool's entry in `Tools-IDE.md`, its node in +9. **`sheet.mjs` on the shared views**, showing each picture's allowances; the tool's entry in `Tools-IDE.md`, its node in `Tools-Groups.dot` (its command-line cases are in `scripts/lib/cli-cases.mjs` since step 5), its tests in `test.bat` (and so in Tools.md's list and the `run-gates` action), and the row in WIP.md's *Which file to open*. @@ -340,6 +355,23 @@ until step 6 a retake through the tool changes nothing in the working tree. The into the detail page's PNG and SVG rows, and the server takes its first POST requests (it answers 405 to any today). +Also in step 6, from the owner's answers of 2026-10-31: + +- **The pictures changed outside the session's runs.** `changedPictures(DOCS_DIR, + baseline.hash)` (`shot-revision.mjs`) lists them; those no run reported become a group of the + overview (between Only noise and Unchanged is a reasonable place; it needs a key in `GROUPS` + and a part in `reviewPictures` for pictures with no run). Their detail page compares HEAD's + picture with the file, its map drawn by `changeMap` (there is no strength map staged), and + has no verdict row. The list is read again when the overview is, since a terminal run can + change it at any time. +- **Moving the session to a new HEAD.** A button beside the "HEAD is now ..." notice: a POST + that sets `session.data.baseline` to HEAD (`revisionOf`, and the build from the manifest at + it, as `shot_review.mjs` makes a baseline), reopens the server's `revision`, and clears + `headNow`. A verdict whose `against` was `revision` was judged against the old baseline's + picture, so it goes back to Needs a look (a new `why`, such as `baseline`, in `pngLook`'s + sense, set by the model when a verdict's run is older than the move; record the move's time + in the session). + Step 5 was checked with the tests in `test/svgshot.test.mjs` and with three real runs started by the tool in one session, the tool closed between them: a retake of `Close_Question` whose job failed in the harness (`the IDE's settings were not made the defaults: its page ran no @@ -409,11 +441,6 @@ MutationObserver (`tabCloseIcon`). ## Open -- **The server's port.** A stable default with `--port`, as bookmarks need, or a port from - `tbbroker`, which today leases one only with a desktop. Step 5 took the first, 4180, until - the owner decides. -- **Pictures changed outside the session.** The overview shows what the session's runs - reported; a picture under `docs/` that differs from the baseline for another reason (a run - from a terminal, an edit) is not on it, though `sheet.mjs --against` lists it. Whether the - overview lists those too, as the static sheet does, is not decided. -- **Allowances in the static sheets**: whether `sheet.mjs` shows them, as the overview does. +Nothing: the questions open after step 5 (the port, pictures changed outside the session, +allowances in the static sheets, HEAD moving under a session) were answered by the owner on +2026-10-31 and are under [Decisions](#decisions-owner). From f1747b08fcf7b996052e91e2dc510f966e109e80 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Sat, 31 Oct 2026 23:03:02 +0100 Subject: [PATCH 23/85] Step 6 of the screenshot review: verdicts applied under docs as they arrive and judged again after a review (shot-verdicts.mjs, holdPicture, copyBundle), decisions and allowances from the detail page, the pictures changed outside the runs, the session moved to HEAD; staged SVGs judged with a PNG not written; WIP.ShotReview.md: step 6 done, step 7 next --- WIP.ShotReview.md | 75 ++-- docs/Documentation/Tools-IDE.md | 2 +- docs/Documentation/Tools-Toolchain-Tests.md | 3 +- scripts/lib/shot-apply.mjs | 98 ++++- scripts/lib/shot-change.mjs | 8 +- scripts/lib/shot-manifest.mjs | 15 + scripts/lib/shot-pages.mjs | 348 ++++++++++++++++-- scripts/lib/shot-policy.mjs | 9 + scripts/lib/shot-review.mjs | 37 +- scripts/lib/shot-server.mjs | 302 ++++++++++++++-- scripts/lib/shot-session.mjs | 15 +- scripts/lib/shot-stage.mjs | 40 +++ scripts/lib/shot-verdicts.mjs | 297 +++++++++++++++ scripts/shoot_docs.mjs | 64 ++-- scripts/shot_review.mjs | 50 ++- scripts/svgshot/bundle.mjs | 20 ++ scripts/svgshot/replay.mjs | 4 +- scripts/svgshot/sheet.mjs | 6 +- test/svgshot.test.mjs | 380 +++++++++++++++++++- 19 files changed, 1602 insertions(+), 171 deletions(-) create mode 100644 scripts/lib/shot-verdicts.mjs diff --git a/WIP.ShotReview.md b/WIP.ShotReview.md index 8983e957d..6416ca978 100644 --- a/WIP.ShotReview.md +++ b/WIP.ShotReview.md @@ -2,9 +2,10 @@ A localhost tool for reviewing what `shoot_docs.mjs` and `replay.mjs` decided about each picture, overriding it, starting retakes and replays, and committing the result. **Design -agreed with the owner on 2026-10-31; steps 1 to 5 of [the order of work](#order-of-work) are -done (step 1 but for the commit helper, which step 8 makes): the tool runs, read-only, as -`node scripts/shot_review.mjs`.** The mockups it was agreed on were inline in that +agreed with the owner on 2026-10-31; steps 1 to 6 of [the order of work](#order-of-work) are +done (step 1 but for the commit helper, which step 8 makes): the tool runs as +`node scripts/shot_review.mjs`, applies each verdict under `docs/` as it arrives, and takes a +review's decisions and allowances; its runs are still started from its command line.** The mockups it was agreed on were inline in that session and are not kept: the pages are described below instead. The tool covers everything the command-line screenshot tools do, interactively, with a person @@ -305,7 +306,7 @@ Each step ends tested and usable on its own. Done. 6. **Decisions and allowances** applied through `shot-apply.mjs`, judged again at once. With them, the group of pictures changed outside the session's runs, and the action that moves - the session to a new HEAD ([Decisions](#decisions-owner)). + the session to a new HEAD ([Decisions](#decisions-owner)). Done. 7. **The runs page**: retake, replay, clean up, stop. 8. **The commit panel**. 9. **`sheet.mjs` on the shared views**, showing each picture's allowances; the tool's entry in `Tools-IDE.md`, its node in @@ -315,7 +316,7 @@ Each step ends tested and usable on its own. ## Where the work stands -On `staging` since 2026-10-31, step 1 but for the commit helper (step 8), and steps 2 to 5. Each move kept the command-line tools' output as it was, checked as +On `staging` since 2026-10-31, step 1 but for the commit helper (step 8), and steps 2 to 6. Each move kept the command-line tools' output as it was, checked as [below](#checking-a-move). | Row of the factoring table | Where it is now | @@ -328,7 +329,7 @@ On `staging` since 2026-10-31, step 1 but for the commit helper (step 8), and st | The queue's rules | `scripts/lib/shot-queue.mjs`: `pngRegions(strength, pngRule, level)`, `svgRegions(strength, entry)`, `surviving`, `keptAsNoise(state)`, `pngLook(verdict, regions)` and `svgLook(verdict, regions)`, each `{why, text}` or null (`why` for a PNG: `earlier`, `noise`, `small`, `faint`); `reviewPicture(verdict, png, reference, entry)` (`{strength, regions, look}`, measured against the picture `verdict.against` names: decidePicture and pictureFate return `against`, "revision", "disk", "dark" or null) and `reviewSvg(verdict, strength, entry)`; the constants `QUEUE_CLOSE`, `QUEUE_ERODE`, `SURVIVES`, `WORD_CORE`, `WORD_BLOCKS`, `FAINT_LEVEL` | | A run's stage and events | `scripts/lib/shot-stage.mjs`: `stageFolders(dir)` (`pictures`, `bundles`, `diffs`, `maps`, `verdicts`), `stagedFiles(dir, out)`, `stageRefusal(dir, docsRoots)` (outputRefusal, and a stage must be new or empty), `stageCapture`, `stageSvg`, `writeStrength` / `readStrength` (8 bytes of size, then the map deflated), `writeVerdict(dir, out, part, value)` / `readVerdicts(dir)`, and the verdict shapes `pngVerdict` and `svgVerdict`, which the `picture` and `svg` events carry too (at most `STAGED_REGIONS`, 64, regions, and `regionCount`). `shoot_docs.mjs` and `replay.mjs` write nothing else with `--stage`: `applyPicture`, `applySvg`, `writeSvg`, `dropPicture` and `restorePictures` take `{dryRun}`, and say what would be done. Events: `run`, `job` (`started`, `done`, `failed`), `picture`, `svg`, `failed`, `skipped`, `retake` (replay), `stopping`, `summary`. Stop: `STOP_LINE`, `onStop(stream, fn)`, `sendStop(child)` in `record-lines.mjs`; `runShards(env, jobs, {signal})` passes it to each child, `inBrowser(items, fn, {stopped})` returns the items not started | | The manifest | `scripts/lib/shot-manifest.mjs`: `MANIFEST`, `openManifest(file)` (`entry`, `setBuild`, `clearBuild`, `save`, `text`), `parseManifest`, `manifestText`, `pngDecision`, `earlierPngDecision`, `svgDecision`, `hashOf`. `shoot_docs.mjs` passes each picture's rectangles to `capture` as `c.shot.ignore`, left out as its unsteady regions are, and records `build` (`buildNumber(ide)`) in `keep` and `dropLight` | -| Writing a verdict | `scripts/lib/shot-apply.mjs`: `applyPicture(outRoot, out, decision, revision)`, `applySvg(outRoot, svgOut, svg or null)`, `restorePictures(outRoot, revision, outs, {dryRun})` (the PNGs and their SVGs, the SVGs by one `git restore`; `--cleanup` and a `restored` picture both use it), `dropPicture(outRoot, out, bundlesRoot)`, `writeSvg`, `svgOf`. What stays in `shoot_docs.mjs`'s `keep` and `keepSvg`: the counts, the lines it prints, the `--diffs` pictures, the bundle written, the user-name check, and the rule that an SVG stays as it is when its PNG was not written | +| Writing a verdict | `scripts/lib/shot-apply.mjs`: `applyPicture(outRoot, out, decision, revision, {svg, bundlesRoot})` (for "dark", with `svg`, and for "once", the light file's SVG and bundle go too, through `dropPicture`: the "dark" verdict's two calls of step 5 made one), `applySvg(outRoot, svgOut, svg or null)`, `restorePictures(outRoot, revision, outs, {dryRun})` (the PNGs and their SVGs, the SVGs by one `git restore`; `--cleanup` and a `restored` picture both use it), `dropPicture(outRoot, out, bundlesRoot)`, `writeSvg`, `svgOf`. What stays in `shoot_docs.mjs`'s `keep` and `keepSvg`: the counts, the lines it prints, the `--diffs` pictures, the bundle written, the user-name check, and the rule that an SVG stays as it is when its PNG was not written | | The git listing | `changedPictures`, `parseNameStatus`, `parseUntracked` in `scripts/lib/shot-revision.mjs` | | A child's report | `scripts/lib/record-lines.mjs`: `recordLine`, `writeRecord`, `parseRecord`, `readRecords(stream, kinds, {onRecord, onLine})`; `runShards` reads `svgshot-result` records with it, and `--events` writes `shot-event` records the same way (a shard's pass through its parent as lines) | | The sheets' HTML | the pieces in `scripts/lib/shot-views.mjs`: `pageHtml({generator, title, intro, summary, contents, body})`, `cardHtml({out, meta, meter, figures})`, `figuresIn(folder)` (`href`, `figure`, `thumb`), `meterHtml`, `tocItem`, `esc`, `anchor`, `sizeText`, `pctText`. The two pages, their rows, their order, their commands and the output-folder rules (`mayWriteSheet`, `SHEET_GENERATOR`) are in `scripts/svgshot/sheets.mjs`, and `outputRefusal`, which the stage shares, in `shot-revision.mjs`; `redraw.mjs` keeps drawing and the shards | @@ -346,31 +347,43 @@ What step 5 added, the tool itself: | The pages | `scripts/lib/shot-pages.mjs`: `overviewHtml`, `detailHtml`, `chipsOf`, `loupesHtml` (a region cropped by CSS from the whole picture, enlarged up to 4 times, at most 360 by 240 CSS px), `hrefs` (every address the server answers); built on `pageHtml` (which now takes `head`, and leaves out the table or the contents when it is given none) and `chipHtml` in `shot-views.mjs`. A page fetches itself again 400 ms after an `update` event that names a picture it shows, and replaces its body; the detail page's comparison mode is kept in `sessionStorage` | | The server | `createReviewServer({session, revision, manifest, docsRoot, headNow, newestIde, onLine, command})` in `scripts/lib/shot-server.mjs`: the pages, `/events`, `/api/state`, `/log/`, the files (`/file/head`, `/file/docs`, `/file/run/`, `/file/ref/`, the picture the capture was judged against) and the difference maps drawn from a run's strength maps (`/map//png|svg/`, `strengthPicture` in `shot-change.mjs`, which colours as `changeMap` does, the last 16 kept). It answers only a request whose Host is the loopback address at its port; a run's verdicts are read back from `readVerdicts` and its other events from `readEvents` when the session is opened again. The Changed group is ordered by the share of a PNG's pixels beyond its noise levels, from its strength map, else the SVG's share off its PNG | -**Next: step 6**, decisions and allowances. Still to write for it, where the tool applies a -verdict: moving a staged bundle into the cache (`readBundle` from the stage, `writeBundle` into -`.svgshot-bundles`), and the "dark" verdict's two calls (`applyPicture`, then `dropPicture` for -its SVG and bundle) as one function in `shot-apply.mjs`, so that `shoot_docs.mjs` and the tool -make the same calls. Today a run's verdicts are only shown: nothing is applied to `docs/`, so -until step 6 a retake through the tool changes nothing in the working tree. The actions go -into the detail page's PNG and SVG rows, and the server takes its first POST requests (it -answers 405 to any today). - -Also in step 6, from the owner's answers of 2026-10-31: - -- **The pictures changed outside the session's runs.** `changedPictures(DOCS_DIR, - baseline.hash)` (`shot-revision.mjs`) lists them; those no run reported become a group of the - overview (between Only noise and Unchanged is a reasonable place; it needs a key in `GROUPS` - and a part in `reviewPictures` for pictures with no run). Their detail page compares HEAD's - picture with the file, its map drawn by `changeMap` (there is no strength map staged), and - has no verdict row. The list is read again when the overview is, since a terminal run can - change it at any time. -- **Moving the session to a new HEAD.** A button beside the "HEAD is now ..." notice: a POST - that sets `session.data.baseline` to HEAD (`revisionOf`, and the build from the manifest at - it, as `shot_review.mjs` makes a baseline), reopens the server's `revision`, and clears - `headNow`. A verdict whose `against` was `revision` was judged against the old baseline's - picture, so it goes back to Needs a look (a new `why`, such as `baseline`, in `pngLook`'s - sense, set by the model when a verdict's run is older than the move; record the move's time - in the session). +What step 6 added: + +| Part | Where it is | +|---|---| +| Applying the verdicts | `createApplier({session, docsRoot, bundlesRoot, revision(), manifest()})` in `scripts/lib/shot-verdicts.mjs`: `arrive(n, out, part)` applies a part of a run's verdict as its event arrives, as the run decided it; `pending()` applies what was reported and not applied (the tool opened again); `rejudge(out, from)` applies every layer of a picture again from the run `from` on, judged anew with the manifest as it is and against the baseline; `reviewed(n, out)`, `heldPng(n, out)`, `errors`. Its header has the rules. What it applied is the stage's `review/` (`readReviewed`, `writeReviewed`, `reviewedFiles` in `shot-stage.mjs`), which the server shows in place of the run's own verdicts; a verdict judged again whose reference changed has its strength map measured again into `review/maps/` | +| What a picture held | before a run's verdict on a picture is applied, `holdPicture(dir, outRoot, out, bundlesRoot, build)` keeps its PNG, SVG, bundle and manifest build in `runs//held/`; `unholdPicture` puts them back (each removed that was not there) and returns the build; `heldOf`. Putting a picture back as its first layer found it undoes that layer and every later one, which is how a decision is applied: there is no other undo | +| A bundle into the cache | `copyBundle(fromRoot, toRoot, out, png)` in `svgshot/bundle.mjs`, naming the PNG the file holds once a review has decided (the run named the one it expected); `BUNDLE_CACHE` there, the one default for `shoot_docs.mjs`, `replay.mjs`, `sheet.mjs` and the tool | +| The SVG with its PNG | `svgFollowsPng({written, svgOnDisk})` in `shot-policy.mjs`, which `shoot_docs.mjs`'s `keepSvg` and the applier both ask. With `--stage`, `keepSvg` judges and stages the SVG of a picture whose PNG is not written too ("unchanged", `why` its reason, no look), and of one restored or the dark one's, so that a review that takes the capture can write the SVG as judged; the lines printed are the same, and without `--stage` nothing changed | +| The noise rule over files | `pngsNear(a, b, rule)` in `shot-change.mjs`, `shoot_docs.mjs`'s `nearly` | +| The manifest's setter | `setPart(out, part, key, value)` on `openManifest`'s object: a decision or an allowance set, or removed with `undefined`, checked as `parseManifest` checks an entry | +| The actions | POSTs of JSON to `/api/picture/` (`hrefs.act`), `{action}`: `png` (`keep` "old", "new" or null), `svg` (`accept` true, false or null), `allowance` (`png` and `svg`, each `{levels, share, ignore}`, empty for the default), `rule` (the picture's decisions removed: "Back to the tool's verdicts"), `judge`; and `/api/baseline` (`hrefs.moveBaseline`). Each changes the manifest, then `rejudge`s from the run the verdict is about. A POST is refused unless its Origin is the server's own and its body JSON, since a page of any origin can send a form to the loopback address. The detail page's PNG and SVG rows have the choices, an Allowances form (a rectangle is drawn by dragging on the picture, in device pixels) and Judge again; the page's script sends them and says "applying..." or the error beside the buttons | +| Changed outside the runs | the server's `changed()` (`changedPictures` against the baseline, read at most once a second); `reviewPictures(runs, {outside})` puts those no run reported in the group `outside`, between Only noise and Unchanged; `outsideHtml` compares HEAD's picture with the file, its map `/map/outside/` drawn by `changeMap` | +| Moving to HEAD | `session.moveBaseline(baseline)` (`moved: {at, from}`); the server's `head()` and `revisionAt(hash)`; HEAD is read again at most once a second, so the notice follows a commit made while the tool is open. Each verdict as applied records the baseline it was judged against (`baseline`, a run's `--against` on arrival), and `reviewPictures(runs, {baseline})` gives one judged against the revision of another baseline the look `baseline` until it is judged again | +| A retake's revision | `shot_review.mjs --retake` passes `--against `, so that a run judges against the session's baseline whatever HEAD is (`againstOf(run)`) | + +**Next: step 7**, the runs page: retake, replay, clean up and stop from the page. `app.start` +already takes `{tool, args}`; a retake's args must carry `--against ` as +`shot_review.mjs` passes it. A clean-up (`--cleanup`) writes under `docs/` itself and is no +staged run: it needs the session's runs stopped first, and the pictures it resets then show in +the outside group. + +Left from step 6, small: `openManifest().save` writes LF, so a manifest checked out with CRLF +shows as modified with no diff once the tool (or `shoot_docs.mjs`) has saved it, even after a +decision is removed again. An allowance is applied as soon as it is submitted; there is no +preview of the verdict it would give without applying it (the plan's "shows the verdict it +gives" is met by the page showing the new verdict). + +Step 6 was checked with the tests in `test/svgshot.test.mjs` (the applier on its own, and the +server with a stand-in run: a verdict applied as it arrives, decisions, allowances, the +outside group, the move), and with the real tool in a worktree: a retake of +`Menu_File_Project` (dark `unchanged`, light `noise`, both SVGs judged and staged as +"unchanged"), nothing written under `docs/`; the light picture taken through a POST and through +the page's own button (the PNG written, the decision and BETA 1034 in the manifest, the SVG as +judged the same as the file), a rectangle drawn on the SVG's picture and applied as an +allowance, then the defaults and the rule again, after which `docs/` was as HEAD has it. The +[checks of a move](#checking-a-move) printed the states listed there, and the restore case +staged the same lines, its verdicts as listed. Step 5 was checked with the tests in `test/svgshot.test.mjs` and with three real runs started by the tool in one session, the tool closed between them: a retake of `Close_Question` whose diff --git a/docs/Documentation/Tools-IDE.md b/docs/Documentation/Tools-IDE.md index 4dfdea927..602e3aa7d 100644 --- a/docs/Documentation/Tools-IDE.md +++ b/docs/Documentation/Tools-IDE.md @@ -147,7 +147,7 @@ Flags: - `--jobs` is how many IDEs run at once, 6 by default and at most 16. Each runs on a private desktop (a slot from [`tbbroker`](Tools-Compiler#tbbroker)) with a work folder and an install copy of its own, and a DevTools port Windows picks. The setups wait in a queue, the longest first, and the three long ones (no-project, sample and customcontrols) are cut into three parts, each in an IDE of its own. Every line of output starts with the setup or part it is from. `--jobs 1` runs each setup whole, one after another. - The help setup's slot also carries a port from the broker for the add-in's detached window, which the add-in has to be told before it binds it. The add-in's saved settings (`tbDocsHelp`) are emptied for the run and, with the IDE's project state, put back at the end. - `--svg` also draws each picture as SVG, from the page as it stands at the capture, and writes it beside the PNG as `X.svg` when it is close enough to the PNG (see [A picture as SVG](Authoring#a-picture-as-svg)). An SVG already there is rewritten only when its PNG is. It needs Python with fontTools. What the SVG is drawn from --- the page's layout, styles and measurements --- is saved for each picture as a *bundle* in the folder `--bundles` names, `.svgshot-bundles` in the repository by default, so that [`replay.mjs`](#replay) can draw the SVG again without the IDE. Git ignores that folder, and it must stay outside `docs`: a bundle holds the Windows user name. -- `--stage` names a folder, new or empty, outside `docs` and at a path git ignores, that takes everything the run would write: nothing is written under the output folder, to the bundle folder or to `test/shots/manifest.json`. Each picture's capture, its SVG whether or not it is close enough, its bundle, its difference pictures, its *strength maps* (how many levels each pixel is off the picture it was compared with, and the SVG's off its PNG) and what was decided about it go there, laid out as `scripts/lib/shot-stage.mjs` describes, so that another program can apply the decisions afterwards. The lines printed say what was decided, as without it. It cannot be given with `--diffs` or `--bundles`. The fonts' cuts are still kept in the bundle folder's `subsets`, since a cut depends only on the font and its characters. +- `--stage` names a folder, new or empty, outside `docs` and at a path git ignores, that takes everything the run would write: nothing is written under the output folder, to the bundle folder or to `test/shots/manifest.json`. Each picture's capture, its SVG whether or not it is close enough, its bundle, its difference pictures, its *strength maps* (how many levels each pixel is off the picture it was compared with, and the SVG's off its PNG) and what was decided about it go there, laid out as `scripts/lib/shot-stage.mjs` describes, so that another program can apply the decisions afterwards. With `--svg`, the SVG of a picture whose PNG is not written is judged and staged too, its decision *unchanged*, so that a program that takes the capture after all can write the SVG as judged. The lines printed say what was decided, as without it. It cannot be given with `--diffs` or `--bundles`. The fonts' cuts are still kept in the bundle folder's `subsets`, since a cut depends only on the font and its characters. - `--events` also prints a line for each event, `@@shot-event` followed by JSON: the run with its jobs and pictures, each job started, done or failed, each picture's verdict, each SVG's, a picture that failed or was not taken, and the summary. A picture's verdict says what it was compared with, its regions of change and whether it needs a person to look at it (`scripts/lib/shot-queue.mjs`). With `--events`, a line `stop` on standard input stops the run: no further picture is taken and no further setup started, and the IDEs end and the registry is put back as at the end of a run. Exit codes: **0** every picture was written or was unchanged (with `--cleanup`, every modified picture was compared; a run stopped with `--events` exits as for the pictures it took); **1** a picture failed (an element was not found, the page showed the user name, or a light picture was more than 32 pixels off the dark one's size), the add-in did not build, or the demo project does not compile, or with `--cleanup` a picture could not be read as a PNG; **2** the tool could not run: a refused command line, no IDE, no built site, a registry it could not record, no such revision (or, with `--against` or `--cleanup`, no git repository), or a crash; **3** the registry or the work folder was not put back (see the lines above). diff --git a/docs/Documentation/Tools-Toolchain-Tests.md b/docs/Documentation/Tools-Toolchain-Tests.md index 4bcb6dc76..29adea9fe 100644 --- a/docs/Documentation/Tools-Toolchain-Tests.md +++ b/docs/Documentation/Tools-Toolchain-Tests.md @@ -279,7 +279,8 @@ Unit tests for the SVG screenshot converter. They cover: - `scripts/lib/shot-policy.mjs`, which decides what becomes of a capture and of its SVG, and `scripts/lib/shot-manifest.mjs`, which reads and writes `test/shots/manifest.json`: a light picture compared with the dark one first; a picture's own noise rule and SVG bar passed to the comparison; a review's decision used for the bytes it was made on and for no others, in a run and in `--cleanup`; the manifest written in a fixed order and read back the same, from an LF or a CRLF checkout; every malformed entry refused with its key named; a build recorded and the file saved only when it changes; and the repository's own manifest read. - The strength map, a byte per pixel saying how far one picture is from another, which `scripts/lib/shot-change.mjs` makes of two captures and `scripts/svgshot/diff.mjs` of an SVG against its PNG: the share of its pixels at a level, with rectangles left out, and its regions of change. The tests draw small maps by hand: a line up to 4 pixels thick leaves no core after the erosion and a block keeps its middle, the strokes of a word are joined into one block, every changed pixel is kept along the picture's edge, and regions that touch at a corner are one. `scripts/lib/shot-queue.mjs` then says which pictures the review tool shows first: a capture taken with nothing surviving or only a word or two, a capture kept as within noise with something 2 grey levels off or more surviving, an SVG accepted with a part surviving or refused with nothing; and the regions of a capture measured against the picture it was compared with, when that is of its size. - `scripts/lib/shot-stage.mjs`, the folder `--stage` writes into instead of `docs`: each picture's files named, both parts of its verdict kept, a strength map written and read back, and a folder refused under `docs`, where git would take it, or when it holds anything. And the line `stop` that ends a run with `--events`, read across chunks and acted on once (`scripts/lib/record-lines.mjs`). -- The screenshot review tool's session, the model of what its pages show, and its server (`scripts/lib/shot-session.mjs`, `shot-review.mjs`, `shot-pages.mjs` and `shot-server.mjs`). A session is made with its baseline, numbers its runs, and marks a run that was still going when the tool closed as interrupted. Its lock refuses a second tool while the first runs, and is taken over from one that ended. When several runs reported a picture, it shows the newest verdict on each part, and a newer run that takes the PNG again drops the older SVG. The tests also cover the overview's groups and their order, a picture whose job failed before reaching it, the filters, a loupe's crop, and a strength map drawn as the difference map of the two pictures it came from. The server is tested with a stand-in run that reports as `shoot_docs.mjs --stage --events` does: the server follows it, serves its pages and files, refuses a request addressed to any host but the loopback address, stops the run through its stdin, and reads the run back when the session is opened again. +- What a picture held before a verdict was applied, kept and put back with its SVG, its bundle and its build (`holdPicture` and `unholdPicture` in `scripts/lib/shot-apply.mjs`), a bundle copied from one folder to another naming another PNG (`copyBundle`), and the screenshot review tool's writes under `docs` (`scripts/lib/shot-verdicts.mjs`): a run's verdicts applied as they arrive, judged again after a review keeps the old picture, takes the new one or refuses its SVG, and a replay's SVG dropped once the PNG it was drawn against is no longer the file's. +- The screenshot review tool's session, the model of what its pages show, and its server (`scripts/lib/shot-session.mjs`, `shot-review.mjs`, `shot-pages.mjs` and `shot-server.mjs`). A session is made with its baseline, numbers its runs, and marks a run that was still going when the tool closed as interrupted. Its lock refuses a second tool while the first runs, and is taken over from one that ended. When several runs reported a picture, it shows the newest verdict on each part, and a newer run that takes the PNG again drops the older SVG. The tests also cover the overview's groups and their order, a picture whose job failed before reaching it, the filters, a loupe's crop, and a strength map drawn as the difference map of the two pictures it came from. The server is tested with a stand-in run that reports as `shoot_docs.mjs --stage --events` does: the server follows it, serves its pages and files, refuses a request addressed to any host but the loopback address, stops the run through its stdin, and reads the run back when the session is opened again. A second stand-in run checks that the server applies its verdict as it arrives, refuses a POST from another origin or not of JSON, takes a review's decision and its allowances (refusing one out of range), lists a picture no run reported as changed outside the runs, and moves the session to HEAD, after which a verdict judged against the old baseline needs a look until it is judged again. What the SVG keeps of an image it embeds is `compactImage`'s, from `scripts/lib/compact-image.mjs`, which [`compact-image.test.mjs`](#compact-image-test) tests. No browser, no built tree, well under a second. diff --git a/scripts/lib/shot-apply.mjs b/scripts/lib/shot-apply.mjs index ec6bdc271..cf7f32516 100644 --- a/scripts/lib/shot-apply.mjs +++ b/scripts/lib/shot-apply.mjs @@ -5,7 +5,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; -import { dropBundle } from "../svgshot/bundle.mjs"; +import { copyBundle, dropBundle } from "../svgshot/bundle.mjs"; import { restoreFromRevision, svgFate } from "./shot-revision.mjs"; /** The SVG beside the picture `out`. */ @@ -69,15 +69,24 @@ export function restorePictures(outRoot, revision, outs, { dryRun = false } = {} /** * Writes the verdict on a capture of the picture `out` (decidePicture): the bytes the file is - * to hold, if any; for "dark", the light file removed; for "restored", the picture and its SVG - * put back as `revision` has them (restorePictures). Returns {removed, svg}: whether a file - * was removed, and for "restored" restorePictures' answer for the picture, else null. With - * `dryRun` nothing is written, and the answer is what would be done (a run's stage, + * to hold, if any; for "dark" and "once", the light file removed, and its SVG and its bundle + * under `bundlesRoot` (dropPicture), for "dark" only with `svg`; for "restored", the picture and its SVG put + * back as `revision` has them (restorePictures). Returns {removed, svg}: whether the picture's + * file was removed, and for "restored" restorePictures' answer for the picture, else null. + * With `dryRun` nothing is written, and the answer is what would be done (a run's stage, * shot-stage.mjs, whose verdicts the review tool applies). */ -export function applyPicture(outRoot, out, decision, revision, { dryRun = false } = {}) { +export function applyPicture( + outRoot, + out, + decision, + revision, + { dryRun = false, svg = false, bundlesRoot = null } = {}, +) { const file = path.join(outRoot, out); - if (decision.state === "dark") { + if (decision.state === "dark" || decision.state === "once") { + if (svg || decision.state === "once") + return { removed: dropPicture(outRoot, out, bundlesRoot, { dryRun }), svg: null }; const had = existsSync(file); if (had && !dryRun) rmSync(file); return { removed: had, svg: null }; @@ -104,6 +113,81 @@ export function applySvg(outRoot, svgOut, svg, { dryRun = false } = {}) { return "removed"; } +// ---- what a picture held before a verdict was applied -------------------------------- +// +// The screenshot review tool applies a run's verdicts as they arrive, and a review may then +// decide otherwise: it puts the picture back as it was before the run's verdict and applies +// the verdict decided. So before it applies a run's verdict on a picture it keeps what the +// picture held, in a folder of the run's (`dir`): +// +// .json {out, png, svg, bundle, build}: whether each was there, and the build +// the manifest recorded for the file (null for none) +// files/ the PNG, and files/ its SVG, when there +// bundles/ its bundle, laid out as the bundle cache is (bundle.mjs) + +const heldFiles = (dir, out) => ({ + record: path.join(dir, `${slugOf(out)}.json`), + png: path.join(dir, "files", out), + svg: path.join(dir, "files", svgOf(out)), + bundles: path.join(dir, "bundles"), +}); + +/** What `dir` keeps of the picture `out` (holdPicture), or null when it keeps nothing. */ +export function heldOf(dir, out) { + const f = heldFiles(dir, out); + if (!existsSync(f.record)) return null; + const held = JSON.parse(readFileSync(f.record, "utf8")); + return { + ...held, + pngBytes: held.png ? readFileSync(f.png) : null, + svgText: held.svg ? readFileSync(f.svg, "utf8") : null, + }; +} + +/** + * Keeps in `dir` what the picture `out` under `outRoot` holds now: its PNG, its SVG, its + * bundle under `bundlesRoot` (or none) and `build`, the build the manifest records for it. + * What `dir` kept of it before is replaced. + */ +export function holdPicture(dir, outRoot, out, bundlesRoot, build) { + const f = heldFiles(dir, out); + const keep = (from, to) => { + if (!existsSync(from)) { + rmSync(to, { force: true }); + return false; + } + mkdirSync(path.dirname(to), { recursive: true }); + writeFileSync(to, readFileSync(from)); + return true; + }; + const png = keep(path.join(outRoot, out), f.png); + const svg = keep(path.join(outRoot, svgOf(out)), f.svg); + const bundle = bundlesRoot ? copyBundle(bundlesRoot, f.bundles, out) : false; + mkdirSync(dir, { recursive: true }); + writeFileSync(f.record, `${JSON.stringify({ out, png, svg, bundle, build: build ?? null })}\n`); +} + +/** + * Puts the picture `out` under `outRoot` back as `dir` keeps it (holdPicture): its PNG, its + * SVG and its bundle, each removed when it was not there. Returns the build kept, or + * undefined when `dir` keeps nothing of the picture and nothing was done. + */ +export function unholdPicture(dir, outRoot, out, bundlesRoot) { + const held = heldOf(dir, out); + if (!held) return undefined; + const put = (bytes, to) => { + if (bytes === null) rmSync(to, { force: true }); + else { + mkdirSync(path.dirname(to), { recursive: true }); + writeFileSync(to, bytes); + } + }; + put(held.pngBytes, path.join(outRoot, out)); + put(held.svgText, path.join(outRoot, svgOf(out))); + if (bundlesRoot) copyBundle(heldFiles(dir, out).bundles, bundlesRoot, out); + return held.build; +} + /** * Removes the picture `out`, its SVG and, when there is a bundle cache (`bundlesRoot`), its * bundle. Returns whether the picture's file was there. With `dryRun` nothing is removed. diff --git a/scripts/lib/shot-change.mjs b/scripts/lib/shot-change.mjs index 15ab0e973..d3c3c1c97 100644 --- a/scripts/lib/shot-change.mjs +++ b/scripts/lib/shot-change.mjs @@ -5,7 +5,7 @@ // // Images are what png.mjs's decodePng returns: {width, height, rgba}. -import { encodePng } from "./png.mjs"; +import { decodePng, encodePng } from "./png.mjs"; /** The most grey levels a channel of a pixel may differ by, in a capture's noise. */ export const NOISE_LEVELS = 16; @@ -43,6 +43,12 @@ export function withinNoise(A, B, { levels = NOISE_LEVELS, share = NOISE_SHARE, return true; } +/** + * withinNoise of two PNG files (bytes), `rule` its limits and rectangles: the `near` that + * decidePicture and cleanupResets (shot-policy.mjs) take. + */ +export const pngsNear = (a, b, rule = {}) => withinNoise(decodePng(a), decodePng(b), rule); + // ---- the strength map and its regions --------------------------------------------------- // // A strength map is how far each pixel of one picture is from another's, a byte per pixel, diff --git a/scripts/lib/shot-manifest.mjs b/scripts/lib/shot-manifest.mjs index b058361c5..94ca6d399 100644 --- a/scripts/lib/shot-manifest.mjs +++ b/scripts/lib/shot-manifest.mjs @@ -219,6 +219,21 @@ export function openManifest(file = MANIFEST) { const { build: _, ...rest } = entry(out); m.pictures[out] = rest; }, + /** + * Sets the key `key` of the part `part` ("png" or "svg") of a picture's entry to `value` + * (a decision, an allowance), or removes it when `value` is undefined. Throws, changing + * nothing, when the entry would not be one the manifest reads. + */ + setPart(out, part, key, value) { + const e = structuredClone(entry(out) ?? {}); + const p = { ...e[part] }; + if (value === undefined) delete p[key]; + else p[key] = value; + e[part] = p; + const next = pruned(e, ["build", "png", "svg"]) ?? {}; + checkEntry(out, next); + m.pictures[out] = next; + }, text: () => manifestText(m), save() { const text = manifestText(m); diff --git a/scripts/lib/shot-pages.mjs b/scripts/lib/shot-pages.mjs index 48efe1884..09a58c184 100644 --- a/scripts/lib/shot-pages.mjs +++ b/scripts/lib/shot-pages.mjs @@ -2,12 +2,14 @@ // picture of the session as a card in its group, and the detail page of one picture, made of // the pieces in shot-views.mjs. Each page is whole HTML; a page keeps itself current by // fetching itself again when the server says over its event stream that something it shows -// has changed. Read-only: the actions come with the decisions (step 6 of the plan). +// has changed. Its actions (a decision, an allowance, the session moved to HEAD) are POSTs of +// JSON to the server, which applies them and says so over the same stream. // // Every address the pages link to is made here (hrefs), and the server answers each. import { anchor, chipHtml, esc, pageHtml, sizeText } from "./shot-views.mjs"; import { FILTERS, GROUPS } from "./shot-review.mjs"; +import { pngRule, svgRule } from "./shot-policy.mjs"; import { SURVIVES } from "./shot-queue.mjs"; /** The generator the tool's pages name in their . */ @@ -30,10 +32,18 @@ export const hrefs = { run: (n, rel) => `/file/run/${n}/${encodePath(rel)}`, /** The picture the run `n` judged its capture of `out` against. */ reference: (n, out) => `/file/ref/${n}/${encodePath(out)}`, + /** The PNG `out` as the run `n`'s layer found it, before its verdict was applied. */ + held: (n, out) => `/file/held/${n}/${encodePath(out)}`, /** The difference map of the run `n`'s capture of `out` ("png") or of its SVG ("svg"). */ map: (n, part, out) => `/map/${n}/${part}/${encodePath(out)}`, + /** The difference map of a picture changed outside the session's runs, against the baseline's. */ + outsideMap: (out) => `/map/outside/${encodePath(out)}`, log: (n) => `/log/${n}`, events: "/events", + /** POST: a review's action on a picture (shot-server.mjs's act). */ + act: (out) => `/api/picture/${encodePath(out)}`, + /** POST: the session moved to HEAD. */ + moveBaseline: "/api/baseline", }; const REVIEW_STYLE = ``; // The page's script: the event stream, the page fetched again when something it shows has @@ -90,6 +107,7 @@ const REVIEW_SCRIPT = ``; @@ -138,13 +241,25 @@ export function svgStateText(svg) { return `${svg.state}${svg.faithful === null ? "" : ", accepted"}`; } +/** + * A button that POSTs `act` (JSON) to `url`, `pressed` true or false for one of a choice, or + * null. The page's script sends it (REVIEW_SCRIPT). + */ +export const actionButton = (label, url, act, { pressed = null, title = "" } = {}) => + ``; + +/** A row of actions, with the place their progress and failures are said. */ +const actionsHtml = (buttons) => + `
    ${buttons.join("")}
    `; + /** The chips of a picture `p` (reviewPictures'): its state, and why it needs a look. */ export function chipsOf(p) { const chips = []; + if (p.outside) chips.push(chipHtml(`changed outside the runs: ${p.outside}`, "change")); if (p.progress) chips.push(chipHtml("in progress", "progress")); if (p.failed) chips.push(chipHtml("failed", "fail", p.failed.error)); if (p.retake) chips.push(chipHtml("needs a retake", "fail", p.retake.why)); - if (!p.png && !p.svg && !p.failed && !p.retake && !p.progress) + if (!p.png && !p.svg && !p.failed && !p.retake && !p.progress && !p.outside) chips.push( p.skipped ? chipHtml("not taken", "fail", `run ${p.skipped} was stopped first`) @@ -217,12 +332,23 @@ ${[...runs] * The overview: `groups` reviewPictures' groups, with only the pictures the filter shows; * `filter` its key; `counts` how many pictures each filter shows ({all, look, overridden, * nosvg}); `runs` [{n, command, live, stopping, interrupted, stopped, exit, done, listed}], - * oldest first; `baseline` the session's ({hash, short, build}); `headNow` HEAD's short hash - * when it is no longer the baseline, else null; `newestIde` the newest install's build, or - * null; `resume` {index, total, out} for the queue's place, or null; `exists(path)` whether a - * file is under docs/. + * oldest first; `baseline` the session's ({hash, short, build, moved}); `headNow` HEAD's short + * hash when it is no longer the baseline, else null, and `canMove` whether the session can be + * moved to it; `newestIde` the newest install's build, or null; `resume` {index, total, out} + * for the queue's place, or null; `exists(path)` whether a file is under docs/. */ -export function overviewHtml({ groups, filter, counts, runs, baseline, headNow, newestIde, resume, exists }) { +export function overviewHtml({ + groups, + filter, + counts, + runs, + baseline, + headNow, + canMove = false, + newestIde, + resume, + exists, +}) { const shown = groups.filter((g) => g.pictures.length); const filters = FILTERS.map( ([key, label]) => @@ -234,7 +360,17 @@ export function overviewHtml({ groups, filter, counts, runs, baseline, headNow, const notices = []; if (headNow) notices.push( - `HEAD is now ${esc(headNow)}: this session compares with its baseline, ${esc(baseline.short)}, until it is committed.`, + `HEAD is now ${esc(headNow)}: this session compares with its baseline, ${esc(baseline.short)}, until it is committed or moved.${ + canMove + ? ` Moving it makes ${esc(headNow)} the baseline; a verdict judged against ${esc(baseline.short)}'s picture goes back to Needs a look.${actionsHtml( + [actionButton(`Move the session to ${headNow}`, hrefs.moveBaseline, { action: "move" })], + )}` + : "" + }`, + ); + if (baseline.moved) + notices.push( + `The session was moved to ${esc(baseline.short)} from ${esc(baseline.moved.from)} on ${esc(baseline.moved.at)}.`, ); if (newestIde && baseline.build && newestIde > baseline.build) notices.push(`BETA ${newestIde} found: the baseline's pictures were taken on BETA ${baseline.build} at newest.`); @@ -254,10 +390,10 @@ ${cards}`; generator: REVIEW_GENERATOR, title: "Screenshot review", head: HEAD, - intro: `Every picture this session's runs took or drew, against the baseline ${esc(baseline.short)}${baseline.build ? ` (its pictures taken on BETA ${baseline.build} at newest)` : ""}. Nothing is written under docs until a verdict is applied.`, + intro: `Every picture this session's runs took or drew, against the baseline ${esc(baseline.short)}${baseline.build ? ` (its pictures taken on BETA ${baseline.build} at newest)` : ""}, and every other picture under docs that differs from it. Each verdict is applied under docs as it arrives, and again when a review decides otherwise.`, summary: shown.map((g) => [g.title, g.pictures.length]), contents: shown.map((g) => `
  • ${esc(g.title)} (${g.pictures.length})
  • `).join("\n"), - body: `${notices.map((n) => `

    ${n}

    `).join("\n")} + body: `${notices.map((n) => `
    ${n}
    `).join("\n")} ${resumeLink}${runsHtml(runs)} ${shown.length ? shown.map(group).join("\n") : "

    No picture.

    "}`, @@ -334,22 +470,127 @@ const rectsText = (rects) => const AGAINST = { revision: "HEAD", disk: "the file", dark: "the dark picture" }; +// The pager of a picture's page: the overview, its neighbours in the list it was opened from, +// and its place there. +function pagerHtml({ p, filter, place, prev, next }) { + const filterLabel = (FILTERS.find(([k]) => k === filter) ?? FILTERS[0])[1]; + const groupTitle = (GROUPS.find(([k]) => k === p.group) ?? [null, p.group])[1]; + return ``; +} + +const written = (state) => state === "new" || state === "updated"; + +// What the file holds once a PNG verdict is applied, as a few words. +const HOLDS = { + new: "the new capture", + updated: "the new capture", + unchanged: "the picture it held, which the capture equals", + noise: "the picture it held: the capture is within noise of it", + restored: "HEAD's picture", + dark: "nothing: the page shows the dark picture", + once: "nothing: the picture is taken once", +}; + +/** + * The page of a picture changed outside the session's runs (reviewPictures' `outside`): the + * baseline's picture against the file, and no verdict. `files` {head, docs}, each {src, size} + * or null; `map` the difference map's address, or null; the rest as detailHtml's. + */ +export function outsideHtml({ p, filter, place, prev, next, files, map, baseline }) { + const size = files.docs?.size ?? files.head?.size ?? null; + const figs = []; + if (files.head) figs.push(figureHtml(files.head.src, files.head.size, `HEAD (${baseline.short})`)); + if (files.docs) figs.push(figureHtml(files.docs.src, files.docs.size, "The file")); + if (map) + figs.push( + figureHtml( + map, + size, + "Difference (yellow: 1 grey level, shading to red at 128 or more; cyan and magenta: only in one)", + ), + ); + const body = `${pagerHtml({ p, filter, place, prev, next })} +

    ${esc(sizeText(size))}; ${chipsOf(p)}

    +

    No run of this session reported this picture: its file is ${esc(p.outside)} against the baseline, ${esc(baseline.short)} (a retake from a terminal, or an edit). It has no verdict to override; a retake of it through this tool judges it.

    +
    ${figs.join("\n")}
    `; + return pageHtml({ + generator: REVIEW_GENERATOR, + title: p.out, + head: HEAD, + intro: "Changed outside the session's runs.", + body, + }).replace("", ``); +} + +/** + * The allowances of a picture, as a form: the PNG's noise rule and the SVG's bar, each with its + * rectangles left out, which are drawn on `pngPic` and `svgPic` ({src, size}) by dragging. + * Empty fields are the defaults, which the placeholders show. + */ +function allowanceHtml(url, entry, pngPic, svgPic) { + const dp = pngRule(null); + const ds = svgRule(null); + const noise = entry?.png?.noise ?? {}; + const bar = entry?.svg?.bar ?? {}; + const rectLines = (list) => (list ?? []).map((r) => `[${r.join(", ")}]`).join("\n"); + const toRects = (list) => (list ?? []).map(([x0, y0, x1, y1]) => ({ x0, y0, x1, y1 })); + const drawOn = (pic, id, rects) => + pic?.size + ? `

    Drag on the picture to add a rectangle.

    Where a rectangle is drawn${overlayOf(null, toRects(rects), pic.size)}` + : ""; + const field = (name, label, value, placeholder, attrs) => + ``; + return `
    +

    Allowances

    +

    An allowance changes the rule for this picture alone, in this theme, until it is removed; the picture is judged again with it at once. Empty is the default.

    +
    +
    PNG: the noise rule +${field("pngLevels", "Levels", noise.levels, dp.levels, 'min="0" max="255" step="1"')}${field("pngShare", "Share (%)", noise.share, +(dp.share * 100).toFixed(4), 'min="0" max="100" step="any"')} + + +${drawOn(pngPic, "png-ignore", entry?.png?.ignore)} +
    +
    SVG: the bar +${field("svgLevels", "Levels", bar.levels, ds.levels, 'min="1" max="255" step="1"')}${field("svgShare", "Share (%)", bar.share, ds.share, 'min="0" max="100" step="any"')} + + +${drawOn(svgPic, "svg-ignore", entry?.svg?.ignore)} +
    + +
    +
    `; +} + /** * The detail page of one picture: `p` reviewPictures' picture; `filter` the filter it was * opened from, `place` {index, total, group} its place in that filter's list, `prev` and * `next` its neighbours there (pictures, or null); `files` {head, reference, capture, svgPng, * svg}, each {src, size} or null, and `maps` {png, svg}, each an address or null; `rules` - * {png: pngRule, svg: svgRule} of its entry in the manifest, `build` the build the manifest - * records for its file, or null; `baseline` the session's. + * {png: pngRule, svg: svgRule} of its entry in the manifest, `entry` that entry (or null), + * `build` the build the manifest records for its file, or null; `baseline` the session's; + * `error` why the last application of its verdicts failed, or null. */ -export function detailHtml({ p, filter, place, prev, next, files, maps, rules, build, baseline }) { - const filterLabel = (FILTERS.find(([k]) => k === filter) ?? FILTERS[0])[1]; +export function detailHtml({ + p, + filter, + place, + prev, + next, + files, + maps, + rules, + entry = null, + build, + baseline, + error = null, +}) { const groupTitle = (GROUPS.find(([k]) => k === p.group) ?? [null, p.group])[1]; - const nav = ``; + const nav = pagerHtml({ p, filter, place, prev, next }); + const act = hrefs.act(p.out); const png = p.png; const svg = p.svg; const size = files.capture?.size ?? files.head?.size ?? null; @@ -393,24 +634,35 @@ ${maps.png ? `
    ${figureHtml(maps.png, size, `Difference let pngRow = ""; if (png) { const rule = rules.png; + const decided = png.by === "decision" ? (written(png.state) ? "new" : "old") : null; + const choice = + files.capture && png.state !== "failed" && png.state !== "once" + ? actionsHtml([ + actionButton("Keep the old picture", act, { action: "png", keep: "old" }, { pressed: decided === "old" }), + actionButton("Take the new capture", act, { action: "png", keep: "new" }, { pressed: decided === "new" }), + ...(decided ? [actionButton("Back to the rule", act, { action: "png", keep: null })] : []), + ]) + : ""; pngRow = `

    PNG: ${esc(pngStateText(png))}

    -${png.look ? `

    Needs a look: ${esc(png.look.text)}.

    ` : ""}${factsHtml([ - ["Decided by", png.by === "decision" ? "a review's decision about these bytes" : "the rule"], - ["Earlier decision", png.earlier ? esc(`a review kept the ${png.earlier} picture of other bytes`) : null], - ["Judged against", png.against ? esc(AGAINST[png.against] ?? png.against) : "nothing: a new picture"], +${png.look ? `

    Needs a look: ${esc(png.look.text)}.

    ` : ""}${HOLDS[png.state] ? `

    The file holds ${esc(HOLDS[png.state])}.

    ` : ""}${choice}${factsHtml( [ - "Regions of change", - png.regionCount === null - ? "not measured" - : `${png.regionCount}, ${png.regions.filter((r) => r.core >= SURVIVES).length} surviving the erosion`, + ["Decided by", png.by === "decision" ? "a review's decision about these bytes" : "the rule"], + ["Earlier decision", png.earlier ? esc(`a review kept the ${png.earlier} picture of other bytes`) : null], + ["Judged against", png.against ? esc(AGAINST[png.against] ?? png.against) : "nothing: a new picture"], + [ + "Regions of change", + png.regionCount === null + ? "not measured" + : `${png.regionCount}, ${png.regions.filter((r) => r.core >= SURVIVES).length} surviving the erosion`, + ], + ["Taken on", png.build ? `BETA ${png.build}` : null], + ["The file's build", build ? `BETA ${build}` : "unknown"], + ["Noise levels", `${rule.levels}`], + ["Noise share", pct(rule.share)], + ["Left out", esc(rectsText(rule.rects))], ], - ["Taken on", png.build ? `BETA ${png.build}` : null], - ["The file's build", build ? `BETA ${build}` : "unknown"], - ["Noise levels", `${rule.levels}`], - ["Noise share", pct(rule.share)], - ["Left out", esc(rectsText(rule.rects))], -])} +)} ${files.capture && before ? loupesHtml(png.regions, size, { src: before.src, label: beforeLabel }, { src: files.capture.src, label: "new" }) : ""}
    `; } @@ -433,9 +685,18 @@ ${files.capture && before ? loupesHtml(png.regions, size, { src: before.src, lab overlayOf(svg.regions, rule.rects, svgSize), ), ); + const accepted = svg.by === "decision" ? svg.faithful : null; + const stays = !restored && svg.state === "unchanged" && svg.why && svg.faithful !== null; + const choice = svg.svg + ? actionsHtml([ + actionButton("Accept the SVG", act, { action: "svg", accept: true }, { pressed: accepted === true }), + actionButton("No SVG", act, { action: "svg", accept: false }, { pressed: accepted === false }), + ...(accepted === null ? [] : [actionButton("Back to the bar", act, { action: "svg", accept: null })]), + ]) + : ""; svgRow = `

    SVG: ${esc(svgStateText(svg))}

    -${restored ? "

    The PNG goes back to HEAD's, and its SVG goes back with it.

    " : ""}${svg.look ? `

    Needs a look: ${esc(svg.look.text)}.

    ` : ""}${factsHtml( +${restored ? "

    The PNG goes back to HEAD's, and its SVG goes back with it.

    " : ""}${stays ? `

    The SVG stays as it is (${esc(svg.why)}): it was judged all the same, and is written as judged if a review takes the capture.

    ` : ""}${svg.look ? `

    Needs a look: ${esc(svg.look.text)}.

    ` : ""}${restored ? "" : choice}${factsHtml( [ ["Run", `${svg.run}`], ["Decided by", svg.by === "decision" ? "a review's decision about these bytes" : svg.by ? "the rule" : null], @@ -462,12 +723,27 @@ ${files.svgPng && files.svg ? loupesHtml(svg.regions, svgSize, { src: files.svgP if (p.failed) notes.push(`Run ${p.failed.run} failed on it: ${esc(p.failed.error)}`); if (p.retake) notes.push(`Run ${p.retake.run} could not draw it again: ${esc(p.retake.why)}`); if (p.skipped && !png && !svg) notes.push(`Run ${p.skipped} was stopped before it took this picture.`); + if (error) notes.push(`Its verdict could not be applied: ${esc(error)}. Judge it again to try again.`); + const judged = png || svg; + const footer = judged + ? `
    +

    Judge again

    +

    Judging again puts the picture back as each run found it and applies each run's verdict again, judged with the manifest as it is now and against the session's baseline, ${esc(baseline.short)}.

    +${actionsHtml([ + actionButton("Judge it again", act, { action: "judge" }), + actionButton("Back to the tool's verdicts", act, { action: "rule" }, { title: "remove this picture's decisions" }), +])} +
    ` + : ""; + const svgPic = files.svgPng ?? null; const body = `${nav}

    ${theme} theme, ${esc(sizeText(size))}; ${chipsOf(p)}

    ${notes.map((n) => `

    ${n}

    `).join("\n")} ${compare} ${pngRow} -${svgRow}`; +${svgRow} +${judged ? allowanceHtml(act, entry, files.capture, svgPic) : ""} +${footer}`; return pageHtml({ generator: REVIEW_GENERATOR, title: p.out, diff --git a/scripts/lib/shot-policy.mjs b/scripts/lib/shot-policy.mjs index 1d64bfdb4..794273c4c 100644 --- a/scripts/lib/shot-policy.mjs +++ b/scripts/lib/shot-policy.mjs @@ -96,6 +96,15 @@ export function decidePicture({ png, theme, dark, revision, disk, near, entry = return { ...pictureFate(png, revision, disk, nearBy), size, dark: other, ...why }; } +/** + * Whether a picture's SVG is judged and written with its PNG: when its PNG is written + * (`written`), or when there is no SVG to keep (`svgOnDisk` false). An SVG drawn from the whole + * page can differ from run to run in layers the picture does not show, so the SVG of a + * picture whose PNG stays stays with it (owner): it changes with its PNG, or when replay.mjs + * draws it again. + */ +export const svgFollowsPng = ({ written, svgOnDisk }) => !!written || !svgOnDisk; + /** * Whether --cleanup puts the file `after` (bytes) back as the revision has it (`before`): * a review's decision about these bytes ("old" puts it back, "new" leaves it), else diff --git a/scripts/lib/shot-review.mjs b/scripts/lib/shot-review.mjs index 1cfa7063e..d795580e1 100644 --- a/scripts/lib/shot-review.mjs +++ b/scripts/lib/shot-review.mjs @@ -12,6 +12,7 @@ export const GROUPS = [ ["look", "Needs a look"], ["changed", "Changed, most changed first"], ["noise", "Only noise"], + ["outside", "Changed outside the session's runs"], ["unchanged", "Unchanged"], ["added", "Added"], ["deleted", "Deleted"], @@ -102,6 +103,7 @@ export const SVG_CHANGES = new Set(["new", "updated", "removed"]); * file is under docs/. */ export function groupOf(p, exists) { + if (p.outside) return "outside"; if (p.progress) return "progress"; if (p.failed) return "failed"; if (p.png?.look || p.svg?.look) return "look"; @@ -128,18 +130,33 @@ export function groupOf(p, exists) { * verdict from the newest run that gave it one, and its SVG verdict from the newest run since * then: an SVG changes with its PNG, so a retake's PNG verdict drops an older SVG's. * + * `outside` lists the pictures under docs/ that differ from the baseline ([{out, status}], as + * changedPictures gives them): those no run reported are in a group of their own, with + * `outside` their status. A PNG verdict judged against the revision (`against`) of another + * baseline than `baseline` (its `baseline`, which the review tool records as it applies it) + * needs a look, `why` "baseline": the picture it was judged against has changed. + * * Returns {pictures, groups}: each picture {out, png, svg, failed, retake, skipped, progress, - * group, measure}, `png` and `svg` with the run that gave them as `run`, `failed` and `retake` - * {run, error | why} or null, `skipped` the run that did not take it, or null; `progress` - * whether a run that is running still owes it a verdict; `measure` how much it changed, from - * `measure(p)`, which orders the Changed group, most first. `groups` is GROUPS with each - * one's pictures, [{key, title, pictures}]. + * outside, group, measure}, `png` and `svg` with the run that gave them as `run`, `failed` and + * `retake` {run, error | why} or null, `skipped` the run that did not take it, or null; + * `progress` whether a run that is running still owes it a verdict; `measure` how much it + * changed, from `measure(p)`, which orders the Changed group, most first. `groups` is GROUPS + * with each one's pictures, [{key, title, pictures}]. */ -export function reviewPictures(runs, { exists = () => false, measure = () => 0 } = {}) { +export function reviewPictures(runs, { exists = () => false, measure = () => 0, outside = [], baseline = null } = {}) { const byOut = new Map(); const at = (out) => { if (!byOut.has(out)) - byOut.set(out, { out, png: null, svg: null, failed: null, retake: null, skipped: null, progress: false }); + byOut.set(out, { + out, + png: null, + svg: null, + failed: null, + retake: null, + skipped: null, + progress: false, + outside: null, + }); return byOut.get(out); }; for (const run of [...runs].sort((a, b) => a.n - b.n)) { @@ -165,8 +182,14 @@ export function reviewPictures(runs, { exists = () => false, measure = () => 0 } p.failed = { run: n, error: `its job, ${facts.jobOf.get(out)}, failed: ${job.error}` }; } } + for (const { out, status } of outside) if (!byOut.has(out)) at(out).outside = status; const pictures = [...byOut.values()].sort((a, b) => (a.out < b.out ? -1 : a.out > b.out ? 1 : 0)); for (const p of pictures) { + if (baseline && p.png?.against === "revision" && p.png.baseline && p.png.baseline !== baseline) + p.png = { + ...p.png, + look: { why: "baseline", text: "judged against the picture of the session's earlier baseline" }, + }; p.group = groupOf(p, exists); p.measure = p.group === "changed" ? measure(p) : 0; } diff --git a/scripts/lib/shot-server.mjs b/scripts/lib/shot-server.mjs index ed68da4f1..f53137d3c 100644 --- a/scripts/lib/shot-server.mjs +++ b/scripts/lib/shot-server.mjs @@ -4,10 +4,13 @@ // only and answers a request only when it names the loopback address as its host, since // its pages and pictures can hold the Windows user name. // -// It decides nothing and writes nothing under docs/: the verdicts are the runs' (shot-policy.mjs, -// in shoot_docs.mjs and replay.mjs), read from their stages and events, and the model of what -// the pages show is shot-review.mjs's. The one thing it writes is the session (shot-session.mjs): -// its runs, and the queue's place. +// It decides nothing by code of its own: the verdicts are the runs' (shot-policy.mjs, in +// shoot_docs.mjs and replay.mjs), read from their stages and events, applied under docs/ as +// they arrive and judged again when a review decides otherwise, both by shot-verdicts.mjs; a +// review's decisions and allowances go to the manifest (shot-manifest.mjs); and the model of +// what the pages show is shot-review.mjs's. It also writes the session (shot-session.mjs): its +// runs, the queue's place and the baseline. A request that changes anything is a POST of JSON +// from the tool's own pages: one from another origin is refused. import { existsSync, readFileSync, statSync } from "node:fs"; import { createServer } from "node:http"; @@ -16,9 +19,11 @@ import { DOCS_DIR } from "../../lib/repo-paths.mjs"; import { createSseChannel } from "../../lib/sse.mjs"; import { decodePng } from "./png.mjs"; import { svgOf } from "./shot-apply.mjs"; -import { shareOff, strengthPicture } from "./shot-change.mjs"; -import { detailHtml, hrefs, overviewHtml } from "./shot-pages.mjs"; +import { changeMap, shareOff, strengthPicture } from "./shot-change.mjs"; +import { hashOf } from "./shot-manifest.mjs"; +import { detailHtml, hrefs, outsideHtml, overviewHtml } from "./shot-pages.mjs"; import { pngRule, pngSize, svgRule } from "./shot-policy.mjs"; +import { createApplier } from "./shot-verdicts.mjs"; import { FILTERS, filterPictures, @@ -53,26 +58,64 @@ const sizeOf = (png) => { // The most difference maps kept drawn: drawing one decodes a picture of up to 4.5 M pixels. const MAPS_KEPT = 16; +// The most bytes a POST's body may have: an allowance with its rectangles. +const MOST_BODY = 64 * 1024; + /** * The server of the session `session` (openSession): `revision` the baseline as openRevision * gives it, `manifest()` the manifest as it is now (openManifest), `docsRoot` the pictures' - * folder, `headNow` HEAD's short hash when it is no longer the baseline, `newestIde` the - * newest install's build, `onLine(run, line)` called for each line of a run meant for a - * person, and `command` the probes' stand-in for the tools a run starts (startRun). Returns {server, listen(port, host), start({tool, args}), live(), stopAll(), - * close(), model()}: `listen` resolves to the port it listens on; `start` starts a run, and - * returns startRun's handle; `live` the handles of the runs that are running. + * folder, `bundlesRoot` the bundle cache (or null), `head()` the baseline HEAD would make + * ({hash, short, build}; null for none, or an error thrown when there is none to read), + * `revisionAt(hash)` a revision as openRevision gives it, `changed()` the pictures that differ + * from the baseline ([{out, status}], changedPictures'), `newestIde` the newest install's build, + * `onLine(run, line)` called for each line of a run meant for a person, and `command` the + * probes' stand-in for the tools a run starts (startRun). Returns {server, listen(port, host), + * start({tool, args}), live(), close(), model(), applyPending()}: `listen` resolves to the port it + * listens on; `start` starts a run, and returns startRun's handle; `live` the handles of the runs + * that are running; `applyPending` applies what the runs reported and was not applied yet, and + * returns how many parts. */ export function createReviewServer({ session, - revision, + revision: initialRevision, manifest, docsRoot = DOCS_DIR, - headNow = null, + bundlesRoot = null, + head = () => null, + revisionAt = null, + changed = () => [], newestIde = null, onLine, command, }) { const sse = createSseChannel(); + let revision = initialRevision; + const applier = createApplier({ session, docsRoot, bundlesRoot, revision: () => revision, manifest }); + // HEAD as it is now, when it is not the baseline: read again at most once a second. + let headSeen = { at: 0, value: null }; + const headNow = () => { + if (Date.now() - headSeen.at > 1000) { + let value = null; + try { + const h = head(); + value = h && h.hash !== session.data.baseline.hash ? h : null; + } catch {} + headSeen = { at: Date.now(), value }; + } + return headSeen.value; + }; + // The pictures changed outside the session's runs: read again at most once a second. + let changedSeen = { at: 0, value: [] }; + const outside = () => { + if (Date.now() - changedSeen.at > 1000) { + let value = []; + try { + value = changed(); + } catch {} + changedSeen = { at: Date.now(), value }; + } + return changedSeen.value; + }; // run n -> {record, live, handle, events, facts, verdicts} const runs = new Map(); for (const record of session.data.runs) { @@ -105,6 +148,14 @@ export function createReviewServer({ } return measures.get(key); }; + // A run's verdicts as the pages show them: each part as it was applied (shot-verdicts.mjs), + // judged again after a review, else as the run reported it. + const shownVerdict = (n, out, v) => { + const done = applier.reviewed(n, out); + if (!done || !v) return v ?? null; + return { png: done.done.includes("png") ? done.png : v.png, svg: done.done.includes("svg") ? done.svg : v.svg }; + }; + const shownVerdicts = (r) => new Map([...r.verdicts].map(([out, v]) => [out, shownVerdict(r.record.n, out, v)])); const model = () => reviewPictures( [...runs.values()].map((r) => ({ @@ -112,9 +163,9 @@ export function createReviewServer({ tool: r.record.tool, live: r.live, facts: r.facts, - verdicts: r.verdicts, + verdicts: shownVerdicts(r), })), - { exists, measure }, + { exists, measure, outside: outside(), baseline: session.data.baseline.hash }, ); const filterOptions = { overridden: (out) => isOverridden(entry(out)), exists }; @@ -129,6 +180,8 @@ export function createReviewServer({ const v = r.verdicts.get(e.out) ?? { png: null, svg: null }; v[part] = value; r.verdicts.set(e.out, v); + // applied as it arrives: the stage holds the verdict by the time its event is written + applier.arrive(record.n, e.out, part); } if (e.out) sse.send("update", { outs: [e.out] }); else sse.send("update", { outs: e.type === "run" || e.type === "summary" ? listedOf(r) : [], runs: true }); @@ -171,12 +224,13 @@ export function createReviewServer({ }; // ---- the files the pages show ---- - const verdictOf = (n, out) => runs.get(n)?.verdicts.get(out) ?? null; - // The picture the run `n` judged its capture of `out` against. + const verdictOf = (n, out) => shownVerdict(n, out, runs.get(n)?.verdicts.get(out)); + // The picture the run `n` judged its capture of `out` against: the file is as the run's layer + // found it, before its verdict was applied. function referenceBytes(n, out) { const against = verdictOf(n, out)?.png?.against; if (against === "revision") return revision.bytes(out); - if (against === "disk") return readIf(path.join(docsRoot, out)); + if (against === "disk") return applier.heldPng(n, out) ?? readIf(path.join(docsRoot, out)); if (against === "dark") { const dark = out.replace(/\.light\.png$/i, ".png"); const staged = verdictOf(n, dark)?.png?.capture; @@ -184,10 +238,12 @@ export function createReviewServer({ } return null; } - // The PNG the run `n` compared its SVG of `out` with: its capture, else the file. + // The PNG the run `n` compared its SVG of `out` with: its capture, else the file as the run's + // layer found it (a replay's). function svgPngBytes(n, out) { const capture = verdictOf(n, out)?.png?.capture; - return capture ? readIf(stageFile(n, capture)) : readIf(path.join(docsRoot, out)); + if (capture) return readIf(stageFile(n, capture)); + return applier.heldPng(n, out) ?? readIf(path.join(docsRoot, out)); } const maps = new Map(); function mapBytes(n, part, out) { @@ -219,7 +275,8 @@ export function createReviewServer({ counts, runs: [...runs.values()].map(runRow), baseline: session.data.baseline, - headNow, + headNow: headNow()?.short ?? null, + canMove: !!revisionAt, newestIde, resume: look.length ? { index: at, total: look.length, out: look[at].out } : null, exists, @@ -239,6 +296,18 @@ export function createReviewServer({ } if (filter === "look") session.setPlace(out); const file = (src, bytes) => (bytes ? { src, size: sizeOf(bytes) } : null); + const pager = { filter, place: { index: place.index, total: list.length }, prev: place.prev, next: place.next }; + if (p.outside) { + const atHead = revision.bytes(out); + const now = readIf(path.join(docsRoot, out)); + return outsideHtml({ + p, + ...pager, + files: { head: file(hrefs.head(out), atHead), docs: file(hrefs.docs(out), now) }, + map: atHead && now ? hrefs.outsideMap(out) : null, + baseline: session.data.baseline, + }); + } const n = p.png?.run; const files = { head: file(hrefs.head(out), revision.bytes(out)), @@ -250,30 +319,135 @@ export function createReviewServer({ if (p.svg) { const s = p.svg.run; const capture = verdictOf(s, out)?.png?.capture; + const held = applier.heldPng(s, out); files.svgPng = capture ? file(hrefs.run(s, capture), readIf(stageFile(s, capture))) - : file(hrefs.docs(out), readIf(path.join(docsRoot, out))); + : held + ? file(hrefs.held(s, out), held) + : file(hrefs.docs(out), readIf(path.join(docsRoot, out))); if (p.svg.svg) files.svg = { src: hrefs.run(s, p.svg.svg), size: null }; else if (exists(svgOf(out))) files.svg = { src: hrefs.docs(svgOf(out)), size: null }; } const e = entry(out); return detailHtml({ p, - filter, - place: { index: place.index, total: list.length }, - prev: place.prev, - next: place.next, + ...pager, files, maps: { png: p.png?.strength ? hrefs.map(n, "png", out) : null, svg: p.svg?.strength ? hrefs.map(p.svg.run, "svg", out) : null, }, rules: { png: pngRule(e), svg: svgRule(e) }, + entry: e, build: e?.build ?? null, baseline: session.data.baseline, + error: applier.errors.get(out) ?? null, }); } + // ---- what a review changes ---- + // Each action changes the manifest, then judges the picture again from the run whose verdict + // it is about (applier.rejudge), so that what the file holds is what the pages then show. + const capturedBytes = (p) => (p.png?.capture ? readIf(stageFile(p.png.run, p.png.capture)) : null); + const firstRun = (p) => Math.min(...[p.png?.run, p.svg?.run].filter((n) => n !== undefined)); + // A noise rule's or a bar's limits from a form: a number, or empty for the default. + const limit = (value, what, { integer = false, min, max }) => { + if (value === null || value === undefined || value === "") return undefined; + const n = Number(value); + if (!Number.isFinite(n) || n < min || n > max || (integer && !Number.isInteger(n))) + throw new Error(`${what} must be ${integer ? "a whole number" : "a number"} from ${min} to ${max}`); + return n; + }; + const rectsOf = (value, what) => { + if (value === null || value === undefined || value === "") return undefined; + const list = Array.isArray(value) ? value : String(value).match(/-?\d+/g)?.map(Number); + const flat = list?.flat() ?? []; + if (flat.length % 4) throw new Error(`${what} must be four numbers for each rectangle: x0, y0, x1, y1`); + const rects = []; + for (let k = 0; k < flat.length; k += 4) rects.push(flat.slice(k, k + 4)); + return rects.length ? rects : undefined; + }; + function act(out, body) { + const p = model().pictures.find((q) => q.out === out); + if (!p || p.outside) throw new Error(`${out} has no verdict of this session to act on`); + const m = manifest(); + let from; + switch (body.action) { + case "png": { + const capture = capturedBytes(p); + if (!capture) throw new Error("there is no capture to decide about"); + if (body.keep !== "old" && body.keep !== "new" && body.keep !== null) + throw new Error('keep must be "old", "new" or null'); + m.setPart(out, "png", "decision", body.keep ? { keep: body.keep, capture: hashOf(capture) } : undefined); + from = p.png.run; + break; + } + case "svg": { + if (!p.svg?.svg) throw new Error("there is no SVG to decide about"); + if (typeof body.accept !== "boolean" && body.accept !== null) + throw new Error("accept must be true, false or null"); + const svgText = readFileSync(stageFile(p.svg.run, p.svg.svg), "utf8"); + const png = svgPngBytes(p.svg.run, out); + m.setPart( + out, + "svg", + "decision", + body.accept === null ? undefined : { accept: body.accept, svg: hashOf(svgText), png: hashOf(png) }, + ); + from = p.svg.run; + break; + } + case "allowance": { + const { png = {}, svg = {} } = body; + const noise = { + levels: limit(png.levels, "The noise levels", { integer: true, min: 0, max: 255 }), + share: limit(png.share, "The noise share", { min: 0, max: 100 }), + }; + const bar = { + levels: limit(svg.levels, "The bar's levels", { integer: true, min: 1, max: 255 }), + share: limit(svg.share, "The bar's share", { min: 0, max: 100 }), + }; + const defined = (o) => (Object.values(o).some((v) => v !== undefined) ? o : undefined); + m.setPart(out, "png", "noise", defined(noise)); + m.setPart(out, "png", "ignore", rectsOf(png.ignore, "The PNG's rectangles")); + m.setPart(out, "svg", "bar", defined(bar)); + m.setPart(out, "svg", "ignore", rectsOf(svg.ignore, "The SVG's rectangles")); + from = firstRun(p); + break; + } + case "rule": + m.setPart(out, "png", "decision", undefined); + m.setPart(out, "svg", "decision", undefined); + from = firstRun(p); + break; + case "judge": + from = firstRun(p); + break; + default: + throw new Error(`no such action: ${body.action}`); + } + if (!Number.isFinite(from)) throw new Error(`${out} has no verdict of this session to act on`); + m.save(); + applier.rejudge(out, from); + maps.clear(); + measures.clear(); + const error = applier.errors.get(out); + if (error) throw new Error(`judged again, but not applied: ${error}`); + } + + // Moves the session to HEAD: the new baseline's picture is what a capture is judged against. + function moveToHead() { + const h = head(); + if (!h || !revisionAt) throw new Error("HEAD cannot be read"); + if (h.hash === session.data.baseline.hash) return; + revision = revisionAt(h.hash); + session.moveBaseline(h); + headSeen = { at: 0, value: null }; + changedSeen = { at: 0, value: [] }; + maps.clear(); + measures.clear(); + } + // ---- answering ---- let port = null; const LOOPBACK = new Set(["127.0.0.1", "localhost", "[::1]"]); @@ -301,9 +475,58 @@ export function createReviewServer({ }; const page = (req, res, html) => send(res, 200, "text/html; charset=utf-8", req.method === "HEAD" ? undefined : html); + const sendJson = (res, status, value) => send(res, status, "application/json", `${JSON.stringify(value)}\n`); + // A POST: JSON from one of the tool's own pages. A page of another origin can send a form to + // the loopback address, but not with this content type unasked, and not with this Origin. + async function post(req, res, url) { + const origin = req.headers.origin; + if (origin !== `http://${req.headers.host}`) + return sendJson(res, 403, { error: "only the tool's own pages may change anything" }); + if (!/^application\/json\b/i.test(req.headers["content-type"] ?? "")) + return sendJson(res, 415, { error: "the body must be JSON" }); + let body; + try { + const chunks = []; + let size = 0; + for await (const chunk of req) { + size += chunk.length; + if (size > MOST_BODY) throw new Error("the body is too large"); + chunks.push(chunk); + } + body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); + } catch (e) { + return sendJson(res, 400, { error: `the body could not be read: ${e.message}` }); + } + let segs; + try { + segs = url.pathname.split("/").slice(1).map(decodeURIComponent); + } catch { + return sendJson(res, 404, { error: "no such address" }); + } + try { + if (url.pathname === hrefs.moveBaseline) { + moveToHead(); + sse.send("update", { outs: model().pictures.map((p) => p.out), runs: true }); + return sendJson(res, 200, { ok: true, baseline: session.data.baseline }); + } + if (segs[0] === "api" && segs[1] === "picture") { + const out = segs.slice(2).join("/"); + if (!isDocsPath(out)) return sendJson(res, 404, { error: "no such picture" }); + act(out, body); + sse.send("update", { outs: [out] }); + return sendJson(res, 200, { ok: true }); + } + } catch (e) { + sse.send("update", { outs: [segs.slice(2).join("/")] }); + return sendJson(res, 400, { error: e.message }); + } + return sendJson(res, 404, { error: "no such address" }); + } + function handle(req, res) { if (!hostAllowed(req.headers.host)) return send(res, 403, "text/plain", "only the loopback address is answered\n"); - if (req.method !== "GET" && req.method !== "HEAD") return send(res, 405, "text/plain", "read-only\n"); + if (req.method === "POST") return post(req, res, new URL(req.url, "http://127.0.0.1")); + if (req.method !== "GET" && req.method !== "HEAD") return send(res, 405, "text/plain", "not answered\n"); const url = new URL(req.url, "http://127.0.0.1"); let segs; try { @@ -331,7 +554,12 @@ export function createReviewServer({ res, 200, "application/json", - JSON.stringify({ baseline: session.data.baseline, runs: [...runs.values()].map(runRow), pictures: m.pictures }), + JSON.stringify({ + baseline: session.data.baseline, + runs: [...runs.values()].map(runRow), + pictures: m.pictures, + errors: Object.fromEntries(applier.errors), + }), ); } if (first === "file") { @@ -347,8 +575,16 @@ export function createReviewServer({ return notFound(res); if (kind === "run") return sendFile(req, res, rel, readIf(stageFile(n, rel))); if (kind === "ref" && isDocsPath(rel)) return sendFile(req, res, rel, referenceBytes(n, rel)); + if (kind === "held" && isDocsPath(rel)) return sendFile(req, res, rel, applier.heldPng(n, rel)); return notFound(res); } + if (first === "map" && rest[0] === "outside") { + const out = rest.slice(1).join("/"); + if (!isDocsPath(out)) return notFound(res); + const [a, b] = [revision.bytes(out), readIf(path.join(docsRoot, out))]; + if (!a || !b) return notFound(res); + return sendFile(req, res, "map.png", changeMap(decodePng(a), decodePng(b))); + } if (first === "map") { const [nText, part, ...more] = rest; const out = more.join("/"); @@ -364,12 +600,15 @@ export function createReviewServer({ return notFound(res); } + const failed = (res, e) => { + if (!res.headersSent) send(res, 500, "text/plain; charset=utf-8", `${e.stack ?? e}\n`); + else res.end(); + }; const server = createServer((req, res) => { try { - handle(req, res); + handle(req, res)?.catch?.((e) => failed(res, e)); } catch (e) { - if (!res.headersSent) send(res, 500, "text/plain; charset=utf-8", `${e.stack ?? e}\n`); - else res.end(); + failed(res, e); } }); return { @@ -387,6 +626,7 @@ export function createReviewServer({ start, live, model, + applyPending: () => applier.pending(), close() { sse.close(); const closed = new Promise((resolve) => server.close(() => resolve())); diff --git a/scripts/lib/shot-session.mjs b/scripts/lib/shot-session.mjs index c1cb8e0b7..3d152f491 100644 --- a/scripts/lib/shot-session.mjs +++ b/scripts/lib/shot-session.mjs @@ -9,10 +9,13 @@ // session/runs//stage/ the run's stage (shot-stage.mjs) // session/runs//events.jsonl each event the run reported (--events), a JSON object a line // session/runs//log.txt the run's lines meant for a person, stdout and stderr +// session/runs//held/ what each picture held before the run's verdict on it was +// applied (shot-apply.mjs's holdPicture, shot-verdicts.mjs) // archive// a closed session: a commit closes one // -// session.json is {format, created, baseline: {hash, short, build}, runs, place}: `build` the -// newest build the manifest at the baseline records, or null; each run {n, tool, args, +// session.json is {format, created, baseline: {hash, short, build, moved}, runs, place}: `build` +// the newest build the manifest at the baseline records, or null, and `moved` {at, from} when +// the session was moved to this baseline from another (`from` its short hash); each run {n, tool, args, // started, ended, exit, stopped, interrupted}, `args` its options as the command line gives // them, without --stage and --events, which the tool adds; `place` the picture the queue was // left at, or null. @@ -161,6 +164,14 @@ export function openSession(root, baseline, now = () => new Date().toISOString() data.place = out; save(); }, + /** + * Makes `baseline` ({hash, short, build}) the session's, recording when and which one it + * replaced as `moved`, {at, from}. + */ + moveBaseline(baseline) { + data.baseline = { ...baseline, moved: { at: now(), from: data.baseline.short } }; + save(); + }, }; } diff --git a/scripts/lib/shot-stage.mjs b/scripts/lib/shot-stage.mjs index 99c80ac56..6d2cff540 100644 --- a/scripts/lib/shot-stage.mjs +++ b/scripts/lib/shot-stage.mjs @@ -13,6 +13,12 @@ // maps/.svg.strength PNG (diffSvg): see writeStrength // verdicts/.json what was decided, {out, png, svg}: each part as the run's // event about it carries it (shoot_docs.mjs, replay.mjs) +// review/verdicts/.json the review tool's own: the verdict as it applied it under +// docs/ (shot-verdicts.mjs), {out, png, svg, done}, each part +// judged again when a review decided otherwise or changed an +// allowance, and `done` the parts applied +// review/maps/.png.strength the capture's strength map measured again, when a +// verdict judged again names another picture to measure against // // `` is the picture's path with its folders joined by __ (slugOf, shot-apply.mjs). A // stage may hold the Windows user name (a bundle holds the page's text), so it is refused @@ -195,6 +201,40 @@ export function writeVerdict(dir, out, part, value) { return verdict; } +/** + * The verdict on the picture `out` in the stage `dir`, as the run wrote it ({out, png, svg}), + * or null. + */ +export function readVerdict(dir, out) { + const file = stagedFiles(dir, out).verdict; + return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : null; +} + +/** + * The review tool's files of the picture `out` in the stage `dir`: {verdict, pngStrength}, + * absolute, and `pngStrengthName`, the map's name as a verdict names it. + */ +export function reviewedFiles(dir, out) { + const s = slugOf(out); + const pngStrength = path.join(dir, "review", "maps", `${s}.png.strength`); + return { + verdict: path.join(dir, "review", "verdicts", `${s}.json`), + pngStrength, + pngStrengthName: named(dir, pngStrength), + }; +} + +/** The verdict the review tool applied on the picture `out` ({out, png, svg, done}), or null. */ +export function readReviewed(dir, out) { + const file = reviewedFiles(dir, out).verdict; + return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : null; +} + +/** Writes the verdict the review tool applied on a picture, {out, png, svg, done}. */ +export function writeReviewed(dir, verdict) { + writeStaged(reviewedFiles(dir, verdict.out).verdict, `${JSON.stringify(verdict, null, 2)}\n`); +} + /** Every verdict in the stage `dir`, sorted by path: [{out, png, svg}]. */ export function readVerdicts(dir) { const folder = stageFolders(dir).verdicts; diff --git a/scripts/lib/shot-verdicts.mjs b/scripts/lib/shot-verdicts.mjs new file mode 100644 index 000000000..d8f6b2897 --- /dev/null +++ b/scripts/lib/shot-verdicts.mjs @@ -0,0 +1,297 @@ +// The screenshot review tool's writes under docs/ (WIP.ShotReview.md, Runs): while its runs are +// staged the tool is the only writer there, and it applies each run's verdict on a picture as +// it arrives, and again, judged anew, when a review decides otherwise, changes an allowance +// (Remembered overrides) or moves the session to another baseline. Every judgement is +// shot-policy.mjs's and shot-queue.mjs's, and every write shot-apply.mjs's, as in shoot_docs.mjs +// and replay.mjs: what is here is the order they are made in. +// +// A picture's verdicts are layers, one for each run that reported it, applied in the runs' +// order. Before a run's verdict on a picture is applied, what the picture holds is kept in the +// run's folder (holdPicture, as `held/`). To apply a layer again, the picture is put back as +// that layer found it (unholdPicture), which undoes it and every later layer, and each is +// applied again from there, judged with what the picture then holds: +// +// a capture decidePicture: the baseline's picture, the file as the layer found it, +// the run's capture of the dark picture (else the dark file as the layer found +// it, else as it is) and the picture's entry in the manifest +// its SVG decideSvg on its strength map, written when its PNG is (svgFollowsPng), +// its bundle into the cache naming the PNG the file then holds +// a replay's SVG decideSvg against the file's PNG; dropped when that PNG is no longer the one +// the replay compared it with +// +// A verdict as it arrives is applied as the run decided it: the run judged it with the same +// inputs. What was applied is in the stage's review/ (shot-stage.mjs), which the server shows in +// place of the run's own verdicts. + +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { copyBundle, readBundle } from "../svgshot/bundle.mjs"; +import { applyPicture, applySvg, heldOf, holdPicture, svgOf, unholdPicture } from "./shot-apply.mjs"; +import { pngsNear } from "./shot-change.mjs"; +import { decidePicture, decideSvg, svgFollowsPng } from "./shot-policy.mjs"; +import { reviewPicture, reviewSvg } from "./shot-queue.mjs"; +import { runPaths } from "./shot-session.mjs"; +import { + pngVerdict, + readReviewed, + readStrength, + readVerdict, + readVerdicts, + reviewedFiles, + stageFolders, + svgVerdict, + writeReviewed, + writeStrength, +} from "./shot-stage.mjs"; + +const LIGHT = /\.light\.png$/i; +const themeOf = (out) => (LIGHT.test(out) ? "light" : "dark"); +const darkOut = (out) => out.replace(LIGHT, ".png"); +const readIf = (file) => (existsSync(file) ? readFileSync(file) : null); +const written = (state) => state === "new" || state === "updated"; + +/** The revision a run's captures were compared with (its --against), or null. */ +export function againstOf(run) { + const k = run?.args?.indexOf("--against") ?? -1; + return k >= 0 ? (run.args[k + 1] ?? null) : null; +} + +/** + * The applier of the session `session` (openSession): `docsRoot` the pictures' folder, + * `bundlesRoot` the bundle cache, `revision()` the baseline as openRevision gives it now, and + * `manifest()` the manifest as it is now (openManifest). Returns: + * + * arrive(n, out, part) applies the part ("png" or "svg") of the run `n`'s verdict on `out`, + * unless it is applied already + * pending() applies every part of every run's verdicts not applied yet, in the + * runs' order: the session opened again after the tool stopped + * between a verdict and its application. Returns how many + * rejudge(out, from) applies again every layer of `out` from the run `from` on + * reviewed(n, out) the verdict applied, {out, png, svg, done}, or null + * heldPng(n, out) the PNG the run `n`'s layer found, or null + * errors a Map from a picture to why its last application failed + * + * An application that fails is recorded in `errors` and leaves the picture as far as it got; + * judging it again (rejudge) tries again. + */ +export function createApplier({ session, docsRoot, bundlesRoot, revision, manifest }) { + const stageOf = (n) => runPaths(session.root, n).stage; + const heldDir = (n) => path.join(runPaths(session.root, n).dir, "held"); + const errors = new Map(); + const cache = new Map(); + const reviewed = (n, out) => { + const key = `${n}\u0000${out}`; + if (!cache.has(key)) cache.set(key, readReviewed(stageOf(n), out)); + return cache.get(key); + }; + const keepReviewed = (n, r) => { + writeReviewed(stageOf(n), r); + cache.set(`${n}\u0000${r.out}`, r); + }; + + const hold = (n, out, m) => holdPicture(heldDir(n), docsRoot, out, bundlesRoot, m.entry(out)?.build ?? null); + function unhold(n, out, m) { + const build = unholdPicture(heldDir(n), docsRoot, out, bundlesRoot); + if (build === null) m.clearBuild(out); + else if (build !== undefined) m.setBuild(out, build); + } + + // The dark picture a light capture of the run `n` is compared with, as shoot_docs.mjs + // compares it: the run's own capture, else the dark file as the run's layer found it. + function darkFor(n, out) { + const d = darkOut(out); + const capture = readVerdict(stageOf(n), d)?.png?.capture; + const staged = capture ? readIf(path.join(stageOf(n), capture)) : null; + if (staged) return staged; + const held = heldOf(heldDir(n), d); + return held ? held.pngBytes : readIf(path.join(docsRoot, d)); + } + + // The png part of the run `n`'s verdict on `out` applied: as the run decided it (`staged`), + // or judged again. Returns the part as applied. + function applyPng(n, out, staged, rejudge, m) { + if (staged.state === "failed") return staged; + const run = session.run(n); + const svg = run?.tool === "shoot_docs" && run.args.includes("--svg"); + const rev = revision(); + if (staged.state === "once") { + const { removed } = applyPicture(docsRoot, out, { state: "once", write: null }, rev, { bundlesRoot }); + if (removed) m.clearBuild(out); + return staged; + } + const stage = stageOf(n); + const capture = staged.capture ? readIf(path.join(stage, staged.capture)) : null; + if (!capture) throw new Error(`run ${n} staged no capture of ${out}`); + let part = { ...staged, baseline: againstOf(run) ?? rev.hash }; + if (rejudge) { + const held = heldOf(heldDir(n), out); + const entry = m.entry(out); + const atRevision = rev.bytes(out); + const dark = themeOf(out) === "light" ? darkFor(n, out) : null; + const fate = decidePicture({ + png: capture, + theme: themeOf(out), + dark, + revision: atRevision, + disk: held?.pngBytes ?? null, + near: pngsNear, + entry, + }); + if (fate.state === "too-far") + throw new Error( + `the light picture is ${fate.size.w}x${fate.size.h} px and the dark one ${fate.dark.w}x${fate.dark.h} px`, + ); + const reference = { revision: atRevision, disk: held?.pngBytes ?? null, dark }[fate.against] ?? null; + const review = reviewPicture(fate, capture, reference, entry); + let strength = null; + if (review.strength) { + const files = reviewedFiles(stage, out); + writeStrength(files.pngStrength, review.strength); + strength = files.pngStrengthName; + } + part = { + ...pngVerdict({ + decision: fate, + review, + build: staged.build, + diffs: staged.diffs ?? [], + staged: { capture: staged.capture, strength }, + }), + baseline: rev.hash, + }; + } + const applied = applyPicture( + docsRoot, + out, + { state: part.state, write: written(part.state) ? capture : null }, + rev, + { svg, bundlesRoot }, + ); + if (written(part.state)) m.setBuild(out, part.build); + if (applied.removed) m.clearBuild(out); + return { ...part, restoredSvg: applied.svg }; + } + + // The svg part of the run `n`'s verdict on `out` applied, its PNG's part as applied `png` + // (null for a replay's). Returns the part as applied. + function applySvgPart(n, out, staged, png, m) { + const held = heldOf(heldDir(n), out); + const stays = (state, why) => ({ ...staged, state, why, look: null }); + if (png?.state === "failed") return stays("unchanged", "its PNG failed"); + if (png?.state === "dark") return stays(held?.svg ? "removed" : "not written", "the light picture is the dark one"); + if (png?.state === "restored") { + const fate = png.restoredSvg?.fate; + return stays( + fate === "write" ? "updated" : fate === "remove" ? "removed" : "unchanged", + "its PNG is the baseline's", + ); + } + if (png && !svgFollowsPng({ written: written(png.state), svgOnDisk: !!held?.svg })) + return stays("unchanged", "its PNG is not written"); + const stage = stageOf(n); + const svgOut = svgOf(out); + if (!staged.svg) { + // a replay's SVG the same as the file's: nothing to write + if (!png) return staged; + // no SVG drawn of a capture written: one there goes with the PNG it was of + const state = applySvg(docsRoot, svgOut, null); + return { ...staged, state, why: staged.why ?? "no SVG was drawn of the capture", look: null }; + } + const svgText = readFileSync(path.join(stage, staged.svg), "utf8"); + const strength = readStrength(path.join(stage, staged.strength)); + const compared = png + ? readFileSync(path.join(stage, png.capture)) + : (held?.pngBytes ?? readIf(path.join(docsRoot, out))); + const entry = m.entry(out); + const verdict = decideSvg(strength, { entry, svg: svgText, png: compared }); + const state = applySvg(docsRoot, svgOut, verdict.faithful ? svgText : null); + // the bundle names the PNG the file holds, which replay.mjs compares with + const holds = readIf(path.join(docsRoot, out)); + const bundles = stageFolders(stage).bundles; + if (bundlesRoot && holds && readBundle(bundles, out)) copyBundle(bundles, bundlesRoot, out, holds); + return svgVerdict({ + state, + why: verdict.faithful ? null : (staged.measures ?? staged.why), + verdict, + review: reviewSvg(verdict, strength, entry), + stats: staged.stats, + measures: staged.measures, + diffs: staged.diffs ?? [], + staged: { svg: staged.svg, strength: staged.strength }, + }); + } + + function arrive(n, out, part) { + const staged = readVerdict(stageOf(n), out); + if (!staged?.[part]) return false; + const r = reviewed(n, out) ?? { out, png: null, svg: null, done: [] }; + if (r.done.includes(part)) return false; + const m = manifest(); + try { + if (!heldOf(heldDir(n), out)) hold(n, out, m); + if (part === "png") r.png = applyPng(n, out, staged.png, false, m); + else r.svg = applySvgPart(n, out, staged.svg, r.done.includes("png") ? r.png : (staged.png ?? null), m); + keepReviewed(n, { ...r, done: [...r.done, part] }); + m.save(); + errors.delete(out); + return true; + } catch (e) { + m.save(); + errors.set(out, e.message); + return false; + } + } + + function pending() { + let count = 0; + for (const { n } of [...session.data.runs].sort((a, b) => a.n - b.n)) + for (const v of readVerdicts(stageOf(n))) + for (const part of ["png", "svg"]) if (v[part] && arrive(n, v.out, part)) count++; + return count; + } + + function rejudge(out, from) { + const layers = session.data.runs + .map((r) => r.n) + .filter((n) => n >= from && readVerdict(stageOf(n), out)) + .sort((a, b) => a - b); + if (!layers.length) return; + const m = manifest(); + try { + unhold(layers[0], out, m); + for (const n of layers) { + const staged = readVerdict(stageOf(n), out); + // the PNG the layer found when it was applied, which a replay compared its SVG with + const found = heldOf(heldDir(n), out)?.pngBytes ?? null; + hold(n, out, m); + const r = { out, png: null, svg: null, done: [] }; + if (staged.png) { + r.png = applyPng(n, out, staged.png, true, m); + r.done.push("png"); + } + if (staged.svg) { + const now = readIf(path.join(docsRoot, out)); + const moved = !staged.png && found && !(now && now.equals(found)); + // a replay's SVG of a PNG that is no longer the file's is dropped + if (!moved) r.svg = applySvgPart(n, out, staged.svg, r.png, m); + r.done.push("svg"); + } + keepReviewed(n, r); + } + m.save(); + errors.delete(out); + } catch (e) { + m.save(); + errors.set(out, e.message); + } + } + + return { + arrive, + pending, + rejudge, + reviewed, + heldPng: (n, out) => heldOf(heldDir(n), out)?.pngBytes ?? null, + errors, + }; +} diff --git a/scripts/shoot_docs.mjs b/scripts/shoot_docs.mjs index 411944582..1f484f86e 100644 --- a/scripts/shoot_docs.mjs +++ b/scripts/shoot_docs.mjs @@ -163,10 +163,10 @@ import { annotate as annotateOver, LAYER_ID, resolveAnchors, unannotate } from " import { composite, uncomposite } from "./lib/shot-composite.mjs"; import { decodePng, encodePng } from "./lib/png.mjs"; import { iconGroup, iconImage, manifestResource, resFile } from "./lib/res-file.mjs"; -import { inRects, withinNoise } from "./lib/shot-change.mjs"; +import { inRects, pngsNear, withinNoise } from "./lib/shot-change.mjs"; import { changedPictures, openRevision, planCleanup } from "./lib/shot-revision.mjs"; import { applyPicture, applySvg, dropPicture, restorePictures, svgOf } from "./lib/shot-apply.mjs"; -import { cleanupResets, decidePicture, pngRule, SVG_FAITHFUL } from "./lib/shot-policy.mjs"; +import { cleanupResets, decidePicture, pngRule, SVG_FAITHFUL, svgFollowsPng } from "./lib/shot-policy.mjs"; import { MANIFEST, openManifest } from "./lib/shot-manifest.mjs"; import { reviewPicture, reviewSvg } from "./lib/shot-queue.mjs"; import { @@ -199,7 +199,7 @@ import { sleep, waitForCompile, } from "./lib/tb-ide.mjs"; -import { writeBundle } from "./svgshot/bundle.mjs"; +import { BUNDLE_CACHE, writeBundle } from "./svgshot/bundle.mjs"; import { svgOfPage } from "./svgshot/capture.mjs"; import { useSubsetCache } from "./svgshot/fonts.mjs"; import { holdsUserName, judgeSvg } from "./svgshot/keep.mjs"; @@ -252,7 +252,7 @@ const DEMO_FILE = "/Inventory/Sources/Inventory.twin"; const DEMO_SOURCE = path.join(DEMO, "Sources", "Inventory.twin"); const SITE = path.join(REPO_ROOT, "docs", "_site"); const DEFAULT_OUT = path.join(REPO_ROOT, "docs"); -const DEFAULT_BUNDLES = path.join(REPO_ROOT, ".svgshot-bundles"); +const DEFAULT_BUNDLES = BUNDLE_CACHE; // The IDE page's size in CSS pixels, and the detached window's. Both at 2x. const IDE_SIZE = { width: 1280, height: 880 }; @@ -750,7 +750,7 @@ function writeDiff(name, out, before, after, { suffix = "", labels } = {}) { // (withinNoise, scripts/lib/shot-change.mjs, which holds the rule and what it rests on). // `rule` is withinNoise's: its limits, and `rects` the pixels left out (unsteadyRects, or a // picture's entry in the manifest, pngRule). -const nearly = (a, b, rule = {}) => withinNoise(decodePng(a), decodePng(b), rule); +const nearly = pngsNear; // Whether two captures are the same picture outside `rects`: byte for byte when there are none. function same(a, b, rects) { @@ -13476,7 +13476,8 @@ function keep(name, out, png, theme, dark, scale = SCALE) { other = `; the dark picture is ${fate.dark.w}x${fate.dark.h} px`; } if (fate.state === "dark") { - const { removed } = applyPicture(outRoot, themed, fate, revision, { dryRun }); + // its SVG and bundle go with it + const { removed } = applyPicture(outRoot, themed, fate, revision, { dryRun, svg: values.svg, bundlesRoot }); if (removed) recordBuild(themed, null); count(theme, "the same as the dark picture"); say( @@ -13581,10 +13582,13 @@ const svgResults = []; // written before is removed. Says how much of it differs from the PNG. The // picture's bundle goes to the bundle cache whether or not its SVG is written, // so that replay.mjs can draw it again once the converter draws it better. `holds` is -// the PNG the file holds once its verdict is applied, which the bundle names. With -// --stage the SVG, its strength map and its bundle go to the stage, whether or not it is -// faithful, and nothing else is written. -async function keepSvg(name, out, png, picture, written, holds) { +// the PNG the file holds once its verdict is applied, which the bundle names. `kept` is +// keep's: whether the PNG was written, "restored", or null for a light picture that is the +// dark one. With --stage the SVG, its strength map and its bundle go to the stage, whether or +// not it is faithful, and nothing else is written; an SVG that stays with its PNG, or goes +// with it, is judged and staged all the same, its verdict "unchanged", for the review tool, +// which writes it when a review takes the capture. +async function keepSvg(name, out, png, picture, kept, holds) { const svgOut = svgOf(out); const file = path.join(outRoot, svgOut); const dryRun = !!stageRoot; @@ -13592,15 +13596,20 @@ async function keepSvg(name, out, png, picture, written, holds) { if (stageRoot) writeVerdict(stageRoot, out, "svg", part); event("svg", { job: name, out, ...part }); }; - // An SVG is drawn from the whole page, layers the picture does not show included, and those - // can differ from run to run (a designer still drawing under a floating panel); and one that - // sits on SVG_FAITHFUL's bar is written in one run and removed in the next. So the SVG of a - // picture whose PNG was not written stays as it is, with its bundle (owner): it changes - // with its PNG, or when replay.mjs draws it again after a change to the converter. - if (!written && existsSync(file)) { - say(name, `${svgOut}: unchanged, as its PNG is`); - report(svgVerdict({ state: "unchanged", why: "its PNG is not written" })); - return; + // The SVG of a picture whose PNG was not written stays as it is, with its bundle + // (svgFollowsPng, lib/shot-policy.mjs); one whose PNG went back to the revision's, or is the + // dark one's, went with it (keep). + const byPng = + kept === null ? "the light picture is the dark one" : kept === "restored" ? `its PNG is ${AGAINST}'s` : null; + const follows = !byPng && svgFollowsPng({ written: kept, svgOnDisk: existsSync(file) }); + let staysWhy = null; + if (!follows) { + staysWhy = byPng ?? "its PNG is not written"; + if (!byPng) say(name, `${svgOut}: unchanged, as its PNG is`); + if (!stageRoot || !picture) { + report(svgVerdict({ state: "unchanged", why: staysWhy })); + return; + } } const drop = (why, judged = {}) => { const state = applySvg(outRoot, svgOut, null, { dryRun }); @@ -13621,7 +13630,8 @@ async function keepSvg(name, out, png, picture, written, holds) { const { diff, verdict, faithful, by, measures } = await judgeSvg(await svgBrowser, svg, png, stats, entry); const how = by === "decision" ? `; ${faithful ? "accepted" : "refused"} by a review` : ""; // To see what went wrong, an SVG not written goes beside its difference map. - const diffs = diffsRoot ? Object.values(writeJudged(diffsRoot, out, diff, { svg: faithful ? null : svg })) : []; + const diffs = + diffsRoot && !staysWhy ? Object.values(writeJudged(diffsRoot, out, diff, { svg: faithful ? null : svg })) : []; const judged = { verdict, review: stageRoot || values.events ? reviewSvg(verdict, diff.strength, entry) : null, @@ -13630,6 +13640,11 @@ async function keepSvg(name, out, png, picture, written, holds) { diffs, staged: stageRoot ? stageSvg(stageRoot, out, svg, diff.strength) : null, }; + if (staysWhy) { + // what stays with its PNG needs no look until a review takes the capture + report(svgVerdict({ state: "unchanged", why: staysWhy, ...judged, review: { ...judged.review, look: null } })); + return; + } const bytes = Buffer.byteLength(svg); svgResults.push({ out: svgOut, bytes, png: png.length, faithful, ...diff.stats }); if (!faithful) { @@ -13769,12 +13784,11 @@ async function runJob(job) { Buffer.isBuffer(taken) ? SCALE : taken.scale, ); if (shot.once) dropLight(job.name, shot.out); - // A light picture that is the dark one is not kept, and neither is its SVG or bundle. - // One put back as the revision has it has had its SVG put back with it. With --stage - // the verdict "dark" says so, and nothing is removed. - if (values.svg && kept !== null && kept !== "restored") + // A light picture that is the dark one is not kept, and neither is its SVG or bundle + // (keep). One put back as the revision has it has had its SVG put back with it. With + // --stage their SVGs are judged and staged all the same (keepSvg). + if (values.svg && ((kept !== null && kept !== "restored") || stageRoot)) await keepSvg(job.name, out, png, state.svg, kept, holds); - else if (values.svg && kept === null && !stageRoot) dropPicture(outRoot, out, bundlesRoot); } catch (e) { complain(job.name, `${out}: FAILED: ${e.message}`); count(theme, "failed"); diff --git a/scripts/shot_review.mjs b/scripts/shot_review.mjs index 71068a588..75aa46569 100644 --- a/scripts/shot_review.mjs +++ b/scripts/shot_review.mjs @@ -5,9 +5,10 @@ // run is the command-line tool as a child process with --stage and --events // (lib/shot-runs.mjs). The server is lib/shot-server.mjs, its pages lib/shot-pages.mjs. // -// Read-only for now: nothing is written under docs/, to the manifest or to the bundle cache. -// Applying a verdict, overriding one, the runs page and the commit come in the plan's later -// steps. +// While a run is staged the tool is the only writer under docs/: each verdict is applied as it +// arrives, and again when a review decides otherwise or changes an allowance (lib/shot-verdicts.mjs), +// and a review's decisions and allowances go to the manifest. The runs page and the commit come +// in the plan's later steps. import path from "node:path"; import { @@ -23,7 +24,8 @@ import { import { DOCS_DIR, REPO_ROOT } from "../lib/repo-paths.mjs"; import { listIdes } from "./lib/tb-install.mjs"; import { MANIFEST, openManifest, parseManifest } from "./lib/shot-manifest.mjs"; -import { openRevision, revisionOf } from "./lib/shot-revision.mjs"; +import { changedPictures, openRevision, revisionOf } from "./lib/shot-revision.mjs"; +import { BUNDLE_CACHE } from "./svgshot/bundle.mjs"; import { createReviewServer } from "./lib/shot-server.mjs"; import { acquireLock, newestBuild, openSession, REVIEW_ROOT, sessionPaths } from "./lib/shot-session.mjs"; @@ -34,10 +36,13 @@ const USAGE = `usage: node scripts/shot_review.mjs [--port N] [--retake Serves the screenshot review of the session in .shot-review at http://127.0.0.1:/: every picture the session's runs took or drew, grouped by what was decided about it, with a page for each that compares it -with the picture at the session's baseline. The pages follow a run as its -verdicts arrive. The session is made when there is none, with HEAD as its -baseline, and one tool at a time holds it; closing the tool loses nothing. -Nothing is written under docs. +with the picture at the session's baseline, and the pictures that differ +from the baseline otherwise. Each verdict is applied under docs as it +arrives, and a picture's page can decide otherwise or change its allowances +(test/shots/manifest.json), which judges it again at once. The session is +made when there is none, with HEAD as its baseline, and one tool at a time +holds it; closing the tool loses nothing, and what a run reported and was +not applied is applied when the session is opened again. --port N the port to listen on, on the loopback address only (default ${DEFAULT_PORT}) @@ -48,7 +53,7 @@ Nothing is written under docs. -h, --help print this text and exit A run is the command-line tool with --stage, a folder of the session, and ---events. Ctrl+C stops a run that is running as its own stop does (its IDEs end +--events; a retake is compared with the session's baseline (--against). Ctrl+C stops a run that is running as its own stop does (its IDEs end and the registry is put back), then closes the tool; a second Ctrl+C ends the run's process at once. @@ -100,21 +105,27 @@ try { release(); die(2, `shot_review: ${e.message}`); } -const session = openSession(REVIEW_ROOT, () => { - const bytes = openRevision(REPO_ROOT, head.hash).bytes(path.relative(REPO_ROOT, MANIFEST).replace(/\\/g, "/")); +// The baseline a commit makes: the commit, and the newest build its manifest records. +const baselineAt = ({ hash, short }) => { + const bytes = openRevision(REPO_ROOT, hash).bytes(path.relative(REPO_ROOT, MANIFEST).replace(/\\/g, "/")); const pictures = bytes ? parseManifest(bytes.toString("utf8")).pictures : {}; - return { hash: head.hash, short: head.short, build: newestBuild(pictures) }; -}); -const { baseline } = session.data; + return { hash, short, build: newestBuild(pictures) }; +}; +const session = openSession(REVIEW_ROOT, () => baselineAt(head)); const app = createReviewServer({ session, - revision: openRevision(DOCS_DIR, baseline.hash), + revision: openRevision(DOCS_DIR, session.data.baseline.hash), manifest: () => openManifest(), - headNow: head.hash === baseline.hash ? null : head.short, + bundlesRoot: BUNDLE_CACHE, + head: () => baselineAt(revisionOf(DOCS_DIR, "HEAD")), + revisionAt: (hash) => openRevision(DOCS_DIR, hash), + changed: () => changedPictures(DOCS_DIR, session.data.baseline.hash).pictures, newestIde: listIdes()[0]?.build ?? null, onLine: (run, line) => console.log(`run ${run.n}: ${line}`), }); +const applied = app.applyPending(); +if (applied) console.log(`applied ${applied} verdicts the session's runs had reported and were not applied`); let listening; try { listening = await app.listen(port); @@ -123,7 +134,7 @@ try { die(2, `shot_review: port ${port} could not be used (${e.code ?? e.message}); choose another with --port`); } console.log( - `screenshot review: http://127.0.0.1:${listening}/ (the session of ${baseline.short}, ${session.data.runs.length} runs)`, + `screenshot review: http://127.0.0.1:${listening}/ (the session of ${session.data.baseline.short}, ${session.data.runs.length} runs)`, ); const runOptions = () => [ @@ -131,7 +142,10 @@ const runOptions = () => [ ...(values.ide === undefined ? [] : ["--ide", values.ide]), ]; if (values.retake !== undefined) { - const { run } = app.start({ tool: "shoot_docs", args: ["--svg", "--only", values.retake, ...runOptions()] }); + const { run } = app.start({ + tool: "shoot_docs", + args: ["--svg", "--only", values.retake, "--against", session.data.baseline.hash, ...runOptions()], + }); console.log(`run ${run.n} started: a retake of ${values.retake}`); } if (values.replay !== undefined) { diff --git a/scripts/svgshot/bundle.mjs b/scripts/svgshot/bundle.mjs index 45ff8183e..d3fb91dfd 100644 --- a/scripts/svgshot/bundle.mjs +++ b/scripts/svgshot/bundle.mjs @@ -39,9 +39,13 @@ import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; import { brotliCompressSync, brotliDecompressSync, constants } from "node:zlib"; +import { REPO_ROOT } from "../../lib/repo-paths.mjs"; import { PictureFonts, webRulesFor, withFaces } from "./fonts.mjs"; import { decodeSnapshot, oneLine, parseShadows, renderSvg, unzoom } from "./snapshot-svg.mjs"; +/** The bundle cache: gitignored, outside docs/, since a bundle holds the Windows user name. */ +export const BUNDLE_CACHE = path.join(REPO_ROOT, ".svgshot-bundles"); + /** * The format of a bundle; one of another version is not read. Version 2 keeps a * web font as its rules and files (webFonts, fontFiles) where version 1 kept the @@ -380,6 +384,22 @@ export function dropBundle(root, out) { /** The bundle of the picture `out`, or null. */ export const readBundle = (root, out) => readPair(bundleFile(root, out))[themeOf(out).theme]; +/** + * Puts the bundle of the picture `out` under `fromRoot` in its place under `toRoot` as it is, + * or, when `png` is given, naming that PNG as the one it was taken with; removes the one under + * `toRoot` when `fromRoot` has none. The other theme's bundle under `toRoot` is kept. Returns + * whether there was one to copy. The screenshot review tool moves a run's staged bundles into + * the cache with it, naming the PNG the file holds once a review has decided. + */ +export function copyBundle(fromRoot, toRoot, out, png = null) { + const bundle = readBundle(fromRoot, out); + const file = bundleFile(toRoot, out); + const pair = readPair(file); + pair[themeOf(out).theme] = bundle && png ? { ...bundle, png: pngHash(png) } : bundle; + writePair(file, pair); + return !!bundle; +} + /** Every picture with a bundle under `root`, as its path under docs/ with forward slashes. */ export function listBundles(root) { const outs = []; diff --git a/scripts/svgshot/replay.mjs b/scripts/svgshot/replay.mjs index 228c34b5c..b6c6d53fa 100644 --- a/scripts/svgshot/replay.mjs +++ b/scripts/svgshot/replay.mjs @@ -26,7 +26,7 @@ import { withUsageError, } from "../../lib/cli.mjs"; import { REPO_ROOT } from "../../lib/repo-paths.mjs"; -import { listBundles } from "./bundle.mjs"; +import { BUNDLE_CACHE, listBundles } from "./bundle.mjs"; import { useSubsetCache } from "./fonts.mjs"; import { applySvg, svgOf } from "../lib/shot-apply.mjs"; import { MANIFEST, openManifest } from "../lib/shot-manifest.mjs"; @@ -124,7 +124,7 @@ const jobs = withUsageError(() => max: 32, }), ); -const bundlesRoot = path.resolve(values.bundles ?? path.join(REPO_ROOT, ".svgshot-bundles")); +const bundlesRoot = path.resolve(values.bundles ?? BUNDLE_CACHE); const outRoot = path.resolve(values.out ?? path.join(REPO_ROOT, "docs")); // A child process the parent started (below) has its share of the pictures in this // variable, "k/n" (redraw.mjs runShards). diff --git a/scripts/svgshot/sheet.mjs b/scripts/svgshot/sheet.mjs index ac9e5c555..a54098c7e 100644 --- a/scripts/svgshot/sheet.mjs +++ b/scripts/svgshot/sheet.mjs @@ -27,9 +27,9 @@ import { regexOption, withUsageError, } from "../../lib/cli.mjs"; -import { DOCS_DIR, REPO_ROOT } from "../../lib/repo-paths.mjs"; +import { DOCS_DIR } from "../../lib/repo-paths.mjs"; import { changedPictures, outputRefusal, readRevisionFiles } from "../lib/shot-revision.mjs"; -import { listBundles } from "./bundle.mjs"; +import { BUNDLE_CACHE, listBundles } from "./bundle.mjs"; import { useSubsetCache } from "./fonts.mjs"; import { SVG_FAITHFUL } from "../lib/shot-policy.mjs"; import { judgeSvg } from "./keep.mjs"; @@ -128,7 +128,7 @@ const jobs = withUsageError(() => ); const outRoot = path.resolve(values.out); const docsRoot = path.resolve(values.docs ?? DOCS_DIR); -const bundlesRoot = path.resolve(values.bundles ?? path.join(REPO_ROOT, ".svgshot-bundles")); +const bundlesRoot = path.resolve(values.bundles ?? BUNDLE_CACHE); const picturesDir = path.join(outRoot, "pictures"); const SHARD_ENV = "SVGSHOT_SHEET_SHARD"; const SHARD = shardOf(SHARD_ENV); diff --git a/test/svgshot.test.mjs b/test/svgshot.test.mjs index 4a66b019b..5848d8f6b 100644 --- a/test/svgshot.test.mjs +++ b/test/svgshot.test.mjs @@ -41,6 +41,7 @@ import { REPO_ROOT } from "../lib/repo-paths.mjs"; import { BUNDLE_VERSION, bundleFile, + copyBundle, dropBundle, drawBundle, listBundles, @@ -54,14 +55,28 @@ import { SNAPSHOT_STYLES, webFontFiles } from "../scripts/svgshot/capture.mjs"; import { decodePng, encodePng } from "../scripts/lib/png.mjs"; import { onStop, parseRecord, readRecords, recordLine, sendStop, STOP_LINE } from "../scripts/lib/record-lines.mjs"; import { + pngVerdict, + readReviewed, readStrength, readVerdicts, + stageCapture, stagedFiles, stageRefusal, + stageSvg, + svgVerdict, writeStrength, writeVerdict, } from "../scripts/lib/shot-stage.mjs"; -import { applyPicture, applySvg, dropPicture, restorePictures, slugOf } from "../scripts/lib/shot-apply.mjs"; +import { + applyPicture, + applySvg, + dropPicture, + heldOf, + holdPicture, + restorePictures, + slugOf, + unholdPicture, +} from "../scripts/lib/shot-apply.mjs"; import { cleanupResets, decidePicture, @@ -69,6 +84,7 @@ import { pngRule, SIZE_SLACK, SVG_FAITHFUL, + svgFollowsPng, svgRule, } from "../scripts/lib/shot-policy.mjs"; import { @@ -112,7 +128,8 @@ import { runFacts, verdictOfEvent, } from "../scripts/lib/shot-review.mjs"; -import { encodePath, hrefs, loupesHtml } from "../scripts/lib/shot-pages.mjs"; +import { chipsOf, encodePath, hrefs, loupesHtml } from "../scripts/lib/shot-pages.mjs"; +import { againstOf, createApplier } from "../scripts/lib/shot-verdicts.mjs"; import { createReviewServer } from "../scripts/lib/shot-server.mjs"; import { FAINT_LEVEL, @@ -2542,6 +2559,71 @@ describe("what becomes of a picture and its SVG (shot-policy.mjs, shot-apply.mjs assert.equal(readBundle(bundles, "a/X.light.png"), null); assert.equal(readBundle(bundles, "a/X.png").v, 1); assert.equal(dropPicture(root, "a/X.light.png", null), false); + // a light picture that is the dark one, with --svg: its SVG and bundle go with it + writeBundle(bundles, "a/X.light.png", { v: 3 }, OTHER, []); + fs.writeFileSync(at("a/X.light.png"), NEAR); + fs.writeFileSync(at("a/X.light.svg"), ""); + const darkVerdict = { state: "dark", write: null }; + assert.equal( + applyPicture(root, "a/X.light.png", darkVerdict, null, { dryRun: true, svg: true, bundlesRoot: bundles }).removed, + true, + ); + assert.ok(fs.existsSync(at("a/X.light.svg"))); + assert.equal( + applyPicture(root, "a/X.light.png", darkVerdict, null, { svg: true, bundlesRoot: bundles }).removed, + true, + ); + assert.equal(fs.existsSync(at("a/X.light.png")) || fs.existsSync(at("a/X.light.svg")), false); + assert.equal(readBundle(bundles, "a/X.light.png"), null); + // a once picture's light file goes with its SVG, --svg or not + fs.writeFileSync(at("a/X.light.png"), NEAR); + fs.writeFileSync(at("a/X.light.svg"), ""); + assert.equal(applyPicture(root, "a/X.light.png", { state: "once", write: null }, null).removed, true); + assert.equal(fs.existsSync(at("a/X.light.svg")), false); + }); + + test("an SVG is judged with its PNG when the PNG is written or there is no SVG to keep", () => { + assert.equal(svgFollowsPng({ written: true, svgOnDisk: true }), true); + assert.equal(svgFollowsPng({ written: false, svgOnDisk: false }), true); + assert.equal(svgFollowsPng({ written: false, svgOnDisk: true }), false); + }); + + test("keeps what a picture held, and puts it back, its build and bundle included", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "shot-hold-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const docs = path.join(root, "docs"); + const bundles = path.join(root, "bundles"); + const held = path.join(root, "held"); + const at = (out) => path.join(docs, out); + fs.mkdirSync(path.join(docs, "a"), { recursive: true }); + fs.writeFileSync(at("a/X.png"), DARK); + fs.writeFileSync(at("a/X.svg"), "old\r\n"); + writeBundle(bundles, "a/X.png", { v: 1 }, DARK, []); + writeBundle(bundles, "a/X.light.png", { v: 2 }, DARK, []); + holdPicture(held, docs, "a/X.png", bundles, 1010); + holdPicture(held, docs, "a/Y.png", bundles, null); + const kept = heldOf(held, "a/X.png"); + assert.deepEqual([kept.png, kept.svg, kept.bundle, kept.build], [true, true, true, 1010]); + assert.ok(kept.pngBytes.equals(DARK)); + assert.deepEqual([heldOf(held, "a/Y.png").png, heldOf(held, "a/Z.png")], [false, null]); + // the picture changed, its SVG removed, its bundle replaced; and a picture added + fs.writeFileSync(at("a/X.png"), OTHER); + fs.rmSync(at("a/X.svg")); + writeBundle(bundles, "a/X.png", { v: 9 }, OTHER, []); + fs.writeFileSync(at("a/Y.png"), OTHER); + assert.equal(unholdPicture(held, docs, "a/X.png", bundles), 1010); + assert.ok(fs.readFileSync(at("a/X.png")).equals(DARK)); + assert.equal(fs.readFileSync(at("a/X.svg"), "utf8"), "old\r\n"); + assert.deepEqual([readBundle(bundles, "a/X.png").v, readBundle(bundles, "a/X.light.png").v], [1, 2]); + assert.equal(unholdPicture(held, docs, "a/Y.png", bundles), null); + assert.equal(fs.existsSync(at("a/Y.png")), false); + assert.equal(unholdPicture(held, docs, "a/Z.png", bundles), undefined); + // a bundle copied naming another PNG; one copied from where there is none removes it + copyBundle(bundles, path.join(root, "b2"), "a/X.png", OTHER); + assert.equal(readBundle(path.join(root, "b2"), "a/X.png").png, pngHash(OTHER)); + assert.equal(copyBundle(path.join(root, "none"), bundles, "a/X.light.png"), false); + assert.equal(readBundle(bundles, "a/X.light.png"), null); + assert.equal(readBundle(bundles, "a/X.png").v, 1); }); test("puts pictures back as a revision has them, each with its SVG", (t) => { @@ -2857,7 +2939,7 @@ describe("what the screenshot review shows (shot-review.mjs, shot-pages.mjs)", ( assert.equal(at(C).failed.error, "boom"); assert.deepEqual( groups.map((g) => g.key), - ["progress", "look", "changed", "noise", "unchanged", "added", "deleted", "failed"], + ["progress", "look", "changed", "noise", "outside", "unchanged", "added", "deleted", "failed"], ); assert.deepEqual( pictures.map((p) => p.out), @@ -2882,6 +2964,26 @@ describe("what the screenshot review shows (shot-review.mjs, shot-pages.mjs)", ( assert.deepEqual([by.look, by.changed], [[C], [B, A]]); }); + test("a picture no run reported is changed outside the runs, and a verdict judged against another baseline needs a look", () => { + const runs = [ + run(1, "shoot_docs", false, [jobs(A, B)], { + [A]: { png: { state: "noise", against: "revision", baseline: "old" } }, + [B]: { png: { state: "updated", against: "disk", baseline: "old" } }, + }), + ]; + const outside = [ + { out: A, status: "modified" }, + { out: C, status: "added" }, + ]; + const { pictures } = reviewPictures(runs, { outside, baseline: "new" }); + const at = (out) => pictures.find((p) => p.out === out); + assert.deepEqual([at(C).group, at(C).outside, at(A).outside], ["outside", "added", null]); + assert.deepEqual([at(A).group, at(A).png.look.why], ["look", "baseline"]); + assert.deepEqual([at(B).group, at(B).png.look], ["changed", undefined]); + assert.equal(reviewPictures(runs, { baseline: "old" }).pictures.find((p) => p.out === A).group, "noise"); + assert.match(chipsOf(at(C)), /changed outside the runs: added/); + }); + test("a picture the run takes once, or the dark one's light picture, is deleted only when its file is there", () => { const p = { out: D, png: { state: "dark" }, svg: null, progress: false, failed: null }; assert.equal( @@ -2959,6 +3061,144 @@ describe("what the screenshot review shows (shot-review.mjs, shot-pages.mjs)", ( }); }); +describe("the screenshot review's writes under docs (shot-verdicts.mjs)", () => { + const A = "IDE/Images/A.png"; + // A grey picture 40 x 30, with a bright block when `block`. + const grey = (block) => { + const rgba = new Uint8Array(40 * 30 * 4); + for (let i = 0; i < 1200; i++) { + const [x, y] = [i % 40, Math.floor(i / 40)]; + const s = block && x >= 10 && x < 30 && y >= 8 && y < 22 ? 250 : 40; + rgba.set([s, s, s, 255], i * 4); + } + return Buffer.from(encodePng({ width: 40, height: 30, rgba })); + }; + const OLD = grey(false); + const NEW = grey(true); + const QUIET = { width: 40, height: 30, data: new Uint8Array(1200) }; + + test("applies a run's verdicts as they arrive, judges them again after a review, and drops a replay's SVG of another PNG", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "shot-verdicts-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const docs = path.join(root, "docs"); + const bundles = path.join(root, "bundles"); + const manifestFile = path.join(root, "manifest.json"); + const at = (out) => path.join(docs, out); + fs.mkdirSync(path.dirname(at(A)), { recursive: true }); + fs.writeFileSync(at(A), OLD); + fs.writeFileSync(at("IDE/Images/A.svg"), "old"); + const m0 = openManifest(manifestFile); + m0.setBuild(A, 1010); + m0.save(); + const base = "a".repeat(40); + const session = openSession(root, () => ({ hash: base, short: "aaaaaaaa", build: 1010 })); + const revision = { hash: base, short: "aaaaaaaa", bytes: (out) => (out === A ? OLD : null), read: () => new Map() }; + const applier = createApplier({ + session, + docsRoot: docs, + bundlesRoot: bundles, + revision: () => revision, + manifest: () => openManifest(manifestFile), + }); + + // run 1, a retake: the capture differs from the file, and its SVG is faithful + const run1 = session.addRun({ tool: "shoot_docs", args: ["--svg", "--against", base] }); + assert.equal(againstOf(run1), base); + const stage1 = runPaths(root, 1).stage; + const fate = { + state: "updated", + write: NEW, + against: "disk", + size: { w: 40, h: 30 }, + dark: null, + by: "rule", + earlier: null, + }; + writeVerdict( + stage1, + A, + "png", + pngVerdict({ decision: fate, review: null, build: 1501, staged: stageCapture(stage1, A, NEW, null) }), + ); + const judged = { faithful: true, by: "rule", share: 0, earlier: null }; + writeVerdict( + stage1, + A, + "svg", + svgVerdict({ + state: "updated", + verdict: judged, + measures: "0% off", + staged: stageSvg(stage1, A, "new", QUIET), + }), + ); + writeBundle(path.join(stage1, "bundles"), A, { v: 1 }, NEW, []); + assert.equal(applier.arrive(1, A, "png"), true); + assert.equal(applier.arrive(1, A, "png"), false); + assert.ok(fs.readFileSync(at(A)).equals(NEW)); + assert.equal(openManifest(manifestFile).entry(A).build, 1501); + assert.equal(applier.arrive(1, A, "svg"), true); + assert.equal(fs.readFileSync(at("IDE/Images/A.svg"), "utf8"), "new"); + assert.equal(readBundle(bundles, A).png, pngHash(NEW)); + assert.deepEqual(readReviewed(stage1, A).done, ["png", "svg"]); + assert.equal(readReviewed(stage1, A).png.baseline, base); + + // a review keeps the old picture: it and its SVG go back, and so does the build + const m = openManifest(manifestFile); + m.setPart(A, "png", "decision", { keep: "old", capture: hashOf(NEW) }); + m.save(); + applier.rejudge(A, 1); + assert.equal(applier.errors.size, 0); + assert.ok(fs.readFileSync(at(A)).equals(OLD)); + assert.equal(fs.readFileSync(at("IDE/Images/A.svg"), "utf8"), "old"); + assert.equal(openManifest(manifestFile).entry(A).build, 1010); + assert.equal(readBundle(bundles, A), null); + const kept = applier.reviewed(1, A); + assert.deepEqual( + [kept.png.state, kept.png.by, kept.svg.state, kept.svg.why], + ["noise", "decision", "unchanged", "its PNG is not written"], + ); + + // then takes the new capture, and refuses its SVG + const m2 = openManifest(manifestFile); + m2.setPart(A, "png", "decision", { keep: "new", capture: hashOf(NEW) }); + m2.setPart(A, "svg", "decision", { accept: false, svg: hashOf("new"), png: hashOf(NEW) }); + m2.save(); + applier.rejudge(A, 1); + assert.ok(fs.readFileSync(at(A)).equals(NEW)); + assert.equal(fs.existsSync(at("IDE/Images/A.svg")), false); + assert.deepEqual([applier.reviewed(1, A).svg.state, applier.reviewed(1, A).svg.by], ["removed", "decision"]); + + // run 2, a replay, draws an SVG of the new PNG; a review then keeps the old picture, and + // the replay's SVG, of a PNG no longer the file's, is dropped + session.addRun({ tool: "replay", args: [] }); + const stage2 = runPaths(root, 2).stage; + writeVerdict( + stage2, + A, + "svg", + svgVerdict({ + state: "new", + verdict: judged, + measures: "0% off", + staged: stageSvg(stage2, A, "replayed", QUIET), + }), + ); + // the review refused another SVG: this one is judged by the bar + assert.equal(applier.pending(), 1); + assert.equal(fs.readFileSync(at("IDE/Images/A.svg"), "utf8"), "replayed"); + assert.equal(applier.reviewed(2, A).svg.earlier, false); + const m3 = openManifest(manifestFile); + m3.setPart(A, "png", "decision", { keep: "old", capture: hashOf(NEW) }); + m3.save(); + applier.rejudge(A, 1); + assert.ok(fs.readFileSync(at(A)).equals(OLD)); + assert.equal(fs.readFileSync(at("IDE/Images/A.svg"), "utf8"), "old"); + assert.deepEqual([applier.reviewed(2, A).svg, applier.reviewed(2, A).done], [null, ["svg"]]); + assert.equal(readReviewed(stage2, A).svg, null); + }); +}); + describe("the screenshot review's server (shot-server.mjs)", () => { const A = "IDE/Images/A.png"; const B = "IDE/Images/B.light.png"; @@ -3009,6 +3249,14 @@ onStop(process.stdin, () => { }); `; + // A revision as openRevision gives one, of the commit "" repeated, holding `files`. + const stubRevision = (c, files) => ({ + hash: c.repeat(40), + short: c.repeat(8), + bytes: (out) => files[out] ?? null, + read: (outs) => new Map(outs.map((out) => [out, files[out] ?? null])), + }); + const until = async (what, ok) => { for (const t0 = Date.now(); !ok(); ) { if (Date.now() - t0 > 15000) assert.fail(`timed out waiting for ${what}`); @@ -3023,13 +3271,13 @@ onStop(process.stdin, () => { const script = path.join(root, "fake-run.mjs"); fs.writeFileSync(script, FAKE); const head = flat(false); - const revision = { hash: "a".repeat(40), short: "aaaaaaaa", bytes: (out) => (out === A ? head : null) }; - const manifest = () => ({ entry: () => null }); + const revision = stubRevision("a", { [A]: head }); + const manifestFile = path.join(root, "manifest.json"); const make = () => createReviewServer({ session: openSession(root, () => ({ hash: revision.hash, short: revision.short, build: 1500 })), revision, - manifest, + manifest: () => openManifest(manifestFile), docsRoot: docs, command: { script }, }); @@ -3134,4 +3382,124 @@ onStop(process.stdin, () => { fs.rmSync(root, { recursive: true, force: true }); } }); + + test("applies a verdict as it arrives, takes a review's decisions and allowances, lists what changed outside its runs, and moves to HEAD", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "shot-review-act-")); + const docs = path.join(root, "docs"); + const Z = "IDE/Images/Z.png"; + fs.mkdirSync(path.join(docs, "IDE", "Images"), { recursive: true }); + fs.writeFileSync(path.join(docs, Z), flat(true)); + const script = path.join(root, "fake-run.mjs"); + fs.writeFileSync(script, FAKE); + const head = flat(false); + const capture = flat(true); + const manifestFile = path.join(root, "manifest.json"); + const app = createReviewServer({ + session: openSession(root, () => ({ hash: "a".repeat(40), short: "aaaaaaaa", build: 1500 })), + revision: stubRevision("a", { [A]: head, [Z]: head }), + manifest: () => openManifest(manifestFile), + docsRoot: docs, + // a commit made outside the tool, whose A is the capture + head: () => ({ hash: "b".repeat(40), short: "bbbbbbbb", build: 1501 }), + revisionAt: (hash) => stubRevision(hash[0], { [A]: capture }), + changed: () => [ + { out: Z, status: "modified" }, + { out: A, status: "added" }, + ], + command: { script }, + }); + try { + const port = await app.listen(0); + const base = `http://127.0.0.1:${port}`; + const get = async (p) => { + const res = await fetch(base + p); + return { status: res.status, text: await res.text() }; + }; + const post = async (p, body, headers = {}) => { + const res = await fetch(base + p, { + method: "POST", + headers: { origin: base, "content-type": "application/json", ...headers }, + body: JSON.stringify(body), + }); + return [res.status, await res.json()]; + }; + const docsA = () => fs.readFileSync(path.join(docs, A)); + const svgA = path.join(docs, "IDE/Images/A.svg"); + const pic = (out) => app.model().pictures.find((p) => p.out === out); + const handle = app.start({ tool: "shoot_docs", args: ["--only", "A|B"] }); + await until("A's SVG verdict", () => pic(A)?.svg); + + // applied as it arrived: the capture and its SVG + assert.ok(docsA().equals(capture)); + assert.ok(fs.existsSync(svgA)); + assert.equal(openManifest(manifestFile).entry(A).build, 1501); + + // Z, which no run reported, is changed outside the runs; A, which one did, is not + assert.deepEqual([pic(Z).group, pic(A).outside], ["outside", null]); + const overview = await get("/"); + assert.match(overview.text, /Changed outside the session's runs \(1\)/); + assert.match(overview.text, /Move the session to bbbbbbbb/); + assert.match((await get(`/picture/${Z}`)).text, /No run of this session reported this picture/); + assert.equal((await get(hrefs.outsideMap(Z))).status, 200); + + // a POST from another origin, or not of JSON, changes nothing + assert.equal( + (await post(hrefs.act(A), { action: "png", keep: "old" }, { origin: "http://docs.example" }))[0], + 403, + ); + assert.equal( + (await post(hrefs.act(A), { action: "png", keep: "old" }, { "content-type": "text/plain" }))[0], + 415, + ); + assert.equal(openManifest(manifestFile).entry(A).png, undefined); + + // keep the old picture: HEAD's goes back, its SVG with it (HEAD has none) + assert.deepEqual(await post(hrefs.act(A), { action: "png", keep: "old" }), [200, { ok: true }]); + assert.ok(docsA().equals(head)); + assert.equal(fs.existsSync(svgA), false); + assert.deepEqual([pic(A).png.state, pic(A).png.by, pic(A).group], ["restored", "decision", "noise"]); + assert.deepEqual(openManifest(manifestFile).entry(A).png.decision, { keep: "old", capture: hashOf(capture) }); + assert.match((await get(`/picture/${A}`)).text, /aria-pressed="true">Keep the old picture/); + + // moved to HEAD: the verdict judged against the old baseline's picture needs a look, + // and judged again it is against the new one's, which is the capture + assert.deepEqual((await post(hrefs.moveBaseline, { action: "move" }))[0], 200); + assert.equal(app.model().pictures.find((p) => p.out === A).png.look.why, "baseline"); + assert.doesNotMatch((await get("/")).text, /Move the session to/); + assert.equal((await post(hrefs.act(A), { action: "judge" }))[0], 200); + assert.deepEqual([pic(A).png.state, pic(A).png.look], ["restored", null]); + assert.ok(docsA().equals(capture)); + + // an allowance refused, then applied; back to the defaults and to the rule + const [status, refused] = await post(hrefs.act(A), { action: "allowance", png: { levels: "300" } }); + assert.equal(status, 400); + assert.match(refused.error, /noise levels must be a whole number from 0 to 255/); + const allowance = { + action: "allowance", + png: { levels: "255", share: "100", ignore: "[0, 0, 4, 4]" }, + svg: { share: "1" }, + }; + assert.equal((await post(hrefs.act(A), allowance))[0], 200); + // (the file is HEAD's picture, put back: its build is not known) + assert.deepEqual(openManifest(manifestFile).entry(A), { + png: { + noise: { levels: 255, share: 100 }, + ignore: [[0, 0, 4, 4]], + decision: { keep: "old", capture: hashOf(capture) }, + }, + svg: { bar: { share: 1 } }, + }); + assert.equal((await post(hrefs.act(A), { action: "allowance", png: {}, svg: {} }))[0], 200); + assert.equal((await post(hrefs.act(A), { action: "rule" }))[0], 200); + assert.equal(openManifest(manifestFile).entry(A), null); + assert.deepEqual([pic(A).png.by, pic(A).png.state], ["rule", "restored"]); + + handle.stop(); + assert.equal(await handle.exited, 0); + } finally { + for (const h of app.live()) h.kill(); + await app.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); }); From 19ee09db6f8b0a7a6d03e69d0f2b00fc0dba7fef Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Sat, 31 Oct 2026 23:29:30 +0100 Subject: [PATCH 24/85] Step 7 of the screenshot review: the runs page (retake, replay, clean-up with its dry run first, Stop and Kill, the overview's selection, one run at a time; runOf shared with the command line); a run never detached on Windows, which opened console windows on the desktop; git calls hidden; WIP.ShotReview.md: step 7 done, step 8 next --- WIP.ShotReview.md | 59 +++- WIP.md | 2 +- docs/Documentation/Tools-Toolchain-Tests.md | 2 +- scripts/lib/shot-pages.mjs | 318 ++++++++++++++++++-- scripts/lib/shot-revision.mjs | 9 +- scripts/lib/shot-runs.mjs | 87 +++++- scripts/lib/shot-server.mjs | 134 +++++++-- scripts/lib/shot-session.mjs | 18 +- scripts/lib/shot-verdicts.mjs | 7 +- scripts/shot_review.mjs | 54 ++-- test/svgshot.test.mjs | 189 +++++++++++- 11 files changed, 774 insertions(+), 105 deletions(-) diff --git a/WIP.ShotReview.md b/WIP.ShotReview.md index 6416ca978..e66b173e2 100644 --- a/WIP.ShotReview.md +++ b/WIP.ShotReview.md @@ -2,11 +2,12 @@ A localhost tool for reviewing what `shoot_docs.mjs` and `replay.mjs` decided about each picture, overriding it, starting retakes and replays, and committing the result. **Design -agreed with the owner on 2026-10-31; steps 1 to 6 of [the order of work](#order-of-work) are +agreed with the owner on 2026-10-31; steps 1 to 7 of [the order of work](#order-of-work) are done (step 1 but for the commit helper, which step 8 makes): the tool runs as -`node scripts/shot_review.mjs`, applies each verdict under `docs/` as it arrives, and takes a -review's decisions and allowances; its runs are still started from its command line.** The mockups it was agreed on were inline in that -session and are not kept: the pages are described below instead. +`node scripts/shot_review.mjs`, applies each verdict under `docs/` as it arrives, takes a +review's decisions and allowances, and starts and stops its runs from its runs page; it does +not commit yet.** The mockups it was agreed on were inline in that session and are not kept: +the pages are described below instead. The tool covers everything the command-line screenshot tools do, interactively, with a person deciding where a threshold cannot (owner). The command-line tools stay, and both reach the @@ -307,7 +308,7 @@ Each step ends tested and usable on its own. 6. **Decisions and allowances** applied through `shot-apply.mjs`, judged again at once. With them, the group of pictures changed outside the session's runs, and the action that moves the session to a new HEAD ([Decisions](#decisions-owner)). Done. -7. **The runs page**: retake, replay, clean up, stop. +7. **The runs page**: retake, replay, clean up, stop. Done. 8. **The commit panel**. 9. **`sheet.mjs` on the shared views**, showing each picture's allowances; the tool's entry in `Tools-IDE.md`, its node in `Tools-Groups.dot` (its command-line cases are in `scripts/lib/cli-cases.mjs` since step 5), its tests in @@ -316,7 +317,7 @@ Each step ends tested and usable on its own. ## Where the work stands -On `staging` since 2026-10-31, step 1 but for the commit helper (step 8), and steps 2 to 6. Each move kept the command-line tools' output as it was, checked as +On `staging` since 2026-10-31, step 1 but for the commit helper (step 8), and steps 2 to 7. Each move kept the command-line tools' output as it was, checked as [below](#checking-a-move). | Row of the factoring table | Where it is now | @@ -340,9 +341,9 @@ What step 5 added, the tool itself: | Part | Where it is | |---|---| -| The tool | `scripts/shot_review.mjs [--port N] [--retake \| --replay ] [--jobs N] [--ide ]`: takes the session's lock, makes the session with HEAD as its baseline (`build` the newest the manifest at HEAD records) when there is none, serves it on `127.0.0.1:4180`, and starts a run when asked (a retake is `shoot_docs.mjs --svg --only`). Until step 7's runs page, a run is started only from its command line. Ctrl+C stops the runs by their stdin and waits, a second Ctrl+C kills them. Its command-line cases are in `scripts/lib/cli-cases.mjs` already | +| The tool | `scripts/shot_review.mjs [--port N] [--retake \| --replay ] [--jobs N] [--ide ]`: takes the session's lock, makes the session with HEAD as its baseline (`build` the newest the manifest at HEAD records) when there is none, serves it on `127.0.0.1:4180`, and starts a run when asked (`runOf`, step 7: a retake is `shoot_docs.mjs --svg --only`). Ctrl+C stops the runs by their stdin and waits, a second Ctrl+C kills them. Its command-line cases are in `scripts/lib/cli-cases.mjs` already | | The session | `scripts/lib/shot-session.mjs`: `REVIEW_ROOT`, `RUN_TOOLS`, `sessionPaths`, `runPaths`, `openSession(root, baseline)` (`addRun`, `endRun`, `setPlace`, `run`), `acquireLock(file)`, `parseSession`, `commandOf(run)` (the command line without `--stage` and `--events`), `newestBuild`, `appendEvent`, `readEvents` | -| A run | `startRun(session, {tool, args}, {onEvent, onLine, onExit})` in `scripts/lib/shot-runs.mjs`: the tool spawned from the repository's root in a process group of its own (`detached`, so that the Ctrl+C that closes the review tool does not end the run before it puts the registry back), its events appended to `events.jsonl` and its other lines, stdout and stderr, to `log.txt`; `{run, stop, kill, exited}` | +| A run | `startRun(session, {tool, args}, {onEvent, onLine, onExit})` in `scripts/lib/shot-runs.mjs`: the tool spawned from the repository's root, hidden, on a console of its own that the Ctrl+C closing the review tool does not reach (so that the run puts the registry back; see step 7 below for why it is never `detached` on Windows), its events appended to `events.jsonl` and its other lines, stdout and stderr, to `log.txt`; `{run, stop, kill, exited}` | | The model | `scripts/lib/shot-review.mjs`, pure: `runFacts(tool, events)` (listed, jobs and `jobOf`, failed, skipped, retakes, stopping, summary), `verdictOfEvent`, `reviewPictures(runs, {exists, measure})` (each part from the newest run that gave one, a newer PNG verdict dropping an older SVG; `progress` while a live run owes a part; a picture whose job failed before reaching it failed with the job's error), `groupOf`, `GROUPS`, `FILTERS`, `filterPictures`, `hasSvg`, `isOverridden`, `neighbours` | | The pages | `scripts/lib/shot-pages.mjs`: `overviewHtml`, `detailHtml`, `chipsOf`, `loupesHtml` (a region cropped by CSS from the whole picture, enlarged up to 4 times, at most 360 by 240 CSS px), `hrefs` (every address the server answers); built on `pageHtml` (which now takes `head`, and leaves out the table or the contents when it is given none) and `chipHtml` in `shot-views.mjs`. A page fetches itself again 400 ms after an `update` event that names a picture it shows, and replaces its body; the detail page's comparison mode is kept in `sessionStorage` | | The server | `createReviewServer({session, revision, manifest, docsRoot, headNow, newestIde, onLine, command})` in `scripts/lib/shot-server.mjs`: the pages, `/events`, `/api/state`, `/log/`, the files (`/file/head`, `/file/docs`, `/file/run/`, `/file/ref/`, the picture the capture was judged against) and the difference maps drawn from a run's strength maps (`/map//png|svg/`, `strengthPicture` in `shot-change.mjs`, which colours as `changeMap` does, the last 16 kept). It answers only a request whose Host is the loopback address at its port; a run's verdicts are read back from `readVerdicts` and its other events from `readEvents` when the session is opened again. The Changed group is ordered by the share of a PNG's pixels beyond its noise levels, from its strength map, else the SVG's share off its PNG | @@ -362,11 +363,31 @@ What step 6 added: | Moving to HEAD | `session.moveBaseline(baseline)` (`moved: {at, from}`); the server's `head()` and `revisionAt(hash)`; HEAD is read again at most once a second, so the notice follows a commit made while the tool is open. Each verdict as applied records the baseline it was judged against (`baseline`, a run's `--against` on arrival), and `reviewPictures(runs, {baseline})` gives one judged against the revision of another baseline the look `baseline` until it is judged again | | A retake's revision | `shot_review.mjs --retake` passes `--against `, so that a run judges against the session's baseline whatever HEAD is (`againstOf(run)`) | -**Next: step 7**, the runs page: retake, replay, clean up and stop from the page. `app.start` -already takes `{tool, args}`; a retake's args must carry `--against ` as -`shot_review.mjs` passes it. A clean-up (`--cleanup`) writes under `docs/` itself and is no -staged run: it needs the session's runs stopped first, and the pictures it resets then show in -the outside group. +What step 7 added: + +| Part | Where it is | +|---|---| +| A run from a request | `runOf({kind, only, pictures, svg, jobs, ide, recheck, dryRun}, baseline)` in `scripts/lib/shot-runs.mjs`, `{tool, args}`, for the runs page and `shot_review.mjs --retake / --replay` alike: `kind` "retake", "replay" or "cleanup" (`RUN_KINDS`), a retake and a clean-up `--against` the baseline, `pictures` made a pattern that matches each alone (`onlyOf`), and every option the kind does not take refused; `kindOf(run)` | +| A clean-up run | tool `cleanup` in `RUN_TOOLS` (`shoot_docs.mjs`, its args naming `--cleanup`), not in `STAGED_TOOLS`, so `runArgs(tool, args, stage)` gives it no `--stage` and no `--events`: it has only its log. `optionOf(run, option)` in `shot-session.mjs` (`againstOf` is one) | +| One run at a time | the server's `start` refuses while a run is live: a picture's layers are applied in the runs' order, which two runs at once would not keep. While a clean-up runs, a review's actions and the move to HEAD are refused (`refuseWhileCleaning`); when it ends, the pictures changed outside the runs are read again at once | +| Stop and kill | POST `/api/run/` (`hrefs.runAct`), `{action}`: `stop` (not for a clean-up, which ends in seconds), and `kill` only once a stop has been asked for (`stopAsked`), behind the page's confirmation. Start is POST `/api/runs` (`hrefs.startRun`), the IDE named by its build among the installs (`ides`, `listIdes()`) | +| The runs page | `/runs`, `runsHtml` in `shot-pages.mjs`: a form for each kind (a retake's IDE, newest first, marked when newer than the baseline's build; its pictures, `--svg` and jobs; a replay's `--recheck`; a clean-up's dry run first, then **Put them back** on the newest one), and each run newest first with its command line, a progress bar for each job (`runRow`'s `jobs`), Stop or Kill, and its log: a clean-up's whole, the last 8 lines of one that runs. A form keeps its values when the page fetches itself again (`data-keep`) | +| Pictures selected | a checkbox on each card of the overview, kept in the browser (`localStorage`, `shot-review-selected`); the runs page's forms take the selection in place of a pattern, and a bar on both pages says how many and clears it. A picture's page has **Retake this picture** and **Replay its SVG**, and a picture changed outside the runs **Retake this picture**, each greyed while a run is live | + +**A run is never `detached` on Windows.** Step 5 started each run with `detached: true` to keep +the review tool's Ctrl+C from it. On Windows that is `DETACHED_PROCESS`: the run has no console +at all, and every console program it starts without `windowsHide` (`git` in +`shot-revision.mjs`, PowerShell, `reg`) opens a console window on the user's desktop, which the +owner saw flash during a retake. `windowsHide` with no stdio inherited, as the lane runner +spawns its children, is `CREATE_NO_WINDOW`: a console of its own with no window, which those +programs inherit and which the terminal's Ctrl+C does not reach. `startRun` now spawns that way +(detached elsewhere only), and the three `git` calls of `shot-revision.mjs` pass `windowsHide` +too; a retake and a stopped retake run so flashed nothing (owner). + +**Next: step 8**, the commit panel ([Commit](#commit)), with the commit helper beside `runGit` +in `shot-revision.mjs` (the factoring table's last row). The session's own files are those its +applier wrote (each run's `review/`, `held/`) and the manifest; a clean-up's are the pictures +its log names as put back. Left from step 6, small: `openManifest().save` writes LF, so a manifest checked out with CRLF shows as modified with no diff once the tool (or `shoot_docs.mjs`) has saved it, even after a @@ -374,6 +395,18 @@ decision is removed again. An allowance is applied as soon as it is submitted; t preview of the verdict it would give without applying it (the plan's "shows the verdict it gives" is met by the page showing the new verdict). +Step 7 was checked with the tests in `test/svgshot.test.mjs` (`runOf` on its own, and the +server with a stand-in clean-up and run: the dry run's list and its button, a second run +refused, a review's actions held while a clean-up runs, the outside group read again after it, +a retake of selected pictures on an install named by its build, its progress by job, Kill +refused before Stop), and with the real tool in a worktree, each run started from the page or +its API: a dry-run clean-up (nothing to put back), a retake of `Menu_File_Project` typed into the +form (dark `unchanged`, light `noise`, as in step 6), a replay of the picture selected on the +overview (the pattern `^(?:IDE/Projects/Images/Menu_File_Project\.light\.png)$`; a new +worktree's bundle cache has none), and retakes of `IDE/Menu/Images/Menu_` stopped by the page's +Stop after their first job (the IDEs ended, the registry put back, the rest `not taken`), Kill +shown in Stop's place with its warning. `docs/` stayed as HEAD has it. + Step 6 was checked with the tests in `test/svgshot.test.mjs` (the applier on its own, and the server with a stand-in run: a verdict applied as it arrives, decisions, allowances, the outside group, the move), and with the real tool in a worktree: a retake of diff --git a/WIP.md b/WIP.md index b406c7922..68f334c98 100644 --- a/WIP.md +++ b/WIP.md @@ -663,7 +663,7 @@ wrapper: | `test.bat` | `test/render.test.mjs` | `builder/render.mjs`'s markdown-it plugins, one at a time, on inputs no page holds --- today the ellipsis plugin's dot counting. Run by `node --test` | | `test.bat` | `test/strftime.test.mjs` | `builder/strftime.mjs`'s `formatDate`, which no build calls because no page sets `last_modified_date`. Run by `node --test` | | `test.bat` | `test/compact-image.test.mjs` | what an embedded image keeps (`compactImage` and `stripFile` in `scripts/lib/compact-image.mjs`): an editor's metadata goes, a notice of whose the picture is and on what terms stays (the IDE's lock icon carries a CC BY-NC-SA attribution), an sRGB profile goes and any other stays, a JPEG or a GIF is labelled by its bytes, and an image inside an SVG inside a CSS file is reached and keeps its encoding. Its second half runs `scripts/imagestrip/` over a CSS file and SVG files of every image the test builds and over the IDE's own stylesheets, and compares output, summary and reports with `stripFile` byte for byte; it is skipped unless `IMAGESTRIP_EXE` names the built exe. Run by `node --test` | -| `test.bat` | `test/svgshot.test.mjs` | an SVG screenshot is written on one line (`oneLine` in `scripts/svgshot/snapshot-svg.mjs`), a break in a tag as a space and one in text as a character reference, so that a CRLF checkout changes only its last line ending; an underline is placed and sized by the figures fitted in Edge (`underlineBand`) under the boxes that set one (`decoratingBoxes`); a background image is sized by its bytes, a JPEG the IDE calls `image/png` included (`naturalSize`); an iframe's document is decoded and placed at the iframe's content box, less its scroll, scaled when zoomed, over its canvas colour (`frameGeometry`, `frameView`, `unzoom`, `viewportScroller`, `canvasColor`); a run's web font is the `@font-face` rule CSS matching picks (`webFontFiles`), and a variable WOFF2 is read and cut per weight; a family split by `unicode-range` gives each character to the rule whose range holds it (`parseUnicodeRange`, `webRulesFor`); an inline `` is found and its ids scoped (`inlineSvgsIn`, `scopeIds`); a filter of colour functions is baked into the colours under it, in order and clamped, its pictures wrapped in a real filter and its opacity kept apart (`colour-filter.mjs`); a transformed element's boxes and text are put back from the snapshot's after-transform boxes (`elementTransform`, `untransformBox`); a closed select shows its option's whole text (`selectLabel`); a bundle draws the same SVG from its file as in memory, and counts each question it cannot answer (`renderBundle`); its web font rules and files draw each character from the file whose range holds it, at the weight its rule allows, and its inline `` markup is drawn in its box, from the file and when trimmed (`drawBundle`); a bundle of version 1 is refused; one trimmed to its view draws the same (`trimSnapshot`); a font's cut comes from the subset cache with no Python run (`subsetKey`, `useSubsetCache`); `redraw` draws a picture again from its bundle or names its retake, and fails on the user name unless `checkUserName: false`; `listPictures` leaves the output trees out; `outputRefusal` refuses a sheet under docs or where git would take it (`scripts/lib/shot-revision.mjs`), and `sheetHtml` puts the worst picture first (`scripts/svgshot/sheets.mjs`); a strength map's share at a level and its regions of change, a word's strokes closed into one block and a line up to 4 pixels thick eroded away, and which pictures need a look, and a capture's regions against the picture it was compared with (`shareOff`, `changeRegions`, `scripts/lib/shot-change.mjs`; `pngLook`, `svgLook`, `reviewPicture`, `scripts/lib/shot-queue.mjs`); a run's stage, its files, verdicts and strength maps, refused under docs, where git would take it or when not empty, and the stop line (`stagedFiles`, `writeVerdict`, `writeStrength`, `stageRefusal`, `scripts/lib/shot-stage.mjs`; `onStop`, `scripts/lib/record-lines.mjs`); a capture's verdict with a picture's allowances and a review's decisions, and the manifest written, read back and refused when malformed (`decidePicture`, `decideSvg`, `cleanupResets`, `scripts/lib/shot-policy.mjs`; `parseManifest`, `manifestText`, `openManifest`, `scripts/lib/shot-manifest.mjs`); a capture's fate against the picture at a git revision and the file, the SVG of one put back, what `--cleanup` resets, and a revision's files read in one git process and a text file put back as a checkout writes it, in a scratch repository (`pictureFate`, `svgFate`, `planCleanup`, `readRevisionFiles`, `restoreFromRevision`, `scripts/lib/shot-revision.mjs`); the screenshot review's session, its lock and its runs read back, which verdict a picture shows and its group, and the server following a stand-in run, answering the loopback host only and stopping the run (`openSession`, `acquireLock`, `reviewPictures`, `createReviewServer`, `scripts/lib/shot-session.mjs`, `shot-review.mjs`, `shot-server.mjs`). What it keeps of an image it embeds is `compactImage`'s, which the row above tests. Run by `node --test` | +| `test.bat` | `test/svgshot.test.mjs` | an SVG screenshot is written on one line (`oneLine` in `scripts/svgshot/snapshot-svg.mjs`), a break in a tag as a space and one in text as a character reference, so that a CRLF checkout changes only its last line ending; an underline is placed and sized by the figures fitted in Edge (`underlineBand`) under the boxes that set one (`decoratingBoxes`); a background image is sized by its bytes, a JPEG the IDE calls `image/png` included (`naturalSize`); an iframe's document is decoded and placed at the iframe's content box, less its scroll, scaled when zoomed, over its canvas colour (`frameGeometry`, `frameView`, `unzoom`, `viewportScroller`, `canvasColor`); a run's web font is the `@font-face` rule CSS matching picks (`webFontFiles`), and a variable WOFF2 is read and cut per weight; a family split by `unicode-range` gives each character to the rule whose range holds it (`parseUnicodeRange`, `webRulesFor`); an inline `` is found and its ids scoped (`inlineSvgsIn`, `scopeIds`); a filter of colour functions is baked into the colours under it, in order and clamped, its pictures wrapped in a real filter and its opacity kept apart (`colour-filter.mjs`); a transformed element's boxes and text are put back from the snapshot's after-transform boxes (`elementTransform`, `untransformBox`); a closed select shows its option's whole text (`selectLabel`); a bundle draws the same SVG from its file as in memory, and counts each question it cannot answer (`renderBundle`); its web font rules and files draw each character from the file whose range holds it, at the weight its rule allows, and its inline `` markup is drawn in its box, from the file and when trimmed (`drawBundle`); a bundle of version 1 is refused; one trimmed to its view draws the same (`trimSnapshot`); a font's cut comes from the subset cache with no Python run (`subsetKey`, `useSubsetCache`); `redraw` draws a picture again from its bundle or names its retake, and fails on the user name unless `checkUserName: false`; `listPictures` leaves the output trees out; `outputRefusal` refuses a sheet under docs or where git would take it (`scripts/lib/shot-revision.mjs`), and `sheetHtml` puts the worst picture first (`scripts/svgshot/sheets.mjs`); a strength map's share at a level and its regions of change, a word's strokes closed into one block and a line up to 4 pixels thick eroded away, and which pictures need a look, and a capture's regions against the picture it was compared with (`shareOff`, `changeRegions`, `scripts/lib/shot-change.mjs`; `pngLook`, `svgLook`, `reviewPicture`, `scripts/lib/shot-queue.mjs`); a run's stage, its files, verdicts and strength maps, refused under docs, where git would take it or when not empty, and the stop line (`stagedFiles`, `writeVerdict`, `writeStrength`, `stageRefusal`, `scripts/lib/shot-stage.mjs`; `onStop`, `scripts/lib/record-lines.mjs`); a capture's verdict with a picture's allowances and a review's decisions, and the manifest written, read back and refused when malformed (`decidePicture`, `decideSvg`, `cleanupResets`, `scripts/lib/shot-policy.mjs`; `parseManifest`, `manifestText`, `openManifest`, `scripts/lib/shot-manifest.mjs`); a capture's fate against the picture at a git revision and the file, the SVG of one put back, what `--cleanup` resets, and a revision's files read in one git process and a text file put back as a checkout writes it, in a scratch repository (`pictureFate`, `svgFate`, `planCleanup`, `readRevisionFiles`, `restoreFromRevision`, `scripts/lib/shot-revision.mjs`); the screenshot review's session, its lock and its runs read back, which verdict a picture shows and its group, and the server following a stand-in run, answering the loopback host only and stopping the run, and starting runs from its runs page one at a time, a clean-up holding a review's actions (`openSession`, `acquireLock`, `reviewPictures`, `createReviewServer`, `runOf`, `scripts/lib/shot-session.mjs`, `shot-review.mjs`, `shot-server.mjs`, `shot-runs.mjs`). What it keeps of an image it embeds is `compactImage`'s, which the row above tests. Run by `node --test` | | `test.bat` | `test/example-batches.test.mjs` | `check_examples.mjs`'s probes, from `scripts/lib/example-batches.mjs`: batching, crash isolation through a fake lane, the canaries, the fence classifier and `check_run`'s refusals, expected output, dispatcher and marker reading, with no IDE. Run by `node --test` | | `test.bat` | `test/ide-folders.test.mjs` | `shutdownIde` and `shutdownIdeAsync` remove the IDE's temp folder and profile once it is gone, and no other folder. Run by `node --test` | | `test.bat` | `test/broker.test.mjs` | `tbbroker` and its client (`scripts/lib/tb-broker.mjs`): one broker per pipe, leases, held processes, waiting, ports, a desktop left with windows on it, a restarted broker finding a desktop in use. Skipped unless `TBBROKER_EXE` names a built broker. Run by `node --test` | diff --git a/docs/Documentation/Tools-Toolchain-Tests.md b/docs/Documentation/Tools-Toolchain-Tests.md index 29adea9fe..48f6a1c2a 100644 --- a/docs/Documentation/Tools-Toolchain-Tests.md +++ b/docs/Documentation/Tools-Toolchain-Tests.md @@ -280,7 +280,7 @@ Unit tests for the SVG screenshot converter. They cover: - The strength map, a byte per pixel saying how far one picture is from another, which `scripts/lib/shot-change.mjs` makes of two captures and `scripts/svgshot/diff.mjs` of an SVG against its PNG: the share of its pixels at a level, with rectangles left out, and its regions of change. The tests draw small maps by hand: a line up to 4 pixels thick leaves no core after the erosion and a block keeps its middle, the strokes of a word are joined into one block, every changed pixel is kept along the picture's edge, and regions that touch at a corner are one. `scripts/lib/shot-queue.mjs` then says which pictures the review tool shows first: a capture taken with nothing surviving or only a word or two, a capture kept as within noise with something 2 grey levels off or more surviving, an SVG accepted with a part surviving or refused with nothing; and the regions of a capture measured against the picture it was compared with, when that is of its size. - `scripts/lib/shot-stage.mjs`, the folder `--stage` writes into instead of `docs`: each picture's files named, both parts of its verdict kept, a strength map written and read back, and a folder refused under `docs`, where git would take it, or when it holds anything. And the line `stop` that ends a run with `--events`, read across chunks and acted on once (`scripts/lib/record-lines.mjs`). - What a picture held before a verdict was applied, kept and put back with its SVG, its bundle and its build (`holdPicture` and `unholdPicture` in `scripts/lib/shot-apply.mjs`), a bundle copied from one folder to another naming another PNG (`copyBundle`), and the screenshot review tool's writes under `docs` (`scripts/lib/shot-verdicts.mjs`): a run's verdicts applied as they arrive, judged again after a review keeps the old picture, takes the new one or refuses its SVG, and a replay's SVG dropped once the PNG it was drawn against is no longer the file's. -- The screenshot review tool's session, the model of what its pages show, and its server (`scripts/lib/shot-session.mjs`, `shot-review.mjs`, `shot-pages.mjs` and `shot-server.mjs`). A session is made with its baseline, numbers its runs, and marks a run that was still going when the tool closed as interrupted. Its lock refuses a second tool while the first runs, and is taken over from one that ended. When several runs reported a picture, it shows the newest verdict on each part, and a newer run that takes the PNG again drops the older SVG. The tests also cover the overview's groups and their order, a picture whose job failed before reaching it, the filters, a loupe's crop, and a strength map drawn as the difference map of the two pictures it came from. The server is tested with a stand-in run that reports as `shoot_docs.mjs --stage --events` does: the server follows it, serves its pages and files, refuses a request addressed to any host but the loopback address, stops the run through its stdin, and reads the run back when the session is opened again. A second stand-in run checks that the server applies its verdict as it arrives, refuses a POST from another origin or not of JSON, takes a review's decision and its allowances (refusing one out of range), lists a picture no run reported as changed outside the runs, and moves the session to HEAD, after which a verdict judged against the old baseline needs a look until it is judged again. +- The screenshot review tool's session, the model of what its pages show, and its server (`scripts/lib/shot-session.mjs`, `shot-review.mjs`, `shot-pages.mjs` and `shot-server.mjs`). A session is made with its baseline, numbers its runs, and marks a run that was still going when the tool closed as interrupted. Its lock refuses a second tool while the first runs, and is taken over from one that ended. When several runs reported a picture, it shows the newest verdict on each part, and a newer run that takes the PNG again drops the older SVG. The tests also cover the overview's groups and their order, a picture whose job failed before reaching it, the filters, a loupe's crop, and a strength map drawn as the difference map of the two pictures it came from. The server is tested with a stand-in run that reports as `shoot_docs.mjs --stage --events` does: the server follows it, serves its pages and files, refuses a request addressed to any host but the loopback address, stops the run through its stdin, and reads the run back when the session is opened again. A second stand-in run checks that the server applies its verdict as it arrives, refuses a POST from another origin or not of JSON, takes a review's decision and its allowances (refusing one out of range), lists a picture no run reported as changed outside the runs, and moves the session to HEAD, after which a verdict judged against the old baseline needs a look until it is judged again. A third, with a stand-in clean-up, checks the runs page: a run started from a request (`runOf` in `scripts/lib/shot-runs.mjs`, which makes the pictures selected on the overview a pattern that matches each of them alone), one run at a time, a clean-up's dry-run list with its button that puts the pictures back, a review's actions refused while a clean-up runs, a retake's progress by job, and Stop before Kill. What the SVG keeps of an image it embeds is `compactImage`'s, from `scripts/lib/compact-image.mjs`, which [`compact-image.test.mjs`](#compact-image-test) tests. No browser, no built tree, well under a second. diff --git a/scripts/lib/shot-pages.mjs b/scripts/lib/shot-pages.mjs index 09a58c184..5204f9e5c 100644 --- a/scripts/lib/shot-pages.mjs +++ b/scripts/lib/shot-pages.mjs @@ -1,9 +1,10 @@ // The screenshot review tool's pages (WIP.ShotReview.md, The pages): the overview, every -// picture of the session as a card in its group, and the detail page of one picture, made of -// the pieces in shot-views.mjs. Each page is whole HTML; a page keeps itself current by -// fetching itself again when the server says over its event stream that something it shows -// has changed. Its actions (a decision, an allowance, the session moved to HEAD) are POSTs of -// JSON to the server, which applies them and says so over the same stream. +// picture of the session as a card in its group, the detail page of one picture, and the runs +// page, made of the pieces in shot-views.mjs. Each page is whole HTML; a page keeps itself +// current by fetching itself again when the server says over its event stream that something +// it shows has changed. Its actions (a decision, an allowance, the session moved to HEAD, a run +// started or stopped) are POSTs of JSON to the server, which applies them and says so over the +// same stream. // // Every address the pages link to is made here (hrefs), and the server answers each. @@ -11,6 +12,7 @@ import { anchor, chipHtml, esc, pageHtml, sizeText } from "./shot-views.mjs"; import { FILTERS, GROUPS } from "./shot-review.mjs"; import { pngRule, svgRule } from "./shot-policy.mjs"; import { SURVIVES } from "./shot-queue.mjs"; +import { RUN_KINDS } from "./shot-runs.mjs"; /** The generator the tool's pages name in their . */ export const REVIEW_GENERATOR = "scripts/shot_review.mjs"; @@ -39,11 +41,17 @@ export const hrefs = { /** The difference map of a picture changed outside the session's runs, against the baseline's. */ outsideMap: (out) => `/map/outside/${encodePath(out)}`, log: (n) => `/log/${n}`, + /** The session's runs, and the forms that start one. */ + runs: "/runs", events: "/events", /** POST: a review's action on a picture (shot-server.mjs's act). */ act: (out) => `/api/picture/${encodePath(out)}`, /** POST: the session moved to HEAD. */ moveBaseline: "/api/baseline", + /** POST: a run started (shot-runs.mjs's runOf takes the body). */ + startRun: "/api/runs", + /** POST: the run `n` stopped or ended ({action: "stop" | "kill"}). */ + runAct: (n) => `/api/run/${n}`, }; const REVIEW_STYLE = ``; // The page's script: the event stream, the page fetched again when something it shows has -// changed, the comparison's modes, and the keys j and k for the next and previous picture. +// changed (the values of a form marked data-keep kept), the comparison's modes, the keys j and +// k for the next and previous picture, and the pictures selected on the overview, which the +// runs page's forms take in place of a pattern (kept in the browser, as a convenience). const REVIEW_SCRIPT = ``; @@ -243,10 +316,34 @@ export function svgStateText(svg) { /** * A button that POSTs `act` (JSON) to `url`, `pressed` true or false for one of a choice, or - * null. The page's script sends it (REVIEW_SCRIPT). + * null; `confirm` a question the page asks first, `disabled` why it cannot be used now (its + * title), or null. The page's script sends it (REVIEW_SCRIPT). */ -export const actionButton = (label, url, act, { pressed = null, title = "" } = {}) => - ``; +export const actionButton = (label, url, act, { pressed = null, title = "", confirm = "", disabled = null } = {}) => + ``; + +// Why a run cannot be started while `busy` ({n, kind}) runs, or null. +const busyText = (busy) => (busy ? `run ${busy.n} is running: a new run starts once it has ended` : null); + +/** A retake of `out` and a replay of its SVG, as buttons on its page. */ +const runButtons = (out, busy, { replay = true } = {}) => [ + actionButton( + "Retake this picture", + hrefs.startRun, + { kind: "retake", pictures: [out] }, + { disabled: busyText(busy) }, + ), + ...(replay + ? [ + actionButton( + "Replay its SVG", + hrefs.startRun, + { kind: "replay", pictures: [out] }, + { disabled: busyText(busy) }, + ), + ] + : []), +]; /** A row of actions, with the place their progress and failures are said. */ const actionsHtml = (buttons) => @@ -301,38 +398,72 @@ const cardOf = (p, filter, exists) => { const thumb = thumbOf(p, exists); const link = hrefs.picture(p.out, filter); return ``; }; const runState = (r) => { - if (r.live) return r.stopping ? "stopping" : "running"; + if (r.live) return r.stopping || r.stopAsked ? "stopping" : "running"; if (r.interrupted) return "interrupted: the tool closed while it ran"; if (r.stopped) return `stopped, exit ${r.exit}`; return `ended, exit ${r.exit}`; }; -const runsHtml = (runs) => +const KIND_TEXT = Object.fromEntries(RUN_KINDS); +const runTitle = (r) => `${KIND_TEXT[r.kind] ?? r.kind}${r.dryRun ? ", a dry run" : ""}`; + +/** Stop for a run that is running, and once it has been asked to stop, Kill. */ +function stopButtons(r) { + if (!r.live || r.kind === "cleanup") return []; + if (!r.stopAsked) + return [ + actionButton( + "Stop", + hrefs.runAct(r.n), + { action: "stop" }, + { title: "finish the picture in hand, end the IDEs and put the registry back" }, + ), + ]; + return [ + actionButton( + "Kill", + hrefs.runAct(r.n), + { action: "kill" }, + { + confirm: `End run ${r.n}'s process at once? Its IDEs are not ended and the registry is not put back.`, + title: "end its process at once, which skips putting the registry back", + }, + ), + ]; +} + +const runsTableHtml = (runs) => runs.length ? `

    Runs

    +

    Runs: start a retake, a replay or a clean-up, or stop one.

    - + ${[...runs] .reverse() .map( (r) => - ``, + ``, ) .join("\n")}
    RunStatePicturesThe same as
    RunKindStatePicturesThe same as
    ${r.n}${esc(runState(r))}${r.done} of ${r.listed}${esc(r.command)} (log)
    ${r.n}${esc(runTitle(r))}${esc(runState(r))}${stopButtons(r).length ? ` ${stopButtons(r).join("")}` : ""}${r.kind === "cleanup" ? "" : `${r.done} of ${r.listed}`}${esc(r.command)} (log)
    ` - : `

    No run yet. Start the tool with --retake <regex> or --replay <regex> to start one.

    `; + : `

    No run yet: start one on the runs page, or start the tool with --retake <regex> or --replay <regex>.

    `; + +// The bar that says how many pictures are selected for a run, with `more` (HTML) after it. +const pickedBar = (more) => + ``; /** * The overview: `groups` reviewPictures' groups, with only the pictures the filter shows; * `filter` its key; `counts` how many pictures each filter shows ({all, look, overridden, - * nosvg}); `runs` [{n, command, live, stopping, interrupted, stopped, exit, done, listed}], - * oldest first; `baseline` the session's ({hash, short, build, moved}); `headNow` HEAD's short + * nosvg}); `runs` [{n, kind, dryRun, command, live, stopping, stopAsked, interrupted, stopped, + * exit, done, listed}], oldest first (the server's runRow); `baseline` the session's ({hash, short, build, moved}); `headNow` HEAD's short * hash when it is no longer the baseline, else null, and `canMove` whether the session can be * moved to it; `newestIde` the newest install's build, or null; `resume` {index, total, out} * for the queue's place, or null; `exists(path)` whether a file is under docs/. @@ -394,8 +525,9 @@ ${cards}`; summary: shown.map((g) => [g.title, g.pictures.length]), contents: shown.map((g) => `
  • ${esc(g.title)} (${g.pictures.length})
  • `).join("\n"), body: `${notices.map((n) => `
    ${n}
    `).join("\n")} +${pickedBar(`: start a run of them.`)} -${resumeLink}${runsHtml(runs)} +${resumeLink}${runsTableHtml(runs)} ${shown.length ? shown.map(group).join("\n") : "

    No picture.

    "}`, }); } @@ -475,7 +607,7 @@ const AGAINST = { revision: "HEAD", disk: "the file", dark: "the dark picture" } function pagerHtml({ p, filter, place, prev, next }) { const filterLabel = (FILTERS.find(([k]) => k === filter) ?? FILTERS[0])[1]; const groupTitle = (GROUPS.find(([k]) => k === p.group) ?? [null, p.group])[1]; - return `