@piwitests/reporter 0.22.1 → 0.24.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
@@ -23,8 +23,391 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
23
23
  mod
24
24
  ));
25
25
 
26
- // src/cli/gate.ts
26
+ // src/cli/ai.ts
27
+ var path5 = __toESM(require("path"));
28
+ var import_node_child_process = require("child_process");
29
+
30
+ // src/internal/ai/check.ts
31
+ var fs2 = __toESM(require("fs"));
32
+ var path4 = __toESM(require("path"));
33
+
34
+ // src/internal/ai/artifact.ts
27
35
  var fs = __toESM(require("fs"));
36
+ var path2 = __toESM(require("path"));
37
+
38
+ // src/internal/capture/locator-healing.ts
39
+ var path = __toESM(require("path"));
40
+
41
+ // ../core/src/locator-methods.ts
42
+ var LOCATOR_BUILDER_METHODS = [
43
+ "getByRole",
44
+ "getByTestId",
45
+ "getByText",
46
+ "getByLabel",
47
+ "getByPlaceholder",
48
+ "getByAltText",
49
+ "getByTitle",
50
+ "locator"
51
+ ];
52
+
53
+ // src/internal/capture/locator-healing.ts
54
+ var LOCATOR_METHODS = [...LOCATOR_BUILDER_METHODS];
55
+ var ACTION_METHODS = [
56
+ "click",
57
+ "fill",
58
+ "check",
59
+ "uncheck",
60
+ "selectOption",
61
+ "dblclick",
62
+ "tap",
63
+ "hover",
64
+ "press",
65
+ "type",
66
+ "pressSequentially",
67
+ "clear",
68
+ "setInputFiles",
69
+ "dragTo",
70
+ "focus",
71
+ "blur",
72
+ "scrollIntoViewIfNeeded",
73
+ "dispatchEvent",
74
+ "selectText",
75
+ // Not an action, but a successful waitFor proves the element resolved — the
76
+ // closest capture hook available for assertion-style usage of a locator.
77
+ "waitFor"
78
+ ];
79
+ var LOCATOR_CREATING_CHAINS = new Set(LOCATOR_METHODS);
80
+
81
+ // src/internal/ai/artifact.ts
82
+ var ARTIFACT_VERSION = 1;
83
+ var LOCATOR_METHOD_SET = new Set(LOCATOR_METHODS);
84
+ var ACTION_METHOD_SET = new Set(ACTION_METHODS);
85
+ var POSTCONDITION_ASSERTS = /* @__PURE__ */ new Set([
86
+ "visible",
87
+ "hidden",
88
+ "attached",
89
+ "url"
90
+ ]);
91
+ function sortDeep(value) {
92
+ if (Array.isArray(value)) return value.map(sortDeep);
93
+ if (value !== null && typeof value === "object") {
94
+ const out = {};
95
+ for (const key of Object.keys(value).sort()) {
96
+ const v = value[key];
97
+ if (v !== void 0) out[key] = sortDeep(v);
98
+ }
99
+ return out;
100
+ }
101
+ return value;
102
+ }
103
+ function serializeEntry(entry) {
104
+ return `${JSON.stringify(sortDeep(entry), null, 2)}
105
+ `;
106
+ }
107
+ var ArtifactError = class extends Error {
108
+ };
109
+ function assert(condition, message) {
110
+ if (!condition) throw new ArtifactError(message);
111
+ }
112
+ function validateStructuredLocator(value, where) {
113
+ assert(value !== null && typeof value === "object", `${where}: locator must be an object`);
114
+ const loc = value;
115
+ assert(typeof loc.method === "string", `${where}: locator.method must be a string`);
116
+ assert(LOCATOR_METHOD_SET.has(loc.method), `${where}: locator method "${String(loc.method)}" is not allowlisted`);
117
+ assert(Array.isArray(loc.args), `${where}: locator.args must be an array`);
118
+ if (loc.chain !== void 0) {
119
+ assert(Array.isArray(loc.chain), `${where}: locator.chain must be an array`);
120
+ loc.chain.forEach((child, i) => validateStructuredLocator(child, `${where}.chain[${i}]`));
121
+ }
122
+ }
123
+ function validateRunStep(value, where) {
124
+ assert(value !== null && typeof value === "object", `${where}: step must be an object`);
125
+ const step = value;
126
+ validateStructuredLocator(step.locator, `${where}.locator`);
127
+ assert(typeof step.action === "string", `${where}: action must be a string`);
128
+ assert(ACTION_METHOD_SET.has(step.action), `${where}: action "${String(step.action)}" is not allowlisted`);
129
+ if (step.value !== void 0) assert(typeof step.value === "string", `${where}: value must be a string`);
130
+ if (step.optional !== void 0) assert(typeof step.optional === "boolean", `${where}: optional must be a boolean`);
131
+ if (step.waitForResponse !== void 0) {
132
+ assert(typeof step.waitForResponse === "string", `${where}: waitForResponse must be a string`);
133
+ }
134
+ }
135
+ function validatePostcondition(value) {
136
+ assert(value !== null && typeof value === "object", "postcondition must be an object");
137
+ const post = value;
138
+ assert(
139
+ POSTCONDITION_ASSERTS.has(post.assert),
140
+ `postcondition.assert "${String(post.assert)}" is not supported`
141
+ );
142
+ if (post.assert === "url") {
143
+ assert(typeof post.url === "string", "postcondition.url must be a string for a url assert");
144
+ } else {
145
+ validateStructuredLocator(post.locator, "postcondition.locator");
146
+ }
147
+ }
148
+ function parseEntry(text) {
149
+ let raw;
150
+ try {
151
+ raw = JSON.parse(text);
152
+ } catch (error) {
153
+ throw new ArtifactError(`entry is not valid JSON: ${error.message}`);
154
+ }
155
+ assert(raw !== null && typeof raw === "object", "entry must be an object");
156
+ const obj = raw;
157
+ assert(obj.version === ARTIFACT_VERSION, `unsupported entry version ${String(obj.version)}`);
158
+ assert(typeof obj.template === "string", "entry.template must be a string");
159
+ if (obj.kind === "locator") {
160
+ validateStructuredLocator(obj.locator, "entry.locator");
161
+ return obj;
162
+ }
163
+ if (obj.kind === "run") {
164
+ assert(Array.isArray(obj.steps), "entry.steps must be an array");
165
+ obj.steps.forEach((step, i) => validateRunStep(step, `entry.steps[${i}]`));
166
+ validatePostcondition(obj.postcondition);
167
+ return obj;
168
+ }
169
+ throw new ArtifactError(`unsupported entry kind "${String(obj.kind)}"`);
170
+ }
171
+
172
+ // src/internal/ai/keys.ts
173
+ var crypto = __toESM(require("crypto"));
174
+ var path3 = __toESM(require("path"));
175
+ var DEFAULT_AI_DIR = "__piwi__";
176
+ function normalizeTemplate(template) {
177
+ return template.trim().replace(/\s+/g, " ").toLowerCase();
178
+ }
179
+
180
+ // src/internal/ai/check.ts
181
+ function findEntryFiles(root, dir) {
182
+ const out = [];
183
+ const walk = (current) => {
184
+ let items;
185
+ try {
186
+ items = fs2.readdirSync(current, { withFileTypes: true });
187
+ } catch {
188
+ return;
189
+ }
190
+ for (const item of items) {
191
+ const full = path4.join(current, item.name);
192
+ if (item.isDirectory()) {
193
+ walk(full);
194
+ } else if (item.isFile() && item.name.endsWith(".json")) {
195
+ const specFileDir = path4.dirname(full);
196
+ const dirName = path4.basename(path4.dirname(specFileDir));
197
+ if (dirName !== dir) continue;
198
+ const specDir = path4.dirname(path4.dirname(specFileDir));
199
+ const specFile = path4.join(specDir, path4.basename(specFileDir));
200
+ out.push({ file: full, specFile, testSlug: item.name.split(".")[0] });
201
+ }
202
+ }
203
+ };
204
+ walk(root);
205
+ return out;
206
+ }
207
+ function readTextOr(file, fallback) {
208
+ try {
209
+ return fs2.readFileSync(file, "utf8");
210
+ } catch {
211
+ return fallback;
212
+ }
213
+ }
214
+ function checkAiTree(root, opts = {}) {
215
+ const dir = opts.dir ?? DEFAULT_AI_DIR;
216
+ const findings = [];
217
+ const rel = (file) => path4.relative(root, file).split(path4.sep).join("/");
218
+ const sourceCache = /* @__PURE__ */ new Map();
219
+ const duplicates = /* @__PURE__ */ new Map();
220
+ for (const found of findEntryFiles(root, dir)) {
221
+ const text = readTextOr(found.file, null);
222
+ if (text === null) continue;
223
+ let templateNormalized = null;
224
+ try {
225
+ const entry = parseEntry(text);
226
+ templateNormalized = normalizeTemplate(entry.template);
227
+ if (serializeEntry(entry) !== text) {
228
+ findings.push({
229
+ severity: "error",
230
+ kind: "non-canonical",
231
+ file: rel(found.file),
232
+ message: "file is not in canonical form \u2014 run `piwi ai prune --apply` or re-resolve to rewrite it"
233
+ });
234
+ }
235
+ if (!sourceCache.has(found.specFile)) sourceCache.set(found.specFile, readTextOr(found.specFile, null));
236
+ const source = sourceCache.get(found.specFile) ?? null;
237
+ if (source === null) {
238
+ findings.push({
239
+ severity: "error",
240
+ kind: "orphan",
241
+ file: rel(found.file),
242
+ message: `spec file is gone (${rel(found.specFile)}) \u2014 the entry is orphaned`
243
+ });
244
+ } else if (!source.includes(entry.template.trim())) {
245
+ findings.push({
246
+ severity: "error",
247
+ kind: "orphan",
248
+ file: rel(found.file),
249
+ message: `template "${entry.template}" no longer appears in ${rel(found.specFile)} \u2014 the entry is orphaned`
250
+ });
251
+ }
252
+ } catch (error) {
253
+ findings.push({
254
+ severity: "error",
255
+ kind: "invalid",
256
+ file: rel(found.file),
257
+ message: `not a valid entry: ${error.message}`
258
+ });
259
+ }
260
+ if (templateNormalized !== null) {
261
+ const key = `${found.specFile}::${found.testSlug}::${templateNormalized}`;
262
+ const list = duplicates.get(key) ?? [];
263
+ list.push(rel(found.file));
264
+ duplicates.set(key, list);
265
+ }
266
+ }
267
+ for (const files of duplicates.values()) {
268
+ if (files.length < 2) continue;
269
+ for (const file of files) {
270
+ findings.push({
271
+ severity: "warning",
272
+ kind: "duplicate-template",
273
+ file,
274
+ message: `duplicate template within the same test \u2014 consider distinct phrasings (${files.join(", ")})`
275
+ });
276
+ }
277
+ }
278
+ return findings.sort((a, b) => a.file < b.file ? -1 : a.file > b.file ? 1 : 0);
279
+ }
280
+ function hasBlockingFindings(findings) {
281
+ return findings.some((f) => f.severity === "error");
282
+ }
283
+
284
+ // src/cli/ai.ts
285
+ var EXIT_OK = 0;
286
+ var EXIT_ISSUES = 1;
287
+ var EXIT_ERROR = 2;
288
+ var USAGE = `
289
+ Usage: piwi ai <command> [options]
290
+
291
+ Commands:
292
+ check Scan committed AI-step entries for orphans, non-canonical files and
293
+ duplicate templates. Read-only; exits 1 when issues are found.
294
+ resolve Author missing entries by running the suite in resolve mode against
295
+ the configured authoring server (forces --workers=1).
296
+ prune Delete orphaned/dormant entries.
297
+
298
+ Options (check):
299
+ --dir <name> Entry directory name per spec (env PIWI_AI_DIR, default ${DEFAULT_AI_DIR})
300
+ --cwd <path> Root to scan (default: current directory)
301
+ --json Emit findings as JSON
302
+
303
+ Options (resolve):
304
+ --grep <re> Only author entries for matching tests
305
+ --project <name> Author under one Playwright project (a resolve profile)
306
+ --env K=V Extra env for the run (repeatable \u2014 flags/viewport profiles)
307
+ --update-ai Re-author entries that already exist
308
+ (needs PIWI_DASHBOARD_URL / PIWI_API_KEY for the server)
309
+
310
+ Exit codes:
311
+ 0 clean (or --help)
312
+ 1 hygiene issues found
313
+ 2 bad arguments / command unavailable
314
+ `.trim();
315
+ function readOption(argv, name) {
316
+ const withEquals = argv.find((arg) => arg.startsWith(`${name}=`));
317
+ if (withEquals) return withEquals.slice(name.length + 1);
318
+ const index = argv.indexOf(name);
319
+ if (index === -1) return void 0;
320
+ const value = argv[index + 1];
321
+ return value && !value.startsWith("--") ? value : void 0;
322
+ }
323
+ function readAll(argv, name) {
324
+ const values = [];
325
+ for (let i = 0; i < argv.length; i++) {
326
+ if (argv[i].startsWith(`${name}=`)) values.push(argv[i].slice(name.length + 1));
327
+ else if (argv[i] === name && argv[i + 1] && !argv[i + 1].startsWith("--")) values.push(argv[++i]);
328
+ }
329
+ return values;
330
+ }
331
+ function formatFinding(finding) {
332
+ const mark = finding.severity === "error" ? "\u2716" : "\u26A0";
333
+ return `${mark} ${finding.file}
334
+ ${finding.kind}: ${finding.message}`;
335
+ }
336
+ function runCheck(argv, env) {
337
+ const root = path5.resolve(readOption(argv, "--cwd") ?? process.cwd());
338
+ const dir = readOption(argv, "--dir") ?? env.PIWI_AI_DIR ?? DEFAULT_AI_DIR;
339
+ let findings;
340
+ try {
341
+ findings = checkAiTree(root, { dir });
342
+ } catch (error) {
343
+ console.error(`piwi ai check: ${error.message}`);
344
+ return EXIT_ERROR;
345
+ }
346
+ if (argv.includes("--json")) {
347
+ console.log(JSON.stringify({ findings }, null, 2));
348
+ } else if (findings.length === 0) {
349
+ console.log("piwi ai check: no issues found.");
350
+ } else {
351
+ for (const finding of findings) console.log(formatFinding(finding));
352
+ const errors = findings.filter((f) => f.severity === "error").length;
353
+ const warnings = findings.length - errors;
354
+ console.log(`
355
+ ${errors} error(s), ${warnings} warning(s).`);
356
+ }
357
+ return hasBlockingFindings(findings) ? EXIT_ISSUES : EXIT_OK;
358
+ }
359
+ function buildResolveInvocation(argv, env) {
360
+ const args = ["playwright", "test"];
361
+ const grep = readOption(argv, "--grep");
362
+ if (grep) args.push("--grep", grep);
363
+ const project = readOption(argv, "--project");
364
+ if (project) args.push("--project", project);
365
+ args.push("--workers=1");
366
+ const childEnv = { ...env, PIWI_AI: "resolve" };
367
+ if (argv.includes("--update-ai")) childEnv.PIWI_AI_UPDATE = "true";
368
+ for (const pair of readAll(argv, "--env")) {
369
+ const eq = pair.indexOf("=");
370
+ if (eq > 0) childEnv[pair.slice(0, eq)] = pair.slice(eq + 1);
371
+ }
372
+ return { command: "npx", args, env: childEnv };
373
+ }
374
+ function runResolve(argv, env) {
375
+ if (!env.PIWI_DASHBOARD_URL) {
376
+ console.error("piwi ai resolve: set PIWI_DASHBOARD_URL (the authoring server) before resolving.");
377
+ return EXIT_ERROR;
378
+ }
379
+ const invocation = buildResolveInvocation(argv, env);
380
+ const result = (0, import_node_child_process.spawnSync)(invocation.command, invocation.args, { stdio: "inherit", env: invocation.env });
381
+ if (result.error) {
382
+ console.error(`piwi ai resolve: ${result.error.message}`);
383
+ return EXIT_ERROR;
384
+ }
385
+ return result.status ?? EXIT_ERROR;
386
+ }
387
+ async function runAi(argv, env = process.env) {
388
+ const [sub, ...rest] = argv;
389
+ if (sub === void 0 || sub === "-h" || sub === "--help") {
390
+ console.log(USAGE);
391
+ return EXIT_OK;
392
+ }
393
+ switch (sub) {
394
+ case "check":
395
+ return runCheck(rest, env);
396
+ case "resolve":
397
+ return runResolve(rest, env);
398
+ case "prune":
399
+ console.error("piwi ai prune: three-tier cleanup is not available in this build yet.");
400
+ return EXIT_ERROR;
401
+ default:
402
+ console.error(`piwi ai: unknown command "${sub}"
403
+ `);
404
+ console.error(USAGE);
405
+ return EXIT_ERROR;
406
+ }
407
+ }
408
+
409
+ // src/cli/gate.ts
410
+ var fs3 = __toESM(require("fs"));
28
411
 
