@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.
- package/README.md +106 -0
- package/dist/bin/init.d.ts +2 -0
- package/dist/bin/init.js +128 -0
- package/dist/utils/copy.d.ts +23 -0
- package/dist/utils/copy.js +249 -0
- package/dist/utils/format.d.ts +1 -0
- package/dist/utils/format.js +49 -0
- package/dist/utils/install.d.ts +2 -0
- package/dist/utils/install.js +54 -0
- package/dist/utils/prompt.d.ts +2 -0
- package/dist/utils/prompt.js +17 -0
- package/package.json +37 -0
- package/templates/claude/agents/playwright-test-generator.md +184 -0
- package/templates/claude/agents/playwright-test-healer.md +70 -0
- package/templates/claude/agents/playwright-test-planner.md +135 -0
- package/templates/claude/partials/_robustness-core.md +69 -0
- package/templates/claude/rules/playwright-workflow.md +203 -0
- package/templates/claude/skills/playwright-test-pipeline/SKILL.md +201 -0
- package/templates/github/scripts/playwright-pr-comment.mjs +95 -0
- package/templates/github/workflows/checkly-deploy.yml +64 -0
- package/templates/github/workflows/pr-playwright-tests.yml +154 -0
- package/templates/shared/checkly.config.template.mjs +58 -0
- package/templates/shared/checkly.config.template.ts +58 -0
- package/templates/shared/playwright.config.template.mjs +44 -0
- package/templates/shared/playwright.config.template.ts +44 -0
- package/templates/shared/playwright.mcp.config.template.mjs +12 -0
- package/templates/shared/playwright.mcp.config.template.ts +12 -0
- package/templates/shared/preview-consent-setup.template.js +32 -0
- package/templates/shared/preview-consent-setup.template.ts +33 -0
- package/templates/shared/seed.spec.js +5 -0
- 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
|
package/dist/bin/init.js
ADDED
|
@@ -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,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,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
|
+
};
|