@piwitests/reporter 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/dist/cli/index.js +653 -10
- package/dist/global-setup-module.js +26 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1738 -1403
- package/dist/internal/capture/capture-fixtures.d.ts +3 -56
- package/dist/internal/capture/capture-fixtures.js +1658 -1349
- package/dist/internal/capture/locator-healing.js +75 -27
- package/dist/internal/capture/pick-on-failure.d.ts +6 -125
- package/dist/internal/capture/pick-on-failure.js +1108 -940
- package/package.json +4 -2
- package/templates/skills/apply-locator-healing/SKILL.md +32 -0
- package/templates/skills/investigate-failure/SKILL.md +36 -0
- package/templates/skills/setup-piwi/SKILL.md +62 -0
- package/templates/skills/stabilize-flaky-tests/SKILL.md +36 -0
package/dist/cli/index.js
CHANGED
|
@@ -26,7 +26,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
26
26
|
// src/cli/gate.ts
|
|
27
27
|
var fs = __toESM(require("fs"));
|
|
28
28
|
|
|
29
|
-
// ../
|
|
29
|
+
// ../core/src/gate.ts
|
|
30
30
|
function formatGateResult(result) {
|
|
31
31
|
const { facts } = result;
|
|
32
32
|
const lines = [
|
|
@@ -51,7 +51,7 @@ var USAGE = `
|
|
|
51
51
|
piwi gate \u2014 fail a CI job on the dashboard's analysis of a run
|
|
52
52
|
|
|
53
53
|
Usage:
|
|
54
|
-
npx
|
|
54
|
+
npx @piwitests/reporter gate [options]
|
|
55
55
|
|
|
56
56
|
Where the run comes from (first match wins):
|
|
57
57
|
--run-id <id> Run id to evaluate
|
|
@@ -92,9 +92,9 @@ function readCount(argv, name) {
|
|
|
92
92
|
if (!Number.isFinite(n) || n < 0) throw new Error(`${name} expects a non-negative number, got "${raw}"`);
|
|
93
93
|
return Math.floor(n);
|
|
94
94
|
}
|
|
95
|
-
function readRunIdFromFile(
|
|
95
|
+
function readRunIdFromFile(path4) {
|
|
96
96
|
try {
|
|
97
|
-
const parsed = JSON.parse(fs.readFileSync(
|
|
97
|
+
const parsed = JSON.parse(fs.readFileSync(path4, "utf-8"));
|
|
98
98
|
const runId = Number(parsed.runId);
|
|
99
99
|
return Number.isFinite(runId) && runId > 0 ? runId : null;
|
|
100
100
|
} catch {
|
|
@@ -168,32 +168,675 @@ async function runGate(argv, env = process.env) {
|
|
|
168
168
|
return result.passed ? EXIT_OK : EXIT_VIOLATED;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
// src/cli/
|
|
171
|
+
// 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");
|
|
175
|
+
|
|
176
|
+
// src/cli/report.ts
|
|
177
|
+
var STATUS_MARK = {
|
|
178
|
+
created: "+",
|
|
179
|
+
updated: "~",
|
|
180
|
+
already: "=",
|
|
181
|
+
manual: "!",
|
|
182
|
+
skipped: "-",
|
|
183
|
+
error: "x"
|
|
184
|
+
};
|
|
185
|
+
function formatStep(result) {
|
|
186
|
+
const where = result.file ? ` ${result.file}` : "";
|
|
187
|
+
return ` ${STATUS_MARK[result.status]} [${result.status}] ${result.step}${where} \u2014 ${result.detail}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// src/cli/detect.ts
|
|
191
|
+
var fs2 = __toESM(require("fs"));
|
|
192
|
+
var path = __toESM(require("path"));
|
|
193
|
+
var CONFIG_NAMES = [
|
|
194
|
+
"playwright.config.ts",
|
|
195
|
+
"playwright.config.mts",
|
|
196
|
+
"playwright.config.cts",
|
|
197
|
+
"playwright.config.js",
|
|
198
|
+
"playwright.config.mjs",
|
|
199
|
+
"playwright.config.cjs"
|
|
200
|
+
];
|
|
201
|
+
var LOCKFILES = [
|
|
202
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
203
|
+
["yarn.lock", "yarn"],
|
|
204
|
+
["bun.lockb", "bun"],
|
|
205
|
+
["bun.lock", "bun"],
|
|
206
|
+
["package-lock.json", "npm"]
|
|
207
|
+
];
|
|
208
|
+
var REPORTER_PACKAGE = "@piwitests/reporter";
|
|
209
|
+
function readPackageJson(root) {
|
|
210
|
+
const pkgPath = path.join(root, "package.json");
|
|
211
|
+
try {
|
|
212
|
+
return { pkg: JSON.parse(fs2.readFileSync(pkgPath, "utf-8")), pkgPath };
|
|
213
|
+
} catch {
|
|
214
|
+
return { pkg: null, pkgPath: null };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function detectPackageManager(root, pkg) {
|
|
218
|
+
const declared = pkg?.packageManager?.split("@")[0];
|
|
219
|
+
if (declared === "pnpm" || declared === "yarn" || declared === "bun" || declared === "npm") return declared;
|
|
220
|
+
for (const [file, manager] of LOCKFILES) {
|
|
221
|
+
if (fs2.existsSync(path.join(root, file))) return manager;
|
|
222
|
+
}
|
|
223
|
+
return "npm";
|
|
224
|
+
}
|
|
225
|
+
function findConfig(root) {
|
|
226
|
+
for (const name of CONFIG_NAMES) {
|
|
227
|
+
const candidate = path.join(root, name);
|
|
228
|
+
if (fs2.existsSync(candidate)) return candidate;
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
function langOf(configPath) {
|
|
233
|
+
if (!configPath) return "ts";
|
|
234
|
+
return /\.[cm]?js$/.test(configPath) ? "js" : "ts";
|
|
235
|
+
}
|
|
236
|
+
function suggestProjectName(root, pkg) {
|
|
237
|
+
const fromPkg = pkg?.name?.replace(/^@[^/]+\//, "").trim();
|
|
238
|
+
return fromPkg || path.basename(root) || "default-project";
|
|
239
|
+
}
|
|
240
|
+
function hasReporter(pkg) {
|
|
241
|
+
if (!pkg) return false;
|
|
242
|
+
return Boolean(pkg.dependencies?.[REPORTER_PACKAGE] || pkg.devDependencies?.[REPORTER_PACKAGE]);
|
|
243
|
+
}
|
|
244
|
+
function detectProject(root) {
|
|
245
|
+
const absRoot = path.resolve(root);
|
|
246
|
+
const { pkg, pkgPath } = readPackageJson(absRoot);
|
|
247
|
+
const configPath = findConfig(absRoot);
|
|
248
|
+
return {
|
|
249
|
+
root: absRoot,
|
|
250
|
+
packageJson: pkg,
|
|
251
|
+
packageJsonPath: pkgPath,
|
|
252
|
+
packageManager: detectPackageManager(absRoot, pkg),
|
|
253
|
+
configPath,
|
|
254
|
+
configLang: langOf(configPath),
|
|
255
|
+
reporterInstalled: hasReporter(pkg),
|
|
256
|
+
suggestedProjectName: suggestProjectName(absRoot, pkg)
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
function installCommand(manager) {
|
|
260
|
+
switch (manager) {
|
|
261
|
+
case "pnpm":
|
|
262
|
+
return `pnpm add -D ${REPORTER_PACKAGE}`;
|
|
263
|
+
case "yarn":
|
|
264
|
+
return `yarn add -D ${REPORTER_PACKAGE}`;
|
|
265
|
+
case "bun":
|
|
266
|
+
return `bun add -d ${REPORTER_PACKAGE}`;
|
|
267
|
+
case "npm":
|
|
268
|
+
return `npm install --save-dev ${REPORTER_PACKAGE}`;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// src/cli/configure.ts
|
|
273
|
+
var REPORTER_IMPORT = "import { wrapConfig } from '@piwitests/reporter'";
|
|
274
|
+
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 }).";
|
|
275
|
+
function quote(value) {
|
|
276
|
+
return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
277
|
+
}
|
|
278
|
+
function matchClosingParen(src, openIndex) {
|
|
279
|
+
let depth = 0;
|
|
280
|
+
let quoteChar = null;
|
|
281
|
+
for (let i = openIndex; i < src.length; i++) {
|
|
282
|
+
const c = src[i];
|
|
283
|
+
if (quoteChar) {
|
|
284
|
+
if (c === quoteChar && src[i - 1] !== "\\") quoteChar = null;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (c === "/" && src[i + 1] === "/") {
|
|
288
|
+
const nl = src.indexOf("\n", i);
|
|
289
|
+
if (nl === -1) return -1;
|
|
290
|
+
i = nl;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (c === "/" && src[i + 1] === "*") {
|
|
294
|
+
const end = src.indexOf("*/", i + 2);
|
|
295
|
+
if (end === -1) return -1;
|
|
296
|
+
i = end + 1;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
300
|
+
quoteChar = c;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (c === "(") depth++;
|
|
304
|
+
else if (c === ")") {
|
|
305
|
+
depth--;
|
|
306
|
+
if (depth === 0) return i;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return -1;
|
|
310
|
+
}
|
|
311
|
+
function optionsLiteral(opts) {
|
|
312
|
+
const lines = [];
|
|
313
|
+
if (opts.serverUrl) lines.push(` serverUrl: ${quote(opts.serverUrl)},`);
|
|
314
|
+
lines.push(` projectName: ${quote(opts.projectName)},`);
|
|
315
|
+
return `{
|
|
316
|
+
${lines.join("\n")}
|
|
317
|
+
}`;
|
|
318
|
+
}
|
|
319
|
+
function withReporterImport(head) {
|
|
320
|
+
const importLine = /^import\b.*$/gm;
|
|
321
|
+
let insertAt = -1;
|
|
322
|
+
let match;
|
|
323
|
+
while (match = importLine.exec(head)) {
|
|
324
|
+
const line = match[0];
|
|
325
|
+
const complete = /from\s*['"][^'"]+['"]\s*;?\s*$/.test(line) || /^import\s+['"][^'"]+['"]\s*;?\s*$/.test(line);
|
|
326
|
+
if (complete) insertAt = match.index + line.length;
|
|
327
|
+
}
|
|
328
|
+
if (insertAt === -1) return `${REPORTER_IMPORT}
|
|
329
|
+
${head}`;
|
|
330
|
+
return `${head.slice(0, insertAt)}
|
|
331
|
+
${REPORTER_IMPORT}${head.slice(insertAt)}`;
|
|
332
|
+
}
|
|
333
|
+
function wrapPlaywrightConfig(source, opts) {
|
|
334
|
+
if (/@piwitests\/reporter/.test(source)) {
|
|
335
|
+
return { text: source, status: "already", detail: "Piwi reporter is already wired into the config" };
|
|
336
|
+
}
|
|
337
|
+
const match = /export\s+default\s+defineConfig\s*\(/.exec(source);
|
|
338
|
+
if (!match) return { text: source, status: "manual", detail: MANUAL_HINT };
|
|
339
|
+
const openParen = match.index + match[0].length - 1;
|
|
340
|
+
const closeParen = matchClosingParen(source, openParen);
|
|
341
|
+
const callStart = source.indexOf("defineConfig", match.index);
|
|
342
|
+
if (closeParen === -1 || callStart === -1) return { text: source, status: "manual", detail: MANUAL_HINT };
|
|
343
|
+
const callExpr = source.slice(callStart, closeParen + 1);
|
|
344
|
+
let before = withReporterImport(source.slice(0, match.index));
|
|
345
|
+
const after = source.slice(closeParen + 1);
|
|
346
|
+
if (before.length && !before.endsWith("\n")) before += "\n";
|
|
347
|
+
const wrapped = `export default wrapConfig(
|
|
348
|
+
${callExpr},
|
|
349
|
+
${optionsLiteral(opts)},
|
|
350
|
+
)`;
|
|
351
|
+
const text = `${before}${wrapped}${after}`;
|
|
352
|
+
return { text, status: "updated", detail: "Wrapped defineConfig(...) with wrapConfig(...) and added the import" };
|
|
353
|
+
}
|
|
354
|
+
function fixturesContents() {
|
|
355
|
+
return [
|
|
356
|
+
"import { test as base, expect } from '@playwright/test'",
|
|
357
|
+
"import { piwiFixtures } from '@piwitests/reporter'",
|
|
358
|
+
"",
|
|
359
|
+
"export const test = base.extend(piwiFixtures)",
|
|
360
|
+
"export { expect }",
|
|
361
|
+
""
|
|
362
|
+
].join("\n");
|
|
363
|
+
}
|
|
364
|
+
function upsertEnvKeys(existing, entries) {
|
|
365
|
+
let text = existing;
|
|
366
|
+
const added = [];
|
|
367
|
+
for (const [key, value] of entries) {
|
|
368
|
+
if (new RegExp(`^\\s*${key}=`, "m").test(text)) continue;
|
|
369
|
+
if (text.length && !text.endsWith("\n")) text += "\n";
|
|
370
|
+
text += `${key}=${value}
|
|
371
|
+
`;
|
|
372
|
+
added.push(key);
|
|
373
|
+
}
|
|
374
|
+
return { text, added };
|
|
375
|
+
}
|
|
376
|
+
function ensureGitignoreEntry(existing, entry = ".env") {
|
|
377
|
+
const present = existing.split(/\r?\n/).some((line) => line.trim() === entry);
|
|
378
|
+
if (present) return { text: existing, added: false };
|
|
379
|
+
let text = existing;
|
|
380
|
+
if (text.length && !text.endsWith("\n")) text += "\n";
|
|
381
|
+
text += `${entry}
|
|
382
|
+
`;
|
|
383
|
+
return { text, added: true };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// src/cli/skills.ts
|
|
387
|
+
var fs3 = __toESM(require("fs"));
|
|
388
|
+
var path2 = __toESM(require("path"));
|
|
389
|
+
var SETUP_SKILL = "setup-piwi";
|
|
390
|
+
var WORKFLOW_SKILLS = ["investigate-failure", "apply-locator-healing", "stabilize-flaky-tests"];
|
|
391
|
+
var ALL_SKILLS = [SETUP_SKILL, ...WORKFLOW_SKILLS];
|
|
392
|
+
var DEFAULT_SKILLS_DIR = path2.join(".claude", "skills");
|
|
393
|
+
function findTemplatesDir(fromDir) {
|
|
394
|
+
let dir = fromDir;
|
|
395
|
+
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);
|
|
398
|
+
if (parent === dir) break;
|
|
399
|
+
dir = parent;
|
|
400
|
+
}
|
|
401
|
+
return path2.resolve(fromDir, "..", "..", "templates");
|
|
402
|
+
}
|
|
403
|
+
function readFrontMatter(markdown) {
|
|
404
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(markdown);
|
|
405
|
+
if (!match) return {};
|
|
406
|
+
const out = {};
|
|
407
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
408
|
+
const kv = /^(name|description):\s*(.*)$/.exec(line);
|
|
409
|
+
if (kv) out[kv[1]] = kv[2].trim().replace(/^['"]|['"]$/g, "");
|
|
410
|
+
}
|
|
411
|
+
return out;
|
|
412
|
+
}
|
|
413
|
+
function templatePath(templatesDir, slug) {
|
|
414
|
+
return path2.join(templatesDir, "skills", slug, "SKILL.md");
|
|
415
|
+
}
|
|
416
|
+
function listSkills(templatesDir, slugs = ALL_SKILLS) {
|
|
417
|
+
const infos = [];
|
|
418
|
+
for (const slug of slugs) {
|
|
419
|
+
const file = templatePath(templatesDir, slug);
|
|
420
|
+
if (!fs3.existsSync(file)) continue;
|
|
421
|
+
const front = readFrontMatter(fs3.readFileSync(file, "utf-8"));
|
|
422
|
+
infos.push({ slug, name: front.name ?? slug, description: front.description ?? "" });
|
|
423
|
+
}
|
|
424
|
+
return infos;
|
|
425
|
+
}
|
|
426
|
+
function installSkills(opts) {
|
|
427
|
+
const results = [];
|
|
428
|
+
for (const slug of opts.slugs) {
|
|
429
|
+
const step = `skill:${slug}`;
|
|
430
|
+
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)) {
|
|
434
|
+
results.push({ step, status: "error", detail: `no template found for "${slug}"` });
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
const contents = fs3.readFileSync(source, "utf-8");
|
|
438
|
+
const exists = fs3.existsSync(dest);
|
|
439
|
+
if (exists && !opts.force) {
|
|
440
|
+
const identical = fs3.readFileSync(dest, "utf-8") === contents;
|
|
441
|
+
results.push({
|
|
442
|
+
step,
|
|
443
|
+
file: relDest,
|
|
444
|
+
status: identical ? "already" : "skipped",
|
|
445
|
+
detail: identical ? "skill already installed" : "skill exists and differs \u2014 pass --force to overwrite"
|
|
446
|
+
});
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
if (!opts.dryRun) {
|
|
450
|
+
fs3.mkdirSync(path2.dirname(dest), { recursive: true });
|
|
451
|
+
fs3.writeFileSync(dest, contents);
|
|
452
|
+
}
|
|
453
|
+
results.push({ step, file: relDest, status: exists ? "updated" : "created", detail: "installed skill" });
|
|
454
|
+
}
|
|
455
|
+
return results;
|
|
456
|
+
}
|
|
172
457
|
var USAGE2 = `
|
|
458
|
+
piwi skills \u2014 install the Piwi agent skills into this project
|
|
459
|
+
|
|
460
|
+
Usage:
|
|
461
|
+
npx @piwitests/reporter skills list
|
|
462
|
+
npx @piwitests/reporter skills add [names...] [options]
|
|
463
|
+
|
|
464
|
+
Commands:
|
|
465
|
+
list Show the available skills and what each one is for
|
|
466
|
+
add [names...] Install the named skills (default: all of them)
|
|
467
|
+
|
|
468
|
+
Options for "add":
|
|
469
|
+
--dir <path> Directory to install into (default: ${DEFAULT_SKILLS_DIR})
|
|
470
|
+
--cwd <path> Project root to operate on (default: current directory)
|
|
471
|
+
--force Overwrite a skill file that already exists
|
|
472
|
+
--dry-run Report what would be written without writing
|
|
473
|
+
--json Print the results as JSON
|
|
474
|
+
|
|
475
|
+
Skills: ${ALL_SKILLS.join(", ")}
|
|
476
|
+
`.trim();
|
|
477
|
+
function readOption2(argv, name) {
|
|
478
|
+
const withEquals = argv.find((arg) => arg.startsWith(`${name}=`));
|
|
479
|
+
if (withEquals) return withEquals.slice(name.length + 1);
|
|
480
|
+
const index = argv.indexOf(name);
|
|
481
|
+
if (index === -1) return void 0;
|
|
482
|
+
const value = argv[index + 1];
|
|
483
|
+
return value && !value.startsWith("--") ? value : void 0;
|
|
484
|
+
}
|
|
485
|
+
function positionalSlugs(argv) {
|
|
486
|
+
const slugs = [];
|
|
487
|
+
for (const arg of argv) {
|
|
488
|
+
if (arg.startsWith("--")) break;
|
|
489
|
+
slugs.push(arg);
|
|
490
|
+
}
|
|
491
|
+
return slugs;
|
|
492
|
+
}
|
|
493
|
+
function runSkills(argv, templatesDir, cwd = process.cwd()) {
|
|
494
|
+
const [sub, ...rest] = argv;
|
|
495
|
+
if (sub === void 0 || sub === "-h" || sub === "--help") {
|
|
496
|
+
console.log(USAGE2);
|
|
497
|
+
return 0;
|
|
498
|
+
}
|
|
499
|
+
if (sub === "list") {
|
|
500
|
+
const infos = listSkills(templatesDir);
|
|
501
|
+
if (rest.includes("--json")) {
|
|
502
|
+
console.log(JSON.stringify(infos, null, 2));
|
|
503
|
+
} else {
|
|
504
|
+
console.log("Available Piwi skills:\n");
|
|
505
|
+
for (const info of infos) console.log(` ${info.slug}
|
|
506
|
+
${info.description}
|
|
507
|
+
`);
|
|
508
|
+
}
|
|
509
|
+
return 0;
|
|
510
|
+
}
|
|
511
|
+
if (sub === "add") {
|
|
512
|
+
const requested = positionalSlugs(rest);
|
|
513
|
+
const unknown = requested.filter((slug) => !ALL_SKILLS.includes(slug));
|
|
514
|
+
if (unknown.length) {
|
|
515
|
+
console.error(`piwi skills: unknown skill(s): ${unknown.join(", ")}
|
|
516
|
+
`);
|
|
517
|
+
console.error(USAGE2);
|
|
518
|
+
return 2;
|
|
519
|
+
}
|
|
520
|
+
const results = installSkills({
|
|
521
|
+
templatesDir,
|
|
522
|
+
root: path2.resolve(readOption2(rest, "--cwd") ?? cwd),
|
|
523
|
+
skillsDir: readOption2(rest, "--dir") ?? DEFAULT_SKILLS_DIR,
|
|
524
|
+
slugs: requested.length ? requested : ALL_SKILLS,
|
|
525
|
+
force: rest.includes("--force"),
|
|
526
|
+
dryRun: rest.includes("--dry-run")
|
|
527
|
+
});
|
|
528
|
+
if (rest.includes("--json")) {
|
|
529
|
+
console.log(JSON.stringify(results, null, 2));
|
|
530
|
+
} else {
|
|
531
|
+
for (const result of results)
|
|
532
|
+
console.log(` [${result.status}] ${result.file ?? result.step} \u2014 ${result.detail}`);
|
|
533
|
+
}
|
|
534
|
+
return results.some((r) => r.status === "error") ? 2 : 0;
|
|
535
|
+
}
|
|
536
|
+
console.error(`piwi skills: unknown command "${sub}"
|
|
537
|
+
`);
|
|
538
|
+
console.error(USAGE2);
|
|
539
|
+
return 2;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// src/cli/init.ts
|
|
543
|
+
var DEFAULT_SERVER_URL = "http://localhost:3000";
|
|
544
|
+
var USAGE3 = `
|
|
545
|
+
piwi init \u2014 wire a Playwright project up to a Piwi Dashboard
|
|
546
|
+
|
|
547
|
+
Usage:
|
|
548
|
+
npx @piwitests/reporter init [options]
|
|
549
|
+
|
|
550
|
+
What it does (each step is idempotent and safe to re-run):
|
|
551
|
+
- installs @piwitests/reporter as a dev dependency
|
|
552
|
+
- wraps export default defineConfig(...) with wrapConfig(...)
|
|
553
|
+
- creates the capture-fixtures file (tests/fixtures.ts)
|
|
554
|
+
- records PIWI_* connection settings in .env / .env.example and .gitignore
|
|
555
|
+
- installs the Piwi agent skills so your coding agent can use them
|
|
556
|
+
|
|
557
|
+
Options:
|
|
558
|
+
--server-url <url> Dashboard URL to write into the config (env PIWI_DASHBOARD_URL,
|
|
559
|
+
default ${DEFAULT_SERVER_URL})
|
|
560
|
+
--project <name> Project name to report under (default: your package/folder name)
|
|
561
|
+
--api-key <key> API key to write into .env (env PIWI_API_KEY). Omit to keep it a
|
|
562
|
+
.env.example placeholder you fill in yourself.
|
|
563
|
+
--cwd <path> Project root to operate on (default: current directory)
|
|
564
|
+
--skills <list> Comma-separated skills to install, or "all" / "none"
|
|
565
|
+
(default: ${WORKFLOW_SKILLS.join(", ")})
|
|
566
|
+
--skills-dir <path> Directory to install skills into (default: ${DEFAULT_SKILLS_DIR})
|
|
567
|
+
--skills-only Only install skills; do not touch the config or env
|
|
568
|
+
--no-skills Configure the project but install no skills
|
|
569
|
+
--no-install Do not run the package manager; record the dependency only
|
|
570
|
+
--force Overwrite skill files that already exist
|
|
571
|
+
--dry-run Report every change without writing anything
|
|
572
|
+
--json Print the plan/result as JSON (for agents)
|
|
573
|
+
-h, --help Show this help
|
|
574
|
+
|
|
575
|
+
Skills: ${ALL_SKILLS.join(", ")}
|
|
576
|
+
`.trim();
|
|
577
|
+
function readOption3(argv, name) {
|
|
578
|
+
const withEquals = argv.find((arg) => arg.startsWith(`${name}=`));
|
|
579
|
+
if (withEquals) return withEquals.slice(name.length + 1);
|
|
580
|
+
const index = argv.indexOf(name);
|
|
581
|
+
if (index === -1) return void 0;
|
|
582
|
+
const value = argv[index + 1];
|
|
583
|
+
return value && !value.startsWith("--") ? value : void 0;
|
|
584
|
+
}
|
|
585
|
+
function resolveSkillSlugs(raw) {
|
|
586
|
+
if (raw === void 0) return WORKFLOW_SKILLS;
|
|
587
|
+
const trimmed = raw.trim().toLowerCase();
|
|
588
|
+
if (trimmed === "none") return [];
|
|
589
|
+
if (trimmed === "all") return ALL_SKILLS;
|
|
590
|
+
return raw.split(",").map((slug) => slug.trim()).filter(Boolean);
|
|
591
|
+
}
|
|
592
|
+
function parseInitArgs(argv, env, cwd) {
|
|
593
|
+
const root = path3.resolve(readOption3(argv, "--cwd") ?? cwd);
|
|
594
|
+
const detected = detectProject(root);
|
|
595
|
+
return {
|
|
596
|
+
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")),
|
|
602
|
+
install: !argv.includes("--no-install"),
|
|
603
|
+
force: argv.includes("--force"),
|
|
604
|
+
dryRun: argv.includes("--dry-run"),
|
|
605
|
+
json: argv.includes("--json"),
|
|
606
|
+
skillsOnly: argv.includes("--skills-only"),
|
|
607
|
+
noSkills: argv.includes("--no-skills")
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
function readFileOr(file, fallback) {
|
|
611
|
+
try {
|
|
612
|
+
return fs4.readFileSync(file, "utf-8");
|
|
613
|
+
} catch {
|
|
614
|
+
return fallback;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
function installArgv(manager) {
|
|
618
|
+
switch (manager) {
|
|
619
|
+
case "pnpm":
|
|
620
|
+
return ["pnpm", ["add", "-D", REPORTER_PACKAGE]];
|
|
621
|
+
case "yarn":
|
|
622
|
+
return ["yarn", ["add", "-D", REPORTER_PACKAGE]];
|
|
623
|
+
case "bun":
|
|
624
|
+
return ["bun", ["add", "-d", REPORTER_PACKAGE]];
|
|
625
|
+
case "npm":
|
|
626
|
+
return ["npm", ["install", "--save-dev", REPORTER_PACKAGE]];
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
function stepInstallReporter(project, opts) {
|
|
630
|
+
const step = "dependency";
|
|
631
|
+
if (project.reporterInstalled)
|
|
632
|
+
return { step, status: "already", detail: `${REPORTER_PACKAGE} is already a dependency` };
|
|
633
|
+
if (!project.packageJson)
|
|
634
|
+
return {
|
|
635
|
+
step,
|
|
636
|
+
status: "manual",
|
|
637
|
+
detail: `No package.json here \u2014 run \`npm init\`, then \`${installCommand(project.packageManager)}\``
|
|
638
|
+
};
|
|
639
|
+
if (opts.dryRun || !opts.install)
|
|
640
|
+
return { step, status: "manual", detail: `Run \`${installCommand(project.packageManager)}\`` };
|
|
641
|
+
const [command, args] = installArgv(project.packageManager);
|
|
642
|
+
const result = (0, import_node_child_process.spawnSync)(command, args, { cwd: opts.root, stdio: "inherit" });
|
|
643
|
+
if (result.status === 0) return { step, status: "updated", detail: `Installed ${REPORTER_PACKAGE}` };
|
|
644
|
+
return {
|
|
645
|
+
step,
|
|
646
|
+
status: "error",
|
|
647
|
+
detail: `\`${installCommand(project.packageManager)}\` failed (exit ${result.status ?? "unknown"}) \u2014 install it by hand`
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
function stepConfig(project, opts) {
|
|
651
|
+
const step = "config";
|
|
652
|
+
if (!project.configPath)
|
|
653
|
+
return {
|
|
654
|
+
step,
|
|
655
|
+
status: "manual",
|
|
656
|
+
detail: "No playwright.config found \u2014 create one, then re-run `npx @piwitests/reporter init`"
|
|
657
|
+
};
|
|
658
|
+
const rel = path3.relative(project.root, project.configPath) || path3.basename(project.configPath);
|
|
659
|
+
const edit = wrapPlaywrightConfig(readFileOr(project.configPath, ""), {
|
|
660
|
+
serverUrl: opts.serverUrl,
|
|
661
|
+
projectName: opts.projectName
|
|
662
|
+
});
|
|
663
|
+
if (edit.status === "updated" && !opts.dryRun) fs4.writeFileSync(project.configPath, edit.text);
|
|
664
|
+
return { step, file: rel, status: edit.status, detail: edit.detail };
|
|
665
|
+
}
|
|
666
|
+
function stepFixtures(project, opts) {
|
|
667
|
+
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)) {
|
|
671
|
+
const current = readFileOr(dest, "");
|
|
672
|
+
if (/piwiFixtures/.test(current))
|
|
673
|
+
return { step, file: rel, status: "already", detail: "capture fixtures already set up" };
|
|
674
|
+
return {
|
|
675
|
+
step,
|
|
676
|
+
file: rel,
|
|
677
|
+
status: "manual",
|
|
678
|
+
detail: "A fixtures file exists \u2014 extend it: `import { piwiFixtures } from '@piwitests/reporter'` and `base.extend(piwiFixtures)`"
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
if (!opts.dryRun) {
|
|
682
|
+
fs4.mkdirSync(path3.dirname(dest), { recursive: true });
|
|
683
|
+
fs4.writeFileSync(dest, fixturesContents());
|
|
684
|
+
}
|
|
685
|
+
return {
|
|
686
|
+
step,
|
|
687
|
+
file: rel,
|
|
688
|
+
status: "created",
|
|
689
|
+
detail: "Created capture fixtures \u2014 import `test` from here in your specs"
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
function stepEnv(project, opts) {
|
|
693
|
+
const results = [];
|
|
694
|
+
const examplePath = path3.join(project.root, ".env.example");
|
|
695
|
+
const example = upsertEnvKeys(readFileOr(examplePath, ""), [
|
|
696
|
+
["PIWI_DASHBOARD_URL", opts.serverUrl],
|
|
697
|
+
["PIWI_API_KEY", ""]
|
|
698
|
+
]);
|
|
699
|
+
if (example.added.length && !opts.dryRun) fs4.writeFileSync(examplePath, example.text);
|
|
700
|
+
results.push({
|
|
701
|
+
step: "env",
|
|
702
|
+
file: ".env.example",
|
|
703
|
+
status: example.added.length ? "updated" : "already",
|
|
704
|
+
detail: example.added.length ? `Recorded ${example.added.join(", ")}` : "connection template already present"
|
|
705
|
+
});
|
|
706
|
+
if (opts.apiKey) {
|
|
707
|
+
const envPath = path3.join(project.root, ".env");
|
|
708
|
+
const env = upsertEnvKeys(readFileOr(envPath, ""), [
|
|
709
|
+
["PIWI_DASHBOARD_URL", opts.serverUrl],
|
|
710
|
+
["PIWI_API_KEY", opts.apiKey]
|
|
711
|
+
]);
|
|
712
|
+
if (env.added.length && !opts.dryRun) fs4.writeFileSync(envPath, env.text);
|
|
713
|
+
results.push({
|
|
714
|
+
step: "env",
|
|
715
|
+
file: ".env",
|
|
716
|
+
status: env.added.length ? "updated" : "already",
|
|
717
|
+
detail: env.added.length ? `Wrote ${env.added.join(", ")} (keep .env out of git)` : "already set"
|
|
718
|
+
});
|
|
719
|
+
const gitignorePath = path3.join(project.root, ".gitignore");
|
|
720
|
+
const gitignore = ensureGitignoreEntry(readFileOr(gitignorePath, ""));
|
|
721
|
+
if (gitignore.added && !opts.dryRun) fs4.writeFileSync(gitignorePath, gitignore.text);
|
|
722
|
+
results.push({
|
|
723
|
+
step: "gitignore",
|
|
724
|
+
file: ".gitignore",
|
|
725
|
+
status: gitignore.added ? "updated" : "already",
|
|
726
|
+
detail: gitignore.added ? "Added .env" : ".env already ignored"
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
return results;
|
|
730
|
+
}
|
|
731
|
+
function nextSteps(opts, steps) {
|
|
732
|
+
const out = [];
|
|
733
|
+
if (!opts.apiKey) {
|
|
734
|
+
out.push(
|
|
735
|
+
"If your dashboard has authentication on, create an API key (Settings \u2192 Users \u2192 API keys), put it in .env as PIWI_API_KEY, and keep .env out of git."
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
if (steps.some((s) => s.status === "manual")) {
|
|
739
|
+
out.push("Finish the steps marked [manual] above \u2014 each one carries the exact change to make.");
|
|
740
|
+
}
|
|
741
|
+
out.push("In your specs, import `test` (and `expect`) from your fixtures file instead of `@playwright/test`.");
|
|
742
|
+
out.push(`Run \`npx playwright test\` \u2014 the run should appear at ${opts.serverUrl}.`);
|
|
743
|
+
out.push(
|
|
744
|
+
"For a machine-readable check, set PIWI_OUTPUT_FILE=piwi-run.json and read runUrl from that file after the run."
|
|
745
|
+
);
|
|
746
|
+
return out;
|
|
747
|
+
}
|
|
748
|
+
async function runInit(argv, env = process.env, cwd = process.cwd()) {
|
|
749
|
+
if (argv.includes("-h") || argv.includes("--help")) {
|
|
750
|
+
console.log(USAGE3);
|
|
751
|
+
return 0;
|
|
752
|
+
}
|
|
753
|
+
const opts = parseInitArgs(argv, env, cwd);
|
|
754
|
+
const project = detectProject(opts.root);
|
|
755
|
+
const steps = [];
|
|
756
|
+
if (!opts.skillsOnly) {
|
|
757
|
+
steps.push(stepInstallReporter(project, opts));
|
|
758
|
+
steps.push(stepConfig(project, opts));
|
|
759
|
+
steps.push(stepFixtures(project, opts));
|
|
760
|
+
steps.push(...stepEnv(project, opts));
|
|
761
|
+
}
|
|
762
|
+
if (!opts.noSkills && opts.skillSlugs.length) {
|
|
763
|
+
steps.push(
|
|
764
|
+
...installSkills({
|
|
765
|
+
templatesDir: findTemplatesDir(__dirname),
|
|
766
|
+
root: opts.root,
|
|
767
|
+
skillsDir: opts.skillsDir,
|
|
768
|
+
slugs: opts.skillSlugs,
|
|
769
|
+
force: opts.force,
|
|
770
|
+
dryRun: opts.dryRun
|
|
771
|
+
})
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
const guidance = nextSteps(opts, steps);
|
|
775
|
+
if (opts.json) {
|
|
776
|
+
console.log(
|
|
777
|
+
JSON.stringify(
|
|
778
|
+
{
|
|
779
|
+
dryRun: opts.dryRun,
|
|
780
|
+
project: {
|
|
781
|
+
root: project.root,
|
|
782
|
+
packageManager: project.packageManager,
|
|
783
|
+
configPath: project.configPath ? path3.relative(project.root, project.configPath) : null,
|
|
784
|
+
projectName: opts.projectName,
|
|
785
|
+
serverUrl: opts.serverUrl
|
|
786
|
+
},
|
|
787
|
+
steps,
|
|
788
|
+
nextSteps: guidance
|
|
789
|
+
},
|
|
790
|
+
null,
|
|
791
|
+
2
|
|
792
|
+
)
|
|
793
|
+
);
|
|
794
|
+
} else {
|
|
795
|
+
console.log(`
|
|
796
|
+
Piwi setup${opts.dryRun ? " (dry run \u2014 nothing written)" : ""} for "${opts.projectName}":
|
|
797
|
+
`);
|
|
798
|
+
for (const step of steps) console.log(formatStep(step));
|
|
799
|
+
console.log("\nNext:");
|
|
800
|
+
for (const line of guidance) console.log(` \u2022 ${line}`);
|
|
801
|
+
console.log("");
|
|
802
|
+
}
|
|
803
|
+
return steps.some((s) => s.status === "error") ? 1 : 0;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// src/cli/index.ts
|
|
807
|
+
var USAGE4 = `
|
|
173
808
|
piwi \u2014 companion commands for the Piwi Dashboard reporter
|
|
174
809
|
|
|
175
810
|
Usage:
|
|
176
|
-
npx
|
|
811
|
+
npx @piwitests/reporter <command> [options]
|
|
177
812
|
|
|
178
813
|
Commands:
|
|
179
|
-
|
|
814
|
+
init Wire a Playwright project up to a Piwi Dashboard
|
|
815
|
+
skills Install the Piwi agent skills into this project
|
|
816
|
+
gate Fail a CI job on the dashboard's analysis of a run
|
|
180
817
|
|
|
181
|
-
Run \`npx
|
|
818
|
+
Run \`npx @piwitests/reporter <command> --help\` for a command's options.
|
|
819
|
+
(The published package is @piwitests/reporter; its command is piwi. Invoke it
|
|
820
|
+
through the package name so npx resolves this package, not an unrelated "piwi".)
|
|
182
821
|
`.trim();
|
|
183
822
|
async function main() {
|
|
184
823
|
const [command, ...rest] = process.argv.slice(2);
|
|
185
824
|
switch (command) {
|
|
825
|
+
case "init":
|
|
826
|
+
return runInit(rest);
|
|
827
|
+
case "skills":
|
|
828
|
+
return runSkills(rest, findTemplatesDir(__dirname));
|
|
186
829
|
case "gate":
|
|
187
830
|
return runGate(rest);
|
|
188
831
|
case void 0:
|
|
189
832
|
case "-h":
|
|
190
833
|
case "--help":
|
|
191
|
-
console.log(
|
|
834
|
+
console.log(USAGE4);
|
|
192
835
|
return 0;
|
|
193
836
|
default:
|
|
194
837
|
console.error(`piwi: unknown command "${command}"
|
|
195
838
|
`);
|
|
196
|
-
console.error(
|
|
839
|
+
console.error(USAGE4);
|
|
197
840
|
return 2;
|
|
198
841
|
}
|
|
199
842
|
}
|