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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -0
  3. package/bin/tuffgal.mjs +2 -0
  4. package/package.json +70 -0
  5. package/src/.gitkeep +0 -0
  6. package/src/cli.ts +158 -0
  7. package/src/commands/init.ts +140 -0
  8. package/src/commands/supervise.ts +267 -0
  9. package/src/config.ts +222 -0
  10. package/src/coverage/flows.ts +90 -0
  11. package/src/coverage/screens.ts +52 -0
  12. package/src/index.ts +28 -0
  13. package/src/reporter/assets/report.css +510 -0
  14. package/src/reporter/assets/report.js +45 -0
  15. package/src/reporter/template.ts +355 -0
  16. package/src/reporter/writeReport.ts +37 -0
  17. package/src/runner/approve.ts +65 -0
  18. package/src/runner/bridges/database.ts +34 -0
  19. package/src/runner/bridges/devServers.ts +174 -0
  20. package/src/runner/coverage.ts +76 -0
  21. package/src/runner/interpolate.ts +36 -0
  22. package/src/runner/resolveLocator.ts +47 -0
  23. package/src/runner/run.ts +177 -0
  24. package/src/runner/runAction.ts +422 -0
  25. package/src/runner/runStory.ts +195 -0
  26. package/src/runner/scheduler.ts +223 -0
  27. package/src/runner/steps/click.ts +16 -0
  28. package/src/runner/steps/input.ts +17 -0
  29. package/src/runner/steps/intercept.ts +28 -0
  30. package/src/runner/steps/navigate.ts +14 -0
  31. package/src/runner/steps/read.ts +20 -0
  32. package/src/runner/steps/scroll.ts +12 -0
  33. package/src/runner/steps/type.ts +11 -0
  34. package/src/runner/steps/wait.ts +5 -0
  35. package/src/runner/steps/waitFor.ts +16 -0
  36. package/src/schema/action.ts +176 -0
  37. package/src/schema/load.ts +94 -0
  38. package/src/schema/result.ts +83 -0
  39. package/src/schema/story.ts +58 -0
  40. package/src/screenshots/baselineStore.ts +114 -0
  41. package/src/screenshots/capture.ts +19 -0
  42. 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
+ }