@piwitests/reporter 0.24.0 → 0.26.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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # Piwi Dashboard Reporter
2
2
 
3
- A custom Playwright reporter that sends test results to a [Piwi Dashboard](https://piwitests.github.io) server. It handles uploading test results, HTML reports, trace files, and performance metrics — with optional live streaming of results as tests execute.
3
+ A custom Playwright reporter that sends test results to a [Piwi Dashboard](https://piwitests.dev) server. It handles uploading test results, HTML reports, trace files, and performance metrics — with optional live streaming of results as tests execute.
4
4
 
5
- 📖 **[Full documentation](https://piwitests.github.io/reporter)**
5
+ 📖 **[Full documentation](https://piwitests.dev/reporter)**
6
6
 
7
7
  ## Installation
8
8
 
@@ -18,14 +18,14 @@ From your Playwright project, `npx @piwitests/reporter init` installs the report
18
18
  npx @piwitests/reporter init --server-url http://localhost:3000 --project my-project
19
19
  ```
20
20
 
21
- Every step is idempotent (safe to re-run); a config shape it will not rewrite is reported as `manual` with the exact change to make, never mangled. Add `--dry-run` to preview or `--json` for a machine-readable plan an agent can act on. It also installs the [Piwi agent skills](https://piwitests.github.io/mcp#agent-skills) so your coding agent can investigate failures, heal locators, and stabilize flaky tests. Run `npx @piwitests/reporter init --help` for all options, or wire it up by hand with the steps below.
21
+ Every step is idempotent (safe to re-run); a config shape it will not rewrite is reported as `manual` with the exact change to make, never mangled. Add `--dry-run` to preview or `--json` for a machine-readable plan an agent can act on. It also installs the [Piwi agent skills](https://piwitests.dev/mcp#agent-skills) so your coding agent can investigate failures, heal locators, and stabilize flaky tests. Run `npx @piwitests/reporter init --help` for all options, or wire it up by hand with the steps below.
22
22
 
23
23
  > The package is `@piwitests/reporter`; its command is `piwi`. Invoke it through the package name (`npx @piwitests/reporter <command>`) so npx resolves this package — `npx piwi` would fetch an unrelated `piwi` from npm. Once the reporter is a project dependency, `npx piwi <command>` resolves the local binary and works too.
24
24
 
25
25
  ## Quick start
26
26
 
27
27
  `wrapConfig` is the recommended setup. It injects the reporter **and** a global
28
- setup step (so the run shows up as "initialising" while your `globalSetup` runs),
28
+ setup step (so the run shows up as "initializing" while your `globalSetup` runs),
29
29
  and forwards your options to that setup:
30
30
 
31
31
  ```typescript
@@ -184,7 +184,7 @@ Then import `test` from your fixtures file in every spec — a spec that imports
184
184
 
185
185
  Capture works for the `page` fixture, `browser.newPage()`, `browser.newContext().newPage()`, and popups. Everything is only collected when `collectPerformanceMetrics` is `true` (the default); locator snapshots can be disabled separately with `captureLocators: false`.
186
186
 
187
- Without the fixtures you still get full run history, statuses, errors, traces, reports, streaming, and clustering — the fixtures add the slow-endpoint, Web Vitals, console, ARIA, and locator-healing layers. See the [capture fixtures guide](https://piwitests.github.io/capture-fixtures) for the full feature matrix and composition patterns.
187
+ Without the fixtures you still get full run history, statuses, errors, traces, reports, streaming, and clustering — the fixtures add the slow-endpoint, Web Vitals, console, ARIA, and locator-healing layers. See the [capture fixtures guide](https://piwitests.dev/capture-fixtures) for the full feature matrix and composition patterns.
188
188
 
189
189
  ## AI steps
190
190
 
@@ -204,7 +204,7 @@ export const test = extendPiwiAi(extendPiwiFixtures(base))
204
204
 
205
205
  Author missing entries once in `resolve` mode (`PIWI_AI=resolve`, pointed at a dashboard with an AI provider configured), commit the artifacts, and CI replays them offline. `{param}` placeholders are type-checked and masked out of everything sent to the model. Manage the committed entries with `piwi ai check | resolve | prune`.
206
206
 
207
- See the [AI steps guide](https://piwitests.github.io/ai-steps) for the authoring/replay lifecycle, the safety model (allowlisted, drift-guarded, postcondition-verified), and the full option/env-var reference.
207
+ See the [AI steps guide](https://piwitests.dev/ai-steps) for the authoring/replay lifecycle, the safety model (allowlisted, drift-guarded, postcondition-verified), and the full option/env-var reference.
208
208
 
209
209
  ## Authentication
210
210
 
@@ -298,7 +298,7 @@ addition:
298
298
 
299
299
  ## Requirements
300
300
 
301
- - Node.js 18 or higher (the reporter runs inside your test project — the dashboard *server* itself targets Node 24+, or use its Docker image)
301
+ - Node.js 18 or higher (the reporter runs inside your test project — the dashboard *server* itself targets Node 22+, or use its Docker image)
302
302
  - Playwright Test 1.40 or higher
303
303
  - Running Piwi Dashboard server
304
304
 
package/dist/cli/index.js CHANGED
@@ -414,7 +414,7 @@ function formatGateResult(result) {
414
414
  const { facts } = result;
415
415
  const lines = [
416
416
  result.passed ? `\u2714 Piwi gate passed \u2014 ${facts.projectName} run #${facts.runId}` : `\u2716 Piwi gate failed \u2014 ${facts.projectName} run #${facts.runId}`,
417
- ` ${facts.totalTests} tests, ${facts.failedTests} failed, ${facts.newRegressions} new, ${facts.newFlaky} newly flaky`
417
+ ` ${facts.totalTests} tests, ${facts.failedTests} failed, ${facts.newRegressions} new, ${facts.newFlaky} newly flaky, ${facts.flakyTests} flaky`
418
418
  ];
419
419
  if (facts.quarantinedFailures > 0) {
420
420
  lines.push(
@@ -453,6 +453,8 @@ Policy (at least one is required):
453
453
  --max-new-flaky <n> Fail when more than n tests newly became flaky
454
454
  --max-quarantined <n> Fail when more than n tests are quarantined
455
455
  --fail-on-new-cluster Fail when this run introduced a new failure cluster
456
+ --fail-on-flaky Fail when this run contains any flaky test
457
+ --require-selection <key> Fail when a test the named selection matches did not run or failed
456
458
 
457
459
  Other:
458
460
  --json Print the raw result as JSON instead of a summary
@@ -475,9 +477,9 @@ function readCount(argv, name) {
475
477
  if (!Number.isFinite(n) || n < 0) throw new Error(`${name} expects a non-negative number, got "${raw}"`);
476
478
  return Math.floor(n);
477
479
  }
478
- function readRunIdFromFile(path9) {
480
+ function readRunIdFromFile(path10) {
479
481
  try {
480
- const parsed = JSON.parse(fs3.readFileSync(path9, "utf-8"));
482
+ const parsed = JSON.parse(fs3.readFileSync(path10, "utf-8"));
481
483
  const runId = Number(parsed.runId);
482
484
  return Number.isFinite(runId) && runId > 0 ? runId : null;
483
485
  } catch {
@@ -504,7 +506,9 @@ function parseGateArgs(argv, env) {
504
506
  maxNewRegressions: readCount(argv, "--max-new-regressions"),
505
507
  maxNewFlaky: readCount(argv, "--max-new-flaky"),
506
508
  maxQuarantined: readCount(argv, "--max-quarantined"),
507
- failOnNewCluster: argv.includes("--fail-on-new-cluster")
509
+ failOnNewCluster: argv.includes("--fail-on-new-cluster"),
510
+ failOnFlaky: argv.includes("--fail-on-flaky"),
511
+ requireSelection: readOption2(argv, "--require-selection")
508
512
  };
509
513
  return { serverUrl, apiKey: readOption2(argv, "--api-key") ?? env.PIWI_API_KEY ?? null, runId, policy };
510
514
  }
@@ -770,7 +774,12 @@ function ensureGitignoreEntry(existing, entry = ".env") {
770
774
  var fs5 = __toESM(require("fs"));
771
775
  var path7 = __toESM(require("path"));
772
776
  var SETUP_SKILL = "setup-piwi";
773
- var WORKFLOW_SKILLS = ["investigate-failure", "apply-locator-healing", "stabilize-flaky-tests"];
777
+ var WORKFLOW_SKILLS = [
778
+ "investigate-failure",
779
+ "apply-locator-healing",
780
+ "stabilize-flaky-tests",
781
+ "run-the-right-tests"
782
+ ];
774
783
  var ALL_SKILLS = [SETUP_SKILL, ...WORKFLOW_SKILLS];
775
784
  var DEFAULT_SKILLS_DIR = path7.join(".claude", "skills");
776
785
  function findTemplatesDir(fromDir) {
@@ -1186,8 +1195,415 @@ Piwi setup${opts.dryRun ? " (dry run \u2014 nothing written)" : ""} for "${opts.
1186
1195
  return steps.some((s) => s.status === "error") ? 1 : 0;
1187
1196
  }
1188
1197
 
1189
- // src/cli/index.ts
1198
+ // src/cli/select.ts
1199
+ var fs7 = __toESM(require("fs"));
1200
+ var path9 = __toESM(require("path"));
1201
+ var import_node_child_process3 = require("child_process");
1202
+ var import_node_module = require("module");
1203
+
1204
+ // src/internal/support/selection-client.ts
1205
+ function authHeaders(apiKey) {
1206
+ return apiKey ? { "X-API-Key": apiKey } : {};
1207
+ }
1208
+ async function resolveProjectId(options) {
1209
+ if (!options.project) throw new Error("No project \u2014 pass --project or set PIWI_PROJECT_NAME");
1210
+ if (/^\d+$/.test(options.project)) return Number(options.project);
1211
+ const res = await fetch(`${options.serverUrl}/api/projects/menu`, { headers: authHeaders(options.apiKey) });
1212
+ if (!res.ok) throw new Error(`Could not list projects (dashboard returned ${res.status})`);
1213
+ const body = await res.json();
1214
+ const match = body.items?.find((p) => p.name.toLowerCase() === options.project.toLowerCase());
1215
+ if (!match) throw new Error(`No project named "${options.project}" on this dashboard`);
1216
+ return match.id;
1217
+ }
1218
+ async function fetchResolution(options, projectId) {
1219
+ const params = new URLSearchParams({ format: options.format ?? "args" });
1220
+ if (options.pkgRunner) params.set("pkgRunner", options.pkgRunner);
1221
+ if (options.budgetMs != null) params.set("budgetMs", String(options.budgetMs));
1222
+ if (options.shard) params.set("shard", options.shard);
1223
+ if (options.order) params.set("order", options.order);
1224
+ const url = `${options.serverUrl}/api/projects/${projectId}/selections/${encodeURIComponent(options.key)}/resolve?${params}`;
1225
+ const res = await fetch(url, { headers: authHeaders(options.apiKey) });
1226
+ if (!res.ok) {
1227
+ const body = await res.json().catch(() => ({}));
1228
+ throw new Error(body.message || `Dashboard returned ${res.status} resolving "${options.key}"`);
1229
+ }
1230
+ return await res.json();
1231
+ }
1232
+ async function fetchImpact(options, projectId, changedFiles) {
1233
+ const url = `${options.serverUrl}/api/projects/${projectId}/selections/impact`;
1234
+ const res = await fetch(url, {
1235
+ method: "POST",
1236
+ headers: { "Content-Type": "application/json", ...authHeaders(options.apiKey) },
1237
+ body: JSON.stringify({
1238
+ changedFiles,
1239
+ format: options.format ?? "args",
1240
+ shard: options.shard ?? void 0,
1241
+ order: options.order ?? void 0
1242
+ })
1243
+ });
1244
+ if (!res.ok) {
1245
+ const body = await res.json().catch(() => ({}));
1246
+ throw new Error(body.message || `Dashboard returned ${res.status} resolving impact`);
1247
+ }
1248
+ return await res.json();
1249
+ }
1250
+
1251
+ // src/cli/select.ts
1252
+ var EXIT_OK3 = 0;
1253
+ var EXIT_ERROR3 = 2;
1190
1254
  var USAGE5 = `
1255
+ piwi select / piwi run \u2014 run a saved selection of tests
1256
+
1257
+ Usage:
1258
+ npx @piwitests/reporter select <key> [options] print the Playwright args
1259
+ npx @piwitests/reporter run <key> [options] [-- <playwright args>]
1260
+ npx @piwitests/reporter run impact --base <ref> run the tests your diff impacts
1261
+
1262
+ Connection:
1263
+ --server-url <url> Dashboard URL (env PIWI_DASHBOARD_URL)
1264
+ --api-key <key> API key (env PIWI_API_KEY)
1265
+ --project <name|id> Project (env PIWI_PROJECT_NAME)
1266
+
1267
+ Selection:
1268
+ --format <fmt> args (file:line, default) | grep | files | json
1269
+ --budget <duration> Cap total time, e.g. 5m, 90s, 300000 (ms)
1270
+ --shard <i/n> Keep only shard i of n, balanced by test duration
1271
+ --fail-fast Order the least-reliable tests first (fail-fast)
1272
+ --base <ref> For "impact": the ref to diff the working tree against
1273
+
1274
+ Behavior:
1275
+ --strict Fail (exit 2) instead of falling back when unreachable
1276
+ --pkg-runner <cmd> Package runner for the printed command (default npx)
1277
+ --json Print the full resolution as JSON (select only)
1278
+ -h, --help Show this help
1279
+
1280
+ Exit codes: 0 ok, 1 the test run failed (run only), 2 could not resolve.
1281
+ `.trim();
1282
+ var IMPACT_KEY = "impact";
1283
+ var VALUE_FLAGS = /* @__PURE__ */ new Set([
1284
+ "--server-url",
1285
+ "--api-key",
1286
+ "--project",
1287
+ "--format",
1288
+ "--budget",
1289
+ "--shard",
1290
+ "--base",
1291
+ "--pkg-runner"
1292
+ ]);
1293
+ function readOption5(argv, name) {
1294
+ const withEquals = argv.find((arg) => arg.startsWith(`${name}=`));
1295
+ if (withEquals) return withEquals.slice(name.length + 1);
1296
+ const index = argv.indexOf(name);
1297
+ if (index === -1) return void 0;
1298
+ const value = argv[index + 1];
1299
+ return value && !value.startsWith("-") ? value : void 0;
1300
+ }
1301
+ function findKey(argv) {
1302
+ for (let i = 0; i < argv.length; i++) {
1303
+ const tok = argv[i];
1304
+ if (tok.startsWith("-")) {
1305
+ if (!tok.includes("=") && VALUE_FLAGS.has(tok)) i++;
1306
+ continue;
1307
+ }
1308
+ return tok;
1309
+ }
1310
+ return void 0;
1311
+ }
1312
+ function parseDuration(raw) {
1313
+ const match = raw.trim().match(/^(\d+(?:\.\d+)?)(ms|s|m|h)?$/);
1314
+ if (!match) return null;
1315
+ const value = Number(match[1]);
1316
+ const unit = match[2];
1317
+ const factor = unit === "h" ? 36e5 : unit === "m" ? 6e4 : unit === "s" ? 1e3 : 1;
1318
+ const ms = Math.round(value * factor);
1319
+ return ms > 0 ? ms : null;
1320
+ }
1321
+ function parseSelectArgs(argv, env) {
1322
+ const dashIndex = argv.indexOf("--");
1323
+ const own = dashIndex === -1 ? argv : argv.slice(0, dashIndex);
1324
+ const extra = dashIndex === -1 ? [] : argv.slice(dashIndex + 1);
1325
+ const serverUrl = (readOption5(own, "--server-url") ?? env.PIWI_DASHBOARD_URL ?? "").replace(/\/$/, "");
1326
+ if (!serverUrl) throw new Error("No dashboard URL \u2014 pass --server-url or set PIWI_DASHBOARD_URL");
1327
+ const key = findKey(own);
1328
+ if (!key) throw new Error("No selection key \u2014 usage: piwi select <key>");
1329
+ const budgetRaw = readOption5(own, "--budget");
1330
+ let budgetMs = null;
1331
+ if (budgetRaw !== void 0) {
1332
+ budgetMs = parseDuration(budgetRaw);
1333
+ if (budgetMs === null) throw new Error(`--budget expects a duration like 5m or 90s, got "${budgetRaw}"`);
1334
+ }
1335
+ const shard = readOption5(own, "--shard") ?? null;
1336
+ if (shard !== null && !/^\d+\s*\/\s*\d+$/.test(shard)) {
1337
+ throw new Error(`--shard expects an "i/n" spec like 2/4, got "${shard}"`);
1338
+ }
1339
+ return {
1340
+ serverUrl,
1341
+ apiKey: readOption5(own, "--api-key") ?? env.PIWI_API_KEY ?? null,
1342
+ project: readOption5(own, "--project") ?? env.PIWI_PROJECT_NAME ?? "",
1343
+ key,
1344
+ format: readOption5(own, "--format") ?? "args",
1345
+ budgetMs,
1346
+ shard,
1347
+ order: own.includes("--fail-fast") ? "failureLikelihood" : null,
1348
+ base: readOption5(own, "--base") ?? null,
1349
+ strict: own.includes("--strict"),
1350
+ pkgRunner: readOption5(own, "--pkg-runner") ?? "npx",
1351
+ json: own.includes("--json"),
1352
+ extra
1353
+ };
1354
+ }
1355
+ var CACHE_FILE = path9.join(".piwi", "selection-cache.json");
1356
+ function cacheKey(projectId, args) {
1357
+ return `${projectId}:${args.key}:${args.format}:${args.budgetMs ?? 0}:${args.shard ?? ""}:${args.order ?? ""}`;
1358
+ }
1359
+ function readCache(projectId, args) {
1360
+ try {
1361
+ const store = JSON.parse(fs7.readFileSync(CACHE_FILE, "utf-8"));
1362
+ return store[cacheKey(projectId, args)] ?? null;
1363
+ } catch {
1364
+ return null;
1365
+ }
1366
+ }
1367
+ function writeCache(projectId, args, resolution) {
1368
+ try {
1369
+ let store = {};
1370
+ try {
1371
+ store = JSON.parse(fs7.readFileSync(CACHE_FILE, "utf-8"));
1372
+ } catch {
1373
+ }
1374
+ store[cacheKey(projectId, args)] = resolution;
1375
+ fs7.mkdirSync(path9.dirname(CACHE_FILE), { recursive: true });
1376
+ fs7.writeFileSync(CACHE_FILE, JSON.stringify(store, null, 2));
1377
+ } catch {
1378
+ }
1379
+ }
1380
+ async function resolveWithCache(args, projectId) {
1381
+ try {
1382
+ const resolution = await fetchResolution(args, projectId);
1383
+ writeCache(projectId, args, resolution);
1384
+ return { resolution, fromCache: false };
1385
+ } catch (e) {
1386
+ if (args.strict) throw e;
1387
+ const cached = readCache(projectId, args);
1388
+ if (cached) {
1389
+ console.error(`piwi: dashboard unreachable, using cached resolution \u2014 ${e.message}`);
1390
+ return { resolution: cached, fromCache: true };
1391
+ }
1392
+ return null;
1393
+ }
1394
+ }
1395
+ function printWarnings(resolution) {
1396
+ for (const w of resolution.warnings) console.error(`piwi: warning [${w.code}] ${w.message}`);
1397
+ }
1398
+ function gitChangedFiles(base) {
1399
+ const out = (0, import_node_child_process3.execFileSync)("git", ["diff", "--name-only", base], {
1400
+ encoding: "utf-8",
1401
+ stdio: ["ignore", "pipe", "ignore"]
1402
+ });
1403
+ return [
1404
+ ...new Set(
1405
+ out.split("\n").map((line) => line.trim()).filter(Boolean)
1406
+ )
1407
+ ];
1408
+ }
1409
+ async function loadImpact(args, projectId) {
1410
+ const files = gitChangedFiles(args.base);
1411
+ return fetchImpact(args, projectId, files);
1412
+ }
1413
+ async function runSelectImpact(args) {
1414
+ let impact;
1415
+ try {
1416
+ const projectId = await resolveProjectId(args);
1417
+ impact = await loadImpact(args, projectId);
1418
+ } catch (e) {
1419
+ console.error(`piwi select: ${e.message}`);
1420
+ return EXIT_ERROR3;
1421
+ }
1422
+ printWarnings(impact);
1423
+ console.error(
1424
+ `piwi select: ${impact.impact.changedFiles} changed file(s) \u2192 ${impact.estimate.count} impacted test(s)${impact.impact.widened ? " (widened to full suite)" : ""}`
1425
+ );
1426
+ if (args.json) {
1427
+ console.log(JSON.stringify(impact, null, 2));
1428
+ return EXIT_OK3;
1429
+ }
1430
+ console.log(impact.impact.widened ? "" : impact.materialization.args.join(" "));
1431
+ return EXIT_OK3;
1432
+ }
1433
+ async function runSelect(argv, env = process.env) {
1434
+ if (argv.includes("-h") || argv.includes("--help")) {
1435
+ console.log(USAGE5);
1436
+ return EXIT_OK3;
1437
+ }
1438
+ let args;
1439
+ try {
1440
+ args = parseSelectArgs(argv, env);
1441
+ } catch (e) {
1442
+ console.error(`piwi select: ${e.message}
1443
+ `);
1444
+ console.error(USAGE5);
1445
+ return EXIT_ERROR3;
1446
+ }
1447
+ if (args.key === IMPACT_KEY) {
1448
+ if (!args.base) {
1449
+ console.error("piwi select: impact needs a base ref \u2014 pass --base <ref> (e.g. --base origin/main)");
1450
+ return EXIT_ERROR3;
1451
+ }
1452
+ return runSelectImpact(args);
1453
+ }
1454
+ let resolution;
1455
+ try {
1456
+ const projectId = await resolveProjectId(args);
1457
+ resolution = await fetchResolution(args, projectId);
1458
+ } catch (e) {
1459
+ console.error(`piwi select: ${e.message}`);
1460
+ return EXIT_ERROR3;
1461
+ }
1462
+ printWarnings(resolution);
1463
+ if (resolution.estimate.count === 0) {
1464
+ console.error(`piwi select: "${args.key}" resolved to 0 tests`);
1465
+ return EXIT_ERROR3;
1466
+ }
1467
+ if (args.json) console.log(JSON.stringify(resolution, null, 2));
1468
+ else console.log(resolution.materialization.args.join(" "));
1469
+ return EXIT_OK3;
1470
+ }
1471
+ function resolvePlaywrightCli() {
1472
+ const require2 = (0, import_node_module.createRequire)(path9.join(process.cwd(), "noop.js"));
1473
+ for (const id of ["playwright/cli", "@playwright/test/cli", "playwright/lib/cli/cli"]) {
1474
+ try {
1475
+ return require2.resolve(id);
1476
+ } catch {
1477
+ }
1478
+ }
1479
+ return null;
1480
+ }
1481
+ function spawnPlaywright(pkgRunner, playwrightArgs, env) {
1482
+ const cli = resolvePlaywrightCli();
1483
+ const child = cli ? (0, import_node_child_process3.spawn)(process.execPath, [cli, "test", ...playwrightArgs], { stdio: "inherit", env }) : (0, import_node_child_process3.spawn)(process.platform === "win32" ? `${pkgRunner}.cmd` : pkgRunner, ["playwright", "test", ...playwrightArgs], {
1484
+ stdio: "inherit",
1485
+ env
1486
+ });
1487
+ return new Promise((resolve5) => {
1488
+ child.on("error", (err) => {
1489
+ console.error(`piwi run: could not start Playwright \u2014 ${err.message}`);
1490
+ resolve5(EXIT_ERROR3);
1491
+ });
1492
+ child.on("exit", (code) => resolve5(code ?? EXIT_ERROR3));
1493
+ });
1494
+ }
1495
+ async function runRunImpact(args, env) {
1496
+ let projectId;
1497
+ try {
1498
+ projectId = await resolveProjectId(args);
1499
+ } catch (e) {
1500
+ if (args.strict) {
1501
+ console.error(`piwi run: ${e.message}`);
1502
+ return EXIT_ERROR3;
1503
+ }
1504
+ console.error(`piwi run: ${e.message} \u2014 running the full suite`);
1505
+ return spawnPlaywright(args.pkgRunner, args.extra, env);
1506
+ }
1507
+ let impact;
1508
+ try {
1509
+ impact = await loadImpact(args, projectId);
1510
+ } catch (e) {
1511
+ if (args.strict) {
1512
+ console.error(`piwi run: ${e.message}`);
1513
+ return EXIT_ERROR3;
1514
+ }
1515
+ console.error(`piwi run: ${e.message} \u2014 running the full suite`);
1516
+ return spawnPlaywright(args.pkgRunner, args.extra, env);
1517
+ }
1518
+ printWarnings(impact);
1519
+ if (impact.impact.widened) {
1520
+ console.error(
1521
+ `piwi run: impact widened to the full suite (${impact.impact.unmappedSourceFiles.length} unmapped source file(s))`
1522
+ );
1523
+ return spawnPlaywright(args.pkgRunner, args.extra, env);
1524
+ }
1525
+ if (impact.estimate.count === 0) {
1526
+ console.error(`piwi run: no tests impacted by ${impact.impact.changedFiles} changed file(s) \u2014 nothing to run`);
1527
+ return EXIT_OK3;
1528
+ }
1529
+ const runEnv = {
1530
+ ...env,
1531
+ PIWI_SELECTION: IMPACT_KEY,
1532
+ PIWI_SELECTION_VERSION: "0",
1533
+ PIWI_SELECTION_HASH: impact.resolvedHash,
1534
+ PIWI_SELECTION_COUNT: String(impact.estimate.count)
1535
+ };
1536
+ console.error(`piwi run: impact \u2192 ${impact.estimate.count} test(s)`);
1537
+ return spawnPlaywright(args.pkgRunner, [...impact.materialization.args, ...args.extra], runEnv);
1538
+ }
1539
+ async function runRun(argv, env = process.env) {
1540
+ if (argv.includes("-h") || argv.includes("--help")) {
1541
+ console.log(USAGE5);
1542
+ return EXIT_OK3;
1543
+ }
1544
+ let args;
1545
+ try {
1546
+ args = parseSelectArgs(argv, env);
1547
+ } catch (e) {
1548
+ console.error(`piwi run: ${e.message}
1549
+ `);
1550
+ console.error(USAGE5);
1551
+ return EXIT_ERROR3;
1552
+ }
1553
+ if (args.format === "json") {
1554
+ console.error("piwi run: --format json cannot be run; use args, grep or files");
1555
+ return EXIT_ERROR3;
1556
+ }
1557
+ if (args.key === IMPACT_KEY) {
1558
+ if (!args.base) {
1559
+ console.error("piwi run: impact needs a base ref \u2014 pass --base <ref> (e.g. --base origin/main)");
1560
+ return EXIT_ERROR3;
1561
+ }
1562
+ return runRunImpact(args, env);
1563
+ }
1564
+ let projectId;
1565
+ try {
1566
+ projectId = await resolveProjectId(args);
1567
+ } catch (e) {
1568
+ if (args.strict) {
1569
+ console.error(`piwi run: ${e.message}`);
1570
+ return EXIT_ERROR3;
1571
+ }
1572
+ console.error(`piwi run: ${e.message} \u2014 running the full suite`);
1573
+ return spawnPlaywright(args.pkgRunner, args.extra, env);
1574
+ }
1575
+ let outcome;
1576
+ try {
1577
+ outcome = await resolveWithCache(args, projectId);
1578
+ } catch (e) {
1579
+ console.error(`piwi run: ${e.message}`);
1580
+ return EXIT_ERROR3;
1581
+ }
1582
+ if (!outcome) {
1583
+ console.error("piwi run: dashboard unreachable and no cached resolution \u2014 running the full suite");
1584
+ return spawnPlaywright(args.pkgRunner, args.extra, env);
1585
+ }
1586
+ const { resolution } = outcome;
1587
+ printWarnings(resolution);
1588
+ if (resolution.estimate.count === 0) {
1589
+ console.error(`piwi run: "${args.key}" resolved to 0 tests`);
1590
+ return EXIT_ERROR3;
1591
+ }
1592
+ const runEnv = {
1593
+ ...env,
1594
+ PIWI_SELECTION: resolution.key ?? args.key,
1595
+ PIWI_SELECTION_VERSION: String(resolution.version ?? 0),
1596
+ PIWI_SELECTION_HASH: resolution.resolvedHash,
1597
+ PIWI_SELECTION_COUNT: String(resolution.estimate.count)
1598
+ };
1599
+ console.error(
1600
+ `piwi run: ${args.key} \u2192 ${resolution.estimate.count} tests${resolution.materialization.format !== args.format ? ` (materialized as ${resolution.materialization.format})` : ""}`
1601
+ );
1602
+ return spawnPlaywright(args.pkgRunner, [...resolution.materialization.args, ...args.extra], runEnv);
1603
+ }
1604
+
1605
+ // src/cli/index.ts
1606
+ var USAGE6 = `
1191
1607
  piwi \u2014 companion commands for the Piwi Dashboard reporter
1192
1608
 
1193
1609
  Usage:
@@ -1197,6 +1613,8 @@ Commands:
1197
1613
  init Wire a Playwright project up to a Piwi Dashboard
1198
1614
  skills Install the Piwi agent skills into this project
1199
1615
  gate Fail a CI job on the dashboard's analysis of a run
1616
+ select Print the Playwright args for a saved test selection
1617
+ run Run a saved test selection with playwright test
1200
1618
  ai Manage committed natural-language AI-step artifacts
1201
1619
 
1202
1620
  Run \`npx @piwitests/reporter <command> --help\` for a command's options.
@@ -1212,17 +1630,21 @@ async function main() {
1212
1630
  return runSkills(rest, findTemplatesDir(__dirname));
1213
1631
  case "gate":
1214
1632
  return runGate(rest);
1633
+ case "select":
1634
+ return runSelect(rest);
1635
+ case "run":
1636
+ return runRun(rest);
1215
1637
  case "ai":
1216
1638
  return runAi(rest);
1217
1639
  case void 0:
1218
1640
  case "-h":
1219
1641
  case "--help":
1220
- console.log(USAGE5);
1642
+ console.log(USAGE6);
1221
1643
  return 0;
1222
1644
  default:
1223
1645
  console.error(`piwi: unknown command "${command}"
1224
1646
  `);
1225
- console.error(USAGE5);
1647
+ console.error(USAGE6);
1226
1648
  return 2;
1227
1649
  }
1228
1650
  }
@@ -81,6 +81,7 @@ var DEFAULTS = {
81
81
  streaming: true,
82
82
  streamingBatchSize: 5,
83
83
  streamingBatchDelay: 2e3,
84
+ failOnFlakyTests: false,
84
85
  username: null,
85
86
  password: null,
86
87
  apiKey: null,
@@ -100,6 +101,7 @@ var PIWI_ENV_KEYS = {
100
101
  streamingBatchSize: "PIWI_STREAMING_BATCH_SIZE",
101
102
  streamingBatchDelay: "PIWI_STREAMING_BATCH_DELAY",
102
103
  liveFileUploads: "PIWI_LIVE_FILE_UPLOADS",
104
+ failOnFlakyTests: "PIWI_FAIL_ON_FLAKY_TESTS",
103
105
  uploadTraces: "PIWI_UPLOAD_TRACES",
104
106
  uploadReport: "PIWI_UPLOAD_REPORT",
105
107
  captureLocators: "PIWI_CAPTURE_LOCATORS",
@@ -135,6 +137,7 @@ var ENV_FALLBACK_SPECS = [
135
137
  { option: "streamingBatchSize", env: PIWI_ENV_KEYS.streamingBatchSize, kind: "number" },
136
138
  { option: "streamingBatchDelay", env: PIWI_ENV_KEYS.streamingBatchDelay, kind: "number" },
137
139
  { option: "liveFileUploads", env: PIWI_ENV_KEYS.liveFileUploads, kind: "bool" },
140
+ { option: "failOnFlakyTests", env: PIWI_ENV_KEYS.failOnFlakyTests, kind: "bool" },
138
141
  { option: "uploadTraces", env: PIWI_ENV_KEYS.uploadTraces, kind: "bool" },
139
142
  { option: "uploadReport", env: PIWI_ENV_KEYS.uploadReport, kind: "bool" },
140
143
  { option: "captureLocators", env: PIWI_ENV_KEYS.captureLocators, kind: "bool" },
@@ -506,7 +509,7 @@ function createGlobalSetup(options, userSetup) {
506
509
  projectName: opts.projectName
507
510
  })
508
511
  );
509
- logger.debug(`Global setup: initialising run #${response.runId}`);
512
+ logger.debug(`Global setup: initializing run #${response.runId}`);
510
513
  }
511
514
  } catch (error) {
512
515
  logger.warn(`Could not register global setup: ${errorMessage(error)}`);
package/dist/index.d.ts CHANGED
@@ -88,6 +88,14 @@ interface PiwiDashboardOptions {
88
88
  streamingBatchSize?: number;
89
89
  /** Max delay (ms) before flushing pending events during streaming. Defaults to `2000`. */
90
90
  streamingBatchDelay?: number;
91
+ /**
92
+ * Fail the run when any test was flaky (passed only after a retry). Forwarded
93
+ * to Playwright's native `failOnFlakyTests` config option (Playwright 1.52+)
94
+ * when the reporter is installed via `wrapConfig`, so a flaky-only run exits
95
+ * non-zero without any server round-trip. Defaults to `false`. Can also be
96
+ * set with `PIWI_FAIL_ON_FLAKY_TESTS`.
97
+ */
98
+ failOnFlakyTests?: boolean;
91
99
  /** Username for dashboard login (use `apiKey` instead when possible) */
92
100
  username?: string | null;
93
101
  /** Password for dashboard login (used with `username`) */
@@ -239,6 +247,8 @@ declare class PiwiDashboardReporter {
239
247
  private plannedTests;
240
248
  /** Ids of tests that actually reported via `onTestEnd`, to find the ones that never ran. */
241
249
  private reportedTestIds;
250
+ /** Per-test attempt history, keyed by `test.id`; snapshotted onto every attempt's payload. */
251
+ private attemptsByTest;
242
252
  private instanceId;
243
253
  private runLabel;
244
254
  private shardInfo;
@@ -248,6 +258,8 @@ declare class PiwiDashboardReporter {
248
258
  private viaDesktopApp;
249
259
  private isFullRun;
250
260
  private filterDetails;
261
+ /** Configured `maxFailures` (0 = unlimited) — disambiguates an interrupted run's unrun reason. */
262
+ private maxFailures;
251
263
  private httpClient;
252
264
  private uploader;
253
265
  private fileHandler;
@@ -265,6 +277,12 @@ declare class PiwiDashboardReporter {
265
277
  onTestBegin(test: TestCase, result: TestResult): void;
266
278
  /** Track suite-level setup steps (beforeAll/afterAll) not tied to any test */
267
279
  private setupSteps;
280
+ /**
281
+ * Step categories streamed live while the run executes. `pw:assert` is
282
+ * excluded: it is the polling noise of `expect()`, not a step a human
283
+ * watches; the meaningful readout is the `pw:expect` wrapper around it.
284
+ */
285
+ private static readonly LIVE_STEP_CATEGORIES;
268
286
  /** Playwright reporter hook: called when a step (including hook/fixture) begins */
269
287
  onStepBegin(test: TestCase | undefined, _result: TestResult | undefined, step: any): void;
270
288
  /** Playwright reporter hook: called when a step (including hook/fixture) ends */
@@ -276,13 +294,44 @@ declare class PiwiDashboardReporter {
276
294
  * (no `onTestEnd`) — typically because `maxFailures` cut the run short. These
277
295
  * carry no result, so they're emitted with zero duration and no error. In
278
296
  * streaming mode they're queued as complete events so the pre-finish drain
279
- * sends them alongside the rest.
297
+ * sends them alongside the rest. The `reason` (global timeout / max failures /
298
+ * interrupted) is resolved once from the overall run status by the caller.
280
299
  */
281
300
  private materializeUnrunTests;
282
301
  /** Playwright reporter hook: called when the full test run finishes */
283
302
  onEnd(result: FullResult): Promise<void>;
284
303
  }
285
304
 
305
+ interface ResolveSelectionResult {
306
+ key: string;
307
+ version: number;
308
+ resolvedHash: string;
309
+ resolvedCount: number;
310
+ /** A `--grep` regex that selects the resolved tests, when the grep format was produced. */
311
+ grep?: string;
312
+ /** File (or `file:line`) tokens, when grep fell back to a file materialization. */
313
+ files?: string[];
314
+ }
315
+ interface ResolveSelectionOptions {
316
+ /** Selection key; defaults to the `PIWI_SELECTION` env var. */
317
+ key?: string;
318
+ serverUrl?: string;
319
+ apiKey?: string | null;
320
+ /** Project name or id; defaults to `PIWI_PROJECT_NAME`. */
321
+ project?: string;
322
+ budgetMs?: number | null;
323
+ /** Throw instead of falling back to the full suite when resolution fails. */
324
+ strict?: boolean;
325
+ env?: NodeJS.ProcessEnv;
326
+ }
327
+ /**
328
+ * Resolve the configured selection. Returns `undefined` when no selection is
329
+ * named or (in non-strict mode) when the dashboard cannot be reached, so the
330
+ * config runs the full suite. On success it also writes `PIWI_SELECTION_*` so
331
+ * the reporter stamps the run with the resolved selection.
332
+ */
333
+ declare function resolveSelection(options?: ResolveSelectionOptions): Promise<ResolveSelectionResult | undefined>;
334
+
286
335
  /**
287
336
  * Parameters are first-class: a template like `row for {name}` is the cache key,
288
337
  * so one entry serves every value. Placeholders survive compilation as `{{name}}`
@@ -345,4 +394,4 @@ declare function extendPiwiAi<TestArgs extends FixtureArgs, WorkerArgs extends F
345
394
  * exported from this file, it isn't part of the supported API.
346
395
  */
347
396
 
348
- export { type AiMode, type AiOnMiss, type PiwiAi, type PiwiDashboardOptions, PiwiDashboardReporter, createGlobalSetup, PiwiDashboardReporter as default, extendPiwiAi, piwiAiFixtures, wrapConfig };
397
+ export { type AiMode, type AiOnMiss, type PiwiAi, type PiwiDashboardOptions, PiwiDashboardReporter, type ResolveSelectionOptions, type ResolveSelectionResult, createGlobalSetup, PiwiDashboardReporter as default, extendPiwiAi, piwiAiFixtures, resolveSelection, wrapConfig };