29
412
  // ../core/src/gate.ts
30
413
  function formatGateResult(result) {
@@ -44,10 +427,10 @@ function formatGateResult(result) {
44
427
  }
45
428
 
46
429
  // src/cli/gate.ts
47
- var EXIT_OK = 0;
430
+ var EXIT_OK2 = 0;
48
431
  var EXIT_VIOLATED = 1;
49
- var EXIT_ERROR = 2;
50
- var USAGE = `
432
+ var EXIT_ERROR2 = 2;
433
+ var USAGE2 = `
51
434
  piwi gate \u2014 fail a CI job on the dashboard's analysis of a run
52
435
 
53
436
  Usage:
@@ -77,7 +460,7 @@ Other:
77
460
 
78
461
  Exit codes: 0 satisfied, 1 violated, 2 could not evaluate.
79
462
  `.trim();
80
- function readOption(argv, name) {
463
+ function readOption2(argv, name) {
81
464
  const withEquals = argv.find((arg) => arg.startsWith(`${name}=`));
82
465
  if (withEquals) return withEquals.slice(name.length + 1);
83
466
  const index = argv.indexOf(name);
@@ -86,15 +469,15 @@ function readOption(argv, name) {
86
469
  return value && !value.startsWith("--") ? value : void 0;
87
470
  }
88
471
  function readCount(argv, name) {
89
- const raw = readOption(argv, name);
472
+ const raw = readOption2(argv, name);
90
473
  if (raw === void 0) return void 0;
91
474
  const n = Number(raw);
92
475
  if (!Number.isFinite(n) || n < 0) throw new Error(`${name} expects a non-negative number, got "${raw}"`);
93
476
  return Math.floor(n);
94
477
  }
95
- function readRunIdFromFile(path4) {
478
+ function readRunIdFromFile(path9) {
96
479
  try {
97
- const parsed = JSON.parse(fs.readFileSync(path4, "utf-8"));
480
+ const parsed = JSON.parse(fs3.readFileSync(path9, "utf-8"));
98
481
  const runId = Number(parsed.runId);
99
482
  return Number.isFinite(runId) && runId > 0 ? runId : null;
100
483
  } catch {
@@ -102,11 +485,11 @@ function readRunIdFromFile(path4) {
102
485
  }
103
486
  }
104
487
  function parseGateArgs(argv, env) {
105
- const serverUrl = (readOption(argv, "--server-url") ?? env.PIWI_DASHBOARD_URL ?? "").replace(/\/$/, "");
488
+ const serverUrl = (readOption2(argv, "--server-url") ?? env.PIWI_DASHBOARD_URL ?? "").replace(/\/$/, "");
106
489
  if (!serverUrl) throw new Error("No dashboard URL \u2014 pass --server-url or set PIWI_DASHBOARD_URL");
107
- let runId = Number(readOption(argv, "--run-id") ?? NaN);
490
+ let runId = Number(readOption2(argv, "--run-id") ?? NaN);
108
491
  if (!Number.isFinite(runId) || runId <= 0) {
109
- const candidate = readOption(argv, "--from-file") ?? env.PIWI_OUTPUT_FILE ?? "piwi-run.json";
492
+ const candidate = readOption2(argv, "--from-file") ?? env.PIWI_OUTPUT_FILE ?? "piwi-run.json";
110
493
  runId = readRunIdFromFile(candidate) ?? NaN;
111
494
  }
112
495
  if (!Number.isFinite(runId) || runId <= 0) {
@@ -114,7 +497,7 @@ function parseGateArgs(argv, env) {
114
497
  "No run to evaluate \u2014 pass --run-id, or set PIWI_OUTPUT_FILE so the reporter records the run it submitted"
115
498
  );
116
499
  }
117
- const requireTags = (readOption(argv, "--require-tag") ?? "").split(",").map((tag) => tag.trim().replace(/^@+/, "")).filter(Boolean);
500
+ const requireTags = (readOption2(argv, "--require-tag") ?? "").split(",").map((tag) => tag.trim().replace(/^@+/, "")).filter(Boolean);
118
501
  const policy = {
119
502
  requireTags,
120
503
  maxFailed: readCount(argv, "--max-failed"),
@@ -123,7 +506,7 @@ function parseGateArgs(argv, env) {
123
506
  maxQuarantined: readCount(argv, "--max-quarantined"),
124
507
  failOnNewCluster: argv.includes("--fail-on-new-cluster")
125
508
  };
126
- return { serverUrl, apiKey: readOption(argv, "--api-key") ?? env.PIWI_API_KEY ?? null, runId, policy };
509
+ return { serverUrl, apiKey: readOption2(argv, "--api-key") ?? env.PIWI_API_KEY ?? null, runId, policy };
127
510
  }
128
511
  async function requestGate(args) {
129
512
  const headers = { "Content-Type": "application/json" };
@@ -141,8 +524,8 @@ async function requestGate(args) {
141
524
  }
142
525
  async function runGate(argv, env = process.env) {
143
526
  if (argv.includes("-h") || argv.includes("--help")) {
144
- console.log(USAGE);
145
- return EXIT_OK;
527
+ console.log(USAGE2);
528
+ return EXIT_OK2;
146
529
  }
147
530
  let args;
148
531
  try {
@@ -150,28 +533,28 @@ async function runGate(argv, env = process.env) {
150
533
  } catch (e) {
151
534
  console.error(`piwi gate: ${e.message}
152
535
  `);
153
- console.error(USAGE);
154
- return EXIT_ERROR;
536
+ console.error(USAGE2);
537
+ return EXIT_ERROR2;
155
538
  }
156
539
  let result;
157
540
  try {
158
541
  result = await requestGate(args);
159
542
  } catch (e) {
160
543
  console.error(`piwi gate: ${e.message}`);
161
- return EXIT_ERROR;
544
+ return EXIT_ERROR2;
162
545
  }
163
546
  if (argv.includes("--json")) {
164
547
  console.log(JSON.stringify(result, null, 2));
165
548
  } else {
166
549
  console.log(formatGateResult(result));
167
550
  }
168
- return result.passed ? EXIT_OK : EXIT_VIOLATED;
551
+ return result.passed ? EXIT_OK2 : EXIT_VIOLATED;
169
552
  }
170
553
 
171
554
  // src/cli/init.ts
172
- var fs4 = __toESM(require("fs"));
173
- var path3 = __toESM(require("path"));
174
- var import_node_child_process = require("child_process");
555
+ var fs6 = __toESM(require("fs"));
556
+ var path8 = __toESM(require("path"));
557
+ var import_node_child_process2 = require("child_process");
175
558
 
176
559
  // src/cli/report.ts
177
560
  var STATUS_MARK = {
@@ -188,8 +571,8 @@ function formatStep(result) {
188
571
  }
189
572
 
190
573
  // src/cli/detect.ts
191
- var fs2 = __toESM(require("fs"));
192
- var path = __toESM(require("path"));
574
+ var fs4 = __toESM(require("fs"));
575
+ var path6 = __toESM(require("path"));
193
576
  var CONFIG_NAMES = [
194
577
  "playwright.config.ts",
195
578
  "playwright.config.mts",
@@ -207,9 +590,9 @@ var LOCKFILES = [
207
590
  ];
208
591
  var REPORTER_PACKAGE = "@piwitests/reporter";
209
592
  function readPackageJson(root) {
210
- const pkgPath = path.join(root, "package.json");
593
+ const pkgPath = path6.join(root, "package.json");
211
594
  try {
212
- return { pkg: JSON.parse(fs2.readFileSync(pkgPath, "utf-8")), pkgPath };
595
+ return { pkg: JSON.parse(fs4.readFileSync(pkgPath, "utf-8")), pkgPath };
213
596
  } catch {
214
597
  return { pkg: null, pkgPath: null };
215
598
  }
@@ -218,14 +601,14 @@ function detectPackageManager(root, pkg) {
218
601
  const declared = pkg?.packageManager?.split("@")[0];
219
602
  if (declared === "pnpm" || declared === "yarn" || declared === "bun" || declared === "npm") return declared;
220
603
  for (const [file, manager] of LOCKFILES) {
221
- if (fs2.existsSync(path.join(root, file))) return manager;
604
+ if (fs4.existsSync(path6.join(root, file))) return manager;
222
605
  }
223
606
  return "npm";
224
607
  }
225
608
  function findConfig(root) {
226
609
  for (const name of CONFIG_NAMES) {
227
- const candidate = path.join(root, name);
228
- if (fs2.existsSync(candidate)) return candidate;
610
+ const candidate = path6.join(root, name);
611
+ if (fs4.existsSync(candidate)) return candidate;
229
612
  }
230
613
  return null;
231
614
  }
@@ -235,14 +618,14 @@ function langOf(configPath) {
235
618
  }
236
619
  function suggestProjectName(root, pkg) {
237
620
  const fromPkg = pkg?.name?.replace(/^@[^/]+\//, "").trim();
238
- return fromPkg || path.basename(root) || "default-project";
621
+ return fromPkg || path6.basename(root) || "default-project";
239
622
  }
240
623
  function hasReporter(pkg) {
241
624
  if (!pkg) return false;
242
625
  return Boolean(pkg.dependencies?.[REPORTER_PACKAGE] || pkg.devDependencies?.[REPORTER_PACKAGE]);
243
626
  }
244
627
  function detectProject(root) {
245
- const absRoot = path.resolve(root);
628
+ const absRoot = path6.resolve(root);
246
629
  const { pkg, pkgPath } = readPackageJson(absRoot);
247
630
  const configPath = findConfig(absRoot);
248
631
  return {
@@ -384,21 +767,21 @@ function ensureGitignoreEntry(existing, entry = ".env") {
384
767
  }
385
768
 
386
769
  // src/cli/skills.ts
387
- var fs3 = __toESM(require("fs"));
388
- var path2 = __toESM(require("path"));
770
+ var fs5 = __toESM(require("fs"));
771
+ var path7 = __toESM(require("path"));
389
772
  var SETUP_SKILL = "setup-piwi";
390
773
  var WORKFLOW_SKILLS = ["investigate-failure", "apply-locator-healing", "stabilize-flaky-tests"];
391
774
  var ALL_SKILLS = [SETUP_SKILL, ...WORKFLOW_SKILLS];
392
- var DEFAULT_SKILLS_DIR = path2.join(".claude", "skills");
775
+ var DEFAULT_SKILLS_DIR = path7.join(".claude", "skills");
393
776
  function findTemplatesDir(fromDir) {
394
777
  let dir = fromDir;
395
778
  for (let i = 0; i < 6; i++) {
396
- if (fs3.existsSync(path2.join(dir, "templates", "skills"))) return path2.join(dir, "templates");
397
- const parent = path2.dirname(dir);
779
+ if (fs5.existsSync(path7.join(dir, "templates", "skills"))) return path7.join(dir, "templates");
780
+ const parent = path7.dirname(dir);
398
781
  if (parent === dir) break;
399
782
  dir = parent;
400
783
  }
401
- return path2.resolve(fromDir, "..", "..", "templates");
784
+ return path7.resolve(fromDir, "..", "..", "templates");
402
785
  }
403
786
  function readFrontMatter(markdown) {
404
787
  const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(markdown);
@@ -411,14 +794,14 @@ function readFrontMatter(markdown) {
411
794
  return out;
412
795
  }
413
796
  function templatePath(templatesDir, slug) {
414
- return path2.join(templatesDir, "skills", slug, "SKILL.md");
797
+ return path7.join(templatesDir, "skills", slug, "SKILL.md");
415
798
  }
416
799
  function listSkills(templatesDir, slugs = ALL_SKILLS) {
417
800
  const infos = [];
418
801
  for (const slug of slugs) {
419
802
  const file = templatePath(templatesDir, slug);
420
- if (!fs3.existsSync(file)) continue;
421
- const front = readFrontMatter(fs3.readFileSync(file, "utf-8"));
803
+ if (!fs5.existsSync(file)) continue;
804
+ const front = readFrontMatter(fs5.readFileSync(file, "utf-8"));
422
805
  infos.push({ slug, name: front.name ?? slug, description: front.description ?? "" });
423
806
  }
424
807
  return infos;
@@ -428,16 +811,16 @@ function installSkills(opts) {
428
811
  for (const slug of opts.slugs) {
429
812
  const step = `skill:${slug}`;
430
813
  const source = templatePath(opts.templatesDir, slug);
431
- const relDest = path2.join(opts.skillsDir, slug, "SKILL.md");
432
- const dest = path2.join(opts.root, relDest);
433
- if (!fs3.existsSync(source)) {
814
+ const relDest = path7.join(opts.skillsDir, slug, "SKILL.md");
815
+ const dest = path7.join(opts.root, relDest);
816
+ if (!fs5.existsSync(source)) {
434
817
  results.push({ step, status: "error", detail: `no template found for "${slug}"` });
435
818
  continue;
436
819
  }
437
- const contents = fs3.readFileSync(source, "utf-8");
438
- const exists = fs3.existsSync(dest);
820
+ const contents = fs5.readFileSync(source, "utf-8");
821
+ const exists = fs5.existsSync(dest);
439
822
  if (exists && !opts.force) {
440
- const identical = fs3.readFileSync(dest, "utf-8") === contents;
823
+ const identical = fs5.readFileSync(dest, "utf-8") === contents;
441
824
  results.push({
442
825
  step,
443
826
  file: relDest,
@@ -447,14 +830,14 @@ function installSkills(opts) {
447
830
  continue;
448
831
  }
449
832
  if (!opts.dryRun) {
450
- fs3.mkdirSync(path2.dirname(dest), { recursive: true });
451
- fs3.writeFileSync(dest, contents);
833
+ fs5.mkdirSync(path7.dirname(dest), { recursive: true });
834
+ fs5.writeFileSync(dest, contents);
452
835
  }
453
836
  results.push({ step, file: relDest, status: exists ? "updated" : "created", detail: "installed skill" });
454
837
  }
455
838
  return results;
456
839
  }
457
- var USAGE2 = `
840
+ var USAGE3 = `
458
841
  piwi skills \u2014 install the Piwi agent skills into this project
459
842
 
460
843
  Usage:
@@ -474,7 +857,7 @@ Options for "add":
474
857
 
475
858
  Skills: ${ALL_SKILLS.join(", ")}
476
859
  `.trim();
477
- function readOption2(argv, name) {
860
+ function readOption3(argv, name) {
478
861
  const withEquals = argv.find((arg) => arg.startsWith(`${name}=`));
479
862
  if (withEquals) return withEquals.slice(name.length + 1);
480
863
  const index = argv.indexOf(name);
@@ -493,7 +876,7 @@ function positionalSlugs(argv) {
493
876
  function runSkills(argv, templatesDir, cwd = process.cwd()) {
494
877
  const [sub, ...rest] = argv;
495
878
  if (sub === void 0 || sub === "-h" || sub === "--help") {
496
- console.log(USAGE2);
879
+ console.log(USAGE3);
497
880
  return 0;
498
881
  }
499
882
  if (sub === "list") {
@@ -514,13 +897,13 @@ function runSkills(argv, templatesDir, cwd = process.cwd()) {
514
897
  if (unknown.length) {
515
898
  console.error(`piwi skills: unknown skill(s): ${unknown.join(", ")}
516
899
  `);
517
- console.error(USAGE2);
900
+ console.error(USAGE3);
518
901
  return 2;
519
902
  }
520
903
  const results = installSkills({
521
904
  templatesDir,
522
- root: path2.resolve(readOption2(rest, "--cwd") ?? cwd),
523
- skillsDir: readOption2(rest, "--dir") ?? DEFAULT_SKILLS_DIR,
905
+ root: path7.resolve(readOption3(rest, "--cwd") ?? cwd),
906
+ skillsDir: readOption3(rest, "--dir") ?? DEFAULT_SKILLS_DIR,
524
907
  slugs: requested.length ? requested : ALL_SKILLS,
525
908
  force: rest.includes("--force"),
526
909
  dryRun: rest.includes("--dry-run")
@@ -535,13 +918,13 @@ function runSkills(argv, templatesDir, cwd = process.cwd()) {
535
918
  }
536
919
  console.error(`piwi skills: unknown command "${sub}"
537
920
  `);
538
- console.error(USAGE2);
921
+ console.error(USAGE3);
539
922
  return 2;
540
923
  }
541
924
 
542
925
  // src/cli/init.ts
543
926
  var DEFAULT_SERVER_URL = "http://localhost:3000";
544
- var USAGE3 = `
927
+ var USAGE4 = `
545
928
  piwi init \u2014 wire a Playwright project up to a Piwi Dashboard
546
929
 
547
930
  Usage:
@@ -574,7 +957,7 @@ Options:
574
957
 
575
958
  Skills: ${ALL_SKILLS.join(", ")}
576
959
  `.trim();
577
- function readOption3(argv, name) {
960
+ function readOption4(argv, name) {
578
961
  const withEquals = argv.find((arg) => arg.startsWith(`${name}=`));
579
962
  if (withEquals) return withEquals.slice(name.length + 1);
580
963
  const index = argv.indexOf(name);
@@ -590,15 +973,15 @@ function resolveSkillSlugs(raw) {
590
973
  return raw.split(",").map((slug) => slug.trim()).filter(Boolean);
591
974
  }
592
975
  function parseInitArgs(argv, env, cwd) {
593
- const root = path3.resolve(readOption3(argv, "--cwd") ?? cwd);
976
+ const root = path8.resolve(readOption4(argv, "--cwd") ?? cwd);
594
977
  const detected = detectProject(root);
595
978
  return {
596
979
  root,
597
- serverUrl: (readOption3(argv, "--server-url") ?? env.PIWI_DASHBOARD_URL ?? DEFAULT_SERVER_URL).replace(/\/$/, ""),
598
- projectName: readOption3(argv, "--project") ?? detected.suggestedProjectName,
599
- apiKey: readOption3(argv, "--api-key") ?? env.PIWI_API_KEY ?? null,
600
- skillsDir: readOption3(argv, "--skills-dir") ?? DEFAULT_SKILLS_DIR,
601
- skillSlugs: resolveSkillSlugs(readOption3(argv, "--skills")),
980
+ serverUrl: (readOption4(argv, "--server-url") ?? env.PIWI_DASHBOARD_URL ?? DEFAULT_SERVER_URL).replace(/\/$/, ""),
981
+ projectName: readOption4(argv, "--project") ?? detected.suggestedProjectName,
982
+ apiKey: readOption4(argv, "--api-key") ?? env.PIWI_API_KEY ?? null,
983
+ skillsDir: readOption4(argv, "--skills-dir") ?? DEFAULT_SKILLS_DIR,
984
+ skillSlugs: resolveSkillSlugs(readOption4(argv, "--skills")),
602
985
  install: !argv.includes("--no-install"),
603
986
  force: argv.includes("--force"),
604
987
  dryRun: argv.includes("--dry-run"),
@@ -609,7 +992,7 @@ function parseInitArgs(argv, env, cwd) {
609
992
  }
610
993
  function readFileOr(file, fallback) {
611
994
  try {
612
- return fs4.readFileSync(file, "utf-8");
995
+ return fs6.readFileSync(file, "utf-8");
613
996
  } catch {
614
997
  return fallback;
615
998
  }
@@ -639,7 +1022,7 @@ function stepInstallReporter(project, opts) {
639
1022
  if (opts.dryRun || !opts.install)
640
1023
  return { step, status: "manual", detail: `Run \`${installCommand(project.packageManager)}\`` };
641
1024
  const [command, args] = installArgv(project.packageManager);
642
- const result = (0, import_node_child_process.spawnSync)(command, args, { cwd: opts.root, stdio: "inherit" });
1025
+ const result = (0, import_node_child_process2.spawnSync)(command, args, { cwd: opts.root, stdio: "inherit" });
643
1026
  if (result.status === 0) return { step, status: "updated", detail: `Installed ${REPORTER_PACKAGE}` };
644
1027
  return {
645
1028
  step,
@@ -655,19 +1038,19 @@ function stepConfig(project, opts) {
655
1038
  status: "manual",
656
1039
  detail: "No playwright.config found \u2014 create one, then re-run `npx @piwitests/reporter init`"
657
1040
  };
658
- const rel = path3.relative(project.root, project.configPath) || path3.basename(project.configPath);
1041
+ const rel = path8.relative(project.root, project.configPath) || path8.basename(project.configPath);
659
1042
  const edit = wrapPlaywrightConfig(readFileOr(project.configPath, ""), {
660
1043
  serverUrl: opts.serverUrl,
661
1044
  projectName: opts.projectName
662
1045
  });
663
- if (edit.status === "updated" && !opts.dryRun) fs4.writeFileSync(project.configPath, edit.text);
1046
+ if (edit.status === "updated" && !opts.dryRun) fs6.writeFileSync(project.configPath, edit.text);
664
1047
  return { step, file: rel, status: edit.status, detail: edit.detail };
665
1048
  }
666
1049
  function stepFixtures(project, opts) {
667
1050
  const step = "fixtures";
668
- const rel = path3.join("tests", project.configLang === "js" ? "fixtures.js" : "fixtures.ts");
669
- const dest = path3.join(project.root, rel);
670
- if (fs4.existsSync(dest)) {
1051
+ const rel = path8.join("tests", project.configLang === "js" ? "fixtures.js" : "fixtures.ts");
1052
+ const dest = path8.join(project.root, rel);
1053
+ if (fs6.existsSync(dest)) {
671
1054
  const current = readFileOr(dest, "");
672
1055
  if (/piwiFixtures/.test(current))
673
1056
  return { step, file: rel, status: "already", detail: "capture fixtures already set up" };
@@ -679,8 +1062,8 @@ function stepFixtures(project, opts) {
679
1062
  };
680
1063
  }
681
1064
  if (!opts.dryRun) {
682
- fs4.mkdirSync(path3.dirname(dest), { recursive: true });
683
- fs4.writeFileSync(dest, fixturesContents());
1065
+ fs6.mkdirSync(path8.dirname(dest), { recursive: true });
1066
+ fs6.writeFileSync(dest, fixturesContents());
684
1067
  }
685
1068
  return {
686
1069
  step,
@@ -691,12 +1074,12 @@ function stepFixtures(project, opts) {
691
1074
  }
692
1075
  function stepEnv(project, opts) {
693
1076
  const results = [];
694
- const examplePath = path3.join(project.root, ".env.example");
1077
+ const examplePath = path8.join(project.root, ".env.example");
695
1078
  const example = upsertEnvKeys(readFileOr(examplePath, ""), [
696
1079
  ["PIWI_DASHBOARD_URL", opts.serverUrl],
697
1080
  ["PIWI_API_KEY", ""]
698
1081
  ]);
699
- if (example.added.length && !opts.dryRun) fs4.writeFileSync(examplePath, example.text);
1082
+ if (example.added.length && !opts.dryRun) fs6.writeFileSync(examplePath, example.text);
700
1083
  results.push({
701
1084
  step: "env",
702
1085
  file: ".env.example",
@@ -704,21 +1087,21 @@ function stepEnv(project, opts) {
704
1087
  detail: example.added.length ? `Recorded ${example.added.join(", ")}` : "connection template already present"
705
1088
  });
706
1089
  if (opts.apiKey) {
707
- const envPath = path3.join(project.root, ".env");
1090
+ const envPath = path8.join(project.root, ".env");
708
1091
  const env = upsertEnvKeys(readFileOr(envPath, ""), [
709
1092
  ["PIWI_DASHBOARD_URL", opts.serverUrl],
710
1093
  ["PIWI_API_KEY", opts.apiKey]
711
1094
  ]);
712
- if (env.added.length && !opts.dryRun) fs4.writeFileSync(envPath, env.text);
1095
+ if (env.added.length && !opts.dryRun) fs6.writeFileSync(envPath, env.text);
713
1096
  results.push({
714
1097
  step: "env",
715
1098
  file: ".env",
716
1099
  status: env.added.length ? "updated" : "already",
717
1100
  detail: env.added.length ? `Wrote ${env.added.join(", ")} (keep .env out of git)` : "already set"
718
1101
  });
719
- const gitignorePath = path3.join(project.root, ".gitignore");
1102
+ const gitignorePath = path8.join(project.root, ".gitignore");
720
1103
  const gitignore = ensureGitignoreEntry(readFileOr(gitignorePath, ""));
721
- if (gitignore.added && !opts.dryRun) fs4.writeFileSync(gitignorePath, gitignore.text);
1104
+ if (gitignore.added && !opts.dryRun) fs6.writeFileSync(gitignorePath, gitignore.text);
722
1105
  results.push({
723
1106
  step: "gitignore",
724
1107
  file: ".gitignore",
@@ -747,7 +1130,7 @@ function nextSteps(opts, steps) {
747
1130
  }
748
1131
  async function runInit(argv, env = process.env, cwd = process.cwd()) {
749
1132
  if (argv.includes("-h") || argv.includes("--help")) {
750
- console.log(USAGE3);
1133
+ console.log(USAGE4);
751
1134
  return 0;
752
1135
  }
753
1136
  const opts = parseInitArgs(argv, env, cwd);
@@ -780,7 +1163,7 @@ async function runInit(argv, env = process.env, cwd = process.cwd()) {
780
1163
  project: {
781
1164
  root: project.root,
782
1165
  packageManager: project.packageManager,
783
- configPath: project.configPath ? path3.relative(project.root, project.configPath) : null,
1166
+ configPath: project.configPath ? path8.relative(project.root, project.configPath) : null,
784
1167
  projectName: opts.projectName,
785
1168
  serverUrl: opts.serverUrl
786
1169
  },
@@ -804,7 +1187,7 @@ Piwi setup${opts.dryRun ? " (dry run \u2014 nothing written)" : ""} for "${opts.
804
1187
  }
805
1188
 
806
1189
  // src/cli/index.ts
807
- var USAGE4 = `
1190
+ var USAGE5 = `
808
1191
  piwi \u2014 companion commands for the Piwi Dashboard reporter
809
1192
 
810
1193
  Usage:
@@ -814,6 +1197,7 @@ Commands:
814
1197
  init Wire a Playwright project up to a Piwi Dashboard
815
1198
  skills Install the Piwi agent skills into this project
816
1199
  gate Fail a CI job on the dashboard's analysis of a run
1200
+ ai Manage committed natural-language AI-step artifacts
817
1201
 
818
1202
  Run \`npx @piwitests/reporter <command> --help\` for a command's options.
819
1203
  (The published package is @piwitests/reporter; its command is piwi. Invoke it
@@ -828,15 +1212,17 @@ async function main() {
828
1212
  return runSkills(rest, findTemplatesDir(__dirname));
829
1213
  case "gate":
830
1214
  return runGate(rest);
1215
+ case "ai":
1216
+ return runAi(rest);
831
1217
  case void 0:
832
1218
  case "-h":
833
1219
  case "--help":
834
- console.log(USAGE4);
1220
+ console.log(USAGE5);
835
1221
  return 0;
836
1222
  default:
837
1223
  console.error(`piwi: unknown command "${command}"
838
1224
  `);
839
- console.error(USAGE4);
1225
+ console.error(USAGE5);
840
1226
  return 2;
841
1227
  }
842
1228
  }