tuffgal 0.1.0-alpha.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.
- package/LICENSE +21 -0
- package/README.md +86 -0
- package/bin/tuffgal.mjs +2 -0
- package/package.json +70 -0
- package/src/.gitkeep +0 -0
- package/src/cli.ts +158 -0
- package/src/commands/init.ts +140 -0
- package/src/commands/supervise.ts +267 -0
- package/src/config.ts +222 -0
- package/src/coverage/flows.ts +90 -0
- package/src/coverage/screens.ts +52 -0
- package/src/index.ts +28 -0
- package/src/reporter/assets/report.css +510 -0
- package/src/reporter/assets/report.js +45 -0
- package/src/reporter/template.ts +355 -0
- package/src/reporter/writeReport.ts +37 -0
- package/src/runner/approve.ts +65 -0
- package/src/runner/bridges/database.ts +34 -0
- package/src/runner/bridges/devServers.ts +174 -0
- package/src/runner/coverage.ts +76 -0
- package/src/runner/interpolate.ts +36 -0
- package/src/runner/resolveLocator.ts +47 -0
- package/src/runner/run.ts +177 -0
- package/src/runner/runAction.ts +422 -0
- package/src/runner/runStory.ts +195 -0
- package/src/runner/scheduler.ts +223 -0
- package/src/runner/steps/click.ts +16 -0
- package/src/runner/steps/input.ts +17 -0
- package/src/runner/steps/intercept.ts +28 -0
- package/src/runner/steps/navigate.ts +14 -0
- package/src/runner/steps/read.ts +20 -0
- package/src/runner/steps/scroll.ts +12 -0
- package/src/runner/steps/type.ts +11 -0
- package/src/runner/steps/wait.ts +5 -0
- package/src/runner/steps/waitFor.ts +16 -0
- package/src/schema/action.ts +176 -0
- package/src/schema/load.ts +94 -0
- package/src/schema/result.ts +83 -0
- package/src/schema/story.ts +58 -0
- package/src/screenshots/baselineStore.ts +114 -0
- package/src/screenshots/capture.ts +19 -0
- package/src/screenshots/diff.ts +101 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outcome model. The runner emits a `RunResult` per invocation; the reporter
|
|
3
|
+
* consumes it to render the HTML report. Status values map onto the three
|
|
4
|
+
* outcomes the framework distinguishes:
|
|
5
|
+
*
|
|
6
|
+
* - `pass` — action succeeded and screenshot matched baseline (or no baseline).
|
|
7
|
+
* - `changed` — action succeeded but screenshot drifted past the threshold.
|
|
8
|
+
* The story does not fail. The user reviews and either approves the new
|
|
9
|
+
* baseline or files a bug.
|
|
10
|
+
* - `failed` — a step threw. The story fails fast and skips any later actions.
|
|
11
|
+
*/
|
|
12
|
+
export type ActionStatus = 'pass' | 'changed' | 'failed' | 'skipped' | 'new';
|
|
13
|
+
|
|
14
|
+
export interface ActionResult {
|
|
15
|
+
action: string;
|
|
16
|
+
parameters?: Record<string, string>;
|
|
17
|
+
status: ActionStatus;
|
|
18
|
+
startedAt: string;
|
|
19
|
+
finishedAt: string;
|
|
20
|
+
durationMs: number;
|
|
21
|
+
/**
|
|
22
|
+
* Number of the step (0-indexed) that failed. Undefined if the action
|
|
23
|
+
* succeeded or was skipped without running.
|
|
24
|
+
*/
|
|
25
|
+
failedStepIndex?: number;
|
|
26
|
+
failureMessage?: string;
|
|
27
|
+
baselinePath?: string;
|
|
28
|
+
actualPath?: string;
|
|
29
|
+
diffPath?: string;
|
|
30
|
+
diffPixels?: number;
|
|
31
|
+
diffRatio?: number;
|
|
32
|
+
/** Mean SSIM score of baseline vs actual — see screenshots/diff.ts. */
|
|
33
|
+
ssimScore?: number;
|
|
34
|
+
/**
|
|
35
|
+
* `true` when the captured page accessibility tree differs from the
|
|
36
|
+
* baseline tree. Informational only — does not gate pass/changed by
|
|
37
|
+
* itself; pixel/SSIM still drives the status.
|
|
38
|
+
*/
|
|
39
|
+
a11yChanged?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type StoryStatus = 'pass' | 'changed' | 'failed';
|
|
43
|
+
|
|
44
|
+
export interface StoryResult {
|
|
45
|
+
story: string;
|
|
46
|
+
file: string;
|
|
47
|
+
status: StoryStatus;
|
|
48
|
+
startedAt: string;
|
|
49
|
+
finishedAt: string;
|
|
50
|
+
durationMs: number;
|
|
51
|
+
actions: ActionResult[];
|
|
52
|
+
/** Absolute path to the Playwright trace zip when the story failed. */
|
|
53
|
+
tracePath?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface CoverageMetric {
|
|
57
|
+
total: number;
|
|
58
|
+
covered: number;
|
|
59
|
+
ratio: number;
|
|
60
|
+
missing: string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RunResult {
|
|
64
|
+
startedAt: string;
|
|
65
|
+
finishedAt: string;
|
|
66
|
+
durationMs: number;
|
|
67
|
+
totals: {
|
|
68
|
+
stories: number;
|
|
69
|
+
passed: number;
|
|
70
|
+
changed: number;
|
|
71
|
+
failed: number;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Custom coverage metrics layered on top of V8 line coverage:
|
|
75
|
+
* `screens` = baselined visit-* actions / declared screens,
|
|
76
|
+
* `flows` = stories with `flow` tag / journeys in `flowInventory`.
|
|
77
|
+
*/
|
|
78
|
+
customCoverage: {
|
|
79
|
+
screens: CoverageMetric;
|
|
80
|
+
flows: CoverageMetric;
|
|
81
|
+
};
|
|
82
|
+
stories: StoryResult[];
|
|
83
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const storyStepSchema = z.object({
|
|
4
|
+
action: z.string().min(1),
|
|
5
|
+
parameters: z.record(z.string(), z.string()).optional(),
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export type StoryStep = z.infer<typeof storyStepSchema>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Stories declare prerequisite **labels** through `needs` and emit
|
|
12
|
+
* **labels** through `produces`. The harness uses these to build a
|
|
13
|
+
* dependency DAG, validate uniqueness, detect cycles, and inherit storage
|
|
14
|
+
* state from a producer onto its consumers. A produced label's storage
|
|
15
|
+
* state lives at `.auth/<label>.json` and is loaded as the initial state
|
|
16
|
+
* for every consumer.
|
|
17
|
+
*
|
|
18
|
+
* `storageState: "logged-in"` is retained as syntactic sugar — equivalent
|
|
19
|
+
* to `needs: ["logged-in"]`. New stories should prefer the explicit form.
|
|
20
|
+
*/
|
|
21
|
+
export const storySchema = z.object({
|
|
22
|
+
story: z.string().min(1),
|
|
23
|
+
storageState: z.enum(['fresh', 'logged-in']).optional(),
|
|
24
|
+
needs: z.array(z.string().min(1)).optional(),
|
|
25
|
+
produces: z.array(z.string().min(1)).optional(),
|
|
26
|
+
/**
|
|
27
|
+
* Named fixtures (see `runner/fixtures/`) applied to the test database
|
|
28
|
+
* BEFORE the story's browser context launches. Useful for stories that
|
|
29
|
+
* need preloaded state — e.g. a "mark all as read" flow needs links to
|
|
30
|
+
* exist first. Fixtures apply sequentially in declaration order. Note
|
|
31
|
+
* that the test DB is shared across parallel stories, so two stories
|
|
32
|
+
* applying conflicting fixtures should be serialised via `needs`/
|
|
33
|
+
* `produces` labels.
|
|
34
|
+
*/
|
|
35
|
+
fixtures: z.array(z.string().min(1)).optional(),
|
|
36
|
+
/**
|
|
37
|
+
* Tag declaring which user-journey row in the configured
|
|
38
|
+
* `flowInventory` markdown table this story covers. Used to compute
|
|
39
|
+
* flow-coverage in the report. Match is case- and
|
|
40
|
+
* whitespace-insensitive.
|
|
41
|
+
*/
|
|
42
|
+
flow: z.string().min(1).optional(),
|
|
43
|
+
actions: z.array(storyStepSchema).min(1),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export type Story = z.infer<typeof storySchema>;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Normalises `storageState` into the canonical `needs` array so the
|
|
50
|
+
* scheduler only has one input format to reason about.
|
|
51
|
+
*/
|
|
52
|
+
export function normalisedNeeds(story: Story): string[] {
|
|
53
|
+
const explicit = story.needs ?? [];
|
|
54
|
+
if (story.storageState === 'logged-in' && !explicit.includes('logged-in')) {
|
|
55
|
+
return [...explicit, 'logged-in'];
|
|
56
|
+
}
|
|
57
|
+
return explicit;
|
|
58
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {
|
|
2
|
+
access,
|
|
3
|
+
copyFile,
|
|
4
|
+
mkdir,
|
|
5
|
+
readFile,
|
|
6
|
+
rm,
|
|
7
|
+
writeFile,
|
|
8
|
+
} from 'node:fs/promises';
|
|
9
|
+
import { dirname, join, relative } from 'node:path';
|
|
10
|
+
|
|
11
|
+
export interface BaselinePaths {
|
|
12
|
+
baseline: string;
|
|
13
|
+
actual: string;
|
|
14
|
+
diff: string;
|
|
15
|
+
a11yBaseline: string;
|
|
16
|
+
a11yActual: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface StoreOptions {
|
|
20
|
+
baselinesDir: string;
|
|
21
|
+
reportDir: string;
|
|
22
|
+
storyFile: string;
|
|
23
|
+
actionName: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Computes deterministic paths for the baseline (committed), actual
|
|
28
|
+
* (regenerated each run), and diff (regenerated when a baseline existed and
|
|
29
|
+
* the diff was non-zero) PNGs. Centralised so the runner and the CLI's
|
|
30
|
+
* `approve` command agree on layout.
|
|
31
|
+
*/
|
|
32
|
+
export function pathsFor(options: StoreOptions): BaselinePaths {
|
|
33
|
+
const storySlug = options.storyFile.replace(/\.json$/i, '');
|
|
34
|
+
return {
|
|
35
|
+
baseline: join(options.baselinesDir, options.actionName, '0.png'),
|
|
36
|
+
actual: join(
|
|
37
|
+
options.reportDir,
|
|
38
|
+
'screenshots',
|
|
39
|
+
storySlug,
|
|
40
|
+
`${options.actionName}.actual.png`,
|
|
41
|
+
),
|
|
42
|
+
diff: join(
|
|
43
|
+
options.reportDir,
|
|
44
|
+
'screenshots',
|
|
45
|
+
storySlug,
|
|
46
|
+
`${options.actionName}.diff.png`,
|
|
47
|
+
),
|
|
48
|
+
a11yBaseline: join(options.baselinesDir, options.actionName, 'a11y.yaml'),
|
|
49
|
+
a11yActual: join(
|
|
50
|
+
options.reportDir,
|
|
51
|
+
'screenshots',
|
|
52
|
+
storySlug,
|
|
53
|
+
`${options.actionName}.a11y.yaml`,
|
|
54
|
+
),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function readBaseline(path: string): Promise<Buffer | undefined> {
|
|
59
|
+
try {
|
|
60
|
+
await access(path);
|
|
61
|
+
} catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
return readFile(path);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function readJsonBaseline(
|
|
68
|
+
path: string,
|
|
69
|
+
): Promise<string | undefined> {
|
|
70
|
+
try {
|
|
71
|
+
await access(path);
|
|
72
|
+
} catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
return readFile(path, 'utf8');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function writeText(path: string, content: string): Promise<void> {
|
|
79
|
+
await mkdir(dirname(path), { recursive: true });
|
|
80
|
+
await writeFile(path, content, 'utf8');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function writePng(path: string, png: Buffer): Promise<void> {
|
|
84
|
+
await mkdir(dirname(path), { recursive: true });
|
|
85
|
+
await writeFile(path, png);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function deleteIfExists(path: string): Promise<void> {
|
|
89
|
+
try {
|
|
90
|
+
await rm(path);
|
|
91
|
+
} catch {
|
|
92
|
+
// file was not there; nothing to do
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function copyToBaseline(
|
|
97
|
+
actualPath: string,
|
|
98
|
+
baselinePath: string,
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
await mkdir(dirname(baselinePath), { recursive: true });
|
|
101
|
+
await copyFile(actualPath, baselinePath);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Turns an absolute screenshot path into a path that is relative to the
|
|
106
|
+
* generated report directory so the HTML report can reference it with a
|
|
107
|
+
* portable `src` attribute.
|
|
108
|
+
*/
|
|
109
|
+
export function pathRelativeToReport(
|
|
110
|
+
reportDir: string,
|
|
111
|
+
absolute: string,
|
|
112
|
+
): string {
|
|
113
|
+
return relative(reportDir, absolute);
|
|
114
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Locator, Page } from 'playwright';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Full-page screenshot with animations disabled, caret hidden, and any masks
|
|
5
|
+
* applied so the same UI renders to bit-identical pixels across runs. Masks
|
|
6
|
+
* are Playwright locators whose bounding boxes are blacked out before the
|
|
7
|
+
* image is encoded — use them to neutralise randomised or time-based regions.
|
|
8
|
+
*/
|
|
9
|
+
export async function capturePage(
|
|
10
|
+
page: Page,
|
|
11
|
+
masks: Locator[] = [],
|
|
12
|
+
): Promise<Buffer> {
|
|
13
|
+
return page.screenshot({
|
|
14
|
+
fullPage: true,
|
|
15
|
+
animations: 'disabled',
|
|
16
|
+
caret: 'hide',
|
|
17
|
+
mask: masks,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import pixelmatch from 'pixelmatch';
|
|
2
|
+
import { PNG } from 'pngjs';
|
|
3
|
+
import { ssim as computeSsim } from 'ssim.js';
|
|
4
|
+
|
|
5
|
+
export interface DiffOutcome {
|
|
6
|
+
diffPng: Buffer;
|
|
7
|
+
diffPixels: number;
|
|
8
|
+
totalPixels: number;
|
|
9
|
+
diffRatio: number;
|
|
10
|
+
/**
|
|
11
|
+
* Mean Structural Similarity score for the two images. 1.0 = identical;
|
|
12
|
+
* 0.99 = very close (sub-pixel layout shifts, font rendering); 0.95 =
|
|
13
|
+
* noticeable change; under 0.9 = obvious change. SSIM is more
|
|
14
|
+
* perceptually accurate than pixel-by-pixel diffing because it weights
|
|
15
|
+
* pixels by their structural context.
|
|
16
|
+
*/
|
|
17
|
+
ssimScore: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Compares two PNG buffers. Computes SSIM (the perceptual gate) and a
|
|
22
|
+
* pixelmatch overlay (the visualisation). Both are returned so the
|
|
23
|
+
* runner can use SSIM for pass/changed decisions while the reporter
|
|
24
|
+
* still has a red-highlight diff image to show the human.
|
|
25
|
+
*
|
|
26
|
+
* Dimension mismatch is a regression on its own — fail loudly.
|
|
27
|
+
*/
|
|
28
|
+
export function diffPngs(
|
|
29
|
+
baseline: Buffer,
|
|
30
|
+
actual: Buffer,
|
|
31
|
+
pixelThreshold: number,
|
|
32
|
+
): DiffOutcome {
|
|
33
|
+
const baselinePng = PNG.sync.read(baseline);
|
|
34
|
+
const actualPng = PNG.sync.read(actual);
|
|
35
|
+
if (
|
|
36
|
+
baselinePng.width !== actualPng.width ||
|
|
37
|
+
baselinePng.height !== actualPng.height
|
|
38
|
+
) {
|
|
39
|
+
throw new ScreenshotSizeMismatchError(
|
|
40
|
+
{ width: baselinePng.width, height: baselinePng.height },
|
|
41
|
+
{ width: actualPng.width, height: actualPng.height },
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const diffPng = new PNG({
|
|
45
|
+
width: baselinePng.width,
|
|
46
|
+
height: baselinePng.height,
|
|
47
|
+
});
|
|
48
|
+
const diffPixels = pixelmatch(
|
|
49
|
+
baselinePng.data,
|
|
50
|
+
actualPng.data,
|
|
51
|
+
diffPng.data,
|
|
52
|
+
baselinePng.width,
|
|
53
|
+
baselinePng.height,
|
|
54
|
+
{ threshold: pixelThreshold },
|
|
55
|
+
);
|
|
56
|
+
const totalPixels = baselinePng.width * baselinePng.height;
|
|
57
|
+
const ssimResult = computeSsim(
|
|
58
|
+
{
|
|
59
|
+
data: new Uint8ClampedArray(
|
|
60
|
+
baselinePng.data.buffer,
|
|
61
|
+
baselinePng.data.byteOffset,
|
|
62
|
+
baselinePng.data.byteLength,
|
|
63
|
+
),
|
|
64
|
+
width: baselinePng.width,
|
|
65
|
+
height: baselinePng.height,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
data: new Uint8ClampedArray(
|
|
69
|
+
actualPng.data.buffer,
|
|
70
|
+
actualPng.data.byteOffset,
|
|
71
|
+
actualPng.data.byteLength,
|
|
72
|
+
),
|
|
73
|
+
width: actualPng.width,
|
|
74
|
+
height: actualPng.height,
|
|
75
|
+
},
|
|
76
|
+
{ ssim: 'bezkrovny' },
|
|
77
|
+
);
|
|
78
|
+
return {
|
|
79
|
+
diffPng: PNG.sync.write(diffPng),
|
|
80
|
+
diffPixels,
|
|
81
|
+
totalPixels,
|
|
82
|
+
diffRatio: diffPixels / totalPixels,
|
|
83
|
+
ssimScore: ssimResult.mssim,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class ScreenshotSizeMismatchError extends Error {
|
|
88
|
+
readonly baseline: { width: number; height: number };
|
|
89
|
+
readonly actual: { width: number; height: number };
|
|
90
|
+
constructor(
|
|
91
|
+
baseline: { width: number; height: number },
|
|
92
|
+
actual: { width: number; height: number },
|
|
93
|
+
) {
|
|
94
|
+
super(
|
|
95
|
+
`Screenshot dimensions changed: baseline ${baseline.width}x${baseline.height}, actual ${actual.width}x${actual.height}`,
|
|
96
|
+
);
|
|
97
|
+
this.name = 'ScreenshotSizeMismatchError';
|
|
98
|
+
this.baseline = baseline;
|
|
99
|
+
this.actual = actual;
|
|
100
|
+
}
|
|
101
|
+
}
|