@piwitests/reporter 0.49.0 → 0.51.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.
@@ -648,11 +648,17 @@ function wildcard(pattern) {
648
648
  const escaped = pattern.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
649
649
  return new RegExp(`^${escaped.join(".*")}$`, "i");
650
650
  }
651
- function selectedProjects(projects, argv = process.argv) {
652
- const names = cliProjectNames(argv);
653
- if (!names || names.length === 0) return projects;
654
- const patterns = names.map(wildcard);
655
- const chosen = new Set(projects.filter((p) => patterns.some((re) => re.test(p.name ?? ""))));
651
+ function selectedProjects(projects, argv = process.argv, filtered) {
652
+ let chosen;
653
+ if (Array.isArray(filtered)) {
654
+ const names = new Set(filtered.map((p) => p.name).filter(Boolean));
655
+ chosen = new Set(projects.filter((p) => filtered.includes(p) || !!p.name && names.has(p.name)));
656
+ } else {
657
+ const names = cliProjectNames(argv);
658
+ if (!names || names.length === 0) return projects;
659
+ const patterns = names.map(wildcard);
660
+ chosen = new Set(projects.filter((p) => patterns.some((re) => re.test(p.name ?? ""))));
661
+ }
656
662
  if (!argv.includes("--no-deps")) {
657
663
  const byName = new Map(projects.map((p) => [p.name ?? "", p]));
658
664
  const queue = [...chosen];
@@ -674,8 +680,9 @@ function asProxy(value) {
674
680
  }
675
681
  function baseUrlTargets(config, argv = process.argv) {
676
682
  const projects = config.projects?.length ? config.projects : [{ name: "", use: config.use }];
683
+ const filtered = config.projects?.length ? config.filteredProjects : void 0;
677
684
  const targets = /* @__PURE__ */ new Map();
678
- for (const project of selectedProjects(projects, argv)) {
685
+ for (const project of selectedProjects(projects, argv, filtered)) {
679
686
  const url = project.use?.baseURL;
680
687
  if (typeof url !== "string" || !/^https?:\/\//i.test(url)) continue;
681
688
  const target = targets.get(url);
package/dist/index.d.ts CHANGED
@@ -175,7 +175,9 @@ interface PiwiDashboardOptions {
175
175
  * runs no test and reaches no dashboard. The check runs in the reporter's
176
176
  * global setup, after `webServer` has started and before the config's own
177
177
  * `globalSetup`, so leave it off when that setup is what starts the app. Only
178
- * the projects `--project` selects are checked. **Defaults to `false`**. Can
178
+ * the projects the run selects are checked, with their dependencies: the ones
179
+ * `--project` names, or every project but those declared `default: false`
180
+ * (Playwright 1.64+). **Defaults to `false`**. Can
179
181
  * also be set with `PIWI_CHECK_BASE_URL=true`.
180
182
  */
181
183
  checkBaseUrl?: boolean;
package/dist/index.js CHANGED
@@ -3831,11 +3831,17 @@ function wildcard(pattern) {
3831
3831
  const escaped = pattern.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
3832
3832
  return new RegExp(`^${escaped.join(".*")}$`, "i");
3833
3833
  }
3834
- function selectedProjects(projects, argv = process.argv) {
3835
- const names = cliProjectNames(argv);
3836
- if (!names || names.length === 0) return projects;
3837
- const patterns = names.map(wildcard);
3838
- const chosen = new Set(projects.filter((p) => patterns.some((re) => re.test(p.name ?? ""))));
3834
+ function selectedProjects(projects, argv = process.argv, filtered) {
3835
+ let chosen;
3836
+ if (Array.isArray(filtered)) {
3837
+ const names = new Set(filtered.map((p) => p.name).filter(Boolean));
3838
+ chosen = new Set(projects.filter((p) => filtered.includes(p) || !!p.name && names.has(p.name)));
3839
+ } else {
3840
+ const names = cliProjectNames(argv);
3841
+ if (!names || names.length === 0) return projects;
3842
+ const patterns = names.map(wildcard);
3843
+ chosen = new Set(projects.filter((p) => patterns.some((re) => re.test(p.name ?? ""))));
3844
+ }
3839
3845
  if (!argv.includes("--no-deps")) {
3840
3846
  const byName = new Map(projects.map((p) => [p.name ?? "", p]));
3841
3847
  const queue = [...chosen];
@@ -3857,8 +3863,9 @@ function asProxy(value) {
3857
3863
  }
3858
3864
  function baseUrlTargets(config, argv = process.argv) {
3859
3865
  const projects = config.projects?.length ? config.projects : [{ name: "", use: config.use }];
3866
+ const filtered = config.projects?.length ? config.filteredProjects : void 0;
3860
3867
  const targets = /* @__PURE__ */ new Map();
3861
- for (const project of selectedProjects(projects, argv)) {
3868
+ for (const project of selectedProjects(projects, argv, filtered)) {
3862
3869
  const url = project.use?.baseURL;
3863
3870
  if (typeof url !== "string" || !/^https?:\/\//i.test(url)) continue;
3864
3871
  const target = targets.get(url);
@@ -4218,7 +4225,9 @@ function probeItemForTest(test) {
4218
4225
  return matchProbeItem(plan, { title: test.title, filePath, suitePath });
4219
4226
  }
4220
4227
  function classifyProbeHandled(signals) {
4221
- return signals.consoleErrors > 0 || signals.dialogs > 0 || signals.backendError ? "degraded" : "graceful";
4228
+ const blank = signals.blankPage === true;
4229
+ if ((signals.pageErrors ?? 0) > 0 || signals.backendError && blank) return "unhandled";
4230
+ return signals.consoleErrors > 0 || signals.dialogs > 0 || signals.backendError || blank ? "degraded" : "graceful";
4222
4231
  }
4223
4232
  function outcomeFromStatus(status, applied) {
4224
4233
  if (!applied) return "inconclusive";
@@ -12094,6 +12103,8 @@ function createSink() {
12094
12103
  stashedAriaJson: null,
12095
12104
  pageInventories: [],
12096
12105
  probeItem: null,
12106
+ probePageErrors: 0,
12107
+ probeBlankPage: false,
12097
12108
  probeInterception: null,
12098
12109
  flake: null,
12099
12110
  pickOffered: false,
@@ -12378,9 +12389,16 @@ async function stashPageState(sink, closing) {
12378
12389
  if (!belongsToClosing) return;
12379
12390
  await internalCall(page, () => readPageBeforeClose(sink, page));
12380
12391
  }
12392
+ async function readBlankPage(page) {
12393
+ await page.waitForLoadState("domcontentloaded", { timeout: 1e3 }).catch(() => {
12394
+ });
12395
+ const aria = await ariaSnapshotBestEffort(page.locator("body"), 1e3);
12396
+ return aria != null && aria.trim() === "";
12397
+ }
12381
12398
  async function readPageBeforeClose(sink, page) {
12382
12399
  const vitals = await readWebVitals(page);
12383
12400
  if (vitals) sink.stashedWebVitals = vitals;
12401
+ if (sink.probeItem?.level === "server") sink.probeBlankPage = await readBlankPage(page);
12384
12402
  if (process.env.PIWI_CAPTURE_PAGE_STATE !== "false") {
12385
12403
  const pageState = await readPageState(page);
12386
12404
  if (pageState) sink.stashedPageState = pageState;
@@ -12783,6 +12801,10 @@ function instrumentPage(page) {
12783
12801
  });
12784
12802
  });
12785
12803
  }
12804
+ page.on("pageerror", () => {
12805
+ const sink = currentSink;
12806
+ if (sink?.probeItem?.level === "server" && sink.probeInterception?.applied()) sink.probePageErrors++;
12807
+ });
12786
12808
  page.on("console", (msg) => {
12787
12809
  const sink = currentSink;
12788
12810
  if (!sink) return;
@@ -12839,6 +12861,9 @@ function instrumentPage(page) {
12839
12861
  };
12840
12862
  if (failure) entry.failure = failure;
12841
12863
  if (response) {
12864
+ if (timing.requestStart < 0 && typeof response.serverAddr === "function") {
12865
+ if (await response.serverAddr().catch(() => void 0) === null) entry.fulfilled = true;
12866
+ }
12842
12867
  const headers = response.headers();
12843
12868
  const contentType = headers["content-type"];
12844
12869
  if (contentType) entry.contentType = contentType.split(";")[0].trim();
@@ -13162,10 +13187,12 @@ var piwiFixtures = {
13162
13187
  if (sink.probeItem) {
13163
13188
  const applied = sink.probeInterception?.applied() ?? false;
13164
13189
  const level = sink.probeItem.level ?? "client";
13165
- const handled = level === "server" ? classifyProbeHandled({
13190
+ const handled = level === "server" && applied ? classifyProbeHandled({
13166
13191
  consoleErrors: sink.consoleEntries.filter((e) => e.type === "error").length,
13167
13192
  dialogs: sink.dialogs.length,
13168
- backendError: sink.probeInterception?.serverError() ?? false
13193
+ backendError: sink.probeInterception?.serverError() ?? false,
13194
+ pageErrors: sink.probePageErrors,
13195
+ blankPage: sink.probeBlankPage
13169
13196
  }) : "n/a";
13170
13197
  recordProbeOutcome({
13171
13198
  testCaseId: sink.probeItem.testCaseId,
@@ -13693,6 +13720,97 @@ function compileFromCandidate(candidate) {
13693
13720
  return null;
13694
13721
  }
13695
13722
 
13723
+ // src/internal/ai/ground.ts
13724
+ var GROUNDED_METHODS = new Set(LOCATOR_METHODS.filter((method) => method !== "locator"));
13725
+ async function refLocator(page, ref) {
13726
+ if (!/^[a-z0-9]+$/i.test(ref)) return null;
13727
+ const getByRef = page.getByRef;
13728
+ try {
13729
+ const target = typeof getByRef === "function" ? getByRef.call(page, ref) : page.locator(`aria-ref=${ref}`);
13730
+ return await target.count() === 1 ? target : null;
13731
+ } catch {
13732
+ return null;
13733
+ }
13734
+ }
13735
+ async function matchesOnly(candidate, target) {
13736
+ try {
13737
+ return await candidate.count() === 1 && await candidate.and(target).count() === 1;
13738
+ } catch {
13739
+ return false;
13740
+ }
13741
+ }
13742
+ async function includes(candidate, target) {
13743
+ try {
13744
+ return await candidate.and(target).count() === 1;
13745
+ } catch {
13746
+ return false;
13747
+ }
13748
+ }
13749
+ function plainArg(arg, params, mask = true) {
13750
+ switch (arg.type) {
13751
+ case "string":
13752
+ return mask ? maskValues(arg.value, params) : arg.value;
13753
+ case "number":
13754
+ case "boolean":
13755
+ return arg.value;
13756
+ case "object": {
13757
+ const out = {};
13758
+ for (const [key, value] of arg.entries) {
13759
+ const plain = plainArg(value, params, mask);
13760
+ if (plain === void 0) return void 0;
13761
+ out[key] = plain;
13762
+ }
13763
+ return out;
13764
+ }
13765
+ default:
13766
+ return void 0;
13767
+ }
13768
+ }
13769
+ function chainToStructured(calls, params = {}) {
13770
+ const links = [];
13771
+ for (const call of calls) {
13772
+ if (!GROUNDED_METHODS.has(call.method)) return null;
13773
+ const args = [];
13774
+ for (const arg of call.args) {
13775
+ const plain = plainArg(arg, params, call.method !== "getByTestId");
13776
+ if (plain === void 0) return null;
13777
+ args.push(plain);
13778
+ }
13779
+ links.push({ method: call.method, args });
13780
+ }
13781
+ const [head, ...chain] = links;
13782
+ if (!head) return null;
13783
+ return chain.length > 0 ? { ...head, chain } : head;
13784
+ }
13785
+ async function normalizedLocator(target, params) {
13786
+ if (typeof target.normalize !== "function") return null;
13787
+ try {
13788
+ const chain = tryParseLocatorChain(String(await target.normalize()));
13789
+ return chain ? chainToStructured(chain.calls, params) : null;
13790
+ } catch {
13791
+ return null;
13792
+ }
13793
+ }
13794
+ async function groundElement(page, element, params) {
13795
+ const compiled = compileFromCandidate({
13796
+ role: element.role,
13797
+ name: element.name ?? null,
13798
+ level: element.level ?? null
13799
+ });
13800
+ const target = element.ref ? await refLocator(page, element.ref) : null;
13801
+ if (!target) return compiled;
13802
+ if (compiled) {
13803
+ const byName = buildLocator(page, compiled.locator, params);
13804
+ if (await matchesOnly(byName, target)) return compiled;
13805
+ if (!await includes(byName, target)) return compiled;
13806
+ }
13807
+ const normalized = await normalizedLocator(target, params);
13808
+ if (!normalized || !await matchesOnly(buildLocator(page, normalized, params), target)) return compiled;
13809
+ const fingerprint = compiled?.fingerprint ?? { role: element.role, name: element.name ?? null };
13810
+ if (!compiled && element.level != null) fingerprint.level = element.level;
13811
+ return { locator: normalized, fingerprint, score: compiled?.score ?? 0 };
13812
+ }
13813
+
13696
13814
  // src/internal/ai/resolution.ts
13697
13815
  var MAX_SNAPSHOT_CHARS = 24e3;
13698
13816
  var DEFAULT_MAX_STEPS = 20;
@@ -13713,12 +13831,6 @@ var ServerStepResolver = class {
13713
13831
  return await res.json();
13714
13832
  }
13715
13833
  };
13716
- function candidateOf(element) {
13717
- return { role: element.role, name: element.name ?? null, level: element.level ?? null };
13718
- }
13719
- function locatorFromElement(element) {
13720
- return compileFromCandidate(candidateOf(element));
13721
- }
13722
13834
  function lazyLocator(resolveOnce) {
13723
13835
  let pending = null;
13724
13836
  const ensure = () => pending ??= resolveOnce();
@@ -13730,20 +13842,21 @@ function lazyLocator(resolveOnce) {
13730
13842
  };
13731
13843
  return new Proxy({}, handler);
13732
13844
  }
13733
- function buildPostcondition(resolved, template) {
13845
+ async function buildPostcondition(resolved, template, ctx) {
13734
13846
  if (!resolved)
13735
13847
  throw new Error(`piwi AI: flow "${template}" resolved without a postcondition (the oracle is mandatory)`);
13736
13848
  if (resolved.assert === "url") {
13737
13849
  if (!resolved.url) throw new Error(`piwi AI: flow "${template}" postcondition is a url assert without a url`);
13738
13850
  return { assert: "url", url: resolved.url };
13739
13851
  }
13740
- const compiled = resolved.element ? locatorFromElement(resolved.element) : null;
13852
+ const compiled = resolved.element ? await groundElement(ctx.page, resolved.element, ctx.params) : null;
13741
13853
  if (!compiled) throw new Error(`piwi AI: flow "${template}" postcondition element could not be compiled`);
13742
13854
  return { assert: resolved.assert, locator: compiled.locator };
13743
13855
  }
13744
13856
  var readMaskedSnapshot = async (page, params) => {
13745
13857
  const raw = await ariaSnapshotBestEffort(page.locator("body"));
13746
- return raw ? maskValues(raw, params) : "";
13858
+ if (!raw) return "";
13859
+ return raw.split(/(\[ref=\w+\])/).map((part, i) => i % 2 === 1 ? part : maskValues(part, params)).join("");
13747
13860
  };
13748
13861
  var observeXhrResponses = async (page, action, settleMs) => {
13749
13862
  const urls = [];
@@ -13795,7 +13908,7 @@ async function resolveLocator(template, ctx) {
13795
13908
  ...screenshot ? { screenshot } : {}
13796
13909
  });
13797
13910
  if (!decision.element) throw new Error(`piwi AI: resolver returned no element for "${template}"`);
13798
- const compiled = locatorFromElement(decision.element);
13911
+ const compiled = await groundElement(ctx.page, decision.element, ctx.params);
13799
13912
  if (!compiled) throw new Error(`piwi AI: element for "${template}" could not be compiled to a stable locator`);
13800
13913
  const entry = {
13801
13914
  version: ARTIFACT_VERSION,
@@ -13828,14 +13941,14 @@ async function resolveRun(template, ctx) {
13828
13941
  ...screenshot ? { screenshot } : {}
13829
13942
  });
13830
13943
  if (decision.done) {
13831
- const postcondition = buildPostcondition(decision.postcondition, template);
13944
+ const postcondition = await buildPostcondition(decision.postcondition, template, ctx);
13832
13945
  await assertPostcondition(postcondition, { page: ctx.page, params: ctx.params });
13833
13946
  return { version: ARTIFACT_VERSION, kind: "run", template, steps, postcondition };
13834
13947
  }
13835
13948
  if (!decision.element || !decision.action) {
13836
13949
  throw new Error(`piwi AI: resolver returned neither a step nor done for "${template}"`);
13837
13950
  }
13838
- const compiled = locatorFromElement(decision.element);
13951
+ const compiled = await groundElement(ctx.page, decision.element, ctx.params);
13839
13952
  if (!compiled) throw new Error(`piwi AI: a step element for "${template}" could not be compiled`);
13840
13953
  const step = { locator: compiled.locator, action: decision.action, fingerprint: compiled.fingerprint };
13841
13954
  if (decision.value !== void 0) step.value = decision.value;
@@ -2416,7 +2416,9 @@ function probeItemForTest(test) {
2416
2416
  return matchProbeItem(plan, { title: test.title, filePath, suitePath });
2417
2417
  }
2418
2418
  function classifyProbeHandled(signals) {
2419
- return signals.consoleErrors > 0 || signals.dialogs > 0 || signals.backendError ? "degraded" : "graceful";
2419
+ const blank = signals.blankPage === true;
2420
+ if ((signals.pageErrors ?? 0) > 0 || signals.backendError && blank) return "unhandled";
2421
+ return signals.consoleErrors > 0 || signals.dialogs > 0 || signals.backendError || blank ? "degraded" : "graceful";
2420
2422
  }
2421
2423
  function outcomeFromStatus(status, applied) {
2422
2424
  if (!applied) return "inconclusive";
@@ -5304,6 +5306,8 @@ function createSink() {
5304
5306
  stashedAriaJson: null,
5305
5307
  pageInventories: [],
5306
5308
  probeItem: null,
5309
+ probePageErrors: 0,
5310
+ probeBlankPage: false,
5307
5311
  probeInterception: null,
5308
5312
  flake: null,
5309
5313
  pickOffered: false,
@@ -5588,9 +5592,16 @@ async function stashPageState(sink, closing) {
5588
5592
  if (!belongsToClosing) return;
5589
5593
  await internalCall(page, () => readPageBeforeClose(sink, page));
5590
5594
  }
5595
+ async function readBlankPage(page) {
5596
+ await page.waitForLoadState("domcontentloaded", { timeout: 1e3 }).catch(() => {
5597
+ });
5598
+ const aria = await ariaSnapshotBestEffort(page.locator("body"), 1e3);
5599
+ return aria != null && aria.trim() === "";
5600
+ }
5591
5601
  async function readPageBeforeClose(sink, page) {
5592
5602
  const vitals = await readWebVitals(page);
5593
5603
  if (vitals) sink.stashedWebVitals = vitals;
5604
+ if (sink.probeItem?.level === "server") sink.probeBlankPage = await readBlankPage(page);
5594
5605
  if (process.env.PIWI_CAPTURE_PAGE_STATE !== "false") {
5595
5606
  const pageState = await readPageState(page);
5596
5607
  if (pageState) sink.stashedPageState = pageState;
@@ -5993,6 +6004,10 @@ function instrumentPage(page) {
5993
6004
  });
5994
6005
  });
5995
6006
  }
6007
+ page.on("pageerror", () => {
6008
+ const sink = currentSink;
6009
+ if (sink?.probeItem?.level === "server" && sink.probeInterception?.applied()) sink.probePageErrors++;
6010
+ });
5996
6011
  page.on("console", (msg) => {
5997
6012
  const sink = currentSink;
5998
6013
  if (!sink) return;
@@ -6049,6 +6064,9 @@ function instrumentPage(page) {
6049
6064
  };
6050
6065
  if (failure) entry.failure = failure;
6051
6066
  if (response) {
6067
+ if (timing.requestStart < 0 && typeof response.serverAddr === "function") {
6068
+ if (await response.serverAddr().catch(() => void 0) === null) entry.fulfilled = true;
6069
+ }
6052
6070
  const headers = response.headers();
6053
6071
  const contentType = headers["content-type"];
6054
6072
  if (contentType) entry.contentType = contentType.split(";")[0].trim();
@@ -6372,10 +6390,12 @@ var piwiFixtures = {
6372
6390
  if (sink.probeItem) {
6373
6391
  const applied = sink.probeInterception?.applied() ?? false;
6374
6392
  const level = sink.probeItem.level ?? "client";
6375
- const handled = level === "server" ? classifyProbeHandled({
6393
+ const handled = level === "server" && applied ? classifyProbeHandled({
6376
6394
  consoleErrors: sink.consoleEntries.filter((e) => e.type === "error").length,
6377
6395
  dialogs: sink.dialogs.length,
6378
- backendError: sink.probeInterception?.serverError() ?? false
6396
+ backendError: sink.probeInterception?.serverError() ?? false,
6397
+ pageErrors: sink.probePageErrors,
6398
+ blankPage: sink.probeBlankPage
6379
6399
  }) : "n/a";
6380
6400
  recordProbeOutcome({
6381
6401
  testCaseId: sink.probeItem.testCaseId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piwitests/reporter",
3
- "version": "0.49.0",
3
+ "version": "0.51.0",
4
4
  "description": "Playwright reporter that streams results, traces and HTML reports to a Piwi Dashboard instance",
5
5
  "url": "https://github.com/PiwiTests/platform",
6
6
  "homepage": "https://piwitests.dev",
@@ -28,7 +28,7 @@ Prefer the **Piwi MCP server** if it is connected to this agent (tools named `li
28
28
  7. **Verify.** Re-run the affected spec(s): `npx playwright test <file>`. Confirm they pass and the new run is green in the dashboard (`get_run_insights` compares against the last green run: regressions cleared, nothing new broken).
29
29
 
30
30
  8. **Report back.** Piwi learns from what you tell it, so end every investigation with these calls (they need reporter or admin access; each is logged with your API key):
31
- - **Your diagnosis.** If the cluster had no diagnosis, or yours differs, record it with `record_diagnosis`: the model you run on and the diagnosis in the schema the tool describes (summary, confidenceScore, severity, ranked hypotheses with their evidence, suggestedFix with the unified-diff `patch`). Piwi validates the patch against the source that failed and shows the diagnosis as written by an agent.
31
+ - **Your diagnosis.** If the cluster had no diagnosis, or yours differs, record it with `record_diagnosis` (`clusterId`, or `executionId` for a failure in no cluster or with a cause of its own): the model you run on and the diagnosis in the schema the tool describes (summary, confidenceScore, severity, ranked hypotheses with their evidence, suggestedFix with the unified-diff `patch`). Piwi validates the patch against the source that failed and shows the diagnosis as written by an agent.
32
32
  - **Your rating of Piwi's diagnosis.** If you read one with `get_cluster_diagnosis`, rate it with `submit_diagnosis_feedback`: `up` when it pointed at the real cause, `down` with a one-line note on what it got wrong when it did not. A `down` keeps the next diagnosis from repeating it.
33
33
  - **Your fix.** Commit the change with the trailer `get_fix_plan` returns in `verify.commitTrailer` (`Piwi-Cluster: <clusterId>`) as the last line of the commit message, then call `report_fix_attempt` with the cluster, the kind (`patch`, `locator-edit` or `fix-plan`), the commit or the branch, the patch you applied and the `diagnosisId` you followed. When the tests pass on that commit, Piwi records the attempt verified and shows it on the cluster's activity.
34
34
  - Leave the cluster's status alone: the run that passes on your commit records the fix.