@piwitests/reporter 0.55.0 → 0.57.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/dist/cli/index.js CHANGED
@@ -24,8 +24,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  ));
25
25
 
26
26
  // src/cli/ai.ts
27
- var path5 = __toESM(require("path"));
28
- var import_node_child_process = require("child_process");
27
+ var path6 = __toESM(require("path"));
29
28
 
30
29
  // src/internal/ai/check.ts
31
30
  var fs2 = __toESM(require("fs"));
@@ -38,8 +37,8 @@ var path2 = __toESM(require("path"));
38
37
  // src/internal/capture/locator-healing.ts
39
38
  var path = __toESM(require("path"));
40
39
 
41
- // ../core/src/locator-methods.ts
42
- var LOCATOR_BUILDER_METHODS = [
40
+ // ../core/src/locator-chain.ts
41
+ var LOCATING_METHODS = /* @__PURE__ */ new Set([
43
42
  "getByRole",
44
43
  "getByTestId",
45
44
  "getByText",
@@ -47,677 +46,565 @@ var LOCATOR_BUILDER_METHODS = [
47
46
  "getByPlaceholder",
48
47
  "getByAltText",
49
48
  "getByTitle",
50
- "locator"
51
- ];
52
-
53
- // ../core/src/locator-generation.ts
54
- function classifyCssStability(className) {
55
- if (/[a-f0-9]{8,}/i.test(className)) return 10;
56
- if (/^(?:css|sc|emotion|styled)-/i.test(className)) return 15;
57
- if (/^(bg|text|border|shadow|opacity|font|w-|h-|m[tblrxy]?-|p[tblrxy]?-|flex|grid|gap|rounded|absolute|relative|fixed|sticky|block|inline|hidden|overflow|z-|top-|right-|bottom-|left-|inset-|justify-|items-|self-|content-|order-|col-|row-)/.test(
58
- className
59
- ))
60
- return 25;
61
- if (/__/.test(className) || /--/.test(className)) return 35;
62
- if (/^[a-z]+(-[a-z]+)+$/.test(className)) return 40;
63
- if (/^[a-z]+[A-Z][a-zA-Z]+$/.test(className)) return 40;
64
- if (className.includes("_")) return 15;
65
- if (/^[a-z]+-[a-z0-9]{5,}$/.test(className) && /[0-9]/.test(className)) return 15;
66
- return 15;
67
- }
68
- function isAutoGenerated(value) {
69
- if (/^[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/i.test(value)) return true;
70
- if (/^[a-f0-9]{8,}$/i.test(value)) return true;
71
- if (/^[a-z]+-\d{4,}$/.test(value)) return true;
72
- if (/^(tab|panel|input|select|option|dialog|modal|popup|tooltip|menu|listbox|combobox|checkbox|radio|textarea|button|field|label|accordion|collapse|step|slider|toggle|switch|dropdown|overlay|portal|layer)-\d+$/i.test(
73
- value
74
- )) {
75
- return true;
76
- }
77
- if (/^(emotion-|styled-|css-|sc-)/.test(value)) return true;
78
- if (value.startsWith("ng-")) return true;
79
- if (/^(radix-|headlessui-|mui-|mantine-|chakra-|reka-|bits-|downshift-|react-aria|el-id-)/i.test(value)) return true;
80
- if (/^:r[0-9a-z]+:$/i.test(value) || /^«r[0-9a-z]+»$/i.test(value)) return true;
81
- if (/(^|[-_:])v-\d+(-\d+)+$/.test(value) || /(^|[-_:])v-\d+(-\d+)+[-_:]/.test(value)) return true;
82
- return false;
83
- }
84
-
85
- // src/internal/capture/locator-healing.ts
86
- var LOCATOR_METHODS = [...LOCATOR_BUILDER_METHODS];
87
- var ACTION_METHODS = [
88
- "click",
89
- "fill",
90
- "check",
91
- "uncheck",
92
- "selectOption",
93
- "dblclick",
94
- "tap",
95
- "hover",
96
- "press",
97
- "type",
98
- "pressSequentially",
99
- "clear",
100
- "setInputFiles",
101
- "dragTo",
102
- "focus",
103
- "blur",
104
- "scrollIntoViewIfNeeded",
105
- "dispatchEvent",
106
- "selectText",
107
- // Not an action, but a successful waitFor proves the element resolved — the
108
- // closest capture hook available for assertion-style usage of a locator.
109
- "waitFor"
110
- ];
111
- var LOCATOR_CREATING_CHAINS = new Set(LOCATOR_METHODS);
112
-
113
- // src/internal/ai/artifact.ts
114
- var ARTIFACT_VERSION = 1;
115
- var LOCATOR_METHOD_SET = new Set(LOCATOR_METHODS);
116
- var ACTION_METHOD_SET = new Set(ACTION_METHODS);
117
- var POSTCONDITION_ASSERTS = /* @__PURE__ */ new Set([
49
+ "locator",
50
+ "frameLocator"
51
+ ]);
52
+ var NARROWING_METHODS = /* @__PURE__ */ new Set([
53
+ "filter",
54
+ "first",
55
+ "last",
56
+ "nth",
57
+ "and",
58
+ "or",
118
59
  "visible",
119
- "hidden",
120
- "attached",
121
- "url"
60
+ "contentFrame",
61
+ "owner"
122
62
  ]);
123
- function sortDeep(value) {
124
- if (Array.isArray(value)) return value.map(sortDeep);
125
- if (value !== null && typeof value === "object") {
126
- const out = {};
127
- for (const key of Object.keys(value).sort()) {
128
- const v = value[key];
129
- if (v !== void 0) out[key] = sortDeep(v);
130
- }
131
- return out;
63
+ var LOCATOR_CHAIN_METHODS = /* @__PURE__ */ new Set([...LOCATING_METHODS, ...NARROWING_METHODS]);
64
+ var LocatorParseError = class extends Error {
65
+ constructor(message) {
66
+ super(message);
67
+ this.name = "LocatorParseError";
132
68
  }
133
- return value;
134
- }
135
- function serializeEntry(entry) {
136
- return `${JSON.stringify(sortDeep(entry), null, 2)}
137
- `;
138
- }
139
- var ArtifactError = class extends Error {
140
69
  };
141
- function assert(condition, message) {
142
- if (!condition) throw new ArtifactError(message);
143
- }
144
- function validateStructuredLocator(value, where) {
145
- assert(value !== null && typeof value === "object", `${where}: locator must be an object`);
146
- const loc = value;
147
- assert(typeof loc.method === "string", `${where}: locator.method must be a string`);
148
- assert(LOCATOR_METHOD_SET.has(loc.method), `${where}: locator method "${String(loc.method)}" is not allowlisted`);
149
- assert(Array.isArray(loc.args), `${where}: locator.args must be an array`);
150
- if (loc.chain !== void 0) {
151
- assert(Array.isArray(loc.chain), `${where}: locator.chain must be an array`);
152
- loc.chain.forEach((child, i) => validateStructuredLocator(child, `${where}.chain[${i}]`));
70
+ var SIMPLE_ESCAPES = {
71
+ n: "\n",
72
+ t: " ",
73
+ r: "\r",
74
+ b: "\b",
75
+ f: "\f",
76
+ v: "\v",
77
+ "0": "\0"
78
+ };
79
+ var Parser = class {
80
+ constructor(src) {
81
+ this.src = src;
82
+ this.i = 0;
153
83
  }
154
- }
155
- function validateRunStep(value, where) {
156
- assert(value !== null && typeof value === "object", `${where}: step must be an object`);
157
- const step = value;
158
- validateStructuredLocator(step.locator, `${where}.locator`);
159
- assert(typeof step.action === "string", `${where}: action must be a string`);
160
- assert(ACTION_METHOD_SET.has(step.action), `${where}: action "${String(step.action)}" is not allowlisted`);
161
- if (step.value !== void 0) assert(typeof step.value === "string", `${where}: value must be a string`);
162
- if (step.optional !== void 0) assert(typeof step.optional === "boolean", `${where}: optional must be a boolean`);
163
- if (step.waitForResponse !== void 0) {
164
- assert(typeof step.waitForResponse === "string", `${where}: waitForResponse must be a string`);
84
+ parseTopLevel() {
85
+ const chain = this.parseChain();
86
+ this.skipSpace();
87
+ if (this.i < this.src.length) this.fail("unexpected text");
88
+ return chain;
165
89
  }
166
- }
167
- function validatePostcondition(value) {
168
- assert(value !== null && typeof value === "object", "postcondition must be an object");
169
- const post = value;
170
- assert(
171
- POSTCONDITION_ASSERTS.has(post.assert),
172
- `postcondition.assert "${String(post.assert)}" is not supported`
173
- );
174
- if (post.assert === "url") {
175
- assert(typeof post.url === "string", "postcondition.url must be a string for a url assert");
176
- } else {
177
- validateStructuredLocator(post.locator, "postcondition.locator");
90
+ fail(message) {
91
+ throw new LocatorParseError(`${message} at "${this.src.slice(this.i, this.i + 20)}"`);
178
92
  }
179
- }
180
- function parseEntry(text) {
181
- let raw;
182
- try {
183
- raw = JSON.parse(text);
184
- } catch (error) {
185
- throw new ArtifactError(`entry is not valid JSON: ${error.message}`);
93
+ skipSpace() {
94
+ while (this.i < this.src.length && /\s/.test(this.src[this.i])) this.i++;
186
95
  }
187
- assert(raw !== null && typeof raw === "object", "entry must be an object");
188
- const obj = raw;
189
- assert(obj.version === ARTIFACT_VERSION, `unsupported entry version ${String(obj.version)}`);
190
- assert(typeof obj.template === "string", "entry.template must be a string");
191
- if (obj.kind === "locator") {
192
- validateStructuredLocator(obj.locator, "entry.locator");
193
- return obj;
96
+ peek() {
97
+ return this.src[this.i];
194
98
  }
195
- if (obj.kind === "run") {
196
- assert(Array.isArray(obj.steps), "entry.steps must be an array");
197
- obj.steps.forEach((step, i) => validateRunStep(step, `entry.steps[${i}]`));
198
- validatePostcondition(obj.postcondition);
199
- return obj;
99
+ expect(ch) {
100
+ this.skipSpace();
101
+ if (this.src[this.i] !== ch) this.fail(`expected "${ch}"`);
102
+ this.i++;
200
103
  }
201
- throw new ArtifactError(`unsupported entry kind "${String(obj.kind)}"`);
202
- }
203
-
204
- // src/internal/ai/keys.ts
205
- var crypto = __toESM(require("crypto"));
206
- var path3 = __toESM(require("path"));
207
- var DEFAULT_AI_DIR = "__piwi__";
208
- function normalizeTemplate(template) {
209
- return template.trim().replace(/\s+/g, " ").toLowerCase();
210
- }
211
-
212
- // src/internal/ai/check.ts
213
- function findEntryFiles(root, dir) {
214
- const out = [];
215
- const walk = (current) => {
216
- let items;
217
- try {
218
- items = fs2.readdirSync(current, { withFileTypes: true });
219
- } catch {
220
- return;
221
- }
222
- for (const item of items) {
223
- const full = path4.join(current, item.name);
224
- if (item.isDirectory()) {
225
- walk(full);
226
- } else if (item.isFile() && item.name.endsWith(".json")) {
227
- const specFileDir = path4.dirname(full);
228
- const dirName = path4.basename(path4.dirname(specFileDir));
229
- if (dirName !== dir) continue;
230
- const specDir = path4.dirname(path4.dirname(specFileDir));
231
- const specFile = path4.join(specDir, path4.basename(specFileDir));
232
- out.push({ file: full, specFile, testSlug: item.name.split(".")[0] });
233
- }
234
- }
235
- };
236
- walk(root);
237
- return out;
238
- }
239
- function readTextOr(file, fallback) {
240
- try {
241
- return fs2.readFileSync(file, "utf8");
242
- } catch {
243
- return fallback;
104
+ identifier() {
105
+ const m = /^[A-Za-z_$][\w$]*/.exec(this.src.slice(this.i));
106
+ if (!m) this.fail("expected a name");
107
+ this.i += m[0].length;
108
+ return m[0];
244
109
  }
245
- }
246
- function checkAiTree(root, opts = {}) {
247
- const dir = opts.dir ?? DEFAULT_AI_DIR;
248
- const findings = [];
249
- const rel = (file) => path4.relative(root, file).split(path4.sep).join("/");
250
- const sourceCache = /* @__PURE__ */ new Map();
251
- const duplicates = /* @__PURE__ */ new Map();
252
- for (const found of findEntryFiles(root, dir)) {
253
- const text = readTextOr(found.file, null);
254
- if (text === null) continue;
255
- let templateNormalized = null;
256
- try {
257
- const entry = parseEntry(text);
258
- templateNormalized = normalizeTemplate(entry.template);
259
- if (serializeEntry(entry) !== text) {
260
- findings.push({
261
- severity: "error",
262
- kind: "non-canonical",
263
- file: rel(found.file),
264
- message: "file is not in canonical form \u2014 re-author it with `piwi ai resolve --update-ai`"
265
- });
110
+ /**
111
+ * A chain of calls joined by `.`. With `lenient`, parsing stops before the
112
+ * first link that doesn't parse instead of failing, so a chain followed by
113
+ * other text (an error message) is read up to where it ends. `spans` receives
114
+ * where each top-level call starts and ends.
115
+ */
116
+ parseChain(opts = {}) {
117
+ this.skipSpace();
118
+ const calls = [];
119
+ for (; ; ) {
120
+ const before = this.i;
121
+ if (calls.length > 0) this.i++;
122
+ this.skipSpace();
123
+ const start = this.i;
124
+ try {
125
+ calls.push(this.parseCall(calls.length === 0));
126
+ } catch (error) {
127
+ if (!opts.lenient || calls.length === 0) throw error;
128
+ this.i = before;
129
+ break;
266
130
  }
267
- if (!sourceCache.has(found.specFile)) sourceCache.set(found.specFile, readTextOr(found.specFile, null));
268
- const source = sourceCache.get(found.specFile) ?? null;
269
- if (source === null) {
270
- findings.push({
271
- severity: "error",
272
- kind: "orphan",
273
- file: rel(found.file),
274
- message: `spec file is gone (${rel(found.specFile)}) \u2014 the entry is orphaned`
275
- });
276
- } else if (!source.includes(entry.template.trim())) {
277
- findings.push({
278
- severity: "error",
279
- kind: "orphan",
280
- file: rel(found.file),
281
- message: `template "${entry.template}" no longer appears in ${rel(found.specFile)} \u2014 the entry is orphaned`
282
- });
131
+ opts.spans?.push({ start, end: this.i });
132
+ const end = this.i;
133
+ this.skipSpace();
134
+ if (this.peek() !== ".") {
135
+ this.i = end;
136
+ break;
283
137
  }
284
- } catch (error) {
285
- findings.push({
286
- severity: "error",
287
- kind: "invalid",
288
- file: rel(found.file),
289
- message: `not a valid entry: ${error.message}`
290
- });
291
138
  }
292
- if (templateNormalized !== null) {
293
- const key = `${found.specFile}::${found.testSlug}::${templateNormalized}`;
294
- const list = duplicates.get(key) ?? [];
295
- list.push(rel(found.file));
296
- duplicates.set(key, list);
139
+ return { calls };
140
+ }
141
+ /** Where the parser stopped. */
142
+ get position() {
143
+ return this.i;
144
+ }
145
+ parseCall(first) {
146
+ this.skipSpace();
147
+ const method = this.identifier();
148
+ if (!LOCATOR_CHAIN_METHODS.has(method)) this.fail(`unsupported method ${method}()`);
149
+ if (first && !LOCATING_METHODS.has(method)) this.fail(`${method}() needs a locator before it`);
150
+ this.expect("(");
151
+ const args = [];
152
+ this.skipSpace();
153
+ if (this.peek() === ")") {
154
+ this.i++;
155
+ return { method, args };
156
+ }
157
+ for (; ; ) {
158
+ args.push(this.parseValue());
159
+ this.skipSpace();
160
+ if (this.peek() === ",") {
161
+ this.i++;
162
+ this.skipSpace();
163
+ if (this.peek() === ")") {
164
+ this.i++;
165
+ break;
166
+ }
167
+ continue;
168
+ }
169
+ this.expect(")");
170
+ break;
297
171
  }
172
+ return { method, args };
298
173
  }
299
- for (const files of duplicates.values()) {
300
- if (files.length < 2) continue;
301
- for (const file of files) {
302
- findings.push({
303
- severity: "warning",
304
- kind: "duplicate-template",
305
- file,
306
- message: `duplicate template within the same test \u2014 consider distinct phrasings (${files.join(", ")})`
307
- });
174
+ parseValue() {
175
+ this.skipSpace();
176
+ const ch = this.peek();
177
+ if (ch === "'" || ch === '"' || ch === "`") return { type: "string", value: this.parseString() };
178
+ if (ch === "/") return this.parseRegex();
179
+ if (ch === "{") return this.parseObject();
180
+ if (ch !== void 0 && /[-\d]/.test(ch)) {
181
+ const m = /^-?\d+(?:\.\d+)?/.exec(this.src.slice(this.i));
182
+ if (!m) this.fail("expected a number");
183
+ this.i += m[0].length;
184
+ return { type: "number", value: Number(m[0]) };
185
+ }
186
+ const rest = this.src.slice(this.i);
187
+ if (/^true\b/.test(rest)) {
188
+ this.i += 4;
189
+ return { type: "boolean", value: true };
190
+ }
191
+ if (/^false\b/.test(rest)) {
192
+ this.i += 5;
193
+ return { type: "boolean", value: false };
308
194
  }
195
+ if (/^[A-Za-z_$]/.test(rest)) return { type: "chain", chain: this.parseChain() };
196
+ return this.fail("expected a value");
309
197
  }
310
- return findings.sort((a, b) => a.file < b.file ? -1 : a.file > b.file ? 1 : 0);
198
+ /** A quoted string, decoding the escapes `JSON.stringify` and JavaScript produce. */
199
+ parseString() {
200
+ const quote3 = this.src[this.i];
201
+ let out = "";
202
+ this.i++;
203
+ while (this.i < this.src.length) {
204
+ const c = this.src[this.i];
205
+ if (c === quote3) {
206
+ this.i++;
207
+ return out;
208
+ }
209
+ if (c !== "\\") {
210
+ out += c;
211
+ this.i++;
212
+ continue;
213
+ }
214
+ const next = this.src[this.i + 1];
215
+ if (next === void 0) break;
216
+ const simple = SIMPLE_ESCAPES[next];
217
+ if (simple !== void 0) {
218
+ out += simple;
219
+ this.i += 2;
220
+ continue;
221
+ }
222
+ const hex = next === "u" && this.src[this.i + 2] === "{" ? /^u\{([0-9a-fA-F]{1,6})\}/.exec(this.src.slice(this.i + 1)) : next === "u" ? /^u([0-9a-fA-F]{4})/.exec(this.src.slice(this.i + 1)) : next === "x" ? /^x([0-9a-fA-F]{2})/.exec(this.src.slice(this.i + 1)) : null;
223
+ if (hex) {
224
+ out += String.fromCodePoint(parseInt(hex[1], 16));
225
+ this.i += 1 + hex[0].length;
226
+ continue;
227
+ }
228
+ if (next === "u" || next === "x") this.fail("malformed escape");
229
+ out += next;
230
+ this.i += 2;
231
+ }
232
+ return this.fail("unterminated string");
233
+ }
234
+ parseRegex() {
235
+ let j = this.i + 1;
236
+ let inClass = false;
237
+ while (j < this.src.length) {
238
+ const c = this.src[j];
239
+ if (c === "\\") {
240
+ j += 2;
241
+ continue;
242
+ }
243
+ if (c === "[") inClass = true;
244
+ else if (c === "]") inClass = false;
245
+ else if (c === "/" && !inClass) break;
246
+ j++;
247
+ }
248
+ if (j >= this.src.length) this.fail("unterminated regex");
249
+ const source = this.src.slice(this.i + 1, j);
250
+ const flags = /^[a-z]*/.exec(this.src.slice(j + 1))[0];
251
+ this.i = j + 1 + flags.length;
252
+ return { type: "regex", source, flags };
253
+ }
254
+ parseObject() {
255
+ this.expect("{");
256
+ const entries = [];
257
+ for (; ; ) {
258
+ this.skipSpace();
259
+ if (this.peek() === "}") {
260
+ this.i++;
261
+ break;
262
+ }
263
+ const ch = this.peek();
264
+ const key = ch === "'" || ch === '"' ? this.parseString() : this.identifier();
265
+ this.expect(":");
266
+ entries.push([key, this.parseValue()]);
267
+ this.skipSpace();
268
+ if (this.peek() === ",") {
269
+ this.i++;
270
+ continue;
271
+ }
272
+ this.expect("}");
273
+ break;
274
+ }
275
+ return { type: "object", entries };
276
+ }
277
+ };
278
+ function parseLocatorChain(expr) {
279
+ const trimmed = expr.trim().replace(/^(?:await\s+)?(?:this\.)?page\./, "");
280
+ if (!trimmed) throw new LocatorParseError("empty expression");
281
+ return new Parser(trimmed).parseTopLevel();
311
282
  }
312
- function hasBlockingFindings(findings) {
313
- return findings.some((f) => f.severity === "error");
283
+ function tryParseLocatorChain(expr) {
284
+ try {
285
+ return parseLocatorChain(expr);
286
+ } catch {
287
+ return null;
288
+ }
289
+ }
290
+ function quoteJsString(value) {
291
+ const json = JSON.stringify(value).slice(1, -1);
292
+ return `'${json.replace(/\\"/g, '"').replace(/'/g, "\\'").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029")}'`;
293
+ }
294
+ function renderArg(arg) {
295
+ switch (arg.type) {
296
+ case "string":
297
+ return quoteJsString(arg.value);
298
+ case "regex":
299
+ return `/${arg.source}/${arg.flags}`;
300
+ case "number":
301
+ case "boolean":
302
+ return String(arg.value);
303
+ case "object":
304
+ return arg.entries.length === 0 ? "{}" : `{ ${arg.entries.map(([k, v]) => `${/^[A-Za-z_$][\w$]*$/.test(k) ? k : quoteJsString(k)}: ${renderArg(v)}`).join(", ")} }`;
305
+ case "chain":
306
+ return renderLocatorChain(arg.chain);
307
+ }
308
+ }
309
+ function renderLocatorCall(call) {
310
+ return `${call.method}(${call.args.map(renderArg).join(", ")})`;
311
+ }
312
+ function renderLocatorChain(chain) {
313
+ return chain.calls.map(renderLocatorCall).join(".");
314
+ }
315
+ function canonicalLocator(expr) {
316
+ const chain = tryParseLocatorChain(expr);
317
+ return chain ? renderLocatorChain(chain) : null;
314
318
  }
315
319
 
316
- // src/cli/ai.ts
317
- var EXIT_OK = 0;
318
- var EXIT_ISSUES = 1;
319
- var EXIT_ERROR = 2;
320
- var USAGE = `
321
- Usage: piwi ai <command> [options]
322
-
323
- Commands:
324
- check Scan committed AI-step entries for orphans, non-canonical files and
325
- duplicate templates. Read-only; exits 1 when issues are found.
326
- resolve Author missing entries by running the suite in resolve mode against
327
- the configured authoring server (forces --workers=1).
328
- prune Delete orphaned/dormant entries.
320
+ // ../core/src/locator-fingerprint.ts
321
+ var FUNCTION_WORDS = new Set(
322
+ "the and for with from your you our this that les des une pour avec par sur aux dans vos votre nos notre".split(" ")
323
+ );
329
324
 
330
- Options (check):
331
- --dir <name> Entry directory name per spec (env PIWI_AI_DIR, default ${DEFAULT_AI_DIR})
332
- --cwd <path> Root to scan (default: current directory)
333
- --json Emit findings as JSON
334
-
335
- Options (resolve):
336
- --grep <re> Only author entries for matching tests
337
- --project <name> Author under one Playwright project (a resolve profile)
338
- --env K=V Extra env for the run (repeatable \u2014 flags/viewport profiles)
339
- --update-ai Re-author entries that already exist
340
- (needs PIWI_DASHBOARD_URL / PIWI_API_KEY for the server)
325
+ // ../core/src/locator-methods.ts
326
+ var LOCATOR_BUILDER_METHODS = [
327
+ "getByRole",
328
+ "getByTestId",
329
+ "getByText",
330
+ "getByLabel",
331
+ "getByPlaceholder",
332
+ "getByAltText",
333
+ "getByTitle",
334
+ "locator"
335
+ ];
341
336
 
342
- Exit codes:
343
- 0 clean (or --help)
344
- 1 hygiene issues found
345
- 2 bad arguments / command unavailable
346
- `.trim();
347
- function readOption(argv, name) {
348
- const withEquals = argv.find((arg) => arg.startsWith(`${name}=`));
349
- if (withEquals) return withEquals.slice(name.length + 1);
350
- const index = argv.indexOf(name);
351
- if (index === -1) return void 0;
352
- const value = argv[index + 1];
353
- return value && !value.startsWith("--") ? value : void 0;
337
+ // ../core/src/locator-generation.ts
338
+ function classifyCssStability(className) {
339
+ if (/[a-f0-9]{8,}/i.test(className)) return 10;
340
+ if (/^(?:css|sc|emotion|styled)-/i.test(className)) return 15;
341
+ if (/^(bg|text|border|shadow|opacity|font|w-|h-|m[tblrxy]?-|p[tblrxy]?-|flex|grid|gap|rounded|absolute|relative|fixed|sticky|block|inline|hidden|overflow|z-|top-|right-|bottom-|left-|inset-|justify-|items-|self-|content-|order-|col-|row-)/.test(
342
+ className
343
+ ))
344
+ return 25;
345
+ if (/__/.test(className) || /--/.test(className)) return 35;
346
+ if (/^[a-z]+(-[a-z]+)+$/.test(className)) return 40;
347
+ if (/^[a-z]+[A-Z][a-zA-Z]+$/.test(className)) return 40;
348
+ if (className.includes("_")) return 15;
349
+ if (/^[a-z]+-[a-z0-9]{5,}$/.test(className) && /[0-9]/.test(className)) return 15;
350
+ return 15;
354
351
  }
355
- function readAll(argv, name) {
356
- const values = [];
357
- for (let i = 0; i < argv.length; i++) {
358
- if (argv[i].startsWith(`${name}=`)) values.push(argv[i].slice(name.length + 1));
359
- else if (argv[i] === name && argv[i + 1] && !argv[i + 1].startsWith("--")) values.push(argv[++i]);
352
+ function isAutoGenerated(value) {
353
+ if (/^[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/i.test(value)) return true;
354
+ if (/^[a-f0-9]{8,}$/i.test(value)) return true;
355
+ if (/^[a-z]+-\d{4,}$/.test(value)) return true;
356
+ if (/^(tab|panel|input|select|option|dialog|modal|popup|tooltip|menu|listbox|combobox|checkbox|radio|textarea|button|field|label|accordion|collapse|step|slider|toggle|switch|dropdown|overlay|portal|layer)-\d+$/i.test(
357
+ value
358
+ )) {
359
+ return true;
360
360
  }
361
- return values;
362
- }
363
- function formatFinding(finding) {
364
- const mark = finding.severity === "error" ? "\u2716" : "\u26A0";
365
- return `${mark} ${finding.file}
366
- ${finding.kind}: ${finding.message}`;
361
+ if (/^(emotion-|styled-|css-|sc-)/.test(value)) return true;
362
+ if (value.startsWith("ng-")) return true;
363
+ if (/^(radix-|headlessui-|mui-|mantine-|chakra-|reka-|bits-|downshift-|react-aria|el-id-)/i.test(value)) return true;
364
+ if (/^:r[0-9a-z]+:$/i.test(value) || /^«r[0-9a-z]+»$/i.test(value)) return true;
365
+ if (/(^|[-_:])v-\d+(-\d+)+$/.test(value) || /(^|[-_:])v-\d+(-\d+)+[-_:]/.test(value)) return true;
366
+ return false;
367
367
  }
368
- function runCheck(argv, env) {
369
- const root = path5.resolve(readOption(argv, "--cwd") ?? process.cwd());
370
- const dir = readOption(argv, "--dir") ?? env.PIWI_AI_DIR ?? DEFAULT_AI_DIR;
371
- let findings;
372
- try {
373
- findings = checkAiTree(root, { dir });
374
- } catch (error) {
375
- console.error(`piwi ai check: ${error.message}`);
376
- return EXIT_ERROR;
377
- }
378
- if (argv.includes("--json")) {
379
- console.log(JSON.stringify({ findings }, null, 2));
380
- } else if (findings.length === 0) {
381
- console.log("piwi ai check: no issues found.");
382
- } else {
383
- for (const finding of findings) console.log(formatFinding(finding));
384
- const errors = findings.filter((f) => f.severity === "error").length;
385
- const warnings = findings.length - errors;
386
- console.log(`
387
- ${errors} error(s), ${warnings} warning(s).`);
368
+
369
+ // src/internal/capture/locator-healing.ts
370
+ var LOCATOR_METHODS = [...LOCATOR_BUILDER_METHODS];
371
+ var ACTION_METHODS = [
372
+ "click",
373
+ "fill",
374
+ "check",
375
+ "uncheck",
376
+ "selectOption",
377
+ "dblclick",
378
+ "tap",
379
+ "hover",
380
+ "press",
381
+ "type",
382
+ "pressSequentially",
383
+ "clear",
384
+ "setInputFiles",
385
+ "dragTo",
386
+ "focus",
387
+ "blur",
388
+ "scrollIntoViewIfNeeded",
389
+ "dispatchEvent",
390
+ "selectText",
391
+ // Not an action, but a successful waitFor proves the element resolved — the
392
+ // closest capture hook available for assertion-style usage of a locator.
393
+ "waitFor"
394
+ ];
395
+ var LOCATOR_CREATING_CHAINS = new Set(LOCATOR_METHODS);
396
+
397
+ // src/internal/ai/artifact.ts
398
+ var ARTIFACT_VERSION = 1;
399
+ var LOCATOR_METHOD_SET = new Set(LOCATOR_METHODS);
400
+ var ACTION_METHOD_SET = new Set(ACTION_METHODS);
401
+ var POSTCONDITION_ASSERTS = /* @__PURE__ */ new Set([
402
+ "visible",
403
+ "hidden",
404
+ "attached",
405
+ "url"
406
+ ]);
407
+ function sortDeep(value) {
408
+ if (Array.isArray(value)) return value.map(sortDeep);
409
+ if (value !== null && typeof value === "object") {
410
+ const out = {};
411
+ for (const key of Object.keys(value).sort()) {
412
+ const v = value[key];
413
+ if (v !== void 0) out[key] = sortDeep(v);
414
+ }
415
+ return out;
388
416
  }
389
- return hasBlockingFindings(findings) ? EXIT_ISSUES : EXIT_OK;
417
+ return value;
390
418
  }
391
- function buildResolveInvocation(argv, env) {
392
- const args = ["playwright", "test"];
393
- const grep = readOption(argv, "--grep");
394
- if (grep) args.push("--grep", grep);
395
- const project = readOption(argv, "--project");
396
- if (project) args.push("--project", project);
397
- args.push("--workers=1");
398
- const childEnv = { ...env, PIWI_AI: "resolve" };
399
- if (argv.includes("--update-ai")) childEnv.PIWI_AI_UPDATE = "true";
400
- for (const pair of readAll(argv, "--env")) {
401
- const eq = pair.indexOf("=");
402
- if (eq > 0) childEnv[pair.slice(0, eq)] = pair.slice(eq + 1);
419
+ function serializeEntry(entry) {
420
+ return `${JSON.stringify(sortDeep(entry), null, 2)}
421
+ `;
422
+ }
423
+ var ArtifactError = class extends Error {
424
+ };
425
+ function assert(condition, message) {
426
+ if (!condition) throw new ArtifactError(message);
427
+ }
428
+ function validateStructuredLocator(value, where) {
429
+ assert(value !== null && typeof value === "object", `${where}: locator must be an object`);
430
+ const loc = value;
431
+ assert(typeof loc.method === "string", `${where}: locator.method must be a string`);
432
+ assert(LOCATOR_METHOD_SET.has(loc.method), `${where}: locator method "${String(loc.method)}" is not allowlisted`);
433
+ assert(Array.isArray(loc.args), `${where}: locator.args must be an array`);
434
+ if (loc.chain !== void 0) {
435
+ assert(Array.isArray(loc.chain), `${where}: locator.chain must be an array`);
436
+ loc.chain.forEach((child, i) => validateStructuredLocator(child, `${where}.chain[${i}]`));
403
437
  }
404
- return { command: "npx", args, env: childEnv };
405
438
  }
406
- function runResolve(argv, env) {
407
- if (!env.PIWI_DASHBOARD_URL) {
408
- console.error("piwi ai resolve: set PIWI_DASHBOARD_URL (the authoring server) before resolving.");
409
- return EXIT_ERROR;
439
+ function validateRunStep(value, where) {
440
+ assert(value !== null && typeof value === "object", `${where}: step must be an object`);
441
+ const step = value;
442
+ validateStructuredLocator(step.locator, `${where}.locator`);
443
+ assert(typeof step.action === "string", `${where}: action must be a string`);
444
+ assert(ACTION_METHOD_SET.has(step.action), `${where}: action "${String(step.action)}" is not allowlisted`);
445
+ if (step.value !== void 0) assert(typeof step.value === "string", `${where}: value must be a string`);
446
+ if (step.optional !== void 0) assert(typeof step.optional === "boolean", `${where}: optional must be a boolean`);
447
+ if (step.waitForResponse !== void 0) {
448
+ assert(typeof step.waitForResponse === "string", `${where}: waitForResponse must be a string`);
410
449
  }
411
- const invocation = buildResolveInvocation(argv, env);
412
- const result = (0, import_node_child_process.spawnSync)(invocation.command, invocation.args, { stdio: "inherit", env: invocation.env });
413
- if (result.error) {
414
- console.error(`piwi ai resolve: ${result.error.message}`);
415
- return EXIT_ERROR;
450
+ }
451
+ function validatePostcondition(value) {
452
+ assert(value !== null && typeof value === "object", "postcondition must be an object");
453
+ const post = value;
454
+ assert(
455
+ POSTCONDITION_ASSERTS.has(post.assert),
456
+ `postcondition.assert "${String(post.assert)}" is not supported`
457
+ );
458
+ if (post.assert === "url") {
459
+ assert(typeof post.url === "string", "postcondition.url must be a string for a url assert");
460
+ } else {
461
+ validateStructuredLocator(post.locator, "postcondition.locator");
416
462
  }
417
- return result.status ?? EXIT_ERROR;
418
463
  }
419
- async function runAi(argv, env = process.env) {
420
- const [sub, ...rest] = argv;
421
- if (sub === void 0 || sub === "-h" || sub === "--help") {
422
- console.log(USAGE);
423
- return EXIT_OK;
464
+ function parseEntry(text) {
465
+ let raw;
466
+ try {
467
+ raw = JSON.parse(text);
468
+ } catch (error) {
469
+ throw new ArtifactError(`entry is not valid JSON: ${error.message}`);
424
470
  }
425
- switch (sub) {
426
- case "check":
427
- return runCheck(rest, env);
428
- case "resolve":
429
- return runResolve(rest, env);
430
- case "prune":
431
- console.error(
432
- "piwi ai prune: not available in this build yet. `piwi ai check` lists orphaned entries: delete their files by hand, and re-author a non-canonical one with `piwi ai resolve --update-ai`."
433
- );
434
- return EXIT_ERROR;
435
- default:
436
- console.error(`piwi ai: unknown command "${sub}"
437
- `);
438
- console.error(USAGE);
439
- return EXIT_ERROR;
471
+ assert(raw !== null && typeof raw === "object", "entry must be an object");
472
+ const obj = raw;
473
+ assert(obj.version === ARTIFACT_VERSION, `unsupported entry version ${String(obj.version)}`);
474
+ assert(typeof obj.template === "string", "entry.template must be a string");
475
+ if (obj.kind === "locator") {
476
+ validateStructuredLocator(obj.locator, "entry.locator");
477
+ return obj;
478
+ }
479
+ if (obj.kind === "run") {
480
+ assert(Array.isArray(obj.steps), "entry.steps must be an array");
481
+ obj.steps.forEach((step, i) => validateRunStep(step, `entry.steps[${i}]`));
482
+ validatePostcondition(obj.postcondition);
483
+ return obj;
440
484
  }
485
+ throw new ArtifactError(`unsupported entry kind "${String(obj.kind)}"`);
441
486
  }
442
487
 
443
- // src/cli/bug.ts
444
- var fs4 = __toESM(require("fs"));
445
- var path7 = __toESM(require("path"));
446
- var import_node_child_process2 = require("child_process");
447
- var import_node_module = require("module");
448
-
449
- // src/internal/config/desktop.ts
450
- var fs3 = __toESM(require("fs"));
451
- var os = __toESM(require("os"));
452
- var path6 = __toESM(require("path"));
453
- function defaultDesktopConfigPath() {
454
- return path6.join(os.homedir(), ".piwi", "desktop.json");
488
+ // src/internal/ai/keys.ts
489
+ var crypto = __toESM(require("crypto"));
490
+ var path3 = __toESM(require("path"));
491
+ var DEFAULT_AI_DIR = "__piwi__";
492
+ function normalizeTemplate(template) {
493
+ return template.trim().replace(/\s+/g, " ").toLowerCase();
455
494
  }
456
- function readDesktopConfig(filePath2) {
457
- try {
458
- const parsed = JSON.parse(fs3.readFileSync(filePath2, "utf8"));
459
- const url = parsed?.url;
460
- const token = parsed?.token;
461
- if (typeof url !== "string" || !url) return null;
462
- if (typeof token !== "string" || !token) return null;
463
- return { url, token };
464
- } catch {
465
- return null;
466
- }
467
- }
468
-
469
- // src/internal/config/env.ts
470
- var DEFAULTS = {
471
- projectName: "default-project",
472
- uploadTraces: true,
473
- uploadReport: true,
474
- liveFileUploads: true,
475
- collectScmInfo: true,
476
- collectCiInfo: true,
477
- collectPerformanceMetrics: true,
478
- captureLocators: true,
479
- capturePageState: true,
480
- // Off by default: the page inventory reads control and link names from every
481
- // visited page, so it stays opt-in until a project turns it on.
482
- capturePageInventory: false,
483
- // Off by default: JavaScript coverage adds time to every test, so a scheduled job opts in.
484
- captureCodeReach: false,
485
- captureServerTraces: true,
486
- captureResources: true,
487
- leakCheck: "report",
488
- sampleAriaOnPass: true,
489
- uploadManifest: true,
490
- defaultCapture: true,
491
- checkBaseUrl: false,
492
- streaming: true,
493
- streamingBatchSize: 20,
494
- streamingBatchDelay: 2e3,
495
- maxStreamBufferBytes: 100 * 1024 * 1024,
496
- submitTimeout: 15 * 60 * 1e3,
497
- failOnFlakyTests: false,
498
- username: null,
499
- password: null,
500
- apiKey: null,
501
- verbose: false
502
- };
503
- var PIWI_ENV_KEYS = {
504
- serverUrl: "PIWI_DASHBOARD_URL",
505
- projectName: "PIWI_PROJECT_NAME",
506
- verbose: "PIWI_VERBOSE",
507
- apiKey: "PIWI_API_KEY",
508
- username: "PIWI_USERNAME",
509
- password: "PIWI_PASSWORD",
510
- environment: "PIWI_ENVIRONMENT",
511
- label: "PIWI_LABEL",
512
- runLabel: "PIWI_RUN_LABEL",
513
- keep: "PIWI_KEEP",
514
- streaming: "PIWI_STREAMING",
515
- streamingBatchSize: "PIWI_STREAMING_BATCH_SIZE",
516
- streamingBatchDelay: "PIWI_STREAMING_BATCH_DELAY",
517
- maxStreamBufferBytes: "PIWI_MAX_STREAM_BUFFER_BYTES",
518
- submitTimeout: "PIWI_SUBMIT_TIMEOUT",
519
- liveFileUploads: "PIWI_LIVE_FILE_UPLOADS",
520
- failOnFlakyTests: "PIWI_FAIL_ON_FLAKY_TESTS",
521
- uploadTraces: "PIWI_UPLOAD_TRACES",
522
- uploadReport: "PIWI_UPLOAD_REPORT",
523
- captureLocators: "PIWI_CAPTURE_LOCATORS",
524
- capturePageState: "PIWI_CAPTURE_PAGE_STATE",
525
- capturePageInventory: "PIWI_CAPTURE_PAGE_INVENTORY",
526
- captureCodeReach: "PIWI_CAPTURE_CODE_REACH",
527
- codeReachRoots: "PIWI_CODE_REACH_ROOTS",
528
- coverageBrowser: "PIWI_COVERAGE_BROWSER",
529
- coverageServer: "PIWI_COVERAGE_SERVER",
530
- captureServerTraces: "PIWI_CAPTURE_SERVER_TRACES",
531
- captureResources: "PIWI_CAPTURE_RESOURCES",
532
- leakCheck: "PIWI_LEAK_CHECK",
533
- sampleAriaOnPass: "PIWI_SAMPLE_ARIA_ON_PASS",
534
- uploadManifest: "PIWI_UPLOAD_MANIFEST",
535
- defaultCapture: "PIWI_DEFAULT_CAPTURE",
536
- checkBaseUrl: "PIWI_CHECK_BASE_URL",
537
- inspectOnFailure: "PIWI_INSPECT_ON_FAIL",
538
- pickLocatorOnFailure: "PIWI_PICK_LOCATOR_ON_FAIL",
539
- outputFile: "PIWI_OUTPUT_FILE",
540
- aiMode: "PIWI_AI",
541
- aiDir: "PIWI_AI_DIR",
542
- aiOnMiss: "PIWI_AI_ON_MISS",
543
- aiMaxSteps: "PIWI_AI_MAX_FLOW_STEPS",
544
- aiMaxSnapshotChars: "PIWI_AI_MAX_SNAPSHOT_CHARS",
545
- aiOptionalProbeTimeout: "PIWI_AI_OPTIONAL_PROBE_TIMEOUT",
546
- aiResponseWaitTimeout: "PIWI_AI_RESPONSE_WAIT_TIMEOUT",
547
- aiScreenshotFallback: "PIWI_AI_SCREENSHOT_FALLBACK"
548
- };
549
- var PIWI_DESKTOP_CONFIG_ENV = "PIWI_DESKTOP_CONFIG";
550
- var PIWI_ORIGIN_ENV = {
551
- kind: "PIWI_ORIGIN",
552
- ref: "PIWI_ORIGIN_REF"
553
- };
554
- function originEnv(kind, ref) {
555
- return ref === void 0 ? { [PIWI_ORIGIN_ENV.kind]: kind } : { [PIWI_ORIGIN_ENV.kind]: kind, [PIWI_ORIGIN_ENV.ref]: String(ref) };
556
- }
557
- var PIWI_SHARD_ENV = "PIWI_SHARD";
558
- var PIWI_PROBE_ENV = {
559
- flag: "PIWI_PROBE",
560
- plan: "PIWI_PROBE_PLAN",
561
- results: "PIWI_PROBE_RESULTS",
562
- secret: "PIWI_PROBE_SECRET"
563
- };
564
- var PIWI_FLAKE_ENV = {
565
- plan: "PIWI_FLAKE_PLAN",
566
- results: "PIWI_FLAKE_RESULTS"
567
- };
568
- var ENV_FALLBACK_SPECS = [
569
- { option: "serverUrl", env: PIWI_ENV_KEYS.serverUrl, kind: "string" },
570
- { option: "projectName", env: PIWI_ENV_KEYS.projectName, kind: "string" },
571
- { option: "apiKey", env: PIWI_ENV_KEYS.apiKey, kind: "string" },
572
- { option: "username", env: PIWI_ENV_KEYS.username, kind: "string" },
573
- { option: "password", env: PIWI_ENV_KEYS.password, kind: "string" },
574
- { option: "environment", env: PIWI_ENV_KEYS.environment, kind: "string" },
575
- { option: "label", env: PIWI_ENV_KEYS.label, kind: "string" },
576
- { option: "runLabel", env: PIWI_ENV_KEYS.runLabel, kind: "string" },
577
- { option: "keep", env: PIWI_ENV_KEYS.keep, kind: "bool" },
578
- { option: "streaming", env: PIWI_ENV_KEYS.streaming, kind: "bool" },
579
- { option: "streamingBatchSize", env: PIWI_ENV_KEYS.streamingBatchSize, kind: "number" },
580
- { option: "streamingBatchDelay", env: PIWI_ENV_KEYS.streamingBatchDelay, kind: "number" },
581
- { option: "maxStreamBufferBytes", env: PIWI_ENV_KEYS.maxStreamBufferBytes, kind: "number" },
582
- { option: "submitTimeout", env: PIWI_ENV_KEYS.submitTimeout, kind: "number" },
583
- { option: "liveFileUploads", env: PIWI_ENV_KEYS.liveFileUploads, kind: "bool" },
584
- { option: "failOnFlakyTests", env: PIWI_ENV_KEYS.failOnFlakyTests, kind: "bool" },
585
- { option: "uploadTraces", env: PIWI_ENV_KEYS.uploadTraces, kind: "bool" },
586
- { option: "uploadReport", env: PIWI_ENV_KEYS.uploadReport, kind: "bool" },
587
- { option: "captureLocators", env: PIWI_ENV_KEYS.captureLocators, kind: "bool" },
588
- { option: "capturePageState", env: PIWI_ENV_KEYS.capturePageState, kind: "bool" },
589
- { option: "capturePageInventory", env: PIWI_ENV_KEYS.capturePageInventory, kind: "bool" },
590
- { option: "captureCodeReach", env: PIWI_ENV_KEYS.captureCodeReach, kind: "bool" },
591
- { option: "captureServerTraces", env: PIWI_ENV_KEYS.captureServerTraces, kind: "bool" },
592
- { option: "captureResources", env: PIWI_ENV_KEYS.captureResources, kind: "bool" },
593
- { option: "leakCheck", env: PIWI_ENV_KEYS.leakCheck, kind: "string" },
594
- { option: "sampleAriaOnPass", env: PIWI_ENV_KEYS.sampleAriaOnPass, kind: "bool" },
595
- { option: "uploadManifest", env: PIWI_ENV_KEYS.uploadManifest, kind: "bool" },
596
- { option: "defaultCapture", env: PIWI_ENV_KEYS.defaultCapture, kind: "bool" },
597
- { option: "checkBaseUrl", env: PIWI_ENV_KEYS.checkBaseUrl, kind: "bool" },
598
- { option: "inspectOnFailure", env: PIWI_ENV_KEYS.inspectOnFailure, kind: "bool" },
599
- { option: "pickLocatorOnFailure", env: PIWI_ENV_KEYS.pickLocatorOnFailure, kind: "bool" },
600
- { option: "outputFile", env: PIWI_ENV_KEYS.outputFile, kind: "string" }
601
- ];
602
-
603
- // src/cli/bug.ts
604
- var EXIT_OK2 = 0;
605
- var EXIT_RUN_FAILED = 1;
606
- var EXIT_ERROR2 = 2;
607
- var REQUEST_TIMEOUT_MS = 3e4;
608
- var USAGE2 = `
609
- piwi bug \u2014 a bug report's failing test, from the dashboard
610
-
611
- Usage:
612
- npx @piwitests/reporter bug <id> [options]
613
-
614
- Prints the spec to commit: the report's steps, test.fail() while the bug
615
- exists, @bug and piwi:bug <id>, written with the project's settings.
616
-
617
- Options:
618
- --write Write it to the project's bugs folder (tests/bugs by default, from the
619
- repository root), then run it once
620
- --out <file> Write it to this file instead (then run it once)
621
- --no-run With --write or --out: write it without running it
622
- --force Replace an existing file
623
- --run-mode The spec without test.fail(), as a reproduction runs it
624
- --server-url <url> Dashboard URL (env PIWI_DASHBOARD_URL)
625
- --api-key <key> API key (env PIWI_API_KEY)
626
495
 
627
- Exit codes: 0 printed or written (and, when run, the test behaved as its
628
- test.fail() expects: the bug reproduces), 1 the run did not (the bug may be
629
- fixed, or a step no longer matches the page), 2 the report could not be read
630
- or the file could not be written.
631
- `.trim();
632
- var VALUE_FLAGS = /* @__PURE__ */ new Set(["--out", "--server-url", "--api-key"]);
633
- function readOption2(argv, name) {
634
- const index = argv.indexOf(name);
635
- if (index === -1) return void 0;
636
- const value = argv[index + 1];
637
- return value !== void 0 && !value.startsWith("--") ? value : void 0;
638
- }
639
- function parseBugArgs(argv, env) {
640
- let idText;
641
- for (let i = 0; i < argv.length; i++) {
642
- const token = argv[i];
643
- if (token.startsWith("--")) {
644
- if (VALUE_FLAGS.has(token)) i++;
645
- continue;
496
+ // src/internal/ai/check.ts
497
+ function findEntryFiles(root, dir) {
498
+ const out = [];
499
+ const walk = (current) => {
500
+ let items;
501
+ try {
502
+ items = fs2.readdirSync(current, { withFileTypes: true });
503
+ } catch {
504
+ return;
505
+ }
506
+ for (const item of items) {
507
+ const full = path4.join(current, item.name);
508
+ if (item.isDirectory()) {
509
+ walk(full);
510
+ } else if (item.isFile() && item.name.endsWith(".json")) {
511
+ const specFileDir = path4.dirname(full);
512
+ const dirName = path4.basename(path4.dirname(specFileDir));
513
+ if (dirName !== dir) continue;
514
+ const specDir = path4.dirname(path4.dirname(specFileDir));
515
+ const specFile = path4.join(specDir, path4.basename(specFileDir));
516
+ out.push({ file: full, specFile, testSlug: item.name.split(".")[0] });
517
+ }
646
518
  }
647
- idText = token;
648
- break;
649
- }
650
- const id = Number((idText ?? "").replace(/^#/, ""));
651
- if (!idText || !Number.isInteger(id) || id <= 0) throw new Error("No bug report id \u2014 usage: piwi bug <id>");
652
- for (const flag of VALUE_FLAGS) {
653
- if (argv.includes(flag) && readOption2(argv, flag) === void 0) throw new Error(`${flag} expects a value`);
654
- }
655
- const serverUrl = (readOption2(argv, "--server-url") ?? env.PIWI_DASHBOARD_URL ?? "").replace(/\/+$/, "");
656
- if (!serverUrl) throw new Error("No dashboard \u2014 pass --server-url or set PIWI_DASHBOARD_URL");
657
- const out = readOption2(argv, "--out") ?? null;
658
- const write = argv.includes("--write") || out !== null;
659
- return {
660
- id,
661
- write,
662
- out,
663
- run: write && !argv.includes("--no-run"),
664
- force: argv.includes("--force"),
665
- mode: argv.includes("--run-mode") ? "run" : "commit",
666
- serverUrl,
667
- apiKey: readOption2(argv, "--api-key") ?? env.PIWI_API_KEY ?? null
668
519
  };
520
+ walk(root);
521
+ return out;
669
522
  }
670
- async function fetchBugSpec(args, specDir) {
671
- const dir = specDir !== void 0 ? `&specDir=${encodeURIComponent(specDir)}` : "";
672
- const url = `${args.serverUrl}/api/bug-reports/${args.id}/spec?mode=${args.mode}${dir}`;
673
- let res;
523
+ function readTextOr(file, fallback) {
674
524
  try {
675
- res = await fetch(url, {
676
- headers: args.apiKey ? { "X-API-Key": args.apiKey } : {},
677
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
678
- });
679
- } catch (e) {
680
- throw new Error(`could not reach ${args.serverUrl} (${e.message})`);
525
+ return fs2.readFileSync(file, "utf8");
526
+ } catch {
527
+ return fallback;
681
528
  }
682
- if (res.status === 404) throw new Error(`no bug report #${args.id} on ${args.serverUrl}`);
683
- if (res.status === 401 || res.status === 403) throw new Error(`the dashboard refused the API key (${res.status})`);
684
- if (!res.ok) throw new Error(`the dashboard answered ${res.status}`);
685
- const body = await res.json();
686
- if (typeof body.code !== "string" || typeof body.path !== "string") throw new Error("the dashboard sent no spec");
687
- return { code: body.code, path: body.path, warnings: Array.isArray(body.warnings) ? body.warnings : [] };
688
529
  }
689
- function repositoryRoot(cwd) {
690
- try {
691
- const out = (0, import_node_child_process2.execFileSync)("git", ["rev-parse", "--show-toplevel"], {
692
- cwd,
693
- encoding: "utf-8",
694
- stdio: ["ignore", "pipe", "ignore"]
695
- }).trim();
696
- return out ? path7.resolve(out) : null;
697
- } catch {
698
- return null;
530
+ function checkAiTree(root, opts = {}) {
531
+ const dir = opts.dir ?? DEFAULT_AI_DIR;
532
+ const findings = [];
533
+ const rel = (file) => path4.relative(root, file).split(path4.sep).join("/");
534
+ const sourceCache = /* @__PURE__ */ new Map();
535
+ const duplicates = /* @__PURE__ */ new Map();
536
+ for (const found of findEntryFiles(root, dir)) {
537
+ const text = readTextOr(found.file, null);
538
+ if (text === null) continue;
539
+ let templateNormalized = null;
540
+ try {
541
+ const entry = parseEntry(text);
542
+ templateNormalized = normalizeTemplate(entry.template);
543
+ if (serializeEntry(entry) !== text) {
544
+ findings.push({
545
+ severity: "error",
546
+ kind: "non-canonical",
547
+ file: rel(found.file),
548
+ message: "file is not in canonical form \u2014 re-author it with `piwi ai resolve --update-ai`"
549
+ });
550
+ }
551
+ if (!sourceCache.has(found.specFile)) sourceCache.set(found.specFile, readTextOr(found.specFile, null));
552
+ const source = sourceCache.get(found.specFile) ?? null;
553
+ if (source === null) {
554
+ findings.push({
555
+ severity: "error",
556
+ kind: "orphan",
557
+ file: rel(found.file),
558
+ message: `spec file is gone (${rel(found.specFile)}) \u2014 the entry is orphaned`
559
+ });
560
+ } else if (!source.includes(entry.template.trim())) {
561
+ findings.push({
562
+ severity: "error",
563
+ kind: "orphan",
564
+ file: rel(found.file),
565
+ message: `template "${entry.template}" no longer appears in ${rel(found.specFile)} \u2014 the entry is orphaned`
566
+ });
567
+ }
568
+ } catch (error) {
569
+ findings.push({
570
+ severity: "error",
571
+ kind: "invalid",
572
+ file: rel(found.file),
573
+ message: `not a valid entry: ${error.message}`
574
+ });
575
+ }
576
+ if (templateNormalized !== null) {
577
+ const key = `${found.specFile}::${found.testSlug}::${templateNormalized}`;
578
+ const list = duplicates.get(key) ?? [];
579
+ list.push(rel(found.file));
580
+ duplicates.set(key, list);
581
+ }
699
582
  }
700
- }
701
- function realFolder(dir) {
702
- const rest = [];
703
- let current = dir;
704
- while (!fs4.existsSync(current) && path7.dirname(current) !== current) {
705
- rest.unshift(path7.basename(current));
706
- current = path7.dirname(current);
583
+ for (const files of duplicates.values()) {
584
+ if (files.length < 2) continue;
585
+ for (const file of files) {
586
+ findings.push({
587
+ severity: "warning",
588
+ kind: "duplicate-template",
589
+ file,
590
+ message: `duplicate template within the same test \u2014 consider distinct phrasings (${files.join(", ")})`
591
+ });
592
+ }
707
593
  }
708
- return path7.join(fs4.realpathSync(current), ...rest);
709
- }
710
- function folderInRepository(root, dir) {
711
- const relative8 = path7.relative(realFolder(root), realFolder(dir));
712
- if (relative8.startsWith("..") || path7.isAbsolute(relative8)) return void 0;
713
- return relative8.split(path7.sep).join("/");
594
+ return findings.sort((a, b) => a.file < b.file ? -1 : a.file > b.file ? 1 : 0);
714
595
  }
715
- function fileFilter(file) {
716
- const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
717
- return `${escape(path7.basename(path7.dirname(file)))}[\\\\/]${escape(path7.basename(file))}$`;
596
+ function hasBlockingFindings(findings) {
597
+ return findings.some((f) => f.severity === "error");
718
598
  }
719
- function resolvePlaywrightCli(cwd) {
720
- const require2 = (0, import_node_module.createRequire)(path7.join(cwd, "noop.js"));
599
+
600
+ // src/internal/support/playwright-spawn.ts
601
+ var import_node_child_process = require("child_process");
602
+ var fs3 = __toESM(require("fs"));
603
+ var import_node_module = require("module");
604
+ var path5 = __toESM(require("path"));
605
+ var COULD_NOT_START = 2;
606
+ function resolvePlaywrightCli(cwd = process.cwd()) {
607
+ const require2 = (0, import_node_module.createRequire)(path5.join(cwd, "noop.js"));
721
608
  for (const id of ["playwright/cli", "@playwright/test/cli", "playwright/lib/cli/cli"]) {
722
609
  try {
723
610
  return require2.resolve(id);
@@ -726,479 +613,639 @@ function resolvePlaywrightCli(cwd) {
726
613
  }
727
614
  return null;
728
615
  }
729
- function runPlaywright(file, cwd, id, env) {
730
- const cli = resolvePlaywrightCli(cwd);
731
- const filter = fileFilter(file);
732
- const childEnv = { ...env, ...originEnv("bug", id) };
733
- const child = cli ? (0, import_node_child_process2.spawn)(process.execPath, [cli, "test", filter], { stdio: "inherit", cwd, env: childEnv }) : (0, import_node_child_process2.spawn)(process.platform === "win32" ? "npx.cmd" : "npx", ["playwright", "test", filter], {
734
- stdio: "inherit",
735
- cwd,
736
- env: childEnv
737
- });
616
+ function resolveNpmEntry(name, execPath = process.execPath) {
617
+ const dir = path5.dirname(execPath);
618
+ for (const candidate of [
619
+ path5.join(dir, "node_modules", "npm", "bin", `${name}-cli.js`),
620
+ path5.join(dir, "..", "lib", "node_modules", "npm", "bin", `${name}-cli.js`)
621
+ ]) {
622
+ if (fs3.existsSync(candidate)) return candidate;
623
+ }
624
+ return null;
625
+ }
626
+ function playwrightInvocation(playwrightArgs2, options = {}) {
627
+ const cli = resolvePlaywrightCli(options.cwd);
628
+ if (cli) return { file: process.execPath, args: [cli, "test", ...playwrightArgs2] };
629
+ const runner = options.runner ?? "npx";
630
+ if ((options.platform ?? process.platform) !== "win32") {
631
+ return { file: runner, args: ["playwright", "test", ...playwrightArgs2] };
632
+ }
633
+ const npx = runner === "npx" ? resolveNpmEntry("npx") : null;
634
+ return npx ? { file: process.execPath, args: [npx, "playwright", "test", ...playwrightArgs2] } : null;
635
+ }
636
+ function spawnPlaywright(playwrightArgs2, env, options = {}) {
637
+ const label = options.command ?? "piwi";
638
+ const cwd = options.cwd ?? process.cwd();
639
+ const invocation = playwrightInvocation(playwrightArgs2, { cwd, runner: options.runner });
640
+ if (!invocation) {
641
+ console.error(
642
+ `${label}: could not find Playwright in ${cwd} \u2014 install it there (npm install --save-dev @playwright/test).`
643
+ );
644
+ return Promise.resolve(COULD_NOT_START);
645
+ }
646
+ const output = options.output ?? "inherit";
647
+ let fd = output === "inherit" ? null : fs3.openSync(output.file, "a");
648
+ const stdio = fd === null ? "inherit" : ["ignore", fd, fd];
649
+ const child = (0, import_node_child_process.spawn)(invocation.file, invocation.args, { stdio, env, cwd });
738
650
  return new Promise((resolve10) => {
651
+ const done = (code) => {
652
+ if (fd !== null) fs3.closeSync(fd);
653
+ fd = null;
654
+ resolve10(code);
655
+ };
739
656
  child.on("error", (err) => {
740
- console.error(`piwi bug: could not start Playwright \u2014 ${err.message}`);
741
- resolve10(EXIT_ERROR2);
657
+ console.error(`${label}: could not start Playwright \u2014 ${err.message}`);
658
+ done(COULD_NOT_START);
742
659
  });
743
- child.on("exit", (code) => resolve10(code ?? EXIT_ERROR2));
660
+ child.on("exit", (code) => done(code ?? COULD_NOT_START));
744
661
  });
745
662
  }
746
- async function runBug(argv, env = process.env, cwd = process.cwd()) {
747
- if (argv.includes("-h") || argv.includes("--help")) {
748
- console.log(USAGE2);
749
- return EXIT_OK2;
750
- }
751
- let args;
752
- try {
753
- args = parseBugArgs(argv, env);
754
- } catch (e) {
755
- console.error(`piwi bug: ${e.message}
663
+ function runPackageManager(command, args, cwd) {
664
+ const result = process.platform === "win32" ? (0, import_node_child_process.spawnSync)([command, ...args].join(" "), { cwd, stdio: "inherit", shell: true }) : (0, import_node_child_process.spawnSync)(command, args, { cwd, stdio: "inherit" });
665
+ return result.status;
666
+ }
756
667
 
757
- ${USAGE2}`);
758
- return EXIT_ERROR2;
668
+ // src/cli/ai.ts
669
+ var EXIT_OK = 0;
670
+ var EXIT_ISSUES = 1;
671
+ var EXIT_ERROR = 2;
672
+ var USAGE = `
673
+ Usage: piwi ai <command> [options]
674
+
675
+ Commands:
676
+ check Scan committed AI-step entries for orphans, non-canonical files and
677
+ duplicate templates. Read-only; exits 1 when issues are found.
678
+ resolve Author missing entries by running the suite in resolve mode against
679
+ the configured authoring server (forces --workers=1).
680
+ prune Delete orphaned/dormant entries.
681
+
682
+ Options (check):
683
+ --dir <name> Entry directory name per spec (env PIWI_AI_DIR, default ${DEFAULT_AI_DIR})
684
+ --cwd <path> Root to scan (default: current directory)
685
+ --json Emit findings as JSON
686
+
687
+ Options (resolve):
688
+ --grep <re> Only author entries for matching tests
689
+ --project <name> Author under one Playwright project (a resolve profile)
690
+ --env K=V Extra env for the run (repeatable \u2014 flags/viewport profiles)
691
+ --update-ai Re-author entries that already exist
692
+ (needs PIWI_DASHBOARD_URL / PIWI_API_KEY for the server)
693
+
694
+ Exit codes:
695
+ 0 clean (or --help)
696
+ 1 hygiene issues found
697
+ 2 bad arguments / command unavailable
698
+ `.trim();
699
+ function readOption(argv, name) {
700
+ const withEquals = argv.find((arg) => arg.startsWith(`${name}=`));
701
+ if (withEquals) return withEquals.slice(name.length + 1);
702
+ const index = argv.indexOf(name);
703
+ if (index === -1) return void 0;
704
+ const value = argv[index + 1];
705
+ return value && !value.startsWith("--") ? value : void 0;
706
+ }
707
+ function readAll(argv, name) {
708
+ const values = [];
709
+ for (let i = 0; i < argv.length; i++) {
710
+ if (argv[i].startsWith(`${name}=`)) values.push(argv[i].slice(name.length + 1));
711
+ else if (argv[i] === name && argv[i + 1] && !argv[i + 1].startsWith("--")) values.push(argv[++i]);
759
712
  }
760
- const root = args.write ? repositoryRoot(cwd) : null;
761
- const outFile = args.out !== null ? path7.resolve(cwd, args.out) : null;
762
- let spec;
713
+ return values;
714
+ }
715
+ function formatFinding(finding) {
716
+ const mark = finding.severity === "error" ? "\u2716" : "\u26A0";
717
+ return `${mark} ${finding.file}
718
+ ${finding.kind}: ${finding.message}`;
719
+ }
720
+ function runCheck(argv, env) {
721
+ const root = path6.resolve(readOption(argv, "--cwd") ?? process.cwd());
722
+ const dir = readOption(argv, "--dir") ?? env.PIWI_AI_DIR ?? DEFAULT_AI_DIR;
723
+ let findings;
763
724
  try {
764
- spec = await fetchBugSpec(args, outFile && root ? folderInRepository(root, path7.dirname(outFile)) : void 0);
765
- } catch (e) {
766
- console.error(`piwi bug: ${e.message}`);
767
- return EXIT_ERROR2;
725
+ findings = checkAiTree(root, { dir });
726
+ } catch (error) {
727
+ console.error(`piwi ai check: ${error.message}`);
728
+ return EXIT_ERROR;
768
729
  }
769
- for (const warning of spec.warnings) {
770
- console.error(`piwi bug: warning: step ${warning.step + 1}: ${warning.message}`);
730
+ if (argv.includes("--json")) {
731
+ console.log(JSON.stringify({ findings }, null, 2));
732
+ } else if (findings.length === 0) {
733
+ console.log("piwi ai check: no issues found.");
734
+ } else {
735
+ for (const finding of findings) console.log(formatFinding(finding));
736
+ const errors = findings.filter((f) => f.severity === "error").length;
737
+ const warnings = findings.length - errors;
738
+ console.log(`
739
+ ${errors} error(s), ${warnings} warning(s).`);
771
740
  }
772
- if (!args.write) {
773
- process.stdout.write(spec.code);
774
- return EXIT_OK2;
741
+ return hasBlockingFindings(findings) ? EXIT_ISSUES : EXIT_OK;
742
+ }
743
+ function buildResolveInvocation(argv, env) {
744
+ const args = [];
745
+ const grep = readOption(argv, "--grep");
746
+ if (grep) args.push("--grep", grep);
747
+ const project = readOption(argv, "--project");
748
+ if (project) args.push("--project", project);
749
+ args.push("--workers=1");
750
+ const childEnv = { ...env, PIWI_AI: "resolve" };
751
+ if (argv.includes("--update-ai")) childEnv.PIWI_AI_UPDATE = "true";
752
+ for (const pair of readAll(argv, "--env")) {
753
+ const eq = pair.indexOf("=");
754
+ if (eq > 0) childEnv[pair.slice(0, eq)] = pair.slice(eq + 1);
775
755
  }
776
- if (!outFile && !root) {
777
- console.error("piwi bug: not in a git repository \u2014 the bugs folder is read from the working directory");
756
+ return { args, env: childEnv };
757
+ }
758
+ function runResolve(argv, env) {
759
+ if (!env.PIWI_DASHBOARD_URL) {
760
+ console.error("piwi ai resolve: set PIWI_DASHBOARD_URL (the authoring server) before resolving.");
761
+ return Promise.resolve(EXIT_ERROR);
778
762
  }
779
- const file = outFile ?? path7.resolve(root ?? cwd, spec.path);
780
- try {
781
- fs4.mkdirSync(path7.dirname(file), { recursive: true });
782
- fs4.writeFileSync(file, spec.code, { flag: args.force ? "w" : "wx" });
783
- } catch (e) {
784
- const exists = e.code === "EEXIST";
785
- console.error(`piwi bug: ${exists ? `${file} exists \u2014 pass --force to replace it` : e.message}`);
786
- return EXIT_ERROR2;
763
+ const invocation = buildResolveInvocation(argv, env);
764
+ return spawnPlaywright(invocation.args, invocation.env, { command: "piwi ai resolve" });
765
+ }
766
+ async function runAi(argv, env = process.env) {
767
+ const [sub, ...rest] = argv;
768
+ if (sub === void 0 || sub === "-h" || sub === "--help") {
769
+ console.log(USAGE);
770
+ return EXIT_OK;
787
771
  }
788
- const shown = path7.relative(cwd, file) || file;
789
- console.error(`piwi bug: wrote ${shown}`);
790
- if (!args.run) return EXIT_OK2;
791
- const code = await runPlaywright(file, cwd, args.id, env);
792
- if (code === EXIT_ERROR2 && !resolvePlaywrightCli(cwd)) return EXIT_ERROR2;
793
- if (code === 0) {
794
- console.error(
795
- args.mode === "commit" ? `piwi bug: the bug reproduces \u2014 the spec fails on it, as test.fail() expects. Commit ${shown}; remove test.fail() with the fix.` : "piwi bug: the spec passed \u2014 the bug does not show here."
796
- );
797
- return EXIT_OK2;
772
+ switch (sub) {
773
+ case "check":
774
+ return runCheck(rest, env);
775
+ case "resolve":
776
+ return runResolve(rest, env);
777
+ case "prune":
778
+ console.error(
779
+ "piwi ai prune: not available in this build yet. `piwi ai check` lists orphaned entries: delete their files by hand, and re-author a non-canonical one with `piwi ai resolve --update-ai`."
780
+ );
781
+ return EXIT_ERROR;
782
+ default:
783
+ console.error(`piwi ai: unknown command "${sub}"
784
+ `);
785
+ console.error(USAGE);
786
+ return EXIT_ERROR;
798
787
  }
799
- console.error(
800
- args.mode === "commit" ? "piwi bug: the spec did not fail as test.fail() expects \u2014 the bug may be fixed here, or a step no longer matches the page (see the output above)." : "piwi bug: the spec failed \u2014 the bug reproduces here, or a step no longer matches the page (see the output above)."
801
- );
802
- return EXIT_RUN_FAILED;
803
788
  }
804
789
 
805
- // src/cli/codegen.ts
790
+ // src/cli/bug.ts
806
791
  var fs5 = __toESM(require("fs"));
807
792
  var path8 = __toESM(require("path"));
793
+ var import_node_child_process2 = require("child_process");
808
794
 
809
- // ../core/src/recording.ts
810
- var ASSERTION_MATCHERS = [
811
- "toHaveText",
812
- "toHaveValue",
813
- "toHaveAccessibleName",
814
- "toBeVisible",
815
- "toBeHidden",
816
- "toBeEnabled",
817
- "toBeDisabled",
818
- "toHaveURL"
819
- ];
820
- var VALUE_MATCHERS = /* @__PURE__ */ new Set([
821
- "toHaveText",
822
- "toHaveValue",
823
- "toHaveAccessibleName",
824
- "toHaveURL"
825
- ]);
826
-
827
- // ../core/src/function-match.ts
828
- function paramArgKey(source) {
829
- return source.path ? `${source.param}.${source.path}` : source.param;
830
- }
831
- function globToRegExp(glob) {
832
- const escaped = glob.split("**").map((part) => part.replace(/[.?+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*")).join(".*");
833
- return new RegExp(`^${escaped}$`);
795
+ // src/internal/config/desktop.ts
796
+ var fs4 = __toESM(require("fs"));
797
+ var os = __toESM(require("os"));
798
+ var path7 = __toESM(require("path"));
799
+ function defaultDesktopConfigPath() {
800
+ return path7.join(os.homedir(), ".piwi", "desktop.json");
834
801
  }
835
- function urlMatches(pattern, url) {
836
- if (!pattern) return true;
802
+ function readDesktopConfig(filePath2) {
837
803
  try {
838
- return globToRegExp(pattern).test(url);
804
+ const parsed = JSON.parse(fs4.readFileSync(filePath2, "utf8"));
805
+ const url = parsed?.url;
806
+ const token = parsed?.token;
807
+ if (typeof url !== "string" || !url) return null;
808
+ if (typeof token !== "string" || !token) return null;
809
+ return { url, token };
839
810
  } catch {
840
- return true;
841
- }
842
- }
843
- function scoreTargetMatch(pattern, candidate) {
844
- if (!candidate) return pattern.role || pattern.testId || pattern.name ? 0 : 0.3;
845
- if (pattern.testId) {
846
- return candidate.testId && candidate.testId.toLowerCase() === pattern.testId.toLowerCase() ? 1 : 0;
847
- }
848
- const roleMatches = !pattern.role || candidate.role != null && candidate.role.toLowerCase() === pattern.role.toLowerCase();
849
- if (!roleMatches) return 0;
850
- if (pattern.name) {
851
- const needle = pattern.name.toLowerCase();
852
- const haystack = `${candidate.accessibleName ?? ""} ${candidate.text ?? ""}`.toLowerCase().trim();
853
- const nameMatches2 = haystack.length > 0 && (haystack.includes(needle) || needle.includes(haystack));
854
- if (!nameMatches2) return pattern.role ? 0.35 : 0;
855
- return pattern.role ? 0.95 : 0.6;
856
- }
857
- return pattern.role ? 0.7 : 0.3;
858
- }
859
- function actionCompatible(patternAction, recordedAction) {
860
- if (patternAction === recordedAction) return true;
861
- return patternAction === "click" && recordedAction === "press" || patternAction === "press" && recordedAction === "click";
862
- }
863
- function stepPairScore(pattern, recorded) {
864
- if (!actionCompatible(pattern.action, recorded.action)) return 0;
865
- const base = scoreTargetMatch(pattern.target, recorded.target);
866
- return pattern.action === recorded.action ? base : base * 0.6;
867
- }
868
- function extractArgs(entry, window, pairs) {
869
- const args = {};
870
- const byPatternIndex = new Map(pairs.map(([windowIdx, patternIdx]) => [patternIdx, windowIdx]));
871
- for (const source of entry.paramSources) {
872
- const windowIdx = byPatternIndex.get(source.stepIndex);
873
- if (windowIdx == null) continue;
874
- const step = window[windowIdx];
875
- const value = source.from === "value" ? step.value : source.from === "testId" ? step.target?.testId ?? null : step.target?.text ?? null;
876
- if (value != null) args[paramArgKey(source)] = value;
877
- }
878
- return args;
879
- }
880
- function sameElement(pattern, recorded) {
881
- if (pattern.testId || !pattern.name) return true;
882
- const haystack = `${recorded?.accessibleName ?? ""} ${recorded?.text ?? ""}`.toLowerCase().trim();
883
- const needle = pattern.name.toLowerCase();
884
- return haystack.length > 0 && (haystack.includes(needle) || needle.includes(haystack));
885
- }
886
- function contiguousAlignment(steps, startIndex, pattern) {
887
- if (startIndex + pattern.length > steps.length) return null;
888
- const pairs = [];
889
- let total = 0;
890
- for (let j = 0; j < pattern.length; j++) {
891
- const recorded = steps[startIndex + j];
892
- const score = stepPairScore(pattern[j], recorded);
893
- if (score <= 0 || !sameElement(pattern[j].target, recorded.target)) return null;
894
- total += score;
895
- pairs.push([j, j]);
896
- }
897
- return { total, pairs };
898
- }
899
- function matchFunctionAt(steps, startIndex, catalog) {
900
- const anchor = steps[startIndex];
901
- if (!anchor) return null;
902
- let best = null;
903
- for (const entry of catalog) {
904
- if (entry.steps.length === 0) continue;
905
- if (!urlMatches(entry.urlPattern, anchor.pageUrl)) continue;
906
- const aligned = contiguousAlignment(steps, startIndex, entry.steps);
907
- if (!aligned) continue;
908
- const { total, pairs } = aligned;
909
- const window = steps.slice(startIndex, startIndex + entry.steps.length);
910
- const score = total / entry.steps.length;
911
- const match = {
912
- entry,
913
- score,
914
- matchedIndices: pairs.map(([windowIdx]) => windowIdx + startIndex),
915
- complete: true,
916
- args: extractArgs(entry, window, pairs)
917
- };
918
- if (!best || match.score > best.score || match.score === best.score && entry.steps.length > best.entry.steps.length)
919
- best = match;
811
+ return null;
920
812
  }
921
- return best;
922
813
  }
923
814
 
924
- // ../core/src/locator-chain.ts
925
- var LOCATING_METHODS = /* @__PURE__ */ new Set([
926
- "getByRole",
927
- "getByTestId",
928
- "getByText",
929
- "getByLabel",
930
- "getByPlaceholder",
931
- "getByAltText",
932
- "getByTitle",
933
- "locator",
934
- "frameLocator"
935
- ]);
936
- var NARROWING_METHODS = /* @__PURE__ */ new Set([
937
- "filter",
938
- "first",
939
- "last",
940
- "nth",
941
- "and",
942
- "or",
943
- "visible",
944
- "contentFrame",
945
- "owner"
946
- ]);
947
- var LOCATOR_CHAIN_METHODS = /* @__PURE__ */ new Set([...LOCATING_METHODS, ...NARROWING_METHODS]);
948
- var LocatorParseError = class extends Error {
949
- constructor(message) {
950
- super(message);
951
- this.name = "LocatorParseError";
952
- }
815
+ // src/internal/config/env.ts
816
+ var DEFAULTS = {
817
+ projectName: "default-project",
818
+ uploadTraces: true,
819
+ uploadReport: true,
820
+ liveFileUploads: true,
821
+ collectScmInfo: true,
822
+ collectCiInfo: true,
823
+ collectPerformanceMetrics: true,
824
+ captureLocators: true,
825
+ capturePageState: true,
826
+ // Off by default: the page inventory reads control and link names from every
827
+ // visited page, so it stays opt-in until a project turns it on.
828
+ capturePageInventory: false,
829
+ // Off by default: JavaScript coverage adds time to every test, so a scheduled job opts in.
830
+ captureCodeReach: false,
831
+ captureServerTraces: true,
832
+ captureResources: true,
833
+ leakCheck: "report",
834
+ sampleAriaOnPass: true,
835
+ uploadManifest: true,
836
+ defaultCapture: true,
837
+ checkBaseUrl: false,
838
+ streaming: true,
839
+ streamingBatchSize: 20,
840
+ streamingBatchDelay: 2e3,
841
+ maxStreamBufferBytes: 100 * 1024 * 1024,
842
+ submitTimeout: 15 * 60 * 1e3,
843
+ failOnFlakyTests: false,
844
+ username: null,
845
+ password: null,
846
+ apiKey: null,
847
+ verbose: false
953
848
  };
954
- var SIMPLE_ESCAPES = {
955
- n: "\n",
956
- t: " ",
957
- r: "\r",
958
- b: "\b",
959
- f: "\f",
960
- v: "\v",
961
- "0": "\0"
849
+ var PIWI_ENV_KEYS = {
850
+ serverUrl: "PIWI_DASHBOARD_URL",
851
+ projectName: "PIWI_PROJECT_NAME",
852
+ verbose: "PIWI_VERBOSE",
853
+ apiKey: "PIWI_API_KEY",
854
+ username: "PIWI_USERNAME",
855
+ password: "PIWI_PASSWORD",
856
+ environment: "PIWI_ENVIRONMENT",
857
+ label: "PIWI_LABEL",
858
+ runLabel: "PIWI_RUN_LABEL",
859
+ keep: "PIWI_KEEP",
860
+ streaming: "PIWI_STREAMING",
861
+ streamingBatchSize: "PIWI_STREAMING_BATCH_SIZE",
862
+ streamingBatchDelay: "PIWI_STREAMING_BATCH_DELAY",
863
+ maxStreamBufferBytes: "PIWI_MAX_STREAM_BUFFER_BYTES",
864
+ submitTimeout: "PIWI_SUBMIT_TIMEOUT",
865
+ liveFileUploads: "PIWI_LIVE_FILE_UPLOADS",
866
+ failOnFlakyTests: "PIWI_FAIL_ON_FLAKY_TESTS",
867
+ uploadTraces: "PIWI_UPLOAD_TRACES",
868
+ uploadReport: "PIWI_UPLOAD_REPORT",
869
+ captureLocators: "PIWI_CAPTURE_LOCATORS",
870
+ capturePageState: "PIWI_CAPTURE_PAGE_STATE",
871
+ capturePageInventory: "PIWI_CAPTURE_PAGE_INVENTORY",
872
+ captureCodeReach: "PIWI_CAPTURE_CODE_REACH",
873
+ codeReachRoots: "PIWI_CODE_REACH_ROOTS",
874
+ coverageBrowser: "PIWI_COVERAGE_BROWSER",
875
+ coverageServer: "PIWI_COVERAGE_SERVER",
876
+ captureServerTraces: "PIWI_CAPTURE_SERVER_TRACES",
877
+ captureResources: "PIWI_CAPTURE_RESOURCES",
878
+ leakCheck: "PIWI_LEAK_CHECK",
879
+ sampleAriaOnPass: "PIWI_SAMPLE_ARIA_ON_PASS",
880
+ uploadManifest: "PIWI_UPLOAD_MANIFEST",
881
+ defaultCapture: "PIWI_DEFAULT_CAPTURE",
882
+ checkBaseUrl: "PIWI_CHECK_BASE_URL",
883
+ inspectOnFailure: "PIWI_INSPECT_ON_FAIL",
884
+ pickLocatorOnFailure: "PIWI_PICK_LOCATOR_ON_FAIL",
885
+ outputFile: "PIWI_OUTPUT_FILE",
886
+ aiMode: "PIWI_AI",
887
+ aiDir: "PIWI_AI_DIR",
888
+ aiOnMiss: "PIWI_AI_ON_MISS",
889
+ aiMaxSteps: "PIWI_AI_MAX_FLOW_STEPS",
890
+ aiMaxSnapshotChars: "PIWI_AI_MAX_SNAPSHOT_CHARS",
891
+ aiOptionalProbeTimeout: "PIWI_AI_OPTIONAL_PROBE_TIMEOUT",
892
+ aiResponseWaitTimeout: "PIWI_AI_RESPONSE_WAIT_TIMEOUT",
893
+ aiScreenshotFallback: "PIWI_AI_SCREENSHOT_FALLBACK"
962
894
  };
963
- var Parser = class {
964
- constructor(src) {
965
- this.src = src;
966
- this.i = 0;
967
- }
968
- parseTopLevel() {
969
- const chain = this.parseChain();
970
- this.skipSpace();
971
- if (this.i < this.src.length) this.fail("unexpected text");
972
- return chain;
973
- }
974
- fail(message) {
975
- throw new LocatorParseError(`${message} at "${this.src.slice(this.i, this.i + 20)}"`);
976
- }
977
- skipSpace() {
978
- while (this.i < this.src.length && /\s/.test(this.src[this.i])) this.i++;
979
- }
980
- peek() {
981
- return this.src[this.i];
895
+ var PIWI_DESKTOP_CONFIG_ENV = "PIWI_DESKTOP_CONFIG";
896
+ var PIWI_ORIGIN_ENV = {
897
+ kind: "PIWI_ORIGIN",
898
+ ref: "PIWI_ORIGIN_REF"
899
+ };
900
+ function originEnv(kind, ref) {
901
+ return ref === void 0 ? { [PIWI_ORIGIN_ENV.kind]: kind } : { [PIWI_ORIGIN_ENV.kind]: kind, [PIWI_ORIGIN_ENV.ref]: String(ref) };
902
+ }
903
+ var PIWI_SHARD_ENV = "PIWI_SHARD";
904
+ var PIWI_PROBE_ENV = {
905
+ flag: "PIWI_PROBE",
906
+ plan: "PIWI_PROBE_PLAN",
907
+ results: "PIWI_PROBE_RESULTS",
908
+ secret: "PIWI_PROBE_SECRET"
909
+ };
910
+ var PIWI_FLAKE_ENV = {
911
+ plan: "PIWI_FLAKE_PLAN",
912
+ results: "PIWI_FLAKE_RESULTS"
913
+ };
914
+ var ENV_FALLBACK_SPECS = [
915
+ { option: "serverUrl", env: PIWI_ENV_KEYS.serverUrl, kind: "string" },
916
+ { option: "projectName", env: PIWI_ENV_KEYS.projectName, kind: "string" },
917
+ { option: "apiKey", env: PIWI_ENV_KEYS.apiKey, kind: "string" },
918
+ { option: "username", env: PIWI_ENV_KEYS.username, kind: "string" },
919
+ { option: "password", env: PIWI_ENV_KEYS.password, kind: "string" },
920
+ { option: "environment", env: PIWI_ENV_KEYS.environment, kind: "string" },
921
+ { option: "label", env: PIWI_ENV_KEYS.label, kind: "string" },
922
+ { option: "runLabel", env: PIWI_ENV_KEYS.runLabel, kind: "string" },
923
+ { option: "keep", env: PIWI_ENV_KEYS.keep, kind: "bool" },
924
+ { option: "streaming", env: PIWI_ENV_KEYS.streaming, kind: "bool" },
925
+ { option: "streamingBatchSize", env: PIWI_ENV_KEYS.streamingBatchSize, kind: "number" },
926
+ { option: "streamingBatchDelay", env: PIWI_ENV_KEYS.streamingBatchDelay, kind: "number" },
927
+ { option: "maxStreamBufferBytes", env: PIWI_ENV_KEYS.maxStreamBufferBytes, kind: "number" },
928
+ { option: "submitTimeout", env: PIWI_ENV_KEYS.submitTimeout, kind: "number" },
929
+ { option: "liveFileUploads", env: PIWI_ENV_KEYS.liveFileUploads, kind: "bool" },
930
+ { option: "failOnFlakyTests", env: PIWI_ENV_KEYS.failOnFlakyTests, kind: "bool" },
931
+ { option: "uploadTraces", env: PIWI_ENV_KEYS.uploadTraces, kind: "bool" },
932
+ { option: "uploadReport", env: PIWI_ENV_KEYS.uploadReport, kind: "bool" },
933
+ { option: "captureLocators", env: PIWI_ENV_KEYS.captureLocators, kind: "bool" },
934
+ { option: "capturePageState", env: PIWI_ENV_KEYS.capturePageState, kind: "bool" },
935
+ { option: "capturePageInventory", env: PIWI_ENV_KEYS.capturePageInventory, kind: "bool" },
936
+ { option: "captureCodeReach", env: PIWI_ENV_KEYS.captureCodeReach, kind: "bool" },
937
+ { option: "captureServerTraces", env: PIWI_ENV_KEYS.captureServerTraces, kind: "bool" },
938
+ { option: "captureResources", env: PIWI_ENV_KEYS.captureResources, kind: "bool" },
939
+ { option: "leakCheck", env: PIWI_ENV_KEYS.leakCheck, kind: "string" },
940
+ { option: "sampleAriaOnPass", env: PIWI_ENV_KEYS.sampleAriaOnPass, kind: "bool" },
941
+ { option: "uploadManifest", env: PIWI_ENV_KEYS.uploadManifest, kind: "bool" },
942
+ { option: "defaultCapture", env: PIWI_ENV_KEYS.defaultCapture, kind: "bool" },
943
+ { option: "checkBaseUrl", env: PIWI_ENV_KEYS.checkBaseUrl, kind: "bool" },
944
+ { option: "inspectOnFailure", env: PIWI_ENV_KEYS.inspectOnFailure, kind: "bool" },
945
+ { option: "pickLocatorOnFailure", env: PIWI_ENV_KEYS.pickLocatorOnFailure, kind: "bool" },
946
+ { option: "outputFile", env: PIWI_ENV_KEYS.outputFile, kind: "string" }
947
+ ];
948
+
949
+ // src/cli/bug.ts
950
+ var EXIT_OK2 = 0;
951
+ var EXIT_RUN_FAILED = 1;
952
+ var EXIT_ERROR2 = 2;
953
+ var REQUEST_TIMEOUT_MS = 3e4;
954
+ var USAGE2 = `
955
+ piwi bug \u2014 a bug report's failing test, from the dashboard
956
+
957
+ Usage:
958
+ npx @piwitests/reporter bug <id> [options]
959
+
960
+ Prints the spec to commit: the report's steps, test.fail() while the bug
961
+ exists, @bug and piwi:bug <id>, written with the project's settings.
962
+
963
+ Options:
964
+ --write Write it to the project's bugs folder (tests/bugs by default, from the
965
+ repository root), then run it once
966
+ --out <file> Write it to this file instead (then run it once)
967
+ --no-run With --write or --out: write it without running it
968
+ --force Replace an existing file
969
+ --run-mode The spec without test.fail(), as a reproduction runs it
970
+ --server-url <url> Dashboard URL (env PIWI_DASHBOARD_URL)
971
+ --api-key <key> API key (env PIWI_API_KEY)
972
+
973
+ Exit codes: 0 printed or written (and, when run, the test behaved as its
974
+ test.fail() expects: the bug reproduces), 1 the run did not (the bug may be
975
+ fixed, or a step no longer matches the page), 2 the report could not be read
976
+ or the file could not be written.
977
+ `.trim();
978
+ var VALUE_FLAGS = /* @__PURE__ */ new Set(["--out", "--server-url", "--api-key"]);
979
+ function readOption2(argv, name) {
980
+ const index = argv.indexOf(name);
981
+ if (index === -1) return void 0;
982
+ const value = argv[index + 1];
983
+ return value !== void 0 && !value.startsWith("--") ? value : void 0;
984
+ }
985
+ function parseBugArgs(argv, env) {
986
+ let idText;
987
+ for (let i = 0; i < argv.length; i++) {
988
+ const token = argv[i];
989
+ if (token.startsWith("--")) {
990
+ if (VALUE_FLAGS.has(token)) i++;
991
+ continue;
992
+ }
993
+ idText = token;
994
+ break;
982
995
  }
983
- expect(ch) {
984
- this.skipSpace();
985
- if (this.src[this.i] !== ch) this.fail(`expected "${ch}"`);
986
- this.i++;
996
+ const id = Number((idText ?? "").replace(/^#/, ""));
997
+ if (!idText || !Number.isInteger(id) || id <= 0) throw new Error("No bug report id \u2014 usage: piwi bug <id>");
998
+ for (const flag of VALUE_FLAGS) {
999
+ if (argv.includes(flag) && readOption2(argv, flag) === void 0) throw new Error(`${flag} expects a value`);
987
1000
  }
988
- identifier() {
989
- const m = /^[A-Za-z_$][\w$]*/.exec(this.src.slice(this.i));
990
- if (!m) this.fail("expected a name");
991
- this.i += m[0].length;
992
- return m[0];
1001
+ const serverUrl = (readOption2(argv, "--server-url") ?? env.PIWI_DASHBOARD_URL ?? "").replace(/\/+$/, "");
1002
+ if (!serverUrl) throw new Error("No dashboard \u2014 pass --server-url or set PIWI_DASHBOARD_URL");
1003
+ const out = readOption2(argv, "--out") ?? null;
1004
+ const write = argv.includes("--write") || out !== null;
1005
+ return {
1006
+ id,
1007
+ write,
1008
+ out,
1009
+ run: write && !argv.includes("--no-run"),
1010
+ force: argv.includes("--force"),
1011
+ mode: argv.includes("--run-mode") ? "run" : "commit",
1012
+ serverUrl,
1013
+ apiKey: readOption2(argv, "--api-key") ?? env.PIWI_API_KEY ?? null
1014
+ };
1015
+ }
1016
+ async function fetchBugSpec(args, specDir) {
1017
+ const dir = specDir !== void 0 ? `&specDir=${encodeURIComponent(specDir)}` : "";
1018
+ const url = `${args.serverUrl}/api/bug-reports/${args.id}/spec?mode=${args.mode}${dir}`;
1019
+ let res;
1020
+ try {
1021
+ res = await fetch(url, {
1022
+ headers: args.apiKey ? { "X-API-Key": args.apiKey } : {},
1023
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
1024
+ });
1025
+ } catch (e) {
1026
+ throw new Error(`could not reach ${args.serverUrl} (${e.message})`);
993
1027
  }
994
- /**
995
- * A chain of calls joined by `.`. With `lenient`, parsing stops before the
996
- * first link that doesn't parse instead of failing, so a chain followed by
997
- * other text (an error message) is read up to where it ends. `spans` receives
998
- * where each top-level call starts and ends.
999
- */
1000
- parseChain(opts = {}) {
1001
- this.skipSpace();
1002
- const calls = [];
1003
- for (; ; ) {
1004
- const before = this.i;
1005
- if (calls.length > 0) this.i++;
1006
- this.skipSpace();
1007
- const start = this.i;
1008
- try {
1009
- calls.push(this.parseCall(calls.length === 0));
1010
- } catch (error) {
1011
- if (!opts.lenient || calls.length === 0) throw error;
1012
- this.i = before;
1013
- break;
1014
- }
1015
- opts.spans?.push({ start, end: this.i });
1016
- const end = this.i;
1017
- this.skipSpace();
1018
- if (this.peek() !== ".") {
1019
- this.i = end;
1020
- break;
1021
- }
1022
- }
1023
- return { calls };
1028
+ if (res.status === 404) throw new Error(`no bug report #${args.id} on ${args.serverUrl}`);
1029
+ if (res.status === 401 || res.status === 403) throw new Error(`the dashboard refused the API key (${res.status})`);
1030
+ if (!res.ok) throw new Error(`the dashboard answered ${res.status}`);
1031
+ const body = await res.json();
1032
+ if (typeof body.code !== "string" || typeof body.path !== "string") throw new Error("the dashboard sent no spec");
1033
+ return { code: body.code, path: body.path, warnings: Array.isArray(body.warnings) ? body.warnings : [] };
1034
+ }
1035
+ function repositoryRoot(cwd) {
1036
+ try {
1037
+ const out = (0, import_node_child_process2.execFileSync)("git", ["rev-parse", "--show-toplevel"], {
1038
+ cwd,
1039
+ encoding: "utf-8",
1040
+ stdio: ["ignore", "pipe", "ignore"]
1041
+ }).trim();
1042
+ return out ? path8.resolve(out) : null;
1043
+ } catch {
1044
+ return null;
1024
1045
  }
1025
- /** Where the parser stopped. */
1026
- get position() {
1027
- return this.i;
1046
+ }
1047
+ function realFolder(dir) {
1048
+ const rest = [];
1049
+ let current = dir;
1050
+ while (!fs5.existsSync(current) && path8.dirname(current) !== current) {
1051
+ rest.unshift(path8.basename(current));
1052
+ current = path8.dirname(current);
1028
1053
  }
1029
- parseCall(first) {
1030
- this.skipSpace();
1031
- const method = this.identifier();
1032
- if (!LOCATOR_CHAIN_METHODS.has(method)) this.fail(`unsupported method ${method}()`);
1033
- if (first && !LOCATING_METHODS.has(method)) this.fail(`${method}() needs a locator before it`);
1034
- this.expect("(");
1035
- const args = [];
1036
- this.skipSpace();
1037
- if (this.peek() === ")") {
1038
- this.i++;
1039
- return { method, args };
1040
- }
1041
- for (; ; ) {
1042
- args.push(this.parseValue());
1043
- this.skipSpace();
1044
- if (this.peek() === ",") {
1045
- this.i++;
1046
- this.skipSpace();
1047
- if (this.peek() === ")") {
1048
- this.i++;
1049
- break;
1050
- }
1051
- continue;
1052
- }
1053
- this.expect(")");
1054
- break;
1055
- }
1056
- return { method, args };
1054
+ return path8.join(fs5.realpathSync(current), ...rest);
1055
+ }
1056
+ function folderInRepository(root, dir) {
1057
+ const relative9 = path8.relative(realFolder(root), realFolder(dir));
1058
+ if (relative9.startsWith("..") || path8.isAbsolute(relative9)) return void 0;
1059
+ return relative9.split(path8.sep).join("/");
1060
+ }
1061
+ function fileFilter(file) {
1062
+ const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1063
+ return `${escape(path8.basename(path8.dirname(file)))}[\\\\/]${escape(path8.basename(file))}$`;
1064
+ }
1065
+ function runPlaywright(file, cwd, id, env) {
1066
+ return spawnPlaywright([fileFilter(file)], { ...env, ...originEnv("bug", id) }, { cwd, command: "piwi bug" });
1067
+ }
1068
+ async function runBug(argv, env = process.env, cwd = process.cwd()) {
1069
+ if (argv.includes("-h") || argv.includes("--help")) {
1070
+ console.log(USAGE2);
1071
+ return EXIT_OK2;
1057
1072
  }
1058
- parseValue() {
1059
- this.skipSpace();
1060
- const ch = this.peek();
1061
- if (ch === "'" || ch === '"' || ch === "`") return { type: "string", value: this.parseString() };
1062
- if (ch === "/") return this.parseRegex();
1063
- if (ch === "{") return this.parseObject();
1064
- if (ch !== void 0 && /[-\d]/.test(ch)) {
1065
- const m = /^-?\d+(?:\.\d+)?/.exec(this.src.slice(this.i));
1066
- if (!m) this.fail("expected a number");
1067
- this.i += m[0].length;
1068
- return { type: "number", value: Number(m[0]) };
1069
- }
1070
- const rest = this.src.slice(this.i);
1071
- if (/^true\b/.test(rest)) {
1072
- this.i += 4;
1073
- return { type: "boolean", value: true };
1074
- }
1075
- if (/^false\b/.test(rest)) {
1076
- this.i += 5;
1077
- return { type: "boolean", value: false };
1078
- }
1079
- if (/^[A-Za-z_$]/.test(rest)) return { type: "chain", chain: this.parseChain() };
1080
- return this.fail("expected a value");
1073
+ let args;
1074
+ try {
1075
+ args = parseBugArgs(argv, env);
1076
+ } catch (e) {
1077
+ console.error(`piwi bug: ${e.message}
1078
+
1079
+ ${USAGE2}`);
1080
+ return EXIT_ERROR2;
1081
1081
  }
1082
- /** A quoted string, decoding the escapes `JSON.stringify` and JavaScript produce. */
1083
- parseString() {
1084
- const quote4 = this.src[this.i];
1085
- let out = "";
1086
- this.i++;
1087
- while (this.i < this.src.length) {
1088
- const c = this.src[this.i];
1089
- if (c === quote4) {
1090
- this.i++;
1091
- return out;
1092
- }
1093
- if (c !== "\\") {
1094
- out += c;
1095
- this.i++;
1096
- continue;
1097
- }
1098
- const next = this.src[this.i + 1];
1099
- if (next === void 0) break;
1100
- const simple = SIMPLE_ESCAPES[next];
1101
- if (simple !== void 0) {
1102
- out += simple;
1103
- this.i += 2;
1104
- continue;
1105
- }
1106
- const hex = next === "u" && this.src[this.i + 2] === "{" ? /^u\{([0-9a-fA-F]{1,6})\}/.exec(this.src.slice(this.i + 1)) : next === "u" ? /^u([0-9a-fA-F]{4})/.exec(this.src.slice(this.i + 1)) : next === "x" ? /^x([0-9a-fA-F]{2})/.exec(this.src.slice(this.i + 1)) : null;
1107
- if (hex) {
1108
- out += String.fromCodePoint(parseInt(hex[1], 16));
1109
- this.i += 1 + hex[0].length;
1110
- continue;
1111
- }
1112
- if (next === "u" || next === "x") this.fail("malformed escape");
1113
- out += next;
1114
- this.i += 2;
1115
- }
1116
- return this.fail("unterminated string");
1082
+ const root = args.write ? repositoryRoot(cwd) : null;
1083
+ const outFile = args.out !== null ? path8.resolve(cwd, args.out) : null;
1084
+ let spec;
1085
+ try {
1086
+ spec = await fetchBugSpec(args, outFile && root ? folderInRepository(root, path8.dirname(outFile)) : void 0);
1087
+ } catch (e) {
1088
+ console.error(`piwi bug: ${e.message}`);
1089
+ return EXIT_ERROR2;
1117
1090
  }
1118
- parseRegex() {
1119
- let j = this.i + 1;
1120
- let inClass = false;
1121
- while (j < this.src.length) {
1122
- const c = this.src[j];
1123
- if (c === "\\") {
1124
- j += 2;
1125
- continue;
1126
- }
1127
- if (c === "[") inClass = true;
1128
- else if (c === "]") inClass = false;
1129
- else if (c === "/" && !inClass) break;
1130
- j++;
1131
- }
1132
- if (j >= this.src.length) this.fail("unterminated regex");
1133
- const source = this.src.slice(this.i + 1, j);
1134
- const flags = /^[a-z]*/.exec(this.src.slice(j + 1))[0];
1135
- this.i = j + 1 + flags.length;
1136
- return { type: "regex", source, flags };
1091
+ for (const warning of spec.warnings) {
1092
+ console.error(`piwi bug: warning: step ${warning.step + 1}: ${warning.message}`);
1137
1093
  }
1138
- parseObject() {
1139
- this.expect("{");
1140
- const entries = [];
1141
- for (; ; ) {
1142
- this.skipSpace();
1143
- if (this.peek() === "}") {
1144
- this.i++;
1145
- break;
1146
- }
1147
- const ch = this.peek();
1148
- const key = ch === "'" || ch === '"' ? this.parseString() : this.identifier();
1149
- this.expect(":");
1150
- entries.push([key, this.parseValue()]);
1151
- this.skipSpace();
1152
- if (this.peek() === ",") {
1153
- this.i++;
1154
- continue;
1155
- }
1156
- this.expect("}");
1157
- break;
1158
- }
1159
- return { type: "object", entries };
1094
+ if (!args.write) {
1095
+ process.stdout.write(spec.code);
1096
+ return EXIT_OK2;
1160
1097
  }
1161
- };
1162
- function parseLocatorChain(expr) {
1163
- const trimmed = expr.trim().replace(/^(?:await\s+)?(?:this\.)?page\./, "");
1164
- if (!trimmed) throw new LocatorParseError("empty expression");
1165
- return new Parser(trimmed).parseTopLevel();
1098
+ if (!outFile && !root) {
1099
+ console.error("piwi bug: not in a git repository \u2014 the bugs folder is read from the working directory");
1100
+ }
1101
+ const file = outFile ?? path8.resolve(root ?? cwd, spec.path);
1102
+ try {
1103
+ fs5.mkdirSync(path8.dirname(file), { recursive: true });
1104
+ fs5.writeFileSync(file, spec.code, { flag: args.force ? "w" : "wx" });
1105
+ } catch (e) {
1106
+ const exists = e.code === "EEXIST";
1107
+ console.error(`piwi bug: ${exists ? `${file} exists \u2014 pass --force to replace it` : e.message}`);
1108
+ return EXIT_ERROR2;
1109
+ }
1110
+ const shown = path8.relative(cwd, file) || file;
1111
+ console.error(`piwi bug: wrote ${shown}`);
1112
+ if (!args.run) return EXIT_OK2;
1113
+ const code = await runPlaywright(file, cwd, args.id, env);
1114
+ if (code === EXIT_ERROR2 && !resolvePlaywrightCli(cwd)) return EXIT_ERROR2;
1115
+ if (code === 0) {
1116
+ console.error(
1117
+ args.mode === "commit" ? `piwi bug: the bug reproduces \u2014 the spec fails on it, as test.fail() expects. Commit ${shown}; remove test.fail() with the fix.` : "piwi bug: the spec passed \u2014 the bug does not show here."
1118
+ );
1119
+ return EXIT_OK2;
1120
+ }
1121
+ console.error(
1122
+ args.mode === "commit" ? "piwi bug: the spec did not fail as test.fail() expects \u2014 the bug may be fixed here, or a step no longer matches the page (see the output above)." : "piwi bug: the spec failed \u2014 the bug reproduces here, or a step no longer matches the page (see the output above)."
1123
+ );
1124
+ return EXIT_RUN_FAILED;
1166
1125
  }
1167
- function tryParseLocatorChain(expr) {
1126
+
1127
+ // src/cli/codegen.ts
1128
+ var fs6 = __toESM(require("fs"));
1129
+ var path9 = __toESM(require("path"));
1130
+
1131
+ // ../core/src/recording.ts
1132
+ var ASSERTION_MATCHERS = [
1133
+ "toHaveText",
1134
+ "toHaveValue",
1135
+ "toHaveAccessibleName",
1136
+ "toBeVisible",
1137
+ "toBeHidden",
1138
+ "toBeEnabled",
1139
+ "toBeDisabled",
1140
+ "toHaveURL",
1141
+ "toContainText",
1142
+ "toHaveCount",
1143
+ "toBeChecked"
1144
+ ];
1145
+ var VALUE_MATCHERS = /* @__PURE__ */ new Set([
1146
+ "toHaveText",
1147
+ "toHaveValue",
1148
+ "toHaveAccessibleName",
1149
+ "toHaveURL",
1150
+ "toContainText",
1151
+ "toHaveCount"
1152
+ ]);
1153
+
1154
+ // ../core/src/function-match.ts
1155
+ function paramArgKey(source) {
1156
+ return source.path ? `${source.param}.${source.path}` : source.param;
1157
+ }
1158
+ function globToRegExp(glob) {
1159
+ const escaped = glob.split("**").map((part) => part.replace(/[.?+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*")).join(".*");
1160
+ return new RegExp(`^${escaped}$`);
1161
+ }
1162
+ function urlMatches(pattern, url) {
1163
+ if (!pattern) return true;
1168
1164
  try {
1169
- return parseLocatorChain(expr);
1165
+ return globToRegExp(pattern).test(url);
1170
1166
  } catch {
1171
- return null;
1167
+ return true;
1172
1168
  }
1173
1169
  }
1174
- function quote(value) {
1175
- const json = JSON.stringify(value);
1176
- return `'${json.slice(1, -1).replace(/\\"/g, '"').replace(/'/g, "\\'")}'`;
1170
+ function scoreTargetMatch(pattern, candidate) {
1171
+ if (!candidate) return pattern.role || pattern.testId || pattern.name ? 0 : 0.3;
1172
+ if (pattern.testId) {
1173
+ return candidate.testId && candidate.testId.toLowerCase() === pattern.testId.toLowerCase() ? 1 : 0;
1174
+ }
1175
+ const roleMatches = !pattern.role || candidate.role != null && candidate.role.toLowerCase() === pattern.role.toLowerCase();
1176
+ if (!roleMatches) return 0;
1177
+ if (pattern.name) {
1178
+ const needle = pattern.name.toLowerCase();
1179
+ const haystack = `${candidate.accessibleName ?? ""} ${candidate.text ?? ""}`.toLowerCase().trim();
1180
+ const nameMatches2 = haystack.length > 0 && (haystack.includes(needle) || needle.includes(haystack));
1181
+ if (!nameMatches2) return pattern.role ? 0.35 : 0;
1182
+ return pattern.role ? 0.95 : 0.6;
1183
+ }
1184
+ return pattern.role ? 0.7 : 0.3;
1177
1185
  }
1178
- function renderArg(arg) {
1179
- switch (arg.type) {
1180
- case "string":
1181
- return quote(arg.value);
1182
- case "regex":
1183
- return `/${arg.source}/${arg.flags}`;
1184
- case "number":
1185
- case "boolean":
1186
- return String(arg.value);
1187
- case "object":
1188
- return arg.entries.length === 0 ? "{}" : `{ ${arg.entries.map(([k, v]) => `${/^[A-Za-z_$][\w$]*$/.test(k) ? k : quote(k)}: ${renderArg(v)}`).join(", ")} }`;
1189
- case "chain":
1190
- return renderLocatorChain(arg.chain);
1186
+ function actionCompatible(patternAction, recordedAction) {
1187
+ if (patternAction === recordedAction) return true;
1188
+ return patternAction === "click" && recordedAction === "press" || patternAction === "press" && recordedAction === "click";
1189
+ }
1190
+ function stepPairScore(pattern, recorded) {
1191
+ if (!actionCompatible(pattern.action, recorded.action)) return 0;
1192
+ const base = scoreTargetMatch(pattern.target, recorded.target);
1193
+ return pattern.action === recorded.action ? base : base * 0.6;
1194
+ }
1195
+ function extractArgs(entry, window, pairs) {
1196
+ const args = {};
1197
+ const byPatternIndex = new Map(pairs.map(([windowIdx, patternIdx]) => [patternIdx, windowIdx]));
1198
+ for (const source of entry.paramSources) {
1199
+ const windowIdx = byPatternIndex.get(source.stepIndex);
1200
+ if (windowIdx == null) continue;
1201
+ const step = window[windowIdx];
1202
+ const value = source.from === "value" ? step.value : source.from === "testId" ? step.target?.testId ?? null : step.target?.text ?? null;
1203
+ if (value != null) args[paramArgKey(source)] = value;
1191
1204
  }
1205
+ return args;
1192
1206
  }
1193
- function renderLocatorCall(call) {
1194
- return `${call.method}(${call.args.map(renderArg).join(", ")})`;
1207
+ function sameElement(pattern, recorded) {
1208
+ if (pattern.testId || !pattern.name) return true;
1209
+ const haystack = `${recorded?.accessibleName ?? ""} ${recorded?.text ?? ""}`.toLowerCase().trim();
1210
+ const needle = pattern.name.toLowerCase();
1211
+ return haystack.length > 0 && (haystack.includes(needle) || needle.includes(haystack));
1195
1212
  }
1196
- function renderLocatorChain(chain) {
1197
- return chain.calls.map(renderLocatorCall).join(".");
1213
+ function contiguousAlignment(steps, startIndex, pattern) {
1214
+ if (startIndex + pattern.length > steps.length) return null;
1215
+ const pairs = [];
1216
+ let total = 0;
1217
+ for (let j = 0; j < pattern.length; j++) {
1218
+ const recorded = steps[startIndex + j];
1219
+ const score = stepPairScore(pattern[j], recorded);
1220
+ if (score <= 0 || !sameElement(pattern[j].target, recorded.target)) return null;
1221
+ total += score;
1222
+ pairs.push([j, j]);
1223
+ }
1224
+ return { total, pairs };
1198
1225
  }
1199
- function canonicalLocator(expr) {
1200
- const chain = tryParseLocatorChain(expr);
1201
- return chain ? renderLocatorChain(chain) : null;
1226
+ function matchFunctionAt(steps, startIndex, catalog) {
1227
+ const anchor = steps[startIndex];
1228
+ if (!anchor) return null;
1229
+ let best = null;
1230
+ for (const entry of catalog) {
1231
+ if (entry.steps.length === 0) continue;
1232
+ if (!urlMatches(entry.urlPattern, anchor.pageUrl)) continue;
1233
+ const aligned = contiguousAlignment(steps, startIndex, entry.steps);
1234
+ if (!aligned) continue;
1235
+ const { total, pairs } = aligned;
1236
+ const window = steps.slice(startIndex, startIndex + entry.steps.length);
1237
+ const score = total / entry.steps.length;
1238
+ const match = {
1239
+ entry,
1240
+ score,
1241
+ matchedIndices: pairs.map(([windowIdx]) => windowIdx + startIndex),
1242
+ complete: true,
1243
+ args: extractArgs(entry, window, pairs)
1244
+ };
1245
+ if (!best || match.score > best.score || match.score === best.score && entry.steps.length > best.entry.steps.length)
1246
+ best = match;
1247
+ }
1248
+ return best;
1202
1249
  }
1203
1250
 
1204
1251
  // ../core/src/locator-stability.ts
@@ -1332,16 +1379,16 @@ function checkFilterOptions(arg, out) {
1332
1379
  function splitTopLevel(text, separator) {
1333
1380
  const parts = [];
1334
1381
  let depth = 0;
1335
- let quote4 = null;
1382
+ let quote3 = null;
1336
1383
  let start = 0;
1337
1384
  for (let i = 0; i < text.length; i++) {
1338
1385
  const c = text[i];
1339
- if (quote4) {
1386
+ if (quote3) {
1340
1387
  if (c === "\\") i++;
1341
- else if (c === quote4) quote4 = null;
1388
+ else if (c === quote3) quote3 = null;
1342
1389
  continue;
1343
1390
  }
1344
- if (c === '"' || c === "'") quote4 = c;
1391
+ if (c === '"' || c === "'") quote3 = c;
1345
1392
  else if (c === "[" || c === "(") depth++;
1346
1393
  else if (c === "]" || c === ")") depth = Math.max(0, depth - 1);
1347
1394
  else if (depth === 0 && text.startsWith(separator, i)) {
@@ -1421,7 +1468,7 @@ function tokenizeComplex(selector) {
1421
1468
  const combinators = [];
1422
1469
  let current = "";
1423
1470
  let depth = 0;
1424
- let quote4 = null;
1471
+ let quote3 = null;
1425
1472
  let pending = null;
1426
1473
  const flush = () => {
1427
1474
  if (!current) return;
@@ -1433,14 +1480,14 @@ function tokenizeComplex(selector) {
1433
1480
  };
1434
1481
  for (let i = 0; i < selector.length; i++) {
1435
1482
  const c = selector[i];
1436
- if (quote4) {
1483
+ if (quote3) {
1437
1484
  current += c;
1438
1485
  if (c === "\\" && i + 1 < selector.length) current += selector[++i];
1439
- else if (c === quote4) quote4 = null;
1486
+ else if (c === quote3) quote3 = null;
1440
1487
  continue;
1441
1488
  }
1442
1489
  if (c === '"' || c === "'") {
1443
- quote4 = c;
1490
+ quote3 = c;
1444
1491
  current += c;
1445
1492
  } else if (c === "[" || c === "(") {
1446
1493
  depth++;
@@ -1482,16 +1529,16 @@ function checkCompound(compound, out) {
1482
1529
  };
1483
1530
  const readBalanced = (open, close) => {
1484
1531
  let depth = 0;
1485
- let quote4 = null;
1532
+ let quote3 = null;
1486
1533
  const start = i + 1;
1487
1534
  for (; i < compound.length; i++) {
1488
1535
  const c = compound[i];
1489
- if (quote4) {
1536
+ if (quote3) {
1490
1537
  if (c === "\\") i++;
1491
- else if (c === quote4) quote4 = null;
1538
+ else if (c === quote3) quote3 = null;
1492
1539
  continue;
1493
1540
  }
1494
- if (c === '"' || c === "'") quote4 = c;
1541
+ if (c === '"' || c === "'") quote3 = c;
1495
1542
  else if (c === open) depth++;
1496
1543
  else if (c === close && --depth === 0) {
1497
1544
  i++;
@@ -1631,17 +1678,7 @@ function pageKey(url) {
1631
1678
  }
1632
1679
 
1633
1680
  // ../core/src/codegen.ts
1634
- var QUOTE_ESCAPES = {
1635
- "\\": "\\\\",
1636
- "'": "\\'",
1637
- "\n": "\\n",
1638
- "\r": "\\r",
1639
- "\u2028": "\\u2028",
1640
- "\u2029": "\\u2029"
1641
- };
1642
- function quote2(s) {
1643
- return `'${s.replace(/[\\'\n\r\u2028\u2029]/g, (c) => QUOTE_ESCAPES[c])}'`;
1644
- }
1681
+ var quote = quoteJsString;
1645
1682
  var LINE_TERMINATORS = /[\n\r\u2028\u2029]/;
1646
1683
  function argIsSafe(arg) {
1647
1684
  switch (arg.type) {
@@ -1768,7 +1805,7 @@ function locatorForStep(step, index, ctx) {
1768
1805
  const chosen = chooseLocator(step.target, ctx.options);
1769
1806
  if (!chosen) {
1770
1807
  ctx.warnings.push({ step: index, code: "no-locator", message: "No locator was captured for this element." });
1771
- return `${ctx.page}.locator(${quote2("/* no locator captured */")})`;
1808
+ return `${ctx.page}.locator(${quote("/* no locator captured */")})`;
1772
1809
  }
1773
1810
  if (chosen.level === "brittle") {
1774
1811
  ctx.warnings.push({
@@ -1801,12 +1838,12 @@ function renderAssertStep(step, index, ctx) {
1801
1838
  });
1802
1839
  return [` // Step ${index + 1}: ${matcher} without an expected value.`];
1803
1840
  }
1804
- arg = quote2(matcher === "toHaveURL" ? urlForCode(assertion.expected, ctx) : assertion.expected);
1841
+ arg = matcher === "toHaveCount" ? String(Number.parseInt(assertion.expected, 10) || 0) : quote(matcher === "toHaveURL" ? urlForCode(assertion.expected, ctx) : assertion.expected);
1805
1842
  }
1806
1843
  const subject = matcher === "toHaveURL" ? ctx.page : locatorForStep(step, index, ctx);
1807
1844
  let line = ` await expect(${subject})${not}.${matcher}(${arg});`;
1808
1845
  if (assertion?.actual != null && assertion.actual !== assertion.expected) {
1809
- line += ` // recorded: ${quote2(assertion.actual)}`;
1846
+ line += ` // recorded: ${quote(assertion.actual)}`;
1810
1847
  }
1811
1848
  lines.push(line);
1812
1849
  return lines;
@@ -1814,12 +1851,27 @@ function renderAssertStep(step, index, ctx) {
1814
1851
  function fileNames(value) {
1815
1852
  return (value ?? "").split("\n").map((name) => name.trim().replace(/^.*[\\/]/, "")).filter(Boolean);
1816
1853
  }
1854
+ function renderManualStep(step, index, ctx) {
1855
+ const description = commentText(step.manual?.description ?? "") || "a step to do by hand";
1856
+ ctx.warnings.push({
1857
+ step: index,
1858
+ code: "manual-step",
1859
+ detail: description,
1860
+ message: `Step ${index + 1} cannot be written as code: ${description}`
1861
+ });
1862
+ const lines = [` // Manual step: ${description}`];
1863
+ const call = step.manual?.call ? commentText(step.manual.call) : "";
1864
+ const location = step.manual?.location ? commentText(step.manual.location) : "";
1865
+ if (call || location) lines.push(` // ${[call, location ? `at ${location}` : ""].filter(Boolean).join(" ")}`);
1866
+ return lines;
1867
+ }
1817
1868
  function renderRawStep(step, index, ctx) {
1818
1869
  const { page } = ctx;
1819
- if (step.action === "goto") return [` await ${page}.goto(${quote2(urlForCode(step.value ?? step.pageUrl, ctx))});`];
1870
+ if (step.action === "goto") return [` await ${page}.goto(${quote(urlForCode(step.value ?? step.pageUrl, ctx))});`];
1820
1871
  if (step.action === "assert" || step.action === "assertVisible") return renderAssertStep(step, index, ctx);
1872
+ if (step.action === "manual") return renderManualStep(step, index, ctx);
1821
1873
  if (step.action === "press" && !step.target) {
1822
- return [` await ${page}.keyboard.press(${quote2(step.value ?? "Enter")});`];
1874
+ return [` await ${page}.keyboard.press(${quote(step.value ?? "Enter")});`];
1823
1875
  }
1824
1876
  const loc = locatorForStep(step, index, ctx);
1825
1877
  switch (step.action) {
@@ -1840,23 +1892,23 @@ function renderRawStep(step, index, ctx) {
1840
1892
  detail: names.join(", "),
1841
1893
  message: `A file was chosen here (${names.join(", ")}); the spec reads it from the directory Playwright runs in.`
1842
1894
  });
1843
- const files = names.length === 1 ? quote2(names[0]) : `[${names.map(quote2).join(", ")}]`;
1895
+ const files = names.length === 1 ? quote(names[0]) : `[${names.map(quote).join(", ")}]`;
1844
1896
  return [` await ${loc}.setInputFiles(${files});`];
1845
1897
  }
1846
1898
  case "hover":
1847
1899
  return [` await ${loc}.hover();`];
1848
1900
  case "fill": {
1849
1901
  if (readsEnv(step, ctx)) return [` await ${loc}.fill(process.env.${envVarFor(step, index, ctx)} ?? '');`];
1850
- return [` await ${loc}.fill(${quote2(step.value ?? "")});`];
1902
+ return [` await ${loc}.fill(${quote(step.value ?? "")});`];
1851
1903
  }
1852
1904
  case "check":
1853
1905
  return [` await ${loc}.check();`];
1854
1906
  case "uncheck":
1855
1907
  return [` await ${loc}.uncheck();`];
1856
1908
  case "selectOption":
1857
- return [` await ${loc}.selectOption(${quote2(step.value ?? "")});`];
1909
+ return [` await ${loc}.selectOption(${quote(step.value ?? "")});`];
1858
1910
  case "press":
1859
- return [` await ${loc}.press(${quote2(step.value ?? "Enter")});`];
1911
+ return [` await ${loc}.press(${quote(step.value ?? "Enter")});`];
1860
1912
  }
1861
1913
  }
1862
1914
  var IDENTIFIER_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
@@ -1939,7 +1991,7 @@ function objectArgExpr(param, match, envArgs) {
1939
1991
  const env = envArgs.get(key);
1940
1992
  const raw = match.args[key];
1941
1993
  if (env == null && raw == null) return null;
1942
- return `${IDENTIFIER_RE.test(field) ? field : quote2(field)}: ${env ?? quote2(raw)}`;
1994
+ return `${IDENTIFIER_RE.test(field) ? field : quote(field)}: ${env ?? quote(raw)}`;
1943
1995
  }).filter((pair) => pair != null);
1944
1996
  return entries.length === 0 ? "{}" : `{ ${entries.join(", ")} }`;
1945
1997
  }
@@ -1967,7 +2019,7 @@ function argExpr(entry, match, envArgs) {
1967
2019
  if (raw == null) return p.type === "number" ? "0" : p.type === "boolean" ? "false" : "''";
1968
2020
  if (p.type === "number") return String(Number(raw) || 0);
1969
2021
  if (p.type === "boolean") return String(raw === "true" || raw === "1");
1970
- return quote2(raw);
2022
+ return quote(raw);
1971
2023
  }).join(", ");
1972
2024
  }
1973
2025
  function renderFunctionCall(match, page, envArgs) {
@@ -1987,11 +2039,11 @@ function renderImports(usedEntries, given) {
1987
2039
  const moduleKey = `${entry.module}#${entry.importName}`;
1988
2040
  if (!seenModules.has(moduleKey)) {
1989
2041
  seenModules.add(moduleKey);
1990
- lines.push(`import { ${entry.importName} } from ${quote2(entry.module)};`);
2042
+ lines.push(`import { ${entry.importName} } from ${quote(entry.module)};`);
1991
2043
  }
1992
2044
  } else if (!seenModules.has(entry.module + entry.name)) {
1993
2045
  seenModules.add(entry.module + entry.name);
1994
- lines.push(`import { ${entry.name} } from ${quote2(entry.module)};`);
2046
+ lines.push(`import { ${entry.name} } from ${quote(entry.module)};`);
1995
2047
  }
1996
2048
  }
1997
2049
  return lines;
@@ -2011,12 +2063,12 @@ function renderDetails(options) {
2011
2063
  const tags = (options.tags ?? []).map((t) => t.trim()).filter(Boolean);
2012
2064
  const annotations = (options.annotations ?? []).filter((a) => a.type.trim());
2013
2065
  const lines = [];
2014
- if (tags.length > 0) lines.push(` tag: [${tags.map((t) => quote2(t.startsWith("@") ? t : `@${t}`)).join(", ")}],`);
2066
+ if (tags.length > 0) lines.push(` tag: [${tags.map((t) => quote(t.startsWith("@") ? t : `@${t}`)).join(", ")}],`);
2015
2067
  if (annotations.length > 0) {
2016
2068
  lines.push(" annotation: [");
2017
2069
  for (const a of annotations) {
2018
- const description = a.description != null ? `, description: ${quote2(a.description)}` : "";
2019
- lines.push(` { type: ${quote2(a.type)}${description} },`);
2070
+ const description = a.description != null ? `, description: ${quote(a.description)}` : "";
2071
+ lines.push(` { type: ${quote(a.type)}${description} },`);
2020
2072
  }
2021
2073
  lines.push(" ],");
2022
2074
  }
@@ -2028,6 +2080,7 @@ function expectFailLine(options) {
2028
2080
  return reason ? ` test.fail(); // ${reason}` : " test.fail();";
2029
2081
  }
2030
2082
  function samePage(a, b) {
2083
+ if (!a || !b) return true;
2031
2084
  const ka = pageKey(a);
2032
2085
  const kb = pageKey(b);
2033
2086
  return ka === null || kb === null || ka === kb;
@@ -2049,7 +2102,7 @@ function wrapInPageSteps(lines, starts, origin) {
2049
2102
  const out = lines.slice(0, starts[0].line);
2050
2103
  starts.forEach((start, n) => {
2051
2104
  const end = starts[n + 1]?.line ?? lines.length;
2052
- out.push(` await test.step(${quote2(pageStepTitle(start.url, origin))}, async () => {`);
2105
+ out.push(` await test.step(${quote(pageStepTitle(start.url, origin))}, async () => {`);
2053
2106
  out.push(...lines.slice(start.line, end).map((line) => ` ${line}`));
2054
2107
  out.push(" });");
2055
2108
  });
@@ -2079,7 +2132,7 @@ function renderSpec(session, options = {}) {
2079
2132
  const pageStarts = [];
2080
2133
  const enterPageOf = (index) => {
2081
2134
  const url = steps[index]?.pageUrl;
2082
- if (options.testSteps !== "page" || url == null) return;
2135
+ if (options.testSteps !== "page" || !url) return;
2083
2136
  const last = pageStarts[pageStarts.length - 1];
2084
2137
  if (!last || !samePage(last.url, url)) pageStarts.push({ line: bodyLines.length, url });
2085
2138
  };
@@ -2091,7 +2144,7 @@ function renderSpec(session, options = {}) {
2091
2144
  const pattern = pageUrlPattern(next.pageUrl);
2092
2145
  enterPageOf(last + 1);
2093
2146
  if (pattern) bodyLines.push(` await expect(${page}).toHaveURL(${pattern});`);
2094
- const acts = next.action !== "assert" && next.action !== "assertVisible";
2147
+ const acts = next.action !== "assert" && next.action !== "assertVisible" && next.action !== "manual";
2095
2148
  const chosen = acts && next.target ? chooseLocator(next.target, options) : null;
2096
2149
  if (chosen) bodyLines.push(` await expect(${page}.${chosen.text}).toHaveCount(1);`);
2097
2150
  };
@@ -2135,7 +2188,7 @@ function renderSpec(session, options = {}) {
2135
2188
  const start = viewportAt.get(0);
2136
2189
  const opening = [
2137
2190
  ...start ? [viewportLine(start, page)] : [],
2138
- ...!sawGoto && session.startUrl ? [` await ${page}.goto(${quote2(urlForCode(session.startUrl, ctx))});`] : []
2191
+ ...!sawGoto && session.startUrl ? [` await ${page}.goto(${quote(urlForCode(session.startUrl, ctx))});`] : []
2139
2192
  ];
2140
2193
  bodyLines.unshift(...opening);
2141
2194
  const bodyOffset = opening.length;
@@ -2172,7 +2225,7 @@ function renderSpec(session, options = {}) {
2172
2225
  }
2173
2226
  const details = renderDetails(options);
2174
2227
  const fixture = [.../* @__PURE__ */ new Set([page.split(".")[0], ...fixtureReceivers])].join(", ");
2175
- const testOpening = details.length > 0 ? [`test(${quote2(title)}, {`, ...details, `}, async ({ ${fixture} }) => {`] : [`test(${quote2(title)}, async ({ ${fixture} }) => {`];
2228
+ const testOpening = details.length > 0 ? [`test(${quote(title)}, {`, ...details, `}, async ({ ${fixture} }) => {`] : [`test(${quote(title)}, async ({ ${fixture} }) => {`];
2176
2229
  if (options.format === "test") {
2177
2230
  const needs = needsLines("");
2178
2231
  return result([...needs, ...testOpening, ...testBody, `});`, ``], needs.length + testOpening.length);
@@ -2181,7 +2234,7 @@ function renderSpec(session, options = {}) {
2181
2234
  // Package name interpolated rather than adjoining the preceding keyword
2182
2235
  // directly, so this generated-code line does not itself look like a real
2183
2236
  // import to the boundary test in tests/boundary.test.ts.
2184
- `import { test, expect } from ${quote2(options.testImport?.trim() || "@playwright/test")};`,
2237
+ `import { test, expect } from ${quote(options.testImport?.trim() || "@playwright/test")};`,
2185
2238
  ...importLines,
2186
2239
  ``,
2187
2240
  ...testOpening,
@@ -2193,7 +2246,11 @@ function renderSpec(session, options = {}) {
2193
2246
  }
2194
2247
 
2195
2248
  // ../core/src/steps.ts
2196
- var STEPS_VERSION = 1;
2249
+ var STEPS_VERSION = 2;
2250
+ var VERSION_2_MATCHERS = /* @__PURE__ */ new Set(["toContainText", "toHaveCount", "toBeChecked"]);
2251
+ function stepsVersionFor(steps) {
2252
+ return steps.some((s) => s.action === "manual" || !!s.assertion && VERSION_2_MATCHERS.has(s.assertion.matcher)) ? 2 : 1;
2253
+ }
2197
2254
  var STEPS_LIMITS = {
2198
2255
  steps: 200,
2199
2256
  alternatives: 10,
@@ -2220,7 +2277,8 @@ var ACTIONS = /* @__PURE__ */ new Set([
2220
2277
  "setInputFiles",
2221
2278
  "dragTo",
2222
2279
  "assertVisible",
2223
- "assert"
2280
+ "assert",
2281
+ "manual"
2224
2282
  ]);
2225
2283
  function originOf2(url) {
2226
2284
  try {
@@ -2245,7 +2303,7 @@ function toAbsolute(url, origin) {
2245
2303
  function toStepsDocument(session, meta = {}) {
2246
2304
  const origin = originOf2(session.startUrl) ?? originOf2(session.steps[0]?.pageUrl ?? "");
2247
2305
  return {
2248
- v: STEPS_VERSION,
2306
+ v: stepsVersionFor(session.steps),
2249
2307
  title: meta.title ?? null,
2250
2308
  origin,
2251
2309
  recordedAt: session.startedAt,
@@ -2285,43 +2343,43 @@ var Checker = class {
2285
2343
  constructor() {
2286
2344
  this.errors = [];
2287
2345
  }
2288
- fail(path23, message) {
2289
- if (this.errors.length < 20) this.errors.push(`${path23}: ${message}`);
2346
+ fail(path24, message) {
2347
+ if (this.errors.length < 20) this.errors.push(`${path24}: ${message}`);
2290
2348
  }
2291
- text(v, path23, max, nullable) {
2349
+ text(v, path24, max, nullable) {
2292
2350
  if (v == null) {
2293
- if (!nullable) this.fail(path23, "is required");
2351
+ if (!nullable) this.fail(path24, "is required");
2294
2352
  return null;
2295
2353
  }
2296
2354
  if (typeof v !== "string") {
2297
- this.fail(path23, "must be a string");
2355
+ this.fail(path24, "must be a string");
2298
2356
  return null;
2299
2357
  }
2300
- if (v.length > max) this.fail(path23, `is longer than ${max} characters`);
2358
+ if (v.length > max) this.fail(path24, `is longer than ${max} characters`);
2301
2359
  return v;
2302
2360
  }
2303
- number(v, path23) {
2361
+ number(v, path24) {
2304
2362
  if (typeof v !== "number" || !Number.isFinite(v)) {
2305
- this.fail(path23, "must be a number");
2363
+ this.fail(path24, "must be a number");
2306
2364
  return 0;
2307
2365
  }
2308
2366
  return v;
2309
2367
  }
2310
2368
  };
2311
- function checkTarget(c, v, path23) {
2369
+ function checkTarget(c, v, path24) {
2312
2370
  if (v == null) return null;
2313
2371
  if (!isObject(v)) {
2314
- c.fail(path23, "must be an object");
2372
+ c.fail(path24, "must be an object");
2315
2373
  return null;
2316
2374
  }
2317
2375
  const alternativesIn = v.alternatives ?? [];
2318
2376
  const alternatives = [];
2319
- if (!Array.isArray(alternativesIn)) c.fail(`${path23}.alternatives`, "must be a list");
2377
+ if (!Array.isArray(alternativesIn)) c.fail(`${path24}.alternatives`, "must be a list");
2320
2378
  else if (alternativesIn.length > STEPS_LIMITS.alternatives)
2321
- c.fail(`${path23}.alternatives`, `holds more than ${STEPS_LIMITS.alternatives}`);
2379
+ c.fail(`${path24}.alternatives`, `holds more than ${STEPS_LIMITS.alternatives}`);
2322
2380
  else {
2323
2381
  alternativesIn.forEach((alt, i) => {
2324
- const at = `${path23}.alternatives[${i}]`;
2382
+ const at = `${path24}.alternatives[${i}]`;
2325
2383
  if (!isObject(alt)) return c.fail(at, "must be an object");
2326
2384
  const locator = c.text(alt.locator, `${at}.locator`, STEPS_LIMITS.textLength, false);
2327
2385
  const safe = locator ? safeLocator(locator) : null;
@@ -2335,31 +2393,47 @@ function checkTarget(c, v, path23) {
2335
2393
  });
2336
2394
  }
2337
2395
  return {
2338
- tagName: c.text(v.tagName, `${path23}.tagName`, 64, false) ?? "",
2339
- role: c.text(v.role, `${path23}.role`, 64, true),
2340
- accessibleName: c.text(v.accessibleName, `${path23}.accessibleName`, STEPS_LIMITS.textLength, true),
2341
- testId: c.text(v.testId, `${path23}.testId`, STEPS_LIMITS.textLength, true),
2342
- text: c.text(v.text, `${path23}.text`, STEPS_LIMITS.textLength, true),
2396
+ tagName: c.text(v.tagName, `${path24}.tagName`, 64, false) ?? "",
2397
+ role: c.text(v.role, `${path24}.role`, 64, true),
2398
+ accessibleName: c.text(v.accessibleName, `${path24}.accessibleName`, STEPS_LIMITS.textLength, true),
2399
+ testId: c.text(v.testId, `${path24}.testId`, STEPS_LIMITS.textLength, true),
2400
+ text: c.text(v.text, `${path24}.text`, STEPS_LIMITS.textLength, true),
2343
2401
  alternatives
2344
2402
  };
2345
2403
  }
2346
- function checkAssertion(c, v, path23) {
2404
+ function checkAssertion(c, v, path24) {
2347
2405
  if (!isObject(v)) {
2348
- c.fail(path23, "is required on an assert step");
2406
+ c.fail(path24, "is required on an assert step");
2349
2407
  return void 0;
2350
2408
  }
2351
2409
  const matcher = v.matcher;
2352
2410
  if (!ASSERTION_MATCHERS.includes(matcher)) {
2353
- c.fail(`${path23}.matcher`, `must be one of ${ASSERTION_MATCHERS.join(", ")}`);
2411
+ c.fail(`${path24}.matcher`, `must be one of ${ASSERTION_MATCHERS.join(", ")}`);
2354
2412
  return void 0;
2355
2413
  }
2356
- const expected = c.text(v.expected, `${path23}.expected`, STEPS_LIMITS.valueLength, !VALUE_MATCHERS.has(matcher));
2414
+ const expected = c.text(v.expected, `${path24}.expected`, STEPS_LIMITS.valueLength, !VALUE_MATCHERS.has(matcher));
2415
+ if (matcher === "toHaveCount" && expected != null && !/^\d{1,6}$/.test(expected)) {
2416
+ c.fail(`${path24}.expected`, "must be a whole number of elements on a toHaveCount assertion");
2417
+ }
2357
2418
  return {
2358
2419
  matcher,
2359
2420
  expected: VALUE_MATCHERS.has(matcher) ? expected : null,
2360
- actual: c.text(v.actual, `${path23}.actual`, STEPS_LIMITS.valueLength, true),
2421
+ actual: c.text(v.actual, `${path24}.actual`, STEPS_LIMITS.valueLength, true),
2361
2422
  negated: v.negated === true,
2362
- note: c.text(v.note, `${path23}.note`, STEPS_LIMITS.valueLength, true)
2423
+ note: c.text(v.note, `${path24}.note`, STEPS_LIMITS.valueLength, true)
2424
+ };
2425
+ }
2426
+ function checkManual(c, v, path24) {
2427
+ if (!isObject(v)) {
2428
+ c.fail(path24, "is required on a manual step");
2429
+ return void 0;
2430
+ }
2431
+ const description = c.text(v.description, `${path24}.description`, STEPS_LIMITS.valueLength, false);
2432
+ if (description != null && !description.trim()) c.fail(`${path24}.description`, "must not be empty");
2433
+ return {
2434
+ description: description ?? "",
2435
+ location: c.text(v.location, `${path24}.location`, STEPS_LIMITS.textLength, true),
2436
+ call: c.text(v.call, `${path24}.call`, STEPS_LIMITS.textLength, true)
2363
2437
  };
2364
2438
  }
2365
2439
  function checkViewports(c, v, stepCount) {
@@ -2400,40 +2474,43 @@ function checkViewports(c, v, stepCount) {
2400
2474
  });
2401
2475
  return out;
2402
2476
  }
2403
- function checkStep(c, v, path23) {
2477
+ function checkStep(c, v, path24) {
2404
2478
  if (!isObject(v)) {
2405
- c.fail(path23, "must be an object");
2479
+ c.fail(path24, "must be an object");
2406
2480
  return null;
2407
2481
  }
2408
2482
  const action = v.action;
2409
2483
  if (!ACTIONS.has(action)) {
2410
- c.fail(`${path23}.action`, "is not a known action");
2484
+ c.fail(`${path24}.action`, "is not a known action");
2411
2485
  return null;
2412
2486
  }
2413
2487
  const step = {
2414
2488
  action: action === "assertVisible" ? "assert" : action,
2415
- target: checkTarget(c, v.target, `${path23}.target`),
2416
- value: c.text(v.value, `${path23}.value`, STEPS_LIMITS.valueLength, true),
2489
+ target: checkTarget(c, v.target, `${path24}.target`),
2490
+ value: c.text(v.value, `${path24}.value`, STEPS_LIMITS.valueLength, true),
2417
2491
  redacted: v.redacted === true,
2418
- pageUrl: c.text(v.pageUrl, `${path23}.pageUrl`, STEPS_LIMITS.valueLength, false) ?? "",
2419
- timestamp: v.timestamp == null ? 0 : c.number(v.timestamp, `${path23}.timestamp`)
2492
+ pageUrl: c.text(v.pageUrl, `${path24}.pageUrl`, STEPS_LIMITS.valueLength, false) ?? "",
2493
+ timestamp: v.timestamp == null ? 0 : c.number(v.timestamp, `${path24}.timestamp`)
2420
2494
  };
2421
2495
  if (step.redacted) step.value = null;
2422
2496
  if (action === "hover" || action === "dblclick") {
2423
2497
  step.value = null;
2424
- if (!step.target) c.fail(`${path23}.target`, `is required on a ${action} step`);
2498
+ if (!step.target) c.fail(`${path24}.target`, `is required on a ${action} step`);
2425
2499
  }
2426
- if (action === "setInputFiles" && !step.target) c.fail(`${path23}.target`, "is required on a setInputFiles step");
2500
+ if (action === "setInputFiles" && !step.target) c.fail(`${path24}.target`, "is required on a setInputFiles step");
2427
2501
  if (action === "dragTo") {
2428
2502
  step.value = null;
2429
- if (!step.target) c.fail(`${path23}.target`, "is required on a dragTo step");
2430
- step.dropTarget = checkTarget(c, v.dropTarget, `${path23}.dropTarget`);
2431
- if (!step.dropTarget) c.fail(`${path23}.dropTarget`, "is required on a dragTo step");
2503
+ if (!step.target) c.fail(`${path24}.target`, "is required on a dragTo step");
2504
+ step.dropTarget = checkTarget(c, v.dropTarget, `${path24}.dropTarget`);
2505
+ if (!step.dropTarget) c.fail(`${path24}.dropTarget`, "is required on a dragTo step");
2432
2506
  }
2433
2507
  if (action === "assertVisible") {
2434
2508
  step.assertion = { matcher: "toBeVisible", expected: null, actual: null, negated: false, note: null };
2435
2509
  } else if (action === "assert") {
2436
- step.assertion = checkAssertion(c, v.assertion, `${path23}.assertion`);
2510
+ step.assertion = checkAssertion(c, v.assertion, `${path24}.assertion`);
2511
+ } else if (action === "manual") {
2512
+ step.value = null;
2513
+ step.manual = checkManual(c, v.manual, `${path24}.manual`);
2437
2514
  }
2438
2515
  return step;
2439
2516
  }
@@ -2449,8 +2526,8 @@ function parseSteps(input) {
2449
2526
  if (!isObject(value)) return { ok: false, errors: ["a steps document must be a JSON object"] };
2450
2527
  const c = new Checker();
2451
2528
  const legacy = value.v == null && typeof value.startUrl === "string";
2452
- if (!legacy && value.v !== STEPS_VERSION) {
2453
- return { ok: false, errors: [`v: this reads version ${STEPS_VERSION}, not ${JSON.stringify(value.v)}`] };
2529
+ if (!legacy && value.v !== 1 && value.v !== STEPS_VERSION) {
2530
+ return { ok: false, errors: [`v: this reads versions 1 to ${STEPS_VERSION}, not ${JSON.stringify(value.v)}`] };
2454
2531
  }
2455
2532
  if (!Array.isArray(value.steps)) return { ok: false, errors: ["steps: must be a list"] };
2456
2533
  if (value.steps.length > STEPS_LIMITS.steps) {
@@ -2473,7 +2550,7 @@ function parseSteps(input) {
2473
2550
  if (originText && !origin) c.fail("origin", "must be an http(s) origin");
2474
2551
  const viewports = checkViewports(c, value.viewports, steps.length);
2475
2552
  doc = {
2476
- v: STEPS_VERSION,
2553
+ v: stepsVersionFor(steps),
2477
2554
  title: c.text(value.title, "title", STEPS_LIMITS.textLength, true),
2478
2555
  origin,
2479
2556
  recordedAt: value.recordedAt == null ? 0 : c.number(value.recordedAt, "recordedAt"),
@@ -2732,8 +2809,8 @@ ${USAGE3}`);
2732
2809
  }
2733
2810
  } else {
2734
2811
  try {
2735
- if (fs5.statSync(args.file).size > MAX_STEPS_FILE_BYTES) throw new Error("is too large to be a steps file");
2736
- text = fs5.readFileSync(args.file, "utf-8");
2812
+ if (fs6.statSync(args.file).size > MAX_STEPS_FILE_BYTES) throw new Error("is too large to be a steps file");
2813
+ text = fs6.readFileSync(args.file, "utf-8");
2737
2814
  } catch (e) {
2738
2815
  const reason = e.code === "ENOENT" ? "does not exist" : e.message;
2739
2816
  console.error(`piwi codegen: ${args.file} ${reason}`);
@@ -2765,8 +2842,8 @@ ${USAGE3}`);
2765
2842
  return EXIT_OK3;
2766
2843
  }
2767
2844
  try {
2768
- fs5.mkdirSync(path8.dirname(args.out), { recursive: true });
2769
- fs5.writeFileSync(args.out, result.code, { flag: args.force ? "w" : "wx" });
2845
+ fs6.mkdirSync(path9.dirname(args.out), { recursive: true });
2846
+ fs6.writeFileSync(args.out, result.code, { flag: args.force ? "w" : "wx" });
2770
2847
  } catch (e) {
2771
2848
  const exists = e.code === "EEXIST";
2772
2849
  console.error(`piwi codegen: ${exists ? `${args.out} exists \u2014 pass --force to replace it` : e.message}`);
@@ -2780,7 +2857,7 @@ ${USAGE3}`);
2780
2857
  }
2781
2858
 
2782
2859
  // src/cli/coverage.ts
2783
- var path12 = __toESM(require("path"));
2860
+ var path14 = __toESM(require("path"));
2784
2861
 
2785
2862
  // ../core/src/codeowners.ts
2786
2863
  function patternParts(pattern) {
@@ -2818,10 +2895,10 @@ function pathPatternMatcher(pattern) {
2818
2895
  const { segments, floating } = patternParts(pattern);
2819
2896
  if (floating) {
2820
2897
  const only = segments[0];
2821
- return (path23) => path23.split("/").some((part) => segmentMatches(only, part));
2898
+ return (path24) => path24.split("/").some((part) => segmentMatches(only, part));
2822
2899
  }
2823
- return (path23) => {
2824
- const parts = path23.split("/");
2900
+ return (path24) => {
2901
+ const parts = path24.split("/");
2825
2902
  let at = new Uint8Array(parts.length);
2826
2903
  at[0] = 1;
2827
2904
  for (let i = 0; i < segments.length; i++) {
@@ -2887,8 +2964,8 @@ function parseCoverageScope(raw) {
2887
2964
  function compileCoverageScope(scope) {
2888
2965
  const include = scope.include.map(pathPatternMatcher);
2889
2966
  const exclude = scope.exclude.map(pathPatternMatcher);
2890
- return (path23) => {
2891
- const file = path23.replace(/\\/g, "/").replace(/^\.?\//, "");
2967
+ return (path24) => {
2968
+ const file = path24.replace(/\\/g, "/").replace(/^\.?\//, "");
2892
2969
  if (include.length > 0 && !include.some((matches) => matches(file))) return false;
2893
2970
  return !exclude.some((matches) => matches(file));
2894
2971
  };
@@ -2903,6 +2980,9 @@ function errorMessage(error) {
2903
2980
  return String(error);
2904
2981
  }
2905
2982
 
2983
+ // src/internal/support/test-paths.ts
2984
+ var path10 = __toESM(require("path"));
2985
+
2906
2986
  // src/internal/collect/metadata-collector.ts
2907
2987
  var DETACHED_HEAD = "HEAD";
2908
2988
  function resolveScmBranch(env, gitBranch) {
@@ -2934,14 +3014,14 @@ function normalizeRef(ref) {
2934
3014
 
2935
3015
  // src/internal/coverage-upload/git-inventory.ts
2936
3016
  var import_node_child_process5 = require("child_process");
2937
- var fs7 = __toESM(require("fs"));
2938
- var path10 = __toESM(require("path"));
3017
+ var fs8 = __toESM(require("fs"));
3018
+ var path12 = __toESM(require("path"));
2939
3019
 
2940
3020
  // src/internal/collect/blob-ids.ts
2941
3021
  var import_node_child_process4 = require("child_process");
2942
3022
  var crypto2 = __toESM(require("crypto"));
2943
- var fs6 = __toESM(require("fs"));
2944
- var path9 = __toESM(require("path"));
3023
+ var fs7 = __toESM(require("fs"));
3024
+ var path11 = __toESM(require("path"));
2945
3025
  function gitHashObjects(root, paths) {
2946
3026
  return new Promise((resolve10) => {
2947
3027
  const child = (0, import_node_child_process4.execFile)(
@@ -3008,7 +3088,7 @@ async function readInventory(root, warn = () => {
3008
3088
  const changed = [];
3009
3089
  for (const file of (await git(root, ["diff", "--name-only", "-z", "HEAD"]).catch(() => "")).split("\0")) {
3010
3090
  if (!file || !inventory.has(file)) continue;
3011
- if (fs7.existsSync(path10.join(root, file))) changed.push(file);
3091
+ if (fs8.existsSync(path12.join(root, file))) changed.push(file);
3012
3092
  else inventory.delete(file);
3013
3093
  }
3014
3094
  const ids = await hashPaths(root, changed);
@@ -3027,8 +3107,8 @@ async function readInventory(root, warn = () => {
3027
3107
  }
3028
3108
 
3029
3109
  // src/internal/coverage-upload/report-files.ts
3030
- var fs8 = __toESM(require("fs"));
3031
- var path11 = __toESM(require("path"));
3110
+ var fs9 = __toESM(require("fs"));
3111
+ var path13 = __toESM(require("path"));
3032
3112
 
3033
3113
  // ../core/src/coverage.ts
3034
3114
  var MAX_LINE = 1e6;
@@ -3107,7 +3187,7 @@ var FileLines = class {
3107
3187
  this.functions.set(key, { start, end: last, ran });
3108
3188
  }
3109
3189
  }
3110
- toFile(path23) {
3190
+ toFile(path24) {
3111
3191
  const executable = [];
3112
3192
  const lines = [];
3113
3193
  const push = (ranges, line) => {
@@ -3127,7 +3207,7 @@ var FileLines = class {
3127
3207
  if (didRun) ran.push([start, end]);
3128
3208
  }
3129
3209
  return {
3130
- path: path23,
3210
+ path: path24,
3131
3211
  executable,
3132
3212
  lines,
3133
3213
  functions: normalizeFunctionRanges(ran),
@@ -3139,16 +3219,16 @@ var ReportFiles = class {
3139
3219
  constructor() {
3140
3220
  this.byPath = /* @__PURE__ */ new Map();
3141
3221
  }
3142
- of(path23) {
3143
- let file = this.byPath.get(path23);
3222
+ of(path24) {
3223
+ let file = this.byPath.get(path24);
3144
3224
  if (!file) {
3145
3225
  file = new FileLines();
3146
- this.byPath.set(path23, file);
3226
+ this.byPath.set(path24, file);
3147
3227
  }
3148
3228
  return file;
3149
3229
  }
3150
3230
  toFiles() {
3151
- return [...this.byPath].map(([path23, file]) => file.toFile(path23));
3231
+ return [...this.byPath].map(([path24, file]) => file.toFile(path24));
3152
3232
  }
3153
3233
  };
3154
3234
  var XML_ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
@@ -3231,21 +3311,21 @@ var CoberturaReader = class {
3231
3311
  if (head.length < 9 && ("<!--".startsWith(head) || "<![CDATA[".startsWith(head))) return -1;
3232
3312
  if (head.startsWith("<!--")) return this.through(text, open, "-->", 4);
3233
3313
  if (head.startsWith("<![CDATA[")) return this.through(text, open, "]]>", 9);
3234
- let quote4 = this.quote;
3314
+ let quote3 = this.quote;
3235
3315
  for (let at = open + Math.max(1, this.scanned); at < text.length; at++) {
3236
3316
  const char = text.charCodeAt(at);
3237
- if (quote4 !== 0) {
3238
- if (char === quote4) quote4 = 0;
3317
+ if (quote3 !== 0) {
3318
+ if (char === quote3) quote3 = 0;
3239
3319
  } else if (char === GREATER_THAN) {
3240
3320
  this.scanned = 0;
3241
3321
  this.quote = 0;
3242
3322
  return at;
3243
3323
  } else if (char === DOUBLE_QUOTE || char === SINGLE_QUOTE) {
3244
- quote4 = char;
3324
+ quote3 = char;
3245
3325
  }
3246
3326
  }
3247
3327
  this.scanned = text.length - open;
3248
- this.quote = quote4;
3328
+ this.quote = quote3;
3249
3329
  return -1;
3250
3330
  }
3251
3331
  /** Where a comment or CDATA section starting at `open` ends: the last character of `end`; -1 if not yet read. */
@@ -3487,36 +3567,36 @@ function normalizeSlashPath(raw) {
3487
3567
  }
3488
3568
  return (drive || (absolute ? "/" : "")) + out.join("/");
3489
3569
  }
3490
- function isAbsolutePath(path23) {
3491
- return path23.startsWith("/") || /^[A-Za-z]:\//.test(path23);
3570
+ function isAbsolutePath(path24) {
3571
+ return path24.startsWith("/") || /^[A-Za-z]:\//.test(path24);
3492
3572
  }
3493
- function relativeTo(path23, root) {
3573
+ function relativeTo(path24, root) {
3494
3574
  const drive = /^[A-Za-z]:/.test(root);
3495
3575
  const base = root.endsWith("/") ? root : `${root}/`;
3496
- const head = path23.slice(0, base.length);
3576
+ const head = path24.slice(0, base.length);
3497
3577
  const inside = drive ? head.toLowerCase() === base.toLowerCase() : head === base;
3498
3578
  if (!inside) return null;
3499
- const relative8 = path23.slice(base.length);
3500
- return relative8 || null;
3579
+ const relative9 = path24.slice(base.length);
3580
+ return relative9 || null;
3501
3581
  }
3502
3582
  function reportPathCandidates(raw, { repoRoot, bases }) {
3503
3583
  const slash = (value) => value.replace(/\\/g, "/");
3504
3584
  const root = normalizeSlashPath(slash(repoRoot));
3505
- const path23 = slash(raw.trim());
3506
- if (!root || !path23 || path23.includes("\0")) return [];
3585
+ const path24 = slash(raw.trim());
3586
+ if (!root || !path24 || path24.includes("\0")) return [];
3507
3587
  const out = [];
3508
3588
  const add = (absolute) => {
3509
3589
  const normalized = normalizeSlashPath(absolute);
3510
3590
  if (!normalized) return;
3511
- const relative8 = normalized.startsWith("/_/") ? normalized.slice(3) : relativeTo(normalized, root);
3512
- if (relative8 && !out.includes(relative8)) out.push(relative8);
3591
+ const relative9 = normalized.startsWith("/_/") ? normalized.slice(3) : relativeTo(normalized, root);
3592
+ if (relative9 && !out.includes(relative9)) out.push(relative9);
3513
3593
  };
3514
- if (isAbsolutePath(path23)) {
3515
- add(path23);
3594
+ if (isAbsolutePath(path24)) {
3595
+ add(path24);
3516
3596
  } else {
3517
3597
  for (const base of bases) {
3518
3598
  const dir = slash(base);
3519
- if (isAbsolutePath(dir)) add(`${dir}/${path23}`);
3599
+ if (isAbsolutePath(dir)) add(`${dir}/${path24}`);
3520
3600
  }
3521
3601
  }
3522
3602
  return out;
@@ -3540,14 +3620,14 @@ function globSegmentRegExp(segment) {
3540
3620
  function expandPattern(pattern, cwd) {
3541
3621
  const normalized = pattern.replace(/\\/g, "/");
3542
3622
  if (!/[*?]/.test(normalized)) {
3543
- const file = path11.resolve(cwd, normalized);
3544
- return fs8.existsSync(file) && fs8.statSync(file).isFile() ? [file] : [];
3623
+ const file = path13.resolve(cwd, normalized);
3624
+ return fs9.existsSync(file) && fs9.statSync(file).isFile() ? [file] : [];
3545
3625
  }
3546
- const absolute = path11.isAbsolute(normalized) || /^[A-Za-z]:\//.test(normalized);
3626
+ const absolute = path13.isAbsolute(normalized) || /^[A-Za-z]:\//.test(normalized);
3547
3627
  const segments = normalized.split("/");
3548
3628
  const firstWild = segments.findIndex((s) => /[*?]/.test(s));
3549
3629
  const baseParts = segments.slice(0, firstWild);
3550
- const base = absolute ? baseParts.join("/") || "/" : path11.resolve(cwd, ...baseParts);
3630
+ const base = absolute ? baseParts.join("/") || "/" : path13.resolve(cwd, ...baseParts);
3551
3631
  const rest = segments.slice(firstWild).filter((s) => s !== "");
3552
3632
  const out = [];
3553
3633
  const walk = (dir, at) => {
@@ -3555,14 +3635,14 @@ function expandPattern(pattern, cwd) {
3555
3635
  const segment = rest[at];
3556
3636
  let entries;
3557
3637
  try {
3558
- entries = fs8.readdirSync(dir, { withFileTypes: true });
3638
+ entries = fs9.readdirSync(dir, { withFileTypes: true });
3559
3639
  } catch {
3560
3640
  return;
3561
3641
  }
3562
3642
  if (segment === "**") {
3563
3643
  walk(dir, at + 1);
3564
3644
  for (const entry of entries) {
3565
- if (entry.isDirectory() && !SKIPPED_DIRS.has(entry.name)) walk(path11.join(dir, entry.name), at);
3645
+ if (entry.isDirectory() && !SKIPPED_DIRS.has(entry.name)) walk(path13.join(dir, entry.name), at);
3566
3646
  }
3567
3647
  return;
3568
3648
  }
@@ -3570,7 +3650,7 @@ function expandPattern(pattern, cwd) {
3570
3650
  const last = at === rest.length - 1;
3571
3651
  for (const entry of entries) {
3572
3652
  if (!match.test(entry.name)) continue;
3573
- const full = path11.join(dir, entry.name);
3653
+ const full = path13.join(dir, entry.name);
3574
3654
  if (last && entry.isFile()) out.push(full);
3575
3655
  else if (!last && entry.isDirectory() && (!SKIPPED_DIRS.has(entry.name) || segment === entry.name)) {
3576
3656
  walk(full, at + 1);
@@ -3583,7 +3663,7 @@ function expandPattern(pattern, cwd) {
3583
3663
  async function readReport(file, format) {
3584
3664
  let reader = format ? new CoverageReportReader(format) : null;
3585
3665
  let head = "";
3586
- for await (const chunk of fs8.createReadStream(file, { encoding: "utf8", highWaterMark: 1024 * 1024 })) {
3666
+ for await (const chunk of fs9.createReadStream(file, { encoding: "utf8", highWaterMark: 1024 * 1024 })) {
3587
3667
  if (!reader) {
3588
3668
  head += chunk;
3589
3669
  if (head.length < 4096) continue;
@@ -3605,18 +3685,18 @@ async function readReport(file, format) {
3605
3685
  return reader.end();
3606
3686
  }
3607
3687
  function reportBases(report, reportFile, options) {
3608
- if (options.root) return [path11.resolve(options.cwd, options.root)];
3609
- const dir = path11.dirname(path11.resolve(reportFile));
3688
+ if (options.root) return [path13.resolve(options.cwd, options.root)];
3689
+ const dir = path13.dirname(path13.resolve(reportFile));
3610
3690
  const sources = report.sources.map(
3611
- (source) => path11.isAbsolute(source) || /^[A-Za-z]:[\\/]/.test(source) ? source : path11.resolve(dir, source)
3691
+ (source) => path13.isAbsolute(source) || /^[A-Za-z]:[\\/]/.test(source) ? source : path13.resolve(dir, source)
3612
3692
  );
3613
- return [...sources, options.cwd, dir, path11.dirname(dir), options.repoRoot];
3693
+ return [...sources, options.cwd, dir, path13.dirname(dir), options.repoRoot];
3614
3694
  }
3615
3695
  function resolveReport(report, reportFile, options, into) {
3616
3696
  const bases = reportBases(report, reportFile, options);
3617
3697
  for (const file of report.files) {
3618
3698
  const candidates = reportPathCandidates(file.path, { repoRoot: options.repoRoot, bases });
3619
- const match = candidates.find((candidate) => options.inventory.has(candidate)) ?? candidates.find((candidate) => fs8.existsSync(path11.join(options.repoRoot, candidate)));
3699
+ const match = candidates.find((candidate) => options.inventory.has(candidate)) ?? candidates.find((candidate) => fs9.existsSync(path13.join(options.repoRoot, candidate)));
3620
3700
  if (!match) {
3621
3701
  into.unmatched.push(file.path);
3622
3702
  continue;
@@ -3841,7 +3921,7 @@ async function uploadCoverage(args, env, cwd, warn = warnOnStderr) {
3841
3921
  commit: checkout.commit,
3842
3922
  branch,
3843
3923
  suite: args.suite,
3844
- reports: reports.map((report) => path12.relative(cwd, report) || report),
3924
+ reports: reports.map((report) => path14.relative(cwd, report) || report),
3845
3925
  files: files.length,
3846
3926
  unmatched: resolved.unmatched.length,
3847
3927
  outOfScope: resolved.outOfScope,
@@ -3910,10 +3990,10 @@ ${USAGE4}`);
3910
3990
  }
3911
3991
 
3912
3992
  // src/cli/flake.ts
3913
- var import_node_child_process7 = require("child_process");
3993
+ var import_node_child_process6 = require("child_process");
3914
3994
  var fs11 = __toESM(require("fs"));
3915
3995
  var os2 = __toESM(require("os"));
3916
- var path15 = __toESM(require("path"));
3996
+ var path16 = __toESM(require("path"));
3917
3997
 
3918
3998
  // ../core/src/error-parse.ts
3919
3999
  var ANSI_RE = new RegExp("\\u001B\\[[0-9;]*m", "g");
@@ -3932,6 +4012,7 @@ var CHAIN_LINK_METHODS = /* @__PURE__ */ new Set([
3932
4012
  var LEAF_METHODS = new Set(LOCATOR_BUILDER_METHODS);
3933
4013
  var SUBJECTS = "locator|page|frame|frameLocator|elementHandle|mouse|keyboard|touchscreen|browserContext|browser|apiRequestContext|request|response|route|download|dialog|fileChooser|expect|worker|jsHandle|clock|tracing|video";
3934
4014
  var CALL_RE = new RegExp(`\\b(${SUBJECTS})\\.(\\w+): `);
4015
+ var MAX_PARSED_ERROR_CHARS = 64 * 1024;
3935
4016
 
3936
4017
  // ../core/src/flake-plan.ts
3937
4018
  var FLAKE_PLAN_VERSION = 1;
@@ -4114,8 +4195,8 @@ function withinBudget(startedAt, now, budgetMs) {
4114
4195
  }
4115
4196
 
4116
4197
  // src/internal/support/connection.ts
4117
- var fs9 = __toESM(require("fs"));
4118
- var path13 = __toESM(require("path"));
4198
+ var fs10 = __toESM(require("fs"));
4199
+ var path15 = __toESM(require("path"));
4119
4200
 
4120
4201
  // ../core/src/dotenv.ts
4121
4202
  function parseDotEnv(text) {
@@ -4124,10 +4205,10 @@ function parseDotEnv(text) {
4124
4205
  const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(raw);
4125
4206
  if (!m) continue;
4126
4207
  let value = m[2];
4127
- const quote4 = value[0];
4128
- if ((quote4 === '"' || quote4 === "'") && value.length > 1 && value.endsWith(quote4)) {
4208
+ const quote3 = value[0];
4209
+ if ((quote3 === '"' || quote3 === "'") && value.length > 1 && value.endsWith(quote3)) {
4129
4210
  value = value.slice(1, -1);
4130
- if (quote4 === '"') value = value.replace(/\\n/g, "\n").replace(/\\"/g, '"');
4211
+ if (quote3 === '"') value = value.replace(/\\n/g, "\n").replace(/\\"/g, '"');
4131
4212
  } else {
4132
4213
  value = value.replace(/\s+#.*$/, "");
4133
4214
  }
@@ -4156,7 +4237,7 @@ function resolveCliConnection(flags, env, dirs) {
4156
4237
  let dotEnv = {};
4157
4238
  for (const dir of dirs) {
4158
4239
  try {
4159
- dotEnv = { ...parseDotEnv(fs9.readFileSync(path13.join(dir, ".env"), "utf-8")), ...dotEnv };
4240
+ dotEnv = { ...parseDotEnv(fs10.readFileSync(path15.join(dir, ".env"), "utf-8")), ...dotEnv };
4160
4241
  } catch {
4161
4242
  }
4162
4243
  }
@@ -4168,44 +4249,6 @@ function resolveCliConnection(flags, env, dirs) {
4168
4249
  });
4169
4250
  }
4170
4251
 
4171
- // src/internal/support/playwright-spawn.ts
4172
- var import_node_child_process6 = require("child_process");
4173
- var fs10 = __toESM(require("fs"));
4174
- var import_node_module2 = require("module");
4175
- var path14 = __toESM(require("path"));
4176
- function resolvePlaywrightCli2(cwd = process.cwd()) {
4177
- const require2 = (0, import_node_module2.createRequire)(path14.join(cwd, "noop.js"));
4178
- for (const id of ["playwright/cli", "@playwright/test/cli", "playwright/lib/cli/cli"]) {
4179
- try {
4180
- return require2.resolve(id);
4181
- } catch {
4182
- }
4183
- }
4184
- return null;
4185
- }
4186
- function spawnPlaywright(playwrightArgs2, env, options = {}) {
4187
- const output = options.output ?? "inherit";
4188
- let fd = output === "inherit" ? null : fs10.openSync(output.file, "a");
4189
- const stdio = fd === null ? "inherit" : ["ignore", fd, fd];
4190
- const cli = resolvePlaywrightCli2();
4191
- const child = cli ? (0, import_node_child_process6.spawn)(process.execPath, [cli, "test", ...playwrightArgs2], { stdio, env }) : (0, import_node_child_process6.spawn)(process.platform === "win32" ? "npx.cmd" : "npx", ["playwright", "test", ...playwrightArgs2], {
4192
- stdio,
4193
- env
4194
- });
4195
- return new Promise((resolve10) => {
4196
- const done = (code) => {
4197
- if (fd !== null) fs10.closeSync(fd);
4198
- fd = null;
4199
- resolve10(code);
4200
- };
4201
- child.on("error", (err) => {
4202
- console.error(`${options.command ?? "piwi"}: could not start Playwright \u2014 ${err.message}`);
4203
- done(2);
4204
- });
4205
- child.on("exit", (code) => done(code ?? 2));
4206
- });
4207
- }
4208
-
4209
4252
  // src/cli/flake.ts
4210
4253
  var USAGE5 = `
4211
4254
  piwi flake \u2014 make a flaky test fail on demand, then prove the fix
@@ -4351,7 +4394,7 @@ function parseFlakeArgs(argv) {
4351
4394
  }
4352
4395
  function gitHead() {
4353
4396
  try {
4354
- return (0, import_node_child_process7.execFileSync)("git", ["rev-parse", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
4397
+ return (0, import_node_child_process6.execFileSync)("git", ["rev-parse", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
4355
4398
  } catch {
4356
4399
  return null;
4357
4400
  }
@@ -4395,7 +4438,7 @@ function playwrightRunner(dir, log) {
4395
4438
  return {
4396
4439
  now: () => Date.now(),
4397
4440
  async runBatch(plan, arm, repeat, batch) {
4398
- const planFile = path15.join(dir, `${arm.id}.plan.json`);
4441
+ const planFile = path16.join(dir, `${arm.id}.plan.json`);
4399
4442
  if (!fs11.existsSync(planFile)) {
4400
4443
  fs11.writeFileSync(
4401
4444
  planFile,
@@ -4408,7 +4451,7 @@ function playwrightRunner(dir, log) {
4408
4451
  })
4409
4452
  );
4410
4453
  }
4411
- const resultsFile = path15.join(dir, `${arm.id}.${batch}.jsonl`);
4454
+ const resultsFile = path16.join(dir, `${arm.id}.${batch}.jsonl`);
4412
4455
  fs11.writeFileSync(resultsFile, "");
4413
4456
  const env = {
4414
4457
  ...process.env,
@@ -4687,8 +4730,8 @@ async function runFlake(argv, runner) {
4687
4730
  if (!args.json) {
4688
4731
  printHeader(plan, commit, estimate, args.budgetMs);
4689
4732
  }
4690
- const dir = fs11.mkdtempSync(path15.join(os2.tmpdir(), "piwi-flake-"));
4691
- const log = path15.join(dir, "playwright.log");
4733
+ const dir = fs11.mkdtempSync(path16.join(os2.tmpdir(), "piwi-flake-"));
4734
+ const log = path16.join(dir, "playwright.log");
4692
4735
  let session;
4693
4736
  try {
4694
4737
  session = await runSession(runner ?? playwrightRunner(dir, log), plan, args, (result) => {
@@ -4879,9 +4922,9 @@ function readCount(argv, name) {
4879
4922
  if (!Number.isFinite(n) || n < 0) throw new Error(`${name} expects a non-negative number, got "${raw}"`);
4880
4923
  return Math.floor(n);
4881
4924
  }
4882
- function readRunIdFromFile(path23) {
4925
+ function readRunIdFromFile(path24) {
4883
4926
  try {
4884
- const parsed = JSON.parse(fs12.readFileSync(path23, "utf-8"));
4927
+ const parsed = JSON.parse(fs12.readFileSync(path24, "utf-8"));
4885
4928
  const runId = Number(parsed.runId);
4886
4929
  return Number.isFinite(runId) && runId > 0 ? runId : null;
4887
4930
  } catch {
@@ -4973,8 +5016,7 @@ async function runGate(argv, env = process.env) {
4973
5016
 
4974
5017
  // src/cli/init.ts
4975
5018
  var fs15 = __toESM(require("fs"));
4976
- var path18 = __toESM(require("path"));
4977
- var import_node_child_process8 = require("child_process");
5019
+ var path19 = __toESM(require("path"));
4978
5020
 
4979
5021
  // src/cli/report.ts
4980
5022
  var STATUS_MARK = {
@@ -4992,7 +5034,7 @@ function formatStep(result) {
4992
5034
 
4993
5035
  // src/cli/detect.ts
4994
5036
  var fs13 = __toESM(require("fs"));
4995
- var path16 = __toESM(require("path"));
5037
+ var path17 = __toESM(require("path"));
4996
5038
  var CONFIG_NAMES = [
4997
5039
  "playwright.config.ts",
4998
5040
  "playwright.config.mts",
@@ -5010,7 +5052,7 @@ var LOCKFILES = [
5010
5052
  ];
5011
5053
  var REPORTER_PACKAGE = "@piwitests/reporter";
5012
5054
  function readPackageJson(root) {
5013
- const pkgPath = path16.join(root, "package.json");
5055
+ const pkgPath = path17.join(root, "package.json");
5014
5056
  try {
5015
5057
  return { pkg: JSON.parse(fs13.readFileSync(pkgPath, "utf-8")), pkgPath };
5016
5058
  } catch {
@@ -5021,13 +5063,13 @@ function detectPackageManager(root, pkg) {
5021
5063
  const declared = pkg?.packageManager?.split("@")[0];
5022
5064
  if (declared === "pnpm" || declared === "yarn" || declared === "bun" || declared === "npm") return declared;
5023
5065
  for (const [file, manager] of LOCKFILES) {
5024
- if (fs13.existsSync(path16.join(root, file))) return manager;
5066
+ if (fs13.existsSync(path17.join(root, file))) return manager;
5025
5067
  }
5026
5068
  return "npm";
5027
5069
  }
5028
5070
  function findConfig(root) {
5029
5071
  for (const name of CONFIG_NAMES) {
5030
- const candidate = path16.join(root, name);
5072
+ const candidate = path17.join(root, name);
5031
5073
  if (fs13.existsSync(candidate)) return candidate;
5032
5074
  }
5033
5075
  return null;
@@ -5038,14 +5080,14 @@ function langOf(configPath) {
5038
5080
  }
5039
5081
  function suggestProjectName(root, pkg) {
5040
5082
  const fromPkg = pkg?.name?.replace(/^@[^/]+\//, "").trim();
5041
- return fromPkg || path16.basename(root) || "default-project";
5083
+ return fromPkg || path17.basename(root) || "default-project";
5042
5084
  }
5043
5085
  function hasReporter(pkg) {
5044
5086
  if (!pkg) return false;
5045
5087
  return Boolean(pkg.dependencies?.[REPORTER_PACKAGE] || pkg.devDependencies?.[REPORTER_PACKAGE]);
5046
5088
  }
5047
5089
  function detectProject(root) {
5048
- const absRoot = path16.resolve(root);
5090
+ const absRoot = path17.resolve(root);
5049
5091
  const { pkg, pkgPath } = readPackageJson(absRoot);
5050
5092
  const configPath = findConfig(absRoot);
5051
5093
  return {
@@ -5075,9 +5117,7 @@ function installCommand(manager) {
5075
5117
  // src/cli/configure.ts
5076
5118
  var REPORTER_IMPORT = "import { wrapConfig } from '@piwitests/reporter'";
5077
5119
  var MANUAL_HINT = "Could not find `export default defineConfig(...)`. Wrap your exported config by hand: import { wrapConfig } from '@piwitests/reporter', then export default wrapConfig(defineConfig({ ... }), { serverUrl, projectName }).";
5078
- function quote3(value) {
5079
- return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
5080
- }
5120
+ var quote2 = quoteJsString;
5081
5121
  function matchClosingParen(src, openIndex) {
5082
5122
  let depth = 0;
5083
5123
  let quoteChar = null;
@@ -5113,8 +5153,8 @@ function matchClosingParen(src, openIndex) {
5113
5153
  }
5114
5154
  function optionsLiteral(opts) {
5115
5155
  const lines = [];
5116
- if (opts.serverUrl) lines.push(` serverUrl: ${quote3(opts.serverUrl)},`);
5117
- lines.push(` projectName: ${quote3(opts.projectName)},`);
5156
+ if (opts.serverUrl) lines.push(` serverUrl: ${quote2(opts.serverUrl)},`);
5157
+ lines.push(` projectName: ${quote2(opts.projectName)},`);
5118
5158
  return `{
5119
5159
  ${lines.join("\n")}
5120
5160
  }`;
@@ -5188,7 +5228,7 @@ function ensureGitignoreEntry(existing, entry = ".env") {
5188
5228
 
5189
5229
  // src/cli/skills.ts
5190
5230
  var fs14 = __toESM(require("fs"));
5191
- var path17 = __toESM(require("path"));
5231
+ var path18 = __toESM(require("path"));
5192
5232
 
5193
5233
  // ../core/src/skill-stamp.ts
5194
5234
  var SKILL_VERSION_KEY = "piwi-version";
@@ -5251,16 +5291,16 @@ var WORKFLOW_SKILLS = [
5251
5291
  "fix-a-reported-bug"
5252
5292
  ];
5253
5293
  var ALL_SKILLS = [SETUP_SKILL, ...WORKFLOW_SKILLS];
5254
- var DEFAULT_SKILLS_DIR = path17.join(".claude", "skills");
5294
+ var DEFAULT_SKILLS_DIR = path18.join(".claude", "skills");
5255
5295
  function findTemplatesDir(fromDir) {
5256
5296
  let dir = fromDir;
5257
5297
  for (let i = 0; i < 6; i++) {
5258
- if (fs14.existsSync(path17.join(dir, "templates", "skills"))) return path17.join(dir, "templates");
5259
- const parent = path17.dirname(dir);
5298
+ if (fs14.existsSync(path18.join(dir, "templates", "skills"))) return path18.join(dir, "templates");
5299
+ const parent = path18.dirname(dir);
5260
5300
  if (parent === dir) break;
5261
5301
  dir = parent;
5262
5302
  }
5263
- return path17.resolve(fromDir, "..", "..", "templates");
5303
+ return path18.resolve(fromDir, "..", "..", "templates");
5264
5304
  }
5265
5305
  function readFrontMatter(markdown) {
5266
5306
  const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(markdown);
@@ -5274,14 +5314,14 @@ function readFrontMatter(markdown) {
5274
5314
  }
5275
5315
  function readTemplatesVersion(templatesDir) {
5276
5316
  try {
5277
- const pkg = JSON.parse(fs14.readFileSync(path17.join(templatesDir, "..", "package.json"), "utf-8"));
5317
+ const pkg = JSON.parse(fs14.readFileSync(path18.join(templatesDir, "..", "package.json"), "utf-8"));
5278
5318
  return typeof pkg.version === "string" ? pkg.version : "0.0.0";
5279
5319
  } catch {
5280
5320
  return "0.0.0";
5281
5321
  }
5282
5322
  }
5283
5323
  function templatePath(templatesDir, slug) {
5284
- return path17.join(templatesDir, "skills", slug, "SKILL.md");
5324
+ return path18.join(templatesDir, "skills", slug, "SKILL.md");
5285
5325
  }
5286
5326
  function listSkills(templatesDir, slugs = ALL_SKILLS) {
5287
5327
  const infos = [];
@@ -5298,8 +5338,8 @@ function installSkills(opts) {
5298
5338
  for (const slug of opts.slugs) {
5299
5339
  const step = `skill:${slug}`;
5300
5340
  const source = templatePath(opts.templatesDir, slug);
5301
- const relDest = path17.join(opts.skillsDir, slug, "SKILL.md");
5302
- const dest = path17.join(opts.root, relDest);
5341
+ const relDest = path18.join(opts.skillsDir, slug, "SKILL.md");
5342
+ const dest = path18.join(opts.root, relDest);
5303
5343
  if (!fs14.existsSync(source)) {
5304
5344
  results.push({ step, status: "error", detail: `no template found for "${slug}"` });
5305
5345
  continue;
@@ -5331,7 +5371,7 @@ function installSkills(opts) {
5331
5371
  }
5332
5372
  }
5333
5373
  if (!opts.dryRun) {
5334
- fs14.mkdirSync(path17.dirname(dest), { recursive: true });
5374
+ fs14.mkdirSync(path18.dirname(dest), { recursive: true });
5335
5375
  fs14.writeFileSync(dest, contents);
5336
5376
  }
5337
5377
  results.push({ step, file: relDest, status: exists ? "updated" : "created", detail });
@@ -5404,7 +5444,7 @@ function runSkills(argv, templatesDir, cwd = process.cwd()) {
5404
5444
  }
5405
5445
  const results = installSkills({
5406
5446
  templatesDir,
5407
- root: path17.resolve(readOption5(rest, "--cwd") ?? cwd),
5447
+ root: path18.resolve(readOption5(rest, "--cwd") ?? cwd),
5408
5448
  skillsDir: readOption5(rest, "--dir") ?? DEFAULT_SKILLS_DIR,
5409
5449
  slugs: requested.length ? requested : ALL_SKILLS,
5410
5450
  force: rest.includes("--force"),
@@ -5475,7 +5515,7 @@ function resolveSkillSlugs(raw) {
5475
5515
  return raw.split(",").map((slug) => slug.trim()).filter(Boolean);
5476
5516
  }
5477
5517
  function parseInitArgs(argv, env, cwd) {
5478
- const root = path18.resolve(readOption6(argv, "--cwd") ?? cwd);
5518
+ const root = path19.resolve(readOption6(argv, "--cwd") ?? cwd);
5479
5519
  const detected = detectProject(root);
5480
5520
  return {
5481
5521
  root,
@@ -5524,12 +5564,12 @@ function stepInstallReporter(project, opts) {
5524
5564
  if (opts.dryRun || !opts.install)
5525
5565
  return { step, status: "manual", detail: `Run \`${installCommand(project.packageManager)}\`` };
5526
5566
  const [command, args] = installArgv(project.packageManager);
5527
- const result = (0, import_node_child_process8.spawnSync)(command, args, { cwd: opts.root, stdio: "inherit" });
5528
- if (result.status === 0) return { step, status: "updated", detail: `Installed ${REPORTER_PACKAGE}` };
5567
+ const status = runPackageManager(command, args, opts.root);
5568
+ if (status === 0) return { step, status: "updated", detail: `Installed ${REPORTER_PACKAGE}` };
5529
5569
  return {
5530
5570
  step,
5531
5571
  status: "error",
5532
- detail: `\`${installCommand(project.packageManager)}\` failed (exit ${result.status ?? "unknown"}) \u2014 install it by hand`
5572
+ detail: `\`${installCommand(project.packageManager)}\` failed (exit ${status ?? "unknown"}) \u2014 install it by hand`
5533
5573
  };
5534
5574
  }
5535
5575
  function stepConfig(project, opts) {
@@ -5540,7 +5580,7 @@ function stepConfig(project, opts) {
5540
5580
  status: "manual",
5541
5581
  detail: "No playwright.config found \u2014 create one, then re-run `npx @piwitests/reporter init`"
5542
5582
  };
5543
- const rel = path18.relative(project.root, project.configPath) || path18.basename(project.configPath);
5583
+ const rel = path19.relative(project.root, project.configPath) || path19.basename(project.configPath);
5544
5584
  const edit = wrapPlaywrightConfig(readFileOr(project.configPath, ""), {
5545
5585
  serverUrl: opts.serverUrl,
5546
5586
  projectName: opts.projectName
@@ -5550,8 +5590,8 @@ function stepConfig(project, opts) {
5550
5590
  }
5551
5591
  function stepFixtures(project, opts) {
5552
5592
  const step = "fixtures";
5553
- const rel = path18.join("tests", project.configLang === "js" ? "fixtures.js" : "fixtures.ts");
5554
- const dest = path18.join(project.root, rel);
5593
+ const rel = path19.join("tests", project.configLang === "js" ? "fixtures.js" : "fixtures.ts");
5594
+ const dest = path19.join(project.root, rel);
5555
5595
  if (fs15.existsSync(dest)) {
5556
5596
  const current = readFileOr(dest, "");
5557
5597
  if (/piwiFixtures/.test(current))
@@ -5564,7 +5604,7 @@ function stepFixtures(project, opts) {
5564
5604
  };
5565
5605
  }
5566
5606
  if (!opts.dryRun) {
5567
- fs15.mkdirSync(path18.dirname(dest), { recursive: true });
5607
+ fs15.mkdirSync(path19.dirname(dest), { recursive: true });
5568
5608
  fs15.writeFileSync(dest, fixturesContents());
5569
5609
  }
5570
5610
  return {
@@ -5576,7 +5616,7 @@ function stepFixtures(project, opts) {
5576
5616
  }
5577
5617
  function stepEnv(project, opts) {
5578
5618
  const results = [];
5579
- const examplePath = path18.join(project.root, ".env.example");
5619
+ const examplePath = path19.join(project.root, ".env.example");
5580
5620
  const example = upsertEnvKeys(readFileOr(examplePath, ""), [
5581
5621
  ["PIWI_DASHBOARD_URL", opts.serverUrl],
5582
5622
  ["PIWI_API_KEY", ""]
@@ -5589,7 +5629,7 @@ function stepEnv(project, opts) {
5589
5629
  detail: example.added.length ? `Recorded ${example.added.join(", ")}` : "connection template already present"
5590
5630
  });
5591
5631
  if (opts.apiKey) {
5592
- const envPath = path18.join(project.root, ".env");
5632
+ const envPath = path19.join(project.root, ".env");
5593
5633
  const env = upsertEnvKeys(readFileOr(envPath, ""), [
5594
5634
  ["PIWI_DASHBOARD_URL", opts.serverUrl],
5595
5635
  ["PIWI_API_KEY", opts.apiKey]
@@ -5601,7 +5641,7 @@ function stepEnv(project, opts) {
5601
5641
  status: env.added.length ? "updated" : "already",
5602
5642
  detail: env.added.length ? `Wrote ${env.added.join(", ")} (keep .env out of git)` : "already set"
5603
5643
  });
5604
- const gitignorePath = path18.join(project.root, ".gitignore");
5644
+ const gitignorePath = path19.join(project.root, ".gitignore");
5605
5645
  const gitignore = ensureGitignoreEntry(readFileOr(gitignorePath, ""));
5606
5646
  if (gitignore.added && !opts.dryRun) fs15.writeFileSync(gitignorePath, gitignore.text);
5607
5647
  results.push({
@@ -5665,7 +5705,7 @@ async function runInit(argv, env = process.env, cwd = process.cwd()) {
5665
5705
  project: {
5666
5706
  root: project.root,
5667
5707
  packageManager: project.packageManager,
5668
- configPath: project.configPath ? path18.relative(project.root, project.configPath) : null,
5708
+ configPath: project.configPath ? path19.relative(project.root, project.configPath) : null,
5669
5709
  projectName: opts.projectName,
5670
5710
  serverUrl: opts.serverUrl
5671
5711
  },
@@ -5690,14 +5730,13 @@ Piwi setup${opts.dryRun ? " (dry run \u2014 nothing written)" : ""} for "${opts.
5690
5730
 
5691
5731
  // src/cli/select.ts
5692
5732
  var fs17 = __toESM(require("fs"));
5693
- var path20 = __toESM(require("path"));
5694
- var import_node_child_process9 = require("child_process");
5695
- var import_node_module4 = require("module");
5733
+ var path21 = __toESM(require("path"));
5734
+ var import_node_child_process7 = require("child_process");
5696
5735
 
5697
5736
  // src/cli/add-reporter.ts
5698
5737
  var fs16 = __toESM(require("fs"));
5699
- var path19 = __toESM(require("path"));
5700
- var import_node_module3 = require("module");
5738
+ var path20 = __toESM(require("path"));
5739
+ var import_node_module2 = require("module");
5701
5740
  var REPORTER_PACKAGE2 = "@piwitests/reporter";
5702
5741
  var ADD_REPORTER_FLAG = "--add-reporter";
5703
5742
  var ADD_REPORTER_MIN = { major: 1, minor: 63 };
@@ -5729,9 +5768,9 @@ function resolveConfigPath(cwd, playwrightArgs2) {
5729
5768
  const arg = playwrightArgs2[i];
5730
5769
  if (arg === "--config" || arg === "-c") {
5731
5770
  const next = playwrightArgs2[i + 1];
5732
- if (next) return path19.resolve(cwd, next);
5771
+ if (next) return path20.resolve(cwd, next);
5733
5772
  } else if (arg.startsWith("--config=")) {
5734
- return path19.resolve(cwd, arg.slice("--config=".length));
5773
+ return path20.resolve(cwd, arg.slice("--config=".length));
5735
5774
  }
5736
5775
  }
5737
5776
  return detectProject(cwd).configPath;
@@ -5746,7 +5785,7 @@ function readConfigSource(configPath) {
5746
5785
  }
5747
5786
  function readInstalledPlaywrightVersion(cwd) {
5748
5787
  try {
5749
- const require2 = (0, import_node_module3.createRequire)(path19.join(cwd, "noop.js"));
5788
+ const require2 = (0, import_node_module2.createRequire)(path20.join(cwd, "noop.js"));
5750
5789
  for (const id of ["@playwright/test/package.json", "playwright/package.json"]) {
5751
5790
  try {
5752
5791
  return require2(id).version ?? null;
@@ -5866,7 +5905,7 @@ function parseSelectArgs(argv, env) {
5866
5905
  extra
5867
5906
  };
5868
5907
  }
5869
- var CACHE_FILE = path20.join(".piwi", "selection-cache.json");
5908
+ var CACHE_FILE = path21.join(".piwi", "selection-cache.json");
5870
5909
  function cacheKey(args) {
5871
5910
  return `${args.project.toLowerCase()}:${args.key}:${args.format}:${args.budgetMs ?? 0}:${args.shard ?? ""}:${args.order ?? ""}`;
5872
5911
  }
@@ -5886,7 +5925,7 @@ function writeCache(args, resolution) {
5886
5925
  } catch {
5887
5926
  }
5888
5927
  store[cacheKey(args)] = resolution;
5889
- fs17.mkdirSync(path20.dirname(CACHE_FILE), { recursive: true });
5928
+ fs17.mkdirSync(path21.dirname(CACHE_FILE), { recursive: true });
5890
5929
  fs17.writeFileSync(CACHE_FILE, JSON.stringify(store, null, 2));
5891
5930
  } catch {
5892
5931
  }
@@ -5911,7 +5950,7 @@ function printWarnings(resolution) {
5911
5950
  for (const w of resolution.warnings) console.error(`piwi: warning [${w.code}] ${w.message}`);
5912
5951
  }
5913
5952
  function gitChangedFiles(base) {
5914
- const out = (0, import_node_child_process9.execFileSync)("git", ["diff", "--name-only", base], {
5953
+ const out = (0, import_node_child_process7.execFileSync)("git", ["diff", "--name-only", base], {
5915
5954
  encoding: "utf-8",
5916
5955
  stdio: ["ignore", "pipe", "ignore"]
5917
5956
  });
@@ -5983,34 +6022,10 @@ async function runSelect(argv, env = process.env) {
5983
6022
  else console.log(resolution.materialization.args.join(" "));
5984
6023
  return EXIT_OK6;
5985
6024
  }
5986
- function resolvePlaywrightCli3() {
5987
- const require2 = (0, import_node_module4.createRequire)(path20.join(process.cwd(), "noop.js"));
5988
- for (const id of ["playwright/cli", "@playwright/test/cli", "playwright/lib/cli/cli"]) {
5989
- try {
5990
- return require2.resolve(id);
5991
- } catch {
5992
- }
5993
- }
5994
- return null;
5995
- }
5996
- function spawnPlaywright2(pkgRunner, playwrightArgs2, env) {
5997
- const cli = resolvePlaywrightCli3();
5998
- const child = cli ? (0, import_node_child_process9.spawn)(process.execPath, [cli, "test", ...playwrightArgs2], { stdio: "inherit", env }) : (0, import_node_child_process9.spawn)(process.platform === "win32" ? `${pkgRunner}.cmd` : pkgRunner, ["playwright", "test", ...playwrightArgs2], {
5999
- stdio: "inherit",
6000
- env
6001
- });
6002
- return new Promise((resolve10) => {
6003
- child.on("error", (err) => {
6004
- console.error(`piwi run: could not start Playwright \u2014 ${err.message}`);
6005
- resolve10(EXIT_ERROR7);
6006
- });
6007
- child.on("exit", (code) => resolve10(code ?? EXIT_ERROR7));
6008
- });
6009
- }
6010
6025
  function spawnPlaywrightForRun(pkgRunner, playwrightArgs2, env) {
6011
6026
  const decision = computeAddReporterArgs(process.cwd(), playwrightArgs2);
6012
6027
  if (decision.log) console.error(decision.log);
6013
- return spawnPlaywright2(pkgRunner, [...decision.args, ...playwrightArgs2], env);
6028
+ return spawnPlaywright([...decision.args, ...playwrightArgs2], env, { runner: pkgRunner, command: "piwi run" });
6014
6029
  }
6015
6030
  function shardEnv(args) {
6016
6031
  return args.shard ? { [PIWI_SHARD_ENV]: args.shard } : {};
@@ -6118,15 +6133,15 @@ async function runRun(argv, env = process.env) {
6118
6133
 
6119
6134
  // src/cli/preflight.ts
6120
6135
  var fs18 = __toESM(require("fs"));
6121
- var path21 = __toESM(require("path"));
6122
- var import_node_child_process10 = require("child_process");
6136
+ var path22 = __toESM(require("path"));
6137
+ var import_node_child_process8 = require("child_process");
6123
6138
 
6124
6139
  // ../core/src/diff-anchors.ts
6125
6140
  var RENAME_FROM = `rename from `;
6126
6141
  var RENAME_TO = "rename to ";
6127
6142
  var HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
6128
- function stripPrefix(path23) {
6129
- const unquoted = path23.startsWith('"') && path23.endsWith('"') ? path23.slice(1, -1) : path23;
6143
+ function stripPrefix(path24) {
6144
+ const unquoted = path24.startsWith('"') && path24.endsWith('"') ? path24.slice(1, -1) : path24;
6130
6145
  return unquoted.replace(/^[ab]\//, "");
6131
6146
  }
6132
6147
  function parseUnifiedDiff(text) {
@@ -6236,8 +6251,8 @@ function pathGlobToRegExp(glob) {
6236
6251
  });
6237
6252
  return new RegExp(`^(?:${parts.join("|")})$`);
6238
6253
  }
6239
- function isTranslationFile(path23, globs = DEFAULT_TRANSLATION_GLOBS) {
6240
- const posix = path23.replace(/\\/g, "/");
6254
+ function isTranslationFile(path24, globs = DEFAULT_TRANSLATION_GLOBS) {
6255
+ const posix = path24.replace(/\\/g, "/");
6241
6256
  if (!TRANSLATION_EXTENSIONS.test(posix)) return false;
6242
6257
  return globs.some((g) => pathGlobToRegExp(g).test(posix));
6243
6258
  }
@@ -6248,7 +6263,7 @@ function keepable(value) {
6248
6263
  return new RegExp("\\p{L}", "u").test(v);
6249
6264
  }
6250
6265
  function readQuoted(text, start) {
6251
- const quote4 = text[start];
6266
+ const quote3 = text[start];
6252
6267
  let value = "";
6253
6268
  for (let i = start + 1; i < text.length; i++) {
6254
6269
  const ch = text[i];
@@ -6258,8 +6273,8 @@ function readQuoted(text, start) {
6258
6273
  i++;
6259
6274
  continue;
6260
6275
  }
6261
- if (quote4 === "`" && ch === "$" && text[i + 1] === "{") return null;
6262
- if (ch === quote4) return { value, end: i + 1 };
6276
+ if (quote3 === "`" && ch === "$" && text[i + 1] === "{") return null;
6277
+ if (ch === quote3) return { value, end: i + 1 };
6263
6278
  value += ch;
6264
6279
  }
6265
6280
  return null;
@@ -6377,13 +6392,13 @@ function unquoteYaml(raw) {
6377
6392
  function decodeXml2(text) {
6378
6393
  return text.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
6379
6394
  }
6380
- function parseTranslationFile(path23, content) {
6395
+ function parseTranslationFile(path24, content) {
6381
6396
  const lines = content.split(/\r?\n/);
6382
6397
  const out = [];
6383
- const ext = /\.([a-z]+)$/i.exec(path23)?.[1]?.toLowerCase();
6398
+ const ext = /\.([a-z]+)$/i.exec(path24)?.[1]?.toLowerCase();
6384
6399
  if (ext === "json") {
6385
6400
  const stack = [];
6386
- const path24 = (key) => [...stack.map((f) => f.key), key].join(".");
6401
+ const path25 = (key) => [...stack.map((f) => f.key), key].join(".");
6387
6402
  lines.forEach((text, i) => {
6388
6403
  const t = text.trim();
6389
6404
  const top = stack[stack.length - 1];
@@ -6392,14 +6407,14 @@ function parseTranslationFile(path23, content) {
6392
6407
  const key = JSON.parse(`"${pair[1]}"`);
6393
6408
  const rest = pair[2];
6394
6409
  const str = /^"((?:[^"\\]|\\.)*)"/.exec(rest);
6395
- if (str) out.push({ key: path24(key), value: JSON.parse(`"${str[1]}"`), line: i + 1 });
6410
+ if (str) out.push({ key: path25(key), value: JSON.parse(`"${str[1]}"`), line: i + 1 });
6396
6411
  else if (/^[[{]\s*$/.test(rest)) stack.push({ key, array: rest.startsWith("["), next: 0 });
6397
6412
  return;
6398
6413
  }
6399
6414
  if (top?.array) {
6400
6415
  const str = /^"((?:[^"\\]|\\.)*)"\s*,?$/.exec(t);
6401
6416
  if (str) {
6402
- out.push({ key: path24(top.next++), value: JSON.parse(`"${str[1]}"`), line: i + 1 });
6417
+ out.push({ key: path25(top.next++), value: JSON.parse(`"${str[1]}"`), line: i + 1 });
6403
6418
  return;
6404
6419
  }
6405
6420
  if (/^[[{]\s*$/.test(t)) {
@@ -6463,8 +6478,8 @@ function parseTranslationFile(path23, content) {
6463
6478
  }
6464
6479
  return out;
6465
6480
  }
6466
- function translationLineToken(path23, text, line) {
6467
- const ext = /\.([a-z]+)$/i.exec(path23)?.[1]?.toLowerCase();
6481
+ function translationLineToken(path24, text, line) {
6482
+ const ext = /\.([a-z]+)$/i.exec(path24)?.[1]?.toLowerCase();
6468
6483
  let key;
6469
6484
  let value;
6470
6485
  if (ext === "json") {
@@ -6501,9 +6516,9 @@ function translationLineToken(path23, text, line) {
6501
6516
  if (value === void 0 || !keepable(value)) return null;
6502
6517
  return { kind: "translation", group: `translation:${key ?? ""}`, value, key, line };
6503
6518
  }
6504
- function lineTokens(path23, text, line, side, attributes, options) {
6519
+ function lineTokens(path24, text, line, side, attributes, options) {
6505
6520
  const spans = [];
6506
- const markup = MARKUP_FILE.test(path23);
6521
+ const markup = MARKUP_FILE.test(path24);
6507
6522
  return [
6508
6523
  ...attributeTokens(text, attributes, line, spans),
6509
6524
  ...translationRefTokens(text, line, side, options.translations, spans),
@@ -6511,10 +6526,10 @@ function lineTokens(path23, text, line, side, attributes, options) {
6511
6526
  ...textTokens(text, line, markup)
6512
6527
  ];
6513
6528
  }
6514
- function keysByLine(path23, side, options) {
6515
- const content = options.readFile?.(path23, side);
6529
+ function keysByLine(path24, side, options) {
6530
+ const content = options.readFile?.(path24, side);
6516
6531
  if (content === void 0) return null;
6517
- return new Map(parseTranslationFile(path23, content).map((e) => [e.line, e.key]));
6532
+ return new Map(parseTranslationFile(path24, content).map((e) => [e.line, e.key]));
6518
6533
  }
6519
6534
  function tokenId(t) {
6520
6535
  return `${t.group}\0${t.value}`;
@@ -6712,9 +6727,9 @@ function cssSlots(selector, testIds, replace) {
6712
6727
  const attr = m[1];
6713
6728
  const names = testIds.includes(attr) ? testIds : attr === "id" || attr === "name" ? [attr] : null;
6714
6729
  if (!names) continue;
6715
- const quote4 = m[2] !== void 0 ? '"' : m[3] !== void 0 ? "'" : "";
6730
+ const quote3 = m[2] !== void 0 ? '"' : m[3] !== void 0 ? "'" : "";
6716
6731
  const value = m[2] ?? m[3] ?? m[4];
6717
- slot(value, [attr], (v) => `[${attr}=${quote4}${v}${quote4}]`, m.index, m[0].length);
6732
+ slot(value, [attr], (v) => `[${attr}=${quote3}${v}${quote3}]`, m.index, m[0].length);
6718
6733
  }
6719
6734
  return slots;
6720
6735
  }
@@ -6814,7 +6829,7 @@ function slotMatches(slot, text) {
6814
6829
  return textMatches(slot.value, slot.exact, text);
6815
6830
  }
6816
6831
  }
6817
- var filePath = (path23) => path23.replace(/\\/g, "/").replace(/^\.\//, "");
6832
+ var filePath = (path24) => path24.replace(/\\/g, "/").replace(/^\.\//, "");
6818
6833
  function sameFilePath(a, b) {
6819
6834
  const x = filePath(a);
6820
6835
  const y = filePath(b);
@@ -6908,16 +6923,16 @@ function confidenceOf(anchor, tests, reach) {
6908
6923
  }
6909
6924
 
6910
6925
  // ../core/src/locator-edit.ts
6911
- function escapeForQuote(value, quote4) {
6912
- let out = value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n");
6913
- out = out.split(quote4).join(`\\${quote4}`);
6914
- return quote4 === "`" ? out.replace(/\$\{/g, "\\${") : out;
6926
+ function escapeForQuote(value, quote3) {
6927
+ let out = value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
6928
+ out = out.split(quote3).join(`\\${quote3}`);
6929
+ return quote3 === "`" ? out.replace(/\$\{/g, "\\${") : out;
6915
6930
  }
6916
6931
  function buildLiteralEdit(sourceLine, oldValue, newValue) {
6917
6932
  if (!sourceLine || oldValue === newValue) return null;
6918
6933
  for (let i = 0; i < sourceLine.length; i++) {
6919
- const quote4 = sourceLine[i];
6920
- if (quote4 !== "'" && quote4 !== '"' && quote4 !== "`") continue;
6934
+ const quote3 = sourceLine[i];
6935
+ if (quote3 !== "'" && quote3 !== '"' && quote3 !== "`") continue;
6921
6936
  let value = "";
6922
6937
  let end = -1;
6923
6938
  for (let j = i + 1; j < sourceLine.length; j++) {
@@ -6928,8 +6943,8 @@ function buildLiteralEdit(sourceLine, oldValue, newValue) {
6928
6943
  j++;
6929
6944
  continue;
6930
6945
  }
6931
- if (quote4 === "`" && ch === "$" && sourceLine[j + 1] === "{") break;
6932
- if (ch === quote4) {
6946
+ if (quote3 === "`" && ch === "$" && sourceLine[j + 1] === "{") break;
6947
+ if (ch === quote3) {
6933
6948
  end = j;
6934
6949
  break;
6935
6950
  }
@@ -6937,7 +6952,7 @@ function buildLiteralEdit(sourceLine, oldValue, newValue) {
6937
6952
  }
6938
6953
  if (end === -1) continue;
6939
6954
  if (value === oldValue) {
6940
- const next = sourceLine.slice(0, i + 1) + escapeForQuote(newValue, quote4) + sourceLine.slice(end);
6955
+ const next = sourceLine.slice(0, i + 1) + escapeForQuote(newValue, quote3) + sourceLine.slice(end);
6941
6956
  return { old: sourceLine, new: next };
6942
6957
  }
6943
6958
  i = end;
@@ -7029,16 +7044,16 @@ function parsePreflightArgs(argv) {
7029
7044
  }
7030
7045
  var CONFIG_NAMES2 = ["playwright.config.ts", "playwright.config.js", "playwright.config.mjs", "playwright.config.cjs"];
7031
7046
  function findPlaywrightRoot(from) {
7032
- let dir = path21.resolve(from);
7047
+ let dir = path22.resolve(from);
7033
7048
  for (; ; ) {
7034
- if (CONFIG_NAMES2.some((name) => fs18.existsSync(path21.join(dir, name)))) return dir;
7035
- const parent = path21.dirname(dir);
7049
+ if (CONFIG_NAMES2.some((name) => fs18.existsSync(path22.join(dir, name)))) return dir;
7050
+ const parent = path22.dirname(dir);
7036
7051
  if (parent === dir) return null;
7037
7052
  dir = parent;
7038
7053
  }
7039
7054
  }
7040
7055
  function git2(cwd, args) {
7041
- return (0, import_node_child_process10.execFileSync)("git", args, {
7056
+ return (0, import_node_child_process8.execFileSync)("git", args, {
7042
7057
  cwd,
7043
7058
  encoding: "utf-8",
7044
7059
  stdio: ["ignore", "pipe", "pipe"],
@@ -7062,7 +7077,7 @@ function translationLookup(repoRoot, base, locale) {
7062
7077
  const build = (side) => {
7063
7078
  const map = /* @__PURE__ */ new Map();
7064
7079
  for (const file of files) {
7065
- const content = side === "new" ? readText(path21.join(repoRoot, file)) : tryGit(repoRoot, ["show", `${base}:${file}`]);
7080
+ const content = side === "new" ? readText(path22.join(repoRoot, file)) : tryGit(repoRoot, ["show", `${base}:${file}`]);
7066
7081
  if (content === void 0) continue;
7067
7082
  for (const entry of parseTranslationFile(file, content)) {
7068
7083
  if (!map.has(entry.key)) map.set(entry.key, entry.value);
@@ -7086,20 +7101,20 @@ function readText(file) {
7086
7101
  return void 0;
7087
7102
  }
7088
7103
  }
7089
- var CACHE_FILE2 = path21.join(".piwi", "locator-index.json");
7104
+ var CACHE_FILE2 = path22.join(".piwi", "locator-index.json");
7090
7105
  function cacheKey2(connection, branch) {
7091
7106
  return `${connection.serverUrl}|${connection.project}|${branch ?? ""}`;
7092
7107
  }
7093
7108
  function readCache2(root, key) {
7094
7109
  try {
7095
- const store = JSON.parse(fs18.readFileSync(path21.join(root, CACHE_FILE2), "utf-8"));
7110
+ const store = JSON.parse(fs18.readFileSync(path22.join(root, CACHE_FILE2), "utf-8"));
7096
7111
  return store[key] ?? null;
7097
7112
  } catch {
7098
7113
  return null;
7099
7114
  }
7100
7115
  }
7101
7116
  function writeCache2(root, key, index) {
7102
- const file = path21.join(root, CACHE_FILE2);
7117
+ const file = path22.join(root, CACHE_FILE2);
7103
7118
  try {
7104
7119
  let store = {};
7105
7120
  try {
@@ -7107,7 +7122,7 @@ function writeCache2(root, key, index) {
7107
7122
  } catch {
7108
7123
  }
7109
7124
  store[key] = { savedAt: (/* @__PURE__ */ new Date()).toISOString(), index };
7110
- fs18.mkdirSync(path21.dirname(file), { recursive: true });
7125
+ fs18.mkdirSync(path22.dirname(file), { recursive: true });
7111
7126
  fs18.writeFileSync(file, JSON.stringify(store));
7112
7127
  } catch {
7113
7128
  }
@@ -7124,7 +7139,7 @@ function planSites(b, testRoot) {
7124
7139
  const sites = [...new Set(b.uses.flatMap((u) => u.callSites))];
7125
7140
  return sites.map((callSite) => {
7126
7141
  if (!b.rewrite || !b.replacements?.length) return { callSite, action: "none" };
7127
- const file = path21.resolve(testRoot, callSiteFile(callSite));
7142
+ const file = path22.resolve(testRoot, callSiteFile(callSite));
7128
7143
  const lineNo = callSiteLine(callSite);
7129
7144
  const text = readText(file);
7130
7145
  if (text === void 0 || lineNo === null) return { callSite, action: "elsewhere" };
@@ -7140,8 +7155,8 @@ function planSites(b, testRoot) {
7140
7155
  }
7141
7156
  const holders = /* @__PURE__ */ new Set();
7142
7157
  for (const [before] of b.replacements) {
7143
- for (const quote4 of ["'", '"', "`"]) {
7144
- const out = tryGit(testRoot, ["grep", "-n", "--fixed-strings", `${quote4}${before}${quote4}`]);
7158
+ for (const quote3 of ["'", '"', "`"]) {
7159
+ const out = tryGit(testRoot, ["grep", "-n", "--fixed-strings", `${quote3}${before}${quote3}`]);
7145
7160
  for (const row of (out ?? "").split("\n")) {
7146
7161
  const m = /^(.+?):(\d+):/.exec(row);
7147
7162
  if (m) holders.add(`${m[1]}:${m[2]}`);
@@ -7288,7 +7303,7 @@ async function runPreflight(argv, env = process.env, cwd = process.cwd()) {
7288
7303
  console.error("piwi preflight: not inside a git repository");
7289
7304
  return EXIT_ERROR8;
7290
7305
  }
7291
- const testRoot = path21.resolve(cwd, args.testRoot ?? findPlaywrightRoot(cwd) ?? cwd);
7306
+ const testRoot = path22.resolve(cwd, args.testRoot ?? findPlaywrightRoot(cwd) ?? cwd);
7292
7307
  let files;
7293
7308
  try {
7294
7309
  files = parseUnifiedDiff(git2(repoRoot, ["diff", "--unified=0", "--no-color", "--no-ext-diff", args.base, "--"]));
@@ -7325,7 +7340,7 @@ async function runPreflight(argv, env = process.env, cwd = process.cwd()) {
7325
7340
  testIdAttributes: index.testIdAttributes ?? void 0,
7326
7341
  isTestFile: (file) => TEST_FILE.test(file),
7327
7342
  translations: translationLookup(repoRoot, args.base, args.locale),
7328
- readFile: (file, side) => side === "new" ? readText(path21.join(repoRoot, file)) : tryGit(repoRoot, ["show", `${args.base}:${file}`])
7343
+ readFile: (file, side) => side === "new" ? readText(path22.join(repoRoot, file)) : tryGit(repoRoot, ["show", `${args.base}:${file}`])
7329
7344
  });
7330
7345
  const found = predictLocatorBreaks(anchors, index, { reach: await loadReach(connection, projectId, args.branch) });
7331
7346
  const replacementsOf = /* @__PURE__ */ new Map();
@@ -7370,8 +7385,8 @@ async function runPreflight(argv, env = process.env, cwd = process.cwd()) {
7370
7385
  if (args.json) console.log(JSON.stringify(result, null, 2));
7371
7386
  else {
7372
7387
  const rel = (file) => {
7373
- const abs = path21.isAbsolute(file) ? file : path21.resolve(testRoot, file);
7374
- const r = path21.relative(cwd, abs).split(path21.sep).join("/");
7388
+ const abs = path22.isAbsolute(file) ? file : path22.resolve(testRoot, file);
7389
+ const r = path22.relative(cwd, abs).split(path22.sep).join("/");
7375
7390
  return r.startsWith("..") ? file : r || file;
7376
7391
  };
7377
7392
  console.log(renderPreflight(result, rel));
@@ -7403,7 +7418,7 @@ async function runPreflight(argv, env = process.env, cwd = process.cwd()) {
7403
7418
  // src/cli/probe.ts
7404
7419
  var fs19 = __toESM(require("fs"));
7405
7420
  var os3 = __toESM(require("os"));
7406
- var path22 = __toESM(require("path"));
7421
+ var path23 = __toESM(require("path"));
7407
7422
  var EXIT_OK8 = 0;
7408
7423
  var EXIT_ERROR9 = 2;
7409
7424
  var USAGE11 = `
@@ -7428,9 +7443,9 @@ function escapeRegex2(s) {
7428
7443
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7429
7444
  }
7430
7445
  async function runProbe(argv) {
7431
- const sep6 = argv.indexOf("--");
7432
- const own = sep6 < 0 ? argv : argv.slice(0, sep6);
7433
- const playwrightArgs2 = sep6 < 0 ? [] : argv.slice(sep6 + 1);
7446
+ const sep7 = argv.indexOf("--");
7447
+ const own = sep7 < 0 ? argv : argv.slice(0, sep7);
7448
+ const playwrightArgs2 = sep7 < 0 ? [] : argv.slice(sep7 + 1);
7434
7449
  if (own.includes("-h") || own.includes("--help")) {
7435
7450
  console.log(USAGE11);
7436
7451
  return EXIT_OK8;
@@ -7467,10 +7482,10 @@ async function runProbe(argv) {
7467
7482
  return EXIT_OK8;
7468
7483
  }
7469
7484
  console.error(`piwi probe: ${plan.items.length} pair(s) to probe (budget ${plan.budget}).`);
7470
- const dir = fs19.mkdtempSync(path22.join(os3.tmpdir(), "piwi-probe-"));
7471
- const planFile = path22.join(dir, "plan.json");
7472
- const resultsFile = path22.join(dir, "results.jsonl");
7473
- const runOutputFile = path22.join(dir, "run.json");
7485
+ const dir = fs19.mkdtempSync(path23.join(os3.tmpdir(), "piwi-probe-"));
7486
+ const planFile = path23.join(dir, "plan.json");
7487
+ const resultsFile = path23.join(dir, "results.jsonl");
7488
+ const runOutputFile = path23.join(dir, "run.json");
7474
7489
  fs19.writeFileSync(planFile, JSON.stringify(plan));
7475
7490
  fs19.writeFileSync(resultsFile, "");
7476
7491
  const grep = plan.items.map((i) => i.testTitle).filter(Boolean).map(escapeRegex2).join("|");