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,267 @@
1
+ import { spawn, type ChildProcess } from 'node:child_process';
2
+ import { createWriteStream, mkdirSync, statSync } from 'node:fs';
3
+ import { createConnection } from 'node:net';
4
+ import { join, resolve } from 'node:path';
5
+ import type { ResolvedConfig, DevServerBridge } from '../config.ts';
6
+
7
+ const HEARTBEAT_FILE = '.heartbeat';
8
+ const SIGTERM_GRACE_MS = 5_000;
9
+ const HEALTHCHECK_PROBE_TIMEOUT_MS = 2_000;
10
+
11
+ const DEFAULT_HEALTHCHECK_INTERVAL_MS = 30_000;
12
+ const DEFAULT_IDLE_LIMIT_MS = 10 * 60_000;
13
+ const DEFAULT_MAX_RUNTIME_MS = 60 * 60_000;
14
+ const DEFAULT_MAX_RESPAWNS = 3;
15
+
16
+ export interface SuperviseOptions {
17
+ /** Milliseconds between port + heartbeat probes. Default 30_000. */
18
+ healthcheckIntervalMs?: number;
19
+ /**
20
+ * Window with no `tuffgal run` heartbeat before the supervisor self-
21
+ * terminates. Default 10 minutes. The heartbeat file lives at
22
+ * `config.paths.report/.heartbeat` and is touched by every `tuffgal
23
+ * run` invocation.
24
+ */
25
+ idleLimitMs?: number;
26
+ /** Wall-clock cap, end-to-end. Default 60 minutes. */
27
+ maxRuntimeMs?: number;
28
+ /**
29
+ * How many times the supervisor may respawn `devServers.command` after
30
+ * an unhealthy probe or unexpected exit before giving up. Default 3.
31
+ */
32
+ maxRespawns?: number;
33
+ }
34
+
35
+ /**
36
+ * Long-running supervisor around `config.devServers.command`. Solves
37
+ * three problems observed during heavy harness iteration:
38
+ *
39
+ * 1. Hot-reload rot — after many file-watch restarts the framework
40
+ * drifts into a broken state. Supervisor probes every declared
41
+ * healthcheck URL and restarts the whole tree on failure.
42
+ * 2. Forgotten dev servers — supervisor self-terminates after a
43
+ * wall-clock cap and after an idle window with no heartbeat from
44
+ * `tuffgal run`.
45
+ * 3. Manual signal management — SIGINT/SIGTERM teardown kills the
46
+ * whole detached process group, not just the shell wrapper.
47
+ *
48
+ * Each invocation of `tuffgal run` writes
49
+ * `config.paths.report/.heartbeat` (ISO timestamp). Supervisor reads
50
+ * its mtime every healthcheck. If `Date.now() - mtime > idleLimitMs`,
51
+ * supervisor shuts down. Missing heartbeat file is treated as "no runs
52
+ * yet", not stale, so a fresh shell does not self-terminate immediately.
53
+ */
54
+ export async function supervise(
55
+ config: ResolvedConfig,
56
+ options: SuperviseOptions = {},
57
+ ): Promise<void> {
58
+ if (!config.devServers) {
59
+ throw new Error(
60
+ 'tuffgal supervise requires a `devServers` block in tuffgal.config.ts. ' +
61
+ 'Declare `devServers: { command, healthCheck: [...] }` and try again.',
62
+ );
63
+ }
64
+
65
+ const healthcheckIntervalMs =
66
+ options.healthcheckIntervalMs ??
67
+ numberFromEnv(
68
+ 'TUFFGAL_HEALTHCHECK_INTERVAL_MS',
69
+ DEFAULT_HEALTHCHECK_INTERVAL_MS,
70
+ );
71
+ const idleLimitMs =
72
+ options.idleLimitMs ??
73
+ numberFromEnv('TUFFGAL_IDLE_LIMIT_MS', DEFAULT_IDLE_LIMIT_MS);
74
+ const maxRuntimeMs =
75
+ options.maxRuntimeMs ??
76
+ numberFromEnv('TUFFGAL_MAX_RUNTIME_MS', DEFAULT_MAX_RUNTIME_MS);
77
+ const maxRespawns =
78
+ options.maxRespawns ??
79
+ numberFromEnv('TUFFGAL_MAX_RESPAWNS', DEFAULT_MAX_RESPAWNS);
80
+
81
+ mkdirSync(config.paths.report, { recursive: true });
82
+ const logPath = join(config.paths.report, 'dev-servers.log');
83
+ const heartbeatPath = join(config.paths.report, HEARTBEAT_FILE);
84
+
85
+ process.stdout.write(
86
+ [
87
+ 'Starting tuffgal supervisor.',
88
+ ` Command: ${config.devServers.command}`,
89
+ ` Healthcheck: ${config.devServers.healthCheck.map((entry) => entry.url).join(', ')}`,
90
+ ` Log: ${logPath}`,
91
+ ` Heartbeat: ${heartbeatPath}`,
92
+ ` Interval: ${healthcheckIntervalMs}ms`,
93
+ ` Idle limit: ${idleLimitMs}ms`,
94
+ ` Max runtime: ${maxRuntimeMs}ms`,
95
+ ` Max respawns: ${maxRespawns}`,
96
+ '',
97
+ ].join('\n'),
98
+ );
99
+
100
+ const startedAt = Date.now();
101
+ let respawns = 0;
102
+ let child = spawnDevServers(config.devServers, config.rootDir, logPath);
103
+ let stopped = false;
104
+
105
+ const teardown = async (reason: string): Promise<void> => {
106
+ if (stopped) return;
107
+ stopped = true;
108
+ process.stdout.write(`Supervisor stopping: ${reason}\n`);
109
+ await stopChild(child, config.devServers);
110
+ process.exit(0);
111
+ };
112
+
113
+ process.on('SIGINT', () => void teardown('SIGINT received'));
114
+ process.on('SIGTERM', () => void teardown('SIGTERM received'));
115
+
116
+ while (!stopped) {
117
+ await sleep(healthcheckIntervalMs);
118
+ if (stopped) break;
119
+
120
+ if (Date.now() - startedAt > maxRuntimeMs) {
121
+ await teardown('wall-clock cap reached');
122
+ return;
123
+ }
124
+ if (heartbeatIsStale(heartbeatPath, idleLimitMs)) {
125
+ await teardown(`no tuffgal run activity in ${idleLimitMs}ms`);
126
+ return;
127
+ }
128
+
129
+ if (child.exitCode !== null) {
130
+ respawns += 1;
131
+ if (respawns > maxRespawns) {
132
+ await teardown(
133
+ `dev servers exited and respawn budget exhausted (${maxRespawns})`,
134
+ );
135
+ return;
136
+ }
137
+ process.stdout.write(
138
+ `Dev servers exited; respawning (${respawns}/${maxRespawns}).\n`,
139
+ );
140
+ child = spawnDevServers(config.devServers, config.rootDir, logPath);
141
+ continue;
142
+ }
143
+
144
+ const healthy = await probeAllHealthchecks(config.devServers);
145
+ if (!healthy) {
146
+ respawns += 1;
147
+ if (respawns > maxRespawns) {
148
+ await teardown(
149
+ `unhealthy ports and respawn budget exhausted (${maxRespawns})`,
150
+ );
151
+ return;
152
+ }
153
+ process.stdout.write(
154
+ `Healthcheck failed; killing + respawning (${respawns}/${maxRespawns}).\n`,
155
+ );
156
+ await stopChild(child, config.devServers);
157
+ child = spawnDevServers(config.devServers, config.rootDir, logPath);
158
+ }
159
+ }
160
+ }
161
+
162
+ function spawnDevServers(
163
+ devServers: DevServerBridge,
164
+ rootDir: string,
165
+ logPath: string,
166
+ ): ChildProcess {
167
+ const stream = createWriteStream(logPath, { flags: 'a' });
168
+ stream.write(`\n--- dev servers spawned @ ${new Date().toISOString()} ---\n`);
169
+ const cwd = devServers.cwd ? resolve(rootDir, devServers.cwd) : rootDir;
170
+ const child = spawn('sh', ['-c', devServers.command], {
171
+ cwd,
172
+ stdio: ['ignore', 'pipe', 'pipe'],
173
+ detached: true,
174
+ });
175
+ if (child.stdout) child.stdout.pipe(stream);
176
+ if (child.stderr) child.stderr.pipe(stream);
177
+ return child;
178
+ }
179
+
180
+ async function probeAllHealthchecks(
181
+ devServers: DevServerBridge,
182
+ ): Promise<boolean> {
183
+ const results = await Promise.all(
184
+ devServers.healthCheck.map((entry) => probeUrl(entry.url)),
185
+ );
186
+ return results.every((result) => result);
187
+ }
188
+
189
+ function probeUrl(url: string): Promise<boolean> {
190
+ const parsed = new URL(url);
191
+ const port = parsed.port
192
+ ? Number(parsed.port)
193
+ : parsed.protocol === 'https:'
194
+ ? 443
195
+ : 80;
196
+ const host = parsed.hostname;
197
+ return new Promise((resolveProbe) => {
198
+ const socket = createConnection({ port, host });
199
+ const cleanup = (result: boolean): void => {
200
+ socket.removeAllListeners();
201
+ socket.destroy();
202
+ resolveProbe(result);
203
+ };
204
+ socket.once('connect', () => cleanup(true));
205
+ socket.once('error', () => cleanup(false));
206
+ socket.once('timeout', () => cleanup(false));
207
+ socket.setTimeout(HEALTHCHECK_PROBE_TIMEOUT_MS);
208
+ });
209
+ }
210
+
211
+ function heartbeatIsStale(heartbeatPath: string, idleLimitMs: number): boolean {
212
+ try {
213
+ const stat = statSync(heartbeatPath);
214
+ return Date.now() - stat.mtimeMs > idleLimitMs;
215
+ } catch {
216
+ // No heartbeat yet — count grace period from supervisor start so a
217
+ // fresh shell does not immediately kill itself.
218
+ return false;
219
+ }
220
+ }
221
+
222
+ async function stopChild(
223
+ child: ChildProcess,
224
+ devServers: DevServerBridge | undefined,
225
+ ): Promise<void> {
226
+ if (!child.pid || child.exitCode !== null) return;
227
+ const pid = child.pid;
228
+ const signal = devServers?.shutdownSignal ?? 'SIGTERM';
229
+ const graceMs = devServers?.shutdownGraceMs ?? SIGTERM_GRACE_MS;
230
+ await new Promise<void>((resolveOuter) => {
231
+ let done = false;
232
+ const finish = (): void => {
233
+ if (done) return;
234
+ done = true;
235
+ resolveOuter();
236
+ };
237
+ child.once('exit', finish);
238
+ try {
239
+ process.kill(-pid, signal);
240
+ } catch {
241
+ finish();
242
+ return;
243
+ }
244
+ setTimeout(() => {
245
+ if (child.exitCode === null) {
246
+ try {
247
+ process.kill(-pid, 'SIGKILL');
248
+ } catch {
249
+ // Already gone.
250
+ }
251
+ }
252
+ }, graceMs);
253
+ });
254
+ }
255
+
256
+ function numberFromEnv(name: string, fallback: number): number {
257
+ const raw = process.env[name];
258
+ if (!raw) return fallback;
259
+ const parsed = Number(raw);
260
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
261
+ }
262
+
263
+ function sleep(ms: number): Promise<void> {
264
+ return new Promise((resolveSleep) => {
265
+ setTimeout(resolveSleep, ms);
266
+ });
267
+ }
package/src/config.ts ADDED
@@ -0,0 +1,222 @@
1
+ import { pathToFileURL } from 'node:url';
2
+ import { resolve } from 'node:path';
3
+ import { access } from 'node:fs/promises';
4
+
5
+ /**
6
+ * Bridge supplied by the consumer for tearing down + reseeding their test
7
+ * database between runs. Both methods are optional: a static-site project
8
+ * with no backend can omit `database` entirely.
9
+ */
10
+ export interface DatabaseBridge {
11
+ /**
12
+ * Wipes the test database and reseeds the deterministic test user. Called
13
+ * once before the scheduler dispatches the first story.
14
+ */
15
+ reset?: () => Promise<void>;
16
+ /**
17
+ * Map of named fixture functions. Each story declares
18
+ * `fixtures: ["name"]` and the runner invokes the matching entry before
19
+ * launching the browser. Fixtures must be idempotent (apply twice safely)
20
+ * because Tuffgal applies them per-story without per-story DB reset.
21
+ */
22
+ fixtures?: Record<string, () => Promise<void>>;
23
+ }
24
+
25
+ /**
26
+ * Bridge for the optional `--manage-servers` mode. When supplied, Tuffgal
27
+ * spawns the command in a detached process group, polls the healthcheck
28
+ * URLs, and tears the process tree down at end-of-run.
29
+ */
30
+ export interface DevServerBridge {
31
+ /** Shell command. Run via `sh -c` so pipes and `&&` work. */
32
+ command: string;
33
+ /** Working directory, relative to the config file's location. */
34
+ cwd?: string;
35
+ /**
36
+ * URLs to poll before considering the dev server stack ready. Each entry
37
+ * is probed via TCP `connect` (not HTTP) so self-signed certificates and
38
+ * 404 responses do not block readiness.
39
+ */
40
+ healthCheck: Array<{ url: string; timeoutMs?: number }>;
41
+ /** Signal sent on shutdown. Defaults to `SIGTERM`. */
42
+ shutdownSignal?: NodeJS.Signals;
43
+ /** Grace period before `SIGKILL`. Defaults to 5000. */
44
+ shutdownGraceMs?: number;
45
+ }
46
+
47
+ /**
48
+ * Static paths Tuffgal reads + writes. All relative to the config file's
49
+ * location.
50
+ */
51
+ export interface PathsConfig {
52
+ /** Action JSON files. Recurses into subdirectories. */
53
+ actions: string;
54
+ /** Story JSON files. Recurses into subdirectories. */
55
+ stories: string;
56
+ /** Committed PNG baselines + a11y snapshots. */
57
+ baselines: string;
58
+ /** Generated HTML report + traces. Gitignore this. */
59
+ report: string;
60
+ /** Storage state cache for `produces`/`needs` label inheritance. */
61
+ authState?: string;
62
+ }
63
+
64
+ /**
65
+ * CI-friendly result emitters. Optional. The default reporter (HTML at
66
+ * `paths.report/index.html`) always runs.
67
+ */
68
+ export interface CiConfig {
69
+ /** Path to write SARIF results.json for GitHub code scanning. */
70
+ sarif?: string;
71
+ /** Paths to advertise as `actions/upload-artifact` candidates. */
72
+ artifactPaths?: string[];
73
+ }
74
+
75
+ /**
76
+ * Root config shape passed to `defineConfig()`.
77
+ */
78
+ export interface TuffgalConfig {
79
+ /** Where actions, stories, baselines, and reports live. */
80
+ paths: PathsConfig;
81
+ /** Base URL of the running app. */
82
+ baseUrl: string;
83
+ /**
84
+ * Origin (scheme + host + port) of the consumer's API. Intercept
85
+ * patterns that begin with this host stay scoped to API traffic and
86
+ * avoid accidentally matching Vite source modules.
87
+ */
88
+ apiHost?: string;
89
+ /**
90
+ * localStorage keys to persist across stories. Cookie-based apps may
91
+ * leave this empty — cookies always persist via Playwright's storage
92
+ * state.
93
+ */
94
+ storageStatePins?: string[];
95
+ /** Browser viewport. Defaults to 1280x800. */
96
+ viewport?: { width: number; height: number };
97
+ /** Default Playwright locator + action timeout. Defaults to 10_000. */
98
+ defaultTimeoutMs?: number;
99
+ /** Default navigation timeout. Defaults to 15_000. */
100
+ navigationTimeoutMs?: number;
101
+ /**
102
+ * Frozen ISO timestamp passed to `page.clock.install`. Pins
103
+ * `Date.now()` for any relative-time UI ("3 minutes ago") so screenshot
104
+ * diffs do not flicker.
105
+ */
106
+ frozenTime?: string;
107
+ /** Worker pool size. Default: `min(cpus / 2, 4)`. */
108
+ workers?: number;
109
+ /** Consumer-provided database bridge. */
110
+ database?: DatabaseBridge;
111
+ /** Consumer-provided dev-server bridge (only used with `--manage-servers`). */
112
+ devServers?: DevServerBridge;
113
+ /**
114
+ * Path to a markdown file listing the consumer's user journeys (one per
115
+ * row in a single markdown table). Tuffgal counts how many stories
116
+ * declare a matching `flow:` field and exposes the ratio as
117
+ * `customCoverage.flows` in the report.
118
+ */
119
+ flowInventory?: string;
120
+ /** CI integration knobs. */
121
+ ci?: CiConfig;
122
+ }
123
+
124
+ /**
125
+ * Resolved config — every optional field replaced with a concrete value
126
+ * so downstream consumers can rely on the shape without per-field `??`.
127
+ * Returned by `loadConfig`. Not part of the public surface.
128
+ */
129
+ export interface ResolvedConfig {
130
+ rootDir: string;
131
+ paths: Required<PathsConfig>;
132
+ baseUrl: string;
133
+ apiHost: string | undefined;
134
+ storageStatePins: string[];
135
+ viewport: { width: number; height: number };
136
+ defaultTimeoutMs: number;
137
+ navigationTimeoutMs: number;
138
+ frozenTime: string;
139
+ workers: number | undefined;
140
+ database: DatabaseBridge | undefined;
141
+ devServers: DevServerBridge | undefined;
142
+ flowInventory: string | undefined;
143
+ ci: CiConfig | undefined;
144
+ }
145
+
146
+ const DEFAULTS = {
147
+ viewport: { width: 1280, height: 800 },
148
+ defaultTimeoutMs: 10_000,
149
+ navigationTimeoutMs: 15_000,
150
+ frozenTime: '2026-01-15T12:00:00.000Z',
151
+ authStateRelative: '.auth',
152
+ } as const;
153
+
154
+ /**
155
+ * Helper that returns its argument unchanged. Use in a `tuffgal.config.ts`
156
+ * file to get full TypeScript type-checking against `TuffgalConfig`.
157
+ */
158
+ export function defineConfig(config: TuffgalConfig): TuffgalConfig {
159
+ return config;
160
+ }
161
+
162
+ /**
163
+ * Locates + dynamically imports the consumer's `tuffgal.config.ts` (or
164
+ * `.js`) from `cwd`. Throws a descriptive error when no config is found
165
+ * so the user knows where to put the file.
166
+ */
167
+ export async function loadConfig(cwd: string): Promise<ResolvedConfig> {
168
+ const candidates = ['tuffgal.config.ts', 'tuffgal.config.js'];
169
+ for (const candidate of candidates) {
170
+ const absolute = resolve(cwd, candidate);
171
+ try {
172
+ await access(absolute);
173
+ } catch {
174
+ continue;
175
+ }
176
+ const module = (await import(pathToFileURL(absolute).href)) as {
177
+ default?: TuffgalConfig;
178
+ };
179
+ if (!module.default) {
180
+ throw new Error(
181
+ `${candidate} found at ${absolute} but does not have a default export. ` +
182
+ `Did you forget \`export default defineConfig({ ... })\`?`,
183
+ );
184
+ }
185
+ return resolveConfig(module.default, resolve(cwd, '.'));
186
+ }
187
+ throw new Error(
188
+ `No tuffgal.config.ts or tuffgal.config.js found in ${cwd}. Run ` +
189
+ `\`npx tuffgal init\` to scaffold one.`,
190
+ );
191
+ }
192
+
193
+ function resolveConfig(input: TuffgalConfig, rootDir: string): ResolvedConfig {
194
+ return {
195
+ rootDir,
196
+ paths: {
197
+ actions: resolve(rootDir, input.paths.actions),
198
+ stories: resolve(rootDir, input.paths.stories),
199
+ baselines: resolve(rootDir, input.paths.baselines),
200
+ report: resolve(rootDir, input.paths.report),
201
+ authState: resolve(
202
+ rootDir,
203
+ input.paths.authState ?? DEFAULTS.authStateRelative,
204
+ ),
205
+ },
206
+ baseUrl: input.baseUrl,
207
+ apiHost: input.apiHost,
208
+ storageStatePins: input.storageStatePins ?? [],
209
+ viewport: input.viewport ?? DEFAULTS.viewport,
210
+ defaultTimeoutMs: input.defaultTimeoutMs ?? DEFAULTS.defaultTimeoutMs,
211
+ navigationTimeoutMs:
212
+ input.navigationTimeoutMs ?? DEFAULTS.navigationTimeoutMs,
213
+ frozenTime: input.frozenTime ?? DEFAULTS.frozenTime,
214
+ workers: input.workers,
215
+ database: input.database,
216
+ devServers: input.devServers,
217
+ flowInventory: input.flowInventory
218
+ ? resolve(rootDir, input.flowInventory)
219
+ : undefined,
220
+ ci: input.ci,
221
+ };
222
+ }
@@ -0,0 +1,90 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import type { StoryFile } from '../schema/load.ts';
3
+
4
+ export interface FlowCoverage {
5
+ total: number;
6
+ covered: number;
7
+ ratio: number;
8
+ missing: string[];
9
+ }
10
+
11
+ /**
12
+ * Compares the journeys catalogued in the consumer's `flowInventory`
13
+ * markdown table with the stories under the configured `stories/`
14
+ * directory. A story is counted as covering a journey when its `flow`
15
+ * field matches a journey row (case- and whitespace-insensitive).
16
+ *
17
+ * Returns 1.0 coverage when no inventory was configured or the file
18
+ * cannot be read, so a fresh checkout without an inventory does not
19
+ * break the report.
20
+ */
21
+ export async function computeFlowCoverage(
22
+ inventoryPath: string | undefined,
23
+ stories: StoryFile[],
24
+ ): Promise<FlowCoverage> {
25
+ if (!inventoryPath) {
26
+ return { total: 0, covered: 0, ratio: 1, missing: [] };
27
+ }
28
+ let raw: string;
29
+ try {
30
+ raw = await readFile(inventoryPath, 'utf8');
31
+ } catch {
32
+ return { total: 0, covered: 0, ratio: 1, missing: [] };
33
+ }
34
+ const journeys = parseJourneyTable(raw);
35
+ const claimed = new Set(
36
+ stories
37
+ .map((entry) => entry.story.flow)
38
+ .filter((flow): flow is string => typeof flow === 'string')
39
+ .map((flow) => normalise(flow)),
40
+ );
41
+ const missing: string[] = [];
42
+ for (const journey of journeys) {
43
+ if (!claimed.has(normalise(journey))) {
44
+ missing.push(journey);
45
+ }
46
+ }
47
+ const total = journeys.length;
48
+ const covered = total - missing.length;
49
+ return {
50
+ total,
51
+ covered,
52
+ ratio: total === 0 ? 1 : covered / total,
53
+ missing,
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Pulls the first column of every body row from the first markdown table
59
+ * in the document. Skips the header row and the dash separator row. The
60
+ * inventory contains one row per journey, so this is the journey list.
61
+ */
62
+ function parseJourneyTable(markdown: string): string[] {
63
+ const lines = markdown.split(/\r?\n/);
64
+ const journeys: string[] = [];
65
+ let insideTable = false;
66
+ let skippedHeaderAndSeparator = 0;
67
+ for (const line of lines) {
68
+ if (!line.startsWith('|')) {
69
+ if (insideTable) break;
70
+ continue;
71
+ }
72
+ insideTable = true;
73
+ if (skippedHeaderAndSeparator < 2) {
74
+ skippedHeaderAndSeparator += 1;
75
+ continue;
76
+ }
77
+ const firstCell = line
78
+ .split('|')
79
+ .map((cell) => cell.trim())
80
+ .filter((cell) => cell.length > 0)[0];
81
+ if (firstCell) {
82
+ journeys.push(firstCell);
83
+ }
84
+ }
85
+ return journeys;
86
+ }
87
+
88
+ function normalise(text: string): string {
89
+ return text.toLowerCase().replaceAll(/\s+/g, ' ').trim();
90
+ }
@@ -0,0 +1,52 @@
1
+ import { access, readdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ export interface ScreenCoverage {
5
+ total: number;
6
+ covered: number;
7
+ ratio: number;
8
+ missing: string[];
9
+ }
10
+
11
+ const SCREENS_SUBDIR = 'screens';
12
+
13
+ /**
14
+ * Walks every `visit-*.json` action under `<actions>/screens/` and reports
15
+ * how many have a baseline screenshot committed at
16
+ * `<baselines>/<action-name>/0.png`. The action name is taken from the
17
+ * filename (minus the `.json` suffix), which by convention matches the
18
+ * action's declared `action` field.
19
+ */
20
+ export async function computeScreenCoverage(
21
+ actionsDir: string,
22
+ baselinesDir: string,
23
+ ): Promise<ScreenCoverage> {
24
+ const screensRoot = join(actionsDir, SCREENS_SUBDIR);
25
+ let entries: string[] = [];
26
+ try {
27
+ entries = await readdir(screensRoot);
28
+ } catch {
29
+ return { total: 0, covered: 0, ratio: 1, missing: [] };
30
+ }
31
+ const screenNames = entries
32
+ .filter((name) => name.endsWith('.json'))
33
+ .map((name) => name.replace(/\.json$/i, ''))
34
+ .sort();
35
+ const missing: string[] = [];
36
+ for (const name of screenNames) {
37
+ const baseline = join(baselinesDir, name, '0.png');
38
+ try {
39
+ await access(baseline);
40
+ } catch {
41
+ missing.push(name);
42
+ }
43
+ }
44
+ const total = screenNames.length;
45
+ const covered = total - missing.length;
46
+ return {
47
+ total,
48
+ covered,
49
+ ratio: total === 0 ? 1 : covered / total,
50
+ missing,
51
+ };
52
+ }
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ // Public API surface. Consumer projects import only what is re-exported here.
2
+ // Anything not re-exported is internal and may break between minor releases.
3
+
4
+ export {
5
+ defineConfig,
6
+ loadConfig,
7
+ type TuffgalConfig,
8
+ type ResolvedConfig,
9
+ type DatabaseBridge,
10
+ type DevServerBridge,
11
+ type PathsConfig,
12
+ type CiConfig,
13
+ } from './config.ts';
14
+
15
+ export { runAll, type RunCliOptions } from './runner/run.ts';
16
+ export { approveAll, type ApproveOptions } from './runner/approve.ts';
17
+ export { supervise, type SuperviseOptions } from './commands/supervise.ts';
18
+ export { init, type InitOptions } from './commands/init.ts';
19
+
20
+ export type { Action, Step, Hint } from './schema/action.ts';
21
+ export type { Story } from './schema/story.ts';
22
+ export type {
23
+ RunResult,
24
+ StoryResult,
25
+ ActionResult,
26
+ ActionStatus,
27
+ StoryStatus,
28
+ } from './schema/result.ts';