@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 +7 -7
- package/dist/cli/index.js +430 -8
- package/dist/global-setup-module.js +4 -1
- package/dist/index.d.ts +51 -2
- package/dist/index.js +348 -51
- package/dist/internal/capture/capture-fixtures.js +106 -23
- package/dist/internal/capture/locator-healing.d.ts +28 -1
- package/dist/internal/capture/locator-healing.js +44 -0
- package/package.json +7 -2
- package/templates/skills/investigate-failure/SKILL.md +1 -1
- package/templates/skills/run-the-right-tests/SKILL.md +38 -0
- package/templates/skills/setup-piwi/SKILL.md +1 -1
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.
|
|
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.
|
|
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.
|
|
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 "
|
|
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.
|
|
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.
|
|
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
|
|
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(
|
|
480
|
+
function readRunIdFromFile(path10) {
|
|
479
481
|
try {
|
|
480
|
-
const parsed = JSON.parse(fs3.readFileSync(
|
|
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 = [
|
|
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/
|
|
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(
|
|
1642
|
+
console.log(USAGE6);
|
|
1221
1643
|
return 0;
|
|
1222
1644
|
default:
|
|
1223
1645
|
console.error(`piwi: unknown command "${command}"
|
|
1224
1646
|
`);
|
|
1225
|
-
console.error(
|
|
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:
|
|
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 };
|