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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nick Schneble
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Tuffgal
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
> JSON-driven visual regression for web apps. Authoring stays declarative,
|
|
6
|
+
> screenshots become a committable build artifact.
|
|
7
|
+
|
|
8
|
+
<img src="tuffgal.png" alt="Tuffgal" />
|
|
9
|
+
|
|
10
|
+
**Status:** Pre-release. `v0.1.0-alpha` in active extraction from the
|
|
11
|
+
`apps/testing-ui` workspace in [Linklater](https://github.com/nschneble/linklater).
|
|
12
|
+
|
|
13
|
+
Public API is unstable until `v1.0.0`.
|
|
14
|
+
|
|
15
|
+
## The idea
|
|
16
|
+
|
|
17
|
+
Tuffgal sits between component tests (which are fast but mocked) and
|
|
18
|
+
end-to-end tests (which are real but verbose). You write **actions**
|
|
19
|
+
(atomic user steps) and **stories** (chains of actions) as pure JSON. The
|
|
20
|
+
harness runs them in a real browser, captures a screenshot after each
|
|
21
|
+
story, and pixel-diffs against a baseline you commit alongside your code.
|
|
22
|
+
|
|
23
|
+
When a screenshot changes, a human reviews the diff and decides what to do.
|
|
24
|
+
|
|
25
|
+
## What ships in v1
|
|
26
|
+
|
|
27
|
+
- 10 step primitives as action verbs: `click`, `input`, `intercept`,
|
|
28
|
+
`navigate`, `read`, `scroll`, `type`, `wait`, `waitFor`, and `screenshot`
|
|
29
|
+
as the implicit capture point
|
|
30
|
+
- DAG scheduler with `needs`/`produces` labels and parallel workers
|
|
31
|
+
- SSIM-gated visual diff + pixelmatch overlay + a11y-tree snapshots
|
|
32
|
+
- Trace zip on failure (Playwright trace viewer)
|
|
33
|
+
- Clock freeze (`page.clock.install`)
|
|
34
|
+
- Storage-state persistence across stories
|
|
35
|
+
- Static HTML reporter + optional SARIF for GitHub code scanning
|
|
36
|
+
- V8 coverage (optional via `monocart-coverage-reports`)
|
|
37
|
+
- Per-story DB reset + fixture hooks (consumer-supplied via config)
|
|
38
|
+
- Process supervisor for dev-server hot-reload rot (it happens)
|
|
39
|
+
|
|
40
|
+
## What's explicitly out of scope (v1)
|
|
41
|
+
|
|
42
|
+
- AI fuzzy locator matching (deferred to v1.1, BYOLLM)
|
|
43
|
+
- Hosted SaaS / cloud runs
|
|
44
|
+
- Native mobile (Playwright cannot drive it)
|
|
45
|
+
- WebDriver / Puppeteer substrate
|
|
46
|
+
- Supporting browsers other than Chromium
|
|
47
|
+
|
|
48
|
+
## Quick start (when alpha releases)
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npm install -D tuffgal
|
|
52
|
+
npx tuffgal init # scaffolds tuffgal.config.ts
|
|
53
|
+
npx tuffgal run # runs all stories
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Documentation
|
|
57
|
+
|
|
58
|
+
- [App contract](docs/app-contract.md)
|
|
59
|
+
- [Authoring guide](docs/authoring.md)
|
|
60
|
+
- [CI integration](docs/ci.md)
|
|
61
|
+
- [Config reference](docs/config.md)
|
|
62
|
+
- [Migrating from Cypress](docs/migration-cypress.md)
|
|
63
|
+
- [Migrating from Playwright](docs/migration-playwright.md)
|
|
64
|
+
- [Product requirements](docs/prd-v1.md)
|
|
65
|
+
- [Supervisor](docs/supervisor.md)
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
|
|
69
|
+
MIT. See [LICENSE](LICENSE).
|
|
70
|
+
|
|
71
|
+
## Roadmap
|
|
72
|
+
|
|
73
|
+
| Milestone | Status |
|
|
74
|
+
| -------------------------- | -------- |
|
|
75
|
+
| Repo bootstrap | ✅ |
|
|
76
|
+
| Core extraction | ✅ |
|
|
77
|
+
| Bridges | ✅ |
|
|
78
|
+
| Linklater migration | ✅ |
|
|
79
|
+
| GitHub Action | 🚧 |
|
|
80
|
+
| `v0.1.0-alpha` npm publish | 🚧 |
|
|
81
|
+
| `v1.0.0` public launch | ⏳ |
|
|
82
|
+
| `v1.1.0` AI fuzzy matching | Deferred |
|
|
83
|
+
|
|
84
|
+
## Acknowledgements
|
|
85
|
+
|
|
86
|
+
The Tuffgal logo is an illustration by [Art Attack](https://unsplash.com/@artattackzone) on [Unsplash](https://unsplash.com/illustrations/a-woman-with-two-dumbs-in-her-hands-0GxJHpQzVvs).
|
package/bin/tuffgal.mjs
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tuffgal",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"description": "JSON-driven visual regression for web apps.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Nick Schneble",
|
|
7
|
+
"homepage": "https://github.com/nschneble/tuffgal",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/nschneble/tuffgal.git"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "echo \"no build step until v0.1.0-alpha\" && exit 0",
|
|
14
|
+
"format": "prettier --write \"**/*.{ts,tsx,json,md}\"",
|
|
15
|
+
"install:browsers": "playwright install chromium",
|
|
16
|
+
"lint": "eslint \"src/**/*.ts\" \"bin/**/*.mjs\" --max-warnings=0",
|
|
17
|
+
"prepublishOnly": "npm run lint && npm run typecheck",
|
|
18
|
+
"test": "echo \"no tests yet\" && exit 0",
|
|
19
|
+
"typecheck": "tsc --noEmit"
|
|
20
|
+
},
|
|
21
|
+
"bin": {
|
|
22
|
+
"tuffgal": "./bin/tuffgal.mjs"
|
|
23
|
+
},
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/nschneble/tuffgal/issues"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=22.0.0"
|
|
29
|
+
},
|
|
30
|
+
"exports": {
|
|
31
|
+
".": "./src/index.ts"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"bin/",
|
|
35
|
+
"LICENSE",
|
|
36
|
+
"README.md",
|
|
37
|
+
"src/"
|
|
38
|
+
],
|
|
39
|
+
"keywords": [
|
|
40
|
+
"playwright",
|
|
41
|
+
"screenshots",
|
|
42
|
+
"snapshot-testing",
|
|
43
|
+
"ssim",
|
|
44
|
+
"testing",
|
|
45
|
+
"ui-testing",
|
|
46
|
+
"visual-regression"
|
|
47
|
+
],
|
|
48
|
+
"main": "./src/index.ts",
|
|
49
|
+
"type": "module",
|
|
50
|
+
"types": "./src/index.ts",
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"monocart-coverage-reports": "^2.12.0",
|
|
53
|
+
"pixelmatch": "^7.1.0",
|
|
54
|
+
"playwright": "^1.49.0",
|
|
55
|
+
"pngjs": "^7.0.0",
|
|
56
|
+
"ssim.js": "^3.5.0",
|
|
57
|
+
"zod": "^4.0.0"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@eslint/js": "^9.39.1",
|
|
61
|
+
"@types/node": "^24.10.1",
|
|
62
|
+
"@types/pixelmatch": "^5.2.6",
|
|
63
|
+
"@types/pngjs": "^6.0.5",
|
|
64
|
+
"eslint": "^9.39.1",
|
|
65
|
+
"eslint-config-prettier": "^10.1.8",
|
|
66
|
+
"prettier": "^3.7.4",
|
|
67
|
+
"typescript": "~5.9.3",
|
|
68
|
+
"typescript-eslint": "^8.49.0"
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/.gitkeep
ADDED
|
File without changes
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { init } from './commands/init.ts';
|
|
3
|
+
import { supervise } from './commands/supervise.ts';
|
|
4
|
+
import { loadConfig } from './config.ts';
|
|
5
|
+
import { approveAll } from './runner/approve.ts';
|
|
6
|
+
import { runAll } from './runner/run.ts';
|
|
7
|
+
|
|
8
|
+
interface ParsedArguments {
|
|
9
|
+
command: 'run' | 'approve' | 'init' | 'supervise' | 'help';
|
|
10
|
+
storyFilter?: string;
|
|
11
|
+
headed: boolean;
|
|
12
|
+
workers?: number;
|
|
13
|
+
manageServers: boolean;
|
|
14
|
+
coverage: boolean;
|
|
15
|
+
healthcheckIntervalMs?: number;
|
|
16
|
+
idleLimitMs?: number;
|
|
17
|
+
maxRuntimeMs?: number;
|
|
18
|
+
maxRespawns?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseArguments(argv: string[]): ParsedArguments {
|
|
22
|
+
const [command, ...rest] = argv;
|
|
23
|
+
const parsed: ParsedArguments = {
|
|
24
|
+
command:
|
|
25
|
+
command === 'run' ||
|
|
26
|
+
command === 'approve' ||
|
|
27
|
+
command === 'init' ||
|
|
28
|
+
command === 'supervise' ||
|
|
29
|
+
command === 'help'
|
|
30
|
+
? command
|
|
31
|
+
: 'help',
|
|
32
|
+
headed: false,
|
|
33
|
+
manageServers: false,
|
|
34
|
+
coverage: false,
|
|
35
|
+
};
|
|
36
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
37
|
+
const arg = rest[index];
|
|
38
|
+
if (arg === undefined) continue;
|
|
39
|
+
if (arg === '--headed') {
|
|
40
|
+
parsed.headed = true;
|
|
41
|
+
} else if (arg === '--story') {
|
|
42
|
+
parsed.storyFilter = rest[index + 1];
|
|
43
|
+
index += 1;
|
|
44
|
+
} else if (arg.startsWith('--story=')) {
|
|
45
|
+
parsed.storyFilter = arg.slice('--story='.length);
|
|
46
|
+
} else if (arg === '--workers') {
|
|
47
|
+
parsed.workers = Number(rest[index + 1]);
|
|
48
|
+
index += 1;
|
|
49
|
+
} else if (arg.startsWith('--workers=')) {
|
|
50
|
+
parsed.workers = Number(arg.slice('--workers='.length));
|
|
51
|
+
} else if (arg === '--manage-servers') {
|
|
52
|
+
parsed.manageServers = true;
|
|
53
|
+
} else if (arg === '--coverage') {
|
|
54
|
+
parsed.coverage = true;
|
|
55
|
+
} else if (arg === '--healthcheck-interval') {
|
|
56
|
+
parsed.healthcheckIntervalMs = Number(rest[index + 1]);
|
|
57
|
+
index += 1;
|
|
58
|
+
} else if (arg.startsWith('--healthcheck-interval=')) {
|
|
59
|
+
parsed.healthcheckIntervalMs = Number(
|
|
60
|
+
arg.slice('--healthcheck-interval='.length),
|
|
61
|
+
);
|
|
62
|
+
} else if (arg === '--idle-limit') {
|
|
63
|
+
parsed.idleLimitMs = Number(rest[index + 1]);
|
|
64
|
+
index += 1;
|
|
65
|
+
} else if (arg.startsWith('--idle-limit=')) {
|
|
66
|
+
parsed.idleLimitMs = Number(arg.slice('--idle-limit='.length));
|
|
67
|
+
} else if (arg === '--max-runtime') {
|
|
68
|
+
parsed.maxRuntimeMs = Number(rest[index + 1]);
|
|
69
|
+
index += 1;
|
|
70
|
+
} else if (arg.startsWith('--max-runtime=')) {
|
|
71
|
+
parsed.maxRuntimeMs = Number(arg.slice('--max-runtime='.length));
|
|
72
|
+
} else if (arg === '--max-respawns') {
|
|
73
|
+
parsed.maxRespawns = Number(rest[index + 1]);
|
|
74
|
+
index += 1;
|
|
75
|
+
} else if (arg.startsWith('--max-respawns=')) {
|
|
76
|
+
parsed.maxRespawns = Number(arg.slice('--max-respawns='.length));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return parsed;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function printHelp(): void {
|
|
83
|
+
process.stdout.write(
|
|
84
|
+
[
|
|
85
|
+
'Usage: tuffgal <command> [options]',
|
|
86
|
+
'',
|
|
87
|
+
'Commands:',
|
|
88
|
+
' run Run every story under the configured stories directory.',
|
|
89
|
+
' approve Promote every "changed" actual to its baseline.',
|
|
90
|
+
' init Scaffold a tuffgal.config.ts in the current directory.',
|
|
91
|
+
' supervise Long-running wrapper around devServers.command with',
|
|
92
|
+
' healthcheck restart, idle auto-term, and wall-clock cap.',
|
|
93
|
+
' help Show this message.',
|
|
94
|
+
'',
|
|
95
|
+
'Options:',
|
|
96
|
+
' --story <name> Filter to a single story (filename or story text).',
|
|
97
|
+
' --headed Show the browser while running.',
|
|
98
|
+
' --workers N Override the worker pool size (default min(cpus/2, 4)).',
|
|
99
|
+
' --manage-servers Spawn devServers.command, wait, run, then kill it.',
|
|
100
|
+
' --coverage Capture V8 JS + CSS coverage and emit a monocart report.',
|
|
101
|
+
'',
|
|
102
|
+
'Supervise options:',
|
|
103
|
+
' --healthcheck-interval N Probe interval in ms (default 30_000).',
|
|
104
|
+
' --idle-limit N Ms with no `tuffgal run` heartbeat before exit (default 600_000).',
|
|
105
|
+
' --max-runtime N Wall-clock cap in ms (default 3_600_000).',
|
|
106
|
+
' --max-respawns N Respawn budget after unhealthy/exit (default 3).',
|
|
107
|
+
].join('\n') + '\n',
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function main(): Promise<void> {
|
|
112
|
+
const args = parseArguments(process.argv.slice(2));
|
|
113
|
+
if (args.command === 'help') {
|
|
114
|
+
printHelp();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (args.command === 'init') {
|
|
118
|
+
await init({ cwd: process.cwd() });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const config = await loadConfig(process.cwd());
|
|
122
|
+
if (args.command === 'run') {
|
|
123
|
+
const result = await runAll(config, {
|
|
124
|
+
storyFilter: args.storyFilter,
|
|
125
|
+
headed: args.headed,
|
|
126
|
+
workers: args.workers,
|
|
127
|
+
manageServers: args.manageServers,
|
|
128
|
+
coverage: args.coverage,
|
|
129
|
+
});
|
|
130
|
+
process.stdout.write(
|
|
131
|
+
`\nTotals: ${result.totals.passed} pass · ${result.totals.changed} changed · ${result.totals.failed} failed\n`,
|
|
132
|
+
);
|
|
133
|
+
process.exit(result.totals.failed > 0 ? 1 : 0);
|
|
134
|
+
}
|
|
135
|
+
if (args.command === 'approve') {
|
|
136
|
+
const summary = await approveAll(config, {
|
|
137
|
+
storyFilter: args.storyFilter,
|
|
138
|
+
});
|
|
139
|
+
process.stdout.write(
|
|
140
|
+
`\nApproved ${summary.approved} baselines; skipped ${summary.skipped} actions.\n`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
if (args.command === 'supervise') {
|
|
144
|
+
await supervise(config, {
|
|
145
|
+
healthcheckIntervalMs: args.healthcheckIntervalMs,
|
|
146
|
+
idleLimitMs: args.idleLimitMs,
|
|
147
|
+
maxRuntimeMs: args.maxRuntimeMs,
|
|
148
|
+
maxRespawns: args.maxRespawns,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
main().catch((error) => {
|
|
154
|
+
process.stderr.write(
|
|
155
|
+
`tuffgal error: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
156
|
+
);
|
|
157
|
+
process.exit(1);
|
|
158
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { mkdir, writeFile, access } from 'node:fs/promises';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const CONFIG_FILES = ['tuffgal.config.ts', 'tuffgal.config.js'];
|
|
5
|
+
|
|
6
|
+
const CONFIG_TEMPLATE = `import { defineConfig } from 'tuffgal';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Tuffgal config. The full field reference lives at
|
|
10
|
+
* https://github.com/nschneble/tuffgal/blob/main/docs/config.md.
|
|
11
|
+
*
|
|
12
|
+
* Required: \`paths\` and \`baseUrl\`. Everything else is optional with
|
|
13
|
+
* sensible defaults.
|
|
14
|
+
*/
|
|
15
|
+
export default defineConfig({
|
|
16
|
+
paths: {
|
|
17
|
+
actions: 'tuffgal/actions',
|
|
18
|
+
stories: 'tuffgal/stories',
|
|
19
|
+
baselines: 'tuffgal/baselines',
|
|
20
|
+
report: 'tuffgal/report',
|
|
21
|
+
authState: 'tuffgal/.auth',
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
baseUrl: process.env.APP_BASE_URL ?? 'http://localhost:5173',
|
|
25
|
+
|
|
26
|
+
// Uncomment if your app makes API calls under a different origin. Used
|
|
27
|
+
// by intercept patterns to scope routes to API traffic.
|
|
28
|
+
// apiHost: 'http://localhost:3000',
|
|
29
|
+
|
|
30
|
+
// localStorage keys to persist across stories. Cookie-based apps may
|
|
31
|
+
// leave this empty — cookies always persist via Playwright storage state.
|
|
32
|
+
// storageStatePins: ['session_token'],
|
|
33
|
+
|
|
34
|
+
viewport: { width: 1280, height: 800 },
|
|
35
|
+
defaultTimeoutMs: 10_000,
|
|
36
|
+
navigationTimeoutMs: 15_000,
|
|
37
|
+
frozenTime: '2026-01-15T12:00:00.000Z',
|
|
38
|
+
|
|
39
|
+
// Database bridge — wire to your reset + fixture functions. Skip the
|
|
40
|
+
// entire block for static sites. See examples/postgres-prisma/ for a
|
|
41
|
+
// working recipe.
|
|
42
|
+
// database: {
|
|
43
|
+
// reset: async () => { /* truncate + reseed */ },
|
|
44
|
+
// fixtures: {
|
|
45
|
+
// 'example-rows': async () => { /* idempotent inserts */ },
|
|
46
|
+
// },
|
|
47
|
+
// },
|
|
48
|
+
|
|
49
|
+
// Dev-server bridge. Used by \`--manage-servers\` and \`tuffgal supervise\`.
|
|
50
|
+
// Omit when you run servers yourself.
|
|
51
|
+
// devServers: {
|
|
52
|
+
// command: 'npm run dev:test',
|
|
53
|
+
// healthCheck: [
|
|
54
|
+
// { url: 'http://localhost:3000', timeoutMs: 120_000 },
|
|
55
|
+
// { url: 'http://localhost:5173', timeoutMs: 120_000 },
|
|
56
|
+
// ],
|
|
57
|
+
// },
|
|
58
|
+
});
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
const GITKEEP_SUBDIRECTORIES = [
|
|
62
|
+
'tuffgal/actions',
|
|
63
|
+
'tuffgal/stories',
|
|
64
|
+
'tuffgal/baselines',
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
const REPORT_GITIGNORE = `# Generated by Tuffgal — do not commit.
|
|
68
|
+
.auth/
|
|
69
|
+
report/
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
export interface InitOptions {
|
|
73
|
+
cwd: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Scaffolds a starter Tuffgal layout in the given directory:
|
|
78
|
+
* - tuffgal.config.ts (commented template)
|
|
79
|
+
* - tuffgal/actions/.gitkeep
|
|
80
|
+
* - tuffgal/stories/.gitkeep
|
|
81
|
+
* - tuffgal/baselines/.gitkeep
|
|
82
|
+
* - tuffgal/.gitignore (ignores .auth/ and report/)
|
|
83
|
+
*
|
|
84
|
+
* Refuses to overwrite an existing tuffgal.config.{ts,js}. Existing
|
|
85
|
+
* scaffolded directories are left alone — a second \`init\` is safe to run
|
|
86
|
+
* after manually deleting just the config.
|
|
87
|
+
*/
|
|
88
|
+
export async function init(options: InitOptions): Promise<void> {
|
|
89
|
+
const { cwd } = options;
|
|
90
|
+
|
|
91
|
+
for (const candidate of CONFIG_FILES) {
|
|
92
|
+
if (await pathExists(join(cwd, candidate))) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`${candidate} already exists at ${cwd}. Refusing to overwrite. ` +
|
|
95
|
+
`Delete it manually if you want to start over.`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const configPath = join(cwd, 'tuffgal.config.ts');
|
|
101
|
+
await writeFile(configPath, CONFIG_TEMPLATE, 'utf8');
|
|
102
|
+
process.stdout.write(`Wrote ${relative(cwd, configPath) || 'tuffgal.config.ts'}\n`);
|
|
103
|
+
|
|
104
|
+
for (const subdirectory of GITKEEP_SUBDIRECTORIES) {
|
|
105
|
+
const absolute = join(cwd, subdirectory);
|
|
106
|
+
await mkdir(absolute, { recursive: true });
|
|
107
|
+
const gitkeep = join(absolute, '.gitkeep');
|
|
108
|
+
if (!(await pathExists(gitkeep))) {
|
|
109
|
+
await writeFile(gitkeep, '', 'utf8');
|
|
110
|
+
process.stdout.write(`Wrote ${relative(cwd, gitkeep)}\n`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const gitignorePath = join(cwd, 'tuffgal', '.gitignore');
|
|
115
|
+
if (!(await pathExists(gitignorePath))) {
|
|
116
|
+
await writeFile(gitignorePath, REPORT_GITIGNORE, 'utf8');
|
|
117
|
+
process.stdout.write(`Wrote ${relative(cwd, gitignorePath)}\n`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
process.stdout.write(
|
|
121
|
+
[
|
|
122
|
+
'',
|
|
123
|
+
'Tuffgal scaffold ready. Next steps:',
|
|
124
|
+
' 1. Edit tuffgal.config.ts to point at your dev server.',
|
|
125
|
+
' 2. Write your first action under tuffgal/actions/.',
|
|
126
|
+
' 3. Write your first story under tuffgal/stories/.',
|
|
127
|
+
' 4. Run `npx tuffgal run` to capture baselines.',
|
|
128
|
+
'',
|
|
129
|
+
].join('\n'),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function pathExists(absolute: string): Promise<boolean> {
|
|
134
|
+
try {
|
|
135
|
+
await access(absolute);
|
|
136
|
+
return true;
|
|
137
|
+
} catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|