@piwitests/reporter 0.58.0 → 0.60.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.
@@ -57,15 +57,18 @@ declare function parseLeafLocatorExpression(rawExpr: string): {
57
57
  declare function deriveFailedLocator(testInfo: TestInfo): FailedLocatorInfo | null;
58
58
  /** What the element → anchors → confirm flow yields: the confirmed locator, its alternatives and the element. */
59
59
  type PickedLocator = Omit<UserPickResult, 'failing'>;
60
+ /** `run`, or undefined as soon as `ended` aborts; `run` itself is left to settle on its own. */
61
+ declare function unlessEnded<T>(run: Promise<T>, ended: AbortSignal | undefined): Promise<T | undefined>;
60
62
  /**
61
63
  * Run the element → anchors → confirm flow on a live page: element pick (snap
62
64
  * + tree-walk), optional stable-parent anchoring with live match counts, ranked
63
65
  * candidates (standard generation merged with anchor-scoped chains), and a
64
66
  * final confirmation. `failing` is the locator the pick replaces, rendered, for
65
67
  * the overlay's headings; null to pick any element. Returns null when the
66
- * human skips or no locator could be generated; throws when the page breaks.
68
+ * human skips, no locator could be generated or `ended` aborts, the overlay
69
+ * then taken out of the page; throws when the page breaks.
67
70
  */
68
- declare function pickLocator(page: Page, probe: PickerProbe, failing: string | null): Promise<PickedLocator | null>;
71
+ declare function pickLocator(page: Page, probe: PickerProbe, failing: string | null, ended?: AbortSignal): Promise<PickedLocator | null>;
69
72
  /**
70
73
  * Drive the full pick flow on the failing page (`pickLocator`), the test
71
74
  * timeout lifted while waiting. Returns null when skipped or when anything
@@ -88,4 +91,4 @@ declare function runLocatorPicker(page: Page, testInfo: TestInfo, failed: Failed
88
91
  */
89
92
  declare function applyPickToSnapshots(snapshots: LocatorSnapshot[], pick: UserPickResult): boolean;
90
93
 
91
- export { type PickedLocator, type PickerProbe, type UserPickResult, applyPickToSnapshots, deriveFailedLocator, parseLeafLocatorExpression, pickLocator, runLocatorPicker };
94
+ export { type PickedLocator, type PickerProbe, type UserPickResult, applyPickToSnapshots, deriveFailedLocator, parseLeafLocatorExpression, pickLocator, runLocatorPicker, unlessEnded };
@@ -39,7 +39,8 @@ __export(pick_on_failure_exports, {
39
39
  pickLocator: () => pickLocator,
40
40
  runLocatorPicker: () => runLocatorPicker,
41
41
  showAnchorPicker: () => showAnchorPicker,
42
- showPickerChoices: () => showPickerChoices
42
+ showPickerChoices: () => showPickerChoices,
43
+ unlessEnded: () => unlessEnded
43
44
  });
44
45
  module.exports = __toCommonJS(pick_on_failure_exports);
45
46
  var path2 = __toESM(require("path"));
@@ -379,10 +380,11 @@ function installPickerOverlay(arg) {
379
380
  }
380
381
  const Z = 2147483600;
381
382
  const testIdAttr = arg.testIdAttribute || "data-testid";
383
+ const enterReaches = arg.transport === "global";
382
384
  const words = {
383
385
  banner: "Piwi inspector \u2014 click any element to generate locators for it",
384
- keys: "\u2191 parent \xB7 \u2193 child \xB7 Esc skip",
385
- keysHovering: "click to pick \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip",
386
+ keys: enterReaches ? "Enter picks the focused element \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip" : "\u2191 parent \xB7 \u2193 child \xB7 Esc skip",
387
+ keysHovering: enterReaches ? "click or Enter to pick \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip" : "click to pick \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip",
386
388
  analyzing: "Analyzing element\u2026"
387
389
  };
388
390
  for (const key of Object.keys(words)) {
@@ -477,6 +479,16 @@ function installPickerOverlay(arg) {
477
479
  const cls = (el.getAttribute && el.getAttribute("class") || "").split(/\s+/).find((c) => c.length > 1);
478
480
  return cls ? `locator(${quoteJs(`.${cls}`)})` : tag;
479
481
  };
482
+ const parentOf = (node) => {
483
+ if (node.parentElement) return node.parentElement;
484
+ const root = node.getRootNode ? node.getRootNode() : null;
485
+ return root && root !== doc && root.host ? root.host : null;
486
+ };
487
+ const targetOf = (e) => {
488
+ const path3 = typeof e.composedPath === "function" ? e.composedPath() : null;
489
+ const first = path3 && path3.length ? path3[0] : null;
490
+ return first && first.nodeType === 1 ? first : e.target;
491
+ };
480
492
  const buildChain = (raw) => {
481
493
  const chain2 = [];
482
494
  let node = raw;
@@ -484,7 +496,7 @@ function installPickerOverlay(arg) {
484
496
  const tag = (node.tagName || "").toLowerCase();
485
497
  if (tag === "body" || tag === "html") break;
486
498
  chain2.push(node);
487
- node = node.parentElement;
499
+ node = parentOf(node);
488
500
  }
489
501
  return chain2.length ? chain2 : [raw];
490
502
  };
@@ -575,10 +587,14 @@ function installPickerOverlay(arg) {
575
587
  }
576
588
  };
577
589
  const onMove = (e) => {
578
- const raw = e.target;
590
+ const raw = targetOf(e);
579
591
  if (!raw || isOwn(raw)) {
592
+ chain = [];
593
+ idx = 0;
594
+ lastRaw = null;
580
595
  highlight.style.display = "none";
581
596
  label.style.display = "none";
597
+ foot.textContent = words.keys;
582
598
  return;
583
599
  }
584
600
  if (raw !== lastRaw) {
@@ -657,17 +673,30 @@ function installPickerOverlay(arg) {
657
673
  const onClick = (e) => {
658
674
  stop(e);
659
675
  const el = current();
660
- if (!el || isOwn(e.target)) return;
676
+ if (!el || isOwn(targetOf(e))) return;
661
677
  reportPicked(el);
662
678
  };
679
+ const focused = () => {
680
+ let el = doc.activeElement;
681
+ while (el && el.shadowRoot && el.shadowRoot.activeElement) el = el.shadowRoot.activeElement;
682
+ const tag = (el && el.tagName || "").toLowerCase();
683
+ return !el || tag === "body" || tag === "html" || isOwn(el) ? null : el;
684
+ };
663
685
  const onKey = (e) => {
664
686
  if (e.key === "Escape") {
665
687
  stop(e);
666
688
  reportSkipped();
667
689
  return;
668
690
  }
691
+ if (e.key === "Enter" && !e.isComposing) {
692
+ stop(e);
693
+ const el = current() || focused();
694
+ if (el) reportPicked(el);
695
+ return;
696
+ }
669
697
  if (e.key === "ArrowUp" || e.key === "ArrowDown") {
670
698
  stop(e);
699
+ if (!chain.length) return;
671
700
  if (e.key === "ArrowUp") idx = Math.min(idx + 1, chain.length - 1);
672
701
  else idx = Math.max(idx - 1, 0);
673
702
  refresh();
@@ -741,12 +770,43 @@ function showAnchorPicker(arg) {
741
770
  const al = n.getAttribute && n.getAttribute("aria-level");
742
771
  return al && /^\d+$/.test(al) ? Number(al) : null;
743
772
  };
773
+ const parentOf = (n) => {
774
+ if (n.parentElement) return n.parentElement;
775
+ const root = n.getRootNode ? n.getRootNode() : null;
776
+ return root && root !== doc && root.host ? root.host : null;
777
+ };
778
+ const shadowRoots = [];
779
+ const collectRoots = (root) => {
780
+ const all = root.querySelectorAll("*");
781
+ for (let i = 0; i < all.length; i++) {
782
+ const sr = all[i].shadowRoot;
783
+ if (sr) {
784
+ shadowRoots.push(sr);
785
+ collectRoots(sr);
786
+ }
787
+ }
788
+ };
789
+ try {
790
+ collectRoots(doc);
791
+ } catch {
792
+ shadowRoots.length = 0;
793
+ }
794
+ const within = (node2, scope) => {
795
+ if (scope === doc) return true;
796
+ for (let n = node2; n; n = parentOf(n)) if (n === scope) return true;
797
+ return false;
798
+ };
799
+ const queryAll = (scope, sel) => {
800
+ const found = Array.from(scope.querySelectorAll(sel));
801
+ for (const sr of shadowRoots) if (within(sr.host, scope)) found.push(...Array.from(sr.querySelectorAll(sel)));
802
+ return found;
803
+ };
744
804
  const leafCountable = !!leafTestId || roleOf(el) === leafRole;
745
805
  const leafMatches = (scope) => {
746
806
  try {
747
- if (leafTestId) return scope.querySelectorAll(`[${testIdAttr}=${JSON.stringify(leafTestId)}]`).length;
807
+ if (leafTestId) return queryAll(scope, `[${testIdAttr}=${JSON.stringify(leafTestId)}]`).length;
748
808
  if (!leafCountable) return -1;
749
- const nodes = scope.querySelectorAll(roleSources);
809
+ const nodes = queryAll(scope, roleSources);
750
810
  if (nodes.length > 2e3) return -1;
751
811
  let matched = 0;
752
812
  for (let i = 0; i < nodes.length; i++) {
@@ -762,20 +822,20 @@ function showAnchorPicker(arg) {
762
822
  };
763
823
  let roleNodes = [];
764
824
  try {
765
- const all = doc.querySelectorAll(roleSources);
766
- if (all.length <= 4e3) roleNodes = Array.from(all);
825
+ const all = queryAll(doc, roleSources);
826
+ if (all.length <= 4e3) roleNodes = all;
767
827
  } catch {
768
828
  roleNodes = [];
769
829
  }
770
830
  const count = (sel) => {
771
831
  try {
772
- return doc.querySelectorAll(sel).length;
832
+ return queryAll(doc, sel).length;
773
833
  } catch {
774
834
  return void 0;
775
835
  }
776
836
  };
777
837
  const rows = [];
778
- let node = el.parentElement;
838
+ let node = parentOf(el);
779
839
  let depth = 0;
780
840
  while (node && depth < 12) {
781
841
  depth++;
@@ -812,7 +872,7 @@ function showAnchorPicker(arg) {
812
872
  hookLabel,
813
873
  selectable: !!(testId || id || role && (ariaLabel || info.roleCount === 1))
814
874
  });
815
- node = node.parentElement;
875
+ node = parentOf(node);
816
876
  }
817
877
  if (rows.length === 0) {
818
878
  g.__piwiAnchorState = "skipped";
@@ -841,9 +901,9 @@ function showAnchorPicker(arg) {
841
901
  footer.style.cssText = "margin:10px 0;font-weight:600;";
842
902
  const segMatches = (scope, info) => {
843
903
  try {
844
- if (info.testId) return Array.from(scope.querySelectorAll(`[${testIdAttr}=${JSON.stringify(info.testId)}]`));
845
- if (info.id) return Array.from(scope.querySelectorAll(`#${doc.defaultView.CSS.escape(info.id)}`));
846
- const nodes = Array.from(scope.querySelectorAll(roleSources));
904
+ if (info.testId) return queryAll(scope, `[${testIdAttr}=${JSON.stringify(info.testId)}]`);
905
+ if (info.id) return queryAll(scope, `#${doc.defaultView.CSS.escape(info.id)}`);
906
+ const nodes = queryAll(scope, roleSources);
847
907
  if (nodes.length > 2e3) return [];
848
908
  return nodes.filter(
849
909
  (n) => roleOf(n) === info.role && (!info.ariaLabel || n.getAttribute && n.getAttribute("aria-label") === info.ariaLabel)
@@ -1557,32 +1617,55 @@ ${err.stack ?? ""}`));
1557
1617
  }
1558
1618
  return null;
1559
1619
  }
1560
- async function cleanupPicker(page) {
1620
+ var PICK_ENDED = "__piwiPickEnded";
1621
+ async function cleanupPicker(page, ended = false) {
1561
1622
  try {
1562
- await page.evaluate(() => {
1563
- const g = globalThis;
1564
- if (typeof g.__piwiPickCleanup === "function") g.__piwiPickCleanup();
1565
- if (typeof g.__piwiAnchorCleanup === "function") g.__piwiAnchorCleanup();
1566
- delete g.__piwiPickCleanup;
1567
- delete g.__piwiAnchorCleanup;
1568
- delete g.__piwiPickState;
1569
- delete g.__piwiPickChoice;
1570
- delete g.__piwiPickedElement;
1571
- delete g.__piwiAnchorState;
1572
- delete g.__piwiPickAnchors;
1573
- delete g.__piwiPickChainCount;
1574
- });
1623
+ await page.evaluate(
1624
+ ([endedKey, unanswered]) => {
1625
+ const g = globalThis;
1626
+ if (typeof g.__piwiPickCleanup === "function") g.__piwiPickCleanup();
1627
+ if (typeof g.__piwiAnchorCleanup === "function") g.__piwiAnchorCleanup();
1628
+ delete g.__piwiPickCleanup;
1629
+ delete g.__piwiAnchorCleanup;
1630
+ delete g.__piwiPickState;
1631
+ delete g.__piwiPickChoice;
1632
+ delete g.__piwiPickedElement;
1633
+ delete g.__piwiAnchorState;
1634
+ delete g.__piwiPickAnchors;
1635
+ delete g.__piwiPickChainCount;
1636
+ if (unanswered) g[endedKey] = true;
1637
+ else delete g[endedKey];
1638
+ },
1639
+ [PICK_ENDED, ended]
1640
+ );
1575
1641
  } catch {
1576
1642
  }
1577
1643
  }
1578
- async function pickLocator(page, probe, failing) {
1644
+ function unlessEnded(run, ended) {
1645
+ if (!ended) return run;
1646
+ return Promise.race([
1647
+ run,
1648
+ new Promise((resolve) => {
1649
+ if (ended.aborted) resolve(void 0);
1650
+ else ended.addEventListener("abort", () => resolve(void 0), { once: true });
1651
+ })
1652
+ ]);
1653
+ }
1654
+ async function pickLocator(page, probe, failing, ended) {
1655
+ const answered = async (name) => {
1656
+ const wait = page.waitForFunction(
1657
+ ([key, endedKey]) => globalThis[key] !== void 0 || globalThis[endedKey] === true,
1658
+ [name, PICK_ENDED],
1659
+ { timeout: 0, polling: 250 }
1660
+ );
1661
+ await unlessEnded(wait, ended);
1662
+ return !ended?.aborted;
1663
+ };
1579
1664
  try {
1580
1665
  const overlayArg = { transport: "global", failing };
1666
+ await page.evaluate((endedKey) => delete globalThis[endedKey], PICK_ENDED);
1581
1667
  await page.evaluate(installPickerOverlay, overlayArg);
1582
- await page.waitForFunction(() => globalThis.__piwiPickState !== void 0, void 0, {
1583
- timeout: 0,
1584
- polling: 250
1585
- });
1668
+ if (!await answered("__piwiPickState")) return null;
1586
1669
  const state = await page.evaluate(() => globalThis.__piwiPickState);
1587
1670
  if (state !== "picked") return null;
1588
1671
  const handle = await page.evaluateHandle(() => globalThis.__piwiPickedElement);
@@ -1603,10 +1686,7 @@ async function pickLocator(page, probe, failing) {
1603
1686
  leafLevel: level,
1604
1687
  leafTestId: attrs.attributes["data-testid"] ?? null
1605
1688
  });
1606
- await page.waitForFunction(() => globalThis.__piwiAnchorState !== void 0, void 0, {
1607
- timeout: 0,
1608
- polling: 250
1609
- });
1689
+ if (!await answered("__piwiAnchorState")) return null;
1610
1690
  const anchorResult = await page.evaluate(() => {
1611
1691
  const g = globalThis;
1612
1692
  return { state: g.__piwiAnchorState, anchors: g.__piwiPickAnchors ?? [], chainCount: g.__piwiPickChainCount };
@@ -1628,10 +1708,7 @@ async function pickLocator(page, probe, failing) {
1628
1708
  failing,
1629
1709
  choices: ranked.map((r) => ({ locator: r.locator, score: r.score }))
1630
1710
  });
1631
- await page.waitForFunction(() => globalThis.__piwiPickChoice !== void 0, void 0, {
1632
- timeout: 0,
1633
- polling: 250
1634
- });
1711
+ if (!await answered("__piwiPickChoice")) return null;
1635
1712
  const choice = await page.evaluate(() => globalThis.__piwiPickChoice);
1636
1713
  if (typeof choice !== "number" || choice < 0 || choice >= ranked.length) return null;
1637
1714
  const picked = { ...ranked[choice], pickedByUser: true };
@@ -1650,7 +1727,7 @@ async function pickLocator(page, probe, failing) {
1650
1727
  ...anchors.length > 0 ? { anchors } : {}
1651
1728
  };
1652
1729
  } finally {
1653
- await cleanupPicker(page);
1730
+ await cleanupPicker(page, ended?.aborted);
1654
1731
  }
1655
1732
  }
1656
1733
  async function runLocatorPicker(page, testInfo, failed, probe) {
@@ -1714,5 +1791,6 @@ function applyPickToSnapshots(snapshots, pick) {
1714
1791
  pickLocator,
1715
1792
  runLocatorPicker,
1716
1793
  showAnchorPicker,
1717
- showPickerChoices
1794
+ showPickerChoices,
1795
+ unlessEnded
1718
1796
  });
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "@piwitests/reporter",
3
- "version": "0.58.0",
3
+ "version": "0.60.0",
4
4
  "description": "Playwright reporter that streams results, traces and HTML reports to a Piwi Dashboard instance",
5
- "url": "https://github.com/PiwiTests/platform",
5
+ "url": "https://github.com/PiwiTests/piwi",
6
6
  "homepage": "https://piwitests.dev",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "https://github.com/PiwiTests/platform"
9
+ "url": "https://github.com/PiwiTests/piwi"
10
10
  },
11
11
  "bugs": {
12
- "url": "https://github.com/PiwiTests/platform/issues"
12
+ "url": "https://github.com/PiwiTests/piwi/issues"
13
13
  },
14
14
  "type": "commonjs",
15
15
  "main": "dist/index.js",
@@ -66,7 +66,7 @@
66
66
  "node": ">=20"
67
67
  },
68
68
  "peerDependencies": {
69
- "@playwright/test": "^1.61.1"
69
+ "@playwright/test": "^1.64.1"
70
70
  },
71
71
  "dependencies": {
72
72
  "form-data": "^4.0.0"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: run-the-right-tests
3
- description: Pick and run the right subset of Playwright tests using Piwi's data-driven selections, instead of always running the whole suite. Use when the user asks to "run smoke tests", "run the right tests", "just run what's relevant", "verify this fix", or wants a fast, targeted test loop rather than the full run.
3
+ description: Pick and run the right subset of Playwright tests using Piwi's data-driven selections and the tests a change impacts, instead of always running the whole suite. Use when the user asks to "run smoke tests", "run the right tests", "just run what's relevant", "run the tests my change affects", "verify this fix", or wants a fast, targeted test loop rather than the full run.
4
4
  ---
5
5
 
6
6
  # Run the right tests with Piwi selections
@@ -9,8 +9,8 @@ Running the whole suite for every change is slow, and hand-maintained grep patte
9
9
 
10
10
  ## How you reach Piwi
11
11
 
12
- Prefer the **Piwi MCP server** if it is connected (`list_selections`, `resolve_selection`, `preview_selection`,
13
- `predict_locator_breaks`). Otherwise use the reporter CLI (`npx @piwitests/reporter select|run|preflight`) with `PIWI_DASHBOARD_URL` / `PIWI_API_KEY` / `PIWI_PROJECT_NAME` set, or the dashboard's **Selections** tab.
12
+ Prefer the **Piwi MCP server** if it is connected (`get_impacted_tests`, `list_selections`, `resolve_selection`,
13
+ `preview_selection`, `predict_locator_breaks`). Otherwise use the reporter CLI (`npx @piwitests/reporter select|run|preflight`) with `PIWI_DASHBOARD_URL` / `PIWI_API_KEY` / `PIWI_PROJECT_NAME` set, or the dashboard's **Selections** tab.
14
14
 
15
15
  ## Steps
16
16
 
@@ -25,22 +25,33 @@ Prefer the **Piwi MCP server** if it is connected (`list_selections`, `resolve_s
25
25
  your `git diff` and returns the same breaks and edits. A prediction is not a failure: say which breaks you fixed and
26
26
  which you left, and why.
27
27
 
28
- 1. **See what selections exist.** Call `list_selections` for the project. Every project has the built-ins `failed` (tests whose latest run failed) and `quarantine-free`, plus any the team saved (often `smoke`). Pick the one that matches the intent:
28
+ 1. **After any code change, run the tests it impacts.** Over MCP, call `get_impacted_tests` with your diff
29
+ (`git diff --full-index <base>`, the base being the commit your change starts from, such as `origin/main`). Each
30
+ changed file lists the tests it selects and how: `lines` (the test ran a changed line), `functions` (it ran the
31
+ function around one), `file` (its coverage cannot rule it out), `test-file`, `frames` or `route`. Run the
32
+ `command` it returns, or, from the CLI, `npx @piwitests/reporter run impact --base origin/main`. Read the answer
33
+ before trusting it:
34
+ - `widened` means a changed file maps to no test, so the command runs the whole suite. Say which files did that.
35
+ - A file's `notRun` lines are changed code no test runs: no test can catch a mistake there. Name them, and offer
36
+ to write the test (the `write-the-missing-test` skill).
37
+ - The tests marked `lines` or `test-file` are the first to check for edits a change in behavior needs.
38
+
39
+ 2. **See what selections exist.** Call `list_selections` for the project. Every project has the built-ins `failed` (tests whose latest run failed) and `quarantine-free`, plus any the team saved (often `smoke`). Pick the one that matches the intent:
29
40
  - About to make a broad change, or want a quick confidence check → `smoke` (or `preview_selection` with `{ "include": [{ "tags": ["smoke"] }] }`).
30
41
  - Iterating on a fix → `failed`, or a `{ "include": [{ "failedInLastRuns": 5 }] }` preview.
31
42
  - Time-boxed → resolve any selection with a `budgetMs` (e.g. 300000 for five minutes).
32
43
 
33
- 2. **Resolve it to a command.** Call `resolve_selection` with the key (and optional `budgetMs`). You get back the matching tests, an estimate, and a ready-to-run `playwright test` command. Check the count and warnings first — a selection that matches **zero** tests is a red flag (too narrow, or nothing qualifies), and a `quarantined-included` warning means a flaky test is in the set.
44
+ 3. **Resolve it to a command.** Call `resolve_selection` with the key (and optional `budgetMs`). You get back the matching tests, an estimate, and a ready-to-run `playwright test` command. Check the count and warnings first — a selection that matches **zero** tests is a red flag (too narrow, or nothing qualifies), and a `quarantined-included` warning means a flaky test is in the set.
34
45
 
35
- 3. **Run it.** The most reliable way is the reporter CLI, which resolves and runs in one step and stamps the run so the dashboard names the subset:
46
+ 4. **Run it.** The most reliable way is the reporter CLI, which resolves and runs in one step and stamps the run so the dashboard names the subset:
36
47
  ```
37
48
  npx @piwitests/reporter run <key>
38
49
  ```
39
50
  Or run the materialized command `resolve_selection` returned directly with `playwright test`. Pass extra Playwright args after `--` (e.g. `-- --workers=4`).
40
51
 
41
- 4. **After a fix, verify with the same selection.** Re-run the selection that covered the failure and confirm it now passes. In CI, `npx @piwitests/reporter gate --require-selection <key>` fails the build if any test the selection currently matches did not run or did not pass — catching a smoke job that silently shrank.
52
+ 5. **After a fix, verify with the same selection.** Re-run the selection that covered the failure and confirm it now passes. In CI, `npx @piwitests/reporter gate --require-selection <key>` fails the build if any test the selection currently matches did not run or did not pass — catching a smoke job that silently shrank.
42
53
 
43
- 5. **Report.** State which selection you ran, how many tests it resolved to, and the outcome. If no saved selection fit, suggest one (a `preview_selection` definition the team could save) rather than falling back to the whole suite silently.
54
+ 6. **Report.** State which selection you ran, how many tests it resolved to, and the outcome. If no saved selection fit, suggest one (a `preview_selection` definition the team could save) rather than falling back to the whole suite silently.
44
55
 
45
56
  ## Guardrails
46
57
 
@@ -13,6 +13,9 @@ Find out two things (ask the user only if you cannot infer them):
13
13
 
14
14
  - **Dashboard URL** — e.g. `http://localhost:3000` for a local server, or a deployed URL. Defaults to `http://localhost:3000`.
15
15
  - **Project name** — the label runs are grouped under. Defaults to the package or folder name.
16
+ - **Where local runs go** — when developers run the Piwi desktop app and CI reports to a shared server, add
17
+ `--desktop-app prefer`: a local run lands in the app while it is running, and CI runs go to `--server-url`. When the
18
+ desktop app is the only dashboard, pass `--no-server-url` instead of a URL: the reporter finds the running app itself.
16
19
 
17
20
  Confirm this is a Playwright project: there should be a `playwright.config.ts|js` and `@playwright/test` in `package.json`. If there is no Playwright at all, set that up first.
18
21
 
@@ -47,7 +50,7 @@ change it to import from the fixtures file instead, e.g. `import { test, expect
47
50
 
48
51
  ## Authentication
49
52
 
50
- If the dashboard has auth enabled, runs need an API key. Have the user create one in the dashboard (**Settings → Account → API keys**; keys start with `pd_`), then put it in `.env` as `PIWI_API_KEY=pd_...` and make sure `.env` is git-ignored. Never hardcode a key into `playwright.config.ts` or commit it. In CI, pass it as a secret (`PIWI_API_KEY`).
53
+ If the dashboard has auth enabled, runs need an API key. Have the user create one in the dashboard (**Settings → Account → API keys**; keys start with `pd_`), then put it in `.env` as `PIWI_API_KEY=pd_...` and make sure `.env` is git-ignored. The reporter reads `PIWI_DASHBOARD_URL`, `PIWI_API_KEY` and `PIWI_PROJECT_NAME` from the `.env` next to `playwright.config`, so the config needs no dotenv loading; a variable set in the environment wins over the file. Never hardcode a key into `playwright.config.ts` or commit it. In CI, where `.env` is not checked out, pass it as a secret (`PIWI_API_KEY`).
51
54
 
52
55
  ## Verify it worked
53
56