@andwhy/e2e-pipeline 0.10.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 (31) hide show
  1. package/README.md +106 -0
  2. package/dist/bin/init.d.ts +2 -0
  3. package/dist/bin/init.js +128 -0
  4. package/dist/utils/copy.d.ts +23 -0
  5. package/dist/utils/copy.js +249 -0
  6. package/dist/utils/format.d.ts +1 -0
  7. package/dist/utils/format.js +49 -0
  8. package/dist/utils/install.d.ts +2 -0
  9. package/dist/utils/install.js +54 -0
  10. package/dist/utils/prompt.d.ts +2 -0
  11. package/dist/utils/prompt.js +17 -0
  12. package/package.json +37 -0
  13. package/templates/claude/agents/playwright-test-generator.md +184 -0
  14. package/templates/claude/agents/playwright-test-healer.md +70 -0
  15. package/templates/claude/agents/playwright-test-planner.md +135 -0
  16. package/templates/claude/partials/_robustness-core.md +69 -0
  17. package/templates/claude/rules/playwright-workflow.md +203 -0
  18. package/templates/claude/skills/playwright-test-pipeline/SKILL.md +201 -0
  19. package/templates/github/scripts/playwright-pr-comment.mjs +95 -0
  20. package/templates/github/workflows/checkly-deploy.yml +64 -0
  21. package/templates/github/workflows/pr-playwright-tests.yml +154 -0
  22. package/templates/shared/checkly.config.template.mjs +58 -0
  23. package/templates/shared/checkly.config.template.ts +58 -0
  24. package/templates/shared/playwright.config.template.mjs +44 -0
  25. package/templates/shared/playwright.config.template.ts +44 -0
  26. package/templates/shared/playwright.mcp.config.template.mjs +12 -0
  27. package/templates/shared/playwright.mcp.config.template.ts +12 -0
  28. package/templates/shared/preview-consent-setup.template.js +32 -0
  29. package/templates/shared/preview-consent-setup.template.ts +33 -0
  30. package/templates/shared/seed.spec.js +5 -0
  31. package/templates/shared/seed.spec.ts +5 -0
