tuffgal 0.2.0-alpha.2 → 0.2.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +41 -19
  2. package/README.md +6 -6
  3. package/dist/cli.d.ts +5 -5
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +12 -5
  6. package/dist/cli.js.map +1 -1
  7. package/dist/commands/init.d.ts +1 -1
  8. package/dist/commands/init.js +6 -6
  9. package/dist/commands/supervise.d.ts +3 -3
  10. package/dist/commands/supervise.js +8 -4
  11. package/dist/commands/supervise.js.map +1 -1
  12. package/dist/config.d.ts +29 -13
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js +5 -2
  15. package/dist/config.js.map +1 -1
  16. package/dist/coverage/screens.d.ts +10 -5
  17. package/dist/coverage/screens.d.ts.map +1 -1
  18. package/dist/coverage/screens.js +18 -8
  19. package/dist/coverage/screens.js.map +1 -1
  20. package/dist/reporter/assets/report.css +11 -11
  21. package/dist/reporter/assets/report.js +81 -21
  22. package/dist/reporter/template.js +56 -45
  23. package/dist/reporter/template.js.map +1 -1
  24. package/dist/runner/approve.d.ts +5 -5
  25. package/dist/runner/approve.js +5 -5
  26. package/dist/runner/approveFrom.d.ts +8 -8
  27. package/dist/runner/approveFrom.js +32 -31
  28. package/dist/runner/approveFrom.js.map +1 -1
  29. package/dist/runner/breakpointPasses.d.ts +16 -9
  30. package/dist/runner/breakpointPasses.d.ts.map +1 -1
  31. package/dist/runner/breakpointPasses.js +17 -9
  32. package/dist/runner/breakpointPasses.js.map +1 -1
  33. package/dist/runner/bridges/devServers.d.ts +10 -1
  34. package/dist/runner/bridges/devServers.d.ts.map +1 -1
  35. package/dist/runner/bridges/devServers.js +16 -8
  36. package/dist/runner/bridges/devServers.js.map +1 -1
  37. package/dist/runner/coverage.d.ts +3 -3
  38. package/dist/runner/coverage.js +5 -5
  39. package/dist/runner/coverage.js.map +1 -1
  40. package/dist/runner/exitCode.d.ts +7 -7
  41. package/dist/runner/exitCode.js +7 -7
  42. package/dist/runner/interpolate.js +1 -1
  43. package/dist/runner/interpolate.js.map +1 -1
  44. package/dist/runner/manifest.d.ts +16 -16
  45. package/dist/runner/manifest.js +12 -12
  46. package/dist/runner/mode.d.ts +2 -2
  47. package/dist/runner/mode.js +4 -4
  48. package/dist/runner/orphanScan.d.ts +3 -3
  49. package/dist/runner/orphanScan.js +5 -5
  50. package/dist/runner/orphanScan.js.map +1 -1
  51. package/dist/runner/resolveLocator.d.ts +5 -5
  52. package/dist/runner/resolveLocator.js +6 -6
  53. package/dist/runner/resolveLocator.js.map +1 -1
  54. package/dist/runner/run.d.ts +55 -18
  55. package/dist/runner/run.d.ts.map +1 -1
  56. package/dist/runner/run.js +118 -67
  57. package/dist/runner/run.js.map +1 -1
  58. package/dist/runner/runAction.d.ts +4 -4
  59. package/dist/runner/runAction.d.ts.map +1 -1
  60. package/dist/runner/runAction.js +72 -34
  61. package/dist/runner/runAction.js.map +1 -1
  62. package/dist/runner/runStory.d.ts +29 -18
  63. package/dist/runner/runStory.d.ts.map +1 -1
  64. package/dist/runner/runStory.js +38 -42
  65. package/dist/runner/runStory.js.map +1 -1
  66. package/dist/runner/scheduler.d.ts +11 -0
  67. package/dist/runner/scheduler.d.ts.map +1 -1
  68. package/dist/runner/scheduler.js +1 -1
  69. package/dist/runner/scheduler.js.map +1 -1
  70. package/dist/runner/steps/intercept.d.ts +6 -4
  71. package/dist/runner/steps/intercept.d.ts.map +1 -1
  72. package/dist/runner/steps/intercept.js.map +1 -1
  73. package/dist/runner/steps/navigate.d.ts +5 -2
  74. package/dist/runner/steps/navigate.d.ts.map +1 -1
  75. package/dist/runner/steps/navigate.js +17 -3
  76. package/dist/runner/steps/navigate.js.map +1 -1
  77. package/dist/runner/steps/read.d.ts +1 -1
  78. package/dist/runner/steps/read.js +1 -1
  79. package/dist/runner/steps/scroll.d.ts +1 -1
  80. package/dist/runner/steps/scroll.d.ts.map +1 -1
  81. package/dist/runner/steps/scroll.js +3 -2
  82. package/dist/runner/steps/scroll.js.map +1 -1
  83. package/dist/schema/action.d.ts +2 -2
  84. package/dist/schema/action.d.ts.map +1 -1
  85. package/dist/schema/action.js +58 -13
  86. package/dist/schema/action.js.map +1 -1
  87. package/dist/schema/load.js +1 -1
  88. package/dist/schema/load.js.map +1 -1
  89. package/dist/schema/result.d.ts +22 -22
  90. package/dist/schema/result.d.ts.map +1 -1
  91. package/dist/schema/result.js +1 -1
  92. package/dist/schema/story.d.ts +1 -1
  93. package/dist/schema/story.js +5 -5
  94. package/dist/screenshots/baselineStore.d.ts +44 -21
  95. package/dist/screenshots/baselineStore.d.ts.map +1 -1
  96. package/dist/screenshots/baselineStore.js +59 -28
  97. package/dist/screenshots/baselineStore.js.map +1 -1
  98. package/dist/screenshots/capture.d.ts +35 -4
  99. package/dist/screenshots/capture.d.ts.map +1 -1
  100. package/dist/screenshots/capture.js +61 -9
  101. package/dist/screenshots/capture.js.map +1 -1
  102. package/dist/screenshots/diff.d.ts +57 -8
  103. package/dist/screenshots/diff.d.ts.map +1 -1
  104. package/dist/screenshots/diff.js +78 -28
  105. package/dist/screenshots/diff.js.map +1 -1
  106. package/dist/util.d.ts +23 -2
  107. package/dist/util.d.ts.map +1 -1
  108. package/dist/util.js +42 -2
  109. package/dist/util.js.map +1 -1
  110. package/package.json +4 -8
