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,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
|
+
}
|