@snaplens/healwright 0.0.0-stage → 0.1.1

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 (3) hide show
  1. package/README.md +36 -2
  2. package/dist/cli.js +278 -0
  3. package/package.json +30 -3
package/README.md CHANGED
@@ -1,3 +1,37 @@
1
- # Temporary Holding Version
1
+ # @snaplens/healwright
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > **Causal E2E regression healer for Playwright powered by git diff and AST analysis.**
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@snaplens/healwright.svg)](https://www.npmjs.com/package/@snaplens/healwright)
6
+ [![license](https://img.shields.io/npm/l/@snaplens/healwright.svg)](https://github.com/snaplens/healwright/blob/main/LICENSE)
7
+
8
+ Healwright automatically detects when Playwright tests break due to intentional frontend component refactors (e.g. renamed IDs, modernized testids, altered labels), cross-references the failures with your Git diff, and heals the broken locators in your test files.
9
+
10
+ ---
11
+
12
+ ## 🚀 Quick Start
13
+
14
+ ### 1. Initialize Healwright in your Playwright project
15
+
16
+ ```bash
17
+ npx @snaplens/healwright init
18
+ ```
19
+ This automatically configures `playwright.config.ts` with the `@snaplens/healwright-playwright/reporter` and trace retention.
20
+
21
+ ### 2. Auto-heal broken tests
22
+
23
+ Whenever a Playwright test fails:
24
+ ```bash
25
+ npx @snaplens/healwright heal
26
+ ```
27
+
28
+ In CI / GitHub Actions:
29
+ ```bash
30
+ npx @snaplens/healwright heal --base origin/main --ci
31
+ ```
32
+
33
+ ---
34
+
35
+ ## 📄 License
36
+
37
+ MIT © [Snaplens](https://github.com/snaplens)
package/dist/cli.js ADDED
@@ -0,0 +1,278 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
18
+ // If the importer is in node compatibility mode or this is not an ESM
19
+ // file that has been converted to a CommonJS file using a Babel-
20
+ // compatible transform (i.e. "__esModule" has not been set), then set
21
+ // "default" to the CommonJS "module.exports" for node compatibility.
22
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
+ mod
24
+ ));
25
+
26
+ // src/cli.ts
27
+ var import_fs2 = __toESM(require("fs"));
28
+ var import_path2 = __toESM(require("path"));
29
+ var import_healwright_core = require("@snaplens/healwright-core");
30
+
31
+ // src/commands/init.ts
32
+ var import_fs = __toESM(require("fs"));
33
+ var import_path = __toESM(require("path"));
34
+ var import_ts_morph = require("ts-morph");
35
+ var REPORTER_PACKAGE = "@snaplens/healwright-playwright/reporter";
36
+ function findPlaywrightConfig(cwd) {
37
+ const candidates = [
38
+ "playwright.config.ts",
39
+ "playwright.config.js",
40
+ "playwright.config.mts",
41
+ "playwright.config.mjs"
42
+ ];
43
+ for (const file of candidates) {
44
+ const fullPath = import_path.default.join(cwd, file);
45
+ if (import_fs.default.existsSync(fullPath)) return fullPath;
46
+ }
47
+ return null;
48
+ }
49
+ function getRootConfigObject(sourceFile) {
50
+ const defineConfigCall = sourceFile.getDescendantsOfKind(import_ts_morph.SyntaxKind.CallExpression).find((call) => call.getExpression().getText() === "defineConfig");
51
+ if (defineConfigCall) {
52
+ const firstArg = defineConfigCall.getArguments()[0];
53
+ if (firstArg && import_ts_morph.Node.isObjectLiteralExpression(firstArg)) return firstArg;
54
+ }
55
+ const exportAssignment = sourceFile.getExportAssignment((d) => !d.isExportEquals());
56
+ if (exportAssignment) {
57
+ const expr = exportAssignment.getExpression();
58
+ if (import_ts_morph.Node.isObjectLiteralExpression(expr)) return expr;
59
+ }
60
+ return null;
61
+ }
62
+ function patchReporterProperty(configObj) {
63
+ const reporterProp = configObj.getProperty("reporter");
64
+ if (!reporterProp) {
65
+ configObj.addPropertyAssignment({
66
+ name: "reporter",
67
+ initializer: `[['list'], ['${REPORTER_PACKAGE}']]`
68
+ });
69
+ return "added";
70
+ }
71
+ if (!import_ts_morph.Node.isPropertyAssignment(reporterProp)) return "unchanged";
72
+ const initializer = reporterProp.getInitializer();
73
+ if (!initializer || initializer.getText().includes(REPORTER_PACKAGE)) return "unchanged";
74
+ if (import_ts_morph.Node.isStringLiteral(initializer)) {
75
+ const existing = initializer.getLiteralValue();
76
+ initializer.replaceWithText(`[['${existing}'], ['${REPORTER_PACKAGE}']]`);
77
+ return "updated";
78
+ }
79
+ if (import_ts_morph.Node.isArrayLiteralExpression(initializer)) {
80
+ initializer.addElement(`['${REPORTER_PACKAGE}']`);
81
+ return "updated";
82
+ }
83
+ return "unchanged";
84
+ }
85
+ function patchTraceSetting(configObj) {
86
+ const useProp = configObj.getProperty("use");
87
+ if (!useProp) {
88
+ configObj.addPropertyAssignment({
89
+ name: "use",
90
+ initializer: `{ trace: 'retain-on-failure' }`
91
+ });
92
+ return "added";
93
+ }
94
+ if (!import_ts_morph.Node.isPropertyAssignment(useProp)) return "unchanged";
95
+ const useInit = useProp.getInitializer();
96
+ if (!useInit || !import_ts_morph.Node.isObjectLiteralExpression(useInit)) return "unchanged";
97
+ const traceProp = useInit.getProperty("trace");
98
+ if (!traceProp) {
99
+ useInit.addPropertyAssignment({
100
+ name: "trace",
101
+ initializer: `'retain-on-failure'`
102
+ });
103
+ return "added";
104
+ }
105
+ if (import_ts_morph.Node.isPropertyAssignment(traceProp)) {
106
+ const traceInit = traceProp.getInitializer();
107
+ if (traceInit && import_ts_morph.Node.isStringLiteral(traceInit) && traceInit.getLiteralValue() === "off") {
108
+ traceInit.replaceWithText(`'retain-on-failure'`);
109
+ return "updated";
110
+ }
111
+ }
112
+ return "unchanged";
113
+ }
114
+ function updateGitignore(cwd) {
115
+ const gitignorePath = import_path.default.join(cwd, ".gitignore");
116
+ const entries = ["healwright-payload.json", ".healwright/"];
117
+ const content = import_fs.default.existsSync(gitignorePath) ? import_fs.default.readFileSync(gitignorePath, "utf-8") : "";
118
+ const missing = entries.filter((entry) => !content.includes(entry));
119
+ if (missing.length === 0) return false;
120
+ const prefix = content.length > 0 && !content.endsWith("\n") ? "\n\n" : content.length > 0 ? "\n" : "";
121
+ import_fs.default.writeFileSync(gitignorePath, `${content}${prefix}# Healwright
122
+ ${missing.join("\n")}
123
+ `, "utf-8");
124
+ return true;
125
+ }
126
+ function scaffoldGithubWorkflow(cwd, provider) {
127
+ const workflowDir = import_path.default.join(cwd, ".github", "workflows");
128
+ const workflowPath = import_path.default.join(workflowDir, "healwright.yml");
129
+ if (import_fs.default.existsSync(workflowPath)) return "exists";
130
+ import_fs.default.mkdirSync(workflowDir, { recursive: true });
131
+ const secretMap = {
132
+ anthropic: "ANTHROPIC_API_KEY",
133
+ openai: "OPENAI_API_KEY",
134
+ gemini: "GEMINI_API_KEY"
135
+ };
136
+ const secretName = secretMap[provider];
137
+ const apiKeyLine = secretName ? ` api-key: \${{ secrets.${secretName} }}
138
+ ` : "";
139
+ const yaml = `name: Healwright Causal Regression Check
140
+
141
+ on:
142
+ pull_request:
143
+ branches: [main]
144
+
145
+ permissions:
146
+ contents: write
147
+ pull-requests: write
148
+
149
+ jobs:
150
+ e2e-and-heal:
151
+ runs-on: ubuntu-latest
152
+ steps:
153
+ - uses: actions/checkout@v4
154
+ with:
155
+ fetch-depth: 0
156
+ - uses: actions/setup-node@v4
157
+ with:
158
+ node-version: 20
159
+ - run: npm ci
160
+ - run: npx playwright install --with-deps chromium
161
+ - name: Run Playwright Regression Suite
162
+ id: playwright
163
+ continue-on-error: true
164
+ run: npx playwright test
165
+ - name: Run Causal Diff Analysis & Auto-Heal
166
+ if: steps.playwright.outcome == 'failure'
167
+ uses: healwright/action@v1
168
+ with:
169
+ base-ref: origin/\${{ github.base_ref }}
170
+ provider: ${provider}
171
+ ${apiKeyLine}`;
172
+ import_fs.default.writeFileSync(workflowPath, yaml, "utf-8");
173
+ return "created";
174
+ }
175
+ async function runInit(options = {}) {
176
+ const cwd = options.cwd ?? process.cwd();
177
+ const provider = options.provider ?? "ollama";
178
+ const configPath = findPlaywrightConfig(cwd);
179
+ if (!configPath) {
180
+ console.error("\u274C Could not find playwright.config.ts (or .js/.mts/.mjs) in this directory.");
181
+ process.exit(1);
182
+ }
183
+ const project = new import_ts_morph.Project();
184
+ const sourceFile = project.addSourceFileAtPath(configPath);
185
+ const configObj = getRootConfigObject(sourceFile);
186
+ if (!configObj) {
187
+ console.error(`\u26A0\uFE0F Could not statically parse root config object in ${import_path.default.basename(configPath)}.`);
188
+ process.exit(1);
189
+ }
190
+ const reporterStatus = patchReporterProperty(configObj);
191
+ const traceStatus = patchTraceSetting(configObj);
192
+ if (reporterStatus !== "unchanged" || traceStatus !== "unchanged") {
193
+ sourceFile.saveSync();
194
+ }
195
+ const gitignoreUpdated = updateGitignore(cwd);
196
+ console.log(`
197
+ \u{1F691} Healwright initialized in ${import_path.default.basename(configPath)}:`);
198
+ console.log(` \u2022 Reporter (${REPORTER_PACKAGE}): ${reporterStatus}`);
199
+ console.log(` \u2022 Zero-touch failure trace ('retain-on-failure'): ${traceStatus}`);
200
+ console.log(` \u2022 .gitignore rules: ${gitignoreUpdated ? "updated" : "already present"}`);
201
+ if (options.withGithubAction) {
202
+ const wfStatus = scaffoldGithubWorkflow(cwd, provider);
203
+ console.log(` \u2022 GitHub Actions workflow (.github/workflows/healwright.yml): ${wfStatus}`);
204
+ }
205
+ }
206
+
207
+ // src/cli.ts
208
+ function getFlagValue(args, flag) {
209
+ const idx = args.indexOf(flag);
210
+ return idx !== -1 && args[idx + 1] && !args[idx + 1].startsWith("--") ? args[idx + 1] : void 0;
211
+ }
212
+ async function main() {
213
+ const args = process.argv.slice(2);
214
+ const command = args[0];
215
+ if (command === "init") {
216
+ const providerArg = getFlagValue(args, "--provider");
217
+ await runInit({
218
+ withGithubAction: args.includes("--ci"),
219
+ provider: providerArg ?? "ollama"
220
+ });
221
+ return;
222
+ }
223
+ if (command === "heal") {
224
+ const baseRef = getFlagValue(args, "--base") ?? "HEAD";
225
+ const provider = getFlagValue(args, "--provider") ?? process.env.HEALWRIGHT_PROVIDER ?? "ollama";
226
+ const llmConfig = {
227
+ provider,
228
+ apiKey: getFlagValue(args, "--api-key") ?? process.env.HEALWRIGHT_API_KEY,
229
+ model: getFlagValue(args, "--model") ?? process.env.HEALWRIGHT_MODEL
230
+ };
231
+ const payloadPath = import_path2.default.join(process.cwd(), "healwright-payload.json");
232
+ if (!import_fs2.default.existsSync(payloadPath)) {
233
+ console.error("\u274C No healwright-payload.json found. Run a failing Playwright suite first.");
234
+ process.exit(1);
235
+ }
236
+ const payloads = JSON.parse(import_fs2.default.readFileSync(payloadPath, "utf-8"));
237
+ const { mutations, rawDiff } = (0, import_healwright_core.getFrontendMutations)(baseRef);
238
+ console.log(`\u{1F50D} Scanned git diff (${baseRef}): found ${mutations.length} JSX element mutations.`);
239
+ let bugsCaught = 0;
240
+ for (const payload of payloads) {
241
+ console.log(
242
+ `
243
+ \u{1F9EA} Evaluating: "${payload.testTitle}" (${import_path2.default.basename(payload.file)}:${payload.line})`
244
+ );
245
+ const verdict = await (0, import_healwright_core.evaluateCausality)(payload, mutations, rawDiff, llmConfig);
246
+ if (verdict.status === "bug") {
247
+ bugsCaught++;
248
+ console.log(`\u{1F6A8} REGRESSION BUG \u2014 Refusing to heal.`);
249
+ console.log(` Reason: ${verdict.reason}`);
250
+ continue;
251
+ }
252
+ const patched = (0, import_healwright_core.applyLocatorPatch)(payload.file, payload.line, verdict.newCallExpression);
253
+ if (patched) {
254
+ console.log(`\u2705 HEALED [${verdict.method}] -> ${verdict.newCallExpression}`);
255
+ console.log(` Proof: ${verdict.evidence}`);
256
+ } else {
257
+ console.log(
258
+ `\u26A0\uFE0F Causal match found (${verdict.evidence}), but could not locate AST call near line ${payload.line}.`
259
+ );
260
+ }
261
+ }
262
+ if (args.includes("--ci") && bugsCaught > 0) {
263
+ process.exit(1);
264
+ }
265
+ return;
266
+ }
267
+ console.log(`
268
+ \u{1F691} Healwright \u2014 Causal Regression Healer for Playwright
269
+
270
+ Commands:
271
+ npx healwright init [--ci] [--provider ollama|anthropic|openai|gemini]
272
+ npx healwright heal [--base HEAD|origin/main] [--provider ...] [--ci]
273
+ `);
274
+ }
275
+ main().catch((err) => {
276
+ console.error(err);
277
+ process.exit(1);
278
+ });
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "@snaplens/healwright",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Causal E2E regression healer powered by git diff + AST analysis",
5
+ "keywords": [
6
+ "playwright",
7
+ "self-healing",
8
+ "cli",
9
+ "ast",
10
+ "git-diff",
11
+ "e2e-testing",
12
+ "causal-regression"
13
+ ],
14
+ "license": "MIT",
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
19
+ "bin": {
20
+ "healwright": "./dist/cli.js"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "dependencies": {
26
+ "@snaplens/healwright-core": "0.1.1",
27
+ "ts-morph": "^24.0.0"
28
+ },
29
+ "scripts": {
30
+ "build": "tsup src/cli.ts --format cjs --clean",
31
+ "typecheck": "tsc --noEmit"
32
+ }
6
33
  }