package/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # @andwhy/e2e-pipeline
2
+
3
+ Scaffold customized Playwright test agents (Planner, Generator, Healer) into any project for **Claude Code**.
4
+
5
+ ## Quick start
6
+
7
+ ```bash
8
+ pnpm dlx @andwhy/e2e-pipeline init
9
+ ```
10
+
11
+ The CLI copies agent definitions, orchestration rules, and config templates into your project. The install is purely additive — it never creates or overwrites `CLAUDE.md`.
12
+
13
+ **Setup and workflow docs:** [E2E Tests in Notion](https://app.notion.com/p/31ff837b6270805d8922c4a7c7beb6b0) (why-agency internal). These are maintained in one place rather than scaffolded into each project, so a workflow change is published once.
14
+
15
+ ## Options
16
+
17
+ ```
18
+ pnpm dlx @andwhy/e2e-pipeline init [--format ts|mjs] [--force] [--reset-config]
19
+ ```
20
+
21
+ | Flag | Description |
22
+ | ---------------- | ------------------------------------------------------------------------------------------------------------ |
23
+ | `--format` | Config file format (`ts` or `mjs`). Use `mjs` if TS causes conflicts. Prompted if omitted. |
24
+ | `--force` | Update package-owned files. Never touches your configs, `CLAUDE.md`, or anything else you own. |
25
+ | `--reset-config` | Write a fresh `*.new.{ts,mjs}` next to each config so you can diff it. Your originals are left alone. |
26
+
27
+ Re-running `init --force` in an existing project is the way to pick up a new version.
28
+
29
+ ## What gets created
30
+
31
+ **Package-owned** — regenerated by `--force`, so don't edit these by hand:
32
+
33
+ - `.claude/agents/` — Planner, Generator, Healer agent definitions (3 agents, each defines its own MCP server)
34
+ - `.claude/rules/playwright-workflow.md` — orchestration rules for the Plan → Generate → Heal pipeline, auto-loaded into the main session alongside any existing `CLAUDE.md`
35
+ - `.claude/skills/playwright-test-pipeline/` — guided walkthrough skill; triggers when someone asks to create or fix tests and escorts them through setup check, Plan → Generate → Heal, preview validation, and deployment
36
+ - `.mcp.json` — Playwright MCP server config (project root)
37
+ - `playwright.mcp.config.{ts,mjs}` — config the MCP server loads: extends `playwright.config` and clears `testIgnore`, so the planner/generator can execute seeds that normal runs ignore
38
+
39
+ **Seeded once** — starting points your project then owns. Written only when absent and never overwritten, `--force` included:
40
+
41
+ - `playwright.config.{ts,mjs}` — Playwright config template with `BASE_URL` support, `testDir: "./tests/e2e"`, and `testIgnore` for seeds
42
+ - `checkly.config.{ts,mjs}` — Checkly config template with placeholders
43
+ - `tests/e2e/seed.spec.{ts,js}` — seed file for Playwright MCP `setup_page`
44
+ - `tests/e2e/helpers/preview-consent-setup.{ts,js}` — `prepareVercelPreviewPage` (Vercel deployment-protection bypass); projects extend it with consent logic
45
+ - `.github/workflows/pr-playwright-tests.yml` — runs the suite against the PR's Vercel preview and comments the result (plus `.github/scripts/playwright-pr-comment.mjs`)
46
+ - `.github/workflows/checkly-deploy.yml` — deploys monitors on merge to `main`
47
+
48
+ The workflows need repository secrets: `CHECKLY_API_KEY`, `CHECKLY_ACCOUNT_ID`,
49
+ and `VERCEL_AUTOMATION_BYPASS_SECRET` (values in 1Password).
50
+
51
+ E2E specs live under **`tests/e2e/`** so a repo can keep unit tests elsewhere under
52
+ `tests/` without Playwright trying to run them. Seeds sit in the same tree and are
53
+ kept out of test discovery by `testIgnore` rather than by living outside it.
54
+
55
+ The agents treat this as a default, not a rule: if a repo already has specs
56
+ somewhere else, they write there instead. See rule 6 in
57
+ `.claude/rules/playwright-workflow.md`.
58
+
59
+ `.gitignore` gains Playwright/test output and seed entries; existing lines are left in place.
60
+
61
+ Scaffolded files are formatted with the project's own prettier when one is
62
+ installed, so they match house style instead of this package's.
63
+
64
+ ## Agent customizations
65
+
66
+ These agents extend the vanilla Playwright test agents with:
67
+
68
+ - **Extra tools**: Generator gets `terminal`, `edit`, `read_file`; Healer gets `terminal`
69
+ - **`data-testid` workflow**: Generator reads recommended attributes from the plan and adds them to source components before recording
70
+ - **Vercel share auth**: every `test.describe` includes a `beforeEach` that authenticates with Vercel preview deployments
71
+ - **Checkly verification**: both Generator and Healer run `pnpm exec checkly pw-test` after local tests pass
72
+ - **Numbered step comments**: every logical step gets a comment (`// 1. Navigate to…`)
73
+ - **Locale-awareness**: no hardcoded domains or locale-specific URL patterns
74
+ - **Optional draft input**: Planner can validate a user-provided journey draft against the live page
75
+
76
+ ## Documentation
77
+
78
+ The Installation and Usage guides live in `docs/notion/` and are the source of
79
+ truth. Merging a change to `main` publishes them to their Notion pages, stamped
80
+ with the package version they document. Edits made directly in Notion are
81
+ overwritten on the next publish.
82
+
83
+ Publishing needs a `NOTION_TOKEN` repository secret — a Notion PAT, or an
84
+ internal integration token whose integration has been shared with both pages.
85
+ Pull requests run `node scripts/publish-notion.mjs --dry-run`, which converts
86
+ without writing, so malformed markdown fails before merge.
87
+
88
+ The stamp shows the **latest version published to npm**, not the version in
89
+ `package.json`. Docs describing an unreleased version send people looking for
90
+ features they cannot install yet.
91
+
92
+ ## Releasing
93
+
94
+ Bump `version` in `package.json`, add a `CHANGELOG.md` entry, merge to `main`.
95
+ That's the whole release.
96
+
97
+ `.github/workflows/release.yml` compares `package.json` against the version on
98
+ npm. If they differ it publishes and tags the commit; if they match it exits
99
+ quietly, so ordinary merges publish nothing. It needs an `NPM_TOKEN` repository
100
+ secret with publish rights on the `@andwhy` scope.
101
+
102
+ Published without npm provenance, which requires a public source repository.
103
+
104
+ ## License
105
+
106
+ MIT
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { findSpecsOutsideE2e, scaffold, } from "../utils/copy.js";
5
+ import { formatFiles } from "../utils/format.js";
6
+ import { installDeps } from "../utils/install.js";
7
+ import { askFileFormat } from "../utils/prompt.js";
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ // Setup and workflow docs are maintained in Notion (why-agency internal), not
10
+ // scaffolded into projects, so a workflow change is published in one place.
11
+ const DOCS_URL = "https://app.notion.com/p/31ff837b6270805d8922c4a7c7beb6b0";
12
+ const parseArgs = (argv) => {
13
+ let format;
14
+ let force = false;
15
+ let resetConfig = false;
16
+ for (let i = 2; i < argv.length; i++) {
17
+ const arg = argv[i];
18
+ if (arg === "init")
19
+ continue;
20
+ if (arg === "--force") {
21
+ force = true;
22
+ }
23
+ else if (arg === "--reset-config") {
24
+ resetConfig = true;
25
+ }
26
+ else if (arg === "--format" && argv[i + 1]) {
27
+ const val = argv[++i];
28
+ if (val === "ts" || val === "mjs") {
29
+ format = val;
30
+ }
31
+ else {
32
+ console.error(`Invalid --format value: ${val} (use "ts" or "mjs")`);
33
+ process.exit(1);
34
+ }
35
+ }
36
+ else if (arg.startsWith("--format=")) {
37
+ const val = arg.slice("--format=".length);
38
+ if (val === "ts" || val === "mjs") {
39
+ format = val;
40
+ }
41
+ else {
42
+ console.error(`Invalid --format value: ${val} (use "ts" or "mjs")`);
43
+ process.exit(1);
44
+ }
45
+ }
46
+ }
47
+ return { format, force, resetConfig };
48
+ };
49
+ const run = async () => {
50
+ const { format: formatArg, force, resetConfig } = parseArgs(process.argv);
51
+ const fileFormat = formatArg ?? (await askFileFormat());
52
+ const projectRoot = process.cwd();
53
+ const templatesRoot = path.resolve(__dirname, "..", "..", "templates");
54
+ console.log(`\nScaffolding Playwright test agents for Claude Code…\n`);
55
+ const result = await scaffold(templatesRoot, projectRoot, {
56
+ force,
57
+ fileFormat,
58
+ resetConfig,
59
+ });
60
+ if (result.created.length > 0) {
61
+ console.log("Created:");
62
+ for (const f of result.created) {
63
+ console.log(` ${path.relative(projectRoot, f)}`);
64
+ }
65
+ }
66
+ if (result.skipped.length > 0) {
67
+ console.log("\nSkipped (already exist):");
68
+ for (const f of result.skipped) {
69
+ console.log(` ${path.relative(projectRoot, f)}`);
70
+ }
71
+ if (!force) {
72
+ console.log(" Use --force to update package-owned files.");
73
+ }
74
+ console.log(" Your configs are never overwritten — use --reset-config for a fresh copy to diff against.");
75
+ }
76
+ if (result.regenerated.length > 0) {
77
+ console.log("\nWrote fresh configs alongside yours (diff, then delete):");
78
+ for (const f of result.regenerated) {
79
+ console.log(` ${path.relative(projectRoot, f)}`);
80
+ }
81
+ }
82
+ const formatted = await formatFiles(projectRoot, [
83
+ ...result.created,
84
+ ...result.regenerated,
85
+ ]);
86
+ // "Ran over", not "formatted" — prettier skips anything the project's
87
+ // .prettierignore covers, and this count cannot see that.
88
+ if (formatted > 0) {
89
+ console.log(`\nRan the project's prettier over ${formatted} scaffolded file(s).`);
90
+ }
91
+ const outsideSpecs = await findSpecsOutsideE2e(projectRoot);
92
+ if (outsideSpecs.length > 0) {
93
+ console.log("\nExisting tests outside tests/e2e:");
94
+ for (const spec of outsideSpecs) {
95
+ console.log(` ${spec}`);
96
+ }
97
+ console.log([
98
+ " These stay outside the scaffolded testDir (./tests/e2e) and never run",
99
+ " on Checkly — testCommand pins cloud runs to tests/e2e. To keep running",
100
+ " them with Playwright, give them their own project with its own testDir",
101
+ " in playwright.config. Do NOT widen testDir to ./tests: that runs them",
102
+ " in every browser project and mixes the suites.",
103
+ ].join("\n"));
104
+ }
105
+ const createdWorkflow = result.created.some((f) => f.includes(`.github${path.sep}workflows`));
106
+ if (createdWorkflow) {
107
+ console.log([
108
+ "\nGitHub workflows created. They need repository secrets before they run",
109
+ "(values in 1Password):",
110
+ " CHECKLY_API_KEY, CHECKLY_ACCOUNT_ID — deploy monitors on merge",
111
+ " VERCEL_AUTOMATION_BYPASS_SECRET — PR tests through Vercel",
112
+ " deployment protection",
113
+ ].join("\n"));
114
+ }
115
+ installDeps(projectRoot, fileFormat);
116
+ console.log("Next steps:");
117
+ let step = 1;
118
+ const ext = fileFormat === "mjs" ? "mjs" : "ts";
119
+ console.log(` ${step++}. Edit checkly.config.${ext} with your project name and check suites`);
120
+ console.log(` ${step++}. Run: npx checkly login`);
121
+ console.log(` ${step++}. Setup and workflow docs: ${DOCS_URL}`);
122
+ console.log("\nPlaywright agent rules installed into .claude/rules/ — they load alongside any existing CLAUDE.md without conflict.");
123
+ console.log();
124
+ };
125
+ run().catch((err) => {
126
+ console.error(err);
127
+ process.exit(1);
128
+ });
@@ -0,0 +1,23 @@
1
+ interface CopyResult {
2
+ created: string[];
3
+ skipped: string[];
4
+ regenerated: string[];
5
+ }
6
+ /**
7
+ * Specs already living under tests/ but outside tests/e2e — a unit or a11y
8
+ * suite that predates the scaffold. They need calling out at install time:
9
+ * left alone they are simply outside testDir, but the tempting "fix" of
10
+ * widening testDir to ./tests runs them in every browser project and mixes
11
+ * the suites.
12
+ */
13
+ export declare const findSpecsOutsideE2e: (projectRoot: string) => Promise<string[]>;
14
+ export type FileFormat = "ts" | "mjs";
15
+ export interface ScaffoldOptions {
16
+ /** Overwrite package-owned files. Never applies to seeded files. */
17
+ force?: boolean;
18
+ fileFormat?: FileFormat;
19
+ /** Write a fresh copy of each config alongside the existing one, as `.new`. */
20
+ resetConfig?: boolean;
21
+ }
22
+ export declare const scaffold: (templatesRoot: string, projectRoot: string, { force, fileFormat, resetConfig }?: ScaffoldOptions) => Promise<CopyResult>;
23
+ export {};
@@ -0,0 +1,249 @@
1
+ import path from "node:path";
2
+ import fs from "fs-extra";
3
+ const copyTemplateDir = async (srcDir, destDir, force, result, destRename) => {
4
+ const entries = await fs.readdir(srcDir, { withFileTypes: true });
5
+ for (const entry of entries) {
6
+ const srcPath = path.join(srcDir, entry.name);
7
+ const relPath = destRename ? destRename(entry.name) : entry.name;
8
+ const destPath = path.join(destDir, relPath);
9
+ if (entry.isDirectory()) {
10
+ await copyTemplateDir(srcPath, destPath, force, result);
11
+ }
12
+ else {
13
+ if (!force && (await fs.pathExists(destPath))) {
14
+ result.skipped.push(destPath);
15
+ }
16
+ else {
17
+ await fs.ensureDir(path.dirname(destPath));
18
+ await fs.copy(srcPath, destPath);
19
+ result.created.push(destPath);
20
+ }
21
+ }
22
+ }
23
+ };
24
+ const ROBUSTNESS_MARKER = "<!-- ROBUSTNESS_CORE -->";
25
+ // Copies agent definition files, inlining the shared robustness core partial in
26
+ // place of the ROBUSTNESS_CORE marker. Subagents run in isolated context and
27
+ // cannot read the partial (or any rules file), so the content must be embedded
28
+ // in each agent body at scaffold time. The partials/ directory is never copied.
29
+ const copyAgentsDir = async (agentsSrc, agentsDest, robustnessCore, force, result) => {
30
+ const entries = await fs.readdir(agentsSrc, { withFileTypes: true });
31
+ for (const entry of entries) {
32
+ if (!entry.isFile())
33
+ continue;
34
+ const srcPath = path.join(agentsSrc, entry.name);
35
+ const destPath = path.join(agentsDest, entry.name);
36
+ if (!force && (await fs.pathExists(destPath))) {
37
+ result.skipped.push(destPath);
38
+ continue;
39
+ }
40
+ const content = await fs.readFile(srcPath, "utf-8");
41
+ const inlined = content.split(ROBUSTNESS_MARKER).join(robustnessCore);
42
+ await fs.ensureDir(path.dirname(destPath));
43
+ await fs.writeFile(destPath, inlined);
44
+ result.created.push(destPath);
45
+ }
46
+ };
47
+ const readProjectName = async (projectRoot) => {
48
+ const pkgPath = path.join(projectRoot, "package.json");
49
+ if (await fs.pathExists(pkgPath)) {
50
+ const pkg = await fs.readJson(pkgPath);
51
+ const name = pkg.name ?? "";
52
+ return name.replace(/^@[^/]+\//, "");
53
+ }
54
+ return path.basename(projectRoot);
55
+ };
56
+ const buildMcpConfig = (configPath) => {
57
+ const server = {
58
+ command: "pnpm",
59
+ args: ["playwright", "run-test-mcp-server", "-c", configPath],
60
+ };
61
+ return (JSON.stringify({ mcpServers: { "playwright-test": server } }, null, 2) +
62
+ "\n");
63
+ };
64
+ const writeMcpConfig = async (destPath, configPath, force, result) => {
65
+ if (!force && (await fs.pathExists(destPath))) {
66
+ result.skipped.push(destPath);
67
+ return;
68
+ }
69
+ await fs.ensureDir(path.dirname(destPath));
70
+ await fs.writeFile(destPath, buildMcpConfig(configPath));
71
+ result.created.push(destPath);
72
+ };
73
+ const GITIGNORE_LINES = [
74
+ "# Playwright",
75
+ "test-results/",
76
+ "playwright-report/",
77
+ // Session artifacts (traces, snapshots) the Playwright MCP server writes
78
+ // while the planner/generator drive the browser.
79
+ ".playwright-mcp/",
80
+ ];
81
+ // Lines earlier scaffold versions added and this one must take back: seeds
82
+ // were once regenerable root-level scaffolding, but since 0.5.0 the planner
83
+ // writes real per-feature setup into them and the convention is to commit
84
+ // seeds alongside specs and plans — a bare "seed.spec.ts" entry ignores every
85
+ // seed at any depth. Removed only as exact-line matches, so a project's own
86
+ // deliberate patterns are never touched.
87
+ const GITIGNORE_STALE_LINES = ["seed.spec.ts", "seed.spec.js"];
88
+ const appendGitignore = async (projectRoot, result) => {
89
+ const gitignorePath = path.join(projectRoot, ".gitignore");
90
+ let existing = (await fs.pathExists(gitignorePath))
91
+ ? await fs.readFile(gitignorePath, "utf-8")
92
+ : "";
93
+ const lines = existing.split("\n");
94
+ const cleaned = lines.filter((line) => !GITIGNORE_STALE_LINES.includes(line.trim()));
95
+ const removedStale = cleaned.length !== lines.length;
96
+ if (removedStale) {
97
+ existing = cleaned.join("\n");
98
+ await fs.writeFile(gitignorePath, existing);
99
+ }
100
+ const missing = GITIGNORE_LINES.filter((line) => !existing.includes(line));
101
+ if (missing.length === 0) {
102
+ if (removedStale)
103
+ result.created.push(gitignorePath);
104
+ return;
105
+ }
106
+ const separator = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
107
+ await fs.appendFile(gitignorePath, `${separator}\n${missing.join("\n")}\n`);
108
+ result.created.push(gitignorePath);
109
+ };
110
+ const writeSeed = async (srcPath, destPath, srcName, projectName) => {
111
+ await fs.ensureDir(path.dirname(destPath));
112
+ await fs.copy(srcPath, destPath);
113
+ if (srcName.startsWith("checkly.config.template")) {
114
+ const content = await fs.readFile(destPath, "utf-8");
115
+ await fs.writeFile(destPath, content.replace(/my-app/g, projectName));
116
+ }
117
+ };
118
+ /**
119
+ * Specs already living under tests/ but outside tests/e2e — a unit or a11y
120
+ * suite that predates the scaffold. They need calling out at install time:
121
+ * left alone they are simply outside testDir, but the tempting "fix" of
122
+ * widening testDir to ./tests runs them in every browser project and mixes
123
+ * the suites.
124
+ */
125
+ export const findSpecsOutsideE2e = async (projectRoot) => {
126
+ const testsRoot = path.join(projectRoot, "tests");
127
+ if (!(await fs.pathExists(testsRoot)))
128
+ return [];
129
+ const found = new Set();
130
+ const walk = async (dir) => {
131
+ const entries = await fs.readdir(dir, { withFileTypes: true });
132
+ for (const entry of entries) {
133
+ const entryPath = path.join(dir, entry.name);
134
+ if (entry.isDirectory()) {
135
+ if (dir === testsRoot && entry.name === "e2e")
136
+ continue;
137
+ await walk(entryPath);
138
+ }
139
+ else if (/\.(spec|test)\.[cm]?[jt]sx?$/.test(entry.name)) {
140
+ const rel = path.relative(projectRoot, entryPath);
141
+ const parts = rel.split(path.sep);
142
+ found.add(parts.length > 2 ? parts.slice(0, 2).join("/") : rel);
143
+ }
144
+ }
145
+ };
146
+ await walk(testsRoot);
147
+ return [...found].sort();
148
+ };
149
+ export const scaffold = async (templatesRoot, projectRoot, { force = false, fileFormat = "ts", resetConfig = false } = {}) => {
150
+ const result = { created: [], skipped: [], regenerated: [] };
151
+ const configExt = fileFormat === "mjs" ? "mjs" : "ts";
152
+ const seedExt = fileFormat === "mjs" ? "js" : "ts";
153
+ // The MCP server gets its own config: it must discover seeds that the main
154
+ // config's testIgnore hides from local, CI, and Checkly runs. Package-owned,
155
+ // like .mcp.json which points at it.
156
+ const mcpConfigPath = `./playwright.mcp.config.${configExt}`;
157
+ const claudeSrc = path.join(templatesRoot, "claude");
158
+ const robustnessCore = (await fs.readFile(path.join(claudeSrc, "partials", "_robustness-core.md"), "utf-8")).trimEnd();
159
+ await copyAgentsDir(path.join(claudeSrc, "agents"), path.join(projectRoot, ".claude", "agents"), robustnessCore, force, result);
160
+ await copyTemplateDir(path.join(claudeSrc, "rules"), path.join(projectRoot, ".claude", "rules"), force, result);
161
+ await copyTemplateDir(path.join(claudeSrc, "skills"), path.join(projectRoot, ".claude", "skills"), force, result);
162
+ await writeMcpConfig(path.join(projectRoot, ".mcp.json"), mcpConfigPath, force, result);
163
+ {
164
+ const src = path.join(templatesRoot, "shared", `playwright.mcp.config.template.${configExt}`);
165
+ const dest = path.join(projectRoot, `playwright.mcp.config.${configExt}`);
166
+ if (!force && (await fs.pathExists(dest))) {
167
+ result.skipped.push(dest);
168
+ }
169
+ else {
170
+ await fs.copy(src, dest);
171
+ result.created.push(dest);
172
+ }
173
+ }
174
+ const projectName = await readProjectName(projectRoot);
175
+ const sharedSrc = path.join(templatesRoot, "shared");
176
+ const sharedMappings = [
177
+ [
178
+ `playwright.config.template.${configExt}`,
179
+ `playwright.config.${configExt}`,
180
+ ],
181
+ [`checkly.config.template.${configExt}`, `checkly.config.${configExt}`],
182
+ // Inside testDir, alongside the specs it seeds. Safe there because the
183
+ // config's testIgnore keeps seeds out of test discovery.
184
+ [`seed.spec.${seedExt}`, path.join("tests", "e2e", `seed.spec.${seedExt}`)],
185
+ // The preview helper every spec's beforeEach calls (rules file, rule 3).
186
+ // Seeded once: projects extend it with their own consent logic.
187
+ [
188
+ `preview-consent-setup.template.${seedExt}`,
189
+ path.join("tests", "e2e", "helpers", `preview-consent-setup.${seedExt}`),
190
+ ],
191
+ ];
192
+ // Seeded files are starting points the project then owns — a Checkly config
193
+ // grows domains, alert policies and environment variables that no template can
194
+ // reproduce. They are written when absent and never overwritten, --force
195
+ // included. Use resetConfig to get a fresh copy to diff against by hand.
196
+ for (const [src, dest] of sharedMappings) {
197
+ const srcPath = path.join(sharedSrc, src);
198
+ const destPath = path.join(projectRoot, dest);
199
+ const exists = await fs.pathExists(destPath);
200
+ if (exists && resetConfig && src.endsWith(`.config.template.${configExt}`)) {
201
+ // checkly.config.new.ts, not checkly.config.ts.new — keeping the real
202
+ // extension means prettier can format it and the editor highlights it,
203
+ // so the diff against your config shows content rather than style.
204
+ const ext = path.extname(destPath);
205
+ const newPath = `${destPath.slice(0, -ext.length)}.new${ext}`;
206
+ await writeSeed(srcPath, newPath, src, projectName);
207
+ result.regenerated.push(newPath);
208
+ continue;
209
+ }
210
+ if (exists) {
211
+ result.skipped.push(destPath);
212
+ continue;
213
+ }
214
+ await writeSeed(srcPath, destPath, src, projectName);
215
+ result.created.push(destPath);
216
+ }
217
+ // GitHub workflows: seeded once, like the configs. Repos customize their
218
+ // workflows (path filters, retry posture), and --force must never clobber
219
+ // that. Both need repository secrets the scaffold cannot create — the CLI
220
+ // says which below, the skill walks the user through adding them.
221
+ const githubSrc = path.join(templatesRoot, "github");
222
+ const githubMappings = [
223
+ [
224
+ path.join("workflows", "checkly-deploy.yml"),
225
+ path.join(".github", "workflows", "checkly-deploy.yml"),
226
+ ],
227
+ [
228
+ path.join("workflows", "pr-playwright-tests.yml"),
229
+ path.join(".github", "workflows", "pr-playwright-tests.yml"),
230
+ ],
231
+ [
232
+ path.join("scripts", "playwright-pr-comment.mjs"),
233
+ path.join(".github", "scripts", "playwright-pr-comment.mjs"),
234
+ ],
235
+ ];
236
+ for (const [src, dest] of githubMappings) {
237
+ const destPath = path.join(projectRoot, dest);
238
+ if (await fs.pathExists(destPath)) {
239
+ result.skipped.push(destPath);
240
+ }
241
+ else {
242
+ await fs.ensureDir(path.dirname(destPath));
243
+ await fs.copy(path.join(githubSrc, src), destPath);
244
+ result.created.push(destPath);
245
+ }
246
+ }
247
+ await appendGitignore(projectRoot, result);
248
+ return result;
249
+ };
@@ -0,0 +1 @@
1
+ export declare const formatFiles: (projectRoot: string, files: string[]) => Promise<number>;
@@ -0,0 +1,49 @@
1
+ import path from "node:path";
2
+ import { spawnSync } from "node:child_process";
3
+ import fs from "fs-extra";
4
+ /**
5
+ * Scaffolded files carry this package's style, which is rarely the host
6
+ * project's — quotes, semicolons, print width all differ. Left alone, every
7
+ * install produces a diff of pure formatting noise and fights the project's
8
+ * pre-commit hook.
9
+ *
10
+ * Running the project's own prettier over the files we wrote fixes that without
11
+ * guessing at conventions: prettier resolves the project's config itself.
12
+ * Resolved from node_modules/.bin rather than through a package manager so
13
+ * nothing gets downloaded and no pnpm/npm/yarn detection is needed.
14
+ *
15
+ * Everything written gets formatted, `.claude/**` included. Those files are
16
+ * committed and reviewed like any other, and a repo that formats on commit
17
+ * (lint-staged over `**\/*`) would reformat them at the next commit anyway —
18
+ * skipping them here only defers the diff to a more surprising moment.
19
+ *
20
+ * A project that wants them left alone says so in its own `.prettierignore`,
21
+ * which prettier honours even for explicitly passed paths. That keeps the
22
+ * policy with the project rather than baked in here.
23
+ */
24
+ const resolvePrettier = async (projectRoot) => {
25
+ const binDir = path.join(projectRoot, "node_modules", ".bin");
26
+ for (const name of ["prettier", "prettier.cmd"]) {
27
+ const candidate = path.join(binDir, name);
28
+ if (await fs.pathExists(candidate))
29
+ return candidate;
30
+ }
31
+ return null;
32
+ };
33
+ export const formatFiles = async (projectRoot, files) => {
34
+ if (files.length === 0)
35
+ return 0;
36
+ const prettier = await resolvePrettier(projectRoot);
37
+ if (!prettier)
38
+ return 0;
39
+ // --ignore-unknown skips files prettier has no parser for (.gitignore), and
40
+ // .prettierignore is honoured automatically.
41
+ const result = spawnSync(prettier, ["--write", "--ignore-unknown", "--log-level", "warn", ...files], { cwd: projectRoot, stdio: "pipe", encoding: "utf-8" });
42
+ // Formatting is a nicety — a prettier failure must not fail the install.
43
+ if (result.status !== 0) {
44
+ const detail = (result.stderr || result.error?.message || "").trim();
45
+ console.warn(`\nCould not format scaffolded files with the project's prettier${detail ? `: ${detail}` : "."}`);
46
+ return 0;
47
+ }
48
+ return files.length;
49
+ };
@@ -0,0 +1,2 @@
1
+ import type { FileFormat } from "./copy.js";
2
+ export declare const installDeps: (projectRoot: string, fileFormat?: FileFormat) => void;
@@ -0,0 +1,54 @@
1
+ import { execSync } from "node:child_process";
2
+ import fs from "fs-extra";
3
+ import path from "node:path";
4
+ const LOCK_FILE_TO_PACKAGE_MANAGER = {
5
+ "pnpm-lock.yaml": "pnpm",
6
+ "package-lock.json": "npm",
7
+ };
8
+ const findWorkspaceInfo = (startDir) => {
9
+ let currentDir = path.resolve(startDir);
10
+ const { root } = path.parse(currentDir);
11
+ while (true) {
12
+ for (const [lockFile, pm] of Object.entries(LOCK_FILE_TO_PACKAGE_MANAGER)) {
13
+ if (fs.existsSync(path.join(currentDir, lockFile))) {
14
+ return {
15
+ packageManager: pm,
16
+ workspaceRoot: currentDir,
17
+ isMonorepo: currentDir !== path.resolve(startDir),
18
+ };
19
+ }
20
+ }
21
+ if (currentDir === root)
22
+ break;
23
+ currentDir = path.dirname(currentDir);
24
+ }
25
+ return {
26
+ packageManager: "npm",
27
+ workspaceRoot: path.resolve(startDir),
28
+ isMonorepo: false,
29
+ };
30
+ };
31
+ const getAddCommand = (packageManager) => {
32
+ if (packageManager === "pnpm")
33
+ return "pnpm add -D";
34
+ return "npm install -D";
35
+ };
36
+ const BASE_DEV_DEPS = ["@playwright/test", "checkly"];
37
+ export const installDeps = (projectRoot, fileFormat = "ts") => {
38
+ const { packageManager, workspaceRoot, isMonorepo } = findWorkspaceInfo(projectRoot);
39
+ const addCmd = getAddCommand(packageManager);
40
+ const devDeps = fileFormat === "ts" ? [...BASE_DEV_DEPS, "jiti"] : BASE_DEV_DEPS;
41
+ const cmd = `${addCmd} ${devDeps.join(" ")}`;
42
+ if (isMonorepo) {
43
+ const relativeRoot = path.relative(projectRoot, workspaceRoot) || workspaceRoot;
44
+ console.log(`Detected ${packageManager} workspace at ${relativeRoot} (root lockfile)`);
45
+ }
46
+ console.log(`Installing dependencies with ${packageManager}…`);
47
+ console.log(` ${cmd}\n`);
48
+ execSync(cmd, { cwd: projectRoot, stdio: "inherit" });
49
+ console.log("\nInstalling Chromium browser…\n");
50
+ execSync("npx playwright install chromium", {
51
+ cwd: projectRoot,
52
+ stdio: "inherit",
53
+ });
54
+ };
@@ -0,0 +1,2 @@
1
+ import type { FileFormat } from "./copy.js";
2
+ export declare const askFileFormat: () => Promise<FileFormat>;
@@ -0,0 +1,17 @@
1
+ import prompts from "prompts";
2
+ export const askFileFormat = async () => {
3
+ const { format } = await prompts({
4
+ type: "select",
5
+ name: "format",
6
+ message: "Config file format (use .mjs if TypeScript causes conflicts)?",
7
+ choices: [
8
+ { title: "TypeScript (.ts)", value: "ts" },
9
+ { title: "JavaScript (.mjs)", value: "mjs" },
10
+ ],
11
+ initial: 0,
12
+ });
13
+ if (!format) {
14
+ process.exit(0);
15
+ }
16
+ return format;
17
+ };