@juangadm/pre-post 0.3.0 → 1.1.0

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 (153) hide show
  1. package/README.md +174 -233
  2. package/dist/baseline.d.ts +119 -0
  3. package/dist/baseline.d.ts.map +1 -0
  4. package/dist/baseline.js +316 -0
  5. package/dist/baseline.js.map +1 -0
  6. package/dist/bin/cli.js +243 -458
  7. package/dist/bin/cli.js.map +1 -1
  8. package/dist/browser.d.ts +68 -25
  9. package/dist/browser.d.ts.map +1 -1
  10. package/dist/browser.js +500 -105
  11. package/dist/browser.js.map +1 -1
  12. package/dist/capture.d.ts +1 -6
  13. package/dist/capture.d.ts.map +1 -1
  14. package/dist/capture.js +12 -36
  15. package/dist/capture.js.map +1 -1
  16. package/dist/commands/compare.d.ts +18 -0
  17. package/dist/commands/compare.d.ts.map +1 -0
  18. package/dist/commands/compare.js +82 -0
  19. package/dist/commands/compare.js.map +1 -0
  20. package/dist/commands/detect.d.ts +25 -0
  21. package/dist/commands/detect.d.ts.map +1 -0
  22. package/dist/commands/detect.js +22 -0
  23. package/dist/commands/detect.js.map +1 -0
  24. package/dist/commands/doctor.d.ts +42 -0
  25. package/dist/commands/doctor.d.ts.map +1 -0
  26. package/dist/commands/doctor.js +119 -0
  27. package/dist/commands/doctor.js.map +1 -0
  28. package/dist/commands/login.d.ts +6 -0
  29. package/dist/commands/login.d.ts.map +1 -0
  30. package/dist/commands/login.js +34 -0
  31. package/dist/commands/login.js.map +1 -0
  32. package/dist/commands/pr.d.ts +32 -0
  33. package/dist/commands/pr.d.ts.map +1 -0
  34. package/dist/commands/pr.js +259 -0
  35. package/dist/commands/pr.js.map +1 -0
  36. package/dist/commands/prune.d.ts +10 -0
  37. package/dist/commands/prune.d.ts.map +1 -0
  38. package/dist/commands/prune.js +13 -0
  39. package/dist/commands/prune.js.map +1 -0
  40. package/dist/comparison.d.ts +104 -0
  41. package/dist/comparison.d.ts.map +1 -0
  42. package/dist/comparison.js +236 -0
  43. package/dist/comparison.js.map +1 -0
  44. package/dist/config.d.ts +46 -0
  45. package/dist/config.d.ts.map +1 -0
  46. package/dist/config.js +82 -0
  47. package/dist/config.js.map +1 -0
  48. package/dist/deployments.d.ts +71 -0
  49. package/dist/deployments.d.ts.map +1 -0
  50. package/dist/deployments.js +204 -0
  51. package/dist/deployments.js.map +1 -0
  52. package/dist/diff-pool.d.ts +17 -0
  53. package/dist/diff-pool.d.ts.map +1 -0
  54. package/dist/diff-pool.js +92 -0
  55. package/dist/diff-pool.js.map +1 -0
  56. package/dist/diff-worker.d.ts +32 -0
  57. package/dist/diff-worker.d.ts.map +1 -0
  58. package/dist/diff-worker.js +39 -0
  59. package/dist/diff-worker.js.map +1 -0
  60. package/dist/diff.d.ts +47 -0
  61. package/dist/diff.d.ts.map +1 -0
  62. package/dist/diff.js +281 -0
  63. package/dist/diff.js.map +1 -0
  64. package/dist/doctor.d.ts +79 -0
  65. package/dist/doctor.d.ts.map +1 -0
  66. package/dist/doctor.js +138 -0
  67. package/dist/doctor.js.map +1 -0
  68. package/dist/errors.d.ts +38 -0
  69. package/dist/errors.d.ts.map +1 -0
  70. package/dist/errors.js +59 -0
  71. package/dist/errors.js.map +1 -0
  72. package/dist/git.d.ts +61 -0
  73. package/dist/git.d.ts.map +1 -0
  74. package/dist/git.js +182 -0
  75. package/dist/git.js.map +1 -0
  76. package/dist/github.d.ts +163 -0
  77. package/dist/github.d.ts.map +1 -0
  78. package/dist/github.js +411 -0
  79. package/dist/github.js.map +1 -0
  80. package/dist/index.d.ts +22 -23
  81. package/dist/index.d.ts.map +1 -1
  82. package/dist/index.js +17 -67
  83. package/dist/index.js.map +1 -1
  84. package/dist/landing.d.ts +48 -0
  85. package/dist/landing.d.ts.map +1 -0
  86. package/dist/landing.js +93 -0
  87. package/dist/landing.js.map +1 -0
  88. package/dist/pkg.d.ts +17 -0
  89. package/dist/pkg.d.ts.map +1 -0
  90. package/dist/pkg.js +49 -0
  91. package/dist/pkg.js.map +1 -0
  92. package/dist/report.d.ts +20 -0
  93. package/dist/report.d.ts.map +1 -0
  94. package/dist/report.js +130 -0
  95. package/dist/report.js.map +1 -0
  96. package/dist/routes/imports.d.ts +46 -0
  97. package/dist/routes/imports.d.ts.map +1 -0
  98. package/dist/routes/imports.js +208 -0
  99. package/dist/routes/imports.js.map +1 -0
  100. package/dist/routes/vite.d.ts +15 -0
  101. package/dist/routes/vite.d.ts.map +1 -0
  102. package/dist/routes/vite.js +130 -0
  103. package/dist/routes/vite.js.map +1 -0
  104. package/dist/routes.d.ts +65 -11
  105. package/dist/routes.d.ts.map +1 -1
  106. package/dist/routes.js +346 -141
  107. package/dist/routes.js.map +1 -1
  108. package/dist/run.d.ts +116 -0
  109. package/dist/run.d.ts.map +1 -0
  110. package/dist/run.js +316 -0
  111. package/dist/run.js.map +1 -0
  112. package/dist/sameness.d.ts +91 -0
  113. package/dist/sameness.d.ts.map +1 -0
  114. package/dist/sameness.js +157 -0
  115. package/dist/sameness.js.map +1 -0
  116. package/dist/sessions.d.ts +50 -0
  117. package/dist/sessions.d.ts.map +1 -0
  118. package/dist/sessions.js +80 -0
  119. package/dist/sessions.js.map +1 -0
  120. package/dist/shift.d.ts +67 -0
  121. package/dist/shift.d.ts.map +1 -0
  122. package/dist/shift.js +184 -0
  123. package/dist/shift.js.map +1 -0
  124. package/dist/types.d.ts +179 -68
  125. package/dist/types.d.ts.map +1 -1
  126. package/dist/types.js +1 -0
  127. package/dist/types.js.map +1 -1
  128. package/dist/url.d.ts +12 -0
  129. package/dist/url.d.ts.map +1 -0
  130. package/dist/url.js +30 -0
  131. package/dist/url.js.map +1 -0
  132. package/dist/viewport.d.ts +6 -1
  133. package/dist/viewport.d.ts.map +1 -1
  134. package/dist/viewport.js +13 -7
  135. package/dist/viewport.js.map +1 -1
  136. package/package.json +21 -14
  137. package/skill/SKILL.md +56 -221
  138. package/dist/clipboard.d.ts +0 -9
  139. package/dist/clipboard.d.ts.map +0 -1
  140. package/dist/clipboard.js +0 -26
  141. package/dist/clipboard.js.map +0 -1
  142. package/dist/upload.d.ts +0 -39
  143. package/dist/upload.d.ts.map +0 -1
  144. package/dist/upload.js +0 -159
  145. package/dist/upload.js.map +0 -1
  146. package/dist/video.d.ts +0 -19
  147. package/dist/video.d.ts.map +0 -1
  148. package/dist/video.js +0 -263
  149. package/dist/video.js.map +0 -1
  150. package/skill/scripts/adapters/0x0st.sh +0 -36
  151. package/skill/scripts/adapters/blob.sh +0 -55
  152. package/skill/scripts/adapters/git-native.sh +0 -51
  153. package/skill/scripts/upload-and-copy.sh +0 -230
