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,76 @@
1
+ import { join } from 'node:path';
2
+ import type { Page } from 'playwright';
3
+ import type MCR from 'monocart-coverage-reports';
4
+
5
+ type CoverageReportInstance = MCR.CoverageReport;
6
+
7
+ const PLAYWRIGHT_COVERAGE_OPTIONS = { resetOnNavigation: false } as const;
8
+
9
+ /**
10
+ * Collects V8 JS + CSS coverage from every Playwright page the harness
11
+ * opens during a run, then hands the aggregate to monocart-coverage-reports
12
+ * for V8, Istanbul, and console summary output. Disabled by default — turn
13
+ * on with `tuffgal run --coverage`.
14
+ *
15
+ * Two stages:
16
+ * 1. `startForPage(page)` — call after every `newPage` so coverage spans
17
+ * every navigation the story performs.
18
+ * 2. `stopForPage(page)` — call before `context.close()` to drain the
19
+ * entries into the aggregate.
20
+ *
21
+ * `generate()` runs once at end-of-run.
22
+ */
23
+ export class CoverageCollector {
24
+ private mcr: CoverageReportInstance | undefined;
25
+ private readonly outputDir: string;
26
+ private initialised = false;
27
+
28
+ constructor(reportDir: string) {
29
+ this.outputDir = join(reportDir, 'coverage');
30
+ }
31
+
32
+ private async ensureInitialised(): Promise<void> {
33
+ if (this.initialised) return;
34
+ const monocartModule = await import('monocart-coverage-reports');
35
+ // `export = MCR` (CommonJS) becomes `.default` under ESM interop.
36
+ const createReport = monocartModule.default;
37
+ this.mcr = createReport({
38
+ name: 'tuffgal coverage',
39
+ outputDir: this.outputDir,
40
+ reports: ['v8', 'console-summary'],
41
+ });
42
+ await this.mcr.cleanCache();
43
+ this.initialised = true;
44
+ }
45
+
46
+ async startForPage(page: Page): Promise<void> {
47
+ await this.ensureInitialised();
48
+ await Promise.all([
49
+ page.coverage.startJSCoverage(PLAYWRIGHT_COVERAGE_OPTIONS),
50
+ page.coverage.startCSSCoverage(PLAYWRIGHT_COVERAGE_OPTIONS),
51
+ ]);
52
+ }
53
+
54
+ async stopForPage(page: Page): Promise<void> {
55
+ if (!this.mcr) return;
56
+ try {
57
+ const [js, css] = await Promise.all([
58
+ page.coverage.stopJSCoverage(),
59
+ page.coverage.stopCSSCoverage(),
60
+ ]);
61
+ await this.mcr.add([...js, ...css]);
62
+ } catch (error) {
63
+ // A page may already be closing when we stop — best effort, do not
64
+ // fail the run because we missed coverage on one tab.
65
+ process.stderr.write(
66
+ `coverage: stopForPage failed — ${error instanceof Error ? error.message : String(error)}\n`,
67
+ );
68
+ }
69
+ }
70
+
71
+ async generate(): Promise<string> {
72
+ if (!this.mcr) return this.outputDir;
73
+ await this.mcr.generate();
74
+ return this.outputDir;
75
+ }
76
+ }
@@ -0,0 +1,36 @@
1
+ import type { Hint } from '../schema/action.ts';
2
+
3
+ /**
4
+ * Expands `${name}` placeholders in a string from a parameter map. Throws when
5
+ * a placeholder has no matching parameter so a typo in a story or action fails
6
+ * loudly instead of silently leaving a literal `${url}` in a URL field.
7
+ */
8
+ export function interpolate(
9
+ template: string,
10
+ parameters: Record<string, string>,
11
+ ): string {
12
+ return template.replace(/\$\{([a-zA-Z0-9_]+)\}/g, (_match, name: string) => {
13
+ const value = parameters[name];
14
+ if (value === undefined) {
15
+ throw new Error(`Missing parameter "${name}" for template "${template}"`);
16
+ }
17
+ return value;
18
+ });
19
+ }
20
+
21
+ /**
22
+ * Returns a new Hint with `text` and `selector` interpolated against the
23
+ * supplied parameter map. `role` and `position` are enums and pass through.
24
+ */
25
+ export function interpolateHint(
26
+ hint: Hint,
27
+ parameters: Record<string, string>,
28
+ ): Hint {
29
+ return {
30
+ ...hint,
31
+ text: hint.text ? interpolate(hint.text, parameters) : hint.text,
32
+ selector: hint.selector
33
+ ? interpolate(hint.selector, parameters)
34
+ : hint.selector,
35
+ };
36
+ }
@@ -0,0 +1,47 @@
1
+ import type { Locator, Page } from 'playwright';
2
+ import type { Hint } from '../schema/action.ts';
3
+
4
+ /**
5
+ * Resolves a hint to a Playwright Locator using a precedence chain. The MVP
6
+ * intentionally stays literal — no LLM fallback. When this throws,
7
+ * `runAction` records `LocatorNotFound` and the parent story fails fast.
8
+ *
9
+ * Order:
10
+ * 1. `role + text` — strongest ARIA contract.
11
+ * 2. `role` alone — when no name is supplied.
12
+ * 3. `selector` — explicit escape hatch / cached resolution.
13
+ * 4. `text` alone — last resort because text-only locators are noisy.
14
+ */
15
+ export function resolveLocator(page: Page, hint: Hint): Locator {
16
+ if (hint.role && hint.text) {
17
+ return page.getByRole(hint.role, { name: hint.text, exact: false });
18
+ }
19
+ if (hint.role) {
20
+ return page.getByRole(hint.role);
21
+ }
22
+ if (hint.selector) {
23
+ return page.locator(hint.selector);
24
+ }
25
+ if (hint.text) {
26
+ return page.getByText(hint.text, { exact: false });
27
+ }
28
+ throw new LocatorHintError(
29
+ 'hint has no role, selector, or text — cannot resolve',
30
+ );
31
+ }
32
+
33
+ export class LocatorHintError extends Error {
34
+ constructor(message: string) {
35
+ super(message);
36
+ this.name = 'LocatorHintError';
37
+ }
38
+ }
39
+
40
+ export class LocatorNotFoundError extends Error {
41
+ override readonly cause?: unknown;
42
+ constructor(hint: Hint, cause?: unknown) {
43
+ super(`No element matched hint ${JSON.stringify(hint)}`);
44
+ this.name = 'LocatorNotFoundError';
45
+ this.cause = cause;
46
+ }
47
+ }
@@ -0,0 +1,177 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { cpus } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import type { ResolvedConfig } from '../config.ts';
5
+ import type { Action } from '../schema/action.ts';
6
+ import type { RunResult, StoryResult } from '../schema/result.ts';
7
+ import { loadActions, loadStories } from '../schema/load.ts';
8
+ import { writeReport } from '../reporter/writeReport.ts';
9
+ import { computeFlowCoverage } from '../coverage/flows.ts';
10
+ import { computeScreenCoverage } from '../coverage/screens.ts';
11
+ import { resetDatabase } from './bridges/database.ts';
12
+ import {
13
+ startManagedDevServers,
14
+ type ManagedDevServers,
15
+ } from './bridges/devServers.ts';
16
+ import { CoverageCollector } from './coverage.ts';
17
+ import { runStory } from './runStory.ts';
18
+ import {
19
+ buildSchedule,
20
+ drainSchedule,
21
+ type ScheduledStory,
22
+ } from './scheduler.ts';
23
+
24
+ export interface RunCliOptions {
25
+ storyFilter?: string;
26
+ headed: boolean;
27
+ workers?: number;
28
+ manageServers?: boolean;
29
+ coverage?: boolean;
30
+ }
31
+
32
+ const HEARTBEAT_FILE = '.heartbeat';
33
+
34
+ /**
35
+ * Loads every action and story from the configured paths, resets the
36
+ * consumer-supplied test database, schedules stories according to their
37
+ * needs/produces DAG, and drives execution across a fixed worker pool.
38
+ * Returns the aggregate `RunResult` so the CLI can set the process exit
39
+ * code.
40
+ */
41
+ export async function runAll(
42
+ config: ResolvedConfig,
43
+ options: RunCliOptions,
44
+ ): Promise<RunResult> {
45
+ const startedAt = new Date();
46
+ let managedServers: ManagedDevServers | undefined;
47
+ if (options.manageServers) {
48
+ managedServers = await startManagedDevServers(config);
49
+ }
50
+ // Heartbeat is opportunistic. A sibling supervisor process can poll this
51
+ // file to know whether the dev servers are still in active use.
52
+ await touchHeartbeat(config);
53
+ const coverage = options.coverage
54
+ ? new CoverageCollector(config.paths.report)
55
+ : undefined;
56
+ try {
57
+ if (config.database?.reset) {
58
+ process.stdout.write('Resetting test database…\n');
59
+ await resetDatabase(config);
60
+ }
61
+ const actions = await loadActions(config.paths.actions);
62
+ const allStories = await loadStories(config.paths.stories);
63
+ const scheduled = buildSchedule(allStories);
64
+ const subset = options.storyFilter
65
+ ? scheduled.filter((item) => matchesFilter(item, options.storyFilter!))
66
+ : scheduled;
67
+
68
+ if (options.storyFilter && subset.length === 0) {
69
+ throw new Error(`No story matched filter "${options.storyFilter}"`);
70
+ }
71
+
72
+ const workerCount = resolveWorkerCount(config, options.workers);
73
+ process.stdout.write(
74
+ `Scheduling ${subset.length} stories on ${workerCount} worker${workerCount === 1 ? '' : 's'}.\n`,
75
+ );
76
+
77
+ const results = await drainSchedule(
78
+ subset,
79
+ workerCount,
80
+ (item) => runScheduledStory(item, actions, config, options.headed, coverage),
81
+ (item) => process.stdout.write(`▶ ${item.file}\n`),
82
+ (item, result) =>
83
+ process.stdout.write(
84
+ ` ${result.status.toUpperCase()} ${item.file} (${result.actions.length} actions, ${result.durationMs} ms)\n`,
85
+ ),
86
+ );
87
+
88
+ const finishedAt = new Date();
89
+ const [screens, flows] = await Promise.all([
90
+ computeScreenCoverage(config.paths.actions, config.paths.baselines),
91
+ computeFlowCoverage(config.flowInventory, allStories),
92
+ ]);
93
+ const runResult: RunResult = {
94
+ startedAt: startedAt.toISOString(),
95
+ finishedAt: finishedAt.toISOString(),
96
+ durationMs: finishedAt.getTime() - startedAt.getTime(),
97
+ totals: summarise(results),
98
+ customCoverage: { screens, flows },
99
+ stories: results,
100
+ };
101
+ const reportPath = await writeReport(config.paths.report, runResult);
102
+ process.stdout.write(`\nReport: ${reportPath}\n`);
103
+ if (coverage) {
104
+ const coveragePath = await coverage.generate();
105
+ process.stdout.write(`Coverage: ${coveragePath}\n`);
106
+ }
107
+ return runResult;
108
+ } finally {
109
+ if (managedServers) {
110
+ await managedServers.stop();
111
+ }
112
+ }
113
+ }
114
+
115
+ function runScheduledStory(
116
+ item: ScheduledStory,
117
+ actions: Map<string, Action>,
118
+ config: ResolvedConfig,
119
+ headed: boolean,
120
+ coverage: CoverageCollector | undefined,
121
+ ): Promise<StoryResult> {
122
+ return runStory({
123
+ story: item.story,
124
+ file: item.file,
125
+ needs: item.needs,
126
+ produces: item.produces,
127
+ actions,
128
+ config,
129
+ headed,
130
+ coverage,
131
+ });
132
+ }
133
+
134
+ function matchesFilter(item: ScheduledStory, filter: string): boolean {
135
+ return (
136
+ item.file === filter ||
137
+ item.file === `${filter}.json` ||
138
+ item.story.story === filter
139
+ );
140
+ }
141
+
142
+ function resolveWorkerCount(
143
+ config: ResolvedConfig,
144
+ requested: number | undefined,
145
+ ): number {
146
+ if (requested && requested > 0) {
147
+ return requested;
148
+ }
149
+ if (config.workers && config.workers > 0) {
150
+ return config.workers;
151
+ }
152
+ const half = Math.floor(cpus().length / 2);
153
+ return Math.max(1, Math.min(half, 4));
154
+ }
155
+
156
+ async function touchHeartbeat(config: ResolvedConfig): Promise<void> {
157
+ try {
158
+ await mkdir(config.paths.report, { recursive: true });
159
+ await writeFile(
160
+ join(config.paths.report, HEARTBEAT_FILE),
161
+ new Date().toISOString(),
162
+ 'utf8',
163
+ );
164
+ } catch {
165
+ // The heartbeat is opportunistic — a missing parent dir or a disk
166
+ // hiccup should not fail the entire run.
167
+ }
168
+ }
169
+
170
+ function summarise(results: StoryResult[]): RunResult['totals'] {
171
+ return {
172
+ stories: results.length,
173
+ passed: results.filter((result) => result.status === 'pass').length,
174
+ changed: results.filter((result) => result.status === 'changed').length,
175
+ failed: results.filter((result) => result.status === 'failed').length,
176
+ };
177
+ }