@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.
- package/README.md +36 -2
- package/dist/cli.js +278 -0
- package/package.json +30 -3
package/README.md
CHANGED
|
@@ -1,3 +1,37 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @snaplens/healwright
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **Causal E2E regression healer for Playwright powered by git diff and AST analysis.**
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@snaplens/healwright)
|
|
6
|
+
[](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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
}
|