tuffgal 0.1.0-alpha.9 → 0.2.0-alpha.2
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 +149 -2
- package/README.md +78 -3
- package/dist/cli.d.ts +50 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +204 -18
- package/dist/cli.js.map +1 -1
- package/dist/commands/init.d.ts +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +11 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/config.d.ts +38 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +20 -1
- package/dist/config.js.map +1 -1
- package/dist/coverage/screens.d.ts +11 -6
- package/dist/coverage/screens.d.ts.map +1 -1
- package/dist/coverage/screens.js +12 -7
- package/dist/coverage/screens.js.map +1 -1
- package/dist/reporter/assets/report.css +415 -51
- package/dist/reporter/assets/report.js +502 -68
- package/dist/reporter/template.d.ts +8 -1
- package/dist/reporter/template.d.ts.map +1 -1
- package/dist/reporter/template.js +517 -85
- package/dist/reporter/template.js.map +1 -1
- package/dist/reporter/writeReport.d.ts +2 -2
- package/dist/reporter/writeReport.d.ts.map +1 -1
- package/dist/reporter/writeReport.js +3 -3
- package/dist/reporter/writeReport.js.map +1 -1
- package/dist/runner/approve.d.ts +34 -9
- package/dist/runner/approve.d.ts.map +1 -1
- package/dist/runner/approve.js +54 -10
- package/dist/runner/approve.js.map +1 -1
- package/dist/runner/approveFrom.d.ts +58 -0
- package/dist/runner/approveFrom.d.ts.map +1 -0
- package/dist/runner/approveFrom.js +384 -0
- package/dist/runner/approveFrom.js.map +1 -0
- package/dist/runner/breakpointPasses.d.ts +39 -0
- package/dist/runner/breakpointPasses.d.ts.map +1 -0
- package/dist/runner/breakpointPasses.js +107 -0
- package/dist/runner/breakpointPasses.js.map +1 -0
- package/dist/runner/bridges/database.d.ts +4 -3
- package/dist/runner/bridges/database.d.ts.map +1 -1
- package/dist/runner/bridges/database.js +4 -3
- package/dist/runner/bridges/database.js.map +1 -1
- package/dist/runner/exitCode.d.ts +34 -0
- package/dist/runner/exitCode.d.ts.map +1 -0
- package/dist/runner/exitCode.js +41 -0
- package/dist/runner/exitCode.js.map +1 -0
- package/dist/runner/manifest.d.ts +149 -0
- package/dist/runner/manifest.d.ts.map +1 -0
- package/dist/runner/manifest.js +229 -0
- package/dist/runner/manifest.js.map +1 -0
- package/dist/runner/mode.d.ts +41 -0
- package/dist/runner/mode.d.ts.map +1 -0
- package/dist/runner/mode.js +43 -0
- package/dist/runner/mode.js.map +1 -0
- package/dist/runner/orphanScan.d.ts +68 -0
- package/dist/runner/orphanScan.d.ts.map +1 -0
- package/dist/runner/orphanScan.js +150 -0
- package/dist/runner/orphanScan.js.map +1 -0
- package/dist/runner/run.d.ts +85 -1
- package/dist/runner/run.d.ts.map +1 -1
- package/dist/runner/run.js +267 -41
- package/dist/runner/run.js.map +1 -1
- package/dist/runner/runAction.d.ts +16 -0
- package/dist/runner/runAction.d.ts.map +1 -1
- package/dist/runner/runAction.js +112 -13
- package/dist/runner/runAction.js.map +1 -1
- package/dist/runner/runStory.d.ts +15 -4
- package/dist/runner/runStory.d.ts.map +1 -1
- package/dist/runner/runStory.js +41 -19
- package/dist/runner/runStory.js.map +1 -1
- package/dist/runner/storyFilter.d.ts +8 -0
- package/dist/runner/storyFilter.d.ts.map +1 -1
- package/dist/runner/storyFilter.js +11 -0
- package/dist/runner/storyFilter.js.map +1 -1
- package/dist/schema/action.d.ts +11 -11
- package/dist/schema/result.d.ts +125 -6
- package/dist/schema/result.d.ts.map +1 -1
- package/dist/schema/result.js.map +1 -1
- package/dist/screenshots/baselineStore.d.ts +92 -10
- package/dist/screenshots/baselineStore.d.ts.map +1 -1
- package/dist/screenshots/baselineStore.js +102 -18
- package/dist/screenshots/baselineStore.js.map +1 -1
- package/dist/screenshots/capture.d.ts +10 -5
- package/dist/screenshots/capture.d.ts.map +1 -1
- package/dist/screenshots/capture.js +31 -18
- package/dist/screenshots/capture.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* A11y companion suffix for a breakpoint-keyed baseline PNG. `<action>/<bp>.png`
|
|
5
|
+
* carries `<action>/<bp>.a11y.yaml` beside it (see `baselineStore.pathsFor`).
|
|
6
|
+
* Exported so `approve --from` derives the same companion filename from the
|
|
7
|
+
* shared constant instead of re-literalling the suffix at its own producer/
|
|
8
|
+
* consumer boundary.
|
|
9
|
+
*/
|
|
10
|
+
export const A11Y_SUFFIX = '.a11y.yaml';
|
|
11
|
+
/**
|
|
12
|
+
* Pre-breakpoint (legacy) baseline files: a project baselined before the
|
|
13
|
+
* per-breakpoint layout committed `<action>/0.png` + `<action>/a11y.yaml`. The
|
|
14
|
+
* scan keys these under the synthetic breakpoint name `'legacy'` so a prune step
|
|
15
|
+
* can delete them without re-deriving the layout. Exported so `approve --from`
|
|
16
|
+
* shares the same legacy-layout literals rather than re-declaring them.
|
|
17
|
+
*/
|
|
18
|
+
export const LEGACY_PNG = '0.png';
|
|
19
|
+
export const LEGACY_A11Y = 'a11y.yaml';
|
|
20
|
+
export const LEGACY_BREAKPOINT = 'legacy';
|
|
21
|
+
/**
|
|
22
|
+
* Non-action files that may sit at the baselines root and must never be treated
|
|
23
|
+
* as orphan candidates. `manifest.json` (the environment manifest, wave 6) is
|
|
24
|
+
* the known one; it is a file, not an action directory, so the readdir walk
|
|
25
|
+
* already skips it by virtue of only descending into directories — this set is a
|
|
26
|
+
* belt-and-suspenders guard for any future flat file a sibling wave drops here.
|
|
27
|
+
*/
|
|
28
|
+
const NON_ACTION_ENTRIES = new Set(['manifest.json']);
|
|
29
|
+
/**
|
|
30
|
+
* Whether the orphan scan should run for this invocation. Two gates, both
|
|
31
|
+
* must hold:
|
|
32
|
+
* - CI mode only. Local runs never read `paths.baselines` (they self-diff
|
|
33
|
+
* against the per-machine cache), so there is no committed set to orphan.
|
|
34
|
+
* - Unfiltered runs only. A `--story` filter runs a deliberate subset, so
|
|
35
|
+
* every unselected story's baseline would look orphaned when it is merely
|
|
36
|
+
* unvisited; a filtered run must never mark a live baseline `deleted`.
|
|
37
|
+
*
|
|
38
|
+
* Kept pure and separate from `run.ts` so the CI/local × filtered/unfiltered
|
|
39
|
+
* matrix is unit-testable without launching a browser.
|
|
40
|
+
*/
|
|
41
|
+
export function shouldScanForOrphans(mode, storyFilter) {
|
|
42
|
+
return mode === 'ci' && storyFilter === undefined;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The distinct set of action names that ran at least one result this run. An
|
|
46
|
+
* action directory under `paths.baselines` whose name is absent from this set
|
|
47
|
+
* had no story compare against it — it is a candidate orphan. Derived from the
|
|
48
|
+
* flat `stories[].actions[]` walk so `skipped` entries (an earlier step failed)
|
|
49
|
+
* still count the action as executed: the baseline was reachable this run, the
|
|
50
|
+
* story simply broke before reaching it, which is a failure to surface — not a
|
|
51
|
+
* baseline to retire.
|
|
52
|
+
*/
|
|
53
|
+
export function executedActionNames(stories) {
|
|
54
|
+
const names = new Set();
|
|
55
|
+
for (const story of stories) {
|
|
56
|
+
for (const action of story.actions) {
|
|
57
|
+
names.add(action.action);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return names;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Scans `baselinesDir` for orphaned committed baselines: action directories no
|
|
64
|
+
* story executed this run. Detection only — the returned entries carry the
|
|
65
|
+
* absolute paths a later `approve --prune` deletes; this function never touches
|
|
66
|
+
* the filesystem beyond reading directory listings.
|
|
67
|
+
*
|
|
68
|
+
* Caller contract (enforced in `run.ts`, documented here so the module reads
|
|
69
|
+
* standalone): only meaningful for an UNFILTERED CI run. A `--story`-filtered run
|
|
70
|
+
* executes a deliberate subset, so every unselected story's baseline would look
|
|
71
|
+
* orphaned; the caller skips the scan entirely on a filtered run rather than
|
|
72
|
+
* mark live baselines `deleted`. Local mode never reads `paths.baselines` at all,
|
|
73
|
+
* so it never calls this.
|
|
74
|
+
*
|
|
75
|
+
* Layout handled (mirrors `baselineStore.pathsFor`):
|
|
76
|
+
* - breakpoint-keyed `<action>/<breakpoint>.png` (+ `.a11y.yaml` companion)
|
|
77
|
+
* - legacy `<action>/0.png` (+ `a11y.yaml` companion)
|
|
78
|
+
*
|
|
79
|
+
* One {@link DeletedBaseline} per orphaned breakpoint (or the single `'legacy'`
|
|
80
|
+
* group), each bundling the PNG with its a11y companion when present. Files that
|
|
81
|
+
* are neither a recognised PNG nor a matching companion are ignored, so a stray
|
|
82
|
+
* file in an orphaned dir neither crashes the scan nor smuggles an untracked
|
|
83
|
+
* path into the prune list.
|
|
84
|
+
*/
|
|
85
|
+
export async function scanOrphanedBaselines(baselinesDir, executed) {
|
|
86
|
+
let entries;
|
|
87
|
+
try {
|
|
88
|
+
const dirents = await readdir(baselinesDir, { withFileTypes: true });
|
|
89
|
+
entries = dirents.map((dirent) => ({
|
|
90
|
+
name: dirent.name,
|
|
91
|
+
isDir: dirent.isDirectory(),
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// No baselines directory yet (a never-approved project) — nothing to orphan.
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
const orphans = [];
|
|
99
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
100
|
+
if (!entry.isDir)
|
|
101
|
+
continue;
|
|
102
|
+
if (NON_ACTION_ENTRIES.has(entry.name))
|
|
103
|
+
continue;
|
|
104
|
+
if (executed.has(entry.name))
|
|
105
|
+
continue;
|
|
106
|
+
const actionDir = join(baselinesDir, entry.name);
|
|
107
|
+
orphans.push(...(await scanOrphanedActionDir(entry.name, actionDir)));
|
|
108
|
+
}
|
|
109
|
+
return orphans;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Groups one orphaned action directory's baseline files into per-breakpoint
|
|
113
|
+
* {@link DeletedBaseline} entries. Each PNG anchors a group; its a11y companion
|
|
114
|
+
* (breakpoint-keyed `<bp>.a11y.yaml`, or legacy `a11y.yaml`) joins the same group
|
|
115
|
+
* when it exists on disk. Files matching neither shape are skipped.
|
|
116
|
+
*/
|
|
117
|
+
async function scanOrphanedActionDir(action, actionDir) {
|
|
118
|
+
let files;
|
|
119
|
+
try {
|
|
120
|
+
files = await readdir(actionDir);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
const present = new Set(files);
|
|
126
|
+
const groups = [];
|
|
127
|
+
for (const file of files.slice().sort()) {
|
|
128
|
+
if (file === LEGACY_PNG) {
|
|
129
|
+
const paths = [join(actionDir, LEGACY_PNG)];
|
|
130
|
+
if (present.has(LEGACY_A11Y))
|
|
131
|
+
paths.push(join(actionDir, LEGACY_A11Y));
|
|
132
|
+
groups.push({
|
|
133
|
+
action,
|
|
134
|
+
breakpoint: LEGACY_BREAKPOINT,
|
|
135
|
+
baselinePaths: paths,
|
|
136
|
+
});
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (file.endsWith('.png')) {
|
|
140
|
+
const breakpoint = file.slice(0, -'.png'.length);
|
|
141
|
+
const companion = `${breakpoint}${A11Y_SUFFIX}`;
|
|
142
|
+
const paths = [join(actionDir, file)];
|
|
143
|
+
if (present.has(companion))
|
|
144
|
+
paths.push(join(actionDir, companion));
|
|
145
|
+
groups.push({ action, breakpoint, baselinePaths: paths });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return groups;
|
|
149
|
+
}
|
|
150
|
+
//# sourceMappingURL=orphanScan.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"orphanScan.js","sourceRoot":"","sources":["../../src/runner/orphanScan.ts"],"names":[],"mappings":"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"}
|
package/dist/runner/run.d.ts
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import type { ResolvedConfig } from '../config.ts';
|
|
2
|
-
import type { RunResult } from '../schema/result.ts';
|
|
2
|
+
import type { EnvironmentReport, RunResult, StoryResult, StoryStatus } from '../schema/result.ts';
|
|
3
|
+
import { type RunMode } from './mode.ts';
|
|
4
|
+
import { type CapturedBrowser } from './manifest.ts';
|
|
3
5
|
export interface RunCliOptions {
|
|
4
6
|
storyFilter?: string;
|
|
5
7
|
headed: boolean;
|
|
6
8
|
workers?: number;
|
|
7
9
|
manageServers?: boolean;
|
|
8
10
|
coverage?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Resolved comparison contract (see {@link RunMode}). Governs baseline-write
|
|
13
|
+
* vs candidate-write in `runAction` and the run's exit-code derivation. The
|
|
14
|
+
* CLI always supplies it (resolved from `--ci`/`--local`/`$CI`); it stays
|
|
15
|
+
* optional so existing programmatic callers keep compiling, defaulting to
|
|
16
|
+
* `local` (the legacy auto-write behaviour) when omitted.
|
|
17
|
+
*/
|
|
18
|
+
mode?: RunMode;
|
|
9
19
|
}
|
|
10
20
|
/**
|
|
11
21
|
* Loads every action and story from the configured paths, resets the
|
|
@@ -15,4 +25,78 @@ export interface RunCliOptions {
|
|
|
15
25
|
* code.
|
|
16
26
|
*/
|
|
17
27
|
export declare function runAll(config: ResolvedConfig, options: RunCliOptions): Promise<RunResult>;
|
|
28
|
+
/**
|
|
29
|
+
* Copies the just-written `results.json` into `<report>/candidates/` so the
|
|
30
|
+
* candidate tree is a self-contained approval artifact. `writeReport` has
|
|
31
|
+
* already written `<report>/results.json`; this places a sibling copy under
|
|
32
|
+
* candidates. The candidates dir may not exist yet (a run where every action
|
|
33
|
+
* passed writes no candidate renders), so it is created first — an empty
|
|
34
|
+
* `candidates/` carrying only `results.json` still correctly describes "nothing
|
|
35
|
+
* to approve".
|
|
36
|
+
*/
|
|
37
|
+
export declare function copyResultsIntoCandidates(reportDir: string): Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* The baseline root the screen-coverage metric measures against: committed
|
|
40
|
+
* `paths.baselines` in CI mode, per-machine `paths.localCache` in local mode.
|
|
41
|
+
* A thin, coverage-named alias over the shared {@link comparisonRootFor} so the
|
|
42
|
+
* metric counts screens against the SAME set the run (via `runAction`) diffed
|
|
43
|
+
* against — the mode→root mapping now lives once, in `mode.ts`. Preserved as a
|
|
44
|
+
* named export because the coverage metric's PRD invariant (a local run never
|
|
45
|
+
* reads `paths.baselines`) reads most clearly against a coverage-specific name.
|
|
46
|
+
*/
|
|
47
|
+
export declare function coverageComparisonRoot(config: ResolvedConfig, mode: RunMode): string;
|
|
48
|
+
/**
|
|
49
|
+
* Builds the run's {@link EnvironmentReport}: the environment this run captured
|
|
50
|
+
* under (`actual`), and — in CI mode — the committed `<baselines>/manifest.json`
|
|
51
|
+
* (`expected`) plus whether their pixel-affecting keys diverge.
|
|
52
|
+
*
|
|
53
|
+
* The live browser-version probe ({@link captureBrowserIdentity}) runs in CI
|
|
54
|
+
* mode ONLY. Local mode never reads `paths.baselines`, so `expected` is always
|
|
55
|
+
* `null` and `browserVersion` is never compared against anything — launching a
|
|
56
|
+
* throwaway chromium just to stamp a value nothing reads is pure cost, so local
|
|
57
|
+
* mode fills the browser identity with an empty sentinel and skips the probe.
|
|
58
|
+
*
|
|
59
|
+
* In CI mode a missing manifest is the bootstrap case (no expectation yet, no
|
|
60
|
+
* mismatch); a malformed one surfaces as a mismatch note (see
|
|
61
|
+
* {@link compareEnvironment}). `expected` carries the parsed manifest only when
|
|
62
|
+
* it read cleanly, so a malformed file reports `null` here while still driving a
|
|
63
|
+
* mismatch.
|
|
64
|
+
*
|
|
65
|
+
* `probe` is the live browser-identity reader, injected (defaulting to the real
|
|
66
|
+
* {@link captureBrowserIdentity}) so a test can assert it is invoked in CI mode
|
|
67
|
+
* and NEVER invoked in local mode without launching a real chromium.
|
|
68
|
+
*/
|
|
69
|
+
export declare function resolveEnvironmentReport(config: ResolvedConfig, mode: RunMode, probe?: () => Promise<CapturedBrowser>): Promise<EnvironmentReport>;
|
|
70
|
+
/**
|
|
71
|
+
* Rolls a set of story results into the outcome counts. `deleted` counts
|
|
72
|
+
* orphaned baselines, which are a run-level detection (not a per-story outcome),
|
|
73
|
+
* so it always seeds `0` here; `runAll` overwrites the run-total `deleted` from
|
|
74
|
+
* the orphan scan. Per-breakpoint pass summaries keep `0`, which is correct —
|
|
75
|
+
* an orphan is not attributable to a single breakpoint pass.
|
|
76
|
+
*/
|
|
77
|
+
export declare function summarise(results: StoryResult[]): RunResult['totals'];
|
|
78
|
+
/**
|
|
79
|
+
* One streaming finish line: `<symbol> <actionCount> <elapsed>s <stem>`. The
|
|
80
|
+
* count is how many steps the story ran, the elapsed time is wall-clock
|
|
81
|
+
* duration to hundredths of a second, and the stem is the story file without
|
|
82
|
+
* its `.json` extension.
|
|
83
|
+
*/
|
|
84
|
+
export declare function formatResultLine(status: StoryStatus, actionCount: number, durationMs: number, file: string): string;
|
|
85
|
+
/**
|
|
86
|
+
* One Summary bullet for a breakpoint pass: `• <parts> on "<name>" breakpoint`,
|
|
87
|
+
* where `<parts>` joins only the nonzero outcome categories in a fixed order
|
|
88
|
+
* (passed, new, changed, failed) — e.g. `2 passed, 1 changed`. Falls back to
|
|
89
|
+
* `0 passed` for the degenerate all-zero pass the run loop never actually
|
|
90
|
+
* emits (it `continue`s past a pass with no participating stories).
|
|
91
|
+
*/
|
|
92
|
+
export declare function formatSummaryBullet(name: string, counts: RunResult['totals']): string;
|
|
93
|
+
/**
|
|
94
|
+
* The breakpoint names that drove a merged story to its status — the modes the
|
|
95
|
+
* reader actually needs to inspect. A `changed` story that drifted only at
|
|
96
|
+
* `desktop` tags `[desktop]`, not the `mobile` pass that stayed clean. Returns
|
|
97
|
+
* empty (no tag) when the story spans a single breakpoint, since there is
|
|
98
|
+
* nothing to disambiguate. The merged actions still carry their `breakpoint`
|
|
99
|
+
* tag, so this is derived, not separately tracked.
|
|
100
|
+
*/
|
|
101
|
+
export declare function drivingBreakpoints(result: StoryResult): string[];
|
|
18
102
|
//# sourceMappingURL=run.d.ts.map
|
package/dist/runner/run.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/runner/run.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/runner/run.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAsB,cAAc,EAAE,MAAM,cAAc,CAAC;AAEvE,OAAO,KAAK,EACV,iBAAiB,EACjB,SAAS,EACT,WAAW,EACX,WAAW,EACZ,MAAM,qBAAqB,CAAC;AA8B7B,OAAO,EAAqB,KAAK,OAAO,EAAE,MAAM,WAAW,CAAC;AAC5D,OAAO,EAIL,KAAK,eAAe,EAErB,MAAM,eAAe,CAAC;AAGvB,MAAM,WAAW,aAAa;IAC5B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAUD;;;;;;GAMG;AACH,wBAAsB,MAAM,CAC1B,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,aAAa,GACrB,OAAO,CAAC,SAAS,CAAC,CA0KpB;AA4DD;;;;;;;;GAQG;AACH,wBAAsB,yBAAyB,CAC7C,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC,CAIf;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,OAAO,GACZ,MAAM,CAER;AA6BD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,wBAAwB,CAC5C,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,OAAO,EACb,KAAK,GAAE,MAAM,OAAO,CAAC,eAAe,CAA0B,GAC7D,OAAO,CAAC,iBAAiB,CAAC,CAe5B;AAED;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,WAAW,EAAE,GAAG,SAAS,CAAC,QAAQ,CAAC,CASrE;AAUD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,WAAW,EACnB,WAAW,EAAE,MAAM,EACnB,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,GACX,MAAM,CAIR;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,SAAS,CAAC,QAAQ,CAAC,GAC1B,MAAM,CAQR;AAoBD;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,EAAE,CAuBhE"}
|
package/dist/runner/run.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { copyFile, mkdir, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { cpus } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { chromium } from 'playwright';
|
|
5
6
|
import { loadActions, loadStories } from "../schema/load.js";
|
|
6
7
|
import { writeReport } from "../reporter/writeReport.js";
|
|
7
8
|
import { computeFlowCoverage } from "../coverage/flows.js";
|
|
@@ -9,9 +10,14 @@ import { computeScreenCoverage } from "../coverage/screens.js";
|
|
|
9
10
|
import { resetDatabase } from "./bridges/database.js";
|
|
10
11
|
import { startManagedDevServers, } from "./bridges/devServers.js";
|
|
11
12
|
import { CoverageCollector } from "./coverage.js";
|
|
12
|
-
import { runStory } from "./runStory.js";
|
|
13
|
+
import { mergeStoryStatus, runStory } from "./runStory.js";
|
|
13
14
|
import { buildSchedule, drainSchedule, } from "./scheduler.js";
|
|
15
|
+
import { adaptNeedsForPass, mergeStoryResults, resolveBreakpointPasses, storyRendersAt, } from "./breakpointPasses.js";
|
|
14
16
|
import { storyMatchesFilter } from "./storyFilter.js";
|
|
17
|
+
import { candidatesDir } from "../screenshots/baselineStore.js";
|
|
18
|
+
import { executedActionNames, scanOrphanedBaselines, shouldScanForOrphans, } from "./orphanScan.js";
|
|
19
|
+
import { comparisonRootFor } from "./mode.js";
|
|
20
|
+
import { captureEnvironment, compareEnvironment, readManifest, } from "./manifest.js";
|
|
15
21
|
const HEARTBEAT_FILE = '.heartbeat';
|
|
16
22
|
/**
|
|
17
23
|
* Loads every action and story from the configured paths, resets the
|
|
@@ -22,6 +28,7 @@ const HEARTBEAT_FILE = '.heartbeat';
|
|
|
22
28
|
*/
|
|
23
29
|
export async function runAll(config, options) {
|
|
24
30
|
const startedAt = new Date();
|
|
31
|
+
const mode = options.mode ?? 'local';
|
|
25
32
|
let managedServers;
|
|
26
33
|
if (options.manageServers) {
|
|
27
34
|
managedServers = await startManagedDevServers(config);
|
|
@@ -33,10 +40,6 @@ export async function runAll(config, options) {
|
|
|
33
40
|
? new CoverageCollector(config.paths.report)
|
|
34
41
|
: undefined;
|
|
35
42
|
try {
|
|
36
|
-
if (config.database?.reset) {
|
|
37
|
-
process.stdout.write('Resetting test database…\n');
|
|
38
|
-
await resetDatabase(config);
|
|
39
|
-
}
|
|
40
43
|
const actions = await loadActions(config.paths.actions);
|
|
41
44
|
const allStories = await loadStories(config.paths.stories);
|
|
42
45
|
const scheduled = buildSchedule(allStories);
|
|
@@ -47,23 +50,100 @@ export async function runAll(config, options) {
|
|
|
47
50
|
throw new Error(`No story matched filter "${options.storyFilter}"`);
|
|
48
51
|
}
|
|
49
52
|
const workerCount = resolveWorkerCount(config, options.workers);
|
|
50
|
-
|
|
51
|
-
const
|
|
53
|
+
const passes = resolveBreakpointPasses(subset, config);
|
|
54
|
+
const multiPass = passes.length > 1;
|
|
55
|
+
process.stdout.write(`Scheduling ${subset.length} stories on ${workerCount} worker${workerCount === 1 ? '' : 's'}` +
|
|
56
|
+
(multiPass ? ` across ${passes.length} breakpoint passes` : '') +
|
|
57
|
+
'.\n');
|
|
58
|
+
// Run each breakpoint as its own pass: a full reset/seed, then the whole
|
|
59
|
+
// schedule rendered at that one breakpoint. This is what keeps breakpoints
|
|
60
|
+
// isolated — a destructive story can mutate the seeded database in the
|
|
61
|
+
// `mobile` pass without the `desktop` pass ever seeing it, because the next
|
|
62
|
+
// pass starts from a fresh reset. Results are merged back per story below.
|
|
63
|
+
const partsByFile = new Map();
|
|
64
|
+
const order = [];
|
|
65
|
+
const passSummaries = [];
|
|
66
|
+
for (const breakpoint of passes) {
|
|
67
|
+
const participating = subset.filter((item) => storyRendersAt(item, config, breakpoint));
|
|
68
|
+
if (participating.length === 0)
|
|
69
|
+
continue;
|
|
70
|
+
// Header for every pass — single- and multi-breakpoint alike. The reset
|
|
71
|
+
// sits beneath the header so the "fresh database" line reads as part of
|
|
72
|
+
// this pass's setup; a trailing blank line then separates setup from the
|
|
73
|
+
// streaming result lines.
|
|
74
|
+
process.stdout.write(`\nStarting "${breakpoint.name}" breakpoint pass at ${breakpoint.width}x${breakpoint.height}\n`);
|
|
75
|
+
if (config.database?.reset) {
|
|
76
|
+
process.stdout.write('Resetting test database…\n');
|
|
77
|
+
await resetDatabase(config);
|
|
78
|
+
}
|
|
79
|
+
process.stdout.write('\n');
|
|
80
|
+
const passResults = await drainSchedule(adaptNeedsForPass(participating), workerCount, (item) => runScheduledStory(item, actions, config, options.headed, coverage, breakpoint, mode), () => { }, (_item, result) => process.stdout.write(`${formatResultLine(result.status, result.actions.length, result.durationMs, result.file)}\n`));
|
|
81
|
+
passSummaries.push({
|
|
82
|
+
name: breakpoint.name,
|
|
83
|
+
counts: summarise(passResults),
|
|
84
|
+
});
|
|
85
|
+
for (const result of passResults) {
|
|
86
|
+
let parts = partsByFile.get(result.file);
|
|
87
|
+
if (!parts) {
|
|
88
|
+
parts = [];
|
|
89
|
+
partsByFile.set(result.file, parts);
|
|
90
|
+
order.push(result.file);
|
|
91
|
+
}
|
|
92
|
+
parts.push(result);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const results = order.map((file) => mergeStoryResults(partsByFile.get(file)));
|
|
52
96
|
const finishedAt = new Date();
|
|
97
|
+
// Screen coverage measures how many screens have a baseline in the set this
|
|
98
|
+
// run compares against — the committed `paths.baselines` in CI mode, the
|
|
99
|
+
// per-machine `paths.localCache` in local mode. Local runs never read
|
|
100
|
+
// `paths.baselines` (PRD invariant). Both the metric and `runAction` pick
|
|
101
|
+
// their root through the shared `comparisonRootFor`, so the metric measures
|
|
102
|
+
// against the exact set the run diffed against; its meaning ("screens with a
|
|
103
|
+
// baseline to diff against") is preserved rather than blanked.
|
|
53
104
|
const [screens, flows] = await Promise.all([
|
|
54
|
-
computeScreenCoverage(config.paths.actions, config
|
|
105
|
+
computeScreenCoverage(config.paths.actions, coverageComparisonRoot(config, mode)),
|
|
55
106
|
computeFlowCoverage(config.flowInventory, allStories),
|
|
56
107
|
]);
|
|
108
|
+
// Orphan scan: committed baselines whose action ran no story this run are
|
|
109
|
+
// retired candidates (status `deleted`). Only meaningful for an UNFILTERED
|
|
110
|
+
// CI run — a `--story` filter runs a deliberate subset, so unselected
|
|
111
|
+
// stories' baselines would look orphaned when they are merely unvisited; we
|
|
112
|
+
// skip the scan rather than mark live baselines deleted. Local mode never
|
|
113
|
+
// reads `paths.baselines`, so it never scans. Detection only — pruning is a
|
|
114
|
+
// later wave, so the baselines directory stays untouched here.
|
|
115
|
+
const deleted = shouldScanForOrphans(mode, options.storyFilter)
|
|
116
|
+
? await scanOrphanedBaselines(config.paths.baselines, executedActionNames(results))
|
|
117
|
+
: [];
|
|
118
|
+
const totals = summarise(results);
|
|
119
|
+
totals.deleted = deleted.length;
|
|
120
|
+
// Capture-environment provenance: what this run rendered under, and — in CI
|
|
121
|
+
// mode — whether it drifted from the committed baselines' manifest. Local
|
|
122
|
+
// mode never reads `paths.baselines`, so `expected` stays null and mismatch
|
|
123
|
+
// is always false (see resolveEnvironmentReport).
|
|
124
|
+
const environment = await resolveEnvironmentReport(config, mode);
|
|
57
125
|
const runResult = {
|
|
58
126
|
startedAt: startedAt.toISOString(),
|
|
59
127
|
finishedAt: finishedAt.toISOString(),
|
|
60
128
|
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
|
61
|
-
|
|
129
|
+
mode,
|
|
130
|
+
totals,
|
|
131
|
+
environment,
|
|
62
132
|
customCoverage: { screens, flows },
|
|
133
|
+
deleted,
|
|
63
134
|
stories: results,
|
|
64
135
|
};
|
|
65
|
-
const reportPath = await writeReport(config.paths.report, runResult);
|
|
66
|
-
|
|
136
|
+
const reportPath = await writeReport(config.paths.report, runResult, config.interactiveMode);
|
|
137
|
+
// In CI mode the `<report>/candidates/` tree is the self-contained approval
|
|
138
|
+
// artifact — copy the run's `results.json` beside the candidate renders so a
|
|
139
|
+
// downstream `approve --from <candidates>` has the outcome data (which
|
|
140
|
+
// actions are new/changed, and later the environment/deleted blocks) without
|
|
141
|
+
// needing the rest of the report dir. Only meaningful in CI mode, where the
|
|
142
|
+
// candidate tree exists.
|
|
143
|
+
if (mode === 'ci') {
|
|
144
|
+
await copyResultsIntoCandidates(config.paths.report);
|
|
145
|
+
}
|
|
146
|
+
writeRunSummary(passSummaries, reportPath);
|
|
67
147
|
if (coverage) {
|
|
68
148
|
const coveragePath = await coverage.generate();
|
|
69
149
|
process.stdout.write(`Coverage: ${coveragePath}\n`);
|
|
@@ -76,7 +156,7 @@ export async function runAll(config, options) {
|
|
|
76
156
|
}
|
|
77
157
|
}
|
|
78
158
|
}
|
|
79
|
-
function runScheduledStory(item, actions, config, headed, coverage) {
|
|
159
|
+
function runScheduledStory(item, actions, config, headed, coverage, breakpoint, mode) {
|
|
80
160
|
return runStory({
|
|
81
161
|
story: item.story,
|
|
82
162
|
file: item.file,
|
|
@@ -86,6 +166,8 @@ function runScheduledStory(item, actions, config, headed, coverage) {
|
|
|
86
166
|
config,
|
|
87
167
|
headed,
|
|
88
168
|
coverage,
|
|
169
|
+
breakpoint,
|
|
170
|
+
mode,
|
|
89
171
|
});
|
|
90
172
|
}
|
|
91
173
|
function matchesFilter(item, filter) {
|
|
@@ -111,44 +193,188 @@ async function touchHeartbeat(config) {
|
|
|
111
193
|
// hiccup should not fail the entire run.
|
|
112
194
|
}
|
|
113
195
|
}
|
|
114
|
-
|
|
196
|
+
/**
|
|
197
|
+
* Copies the just-written `results.json` into `<report>/candidates/` so the
|
|
198
|
+
* candidate tree is a self-contained approval artifact. `writeReport` has
|
|
199
|
+
* already written `<report>/results.json`; this places a sibling copy under
|
|
200
|
+
* candidates. The candidates dir may not exist yet (a run where every action
|
|
201
|
+
* passed writes no candidate renders), so it is created first — an empty
|
|
202
|
+
* `candidates/` carrying only `results.json` still correctly describes "nothing
|
|
203
|
+
* to approve".
|
|
204
|
+
*/
|
|
205
|
+
export async function copyResultsIntoCandidates(reportDir) {
|
|
206
|
+
const dir = candidatesDir(reportDir);
|
|
207
|
+
await mkdir(dir, { recursive: true });
|
|
208
|
+
await copyFile(join(reportDir, 'results.json'), join(dir, 'results.json'));
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* The baseline root the screen-coverage metric measures against: committed
|
|
212
|
+
* `paths.baselines` in CI mode, per-machine `paths.localCache` in local mode.
|
|
213
|
+
* A thin, coverage-named alias over the shared {@link comparisonRootFor} so the
|
|
214
|
+
* metric counts screens against the SAME set the run (via `runAction`) diffed
|
|
215
|
+
* against — the mode→root mapping now lives once, in `mode.ts`. Preserved as a
|
|
216
|
+
* named export because the coverage metric's PRD invariant (a local run never
|
|
217
|
+
* reads `paths.baselines`) reads most clearly against a coverage-specific name.
|
|
218
|
+
*/
|
|
219
|
+
export function coverageComparisonRoot(config, mode) {
|
|
220
|
+
return comparisonRootFor(config, mode);
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Reads the launched Chromium's `browser.version()` so the run's environment
|
|
224
|
+
* manifest records the exact browser build the baselines were rendered against —
|
|
225
|
+
* the single pixel-affecting fact that only a live browser can report. Launched
|
|
226
|
+
* throwaway (not the per-story browsers, which spin up and tear down inside the
|
|
227
|
+
* schedule) because the version is invariant across stories, so one probe covers
|
|
228
|
+
* the whole run. Kept narrow: launch, read, close.
|
|
229
|
+
*/
|
|
230
|
+
async function captureBrowserIdentity() {
|
|
231
|
+
const browser = await chromium.launch({ headless: true });
|
|
232
|
+
try {
|
|
233
|
+
return { name: 'chromium', version: browser.version() };
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
await browser.close();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* The browser identity stamped into a LOCAL run's environment block. Local mode
|
|
241
|
+
* skips the live probe (see {@link resolveEnvironmentReport}), so `version` is an
|
|
242
|
+
* empty sentinel: it is shape-valid for the manifest, never compared (local
|
|
243
|
+
* `expected` is always null), and never promoted (local candidates are refused
|
|
244
|
+
* by `approve --from`). Kept as chromium's name for provenance, since that is
|
|
245
|
+
* still the browser a local run renders under.
|
|
246
|
+
*/
|
|
247
|
+
const LOCAL_BROWSER_IDENTITY = { name: 'chromium', version: '' };
|
|
248
|
+
/**
|
|
249
|
+
* Builds the run's {@link EnvironmentReport}: the environment this run captured
|
|
250
|
+
* under (`actual`), and — in CI mode — the committed `<baselines>/manifest.json`
|
|
251
|
+
* (`expected`) plus whether their pixel-affecting keys diverge.
|
|
252
|
+
*
|
|
253
|
+
* The live browser-version probe ({@link captureBrowserIdentity}) runs in CI
|
|
254
|
+
* mode ONLY. Local mode never reads `paths.baselines`, so `expected` is always
|
|
255
|
+
* `null` and `browserVersion` is never compared against anything — launching a
|
|
256
|
+
* throwaway chromium just to stamp a value nothing reads is pure cost, so local
|
|
257
|
+
* mode fills the browser identity with an empty sentinel and skips the probe.
|
|
258
|
+
*
|
|
259
|
+
* In CI mode a missing manifest is the bootstrap case (no expectation yet, no
|
|
260
|
+
* mismatch); a malformed one surfaces as a mismatch note (see
|
|
261
|
+
* {@link compareEnvironment}). `expected` carries the parsed manifest only when
|
|
262
|
+
* it read cleanly, so a malformed file reports `null` here while still driving a
|
|
263
|
+
* mismatch.
|
|
264
|
+
*
|
|
265
|
+
* `probe` is the live browser-identity reader, injected (defaulting to the real
|
|
266
|
+
* {@link captureBrowserIdentity}) so a test can assert it is invoked in CI mode
|
|
267
|
+
* and NEVER invoked in local mode without launching a real chromium.
|
|
268
|
+
*/
|
|
269
|
+
export async function resolveEnvironmentReport(config, mode, probe = captureBrowserIdentity) {
|
|
270
|
+
if (mode !== 'ci') {
|
|
271
|
+
// Local mode never gates on `browserVersion` (expected is always null), so
|
|
272
|
+
// skip the browser launch entirely. The empty sentinel is shape-valid and
|
|
273
|
+
// never promoted — a local run writes no candidate tree, and `approve --from`
|
|
274
|
+
// refuses non-ci candidates outright.
|
|
275
|
+
const actual = captureEnvironment(config, LOCAL_BROWSER_IDENTITY);
|
|
276
|
+
return { expected: null, actual, mismatch: false, mismatchKeys: [] };
|
|
277
|
+
}
|
|
278
|
+
const actual = captureEnvironment(config, await probe());
|
|
279
|
+
const read = await readManifest(config.paths.baselines);
|
|
280
|
+
const { mismatch, mismatchKeys } = compareEnvironment(read, actual);
|
|
281
|
+
const expected = read.status === 'ok' ? read.manifest : null;
|
|
282
|
+
return { expected, actual, mismatch, mismatchKeys };
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Rolls a set of story results into the outcome counts. `deleted` counts
|
|
286
|
+
* orphaned baselines, which are a run-level detection (not a per-story outcome),
|
|
287
|
+
* so it always seeds `0` here; `runAll` overwrites the run-total `deleted` from
|
|
288
|
+
* the orphan scan. Per-breakpoint pass summaries keep `0`, which is correct —
|
|
289
|
+
* an orphan is not attributable to a single breakpoint pass.
|
|
290
|
+
*/
|
|
291
|
+
export function summarise(results) {
|
|
115
292
|
return {
|
|
116
293
|
stories: results.length,
|
|
117
294
|
passed: results.filter((result) => result.status === 'pass').length,
|
|
118
295
|
changed: results.filter((result) => result.status === 'changed').length,
|
|
119
296
|
failed: results.filter((result) => result.status === 'failed').length,
|
|
297
|
+
new: results.filter((result) => result.status === 'new').length,
|
|
298
|
+
deleted: 0,
|
|
120
299
|
};
|
|
121
300
|
}
|
|
301
|
+
/** Status → leading glyph for a streaming result line. */
|
|
302
|
+
const STATUS_SYMBOL = {
|
|
303
|
+
pass: '✓',
|
|
304
|
+
changed: '~',
|
|
305
|
+
new: '+',
|
|
306
|
+
failed: '✗',
|
|
307
|
+
};
|
|
122
308
|
/**
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* a `file://` URL so terminals that recognise file URIs (iTerm2, Warp, VS
|
|
128
|
-
* Code) render it as a clickable link.
|
|
309
|
+
* One streaming finish line: `<symbol> <actionCount> <elapsed>s <stem>`. The
|
|
310
|
+
* count is how many steps the story ran, the elapsed time is wall-clock
|
|
311
|
+
* duration to hundredths of a second, and the stem is the story file without
|
|
312
|
+
* its `.json` extension.
|
|
129
313
|
*/
|
|
130
|
-
function
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
314
|
+
export function formatResultLine(status, actionCount, durationMs, file) {
|
|
315
|
+
const elapsed = (durationMs / 1000).toFixed(2);
|
|
316
|
+
const stem = file.replace(/\.json$/, '');
|
|
317
|
+
return `${STATUS_SYMBOL[status]} ${actionCount} ${elapsed}s ${stem}`;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* One Summary bullet for a breakpoint pass: `• <parts> on "<name>" breakpoint`,
|
|
321
|
+
* where `<parts>` joins only the nonzero outcome categories in a fixed order
|
|
322
|
+
* (passed, new, changed, failed) — e.g. `2 passed, 1 changed`. Falls back to
|
|
323
|
+
* `0 passed` for the degenerate all-zero pass the run loop never actually
|
|
324
|
+
* emits (it `continue`s past a pass with no participating stories).
|
|
325
|
+
*/
|
|
326
|
+
export function formatSummaryBullet(name, counts) {
|
|
327
|
+
const parts = [];
|
|
328
|
+
if (counts.passed > 0)
|
|
329
|
+
parts.push(`${counts.passed} passed`);
|
|
330
|
+
if (counts.new > 0)
|
|
331
|
+
parts.push(`${counts.new} new`);
|
|
332
|
+
if (counts.changed > 0)
|
|
333
|
+
parts.push(`${counts.changed} changed`);
|
|
334
|
+
if (counts.failed > 0)
|
|
335
|
+
parts.push(`${counts.failed} failed`);
|
|
336
|
+
const summary = parts.length > 0 ? parts.join(', ') : '0 passed';
|
|
337
|
+
return `• ${summary} on "${name}" breakpoint`;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Emits the end-of-run tail: a `Summary` section with one bullet per breakpoint
|
|
341
|
+
* pass — counted from that pass's own results, not the merged-across-passes
|
|
342
|
+
* rollup — then the `Report:` line. The report link is a `file://` URL so
|
|
343
|
+
* terminals that recognise file URIs (iTerm2, Warp, VS Code) render it as a
|
|
344
|
+
* clickable link.
|
|
345
|
+
*/
|
|
346
|
+
function writeRunSummary(passSummaries, reportPath) {
|
|
347
|
+
process.stdout.write('\nSummary\n');
|
|
348
|
+
for (const pass of passSummaries) {
|
|
349
|
+
process.stdout.write(`${formatSummaryBullet(pass.name, pass.counts)}\n`);
|
|
147
350
|
}
|
|
148
|
-
process.stdout.write(`\
|
|
149
|
-
process.stdout.write(`Report: ${pathToFileURL(reportPath).href}\n`);
|
|
351
|
+
process.stdout.write(`\nReport: ${pathToFileURL(reportPath).href}\n`);
|
|
150
352
|
}
|
|
151
|
-
|
|
152
|
-
|
|
353
|
+
/**
|
|
354
|
+
* The breakpoint names that drove a merged story to its status — the modes the
|
|
355
|
+
* reader actually needs to inspect. A `changed` story that drifted only at
|
|
356
|
+
* `desktop` tags `[desktop]`, not the `mobile` pass that stayed clean. Returns
|
|
357
|
+
* empty (no tag) when the story spans a single breakpoint, since there is
|
|
358
|
+
* nothing to disambiguate. The merged actions still carry their `breakpoint`
|
|
359
|
+
* tag, so this is derived, not separately tracked.
|
|
360
|
+
*/
|
|
361
|
+
export function drivingBreakpoints(result) {
|
|
362
|
+
const order = [];
|
|
363
|
+
const perBreakpoint = new Map();
|
|
364
|
+
for (const action of result.actions) {
|
|
365
|
+
const breakpoint = action.breakpoint;
|
|
366
|
+
if (!breakpoint)
|
|
367
|
+
continue;
|
|
368
|
+
if (!perBreakpoint.has(breakpoint)) {
|
|
369
|
+
perBreakpoint.set(breakpoint, 'pass');
|
|
370
|
+
order.push(breakpoint);
|
|
371
|
+
}
|
|
372
|
+
perBreakpoint.set(breakpoint, mergeStoryStatus(perBreakpoint.get(breakpoint),
|
|
373
|
+
// A skipped action means an earlier action in that breakpoint failed.
|
|
374
|
+
action.status === 'skipped' ? 'failed' : action.status));
|
|
375
|
+
}
|
|
376
|
+
if (order.length <= 1)
|
|
377
|
+
return [];
|
|
378
|
+
return order.filter((breakpoint) => perBreakpoint.get(breakpoint) === result.status);
|
|
153
379
|
}
|
|
154
380
|
//# sourceMappingURL=run.js.map
|