package/dist/run.d.ts ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The capture → diff pipeline shared by `pr` and the two-URL mode.
3
+ * Every route/viewport pair runs concurrently; the browser's page pool bounds it.
4
+ */
5
+ import { AuthOptions, RouteCaptureOutcome, ViewportSize } from './types.js';
6
+ export interface CaptureTask {
7
+ route: string;
8
+ resolvedRoute: string;
9
+ viewport: string;
10
+ size: ViewportSize;
11
+ beforeUrl: string;
12
+ afterUrl: string;
13
+ }
14
+ /** Where the two sides came from, so a verdict about them can say which is which. */
15
+ export interface RunSides {
16
+ before: {
17
+ url: string;
18
+ detail?: string;
19
+ };
20
+ after: {
21
+ url: string;
22
+ detail?: string;
23
+ };
24
+ /**
25
+ * How this caller's user would fix a wrong pairing. Only the command knows:
26
+ * `pr` chose the baseline and can offer `--before`, while the two-URL mode
27
+ * was handed both sides as positional arguments, where that flag means
28
+ * nothing.
29
+ */
30
+ fix?: string;
31
+ }
32
+ /**
33
+ * Why this run cannot be reported as a comparison.
34
+ *
35
+ * Two ways to reach the same conclusion, kept apart because they need
36
+ * different advice: a wall wants credentials, a wrong baseline wants a URL.
37
+ */
38
+ export interface RunVerdict {
39
+ kind: 'walled' | 'different-sites';
40
+ /** The single actionable sentence a human needs. */
41
+ hint: string;
42
+ }
43
+ export interface RunResult {
44
+ outcomes: RouteCaptureOutcome[];
45
+ /** Non-null when the run did not compare what it claims to have compared. */
46
+ verdict: RunVerdict | null;
47
+ }
48
+ export interface PipelineOptions {
49
+ outputDir: string;
50
+ fullPage: boolean;
51
+ maxHeight: number;
52
+ scale: number;
53
+ threshold: number;
54
+ minChangedArea: number;
55
+ wait?: number;
56
+ auth?: AuthOptions;
57
+ /**
58
+ * How resolution chose the two sides. Optional: without it a verdict still
59
+ * fires and names the URLs the captures actually used, which is enough to
60
+ * act on — it just cannot say *why* that baseline was picked.
61
+ */
62
+ sides?: RunSides;
63
+ log?: (msg: string) => void;
64
+ }
65
+ export declare function routeSlug(route: string): string;
66
+ /**
67
+ * Did this capture change?
68
+ *
69
+ * Two arms, because they cover different regimes and neither covers both:
70
+ *
71
+ * - Painted area, in CSS pixels² — the rule for pages. Measured against the
72
+ * fixture ladder, every deliberate change covers 242 CSS px² or more while
73
+ * every no-op covers exactly 0, so the floor sits in a wide empty band. CSS
74
+ * pixels rather than device pixels so it means the same at --scale 1 as at
75
+ * --scale 2; the old device-pixel constant was silently four times stricter
76
+ * at scale 1, and at ~10 CSS px² amounted to a third of one letter.
77
+ * - Share of the canvas — the rule for small images. One pixel of a 20x20 icon
78
+ * is a quarter of a percent of it and obviously a change, but it will never
79
+ * reach an area floor sized for pages.
80
+ *
81
+ * On a full page the ratio arm effectively never fires on its own (a recoloured
82
+ * 16px icon is 0.024% of one), and on a small image the area arm never does.
83
+ * Dropping either one loses real changes, which is the worse error: reporting
84
+ * "no visual changes" on a PR that changed something is the one answer that
85
+ * makes a reviewer stop looking.
86
+ */
87
+ export declare function isChanged(diff: {
88
+ changedRatio: number;
89
+ changedPixels: number;
90
+ }, opts: {
91
+ minChangedArea: number;
92
+ threshold: number;
93
+ scale: number;
94
+ }): boolean;
95
+ /** "shifted down 48px" — the direction spelled out, because the sign is not. */
96
+ export declare function describeShift(px: number): string;
97
+ /**
98
+ * Did the baseline answer unrelated routes with the same page?
99
+ *
100
+ * This is the soft 404 — a not-found view served with status 200, which no
101
+ * status check can see and which a keyword hunt for "404" would catch only in
102
+ * English, only when the page says it, and only when no real page happens to
103
+ * mention it. The durable signal is not the wording but the sameness: a host
104
+ * that returns pixel-identical bytes for several different routes is serving a
105
+ * catch-all, and every comparison against it is measuring the distance from
106
+ * that catch-all rather than from the page.
107
+ *
108
+ * It warns rather than reclassifies. Two routes can legitimately render the
109
+ * same page, and silently turning a real diff into "new page" on a heuristic
110
+ * would hide exactly the change this tool exists to show. The authoritative
111
+ * signal (a 404 status) acts; this one only tells the reader what it sees.
112
+ */
113
+ export declare function softNotFoundWarning(outcomes: RouteCaptureOutcome[], beforeUrl: string): string | null;
114
+ export declare function verdictFor(outcomes: RouteCaptureOutcome[], sides: RunSides): RunVerdict | null;
115
+ export declare function runTasks(tasks: CaptureTask[], opts: PipelineOptions): Promise<RunResult>;
116
+ //# sourceMappingURL=run.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../src/run.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAKH,OAAO,EAAE,WAAW,EAA8B,mBAAmB,EAAc,YAAY,EAAE,MAAM,YAAY,CAAC;AASpH,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,YAAY,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,qFAAqF;AACrF,MAAM,WAAW,QAAQ;IACvB,MAAM,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACzC,KAAK,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACxC;;;;;OAKG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,QAAQ,GAAG,iBAAiB,CAAC;IACnC,oDAAoD;IACpD,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,SAAS;IACxB,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC,6EAA6E;IAC7E,OAAO,EAAE,UAAU,GAAG,IAAI,CAAC;CAC5B;AAED,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,OAAO,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,WAAW,CAAC;IACnB;;;;OAIG;IACH,KAAK,CAAC,EAAE,QAAQ,CAAC;IACjB,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CAC7B;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAG/C;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,SAAS,CACvB,IAAI,EAAE;IAAE,YAAY,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAA;CAAE,EACrD,IAAI,EAAE;IAAE,cAAc,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GACjE,OAAO,CAIT;AAED,gFAAgF;AAChF,wBAAgB,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAGhD;AA6MD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,mBAAmB,EAAE,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAiBrG;AAED,wBAAgB,UAAU,CAAC,QAAQ,EAAE,mBAAmB,EAAE,EAAE,KAAK,EAAE,QAAQ,GAAG,UAAU,GAAG,IAAI,CAY9F;AAED,wBAAsB,QAAQ,CAAC,KAAK,EAAE,WAAW,EAAE,EAAE,IAAI,EAAE,eAAe,GAAG,OAAO,CAAC,SAAS,CAAC,CAkB9F"}
package/dist/run.js ADDED
@@ -0,0 +1,316 @@
1
+ /**
2
+ * The capture → diff pipeline shared by `pr` and the two-URL mode.
3
+ * Every route/viewport pair runs concurrently; the browser's page pool bounds it.
4
+ */
5
+ import fs from 'fs';
6
+ import { createHash } from 'crypto';
7
+ import path from 'path';
8
+ import { captureScreenshot } from './browser.js';
9
+ import { checkLanding, signInHint } from './landing.js';
10
+ import { differentSitesHint, looksLikeDifferentSites, textOverlap, titleOverlap } from './sameness.js';
11
+ import { HttpStatusError, NavigationError } from './errors.js';
12
+ import { DiffPool } from './diff-pool.js';
13
+ import { authHint } from './doctor.js';
14
+ import { hostOf } from './url.js';
15
+ export function routeSlug(route) {
16
+ const slug = route.replace(/^\/+|\/+$/g, '').replace(/[^\w.-]+/g, '-').replace(/^-+|-+$/g, '');
17
+ return slug || 'home';
18
+ }
19
+ /**
20
+ * Did this capture change?
21
+ *
22
+ * Two arms, because they cover different regimes and neither covers both:
23
+ *
24
+ * - Painted area, in CSS pixels² — the rule for pages. Measured against the
25
+ * fixture ladder, every deliberate change covers 242 CSS px² or more while
26
+ * every no-op covers exactly 0, so the floor sits in a wide empty band. CSS
27
+ * pixels rather than device pixels so it means the same at --scale 1 as at
28
+ * --scale 2; the old device-pixel constant was silently four times stricter
29
+ * at scale 1, and at ~10 CSS px² amounted to a third of one letter.
30
+ * - Share of the canvas — the rule for small images. One pixel of a 20x20 icon
31
+ * is a quarter of a percent of it and obviously a change, but it will never
32
+ * reach an area floor sized for pages.
33
+ *
34
+ * On a full page the ratio arm effectively never fires on its own (a recoloured
35
+ * 16px icon is 0.024% of one), and on a small image the area arm never does.
36
+ * Dropping either one loses real changes, which is the worse error: reporting
37
+ * "no visual changes" on a PR that changed something is the one answer that
38
+ * makes a reviewer stop looking.
39
+ */
40
+ export function isChanged(diff, opts) {
41
+ if (diff.changedPixels === 0)
42
+ return false;
43
+ return diff.changedPixels / (opts.scale * opts.scale) >= opts.minChangedArea
44
+ || diff.changedRatio >= opts.threshold;
45
+ }
46
+ /** "shifted down 48px" — the direction spelled out, because the sign is not. */
47
+ export function describeShift(px) {
48
+ const rounded = Math.round(Math.abs(px));
49
+ return `shifted ${px < 0 ? 'up' : 'down'} ${rounded}px`;
50
+ }
51
+ function describeError(err, side, url) {
52
+ if (err instanceof HttpStatusError)
53
+ return authHint(err);
54
+ if (err instanceof NavigationError) {
55
+ switch (err.kind) {
56
+ case 'refused': return `connection refused at ${url} (is the ${side === 'after' ? 'dev server' : 'site'} running?)`;
57
+ case 'timeout': return `timed out loading ${url}`;
58
+ case 'dns': return `cannot resolve ${hostOf(url)}`;
59
+ default: return `${side}: ${err.message.slice(0, 160)}`;
60
+ }
61
+ }
62
+ const msg = err?.message || String(err);
63
+ return `${side}: ${msg.split('\n')[0].slice(0, 160)}`;
64
+ }
65
+ /** Did this side reach the site, or a wall standing in front of it? */
66
+ function blockedSide(requested, result, side) {
67
+ const landing = checkLanding(requested, result.finalUrl ?? requested, result.title ?? '');
68
+ return landing.blocked ? { side, finalUrl: landing.finalUrl, vercel: Boolean(result.vercel) } : null;
69
+ }
70
+ async function runTask(task, opts, pool) {
71
+ const started = Date.now();
72
+ const base = { route: task.route, resolvedRoute: task.resolvedRoute, viewport: task.viewport, status: 'error' };
73
+ const captureOpts = {
74
+ viewport: task.size,
75
+ fullPage: opts.fullPage,
76
+ maxHeight: opts.maxHeight,
77
+ scale: opts.scale,
78
+ wait: opts.wait,
79
+ auth: opts.auth,
80
+ };
81
+ const [beforeRes, afterRes] = await Promise.allSettled([
82
+ captureScreenshot(task.beforeUrl, captureOpts),
83
+ captureScreenshot(task.afterUrl, captureOpts),
84
+ ]);
85
+ if (beforeRes.status === 'rejected' || afterRes.status === 'rejected') {
86
+ const errors = [];
87
+ if (beforeRes.status === 'rejected')
88
+ errors.push(describeError(beforeRes.reason, 'before', task.beforeUrl));
89
+ if (afterRes.status === 'rejected')
90
+ errors.push(describeError(afterRes.reason, 'after', task.afterUrl));
91
+ return { ...base, error: errors.join('; '), durationMs: Date.now() - started };
92
+ }
93
+ const before = beforeRes.value;
94
+ const after = afterRes.value;
95
+ // A sign-in wall answers with 200, so both captures "succeeded" — of a login
96
+ // page. Diffing those and calling the result a visual change is the failure
97
+ // this check exists to prevent, so nothing is compared when either side is
98
+ // walled.
99
+ const blocked = blockedSide(task.beforeUrl, before, 'before') ?? blockedSide(task.afterUrl, after, 'after');
100
+ if (blocked) {
101
+ const label = blocked.side === 'before' ? 'Pre' : 'Post';
102
+ return {
103
+ ...base,
104
+ blocked,
105
+ error: `${label} landed on a sign-in page (${blocked.finalUrl}), so the site was never captured`,
106
+ durationMs: Date.now() - started,
107
+ };
108
+ }
109
+ if (before.status === 404 && after.status === 404) {
110
+ return { ...base, error: `404 on both ${task.beforeUrl} and ${task.afterUrl} (wrong route or missing sample?)`, durationMs: Date.now() - started };
111
+ }
112
+ const prefix = `${routeSlug(task.route)}-${task.viewport}`;
113
+ const file = (kind) => path.join(opts.outputDir, `${prefix}-${kind}.png`);
114
+ const outputs = { before: file('pre'), after: file('post'), diff: file('diff'), cropBefore: file('pre-crop'), cropAfter: file('post-crop') };
115
+ // A route that exists on only one side is not a comparison, and the moment to
116
+ // notice is here — before the diff, not after it in a footnote.
117
+ //
118
+ // The old path diffed anyway. For a page this branch adds, "Pre" was a
119
+ // screenshot of the baseline's 404 and the percentage measured the distance
120
+ // between an error page and a new one: 10.81% for a coloured box on white,
121
+ // 0.05% for a light-grey one, neither a fact about the change. Worse, both
122
+ // numbers were published as a Pre/Post pair, so a reviewer was shown a
123
+ // comparison that never existed and four images where one was true.
124
+ //
125
+ // Nothing can be recovered by trying harder — a page the baseline does not
126
+ // serve has no "before" to find — so the honest move is to keep the side that
127
+ // rendered, name why the other is missing, and skip the diff entirely.
128
+ const absent = absentSide(before, after);
129
+ if (absent) {
130
+ const present = absent === 'before' ? after : before;
131
+ const target = absent === 'before' ? outputs.after : outputs.before;
132
+ fs.writeFileSync(target, Buffer.from(present.image));
133
+ const status = absent === 'before' ? 'added' : 'removed';
134
+ const missingHost = hostOf(absent === 'before' ? task.beforeUrl : task.afterUrl);
135
+ opts.log?.(` ${status === 'added' ? 'new ' : 'removed '} ${task.route} @ ${task.viewport} (no ${absent === 'before' ? 'Pre' : 'Post'}, ${Date.now() - started}ms)`);
136
+ return {
137
+ ...base,
138
+ status,
139
+ files: absent === 'before' ? { after: outputs.after } : { before: outputs.before },
140
+ // Deliberately no changedRatio: there is nothing to take a ratio of.
141
+ // Deliberately no textOverlap either — a 404's wording is evidence about
142
+ // an error page, not about whether the two sides are the same site.
143
+ note: absent === 'before'
144
+ ? `new page — no baseline (${missingHost} returned 404)`
145
+ : `page removed — ${missingHost} returned 404`,
146
+ durationMs: Date.now() - started,
147
+ };
148
+ }
149
+ // Copy into standalone buffers so they can be transferred to the worker.
150
+ const diff = await pool.run({
151
+ before: new Uint8Array(before.image),
152
+ after: new Uint8Array(after.image),
153
+ outputs,
154
+ options: {
155
+ padding: 40 * opts.scale,
156
+ minCrop: { width: 400 * opts.scale, height: 200 * opts.scale },
157
+ highlightDownscale: opts.scale >= 2 ? 2 : 1,
158
+ highlight: false,
159
+ },
160
+ });
161
+ const notes = [];
162
+ // The 404-on-one-side cases returned above; what is left is a page both sides
163
+ // served with an unhappy status, which is still worth diffing and flagging.
164
+ if (before.status && before.status >= 400)
165
+ notes.push(`production returned ${before.status}`);
166
+ if (after.status && after.status >= 400)
167
+ notes.push(`local returned ${after.status}`);
168
+ const changed = isChanged(diff, opts);
169
+ // Cheap fingerprint of the baseline, kept so the run can notice a host that
170
+ // answers every route with the same page. See softNotFoundWarning.
171
+ const baselineHash = createHash('sha1').update(Buffer.from(before.image)).digest('hex');
172
+ // Recorded on every outcome, judged only across the whole run: one route
173
+ // sharing no words is a rewrite, every route sharing none is the wrong site.
174
+ const overlap = textOverlap(before.text ?? '', after.text ?? '');
175
+ const titles = titleOverlap(before.title ?? '', after.title ?? '');
176
+ // A shift is measured in device pixels; everything a reader sees is in CSS
177
+ // pixels. Whether anything changed besides the move is judged by the same
178
+ // rule as any other capture, against the aligned numbers.
179
+ const shift = diff.shift
180
+ ? {
181
+ px: diff.shift.dy / opts.scale,
182
+ otherChange: isChanged({ changedPixels: diff.shift.alignedChangedPixels, changedRatio: diff.shift.alignedChangedRatio }, opts),
183
+ }
184
+ : undefined;
185
+ const shiftNote = shift ? `${describeShift(shift.px)}, ` : '';
186
+ opts.log?.(` ${changed ? 'changed ' : 'same '} ${task.route} @ ${task.viewport} (${shiftNote}${(diff.changedRatio * 100).toFixed(2)}%, ${Date.now() - started}ms)`);
187
+ return {
188
+ ...base,
189
+ status: changed ? 'changed' : 'unchanged',
190
+ baselineHash,
191
+ changedRatio: diff.changedRatio,
192
+ sizeChanged: diff.sizeChanged,
193
+ textOverlap: overlap,
194
+ titleOverlap: titles,
195
+ shift,
196
+ files: {
197
+ before: outputs.before,
198
+ after: outputs.after,
199
+ diff: diff.hasHighlight ? outputs.diff : undefined,
200
+ cropBefore: diff.hasCrop ? outputs.cropBefore : undefined,
201
+ cropAfter: diff.hasCrop ? outputs.cropAfter : undefined,
202
+ },
203
+ note: notes.join('; ') || undefined,
204
+ durationMs: Date.now() - started,
205
+ };
206
+ }
207
+ /**
208
+ * Did this run compare the two sites, or something standing in front of them?
209
+ *
210
+ * This lives beside the code that produces the evidence, not in the command
211
+ * that happens to want it. Both checks used to be written out in `pr`, and
212
+ * `compare` — the other caller of this pipeline — therefore had neither, and
213
+ * would print a confident percentage for two unrelated sites. Returning the
214
+ * verdict with the outcomes makes that omission impossible to repeat: a caller
215
+ * cannot get the numbers without also being handed the reason not to trust
216
+ * them.
217
+ *
218
+ * Order matters. A wall is the more specific diagnosis and has the more
219
+ * actionable fix, and a walled run also looks like two different sites — the
220
+ * sign-in page shares no words with the site — so it is answered first.
221
+ */
222
+ /**
223
+ * Which side, if either, does not have this page at all.
224
+ *
225
+ * The signal is the HTTP status, not the page's wording. A 404 status is a fact
226
+ * the server states about the route; the string "404" in the body is just text,
227
+ * and it is present on pages that are merely *about* error codes and absent
228
+ * from every custom or localised not-found page ever shipped. Sniffing for it
229
+ * would mean this tool's own marketing page — which shows a 404 in a mockup —
230
+ * could be mistaken for a missing route.
231
+ *
232
+ * Soft 404s (a not-found view served with status 200, the usual SPA shape) are
233
+ * therefore invisible here by design; they are caught at the run level instead,
234
+ * where identical baselines across unrelated routes give them away.
235
+ */
236
+ function absentSide(before, after) {
237
+ const gone = (c) => c.status === 404;
238
+ const served = (c) => !c.status || c.status < 400;
239
+ if (gone(before) && served(after))
240
+ return 'before';
241
+ if (gone(after) && served(before))
242
+ return 'after';
243
+ return null;
244
+ }
245
+ /**
246
+ * Did the baseline answer unrelated routes with the same page?
247
+ *
248
+ * This is the soft 404 — a not-found view served with status 200, which no
249
+ * status check can see and which a keyword hunt for "404" would catch only in
250
+ * English, only when the page says it, and only when no real page happens to
251
+ * mention it. The durable signal is not the wording but the sameness: a host
252
+ * that returns pixel-identical bytes for several different routes is serving a
253
+ * catch-all, and every comparison against it is measuring the distance from
254
+ * that catch-all rather than from the page.
255
+ *
256
+ * It warns rather than reclassifies. Two routes can legitimately render the
257
+ * same page, and silently turning a real diff into "new page" on a heuristic
258
+ * would hide exactly the change this tool exists to show. The authoritative
259
+ * signal (a 404 status) acts; this one only tells the reader what it sees.
260
+ */
261
+ export function softNotFoundWarning(outcomes, beforeUrl) {
262
+ const routesByHash = new Map();
263
+ for (const o of outcomes) {
264
+ if (!o.baselineHash)
265
+ continue;
266
+ const seen = routesByHash.get(o.baselineHash) ?? new Set();
267
+ seen.add(o.route);
268
+ routesByHash.set(o.baselineHash, seen);
269
+ }
270
+ let worst = null;
271
+ for (const routes of routesByHash.values()) {
272
+ if (routes.size > 1 && (!worst || routes.size > worst.size))
273
+ worst = routes;
274
+ }
275
+ if (!worst)
276
+ return null;
277
+ const shown = [...worst].sort().slice(0, 4).join(', ');
278
+ const more = worst.size > 4 ? `, and ${worst.size - 4} more` : '';
279
+ return `Note: ${hostOf(beforeUrl)} served an identical page for ${worst.size} routes (${shown}${more}). `
280
+ + 'If that is a catch-all or a not-found page, those comparisons are against it, not against the real baseline.';
281
+ }
282
+ export function verdictFor(outcomes, sides) {
283
+ // Every route walled means the run never saw the site. Publishing anything
284
+ // from that, "no visual changes" included, would be a confident lie.
285
+ const walled = outcomes.filter(o => o.blocked);
286
+ if (walled.length > 0 && walled.length === outcomes.length) {
287
+ const { side, vercel } = walled[0].blocked;
288
+ return { kind: 'walled', hint: signInHint(side === 'before' ? sides.before.url : sides.after.url, vercel) };
289
+ }
290
+ if (looksLikeDifferentSites(outcomes)) {
291
+ return { kind: 'different-sites', hint: differentSitesHint(sides.before.url, sides.before.detail, sides.after.url, sides.fix) };
292
+ }
293
+ return null;
294
+ }
295
+ export async function runTasks(tasks, opts) {
296
+ fs.mkdirSync(opts.outputDir, { recursive: true });
297
+ const pool = new DiffPool();
298
+ let outcomes;
299
+ try {
300
+ outcomes = await Promise.all(tasks.map(t => runTask(t, opts, pool)));
301
+ }
302
+ finally {
303
+ await pool.close();
304
+ }
305
+ // Falling back to the first task's URLs keeps the verdict available to a
306
+ // caller that never resolved a pair — `compare`, given two URLs by hand.
307
+ const sides = opts.sides ?? {
308
+ before: { url: tasks[0]?.beforeUrl ?? '' },
309
+ after: { url: tasks[0]?.afterUrl ?? '' },
310
+ };
311
+ const softNotFound = softNotFoundWarning(outcomes, sides.before.url);
312
+ if (softNotFound)
313
+ opts.log?.(softNotFound);
314
+ return { outcomes, verdict: verdictFor(outcomes, sides) };
315
+ }
316
+ //# sourceMappingURL=run.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"run.js","sourceRoot":"","sources":["../src/run.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Are the two sides the same site at all?
3
+ *
4
+ * The backstop this file exists to be was first specified as "a diff near 100%
5
+ * on every route means the two sides are different sites". Measured, that rule
6
+ * is wrong in both directions, and it is worth writing down why so it is not
7
+ * proposed again:
8
+ *
9
+ * | pair | changed |
10
+ * |-------------------------------------------------|---------|
11
+ * | the marketing site vs an unrelated real site | 2.66% desktop / 5.02% mobile |
12
+ * | fixture pricing page vs an unrelated fixture | 0.72% – 7.99% |
13
+ * | the same site with a full theme change (fixture) | 99.14% |
14
+ * | the same site redesigned (fixture rung-7) | 99.60% |
15
+ *
16
+ * Web pages are mostly background, and a pixel diff counts pixels that differ,
17
+ * so two *unrelated* pages agree on most of the canvas and read as a small
18
+ * change — the same 1-8% band as the login-page run that started all of this
19
+ * (4.53% / 1.99%). Meanwhile a legitimate redesign, the PR most worth
20
+ * screenshotting, reads ~100%. A threshold at 100% would have missed every
21
+ * real occurrence and fired on the best ones.
22
+ *
23
+ * What does separate them is the words on the page. A redesign changes how a
24
+ * page looks, not what it says; a different site says something else entirely.
25
+ * Measured over the same pairs, with common English words removed:
26
+ *
27
+ * different sites 0.000, 0.000, 0.000, 0.071
28
+ * same site 0.200 (the weakest fixture), 0.417+, 1.000 for real pages
29
+ *
30
+ * The floor below sits in that empty band.
31
+ */
32
+ /** The distinctive words a page shows a reader. */
33
+ export declare function pageWords(text: string): Set<string>;
34
+ /**
35
+ * A page with almost nothing to say cannot answer this question: an image or a
36
+ * canvas with six words on it scores near zero against anything, including
37
+ * itself one version later. Below this many distinct words, the answer is "do
38
+ * not know" rather than "different".
39
+ */
40
+ export declare const MIN_WORDS = 10;
41
+ /** The floor, from the measurements above: 0.071 was different, 0.200 was not. */
42
+ export declare const SAME_SITE_FLOOR = 0.1;
43
+ /** Overlap of two pages' body text, or null when either is too text-poor to judge. */
44
+ export declare function textOverlap(before: string, after: string): number | null;
45
+ /**
46
+ * Overlap of two pages' titles, or null when either says nothing distinctive.
47
+ *
48
+ * No `MIN_WORDS` here: a title is a handful of words by nature, and it is the
49
+ * one place a site almost always names itself. That makes it the corroborating
50
+ * signal for a run with only one route to go on — a branch that rewrites a
51
+ * page's copy keeps the site's name in the tab, a different site does not.
52
+ */
53
+ export declare function titleOverlap(before: string, after: string): number | null;
54
+ /** What this needs from each capture: which page it was, and how the two sides compared. */
55
+ export interface RouteEvidence {
56
+ route?: string;
57
+ textOverlap?: number | null;
58
+ titleOverlap?: number | null;
59
+ }
60
+ /**
61
+ * Do the captures, taken together, say these are two different sites?
62
+ *
63
+ * Every judgeable page has to disagree, and the evidence has to be about
64
+ * *pages*, not captures. One route shot at three viewports is one page's word
65
+ * list three times over; counting it as three agreeing witnesses is how a run
66
+ * that detected a single route — an explicit `--routes /`, or the `/` fallback
67
+ * when the diff names none — could reject the one thing this function's own
68
+ * rule permits: a branch that rewrote a page.
69
+ *
70
+ * So captures are folded per route, keeping each route's *strongest* showing of
71
+ * shared words, and a run with only one page to go on asks the title as well. A
72
+ * title is where a site names itself, so a rewritten page keeps it and a
73
+ * different site does not. Without a usable title there is no second witness
74
+ * and the run is published, which is the right way to be wrong here: publishing
75
+ * a diff someone can look at beats refusing to on one page's say-so.
76
+ */
77
+ export declare function looksLikeDifferentSites(routes: RouteEvidence[]): boolean;
78
+ /**
79
+ * The one sentence a human needs when the two sides are not the same site.
80
+ *
81
+ * `beforeDetail` is how Pre was chosen ("production deployment for 26aa6cd"),
82
+ * absent when a caller was handed both URLs by hand and has no provenance to
83
+ * report. `fix` is the remedy, which is the caller's to name: telling someone
84
+ * to pass `--before` is right when the tool picked the baseline and wrong in
85
+ * the two-URL mode, where both sides came in as positional arguments and that
86
+ * flag is not how the command is invoked. Advice that does not apply to the
87
+ * mode you are in is the failure this project keeps finding — it is what sent
88
+ * someone to sign in to a macOS system service.
89
+ */
90
+ export declare function differentSitesHint(beforeUrl: string, beforeDetail: string | undefined, afterUrl: string, fix?: string): string;
91
+ //# sourceMappingURL=sameness.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sameness.d.ts","sourceRoot":"","sources":["../src/sameness.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAeH,mDAAmD;AACnD,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAGnD;AAED;;;;;GAKG;AACH,eAAO,MAAM,SAAS,KAAK,CAAC;AAE5B,kFAAkF;AAClF,eAAO,MAAM,eAAe,MAAM,CAAC;AAgBnC,sFAAsF;AACtF,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAKxE;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAKzE;AAED,4FAA4F;AAC5F,MAAM,WAAW,aAAa;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,aAAa,EAAE,GAAG,OAAO,CAkBxE;AAYD;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,MAAM,GAAG,SAAS,EAChC,QAAQ,EAAE,MAAM,EAChB,GAAG,GAAE,MAAoC,GACxC,MAAM,CAGR"}