styleproof 3.1.1 → 3.1.3
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.
- package/CHANGELOG.md +83 -1
- package/README.md +104 -70
- package/bin/styleproof-diff.mjs +46 -32
- package/bin/styleproof-init.mjs +78 -153
- package/bin/styleproof-map.mjs +139 -12
- package/bin/styleproof-report.mjs +41 -23
- package/dist/capture.d.ts +6 -5
- package/dist/capture.js +9 -3
- package/dist/cli-errors.d.ts +0 -1
- package/dist/cli-errors.js +2 -8
- package/dist/diff.js +6 -5
- package/dist/gitref.d.ts +2 -12
- package/dist/gitref.js +2 -42
- package/dist/index.d.ts +1 -1
- package/dist/map-store.d.ts +85 -0
- package/dist/map-store.js +342 -0
- package/dist/report.d.ts +6 -0
- package/dist/report.js +79 -9
- package/dist/runner.d.ts +31 -5
- package/dist/runner.js +189 -6
- package/package.json +5 -3
- package/dist/cli-base-ref.d.ts +0 -15
- package/dist/cli-base-ref.js +0 -53
package/dist/capture.d.ts
CHANGED
|
@@ -58,7 +58,7 @@ export type ElementEntry = {
|
|
|
58
58
|
export type CaptureMetadata = {
|
|
59
59
|
surfaceKey?: string;
|
|
60
60
|
variantKey?: string;
|
|
61
|
-
variantKind?: 'variant' | 'live-state';
|
|
61
|
+
variantKind?: 'variant' | 'live-state' | 'popup';
|
|
62
62
|
};
|
|
63
63
|
export type LiveRegionCandidate = {
|
|
64
64
|
path: string;
|
|
@@ -76,10 +76,11 @@ export type StyleMap = {
|
|
|
76
76
|
elements: Record<string, ElementEntry>;
|
|
77
77
|
states: Record<string, Record<string, Record<string, Props>>>;
|
|
78
78
|
/**
|
|
79
|
-
* True when the forced :hover/:focus/:active layer was
|
|
80
|
-
* capture
|
|
81
|
-
*
|
|
82
|
-
*
|
|
79
|
+
* True when the forced :hover/:focus/:active layer was not fully captured for
|
|
80
|
+
* this capture — either CDP/page interactive-element count skew, or because the
|
|
81
|
+
* interactive-element count exceeded `maxInteractive` and capture was truncated.
|
|
82
|
+
* Persisted so a diff against a fully-captured side flags that the state layer
|
|
83
|
+
* wasn't certified, instead of silently reading as "identical".
|
|
83
84
|
*/
|
|
84
85
|
statesSkipped?: boolean;
|
|
85
86
|
/**
|
package/dist/capture.js
CHANGED
|
@@ -375,7 +375,8 @@ async function captureForcedStates(page, ignore, maxInteractive, skipPaths = [])
|
|
|
375
375
|
const skipSel = ignore.length ? ignore.map((s) => `${s}, ${s} *`).join(', ') : '';
|
|
376
376
|
const marked = (await page.evaluate(markInteractiveElements, { selector: INTERACTIVE, skipSel, attr: STATE_ID_ATTR })).filter((m) => !(skipPaths.length && isUnder(m.path, skipPaths)));
|
|
377
377
|
const limit = Math.min(marked.length, maxInteractive);
|
|
378
|
-
|
|
378
|
+
const truncated = marked.length > maxInteractive;
|
|
379
|
+
if (truncated) {
|
|
379
380
|
// eslint-disable-next-line no-console
|
|
380
381
|
console.warn(`styleproof: ${marked.length} interactive elements exceeds maxInteractive=${maxInteractive}; ` +
|
|
381
382
|
`forced-state capture truncated to the first ${maxInteractive}. Raise maxInteractive to cover them all.`);
|
|
@@ -405,7 +406,10 @@ async function captureForcedStates(page, ignore, maxInteractive, skipPaths = [])
|
|
|
405
406
|
await page.evaluate(clearInteractiveMarks, STATE_ID_ATTR).catch(() => undefined);
|
|
406
407
|
await client.detach();
|
|
407
408
|
}
|
|
408
|
-
|
|
409
|
+
// When truncated, the elements past the limit have no forced-state delta —
|
|
410
|
+
// flag the layer so the diff reports it as uncertified instead of letting the
|
|
411
|
+
// missing states read as "identical" against a fully-captured side.
|
|
412
|
+
return { states, skipped: truncated };
|
|
409
413
|
}
|
|
410
414
|
/** Element paths that differ between two captures (added, removed, or restyled). */
|
|
411
415
|
function changedElementPaths(a, b) {
|
|
@@ -630,7 +634,9 @@ export async function captureStyleMap(page, options = {}) {
|
|
|
630
634
|
// Detect semantic live-state candidates automatically, but don't exclude them
|
|
631
635
|
// merely for being live regions. Stable status/alert/log UI is product UI and
|
|
632
636
|
// should still be captured; this metadata only improves reports and diagnostics.
|
|
633
|
-
|
|
637
|
+
// Honour the caller's `ignore` too: a region the user excluded shouldn't be
|
|
638
|
+
// surfaced (or persisted) as a live candidate, matching every other capture pass.
|
|
639
|
+
const liveCandidates = await page.evaluate(detectLiveCandidates, { ignore });
|
|
634
640
|
// Motion longhands (transition/animation) are read separately so declared
|
|
635
641
|
// motion is verified even though every other value is a frozen end state.
|
|
636
642
|
// Read them on the SETTLED DOM, not before it: capturing pre-settle missed any
|
package/dist/cli-errors.d.ts
CHANGED
|
@@ -7,4 +7,3 @@ export declare function playwrightMissingMessage(message: string): string;
|
|
|
7
7
|
export declare function missingWorkingMapsMessage(command: string, dir: string): string;
|
|
8
8
|
export declare function missingManualCaptureMessage(command: string, dir: string): string;
|
|
9
9
|
export declare function baseInferenceMessage(command: string, message: string): string;
|
|
10
|
-
export declare function baseMapsMessage(command: string, message: string, baseRef: string, mapsDir: string): string;
|
package/dist/cli-errors.js
CHANGED
|
@@ -26,8 +26,8 @@ export function playwrightMissingMessage(message) {
|
|
|
26
26
|
export function missingWorkingMapsMessage(command, dir) {
|
|
27
27
|
return [
|
|
28
28
|
`${command}: no capture at ${dir}`,
|
|
29
|
-
`Next: run styleproof-map to create ${dir},
|
|
30
|
-
'If your maps live elsewhere, pass
|
|
29
|
+
`Next: run styleproof-map to create ${dir}, or run styleproof-map --restore --sha <commit> to restore it from the map store.`,
|
|
30
|
+
'If your maps live elsewhere, pass explicit capture directories.',
|
|
31
31
|
].join('\n');
|
|
32
32
|
}
|
|
33
33
|
export function missingManualCaptureMessage(command, dir) {
|
|
@@ -42,9 +42,3 @@ export function baseInferenceMessage(command, message) {
|
|
|
42
42
|
`Next: pass a base ref explicitly, e.g. ${command} main, or set git config branch.<name>.gh-merge-base main.`,
|
|
43
43
|
].join('\n');
|
|
44
44
|
}
|
|
45
|
-
export function baseMapsMessage(command, message, baseRef, mapsDir) {
|
|
46
|
-
return [
|
|
47
|
-
`${command}: ${message}`,
|
|
48
|
-
`Next: make sure ${baseRef} contains committed captures at ${mapsDir}. On the base branch, run styleproof-map and commit ${mapsDir}.`,
|
|
49
|
-
].join('\n');
|
|
50
|
-
}
|
package/dist/diff.js
CHANGED
|
@@ -101,8 +101,9 @@ export function diffStyleMaps(a, b) {
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
// If the forced-state layer was skipped on exactly one side (CDP skew during
|
|
104
|
-
//
|
|
105
|
-
// loudly rather than letting {} vs {} read as
|
|
104
|
+
// capture, or truncation past maxInteractive), the :hover/:focus/:active layer
|
|
105
|
+
// was not fully compared — flag it loudly rather than letting {} vs {} read as
|
|
106
|
+
// "identical".
|
|
106
107
|
if (!!a.statesSkipped !== !!b.statesSkipped) {
|
|
107
108
|
findings.push({
|
|
108
109
|
kind: 'state',
|
|
@@ -113,8 +114,8 @@ export function diffStyleMaps(a, b) {
|
|
|
113
114
|
props: [
|
|
114
115
|
{
|
|
115
116
|
prop: 'forced :hover/:focus/:active layer',
|
|
116
|
-
before: a.statesSkipped ? '
|
|
117
|
-
after: b.statesSkipped ? '
|
|
117
|
+
before: a.statesSkipped ? 'not fully captured' : 'captured',
|
|
118
|
+
after: b.statesSkipped ? 'not fully captured' : 'captured',
|
|
118
119
|
},
|
|
119
120
|
],
|
|
120
121
|
});
|
|
@@ -152,7 +153,7 @@ function tallyCounts(findings, counts) {
|
|
|
152
153
|
function indexDir(dir) {
|
|
153
154
|
return Object.fromEntries(fs
|
|
154
155
|
.readdirSync(dir)
|
|
155
|
-
.filter((f) => /\.json(\.gz)?$/.test(f))
|
|
156
|
+
.filter((f) => f !== 'styleproof-manifest.json' && /\.json(\.gz)?$/.test(f))
|
|
156
157
|
.map((f) => [f.replace(/\.json(\.gz)?$/, ''), path.join(dir, f)]));
|
|
157
158
|
}
|
|
158
159
|
/** Diff every same-named capture between two directories. `volatile` is the
|
package/dist/gitref.d.ts
CHANGED
|
@@ -1,15 +1,5 @@
|
|
|
1
|
-
/** A readable failure
|
|
1
|
+
/** A readable failure inferring a base branch — CLIs map this to exit 2. */
|
|
2
2
|
export declare class GitRefError extends Error {
|
|
3
3
|
}
|
|
4
|
-
/** Infer
|
|
4
|
+
/** Infer a PR/base ref for local and GitHub Actions CLI runs. */
|
|
5
5
|
export declare function inferBaseRef(env?: NodeJS.ProcessEnv): string;
|
|
6
|
-
/**
|
|
7
|
-
* Materialise the captures committed at `dir` as of `ref` into a fresh temp dir,
|
|
8
|
-
* so a diff/report can use e.g. `main`'s committed map as the base with no checkout
|
|
9
|
-
* and no recapture — the "base map lives on main, CI just diffs" model.
|
|
10
|
-
*
|
|
11
|
-
* Reads purely through git (`ls-tree`/`show`, no `tar`/deps); binary `.json.gz`
|
|
12
|
-
* bytes come straight from `git show`. The caller owns cleanup of the returned dir.
|
|
13
|
-
* Throws {@link GitRefError} (never exits) so it's reusable from any entry point.
|
|
14
|
-
*/
|
|
15
|
-
export declare function materializeRef(ref: string, dir: string): string;
|
package/dist/gitref.js
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
-
|
|
3
|
-
import os from 'node:os';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
/** A readable failure materialising a base from git — CLIs map this to exit 2. */
|
|
2
|
+
/** A readable failure inferring a base branch — CLIs map this to exit 2. */
|
|
6
3
|
export class GitRefError extends Error {
|
|
7
4
|
}
|
|
8
5
|
function git(args) {
|
|
@@ -29,7 +26,7 @@ function ghPrBaseRef() {
|
|
|
29
26
|
});
|
|
30
27
|
return r.status === 0 ? r.stdout.trim() : '';
|
|
31
28
|
}
|
|
32
|
-
/** Infer
|
|
29
|
+
/** Infer a PR/base ref for local and GitHub Actions CLI runs. */
|
|
33
30
|
export function inferBaseRef(env = process.env) {
|
|
34
31
|
if (env.GITHUB_BASE_REF) {
|
|
35
32
|
return baseRefCandidate(env.GITHUB_BASE_REF);
|
|
@@ -49,40 +46,3 @@ export function inferBaseRef(env = process.env) {
|
|
|
49
46
|
return fallback;
|
|
50
47
|
throw new GitRefError('could not infer a base branch (tried GITHUB_BASE_REF, branch.<name>.gh-merge-base, gh pr view, origin/main, origin/master, main, master)');
|
|
51
48
|
}
|
|
52
|
-
/**
|
|
53
|
-
* Materialise the captures committed at `dir` as of `ref` into a fresh temp dir,
|
|
54
|
-
* so a diff/report can use e.g. `main`'s committed map as the base with no checkout
|
|
55
|
-
* and no recapture — the "base map lives on main, CI just diffs" model.
|
|
56
|
-
*
|
|
57
|
-
* Reads purely through git (`ls-tree`/`show`, no `tar`/deps); binary `.json.gz`
|
|
58
|
-
* bytes come straight from `git show`. The caller owns cleanup of the returned dir.
|
|
59
|
-
* Throws {@link GitRefError} (never exits) so it's reusable from any entry point.
|
|
60
|
-
*/
|
|
61
|
-
export function materializeRef(ref, dir) {
|
|
62
|
-
const top = git(['rev-parse', '--show-toplevel']);
|
|
63
|
-
if (top.status !== 0)
|
|
64
|
-
throw new GitRefError('must run inside a git repository');
|
|
65
|
-
const toplevel = top.stdout.trim();
|
|
66
|
-
// -z: NUL-separated, unquoted paths (handles spaces/unicode).
|
|
67
|
-
const ls = spawnSync('git', ['ls-tree', '-r', '-z', '--name-only', ref, '--', dir], {
|
|
68
|
-
encoding: 'utf8',
|
|
69
|
-
maxBuffer: 1 << 28,
|
|
70
|
-
});
|
|
71
|
-
if (ls.status !== 0)
|
|
72
|
-
throw new GitRefError(`cannot read ${dir} at ${ref}: ${(ls.stderr || '').trim()}`);
|
|
73
|
-
const files = ls.stdout.split('\0').filter(Boolean);
|
|
74
|
-
if (files.length === 0) {
|
|
75
|
-
throw new GitRefError(`no committed captures at ${dir} in ${ref} — commit the maps so the base lives in git`);
|
|
76
|
-
}
|
|
77
|
-
const dirRepoRel = path.relative(toplevel, path.resolve(dir));
|
|
78
|
-
const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'styleproof-baseref-'));
|
|
79
|
-
for (const f of files) {
|
|
80
|
-
const show = spawnSync('git', ['show', `${ref}:${f}`], { maxBuffer: 1 << 28 }); // buffer — preserves .json.gz
|
|
81
|
-
if (show.status !== 0)
|
|
82
|
-
throw new GitRefError(`cannot read ${f} at ${ref}`);
|
|
83
|
-
const out = path.join(dest, path.relative(dirRepoRel, f));
|
|
84
|
-
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
85
|
-
fs.writeFileSync(out, show.stdout);
|
|
86
|
-
}
|
|
87
|
-
return dest;
|
|
88
|
-
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
|
|
2
2
|
export type { StyleMap, CaptureOptions, CaptureMetadata, ElementEntry, LiveRegionCandidate, Rect } from './capture.js';
|
|
3
3
|
export { defineStyleMapCapture, defineCrawlCapture } from './runner.js';
|
|
4
|
-
export type { Surface, SurfaceLiveState, SurfaceVariant, DefineOptions, CrawlOptions } from './runner.js';
|
|
4
|
+
export type { Surface, SurfaceLiveState, SurfaceVariant, PopupCaptureOptions, DefineOptions, CrawlOptions, } from './runner.js';
|
|
5
5
|
export { coverageGaps } from './coverage.js';
|
|
6
6
|
export type { CoverageGaps } from './coverage.js';
|
|
7
7
|
export { detectViewportWidths, mediaTextWidthBoundaries, widthsFromBoundaries } from './breakpoints.js';
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export declare const DEFAULT_MAP_DIR = ".styleproof/maps";
|
|
2
|
+
export declare const DEFAULT_MAP_LABEL = "current";
|
|
3
|
+
export declare const DEFAULT_MAP_STORE_BRANCH = "styleproof-maps";
|
|
4
|
+
export declare const DEFAULT_REMOTE = "origin";
|
|
5
|
+
export declare const MAP_MANIFEST = "styleproof-manifest.json";
|
|
6
|
+
export declare class MapStoreError extends Error {
|
|
7
|
+
}
|
|
8
|
+
export interface MapManifest {
|
|
9
|
+
version: 1;
|
|
10
|
+
packageVersion: string;
|
|
11
|
+
sha: string;
|
|
12
|
+
dirty: boolean;
|
|
13
|
+
spec: string;
|
|
14
|
+
specHash: string;
|
|
15
|
+
lockfile?: string;
|
|
16
|
+
lockfileHash?: string;
|
|
17
|
+
playwrightVersion?: string;
|
|
18
|
+
platform: string;
|
|
19
|
+
arch: string;
|
|
20
|
+
nodeMajor: string;
|
|
21
|
+
baseUrl?: string;
|
|
22
|
+
screenshots: boolean;
|
|
23
|
+
har: boolean;
|
|
24
|
+
compatibilityKey: string;
|
|
25
|
+
createdAt: string;
|
|
26
|
+
}
|
|
27
|
+
export interface CachedCaptureDirs {
|
|
28
|
+
beforeDir: string;
|
|
29
|
+
afterDir: string;
|
|
30
|
+
baseRef: string;
|
|
31
|
+
baseSha: string;
|
|
32
|
+
headSha: string;
|
|
33
|
+
compatibilityKey: string;
|
|
34
|
+
tmpRoot: string;
|
|
35
|
+
}
|
|
36
|
+
export declare function expectedCompatibilityKey(options?: {
|
|
37
|
+
cwd?: string;
|
|
38
|
+
spec?: string;
|
|
39
|
+
baseUrl?: string;
|
|
40
|
+
}): string;
|
|
41
|
+
export declare function currentGitSha(cwd?: string, env?: NodeJS.ProcessEnv): string;
|
|
42
|
+
export declare function refSha(ref: string, cwd?: string): string;
|
|
43
|
+
export declare function workingTreeDirty(cwd?: string): boolean;
|
|
44
|
+
export declare function remoteExists(remote?: string, cwd?: string): boolean;
|
|
45
|
+
export declare function writeMapManifest(options: {
|
|
46
|
+
dir: string;
|
|
47
|
+
spec: string;
|
|
48
|
+
sha?: string;
|
|
49
|
+
screenshots: boolean;
|
|
50
|
+
dirty?: boolean;
|
|
51
|
+
cwd?: string;
|
|
52
|
+
env?: NodeJS.ProcessEnv;
|
|
53
|
+
}): MapManifest;
|
|
54
|
+
export declare function readMapManifest(dir: string): MapManifest | null;
|
|
55
|
+
export declare function assertCompatibleMapDirs(beforeDir: string, afterDir: string): void;
|
|
56
|
+
export declare function publishMapBundle(options: {
|
|
57
|
+
dir: string;
|
|
58
|
+
branch?: string;
|
|
59
|
+
remote?: string;
|
|
60
|
+
cwd?: string;
|
|
61
|
+
includeHar?: boolean;
|
|
62
|
+
}): {
|
|
63
|
+
sha: string;
|
|
64
|
+
compatibilityKey: string;
|
|
65
|
+
branch: string;
|
|
66
|
+
};
|
|
67
|
+
export declare function restoreMapBundle(options: {
|
|
68
|
+
sha: string;
|
|
69
|
+
outDir: string;
|
|
70
|
+
branch?: string;
|
|
71
|
+
remote?: string;
|
|
72
|
+
cwd?: string;
|
|
73
|
+
compatibilityKey?: string;
|
|
74
|
+
}): MapManifest;
|
|
75
|
+
export declare function resolveCachedCaptureDirs(options: {
|
|
76
|
+
command: string;
|
|
77
|
+
args: string[];
|
|
78
|
+
spec: string;
|
|
79
|
+
branch?: string;
|
|
80
|
+
remote?: string;
|
|
81
|
+
cwd?: string;
|
|
82
|
+
baseUrl?: string;
|
|
83
|
+
usage: string;
|
|
84
|
+
}): CachedCaptureDirs;
|
|
85
|
+
export declare function cleanupCachedCaptureDirs(captureDirs: CachedCaptureDirs | null): void;
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { inferBaseRef } from './gitref.js';
|
|
8
|
+
export const DEFAULT_MAP_DIR = '.styleproof/maps';
|
|
9
|
+
export const DEFAULT_MAP_LABEL = 'current';
|
|
10
|
+
export const DEFAULT_MAP_STORE_BRANCH = 'styleproof-maps';
|
|
11
|
+
export const DEFAULT_REMOTE = 'origin';
|
|
12
|
+
export const MAP_MANIFEST = 'styleproof-manifest.json';
|
|
13
|
+
export class MapStoreError extends Error {
|
|
14
|
+
}
|
|
15
|
+
function runGit(cwd, args, maxBuffer = 1 << 28) {
|
|
16
|
+
return spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer });
|
|
17
|
+
}
|
|
18
|
+
function gitOutput(cwd, args) {
|
|
19
|
+
const r = runGit(cwd, args);
|
|
20
|
+
return r.status === 0 ? r.stdout.trim() : '';
|
|
21
|
+
}
|
|
22
|
+
function hash(input) {
|
|
23
|
+
return createHash('sha256').update(input).digest('hex');
|
|
24
|
+
}
|
|
25
|
+
function hashFile(file) {
|
|
26
|
+
try {
|
|
27
|
+
return hash(fs.readFileSync(file));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function styleProofPackageVersion() {
|
|
34
|
+
try {
|
|
35
|
+
const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
36
|
+
return pkg.version ?? 'unknown';
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return 'unknown';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function consumerRequire(cwd) {
|
|
43
|
+
return createRequire(path.join(cwd, 'package.json'));
|
|
44
|
+
}
|
|
45
|
+
function playwrightVersion(cwd) {
|
|
46
|
+
try {
|
|
47
|
+
const pkgPath = consumerRequire(cwd).resolve('@playwright/test/package.json');
|
|
48
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
49
|
+
return pkg.version;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function detectLockfile(cwd) {
|
|
56
|
+
for (const file of ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb']) {
|
|
57
|
+
const full = path.join(cwd, file);
|
|
58
|
+
const h = hashFile(full);
|
|
59
|
+
if (h)
|
|
60
|
+
return { file, hash: h };
|
|
61
|
+
}
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
function hasHar(dir) {
|
|
65
|
+
if (!fs.existsSync(dir))
|
|
66
|
+
return false;
|
|
67
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
68
|
+
const full = path.join(dir, entry.name);
|
|
69
|
+
if (entry.isDirectory() && hasHar(full))
|
|
70
|
+
return true;
|
|
71
|
+
if (entry.isFile() && entry.name.endsWith('.har'))
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
function compatibilityInput(options) {
|
|
77
|
+
const specPath = path.resolve(options.cwd, options.spec);
|
|
78
|
+
const lock = detectLockfile(options.cwd);
|
|
79
|
+
return {
|
|
80
|
+
packageVersion: styleProofPackageVersion(),
|
|
81
|
+
spec: path.relative(options.cwd, specPath) || options.spec,
|
|
82
|
+
specHash: hashFile(specPath) ?? 'missing',
|
|
83
|
+
lockfile: lock.file,
|
|
84
|
+
lockfileHash: lock.hash,
|
|
85
|
+
playwrightVersion: playwrightVersion(options.cwd),
|
|
86
|
+
platform: process.platform,
|
|
87
|
+
arch: process.arch,
|
|
88
|
+
nodeMajor: process.versions.node.split('.')[0] ?? process.versions.node,
|
|
89
|
+
baseUrl: options.baseUrl,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export function expectedCompatibilityKey(options = {}) {
|
|
93
|
+
return hash(JSON.stringify(compatibilityInput({
|
|
94
|
+
cwd: options.cwd ?? process.cwd(),
|
|
95
|
+
spec: options.spec ?? 'e2e/styleproof.spec.ts',
|
|
96
|
+
baseUrl: options.baseUrl,
|
|
97
|
+
}))).slice(0, 16);
|
|
98
|
+
}
|
|
99
|
+
export function currentGitSha(cwd = process.cwd(), env = process.env) {
|
|
100
|
+
const fromEnv = env.GITHUB_SHA || env.GITHUB_HEAD_SHA;
|
|
101
|
+
if (fromEnv && /^[0-9a-f]{7,40}$/i.test(fromEnv))
|
|
102
|
+
return fromEnv;
|
|
103
|
+
const sha = gitOutput(cwd, ['rev-parse', 'HEAD']);
|
|
104
|
+
if (!sha)
|
|
105
|
+
throw new MapStoreError('must run inside a git repository, or pass --sha <commit>');
|
|
106
|
+
return sha;
|
|
107
|
+
}
|
|
108
|
+
export function refSha(ref, cwd = process.cwd()) {
|
|
109
|
+
const sha = gitOutput(cwd, ['rev-parse', `${ref}^{commit}`]);
|
|
110
|
+
if (!sha)
|
|
111
|
+
throw new MapStoreError(`could not resolve ${ref} to a commit`);
|
|
112
|
+
return sha;
|
|
113
|
+
}
|
|
114
|
+
export function workingTreeDirty(cwd = process.cwd()) {
|
|
115
|
+
return gitOutput(cwd, ['status', '--porcelain']) !== '';
|
|
116
|
+
}
|
|
117
|
+
export function remoteExists(remote = DEFAULT_REMOTE, cwd = process.cwd()) {
|
|
118
|
+
return runGit(cwd, ['remote', 'get-url', remote], 1 << 20).status === 0;
|
|
119
|
+
}
|
|
120
|
+
export function writeMapManifest(options) {
|
|
121
|
+
const cwd = options.cwd ?? process.cwd();
|
|
122
|
+
const input = compatibilityInput({ cwd, spec: options.spec, baseUrl: options.env?.BASE_URL ?? process.env.BASE_URL });
|
|
123
|
+
const manifest = {
|
|
124
|
+
version: 1,
|
|
125
|
+
packageVersion: input.packageVersion,
|
|
126
|
+
sha: options.sha ?? currentGitSha(cwd, options.env),
|
|
127
|
+
dirty: options.dirty ?? workingTreeDirty(cwd),
|
|
128
|
+
spec: input.spec,
|
|
129
|
+
specHash: input.specHash,
|
|
130
|
+
...(input.lockfile ? { lockfile: input.lockfile } : {}),
|
|
131
|
+
...(input.lockfileHash ? { lockfileHash: input.lockfileHash } : {}),
|
|
132
|
+
...(input.playwrightVersion ? { playwrightVersion: input.playwrightVersion } : {}),
|
|
133
|
+
platform: input.platform,
|
|
134
|
+
arch: input.arch,
|
|
135
|
+
nodeMajor: input.nodeMajor,
|
|
136
|
+
...(input.baseUrl ? { baseUrl: input.baseUrl } : {}),
|
|
137
|
+
screenshots: options.screenshots,
|
|
138
|
+
har: hasHar(options.dir),
|
|
139
|
+
compatibilityKey: hash(JSON.stringify(input)).slice(0, 16),
|
|
140
|
+
createdAt: new Date().toISOString(),
|
|
141
|
+
};
|
|
142
|
+
fs.writeFileSync(path.join(options.dir, MAP_MANIFEST), JSON.stringify(manifest, null, 2));
|
|
143
|
+
return manifest;
|
|
144
|
+
}
|
|
145
|
+
export function readMapManifest(dir) {
|
|
146
|
+
try {
|
|
147
|
+
return JSON.parse(fs.readFileSync(path.join(dir, MAP_MANIFEST), 'utf8'));
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
export function assertCompatibleMapDirs(beforeDir, afterDir) {
|
|
154
|
+
const before = readMapManifest(beforeDir);
|
|
155
|
+
const after = readMapManifest(afterDir);
|
|
156
|
+
if (!before || !after)
|
|
157
|
+
return;
|
|
158
|
+
const beforeRuntime = {
|
|
159
|
+
platform: before.platform,
|
|
160
|
+
arch: before.arch,
|
|
161
|
+
nodeMajor: before.nodeMajor,
|
|
162
|
+
playwrightVersion: before.playwrightVersion ?? '',
|
|
163
|
+
baseUrl: before.baseUrl ?? '',
|
|
164
|
+
};
|
|
165
|
+
const afterRuntime = {
|
|
166
|
+
platform: after.platform,
|
|
167
|
+
arch: after.arch,
|
|
168
|
+
nodeMajor: after.nodeMajor,
|
|
169
|
+
playwrightVersion: after.playwrightVersion ?? '',
|
|
170
|
+
baseUrl: after.baseUrl ?? '',
|
|
171
|
+
};
|
|
172
|
+
if (JSON.stringify(beforeRuntime) === JSON.stringify(afterRuntime))
|
|
173
|
+
return;
|
|
174
|
+
throw new MapStoreError([
|
|
175
|
+
'maps were captured in different runtime environments',
|
|
176
|
+
`before ${before.sha.slice(0, 12)}: ${before.compatibilityKey} (${before.platform}/${before.arch}, Playwright ${before.playwrightVersion ?? 'unknown'})`,
|
|
177
|
+
`after ${after.sha.slice(0, 12)}: ${after.compatibilityKey} (${after.platform}/${after.arch}, Playwright ${after.playwrightVersion ?? 'unknown'})`,
|
|
178
|
+
'Next: rebuild one side with styleproof-map in the same environment, or let CI recapture both maps.',
|
|
179
|
+
].join('\n'));
|
|
180
|
+
}
|
|
181
|
+
function safeSegment(value, name) {
|
|
182
|
+
if (!/^[A-Za-z0-9._-]+$/.test(value))
|
|
183
|
+
throw new MapStoreError(`${name} contains unsupported characters: ${value}`);
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
186
|
+
function copyDir(src, dest, includeHar) {
|
|
187
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
188
|
+
fs.cpSync(src, dest, {
|
|
189
|
+
recursive: true,
|
|
190
|
+
filter: (source) => includeHar || !source.endsWith('.har'),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
function checkoutMapStore(cwd, remote, branch) {
|
|
194
|
+
if (!remoteExists(remote, cwd))
|
|
195
|
+
throw new MapStoreError(`git remote ${remote} was not found`);
|
|
196
|
+
const remoteUrl = gitOutput(cwd, ['remote', 'get-url', remote]);
|
|
197
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'styleproof-map-store-'));
|
|
198
|
+
if (runGit(cwd, ['ls-remote', '--exit-code', '--heads', remote, branch], 1 << 20).status === 0) {
|
|
199
|
+
const clone = spawnSync('git', ['clone', '-q', '--depth', '1', '--branch', branch, remoteUrl, tmp], {
|
|
200
|
+
encoding: 'utf8',
|
|
201
|
+
maxBuffer: 1 << 20,
|
|
202
|
+
});
|
|
203
|
+
if (clone.status !== 0) {
|
|
204
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
205
|
+
throw new MapStoreError(clone.stderr.trim() || 'could not clone map store branch');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
runGit(tmp, ['init', '-q', '-b', branch]);
|
|
210
|
+
runGit(tmp, ['remote', 'add', 'origin', remoteUrl]);
|
|
211
|
+
}
|
|
212
|
+
return tmp;
|
|
213
|
+
}
|
|
214
|
+
export function publishMapBundle(options) {
|
|
215
|
+
const cwd = options.cwd ?? process.cwd();
|
|
216
|
+
const branch = options.branch ?? DEFAULT_MAP_STORE_BRANCH;
|
|
217
|
+
const remote = options.remote ?? DEFAULT_REMOTE;
|
|
218
|
+
const manifest = readMapManifest(options.dir);
|
|
219
|
+
if (!manifest)
|
|
220
|
+
throw new MapStoreError(`no ${MAP_MANIFEST} in ${options.dir}`);
|
|
221
|
+
if (manifest.dirty) {
|
|
222
|
+
throw new MapStoreError(`not uploading ${options.dir}: working tree was dirty when the map was captured. Commit first, then rerun styleproof-map.`);
|
|
223
|
+
}
|
|
224
|
+
if (!remoteExists(remote, cwd))
|
|
225
|
+
throw new MapStoreError(`git remote ${remote} was not found`);
|
|
226
|
+
const sha = safeSegment(manifest.sha, 'sha');
|
|
227
|
+
const compatibilityKey = safeSegment(manifest.compatibilityKey, 'compatibility key');
|
|
228
|
+
const target = `${sha}/${compatibilityKey}`;
|
|
229
|
+
let ok = false;
|
|
230
|
+
let lastError = '';
|
|
231
|
+
for (let attempt = 1; attempt <= 5; attempt++) {
|
|
232
|
+
const tmp = checkoutMapStore(cwd, remote, branch);
|
|
233
|
+
try {
|
|
234
|
+
runGit(tmp, ['config', 'user.name', 'github-actions[bot]']);
|
|
235
|
+
runGit(tmp, ['config', 'user.email', '41898282+github-actions[bot]@users.noreply.github.com']);
|
|
236
|
+
fs.writeFileSync(path.join(tmp, 'README.md'), '# StyleProof maps\n\nMachine-generated reusable map bundles. Each folder is keyed by commit SHA and capture compatibility.\n');
|
|
237
|
+
fs.rmSync(path.join(tmp, target), { recursive: true, force: true });
|
|
238
|
+
copyDir(options.dir, path.join(tmp, target), options.includeHar === true);
|
|
239
|
+
if (!options.includeHar) {
|
|
240
|
+
fs.writeFileSync(path.join(tmp, target, MAP_MANIFEST), JSON.stringify({ ...manifest, har: false }, null, 2));
|
|
241
|
+
}
|
|
242
|
+
runGit(tmp, ['add', '-A']);
|
|
243
|
+
runGit(tmp, ['commit', '-q', '-m', `StyleProof map ${sha.slice(0, 12)} ${compatibilityKey}`], 1 << 20);
|
|
244
|
+
const push = runGit(tmp, ['push', '-q', 'origin', `HEAD:${branch}`], 1 << 20);
|
|
245
|
+
if (push.status === 0) {
|
|
246
|
+
ok = true;
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
lastError = push.stderr.trim();
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
253
|
+
}
|
|
254
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, attempt * 250);
|
|
255
|
+
}
|
|
256
|
+
if (!ok)
|
|
257
|
+
throw new MapStoreError(lastError || `could not push ${branch}`);
|
|
258
|
+
return { sha, compatibilityKey, branch };
|
|
259
|
+
}
|
|
260
|
+
export function restoreMapBundle(options) {
|
|
261
|
+
const cwd = options.cwd ?? process.cwd();
|
|
262
|
+
const branch = options.branch ?? DEFAULT_MAP_STORE_BRANCH;
|
|
263
|
+
const remote = options.remote ?? DEFAULT_REMOTE;
|
|
264
|
+
const sha = safeSegment(options.sha, 'sha');
|
|
265
|
+
const compatibilityKey = options.compatibilityKey
|
|
266
|
+
? safeSegment(options.compatibilityKey, 'compatibility key')
|
|
267
|
+
: undefined;
|
|
268
|
+
if (!remoteExists(remote, cwd))
|
|
269
|
+
throw new MapStoreError(`git remote ${remote} was not found`);
|
|
270
|
+
if (runGit(cwd, ['ls-remote', '--exit-code', '--heads', remote, branch], 1 << 20).status !== 0) {
|
|
271
|
+
throw new MapStoreError(`map store branch ${branch} does not exist`);
|
|
272
|
+
}
|
|
273
|
+
const tmp = checkoutMapStore(cwd, remote, branch);
|
|
274
|
+
try {
|
|
275
|
+
const shaDir = path.join(tmp, sha);
|
|
276
|
+
if (!fs.existsSync(shaDir))
|
|
277
|
+
throw new MapStoreError(`no cached map for ${sha} on ${branch}`);
|
|
278
|
+
const candidates = fs
|
|
279
|
+
.readdirSync(shaDir, { withFileTypes: true })
|
|
280
|
+
.filter((entry) => entry.isDirectory())
|
|
281
|
+
.map((entry) => entry.name)
|
|
282
|
+
.filter((name) => !compatibilityKey || name === compatibilityKey)
|
|
283
|
+
.sort();
|
|
284
|
+
if (!candidates.length) {
|
|
285
|
+
throw new MapStoreError(compatibilityKey
|
|
286
|
+
? `no cached map for ${sha} with compatibility ${compatibilityKey} on ${branch}`
|
|
287
|
+
: `no cached map bundle under ${sha} on ${branch}`);
|
|
288
|
+
}
|
|
289
|
+
const src = path.join(shaDir, candidates[0]);
|
|
290
|
+
fs.rmSync(options.outDir, { recursive: true, force: true });
|
|
291
|
+
copyDir(src, options.outDir, true);
|
|
292
|
+
const manifest = readMapManifest(options.outDir);
|
|
293
|
+
if (!manifest)
|
|
294
|
+
throw new MapStoreError(`cached map for ${sha} is missing ${MAP_MANIFEST}`);
|
|
295
|
+
return manifest;
|
|
296
|
+
}
|
|
297
|
+
finally {
|
|
298
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
export function resolveCachedCaptureDirs(options) {
|
|
302
|
+
const cwd = options.cwd ?? process.cwd();
|
|
303
|
+
if (options.args.length > 1)
|
|
304
|
+
throw new MapStoreError(options.usage);
|
|
305
|
+
if (!fs.existsSync(path.resolve(cwd, options.spec))) {
|
|
306
|
+
throw new MapStoreError(`${options.command}: no StyleProof spec at ${options.spec}`);
|
|
307
|
+
}
|
|
308
|
+
const baseRef = options.args[0] ?? inferBaseRef();
|
|
309
|
+
const baseSha = refSha(baseRef, cwd);
|
|
310
|
+
const headSha = currentGitSha(cwd);
|
|
311
|
+
const compatibilityKey = expectedCompatibilityKey({ cwd, spec: options.spec, baseUrl: options.baseUrl });
|
|
312
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'styleproof-cache-'));
|
|
313
|
+
const beforeDir = path.join(tmpRoot, 'base');
|
|
314
|
+
const afterDir = path.join(tmpRoot, 'head');
|
|
315
|
+
try {
|
|
316
|
+
restoreMapBundle({
|
|
317
|
+
sha: baseSha,
|
|
318
|
+
outDir: beforeDir,
|
|
319
|
+
branch: options.branch,
|
|
320
|
+
remote: options.remote,
|
|
321
|
+
cwd,
|
|
322
|
+
compatibilityKey,
|
|
323
|
+
});
|
|
324
|
+
restoreMapBundle({
|
|
325
|
+
sha: headSha,
|
|
326
|
+
outDir: afterDir,
|
|
327
|
+
branch: options.branch,
|
|
328
|
+
remote: options.remote,
|
|
329
|
+
cwd,
|
|
330
|
+
compatibilityKey,
|
|
331
|
+
});
|
|
332
|
+
return { beforeDir, afterDir, baseRef, baseSha, headSha, compatibilityKey, tmpRoot };
|
|
333
|
+
}
|
|
334
|
+
catch (e) {
|
|
335
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
336
|
+
throw e;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
export function cleanupCachedCaptureDirs(captureDirs) {
|
|
340
|
+
if (captureDirs)
|
|
341
|
+
fs.rmSync(captureDirs.tmpRoot, { recursive: true, force: true });
|
|
342
|
+
}
|
package/dist/report.d.ts
CHANGED
|
@@ -24,6 +24,12 @@ export type ReportOptions = {
|
|
|
24
24
|
minHeight?: number;
|
|
25
25
|
/** Crops taller than this are clamped (default 1600px). */
|
|
26
26
|
maxHeight?: number;
|
|
27
|
+
/**
|
|
28
|
+
* Changed-element footprint (max of its width/height, in px) at or below which a
|
|
29
|
+
* magnified zoom crop is added so a sub-pixel change is visible by default
|
|
30
|
+
* (default 64). Set to 0 to disable zoom crops.
|
|
31
|
+
*/
|
|
32
|
+
zoomBelow?: number;
|
|
27
33
|
/** Max crop regions per surface before collapsing into one union crop (default 8). */
|
|
28
34
|
maxCrops?: number;
|
|
29
35
|
/**
|