@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/dist/index.js CHANGED
@@ -37,6 +37,7 @@ __export(index_exports, {
37
37
  extendPiwiFixtures: () => extendPiwiFixtures,
38
38
  piwiAiFixtures: () => piwiAiFixtures,
39
39
  piwiFixtures: () => piwiFixtures,
40
+ resolveSelection: () => resolveSelection,
40
41
  wrapConfig: () => wrapConfig
41
42
  });
42
43
  module.exports = __toCommonJS(index_exports);
@@ -79,6 +80,7 @@ var DEFAULTS = {
79
80
  streaming: true,
80
81
  streamingBatchSize: 5,
81
82
  streamingBatchDelay: 2e3,
83
+ failOnFlakyTests: false,
82
84
  username: null,
83
85
  password: null,
84
86
  apiKey: null,
@@ -98,6 +100,7 @@ var PIWI_ENV_KEYS = {
98
100
  streamingBatchSize: "PIWI_STREAMING_BATCH_SIZE",
99
101
  streamingBatchDelay: "PIWI_STREAMING_BATCH_DELAY",
100
102
  liveFileUploads: "PIWI_LIVE_FILE_UPLOADS",
103
+ failOnFlakyTests: "PIWI_FAIL_ON_FLAKY_TESTS",
101
104
  uploadTraces: "PIWI_UPLOAD_TRACES",
102
105
  uploadReport: "PIWI_UPLOAD_REPORT",
103
106
  captureLocators: "PIWI_CAPTURE_LOCATORS",
@@ -116,6 +119,12 @@ var PIWI_ENV_KEYS = {
116
119
  aiScreenshotFallback: "PIWI_AI_SCREENSHOT_FALLBACK"
117
120
  };
118
121
  var PIWI_DESKTOP_CONFIG_ENV = "PIWI_DESKTOP_CONFIG";
122
+ var PIWI_SELECTION_ENV = {
123
+ key: "PIWI_SELECTION",
124
+ version: "PIWI_SELECTION_VERSION",
125
+ hash: "PIWI_SELECTION_HASH",
126
+ count: "PIWI_SELECTION_COUNT"
127
+ };
119
128
  function readBool(val) {
120
129
  if (val === void 0) return void 0;
121
130
  return val === "true";
@@ -133,6 +142,7 @@ var ENV_FALLBACK_SPECS = [
133
142
  { option: "streamingBatchSize", env: PIWI_ENV_KEYS.streamingBatchSize, kind: "number" },
134
143
  { option: "streamingBatchDelay", env: PIWI_ENV_KEYS.streamingBatchDelay, kind: "number" },
135
144
  { option: "liveFileUploads", env: PIWI_ENV_KEYS.liveFileUploads, kind: "bool" },
145
+ { option: "failOnFlakyTests", env: PIWI_ENV_KEYS.failOnFlakyTests, kind: "bool" },
136
146
  { option: "uploadTraces", env: PIWI_ENV_KEYS.uploadTraces, kind: "bool" },
137
147
  { option: "uploadReport", env: PIWI_ENV_KEYS.uploadReport, kind: "bool" },
138
148
  { option: "captureLocators", env: PIWI_ENV_KEYS.captureLocators, kind: "bool" },
@@ -430,8 +440,8 @@ function resolveOverallStatus(result, counters) {
430
440
  const STATUS_MAP = {
431
441
  passed: "passed",
432
442
  failed: "failed",
433
- timedout: "failed",
434
- interrupted: "failed"
443
+ timedout: "timedout",
444
+ interrupted: "interrupted"
435
445
  };
436
446
  if (result?.status) return STATUS_MAP[result.status] ?? "failed";
437
447
  if (counters.failedTests === 0 && counters.timedOutTests === 0 && counters.totalTests > 0) return "passed";
@@ -448,6 +458,7 @@ function toWireTestCase(tc) {
448
458
  timeout: rest.timeout ?? null,
449
459
  error: rest.error,
450
460
  retries: rest.retries,
461
+ attempts: rest.attempts || null,
451
462
  workerIndex: rest.workerIndex ?? null,
452
463
  shardIndex: rest.shardIndex ?? null,
453
464
  startedAt: rest.startedAt ?? null,
@@ -470,7 +481,9 @@ function toWireTestCase(tc) {
470
481
  testAnnotations: rest.testAnnotations ?? null,
471
482
  tags: rest.tags ?? null,
472
483
  testMeta: rest.testMeta ?? null,
473
- locatorSnapshots: rest.locatorSnapshots || null
484
+ locatorSnapshots: rest.locatorSnapshots || null,
485
+ didNotRunReason: rest.didNotRunReason ?? null,
486
+ blockedBy: rest.blockedBy ?? null
474
487
  };
475
488
  }
476
489
  function serializeRun(payload, opts) {
@@ -523,8 +536,8 @@ var Uploader = class {
523
536
  auth
524
537
  );
525
538
  this.logger.info(`Successfully uploaded test results`);
526
- if (response.testRunId) {
527
- this.logger.info(`Test Run ID: ${response.testRunId}, Project ID: ${response.projectId}`);
539
+ if (response.runId) {
540
+ this.logger.info(`Test Run ID: ${response.runId}, Project ID: ${response.projectId}`);
528
541
  }
529
542
  return response;
530
543
  }
@@ -538,8 +551,8 @@ var Uploader = class {
538
551
  await this.appendFilesToForm(form, payload.testCases, reportOptions.uploadTraces);
539
552
  const response = await this.httpClient.postFormData("/api/test-runs/upload", form, auth);
540
553
  this.logger.info(`Successfully uploaded test results with files`);
541
- if (response.testRunId) {
542
- this.logger.info(`Test Run ID: ${response.testRunId}, Project ID: ${response.projectId}`);
554
+ if (response.runId) {
555
+ this.logger.info(`Test Run ID: ${response.runId}, Project ID: ${response.projectId}`);
543
556
  }
544
557
  if (response.reports) {
545
558
  for (const r of response.reports) this.logger.info(`${r.label}: ${r.path}`);
@@ -549,7 +562,7 @@ var Uploader = class {
549
562
  /** Upload report files for an already-submitted streaming run */
550
563
  async uploadReportsForStreamingRun(projectName, runId, reportOptions, startTime, auth) {
551
564
  const form = new import_form_data2.default();
552
- form.append("testRunId", String(runId));
565
+ form.append("runId", String(runId));
553
566
  form.append("projectName", projectName);
554
567
  form.append(
555
568
  "testRun",
@@ -1012,6 +1025,39 @@ var FileHandler = class {
1012
1025
 
1013
1026
  // src/internal/collect/metadata-collector.ts
1014
1027
  var import_node_child_process = require("child_process");
1028
+ var DETACHED_HEAD = "HEAD";
1029
+ function resolveScmBranch(env, gitBranch) {
1030
+ const pick = (...names) => {
1031
+ for (const name of names) {
1032
+ const value = env[name]?.trim();
1033
+ if (value) return value;
1034
+ }
1035
+ return void 0;
1036
+ };
1037
+ const override = env.PIWI_BRANCH?.trim();
1038
+ if (override) return override;
1039
+ let provider;
1040
+ if (env.JENKINS_URL) provider = pick("CHANGE_BRANCH", "BRANCH_NAME");
1041
+ else if (env.GITHUB_ACTIONS) provider = pick("GITHUB_HEAD_REF", "GITHUB_REF_NAME");
1042
+ else if (env.GITLAB_CI) provider = pick("CI_MERGE_REQUEST_SOURCE_BRANCH_NAME", "CI_COMMIT_REF_NAME");
1043
+ else if (env.CIRCLECI) provider = pick("CIRCLE_BRANCH");
1044
+ else if (env.TRAVIS) provider = pick("TRAVIS_PULL_REQUEST_BRANCH", "TRAVIS_BRANCH");
1045
+ else if (env.TF_BUILD) provider = pick("SYSTEM_PULLREQUEST_SOURCEBRANCH", "BUILD_SOURCEBRANCHNAME");
1046
+ else if (env.BITBUCKET_BUILD_NUMBER) provider = pick("BITBUCKET_BRANCH");
1047
+ if (provider) return normalizeRef(provider);
1048
+ const trimmed = gitBranch?.trim();
1049
+ if (trimmed && trimmed !== DETACHED_HEAD) return trimmed;
1050
+ return void 0;
1051
+ }
1052
+ function resolveScmPrNumber(env) {
1053
+ const githubRef = env.GITHUB_ACTIONS ? env.GITHUB_REF?.match(/^refs\/pull\/(\d+)\/(?:merge|head)$/) : void 0;
1054
+ const raw = githubRef?.[1] ?? env.CI_MERGE_REQUEST_IID ?? env.BITBUCKET_PR_ID ?? env.SYSTEM_PULLREQUEST_PULLREQUESTNUMBER ?? env.CHANGE_ID;
1055
+ const trimmed = raw?.trim();
1056
+ return trimmed && /^\d+$/.test(trimmed) ? trimmed : void 0;
1057
+ }
1058
+ function normalizeRef(ref) {
1059
+ return ref.replace(/^refs\/heads\//, "");
1060
+ }
1015
1061
  var MetadataCollector = class {
1016
1062
  constructor(logger = new Logger()) {
1017
1063
  this.logger = logger;
@@ -1117,6 +1163,7 @@ var MetadataCollector = class {
1117
1163
  }
1118
1164
  collectScmInfo(_options) {
1119
1165
  const scm = {};
1166
+ let gitBranch;
1120
1167
  try {
1121
1168
  const execOpts = {
1122
1169
  encoding: "utf8",
@@ -1125,7 +1172,7 @@ var MetadataCollector = class {
1125
1172
  stdio: ["ignore", "pipe", "ignore"]
1126
1173
  };
1127
1174
  scm.commit = (0, import_node_child_process.execSync)("git rev-parse HEAD", execOpts).trim();
1128
- scm.branch = (0, import_node_child_process.execSync)("git rev-parse --abbrev-ref HEAD", execOpts).trim();
1175
+ gitBranch = (0, import_node_child_process.execSync)("git rev-parse --abbrev-ref HEAD", execOpts).trim();
1129
1176
  scm.author = (0, import_node_child_process.execSync)('git log -1 --pretty=format:"%an"', execOpts).trim();
1130
1177
  scm.commitMessage = (0, import_node_child_process.execSync)('git log -1 --pretty=format:"%s"', execOpts).trim();
1131
1178
  try {
@@ -1135,6 +1182,10 @@ var MetadataCollector = class {
1135
1182
  } catch (error) {
1136
1183
  this.logger.debug(`Git info not available: ${errorMessage(error)}`);
1137
1184
  }
1185
+ const branch = resolveScmBranch(process.env, gitBranch);
1186
+ if (branch) scm.branch = branch;
1187
+ const prNumber = resolveScmPrNumber(process.env);
1188
+ if (prNumber) scm.prNumber = prNumber;
1138
1189
  return Object.keys(scm).length > 0 ? scm : void 0;
1139
1190
  }
1140
1191
  collectCiInfo() {
@@ -1421,8 +1472,8 @@ var StreamManager = class {
1421
1472
  if (this.pendingBeginEvents.length > 0) {
1422
1473
  this.pendingEvents = [...this.pendingBeginEvents, ...this.pendingEvents];
1423
1474
  this.pendingBeginEvents = [];
1424
- this.flush();
1425
1475
  }
1476
+ if (this.pendingEvents.length > 0) this.flush();
1426
1477
  }
1427
1478
  } catch (error) {
1428
1479
  if (error instanceof HttpError && (error.status === 401 || error.status === 403)) {
@@ -1934,6 +1985,17 @@ function detectCliFileFilters(argv = process.argv) {
1934
1985
  return files;
1935
1986
  }
1936
1987
 
1988
+ // src/internal/support/selection-env.ts
1989
+ function readSelectionStamp(env = process.env) {
1990
+ const key = env[PIWI_SELECTION_ENV.key];
1991
+ if (!key) return null;
1992
+ const version = Number(env[PIWI_SELECTION_ENV.version]);
1993
+ const resolvedCount = Number(env[PIWI_SELECTION_ENV.count]);
1994
+ const resolvedHash = env[PIWI_SELECTION_ENV.hash] ?? "";
1995
+ if (!Number.isFinite(version) || !Number.isFinite(resolvedCount) || !resolvedHash) return null;
1996
+ return { key, version, resolvedHash, resolvedCount };
1997
+ }
1998
+
1937
1999
  // src/public/global-setup.ts
1938
2000
  var path10 = __toESM(require("path"));
1939
2001
  var fs11 = __toESM(require("fs"));
@@ -2024,7 +2086,7 @@ function createGlobalSetup(options, userSetup) {
2024
2086
  projectName: opts.projectName
2025
2087
  })
2026
2088
  );
2027
- logger.debug(`Global setup: initialising run #${response.runId}`);
2089
+ logger.debug(`Global setup: initializing run #${response.runId}`);
2028
2090
  }
2029
2091
  } catch (error) {
2030
2092
  logger.warn(`Could not register global setup: ${errorMessage(error)}`);
@@ -2067,8 +2129,12 @@ function wrapConfig(config, piwiOptions) {
2067
2129
  globalSetupModules.push(...orig);
2068
2130
  }
2069
2131
  globalSetupModules.push(resolveSetupModule());
2132
+ const forwarded = {};
2133
+ const failOnFlaky = piwiOptions?.failOnFlakyTests ?? readBool(process.env[PIWI_ENV_KEYS.failOnFlakyTests]);
2134
+ if (failOnFlaky === true) forwarded.failOnFlakyTests = true;
2070
2135
  return {
2071
2136
  ...config,
2137
+ ...forwarded,
2072
2138
  reporter: injectReporter(config.reporter, piwiOptions),
2073
2139
  globalSetup: globalSetupModules.length === 1 ? globalSetupModules[0] : globalSetupModules
2074
2140
  };
@@ -2093,6 +2159,45 @@ function classifyStatus(rawStatus, annotations) {
2093
2159
  const intentional = annotations.some((a) => a.type === "skip" || a.type === "fixme");
2094
2160
  return intentional ? "skipped" : "didnotrun";
2095
2161
  }
2162
+ function resolveUnrunReason(runStatus, opts) {
2163
+ if (runStatus === "timedout") return "global-timeout";
2164
+ if (opts.maxFailures > 0 && opts.failures >= opts.maxFailures) return "max-failures";
2165
+ return "interrupted";
2166
+ }
2167
+ function fileOf(location) {
2168
+ return location.replace(/:\d+:\d+$/, "");
2169
+ }
2170
+ function suiteKey(suitePath) {
2171
+ return (suitePath ?? []).join("");
2172
+ }
2173
+ function sharedPrefixDepth(a, b) {
2174
+ let depth = 0;
2175
+ while (depth < a.length && depth < b.length && a[depth] === b[depth]) depth++;
2176
+ return depth;
2177
+ }
2178
+ function linkBlockedTests(cases) {
2179
+ const blockers = cases.filter((c) => c.status === "failed" || c.status === "timedOut" || c.status === "timedout");
2180
+ if (blockers.length === 0) return;
2181
+ for (const c of cases) {
2182
+ if (c.didNotRunReason !== "previous-failure") continue;
2183
+ const file = fileOf(c.location);
2184
+ const inFile = blockers.filter((b) => fileOf(b.location) === file);
2185
+ if (inFile.length === 0) continue;
2186
+ const key = suiteKey(c.suitePath);
2187
+ let blocker = inFile.find((b) => suiteKey(b.suitePath) === key);
2188
+ if (!blocker) {
2189
+ let bestDepth = 0;
2190
+ for (const b of inFile) {
2191
+ const depth = sharedPrefixDepth(c.suitePath ?? [], b.suitePath ?? []);
2192
+ if (depth > bestDepth) {
2193
+ bestDepth = depth;
2194
+ blocker = b;
2195
+ }
2196
+ }
2197
+ }
2198
+ if (blocker) c.blockedBy = blocker.location;
2199
+ }
2200
+ }
2096
2201
 
2097
2202
  // ../core/src/test-meta.ts
2098
2203
  var PIWI_ANNOTATION_PREFIX = "piwi:";
@@ -2462,7 +2567,7 @@ var RunSubmitter = class {
2462
2567
  auth
2463
2568
  );
2464
2569
  this.recovery.clear();
2465
- return { done: true, output: this.buildOutput(response?.testRunId, response?.projectId, run, overallStatus) };
2570
+ return { done: true, output: this.buildOutput(response?.runId, response?.projectId, run, overallStatus) };
2466
2571
  } catch (error) {
2467
2572
  if (error instanceof HttpError && error.status === 401 && !auth) {
2468
2573
  this.logAuthRequired(run.options.serverUrl);
@@ -2478,7 +2583,7 @@ var RunSubmitter = class {
2478
2583
  try {
2479
2584
  const response = await this.uploader.uploadJSON(payload, auth);
2480
2585
  this.recovery.clear();
2481
- return { done: true, output: this.buildOutput(response?.testRunId, response?.projectId, run, overallStatus) };
2586
+ return { done: true, output: this.buildOutput(response?.runId, response?.projectId, run, overallStatus) };
2482
2587
  } catch (error) {
2483
2588
  if (error instanceof HttpError && error.status === 401 && !auth) {
2484
2589
  this.logAuthRequired(run.options.serverUrl);
@@ -2505,7 +2610,7 @@ function testLocation(test) {
2505
2610
  const relativeFilePath = path14.relative(process.cwd(), test.location.file).split(path14.sep).join("/");
2506
2611
  return `${relativeFilePath}:${test.location.line}:${test.location.column}`;
2507
2612
  }
2508
- var PiwiDashboardReporter = class {
2613
+ var PiwiDashboardReporter = class _PiwiDashboardReporter {
2509
2614
  constructor(rawOptions = {}) {
2510
2615
  this.testCases = [];
2511
2616
  this.startTime = null;
@@ -2521,11 +2626,15 @@ var PiwiDashboardReporter = class {
2521
2626
  this.plannedTests = [];
2522
2627
  /** Ids of tests that actually reported via `onTestEnd`, to find the ones that never ran. */
2523
2628
  this.reportedTestIds = /* @__PURE__ */ new Set();
2629
+ /** Per-test attempt history, keyed by `test.id`; snapshotted onto every attempt's payload. */
2630
+ this.attemptsByTest = /* @__PURE__ */ new Map();
2524
2631
  this.runLabel = null;
2525
2632
  this.shardInfo = null;
2526
2633
  this.metadata = {};
2527
2634
  this.isFullRun = true;
2528
2635
  this.filterDetails = null;
2636
+ /** Configured `maxFailures` (0 = unlimited) — disambiguates an interrupted run's unrun reason. */
2637
+ this.maxFailures = 0;
2529
2638
  this.streamManager = null;
2530
2639
  /** Track suite-level setup steps (beforeAll/afterAll) not tied to any test */
2531
2640
  this.setupSteps = [];
@@ -2573,6 +2682,7 @@ var PiwiDashboardReporter = class {
2573
2682
  }
2574
2683
  this.startTime = (/* @__PURE__ */ new Date()).toISOString();
2575
2684
  this.playwrightVersion = config.version;
2685
+ this.maxFailures = config.maxFailures ?? 0;
2576
2686
  this.logger.info(
2577
2687
  `Starting test run for project: ${this.options.projectName} (Playwright v${this.playwrightVersion})`
2578
2688
  );
@@ -2582,14 +2692,18 @@ var PiwiDashboardReporter = class {
2582
2692
  const grep = grepRe && grepRe.source !== ".*" ? grepRe.source : void 0;
2583
2693
  const grepInvert = grepInvertRe?.source;
2584
2694
  const fileFilters = detectCliFileFilters();
2585
- if (grep || grepInvert || fileFilters.length > 0) {
2695
+ const selection = readSelectionStamp();
2696
+ if (grep || grepInvert || fileFilters.length > 0 || selection) {
2586
2697
  this.isFullRun = false;
2587
2698
  this.filterDetails = {
2588
2699
  ...grep ? { grep } : {},
2589
2700
  ...grepInvert ? { grepInvert } : {},
2590
- ...fileFilters.length > 0 ? { files: fileFilters } : {}
2701
+ ...fileFilters.length > 0 ? { files: fileFilters } : {},
2702
+ ...selection ? { selection } : {}
2591
2703
  };
2592
- this.logger.info("Partial run detected (filter active)");
2704
+ this.logger.info(
2705
+ selection ? `Selection run detected (${selection.key})` : "Partial run detected (filter active)"
2706
+ );
2593
2707
  }
2594
2708
  this.metadata = this.metadataCollector.collect(config, suite, this.options);
2595
2709
  this.plannedTests = suite.allTests();
@@ -2626,11 +2740,19 @@ var PiwiDashboardReporter = class {
2626
2740
  this.streamManager.queueBeginEvent(toWireTestCase(beginEvent));
2627
2741
  }
2628
2742
  }
2743
+ static {
2744
+ /**
2745
+ * Step categories streamed live while the run executes. `pw:assert` is
2746
+ * excluded: it is the polling noise of `expect()`, not a step a human
2747
+ * watches; the meaningful readout is the `pw:expect` wrapper around it.
2748
+ */
2749
+ this.LIVE_STEP_CATEGORIES = /* @__PURE__ */ new Set(["hook", "fixture", "pw:api", "pw:expect"]);
2750
+ }
2629
2751
  /** Playwright reporter hook: called when a step (including hook/fixture) begins */
2630
2752
  onStepBegin(test, _result, step) {
2631
- if (!this.enabled || !this.streamManager?.enabled) return;
2753
+ if (!this.enabled || !this.streamManager) return;
2632
2754
  const cat = step.category;
2633
- if (cat !== "hook" && cat !== "fixture") return;
2755
+ if (!_PiwiDashboardReporter.LIVE_STEP_CATEGORIES.has(cat)) return;
2634
2756
  const event = {
2635
2757
  type: "step-begin",
2636
2758
  title: step.title,
@@ -2644,10 +2766,9 @@ var PiwiDashboardReporter = class {
2644
2766
  }
2645
2767
  /** Playwright reporter hook: called when a step (including hook/fixture) ends */
2646
2768
  onStepEnd(test, _result, step) {
2769
+ if (!this.enabled || !this.streamManager) return;
2647
2770
  const cat = step.category;
2648
- if (cat === "pw:api") return;
2649
- if (!this.enabled || !this.streamManager?.enabled) return;
2650
- if (cat !== "hook" && cat !== "fixture") return;
2771
+ if (!_PiwiDashboardReporter.LIVE_STEP_CATEGORIES.has(cat)) return;
2651
2772
  const workerIndex = workerIndexOf(_result);
2652
2773
  const startedAt = step.startTime instanceof Date ? step.startTime.getTime() : null;
2653
2774
  const event = {
@@ -2662,7 +2783,7 @@ var PiwiDashboardReporter = class {
2662
2783
  startedAt
2663
2784
  };
2664
2785
  this.streamManager?.queueEvent(event);
2665
- if (!test && startedAt) {
2786
+ if (!test && startedAt && (cat === "hook" || cat === "fixture")) {
2666
2787
  this.setupSteps.push({
2667
2788
  title: step.title,
2668
2789
  category: cat,
@@ -2682,6 +2803,14 @@ var PiwiDashboardReporter = class {
2682
2803
  const annotations = mergeAnnotations(test, result);
2683
2804
  const status = classifyStatus(result.status, annotations);
2684
2805
  const tags = collectTestTags(test);
2806
+ const attempts = this.attemptsByTest.get(test.id) ?? [];
2807
+ attempts.push({
2808
+ retry: result.retry,
2809
+ status,
2810
+ duration: result.duration,
2811
+ startedAt: result.startTime ? result.startTime.getTime() : null
2812
+ });
2813
+ this.attemptsByTest.set(test.id, attempts);
2685
2814
  const testCase = {
2686
2815
  type: "complete",
2687
2816
  title: test.title,
@@ -2693,6 +2822,7 @@ var PiwiDashboardReporter = class {
2693
2822
  timeout: test.timeout ?? null,
2694
2823
  error: buildErrorText(result),
2695
2824
  retries: result.retry,
2825
+ attempts: attempts.map((a) => ({ ...a })),
2696
2826
  workerIndex: workerIndexOf(result),
2697
2827
  shardIndex: this.shardInfo?.current ?? null,
2698
2828
  startedAt: result.startTime ? result.startTime.getTime() : null,
@@ -2702,7 +2832,10 @@ var PiwiDashboardReporter = class {
2702
2832
  suiteConfig,
2703
2833
  testAnnotations: annotations.length ? annotations : null,
2704
2834
  tags: tags.length ? tags : null,
2705
- testMeta: collectTestMetadata(annotations)
2835
+ testMeta: collectTestMetadata(annotations),
2836
+ // An annotation-less skip reclassified to `didnotrun` is a serial-group
2837
+ // cascade: an earlier test failed and Playwright skipped the rest.
2838
+ didNotRunReason: status === "didnotrun" ? "previous-failure" : null
2706
2839
  };
2707
2840
  if (result.status === "failed" || result.status === "timedOut") {
2708
2841
  const failingLine = extractFailingLine(testCase.error, test.location.file, test.location.line);
@@ -2746,6 +2879,7 @@ var PiwiDashboardReporter = class {
2746
2879
  break;
2747
2880
  }
2748
2881
  this.testCases.push(testCase);
2882
+ if (status === "didnotrun") linkBlockedTests(this.testCases);
2749
2883
  if (this.streamManager) {
2750
2884
  this.streamManager.queueEvent(toWireTestCase(testCase));
2751
2885
  if (this.options.liveFileUploads) this.streamManager.scheduleLiveUpload(testCase);
@@ -2756,9 +2890,10 @@ var PiwiDashboardReporter = class {
2756
2890
  * (no `onTestEnd`) — typically because `maxFailures` cut the run short. These
2757
2891
  * carry no result, so they're emitted with zero duration and no error. In
2758
2892
  * streaming mode they're queued as complete events so the pre-finish drain
2759
- * sends them alongside the rest.
2893
+ * sends them alongside the rest. The `reason` (global timeout / max failures /
2894
+ * interrupted) is resolved once from the overall run status by the caller.
2760
2895
  */
2761
- materializeUnrunTests() {
2896
+ materializeUnrunTests(reason) {
2762
2897
  for (const test of this.plannedTests) {
2763
2898
  if (this.reportedTestIds.has(test.id)) continue;
2764
2899
  const { suitePath, suiteConfig } = this.metadataCollector.getSuiteInfo(test);
@@ -2782,7 +2917,8 @@ var PiwiDashboardReporter = class {
2782
2917
  suiteConfig,
2783
2918
  testAnnotations: declaredAnnotations.length ? declaredAnnotations : null,
2784
2919
  tags: tags.length ? tags : null,
2785
- testMeta: collectTestMetadata(declaredAnnotations)
2920
+ testMeta: collectTestMetadata(declaredAnnotations),
2921
+ didNotRunReason: reason
2786
2922
  };
2787
2923
  this.testCases.push(testCase);
2788
2924
  this.totalTests++;
@@ -2795,7 +2931,11 @@ var PiwiDashboardReporter = class {
2795
2931
  /** Playwright reporter hook: called when the full test run finishes */
2796
2932
  async onEnd(result) {
2797
2933
  if (!this.enabled) return;
2798
- this.materializeUnrunTests();
2934
+ const unrunReason = resolveUnrunReason(result?.status, {
2935
+ maxFailures: this.maxFailures,
2936
+ failures: this.failedTests + this.timedOutTests
2937
+ });
2938
+ this.materializeUnrunTests(unrunReason);
2799
2939
  await this.submitter.submit(
2800
2940
  {
2801
2941
  options: this.options,
@@ -2821,6 +2961,79 @@ var PiwiDashboardReporter = class {
2821
2961
  }
2822
2962
  };
2823
2963
 
2964
+ // src/internal/support/selection-client.ts
2965
+ function authHeaders(apiKey) {
2966
+ return apiKey ? { "X-API-Key": apiKey } : {};
2967
+ }
2968
+ async function resolveProjectId(options) {
2969
+ if (!options.project) throw new Error("No project \u2014 pass --project or set PIWI_PROJECT_NAME");
2970
+ if (/^\d+$/.test(options.project)) return Number(options.project);
2971
+ const res = await fetch(`${options.serverUrl}/api/projects/menu`, { headers: authHeaders(options.apiKey) });
2972
+ if (!res.ok) throw new Error(`Could not list projects (dashboard returned ${res.status})`);
2973
+ const body = await res.json();
2974
+ const match = body.items?.find((p) => p.name.toLowerCase() === options.project.toLowerCase());
2975
+ if (!match) throw new Error(`No project named "${options.project}" on this dashboard`);
2976
+ return match.id;
2977
+ }
2978
+ async function fetchResolution(options, projectId) {
2979
+ const params = new URLSearchParams({ format: options.format ?? "args" });
2980
+ if (options.pkgRunner) params.set("pkgRunner", options.pkgRunner);
2981
+ if (options.budgetMs != null) params.set("budgetMs", String(options.budgetMs));
2982
+ if (options.shard) params.set("shard", options.shard);
2983
+ if (options.order) params.set("order", options.order);
2984
+ const url = `${options.serverUrl}/api/projects/${projectId}/selections/${encodeURIComponent(options.key)}/resolve?${params}`;
2985
+ const res = await fetch(url, { headers: authHeaders(options.apiKey) });
2986
+ if (!res.ok) {
2987
+ const body = await res.json().catch(() => ({}));
2988
+ throw new Error(body.message || `Dashboard returned ${res.status} resolving "${options.key}"`);
2989
+ }
2990
+ return await res.json();
2991
+ }
2992
+
2993
+ // src/public/selection.ts
2994
+ async function resolveSelection(options = {}) {
2995
+ const env = options.env ?? process.env;
2996
+ const key = options.key ?? env[PIWI_SELECTION_ENV.key];
2997
+ if (!key) return void 0;
2998
+ const serverUrl = (options.serverUrl ?? env[PIWI_ENV_KEYS.serverUrl] ?? "").replace(/\/$/, "");
2999
+ const apiKey = options.apiKey ?? env[PIWI_ENV_KEYS.apiKey] ?? null;
3000
+ const project = options.project ?? env[PIWI_ENV_KEYS.projectName] ?? "";
3001
+ if (!serverUrl) {
3002
+ if (options.strict) throw new Error("resolveSelection: no dashboard URL (set PIWI_DASHBOARD_URL)");
3003
+ return void 0;
3004
+ }
3005
+ const clientOptions = { serverUrl, apiKey, project, key, format: "grep", budgetMs: options.budgetMs ?? null };
3006
+ try {
3007
+ const projectId = await resolveProjectId(clientOptions);
3008
+ const resolution = await fetchResolution(clientOptions, projectId);
3009
+ if (resolution.estimate.count === 0) {
3010
+ if (options.strict) throw new Error(`resolveSelection: "${key}" resolved to 0 tests`);
3011
+ console.warn(`[Piwi Dashboard] selection "${key}" resolved to 0 tests \u2014 running the full suite`);
3012
+ return void 0;
3013
+ }
3014
+ env[PIWI_SELECTION_ENV.key] = resolution.key ?? key;
3015
+ env[PIWI_SELECTION_ENV.version] = String(resolution.version ?? 0);
3016
+ env[PIWI_SELECTION_ENV.hash] = resolution.resolvedHash;
3017
+ env[PIWI_SELECTION_ENV.count] = String(resolution.estimate.count);
3018
+ const args = resolution.materialization.args;
3019
+ const result = {
3020
+ key: resolution.key ?? key,
3021
+ version: resolution.version ?? 0,
3022
+ resolvedHash: resolution.resolvedHash,
3023
+ resolvedCount: resolution.estimate.count
3024
+ };
3025
+ if (args[0] === "--grep") result.grep = args[1];
3026
+ else result.files = args;
3027
+ return result;
3028
+ } catch (e) {
3029
+ if (options.strict) throw e;
3030
+ console.warn(
3031
+ `[Piwi Dashboard] selection "${key}" could not be resolved \u2014 running the full suite (${e.message})`
3032
+ );
3033
+ return void 0;
3034
+ }
3035
+ }
3036
+
2824
3037
  // src/internal/capture/capture-fixtures.ts
2825
3038
  var import_node_zlib = require("zlib");
2826
3039
 
@@ -3092,7 +3305,16 @@ function probeElementAttrs(el, arg) {
3092
3305
  }
3093
3306
  }
3094
3307
  const hasLabel = !!(el.labels && el.labels.length > 0);
3095
- const labelText = includeLabelText ? hasLabel ? (el.labels[0].textContent || "").replace(/\s+/g, " ").trim().slice(0, 120) || null : null : void 0;
3308
+ let labelText;
3309
+ if (includeLabelText) {
3310
+ labelText = null;
3311
+ try {
3312
+ const label = hasLabel ? el.labels[0] : null;
3313
+ labelText = (label && label.textContent || "").replace(/\s+/g, " ").trim().slice(0, 120) || null;
3314
+ } catch {
3315
+ labelText = null;
3316
+ }
3317
+ }
3096
3318
  return {
3097
3319
  tagName: el.tagName?.toLowerCase?.() ?? "unknown",
3098
3320
  attributes: attrMap,
@@ -3796,6 +4018,7 @@ var CAPTURED_ATTRIBUTES = [
3796
4018
  "alt",
3797
4019
  "title",
3798
4020
  "aria-label",
4021
+ "aria-labelledby",
3799
4022
  "aria-level",
3800
4023
  "role",
3801
4024
  "type",
@@ -4387,6 +4610,43 @@ function extractAccessibleName(ariaSnapshot) {
4387
4610
  if (match) return match[1];
4388
4611
  return null;
4389
4612
  }
4613
+ var FORM_FIELD_TAGS = /* @__PURE__ */ new Set(["input", "select", "textarea"]);
4614
+ var NAME_FROM_CONTENT_ROLES = /* @__PURE__ */ new Set([
4615
+ "button",
4616
+ "cell",
4617
+ "checkbox",
4618
+ "columnheader",
4619
+ "gridcell",
4620
+ "heading",
4621
+ "link",
4622
+ "menuitem",
4623
+ "menuitemcheckbox",
4624
+ "menuitemradio",
4625
+ "option",
4626
+ "radio",
4627
+ "row",
4628
+ "rowheader",
4629
+ "switch",
4630
+ "tab",
4631
+ "tooltip",
4632
+ "treeitem"
4633
+ ]);
4634
+ var PROBE_TEXT_CAP = 80;
4635
+ var PROBE_LABEL_TEXT_CAP = 120;
4636
+ function exactAccessibleName(attrs, role) {
4637
+ if (attrs.attributes["aria-labelledby"]) return null;
4638
+ const ariaLabel = attrs.attributes["aria-label"];
4639
+ if (ariaLabel) return ariaLabel;
4640
+ if (FORM_FIELD_TAGS.has(attrs.tagName)) {
4641
+ const labelText = attrs.labelText;
4642
+ return labelText && labelText.length < PROBE_LABEL_TEXT_CAP ? labelText : null;
4643
+ }
4644
+ if (role && NAME_FROM_CONTENT_ROLES.has(role)) {
4645
+ const text = attrs.textContent;
4646
+ return text && text.length < PROBE_TEXT_CAP ? text : null;
4647
+ }
4648
+ return null;
4649
+ }
4390
4650
  var NAME_BASED_METHODS = /* @__PURE__ */ new Set([
4391
4651
  "getByText",
4392
4652
  "getByRole",
@@ -4831,7 +5091,7 @@ function createSink() {
4831
5091
  capturedLocators: [],
4832
5092
  capturePromises: [],
4833
5093
  failedLocators: [],
4834
- expectCapturedLocations: /* @__PURE__ */ new Set(),
5094
+ probedLocations: /* @__PURE__ */ new Set(),
4835
5095
  lastActivePage: null,
4836
5096
  testInfo: null,
4837
5097
  stashedWebVitals: null,
@@ -5058,7 +5318,6 @@ var INSTRUMENTED_CONTEXTS = /* @__PURE__ */ new WeakSet();
5058
5318
  var PATCHED_BROWSERS = /* @__PURE__ */ new WeakSet();
5059
5319
  var CHAIN_METHOD_SET = new Set(CHAIN_METHODS);
5060
5320
  var ACTION_METHOD_SET = new Set(ACTION_METHODS);
5061
- var FORM_FIELD_TAGS = /* @__PURE__ */ new Set(["input", "select", "textarea"]);
5062
5321
  var CAPTURED_ATTRS_ARG = {
5063
5322
  keep: [...CAPTURED_ATTRIBUTES],
5064
5323
  tagRoles: TAG_TO_ROLE,
@@ -5067,11 +5326,32 @@ var CAPTURED_ATTRS_ARG = {
5067
5326
  // special-cased logic in the probe, so add them explicitly).
5068
5327
  roleSources: [.../* @__PURE__ */ new Set(["[role]", "input", "select", ...Object.keys(TAG_TO_ROLE)])].join(","),
5069
5328
  // The reporter always wants ancestor-anchored alternatives (the picker's
5070
- // anchors step and generateAnchoredAlternatives both need them); it derives
5071
- // the accessible name itself, so the probe's own labelText is unneeded.
5329
+ // anchors step and generateAnchoredAlternatives both need them). `labelText`
5330
+ // is what names a form field, and the probe reads it from `el.labels` in the
5331
+ // same pass — so asking for it here is free and settles the accessible name
5332
+ // of a labeled field without a second round trip (`exactAccessibleName`).
5072
5333
  includeStructural: true,
5073
- includeLabelText: false
5334
+ includeLabelText: true
5074
5335
  };
5336
+ var PROBE_GLOBAL = "__piwiProbeElement";
5337
+ var PROBE_INIT_SCRIPT = `(() => {
5338
+ const probe = ${probeElementAttrs.toString()};
5339
+ const arg = ${JSON.stringify(CAPTURED_ATTRS_ARG)};
5340
+ globalThis[${JSON.stringify(PROBE_GLOBAL)}] = (el) => probe(el, arg);
5341
+ })();`;
5342
+ var PROBE_UNSEEDED_PAGES = /* @__PURE__ */ new WeakSet();
5343
+ function probeElement(page, target) {
5344
+ const shipSource = () => target.evaluate(probeElementAttrs, CAPTURED_ATTRS_ARG);
5345
+ if (!page || PROBE_UNSEEDED_PAGES.has(page)) return shipSource();
5346
+ return target.evaluate((el, name) => {
5347
+ const seeded = globalThis[name];
5348
+ return typeof seeded === "function" ? seeded(el) : null;
5349
+ }, PROBE_GLOBAL).then((attrs) => {
5350
+ if (attrs) return attrs;
5351
+ PROBE_UNSEEDED_PAGES.add(page);
5352
+ return shipSource();
5353
+ });
5354
+ }
5075
5355
  async function ariaSnapshotBestEffort(target, timeout) {
5076
5356
  if (typeof target.ariaSnapshot !== "function") return null;
5077
5357
  try {
@@ -5090,8 +5370,8 @@ async function ariaSnapshotBestEffort(target, timeout) {
5090
5370
  }
5091
5371
  }
5092
5372
  }
5093
- function startElementCapture(sink, target, seq, callerLocation, used) {
5094
- const probe = target.evaluate(probeElementAttrs, CAPTURED_ATTRS_ARG);
5373
+ function startElementCapture(sink, page, target, seq, callerLocation, used) {
5374
+ const probe = probeElement(page, target);
5095
5375
  const settledProbe = probe.then(
5096
5376
  () => void 0,
5097
5377
  () => void 0
@@ -5110,8 +5390,9 @@ function startElementCapture(sink, target, seq, callerLocation, used) {
5110
5390
  ]);
5111
5391
  const role = resolveAriaRole({ ...attrs, accessibleName: null });
5112
5392
  const isFormField = FORM_FIELD_TAGS.has(attrs.tagName);
5113
- const aria = role || isFormField ? await ariaSnapshotBestEffort(target, 500) : null;
5114
- const accessibleName = extractAccessibleName(aria) || approximateAccessibleName({ ...attrs, accessibleName: null });
5393
+ const exactName = exactAccessibleName(attrs, role);
5394
+ const aria = exactName === null && (role || isFormField) ? await ariaSnapshotBestEffort(target, 500) : null;
5395
+ const accessibleName = exactName ?? (extractAccessibleName(aria) || approximateAccessibleName({ ...attrs, accessibleName: null }));
5115
5396
  sink.capturedLocators[seq] = {
5116
5397
  location: callerLocation,
5117
5398
  used,
@@ -5131,6 +5412,7 @@ function startElementCapture(sink, target, seq, callerLocation, used) {
5131
5412
  alternatives: generateAlternatives({ ...attrs, accessibleName })
5132
5413
  };
5133
5414
  } catch {
5415
+ if (callerLocation) sink.probedLocations.delete(callerLocation);
5134
5416
  } finally {
5135
5417
  clearTimeout(deadline);
5136
5418
  }
@@ -5167,17 +5449,17 @@ function wrapLocator(page, locator, originMethod, originArgs) {
5167
5449
  args: originArgs,
5168
5450
  raw: `${originMethod}(${JSON.stringify(originArgs)})`
5169
5451
  };
5170
- const alreadyCaptured = callerLocation !== null && sink.expectCapturedLocations.has(callerLocation);
5452
+ const alreadyProbed = callerLocation !== null && sink.probedLocations.has(callerLocation);
5171
5453
  let seq = -1;
5172
- if (!alreadyCaptured) {
5454
+ if (!alreadyProbed) {
5173
5455
  seq = sink.capturedLocators.length;
5174
5456
  sink.capturedLocators.push({ location: callerLocation, used, element: null, alternatives: [] });
5175
5457
  }
5176
5458
  const result = await fn.apply(target, callArgs);
5177
5459
  const matches = result?.matches;
5178
- if (matches === true && !alreadyCaptured) {
5179
- if (callerLocation) sink.expectCapturedLocations.add(callerLocation);
5180
- startElementCapture(sink, target, seq, callerLocation, used);
5460
+ if (matches === true && !alreadyProbed) {
5461
+ if (callerLocation) sink.probedLocations.add(callerLocation);
5462
+ startElementCapture(sink, page, target, seq, callerLocation, used);
5181
5463
  } else if (matches === false) {
5182
5464
  sink.failedLocators.push({ method: originMethod, args: originArgs, location: callerLocation });
5183
5465
  }
@@ -5189,19 +5471,23 @@ function wrapLocator(page, locator, originMethod, originArgs) {
5189
5471
  const sink = currentSink;
5190
5472
  if (!sink) return fn.apply(target, callArgs);
5191
5473
  sink.lastActivePage = page;
5192
- const seq = sink.capturedLocators.length;
5193
5474
  const callerLocation = captureCallerLocation();
5194
5475
  const used = {
5195
5476
  method: originMethod,
5196
5477
  args: originArgs,
5197
5478
  raw: `${originMethod}(${JSON.stringify(originArgs)})`
5198
5479
  };
5199
- sink.capturedLocators.push({
5200
- location: callerLocation,
5201
- used,
5202
- element: null,
5203
- alternatives: []
5204
- });
5480
+ const alreadyProbed = callerLocation !== null && sink.probedLocations.has(callerLocation);
5481
+ let seq = -1;
5482
+ if (!alreadyProbed) {
5483
+ seq = sink.capturedLocators.length;
5484
+ sink.capturedLocators.push({
5485
+ location: callerLocation,
5486
+ used,
5487
+ element: null,
5488
+ alternatives: []
5489
+ });
5490
+ }
5205
5491
  let result;
5206
5492
  try {
5207
5493
  result = await fn.apply(target, callArgs);
@@ -5209,7 +5495,10 @@ function wrapLocator(page, locator, originMethod, originArgs) {
5209
5495
  sink.failedLocators.push({ method: originMethod, args: originArgs, location: callerLocation });
5210
5496
  throw error;
5211
5497
  }
5212
- startElementCapture(sink, target, seq, callerLocation, used);
5498
+ if (!alreadyProbed) {
5499
+ if (callerLocation) sink.probedLocations.add(callerLocation);
5500
+ startElementCapture(sink, page, target, seq, callerLocation, used);
5501
+ }
5213
5502
  return result;
5214
5503
  };
5215
5504
  }
@@ -5242,6 +5531,9 @@ function instrumentPage(page) {
5242
5531
  return wrapLocator(page, original(...args), method, args);
5243
5532
  };
5244
5533
  }
5534
+ if (typeof page.on === "function") {
5535
+ page.on("framenavigated", () => PROBE_UNSEEDED_PAGES.delete(page));
5536
+ }
5245
5537
  }
5246
5538
  page.on("console", (msg) => {
5247
5539
  const sink = currentSink;
@@ -5306,6 +5598,10 @@ function instrumentPage(page) {
5306
5598
  function instrumentContext(context) {
5307
5599
  if (!context || INSTRUMENTED_CONTEXTS.has(context)) return;
5308
5600
  INSTRUMENTED_CONTEXTS.add(context);
5601
+ if (process.env.PIWI_CAPTURE_LOCATORS !== "false" && typeof context.addInitScript === "function") {
5602
+ void Promise.resolve(context.addInitScript({ content: PROBE_INIT_SCRIPT })).catch(() => {
5603
+ });
5604
+ }
5309
5605
  const originalNewPage = context.newPage.bind(context);
5310
5606
  context.newPage = async (...args) => {
5311
5607
  const page = await originalNewPage(...args);
@@ -6279,5 +6575,6 @@ var index_default = PiwiDashboardReporter;
6279
6575
  extendPiwiFixtures,
6280
6576
  piwiAiFixtures,
6281
6577
  piwiFixtures,
6578
+ resolveSelection,
6282
6579
  wrapConfig
6283
6580
  });