@@ -8,7 +8,7 @@ export interface BaselinePaths {
8
8
  * Pre-breakpoint baseline location (`<action>/0.png`). Populated so a caller
9
9
  * that finds no breakpoint-keyed baseline can fall back to a project's
10
10
  * legacy committed baseline instead of declaring every action `new`. Read
11
- * fallback only — the runner reads `baseline` first, then `legacyBaseline`;
11
+ * fallback only. The runner reads `baseline` first, then `legacyBaseline`;
12
12
  * promotion always writes the breakpoint-keyed `baseline`, never this. See
13
13
  * the read-order note below.
14
14
  */
@@ -19,7 +19,7 @@ export interface BaselinePaths {
19
19
  * Proposed-new-baseline PNG for CI mode, under
20
20
  * `<report>/candidates/<action>/<breakpoint>.png`. Mirrors the `baseline`
21
21
  * layout exactly (action dir + breakpoint filename) so approving a candidate
22
- * set is a plain tree copy into `paths.baselines`. Always report-rooted —
22
+ * set is a plain tree copy into `paths.baselines`. Always report-rooted,
23
23
  * never affected by the comparison root, which only moves the baseline side.
24
24
  */
25
25
  candidate: string;
@@ -31,7 +31,7 @@ export interface StoreOptions {
31
31
  /**
32
32
  * Root the baseline-side paths (`baseline`, `a11yBaseline`, and the two
33
33
  * `legacy*` fallbacks) resolve under. Defaults to `baselinesDir`, so an
34
- * omitted root reproduces the committed-baselines layout byte-for-byte — the
34
+ * omitted root reproduces the committed-baselines layout byte-for-byte, the
35
35
  * zero-behaviour-change default. Local mode passes the per-machine cache dir
36
36
  * here to self-diff against it instead. Report-side artifacts (actual, diff,
37
37
  * candidate, a11yActual, a11yCandidate) ignore this and stay report-rooted.
@@ -70,7 +70,7 @@ export declare const ACTION_NAME_PATTERN: RegExp;
70
70
  */
71
71
  export declare const BREAKPOINT_SEGMENT_PATTERN: RegExp;
72
72
  /**
73
- * Computes deterministic paths for the baseline (the comparison target —
73
+ * Computes deterministic paths for the baseline (the comparison target:
74
74
  * committed baselines by default, or the local cache when `comparisonRoot` is
75
75
  * supplied), actual (regenerated each run), diff (regenerated when a baseline
76
76
  * existed and the diff was non-zero), and candidate (proposed-new-baseline for
@@ -110,31 +110,54 @@ export declare function writeText(path: string, content: string): Promise<void>;
110
110
  * case) is swallowed and the original buffer is returned, so a recompress
111
111
  * miss degrades to "write the bytes we were given" rather than a torn file.
112
112
  *
113
- * This is the single lossless-recompress seam for every PNG the tool writes:
114
- * `writePng` routes through it, so baseline, candidate, cache, actual, and diff
115
- * writes all inherit it without per-callsite wiring. Deterministic — pngjs's
116
- * encoder is a pure function of the pixel data and the fixed options above.
113
+ * This is the lossless-recompress seam for the tool's DURABLE PNG writes:
114
+ * {@link writeDurablePng} routes through it, so baseline, candidate, cache, and
115
+ * promoted-baseline writes inherit it without per-callsite wiring. Transient
116
+ * report artifacts (`actual`, `diff`) deliberately skip it via
117
+ * {@link writeTransientPng}. The max-effort encode is wasted on a file that is
118
+ * overwritten or deleted next run. Deterministic: pngjs's encoder is a pure
119
+ * function of the pixel data and the fixed options above.
117
120
  *
118
121
  * Deliberately NOT palette-quantised and never lossy: colour type and bit depth
119
122
  * are preserved by the RGBA round-trip.
120
123
  */
121
124
  export declare function recompressPng(png: Buffer): Buffer;
122
- export declare function writePng(path: string, png: Buffer): Promise<void>;
125
+ /**
126
+ * Writes a DURABLE PNG (a baseline, a candidate, or a promoted baseline) that
127
+ * outlives the run and is re-read on every later comparison. These earn the
128
+ * max-effort lossless recompress (see {@link recompressPng}): the level-9
129
+ * deflate + adaptive per-scanline filtering is paid once at write, and every
130
+ * subsequent read of the smaller file benefits.
131
+ *
132
+ * Transient report artifacts (the run's `actual`, the `diff` overlay) are
133
+ * overwritten or deleted next run and must NOT pay this cost. They route
134
+ * through {@link writeTransientPng}, which skips recompress entirely.
135
+ */
136
+ export declare function writeDurablePng(path: string, png: Buffer): Promise<void>;
137
+ /**
138
+ * Writes a TRANSIENT PNG (the run's `actual` capture or the `diff` overlay)
139
+ * verbatim, with NO recompress pass. These artifacts live only for the current
140
+ * report: an `actual` is overwritten on the next run and a `diff` is deleted the
141
+ * moment a comparison passes, so shrinking them with the expensive level-9
142
+ * encode is wasted work. The `diff` overlay in particular arrives already
143
+ * deflate-encoded by {@link renderDiffOverlay}'s `PNG.sync.write`, so a
144
+ * recompress round-trip would only decode and re-encode it back to
145
+ * near-identical bytes.
146
+ *
147
+ * Durable artifacts (baselines, candidates, promoted baselines) DO earn the
148
+ * recompress and use {@link writeDurablePng}.
149
+ */
150
+ export declare function writeTransientPng(path: string, png: Buffer): Promise<void>;
123
151
  export declare function deleteIfExists(path: string): Promise<void>;
124
152
  /**
125
153
  * Copies an already-written PNG (the run's `actual`, or a `candidate`) to a new
126
- * destination verbatim. No recompress here on purpose: the source was produced
127
- * by `writePng`, which already ran `recompressPng`, so the bytes on disk are the
128
- * recompressed ones. Copying them forward keeps the destination losslessly
129
- * recompressed for free. The only non-test caller is `approve` refreshing the
130
- * per-machine cache; `approve --from` does NOT use this — it reads each source
131
- * once during validation and writes the retained buffer through {@link writePng}.
154
+ * destination verbatim. No recompress here on purpose: the source is a durable
155
+ * baseline produced by {@link writeDurablePng}, which already ran
156
+ * `recompressPng`, so the bytes on disk are the recompressed ones. Copying them
157
+ * forward keeps the destination losslessly recompressed for free. The only
158
+ * non-test caller is `approve` refreshing the per-machine cache; `approve --from`
159
+ * does NOT use this. It reads each source once during validation and writes the
160
+ * retained buffer through {@link writeDurablePng}.
132
161
  */
133
162
  export declare function copyRecompressedPng(actualPath: string, destinationPath: string): Promise<void>;
134
- /**
135
- * Turns an absolute screenshot path into a path that is relative to the
136
- * generated report directory so the HTML report can reference it with a
137
- * portable `src` attribute.
138
- */
139
- export declare function pathRelativeToReport(reportDir: string, absolute: string): string;
140
163
  //# sourceMappingURL=baselineStore.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"baselineStore.d.ts","sourceRoot":"","sources":["../../src/screenshots/baselineStore.ts"],"names":[],"mappings":"AAYA,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB;;;;;;;OAOG;IACH,cAAc,EAAE,MAAM,CAAC;IACvB,iFAAiF;IACjF,kBAAkB,EAAE,MAAM,CAAC;IAC3B;;;;;;OAMG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,uFAAuF;IACvF,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,YAAY;IAC3B,YAAY,EAAE,MAAM,CAAC;IACrB;;;;;;;OAOG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,UAAU,EAAE,MAAM,CAAC;CACpB;AAcD;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,QAAiB,CAAC;AAElD;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B,QAAkB,CAAC;AAE1D;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAAE,YAAY,GAAG,aAAa,CA6C7D;AAiBD,wBAAgB,gBAAgB,CAAC,CAAC,EAChC,GAAG,EAAE,MAAM,EACX,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACnB,OAAO,CAAC,CAAC,CAAC,CAcZ;AAED,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAO5E;AAED,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAO7B;AAED,wBAAsB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAG5E;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAWjD;AAED,wBAAsB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAGvE;AAED,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAMhE;AAED;;;;;;;;GAQG;AACH,wBAAsB,mBAAmB,CACvC,UAAU,EAAE,MAAM,EAClB,eAAe,EAAE,MAAM,GACtB,OAAO,CAAC,IAAI,CAAC,CAGf;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,GACf,MAAM,CAER"}
1
+ {"version":3,"file":"baselineStore.d.ts","sourceRoot":"","sources":["../../src/screenshots/baselineStore.ts"],"names":[],"mappings":"AAYA,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB;;;;;;;OAOG;IACH,cAAc,EAAE,MAAM,CAAC;IACvB,iFAAiF;IACjF,kBAAkB,EAAE,MAAM,CAAC;IAC3B;;;;;;OAMG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,uFAAuF;IACvF,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,YAAY;IAC3B,YAAY,EAAE,MAAM,CAAC;IACrB;;;;;;;OAOG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,UAAU,EAAE,MAAM,CAAC;CACpB;AAcD;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,QAAiB,CAAC;AAElD;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B,QAAkB,CAAC;AAE1D;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAAE,YAAY,GAAG,aAAa,CA6C7D;AAiBD,wBAAgB,gBAAgB,CAAC,CAAC,EAChC,GAAG,EAAE,MAAM,EACX,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACnB,OAAO,CAAC,CAAC,CAAC,CAcZ;AAED,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAO5E;AAED,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAO7B;AAED,wBAAsB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAG5E;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAWjD;AAYD;;;;;;;;;;GAUG;AACH,wBAAsB,eAAe,CACnC,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,IAAI,CAAC,CAEf;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,IAAI,CAAC,CAEf;AAED,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAMhE;AAED;;;;;;;;;GASG;AACH,wBAAsB,mBAAmB,CACvC,UAAU,EAAE,MAAM,EAClB,eAAe,EAAE,MAAM,GACtB,OAAO,CAAC,IAAI,CAAC,CAGf"}
@@ -1,5 +1,5 @@
1
1
  import { access, copyFile, mkdir, readFile, rm, writeFile, } from 'node:fs/promises';
2
- import { dirname, join, relative } from 'node:path';
2
+ import { dirname, join } from 'node:path';
3
3
  import { PNG } from 'pngjs';
4
4
  /**
5
5
  * Reduces an arbitrary breakpoint name to a filesystem-safe path segment.
@@ -37,7 +37,7 @@ export const ACTION_NAME_PATTERN = /^[a-z0-9-]+$/;
37
37
  */
38
38
  export const BREAKPOINT_SEGMENT_PATTERN = /^[a-z0-9_-]+$/;
39
39
  /**
40
- * Computes deterministic paths for the baseline (the comparison target —
40
+ * Computes deterministic paths for the baseline (the comparison target:
41
41
  * committed baselines by default, or the local cache when `comparisonRoot` is
42
42
  * supplied), actual (regenerated each run), diff (regenerated when a baseline
43
43
  * existed and the diff was non-zero), and candidate (proposed-new-baseline for
@@ -80,15 +80,15 @@ export function pathsFor(options) {
80
80
  }
81
81
  /**
82
82
  * Per-path serialization for baseline creation. A baseline is keyed on action
83
- * name *and* breakpoint (see `pathsFor` — the breakpoint is part of the
83
+ * name *and* breakpoint (see `pathsFor`, the breakpoint is part of the
84
84
  * filename), so when the same action+breakpoint runs in two stories
85
85
  * concurrently (`workers > 1`, fresh run), both would otherwise read "no
86
- * baseline" and race to write the same `<breakpoint>.png` — a torn or
86
+ * baseline" and race to write the same `<breakpoint>.png`, a torn or
87
87
  * last-writer-wins file. Callers wrap their read-then-maybe-write critical
88
88
  * section in this lock so exactly one writer per baseline path runs at a time;
89
89
  * later callers see the baseline the first writer produced. The map is keyed by
90
- * path, so distinct actions — and now distinct breakpoints of the same action,
91
- * whose baseline paths differ — never block each other; entries are bounded by
90
+ * path, so distinct actions (and now distinct breakpoints of the same action,
91
+ * whose baseline paths differ) never block each other; entries are bounded by
92
92
  * action count times breakpoint count.
93
93
  */
94
94
  const baselineLocks = new Map();
@@ -140,10 +140,13 @@ export async function writeText(path, content) {
140
140
  * case) is swallowed and the original buffer is returned, so a recompress
141
141
  * miss degrades to "write the bytes we were given" rather than a torn file.
142
142
  *
143
- * This is the single lossless-recompress seam for every PNG the tool writes:
144
- * `writePng` routes through it, so baseline, candidate, cache, actual, and diff
145
- * writes all inherit it without per-callsite wiring. Deterministic — pngjs's
146
- * encoder is a pure function of the pixel data and the fixed options above.
143
+ * This is the lossless-recompress seam for the tool's DURABLE PNG writes:
144
+ * {@link writeDurablePng} routes through it, so baseline, candidate, cache, and
145
+ * promoted-baseline writes inherit it without per-callsite wiring. Transient
146
+ * report artifacts (`actual`, `diff`) deliberately skip it via
147
+ * {@link writeTransientPng}. The max-effort encode is wasted on a file that is
148
+ * overwritten or deleted next run. Deterministic: pngjs's encoder is a pure
149
+ * function of the pixel data and the fixed options above.
147
150
  *
148
151
  * Deliberately NOT palette-quantised and never lossy: colour type and bit depth
149
152
  * are preserved by the RGBA round-trip.
@@ -154,16 +157,51 @@ export function recompressPng(png) {
154
157
  recompressed = PNG.sync.write(PNG.sync.read(png));
155
158
  }
156
159
  catch {
157
- // Not a decodable PNG, or an encoder edge case — write what we were given
160
+ // Not a decodable PNG, or an encoder edge case. Write what we were given
158
161
  // rather than risk emitting a corrupt or truncated file.
159
162
  return png;
160
163
  }
161
- // A recompress that grows the file is a loss, not a win — keep the smaller.
164
+ // A recompress that grows the file is a loss, not a win. Keep the smaller.
162
165
  return recompressed.length < png.length ? recompressed : png;
163
166
  }
164
- export async function writePng(path, png) {
167
+ /**
168
+ * Shared write core: ensure the parent directory exists, then write the bytes
169
+ * verbatim. The durable/transient split lives one level up. This only handles
170
+ * the mkdir + write both share.
171
+ */
172
+ async function writePngBytes(path, bytes) {
165
173
  await mkdir(dirname(path), { recursive: true });
166
- await writeFile(path, recompressPng(png));
174
+ await writeFile(path, bytes);
175
+ }
176
+ /**
177
+ * Writes a DURABLE PNG (a baseline, a candidate, or a promoted baseline) that
178
+ * outlives the run and is re-read on every later comparison. These earn the
179
+ * max-effort lossless recompress (see {@link recompressPng}): the level-9
180
+ * deflate + adaptive per-scanline filtering is paid once at write, and every
181
+ * subsequent read of the smaller file benefits.
182
+ *
183
+ * Transient report artifacts (the run's `actual`, the `diff` overlay) are
184
+ * overwritten or deleted next run and must NOT pay this cost. They route
185
+ * through {@link writeTransientPng}, which skips recompress entirely.
186
+ */
187
+ export async function writeDurablePng(path, png) {
188
+ await writePngBytes(path, recompressPng(png));
189
+ }
190
+ /**
191
+ * Writes a TRANSIENT PNG (the run's `actual` capture or the `diff` overlay)
192
+ * verbatim, with NO recompress pass. These artifacts live only for the current
193
+ * report: an `actual` is overwritten on the next run and a `diff` is deleted the
194
+ * moment a comparison passes, so shrinking them with the expensive level-9
195
+ * encode is wasted work. The `diff` overlay in particular arrives already
196
+ * deflate-encoded by {@link renderDiffOverlay}'s `PNG.sync.write`, so a
197
+ * recompress round-trip would only decode and re-encode it back to
198
+ * near-identical bytes.
199
+ *
200
+ * Durable artifacts (baselines, candidates, promoted baselines) DO earn the
201
+ * recompress and use {@link writeDurablePng}.
202
+ */
203
+ export async function writeTransientPng(path, png) {
204
+ await writePngBytes(path, png);
167
205
  }
168
206
  export async function deleteIfExists(path) {
169
207
  try {
@@ -175,23 +213,16 @@ export async function deleteIfExists(path) {
175
213
  }
176
214
  /**
177
215
  * Copies an already-written PNG (the run's `actual`, or a `candidate`) to a new
178
- * destination verbatim. No recompress here on purpose: the source was produced
179
- * by `writePng`, which already ran `recompressPng`, so the bytes on disk are the
180
- * recompressed ones. Copying them forward keeps the destination losslessly
181
- * recompressed for free. The only non-test caller is `approve` refreshing the
182
- * per-machine cache; `approve --from` does NOT use this — it reads each source
183
- * once during validation and writes the retained buffer through {@link writePng}.
216
+ * destination verbatim. No recompress here on purpose: the source is a durable
217
+ * baseline produced by {@link writeDurablePng}, which already ran
218
+ * `recompressPng`, so the bytes on disk are the recompressed ones. Copying them
219
+ * forward keeps the destination losslessly recompressed for free. The only
220
+ * non-test caller is `approve` refreshing the per-machine cache; `approve --from`
221
+ * does NOT use this. It reads each source once during validation and writes the
222
+ * retained buffer through {@link writeDurablePng}.
184
223
  */
185
224
  export async function copyRecompressedPng(actualPath, destinationPath) {
186
225
  await mkdir(dirname(destinationPath), { recursive: true });
187
226
  await copyFile(actualPath, destinationPath);
188
227
  }
189
- /**
190
- * Turns an absolute screenshot path into a path that is relative to the
191
- * generated report directory so the HTML report can reference it with a
192
- * portable `src` attribute.
193
- */
194
- export function pathRelativeToReport(reportDir, absolute) {
195
- return relative(reportDir, absolute);
196
- }
197
228
  //# sourceMappingURL=baselineStore.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"baselineStore.js","sourceRoot":"","sources":["../../src/screenshots/baselineStore.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"baselineStore.js","sourceRoot":"","sources":["../../src/screenshots/baselineStore.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,MAAM,EACN,QAAQ,EACR,KAAK,EACL,QAAQ,EACR,EAAE,EACF,SAAS,GACV,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAE1C,OAAO,EAAE,GAAG,EAAE,MAAM,OAAO,CAAC;AAqD5B;;;;;;;GAOG;AACH,SAAS,iBAAiB,CAAC,UAAkB;IAC3C,OAAO,UAAU,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,cAAc,EAAE,GAAG,CAAC,CAAC;AAC/D,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,aAAa,CAAC,SAAiB;IAC7C,OAAO,IAAI,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC;AACvC,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,cAAc,CAAC;AAElD;;;;;GAKG;AACH,MAAM,CAAC,MAAM,0BAA0B,GAAG,eAAe,CAAC;AAE1D;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,QAAQ,CAAC,OAAqB;IAC5C,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC;IAC5D,MAAM,EAAE,GAAG,iBAAiB,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC;IACjD,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,IAAI,OAAO,CAAC,YAAY,CAAC;IACtE,OAAO;QACL,QAAQ,EAAE,IAAI,CAAC,cAAc,EAAE,OAAO,CAAC,UAAU,EAAE,GAAG,EAAE,MAAM,CAAC;QAC/D,MAAM,EAAE,IAAI,CACV,OAAO,CAAC,SAAS,EACjB,aAAa,EACb,SAAS,EACT,GAAG,OAAO,CAAC,UAAU,IAAI,EAAE,aAAa,CACzC;QACD,IAAI,EAAE,IAAI,CACR,OAAO,CAAC,SAAS,EACjB,aAAa,EACb,SAAS,EACT,GAAG,OAAO,CAAC,UAAU,IAAI,EAAE,WAAW,CACvC;QACD,YAAY,EAAE,IAAI,CAAC,cAAc,EAAE,OAAO,CAAC,UAAU,EAAE,GAAG,EAAE,YAAY,CAAC;QACzE,UAAU,EAAE,IAAI,CACd,OAAO,CAAC,SAAS,EACjB,aAAa,EACb,SAAS,EACT,GAAG,OAAO,CAAC,UAAU,IAAI,EAAE,YAAY,CACxC;QACD,sEAAsE;QACtE,6EAA6E;QAC7E,SAAS,EAAE,IAAI,CACb,aAAa,CAAC,OAAO,CAAC,SAAS,CAAC,EAChC,OAAO,CAAC,UAAU,EAClB,GAAG,EAAE,MAAM,CACZ;QACD,aAAa,EAAE,IAAI,CACjB,aAAa,CAAC,OAAO,CAAC,SAAS,CAAC,EAChC,OAAO,CAAC,UAAU,EAClB,GAAG,EAAE,YAAY,CAClB;QACD,wEAAwE;QACxE,0EAA0E;QAC1E,4EAA4E;QAC5E,yEAAyE;QACzE,4DAA4D;QAC5D,cAAc,EAAE,IAAI,CAAC,cAAc,EAAE,OAAO,CAAC,UAAU,EAAE,OAAO,CAAC;QACjE,kBAAkB,EAAE,IAAI,CAAC,cAAc,EAAE,OAAO,CAAC,UAAU,EAAE,WAAW,CAAC;KAC1E,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,aAAa,GAAG,IAAI,GAAG,EAAyB,CAAC;AAEvD,MAAM,UAAU,gBAAgB,CAC9B,GAAW,EACX,EAAoB;IAEpB,MAAM,QAAQ,GAAG,aAAa,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,OAAO,CAAC,OAAO,EAAE,CAAC;IAC7D,2EAA2E;IAC3E,MAAM,GAAG,GAAG,QAAQ,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;IAClC,4EAA4E;IAC5E,+DAA+D;IAC/D,aAAa,CAAC,GAAG,CACf,GAAG,EACH,GAAG,CAAC,IAAI,CACN,GAAG,EAAE,CAAC,SAAS,EACf,GAAG,EAAE,CAAC,SAAS,CAChB,CACF,CAAC;IACF,OAAO,GAAG,CAAC;AACb,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,IAAY;IAC7C,IAAI,CAAC;QACH,MAAM,MAAM,CAAC,IAAI,CAAC,CAAC;IACrB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,OAAO,QAAQ,CAAC,IAAI,CAAC,CAAC;AACxB,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACpC,IAAY;IAEZ,IAAI,CAAC;QACH,MAAM,MAAM,CAAC,IAAI,CAAC,CAAC;IACrB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,OAAO,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;AAChC,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,IAAY,EAAE,OAAe;IAC3D,MAAM,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAChD,MAAM,SAAS,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC;AACzC,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,MAAM,UAAU,aAAa,CAAC,GAAW;IACvC,IAAI,YAAoB,CAAC;IACzB,IAAI,CAAC;QACH,YAAY,GAAG,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;IACpD,CAAC;IAAC,MAAM,CAAC;QACP,yEAAyE;QACzE,yDAAyD;QACzD,OAAO,GAAG,CAAC;IACb,CAAC;IACD,2EAA2E;IAC3E,OAAO,YAAY,CAAC,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,GAAG,CAAC;AAC/D,CAAC;AAED;;;;GAIG;AACH,KAAK,UAAU,aAAa,CAAC,IAAY,EAAE,KAAa;IACtD,MAAM,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAChD,MAAM,SAAS,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;AAC/B,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,IAAY,EACZ,GAAW;IAEX,MAAM,aAAa,CAAC,IAAI,EAAE,aAAa,CAAC,GAAG,CAAC,CAAC,CAAC;AAChD,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CACrC,IAAY,EACZ,GAAW;IAEX,MAAM,aAAa,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;AACjC,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,IAAY;IAC/C,IAAI,CAAC;QACH,MAAM,EAAE,CAAC,IAAI,CAAC,CAAC;IACjB,CAAC;IAAC,MAAM,CAAC;QACP,oCAAoC;IACtC,CAAC;AACH,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,CAAC,KAAK,UAAU,mBAAmB,CACvC,UAAkB,EAClB,eAAuB;IAEvB,MAAM,KAAK,CAAC,OAAO,CAAC,eAAe,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC3D,MAAM,QAAQ,CAAC,UAAU,EAAE,eAAe,CAAC,CAAC;AAC9C,CAAC"}
@@ -1,14 +1,45 @@
1
1
  import type { Locator, Page } from 'playwright';
2
2
  import type { CaptureMode } from '../config.ts';
3
+ /**
4
+ * Safety bound for a `fullPage` capture. `maxPixels` caps the composited image's
5
+ * total area (width × height); `label` identifies the capture in the error the
6
+ * guard throws so the operator knows exactly which story/action to fix.
7
+ */
8
+ export interface FullPageGuard {
9
+ maxPixels: number;
10
+ label: string;
11
+ }
12
+ /**
13
+ * Thrown when a `fullPage` capture's measured layout area exceeds its
14
+ * {@link FullPageGuard} cap. Carries the offending dimensions and the cap so
15
+ * callers (and the message) can report precisely. Failing here is deliberate:
16
+ * compositing and decoding a runaway-tall page to an RGBA array risks exhausting
17
+ * memory, so a loud, actionable error beats an OOM crash.
18
+ */
19
+ export declare class FullPageTooLargeError extends Error {
20
+ readonly width: number;
21
+ readonly height: number;
22
+ readonly maxPixels: number;
23
+ constructor(label: string, dimensions: {
24
+ width: number;
25
+ height: number;
26
+ }, maxPixels: number);
27
+ }
3
28
  /**
4
29
  * Screenshot with animations disabled, caret hidden, and any masks applied so
5
30
  * the same UI renders to bit-identical pixels across runs. Masks are Playwright
6
- * locators whose bounding boxes are blacked out before the image is encoded —
7
- * use them to neutralise randomised or time-based regions.
31
+ * locators whose bounding boxes are blacked out before the image is encoded.
32
+ * Use them to neutralise randomised or time-based regions.
8
33
  *
9
34
  * `mode` controls scope: `viewport` (default) captures only the page's current
10
- * viewport box — what the user sees above the fold — while `fullPage`
35
+ * viewport box (what the user sees above the fold) while `fullPage`
11
36
  * composites the entire scrollable document however tall.
37
+ *
38
+ * `fullPageGuard`, when supplied, bounds a `fullPage` capture's area: the page's
39
+ * measured layout dimensions are checked against the cap before the shutter
40
+ * fires, and an over-cap page throws {@link FullPageTooLargeError} rather than
41
+ * compositing an array large enough to risk an OOM. It is ignored for `viewport`
42
+ * captures (already bounded by the breakpoint) and when omitted.
12
43
  */
13
- export declare function capturePage(page: Page, masks?: Locator[], mode?: CaptureMode): Promise<Buffer>;
44
+ export declare function capturePage(page: Page, masks?: Locator[], mode?: CaptureMode, fullPageGuard?: FullPageGuard): Promise<Buffer>;
14
45
  //# sourceMappingURL=capture.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"capture.d.ts","sourceRoot":"","sources":["../../src/screenshots/capture.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAChD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAEhD;;;;;;;;;GASG;AACH,wBAAsB,WAAW,CAC/B,IAAI,EAAE,IAAI,EACV,KAAK,GAAE,OAAO,EAAO,EACrB,IAAI,GAAE,WAAwB,GAC7B,OAAO,CAAC,MAAM,CAAC,CA8BjB"}
1
+ {"version":3,"file":"capture.d.ts","sourceRoot":"","sources":["../../src/screenshots/capture.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAChD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAEhD;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;;GAMG;AACH,qBAAa,qBAAsB,SAAQ,KAAK;IAC9C,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;gBAEzB,KAAK,EAAE,MAAM,EACb,UAAU,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,EAC7C,SAAS,EAAE,MAAM;CAkBpB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,WAAW,CAC/B,IAAI,EAAE,IAAI,EACV,KAAK,GAAE,OAAO,EAAO,EACrB,IAAI,GAAE,WAAwB,EAC9B,aAAa,CAAC,EAAE,aAAa,GAC5B,OAAO,CAAC,MAAM,CAAC,CAuDjB"}
@@ -1,18 +1,51 @@
1
+ /**
2
+ * Thrown when a `fullPage` capture's measured layout area exceeds its
3
+ * {@link FullPageGuard} cap. Carries the offending dimensions and the cap so
4
+ * callers (and the message) can report precisely. Failing here is deliberate:
5
+ * compositing and decoding a runaway-tall page to an RGBA array risks exhausting
6
+ * memory, so a loud, actionable error beats an OOM crash.
7
+ */
8
+ export class FullPageTooLargeError extends Error {
9
+ width;
10
+ height;
11
+ maxPixels;
12
+ constructor(label, dimensions, maxPixels) {
13
+ const area = dimensions.width * dimensions.height;
14
+ super(`Full-page screenshot for ${label} would composite a ` +
15
+ `${dimensions.width}x${dimensions.height} image ` +
16
+ `(${area.toLocaleString('en-US')} pixels), exceeding the ` +
17
+ `${maxPixels.toLocaleString('en-US')}-pixel safety cap. A page this ` +
18
+ `tall decodes to a huge RGBA array and risks exhausting memory. Bound ` +
19
+ `the page height (e.g. cap infinite-scroll content), switch this story ` +
20
+ `to captureMode 'viewport', or raise maxFullPagePixels in your tuffgal ` +
21
+ `config.`);
22
+ this.name = 'FullPageTooLargeError';
23
+ this.width = dimensions.width;
24
+ this.height = dimensions.height;
25
+ this.maxPixels = maxPixels;
26
+ }
27
+ }
1
28
  /**
2
29
  * Screenshot with animations disabled, caret hidden, and any masks applied so
3
30
  * the same UI renders to bit-identical pixels across runs. Masks are Playwright
4
- * locators whose bounding boxes are blacked out before the image is encoded —
5
- * use them to neutralise randomised or time-based regions.
31
+ * locators whose bounding boxes are blacked out before the image is encoded.
32
+ * Use them to neutralise randomised or time-based regions.
6
33
  *
7
34
  * `mode` controls scope: `viewport` (default) captures only the page's current
8
- * viewport box — what the user sees above the fold — while `fullPage`
35
+ * viewport box (what the user sees above the fold) while `fullPage`
9
36
  * composites the entire scrollable document however tall.
37
+ *
38
+ * `fullPageGuard`, when supplied, bounds a `fullPage` capture's area: the page's
39
+ * measured layout dimensions are checked against the cap before the shutter
40
+ * fires, and an over-cap page throws {@link FullPageTooLargeError} rather than
41
+ * compositing an array large enough to risk an OOM. It is ignored for `viewport`
42
+ * captures (already bounded by the breakpoint) and when omitted.
10
43
  */
11
- export async function capturePage(page, masks = [], mode = 'viewport') {
44
+ export async function capturePage(page, masks = [], mode = 'viewport', fullPageGuard) {
12
45
  // `fullPage` composites the whole scrollable document into one image. When
13
46
  // the document is captured at a non-zero offset, `position: sticky` / `fixed`
14
47
  // chrome (sidebars, headers) resolves against that scroll position and lands
15
- // shifted down by `scrollY` in the stitch — the same UI produces a different
48
+ // shifted down by `scrollY` in the stitch. The same UI produces a different
16
49
  // image purely because an earlier step left the viewport scrolled. Resetting
17
50
  // to the origin first pins those elements to their static-baseline position
18
51
  // so the capture is deterministic. `instant` defeats any `scroll-behavior:
@@ -20,15 +53,34 @@ export async function capturePage(page, masks = [], mode = 'viewport') {
20
53
  // timing race.
21
54
  //
22
55
  // A `viewport` shot captures exactly the box the flow scrolled to, so it must
23
- // NOT reset — a story that scrolls to a below-the-fold section (a Settings
56
+ // NOT reset. A story that scrolls to a below-the-fold section (a Settings
24
57
  // panel, a success banner) would otherwise capture the top of the page
25
58
  // instead of the region it navigated to. The flow lands at a deterministic
26
59
  // offset, so the viewport shot is deterministic without any reset.
27
60
  //
28
- // String form so the snippet runs in the page (DOM) context without pulling
29
- // the DOM lib into this Node-side module's type environment.
61
+ // The same page-context call that resets scroll also returns the document's
62
+ // layout dimensions, so the guard measures the true (unclipped) full-page size
63
+ // (Chromium may clip the actual screenshot, but the layout height reflects the
64
+ // real pathology) in one round-trip, with no extra evaluate. String form so
65
+ // the snippet runs in the page (DOM) context without pulling the DOM lib into
66
+ // this Node-side module's type environment.
30
67
  if (mode === 'fullPage') {
31
- await page.evaluate(`window.scrollTo({ top: 0, left: 0, behavior: 'instant' })`);
68
+ const dimensions = (await page.evaluate(`(() => {
69
+ window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
70
+ const el = document.documentElement;
71
+ const body = document.body;
72
+ return {
73
+ width: Math.max(el.scrollWidth, el.clientWidth, body ? body.scrollWidth : 0),
74
+ height: Math.max(el.scrollHeight, el.clientHeight, body ? body.scrollHeight : 0),
75
+ };
76
+ })()`));
77
+ // Guard only when a cap is supplied. The measurement above always runs (it
78
+ // is also the scroll reset), but an unbounded caller (e.g. a test) simply
79
+ // ignores the returned dimensions.
80
+ if (fullPageGuard &&
81
+ dimensions.width * dimensions.height > fullPageGuard.maxPixels) {
82
+ throw new FullPageTooLargeError(fullPageGuard.label, dimensions, fullPageGuard.maxPixels);
83
+ }
32
84
  }
33
85
  return page.screenshot({
34
86
  fullPage: mode === 'fullPage',
@@ -1 +1 @@
1
- {"version":3,"file":"capture.js","sourceRoot":"","sources":["../../src/screenshots/capture.ts"],"names":[],"mappings":"AAGA;;;;;;;;;GASG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,IAAU,EACV,QAAmB,EAAE,EACrB,OAAoB,UAAU;IAE9B,2EAA2E;IAC3E,8EAA8E;IAC9E,6EAA6E;IAC7E,6EAA6E;IAC7E,6EAA6E;IAC7E,4EAA4E;IAC5E,2EAA2E;IAC3E,yEAAyE;IACzE,eAAe;IACf,EAAE;IACF,8EAA8E;IAC9E,2EAA2E;IAC3E,uEAAuE;IACvE,2EAA2E;IAC3E,mEAAmE;IACnE,EAAE;IACF,4EAA4E;IAC5E,6DAA6D;IAC7D,IAAI,IAAI,KAAK,UAAU,EAAE,CAAC;QACxB,MAAM,IAAI,CAAC,QAAQ,CACjB,2DAA2D,CAC5D,CAAC;IACJ,CAAC;IACD,OAAO,IAAI,CAAC,UAAU,CAAC;QACrB,QAAQ,EAAE,IAAI,KAAK,UAAU;QAC7B,UAAU,EAAE,UAAU;QACtB,KAAK,EAAE,MAAM;QACb,IAAI,EAAE,KAAK;KACZ,CAAC,CAAC;AACL,CAAC"}
1
+ {"version":3,"file":"capture.js","sourceRoot":"","sources":["../../src/screenshots/capture.ts"],"names":[],"mappings":"AAaA;;;;;;GAMG;AACH,MAAM,OAAO,qBAAsB,SAAQ,KAAK;IACrC,KAAK,CAAS;IACd,MAAM,CAAS;IACf,SAAS,CAAS;IAC3B,YACE,KAAa,EACb,UAA6C,EAC7C,SAAiB;QAEjB,MAAM,IAAI,GAAG,UAAU,CAAC,KAAK,GAAG,UAAU,CAAC,MAAM,CAAC;QAClD,KAAK,CACH,4BAA4B,KAAK,qBAAqB;YACpD,GAAG,UAAU,CAAC,KAAK,IAAI,UAAU,CAAC,MAAM,SAAS;YACjD,IAAI,IAAI,CAAC,cAAc,CAAC,OAAO,CAAC,0BAA0B;YAC1D,GAAG,SAAS,CAAC,cAAc,CAAC,OAAO,CAAC,iCAAiC;YACrE,uEAAuE;YACvE,wEAAwE;YACxE,wEAAwE;YACxE,SAAS,CACZ,CAAC;QACF,IAAI,CAAC,IAAI,GAAG,uBAAuB,CAAC;QACpC,IAAI,CAAC,KAAK,GAAG,UAAU,CAAC,KAAK,CAAC;QAC9B,IAAI,CAAC,MAAM,GAAG,UAAU,CAAC,MAAM,CAAC;QAChC,IAAI,CAAC,SAAS,GAAG,SAAS,CAAC;IAC7B,CAAC;CACF;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,IAAU,EACV,QAAmB,EAAE,EACrB,OAAoB,UAAU,EAC9B,aAA6B;IAE7B,2EAA2E;IAC3E,8EAA8E;IAC9E,6EAA6E;IAC7E,4EAA4E;IAC5E,6EAA6E;IAC7E,4EAA4E;IAC5E,2EAA2E;IAC3E,yEAAyE;IACzE,eAAe;IACf,EAAE;IACF,8EAA8E;IAC9E,0EAA0E;IAC1E,uEAAuE;IACvE,2EAA2E;IAC3E,mEAAmE;IACnE,EAAE;IACF,4EAA4E;IAC5E,+EAA+E;IAC/E,+EAA+E;IAC/E,4EAA4E;IAC5E,8EAA8E;IAC9E,4CAA4C;IAC5C,IAAI,IAAI,KAAK,UAAU,EAAE,CAAC;QACxB,MAAM,UAAU,GAAG,CAAC,MAAM,IAAI,CAAC,QAAQ,CACrC;;;;;;;;WAQK,CACN,CAAsC,CAAC;QACxC,2EAA2E;QAC3E,0EAA0E;QAC1E,mCAAmC;QACnC,IACE,aAAa;YACb,UAAU,CAAC,KAAK,GAAG,UAAU,CAAC,MAAM,GAAG,aAAa,CAAC,SAAS,EAC9D,CAAC;YACD,MAAM,IAAI,qBAAqB,CAC7B,aAAa,CAAC,KAAK,EACnB,UAAU,EACV,aAAa,CAAC,SAAS,CACxB,CAAC;QACJ,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC,UAAU,CAAC;QACrB,QAAQ,EAAE,IAAI,KAAK,UAAU;QAC7B,UAAU,EAAE,UAAU;QACtB,KAAK,EAAE,MAAM;QACb,IAAI,EAAE,KAAK;KACZ,CAAC,CAAC;AACL,CAAC"}
@@ -1,5 +1,5 @@
1
- export interface DiffOutcome {
2
- diffPng: Buffer;
1
+ import { PNG } from 'pngjs';
2
+ export interface DiffScore {
3
3
  diffPixels: number;
4
4
  totalPixels: number;
5
5
  diffRatio: number;
@@ -13,14 +13,63 @@ export interface DiffOutcome {
13
13
  ssimScore: number;
14
14
  }
15
15
  /**
16
- * Compares two PNG buffers. Computes SSIM (the perceptual gate) and a
17
- * pixelmatch overlay (the visualisation). Both are returned so the
18
- * runner can use SSIM for pass/changed decisions while the reporter
19
- * still has a red-highlight diff image to show the human.
16
+ * The decoded PNG pair, carried out of {@link scoreDiff} so the changed branch
17
+ * can render its overlay from the SAME decode rather than reading the two
18
+ * buffers a second time. Both PNGs are validated (matching dimensions, packed
19
+ * RGBA) by {@link decodePair} before they reach here.
20
+ */
21
+ export interface DecodedPair {
22
+ baselinePng: PNG;
23
+ actualPng: PNG;
24
+ }
25
+ /** {@link scoreDiff}'s result: the reported metrics plus the decode to reuse. */
26
+ export interface ScoredDiff {
27
+ score: DiffScore;
28
+ /**
29
+ * The decoded pair {@link scoreDiff} already produced. On the changed branch
30
+ * the caller threads this straight into {@link renderDiffOverlay}, so the
31
+ * failing comparison decodes the image pair exactly once instead of twice.
32
+ */
33
+ decoded: DecodedPair;
34
+ }
35
+ /**
36
+ * Diffing is split into two phases so the common case stays cheap:
37
+ *
38
+ * 1. {@link scoreDiff}: the gate. Computes SSIM (the pass/changed
39
+ * decision) plus the pixel metrics reported on every outcome
40
+ * (`diffPixels`, `diffRatio`). It runs pixelmatch in count-only mode
41
+ * (no output buffer) and never encodes a diff image. It also returns the
42
+ * {@link DecodedPair} it read, so the changed branch can render its
43
+ * overlay without a second decode.
44
+ * 2. {@link renderDiffOverlay}: the visualisation. Takes that same decoded
45
+ * pair, runs pixelmatch with an output buffer, and deflate-encodes the
46
+ * red-highlight overlay PNG for the human-facing report.
47
+ *
48
+ * The overlay is only kept when a comparison FAILS; a passing comparison
49
+ * deletes it. Because passing is the common case in a healthy suite,
50
+ * encoding the overlay on every comparison was pure waste, so the caller
51
+ * runs {@link scoreDiff} unconditionally and reaches for
52
+ * {@link renderDiffOverlay} only on the changed branch, where the image is
53
+ * actually written. The decode is shared across both, so the changed branch
54
+ * pays for exactly one `PNG.sync.read` pair; the pass branch keeps its
55
+ * no-allocate, no-encode shape (the overlay is never filled on a pass).
56
+ */
57
+ export declare function scoreDiff(baseline: Buffer, actual: Buffer, pixelThreshold: number): ScoredDiff;
58
+ /**
59
+ * Renders the red-highlight overlay PNG for the report from the already-decoded
60
+ * pair {@link scoreDiff} produced. This is the expensive half (pixelmatch
61
+ * fills an output buffer and `PNG.sync.write` deflate-encodes it) so it runs
62
+ * only on the changed/failed branch where the diff image is actually written to
63
+ * disk.
20
64
  *
21
- * Dimension mismatch is a regression on its own — fail loudly.
65
+ * Takes the {@link DecodedPair} rather than raw buffers so it reuses
66
+ * {@link scoreDiff}'s decode: the changed branch reads the image pair once, not
67
+ * twice. The dimensions/packing invariants were already enforced by
68
+ * {@link decodePair} when the pair was decoded, so no re-validation is needed
69
+ * here. pixelmatch's count is output-independent, so filling the overlay here
70
+ * cannot disturb the `diffPixels` {@link scoreDiff} already reported.
22
71
  */
23
- export declare function diffPngs(baseline: Buffer, actual: Buffer, pixelThreshold: number): DiffOutcome;
72
+ export declare function renderDiffOverlay(decoded: DecodedPair, pixelThreshold: number): Buffer;
24
73
  export declare class ScreenshotSizeMismatchError extends Error {
25
74
  readonly baseline: {
26
75
  width: number;
@@ -1 +1 @@
1
- {"version":3,"file":"diff.d.ts","sourceRoot":"","sources":["../../src/screenshots/diff.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;OAMG;IACH,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;GAOG;AACH,wBAAgB,QAAQ,CACtB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,EACd,cAAc,EAAE,MAAM,GACrB,WAAW,CA2Db;AAWD,qBAAa,2BAA4B,SAAQ,KAAK;IACpD,QAAQ,CAAC,QAAQ,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IACrD,QAAQ,CAAC,MAAM,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;gBAEjD,QAAQ,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,EAC3C,MAAM,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE;CAS5C"}
1
+ {"version":3,"file":"diff.d.ts","sourceRoot":"","sources":["../../src/screenshots/diff.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,GAAG,EAAE,MAAM,OAAO,CAAC;AAG5B,MAAM,WAAW,SAAS;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;OAMG;IACH,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC1B,WAAW,EAAE,GAAG,CAAC;IACjB,SAAS,EAAE,GAAG,CAAC;CAChB;AAED,iFAAiF;AACjF,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,SAAS,CAAC;IACjB;;;;OAIG;IACH,OAAO,EAAE,WAAW,CAAC;CACtB;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,SAAS,CACvB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,EACd,cAAc,EAAE,MAAM,GACrB,UAAU,CA6CZ;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,WAAW,EACpB,cAAc,EAAE,MAAM,GACrB,MAAM,CAeR;AAuCD,qBAAa,2BAA4B,SAAQ,KAAK;IACpD,QAAQ,CAAC,QAAQ,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IACrD,QAAQ,CAAC,MAAM,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;gBAEjD,QAAQ,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,EAC3C,MAAM,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE;CAS5C"}