@piwitests/reporter 0.45.0 → 0.46.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/cli/index.js CHANGED
@@ -261,7 +261,7 @@ function checkAiTree(root, opts = {}) {
261
261
  severity: "error",
262
262
  kind: "non-canonical",
263
263
  file: rel(found.file),
264
- message: "file is not in canonical form \u2014 run `piwi ai prune --apply` or re-resolve to rewrite it"
264
+ message: "file is not in canonical form \u2014 re-author it with `piwi ai resolve --update-ai`"
265
265
  });
266
266
  }
267
267
  if (!sourceCache.has(found.specFile)) sourceCache.set(found.specFile, readTextOr(found.specFile, null));
@@ -428,7 +428,9 @@ async function runAi(argv, env = process.env) {
428
428
  case "resolve":
429
429
  return runResolve(rest, env);
430
430
  case "prune":
431
- console.error("piwi ai prune: three-tier cleanup is not available in this build yet.");
431
+ console.error(
432
+ "piwi ai prune: not available in this build yet. `piwi ai check` lists orphaned entries: delete their files by hand, and re-author a non-canonical one with `piwi ai resolve --update-ai`."
433
+ );
432
434
  return EXIT_ERROR;
433
435
  default:
434
436
  console.error(`piwi ai: unknown command "${sub}"
@@ -439,10 +441,164 @@ async function runAi(argv, env = process.env) {
439
441
  }
440
442
 
441
443
  // src/cli/bug.ts
442
- var fs3 = __toESM(require("fs"));
443
- var path6 = __toESM(require("path"));
444
+ var fs4 = __toESM(require("fs"));
445
+ var path7 = __toESM(require("path"));
444
446
  var import_node_child_process2 = require("child_process");
445
447
  var import_node_module = require("module");
448
+
449
+ // src/internal/config/desktop.ts
450
+ var fs3 = __toESM(require("fs"));
451
+ var os = __toESM(require("os"));
452
+ var path6 = __toESM(require("path"));
453
+ function defaultDesktopConfigPath() {
454
+ return path6.join(os.homedir(), ".piwi", "desktop.json");
455
+ }
456
+ function readDesktopConfig(filePath) {
457
+ try {
458
+ const parsed = JSON.parse(fs3.readFileSync(filePath, "utf8"));
459
+ const url = parsed?.url;
460
+ const token = parsed?.token;
461
+ if (typeof url !== "string" || !url) return null;
462
+ if (typeof token !== "string" || !token) return null;
463
+ return { url, token };
464
+ } catch {
465
+ return null;
466
+ }
467
+ }
468
+
469
+ // src/internal/config/env.ts
470
+ var DEFAULTS = {
471
+ projectName: "default-project",
472
+ uploadTraces: true,
473
+ uploadReport: true,
474
+ liveFileUploads: true,
475
+ collectScmInfo: true,
476
+ collectCiInfo: true,
477
+ collectPerformanceMetrics: true,
478
+ captureLocators: true,
479
+ capturePageState: true,
480
+ // Off by default: the page inventory reads control and link names from every
481
+ // visited page, so it stays opt-in until a project turns it on.
482
+ capturePageInventory: false,
483
+ // Off by default: JavaScript coverage slows the page, so a scheduled job opts in.
484
+ captureCodeReach: false,
485
+ captureServerTraces: true,
486
+ captureResources: true,
487
+ leakCheck: "report",
488
+ sampleAriaOnPass: true,
489
+ uploadManifest: true,
490
+ defaultCapture: true,
491
+ checkBaseUrl: false,
492
+ streaming: true,
493
+ streamingBatchSize: 5,
494
+ streamingBatchDelay: 2e3,
495
+ maxStreamBufferBytes: 100 * 1024 * 1024,
496
+ submitTimeout: 15 * 60 * 1e3,
497
+ failOnFlakyTests: false,
498
+ username: null,
499
+ password: null,
500
+ apiKey: null,
501
+ verbose: false
502
+ };
503
+ var PIWI_ENV_KEYS = {
504
+ serverUrl: "PIWI_DASHBOARD_URL",
505
+ projectName: "PIWI_PROJECT_NAME",
506
+ verbose: "PIWI_VERBOSE",
507
+ apiKey: "PIWI_API_KEY",
508
+ username: "PIWI_USERNAME",
509
+ password: "PIWI_PASSWORD",
510
+ environment: "PIWI_ENVIRONMENT",
511
+ label: "PIWI_LABEL",
512
+ runLabel: "PIWI_RUN_LABEL",
513
+ keep: "PIWI_KEEP",
514
+ streaming: "PIWI_STREAMING",
515
+ streamingBatchSize: "PIWI_STREAMING_BATCH_SIZE",
516
+ streamingBatchDelay: "PIWI_STREAMING_BATCH_DELAY",
517
+ maxStreamBufferBytes: "PIWI_MAX_STREAM_BUFFER_BYTES",
518
+ submitTimeout: "PIWI_SUBMIT_TIMEOUT",
519
+ liveFileUploads: "PIWI_LIVE_FILE_UPLOADS",
520
+ failOnFlakyTests: "PIWI_FAIL_ON_FLAKY_TESTS",
521
+ uploadTraces: "PIWI_UPLOAD_TRACES",
522
+ uploadReport: "PIWI_UPLOAD_REPORT",
523
+ captureLocators: "PIWI_CAPTURE_LOCATORS",
524
+ capturePageState: "PIWI_CAPTURE_PAGE_STATE",
525
+ capturePageInventory: "PIWI_CAPTURE_PAGE_INVENTORY",
526
+ captureCodeReach: "PIWI_CAPTURE_CODE_REACH",
527
+ codeReachRoots: "PIWI_CODE_REACH_ROOTS",
528
+ captureServerTraces: "PIWI_CAPTURE_SERVER_TRACES",
529
+ captureResources: "PIWI_CAPTURE_RESOURCES",
530
+ leakCheck: "PIWI_LEAK_CHECK",
531
+ sampleAriaOnPass: "PIWI_SAMPLE_ARIA_ON_PASS",
532
+ uploadManifest: "PIWI_UPLOAD_MANIFEST",
533
+ defaultCapture: "PIWI_DEFAULT_CAPTURE",
534
+ checkBaseUrl: "PIWI_CHECK_BASE_URL",
535
+ inspectOnFailure: "PIWI_INSPECT_ON_FAIL",
536
+ pickLocatorOnFailure: "PIWI_PICK_LOCATOR_ON_FAIL",
537
+ outputFile: "PIWI_OUTPUT_FILE",
538
+ aiMode: "PIWI_AI",
539
+ aiDir: "PIWI_AI_DIR",
540
+ aiOnMiss: "PIWI_AI_ON_MISS",
541
+ aiMaxSteps: "PIWI_AI_MAX_FLOW_STEPS",
542
+ aiMaxSnapshotChars: "PIWI_AI_MAX_SNAPSHOT_CHARS",
543
+ aiOptionalProbeTimeout: "PIWI_AI_OPTIONAL_PROBE_TIMEOUT",
544
+ aiResponseWaitTimeout: "PIWI_AI_RESPONSE_WAIT_TIMEOUT",
545
+ aiScreenshotFallback: "PIWI_AI_SCREENSHOT_FALLBACK"
546
+ };
547
+ var PIWI_DESKTOP_CONFIG_ENV = "PIWI_DESKTOP_CONFIG";
548
+ var PIWI_ORIGIN_ENV = {
549
+ kind: "PIWI_ORIGIN",
550
+ ref: "PIWI_ORIGIN_REF"
551
+ };
552
+ function originEnv(kind, ref) {
553
+ return ref === void 0 ? { [PIWI_ORIGIN_ENV.kind]: kind } : { [PIWI_ORIGIN_ENV.kind]: kind, [PIWI_ORIGIN_ENV.ref]: String(ref) };
554
+ }
555
+ var PIWI_SHARD_ENV = "PIWI_SHARD";
556
+ var PIWI_PROBE_ENV = {
557
+ flag: "PIWI_PROBE",
558
+ plan: "PIWI_PROBE_PLAN",
559
+ results: "PIWI_PROBE_RESULTS",
560
+ secret: "PIWI_PROBE_SECRET"
561
+ };
562
+ var PIWI_FLAKE_ENV = {
563
+ plan: "PIWI_FLAKE_PLAN",
564
+ results: "PIWI_FLAKE_RESULTS"
565
+ };
566
+ var ENV_FALLBACK_SPECS = [
567
+ { option: "serverUrl", env: PIWI_ENV_KEYS.serverUrl, kind: "string" },
568
+ { option: "projectName", env: PIWI_ENV_KEYS.projectName, kind: "string" },
569
+ { option: "apiKey", env: PIWI_ENV_KEYS.apiKey, kind: "string" },
570
+ { option: "username", env: PIWI_ENV_KEYS.username, kind: "string" },
571
+ { option: "password", env: PIWI_ENV_KEYS.password, kind: "string" },
572
+ { option: "environment", env: PIWI_ENV_KEYS.environment, kind: "string" },
573
+ { option: "label", env: PIWI_ENV_KEYS.label, kind: "string" },
574
+ { option: "runLabel", env: PIWI_ENV_KEYS.runLabel, kind: "string" },
575
+ { option: "keep", env: PIWI_ENV_KEYS.keep, kind: "bool" },
576
+ { option: "streaming", env: PIWI_ENV_KEYS.streaming, kind: "bool" },
577
+ { option: "streamingBatchSize", env: PIWI_ENV_KEYS.streamingBatchSize, kind: "number" },
578
+ { option: "streamingBatchDelay", env: PIWI_ENV_KEYS.streamingBatchDelay, kind: "number" },
579
+ { option: "maxStreamBufferBytes", env: PIWI_ENV_KEYS.maxStreamBufferBytes, kind: "number" },
580
+ { option: "submitTimeout", env: PIWI_ENV_KEYS.submitTimeout, kind: "number" },
581
+ { option: "liveFileUploads", env: PIWI_ENV_KEYS.liveFileUploads, kind: "bool" },
582
+ { option: "failOnFlakyTests", env: PIWI_ENV_KEYS.failOnFlakyTests, kind: "bool" },
583
+ { option: "uploadTraces", env: PIWI_ENV_KEYS.uploadTraces, kind: "bool" },
584
+ { option: "uploadReport", env: PIWI_ENV_KEYS.uploadReport, kind: "bool" },
585
+ { option: "captureLocators", env: PIWI_ENV_KEYS.captureLocators, kind: "bool" },
586
+ { option: "capturePageState", env: PIWI_ENV_KEYS.capturePageState, kind: "bool" },
587
+ { option: "capturePageInventory", env: PIWI_ENV_KEYS.capturePageInventory, kind: "bool" },
588
+ { option: "captureCodeReach", env: PIWI_ENV_KEYS.captureCodeReach, kind: "bool" },
589
+ { option: "captureServerTraces", env: PIWI_ENV_KEYS.captureServerTraces, kind: "bool" },
590
+ { option: "captureResources", env: PIWI_ENV_KEYS.captureResources, kind: "bool" },
591
+ { option: "leakCheck", env: PIWI_ENV_KEYS.leakCheck, kind: "string" },
592
+ { option: "sampleAriaOnPass", env: PIWI_ENV_KEYS.sampleAriaOnPass, kind: "bool" },
593
+ { option: "uploadManifest", env: PIWI_ENV_KEYS.uploadManifest, kind: "bool" },
594
+ { option: "defaultCapture", env: PIWI_ENV_KEYS.defaultCapture, kind: "bool" },
595
+ { option: "checkBaseUrl", env: PIWI_ENV_KEYS.checkBaseUrl, kind: "bool" },
596
+ { option: "inspectOnFailure", env: PIWI_ENV_KEYS.inspectOnFailure, kind: "bool" },
597
+ { option: "pickLocatorOnFailure", env: PIWI_ENV_KEYS.pickLocatorOnFailure, kind: "bool" },
598
+ { option: "outputFile", env: PIWI_ENV_KEYS.outputFile, kind: "string" }
599
+ ];
600
+
601
+ // src/cli/bug.ts
446
602
  var EXIT_OK2 = 0;
447
603
  var EXIT_RUN_FAILED = 1;
448
604
  var EXIT_ERROR2 = 2;
@@ -535,7 +691,7 @@ function repositoryRoot(cwd) {
535
691
  encoding: "utf-8",
536
692
  stdio: ["ignore", "pipe", "ignore"]
537
693
  }).trim();
538
- return out ? path6.resolve(out) : null;
694
+ return out ? path7.resolve(out) : null;
539
695
  } catch {
540
696
  return null;
541
697
  }
@@ -543,23 +699,23 @@ function repositoryRoot(cwd) {
543
699
  function realFolder(dir) {
544
700
  const rest = [];
545
701
  let current = dir;
546
- while (!fs3.existsSync(current) && path6.dirname(current) !== current) {
547
- rest.unshift(path6.basename(current));
548
- current = path6.dirname(current);
702
+ while (!fs4.existsSync(current) && path7.dirname(current) !== current) {
703
+ rest.unshift(path7.basename(current));
704
+ current = path7.dirname(current);
549
705
  }
550
- return path6.join(fs3.realpathSync(current), ...rest);
706
+ return path7.join(fs4.realpathSync(current), ...rest);
551
707
  }
552
708
  function folderInRepository(root, dir) {
553
- const relative6 = path6.relative(realFolder(root), realFolder(dir));
554
- if (relative6.startsWith("..") || path6.isAbsolute(relative6)) return void 0;
555
- return relative6.split(path6.sep).join("/");
709
+ const relative6 = path7.relative(realFolder(root), realFolder(dir));
710
+ if (relative6.startsWith("..") || path7.isAbsolute(relative6)) return void 0;
711
+ return relative6.split(path7.sep).join("/");
556
712
  }
557
713
  function fileFilter(file) {
558
714
  const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
559
- return `${escape(path6.basename(path6.dirname(file)))}[\\\\/]${escape(path6.basename(file))}$`;
715
+ return `${escape(path7.basename(path7.dirname(file)))}[\\\\/]${escape(path7.basename(file))}$`;
560
716
  }
561
717
  function resolvePlaywrightCli(cwd) {
562
- const require2 = (0, import_node_module.createRequire)(path6.join(cwd, "noop.js"));
718
+ const require2 = (0, import_node_module.createRequire)(path7.join(cwd, "noop.js"));
563
719
  for (const id of ["playwright/cli", "@playwright/test/cli", "playwright/lib/cli/cli"]) {
564
720
  try {
565
721
  return require2.resolve(id);
@@ -568,12 +724,14 @@ function resolvePlaywrightCli(cwd) {
568
724
  }
569
725
  return null;
570
726
  }
571
- function runPlaywright(file, cwd) {
727
+ function runPlaywright(file, cwd, id, env) {
572
728
  const cli = resolvePlaywrightCli(cwd);
573
729
  const filter = fileFilter(file);
574
- const child = cli ? (0, import_node_child_process2.spawn)(process.execPath, [cli, "test", filter], { stdio: "inherit", cwd }) : (0, import_node_child_process2.spawn)(process.platform === "win32" ? "npx.cmd" : "npx", ["playwright", "test", filter], {
730
+ const childEnv = { ...env, ...originEnv("bug", id) };
731
+ const child = cli ? (0, import_node_child_process2.spawn)(process.execPath, [cli, "test", filter], { stdio: "inherit", cwd, env: childEnv }) : (0, import_node_child_process2.spawn)(process.platform === "win32" ? "npx.cmd" : "npx", ["playwright", "test", filter], {
575
732
  stdio: "inherit",
576
- cwd
733
+ cwd,
734
+ env: childEnv
577
735
  });
578
736
  return new Promise((resolve8) => {
579
737
  child.on("error", (err) => {
@@ -598,10 +756,10 @@ ${USAGE2}`);
598
756
  return EXIT_ERROR2;
599
757
  }
600
758
  const root = args.write ? repositoryRoot(cwd) : null;
601
- const outFile = args.out !== null ? path6.resolve(cwd, args.out) : null;
759
+ const outFile = args.out !== null ? path7.resolve(cwd, args.out) : null;
602
760
  let spec;
603
761
  try {
604
- spec = await fetchBugSpec(args, outFile && root ? folderInRepository(root, path6.dirname(outFile)) : void 0);
762
+ spec = await fetchBugSpec(args, outFile && root ? folderInRepository(root, path7.dirname(outFile)) : void 0);
605
763
  } catch (e) {
606
764
  console.error(`piwi bug: ${e.message}`);
607
765
  return EXIT_ERROR2;
@@ -616,19 +774,19 @@ ${USAGE2}`);
616
774
  if (!outFile && !root) {
617
775
  console.error("piwi bug: not in a git repository \u2014 the bugs folder is read from the working directory");
618
776
  }
619
- const file = outFile ?? path6.resolve(root ?? cwd, spec.path);
777
+ const file = outFile ?? path7.resolve(root ?? cwd, spec.path);
620
778
  try {
621
- fs3.mkdirSync(path6.dirname(file), { recursive: true });
622
- fs3.writeFileSync(file, spec.code, { flag: args.force ? "w" : "wx" });
779
+ fs4.mkdirSync(path7.dirname(file), { recursive: true });
780
+ fs4.writeFileSync(file, spec.code, { flag: args.force ? "w" : "wx" });
623
781
  } catch (e) {
624
782
  const exists = e.code === "EEXIST";
625
783
  console.error(`piwi bug: ${exists ? `${file} exists \u2014 pass --force to replace it` : e.message}`);
626
784
  return EXIT_ERROR2;
627
785
  }
628
- const shown = path6.relative(cwd, file) || file;
786
+ const shown = path7.relative(cwd, file) || file;
629
787
  console.error(`piwi bug: wrote ${shown}`);
630
788
  if (!args.run) return EXIT_OK2;
631
- const code = await runPlaywright(file, cwd);
789
+ const code = await runPlaywright(file, cwd, args.id, env);
632
790
  if (code === EXIT_ERROR2 && !resolvePlaywrightCli(cwd)) return EXIT_ERROR2;
633
791
  if (code === 0) {
634
792
  console.error(
@@ -643,8 +801,8 @@ ${USAGE2}`);
643
801
  }
644
802
 
645
803
  // src/cli/codegen.ts
646
- var fs4 = __toESM(require("fs"));
647
- var path7 = __toESM(require("path"));
804
+ var fs5 = __toESM(require("fs"));
805
+ var path8 = __toESM(require("path"));
648
806
 
649
807
  // ../core/src/recording.ts
650
808
  var ASSERTION_MATCHERS = [
@@ -717,13 +875,20 @@ function extractArgs(entry, window, pairs) {
717
875
  }
718
876
  return args;
719
877
  }
878
+ function sameElement(pattern, recorded) {
879
+ if (pattern.testId || !pattern.name) return true;
880
+ const haystack = `${recorded?.accessibleName ?? ""} ${recorded?.text ?? ""}`.toLowerCase().trim();
881
+ const needle = pattern.name.toLowerCase();
882
+ return haystack.length > 0 && (haystack.includes(needle) || needle.includes(haystack));
883
+ }
720
884
  function contiguousAlignment(steps, startIndex, pattern) {
721
885
  if (startIndex + pattern.length > steps.length) return null;
722
886
  const pairs = [];
723
887
  let total = 0;
724
888
  for (let j = 0; j < pattern.length; j++) {
725
- const score = stepPairScore(pattern[j], steps[startIndex + j]);
726
- if (score <= 0) return null;
889
+ const recorded = steps[startIndex + j];
890
+ const score = stepPairScore(pattern[j], recorded);
891
+ if (score <= 0 || !sameElement(pattern[j].target, recorded.target)) return null;
727
892
  total += score;
728
893
  pairs.push([j, j]);
729
894
  }
@@ -1549,6 +1714,41 @@ function pageUrlPattern(url) {
1549
1714
  function commentText(s) {
1550
1715
  return s.replace(/[\n\r\u2028\u2029]+/g, " ").trim();
1551
1716
  }
1717
+ var ENV_NAME_MAX = 40;
1718
+ function envPrefixOf(options) {
1719
+ const prefix = options.envPrefix;
1720
+ return typeof prefix === "string" && ENV_PREFIX.test(prefix) ? prefix : "E2E_";
1721
+ }
1722
+ var ENV_PREFIX = /^[A-Z][A-Z0-9_]*$/;
1723
+ function envNameOf(target, prefix) {
1724
+ for (const source of [target?.accessibleName, target?.testId, target?.text]) {
1725
+ const name = (source ?? "").normalize("NFKD").replace(new RegExp("\\p{M}", "gu"), "").toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, ENV_NAME_MAX).replace(/_+$/, "");
1726
+ if (name) return `${prefix}${name}`;
1727
+ }
1728
+ return `${prefix}VALUE`;
1729
+ }
1730
+ function uniqueEnvName(name, taken) {
1731
+ if (!taken.includes(name)) return name;
1732
+ let n = 2;
1733
+ while (taken.includes(`${name}_${n}`)) n++;
1734
+ return `${name}_${n}`;
1735
+ }
1736
+ function envVarFor(step, index, ctx) {
1737
+ const envVar = uniqueEnvName(envNameOf(step.target, envPrefixOf(ctx.options)), ctx.envNames);
1738
+ ctx.envNames.push(envVar);
1739
+ if (step.redacted) {
1740
+ ctx.warnings.push({
1741
+ step: index,
1742
+ code: "redacted-value",
1743
+ detail: envVar,
1744
+ message: `A password was typed here; the spec reads it from ${envVar}.`
1745
+ });
1746
+ }
1747
+ return envVar;
1748
+ }
1749
+ function readsEnv(step, ctx) {
1750
+ return step.action === "fill" && (step.redacted || ctx.options.values === "env");
1751
+ }
1552
1752
  function urlForCode(url, ctx) {
1553
1753
  const isPath = url.startsWith("/");
1554
1754
  if (ctx.options.urls === "relative") {
@@ -1566,7 +1766,7 @@ function locatorForStep(step, index, ctx) {
1566
1766
  const chosen = chooseLocator(step.target, ctx.options);
1567
1767
  if (!chosen) {
1568
1768
  ctx.warnings.push({ step: index, code: "no-locator", message: "No locator was captured for this element." });
1569
- return `page.locator(${quote2("/* no locator captured */")})`;
1769
+ return `${ctx.page}.locator(${quote2("/* no locator captured */")})`;
1570
1770
  }
1571
1771
  if (chosen.level === "brittle") {
1572
1772
  ctx.warnings.push({
@@ -1576,7 +1776,7 @@ function locatorForStep(step, index, ctx) {
1576
1776
  message: `The best locator captured for this element is brittle: ${chosen.text}`
1577
1777
  });
1578
1778
  }
1579
- return `page.${chosen.text}`;
1779
+ return `${ctx.page}.${chosen.text}`;
1580
1780
  }
1581
1781
  function renderAssertStep(step, index, ctx) {
1582
1782
  const assertion = step.action === "assertVisible" ? null : step.assertion;
@@ -1601,7 +1801,7 @@ function renderAssertStep(step, index, ctx) {
1601
1801
  }
1602
1802
  arg = quote2(matcher === "toHaveURL" ? urlForCode(assertion.expected, ctx) : assertion.expected);
1603
1803
  }
1604
- const subject = matcher === "toHaveURL" ? "page" : locatorForStep(step, index, ctx);
1804
+ const subject = matcher === "toHaveURL" ? ctx.page : locatorForStep(step, index, ctx);
1605
1805
  let line = ` await expect(${subject})${not}.${matcher}(${arg});`;
1606
1806
  if (assertion?.actual != null && assertion.actual !== assertion.expected) {
1607
1807
  line += ` // recorded: ${quote2(assertion.actual)}`;
@@ -1613,9 +1813,12 @@ function fileNames(value) {
1613
1813
  return (value ?? "").split("\n").map((name) => name.trim().replace(/^.*[\\/]/, "")).filter(Boolean);
1614
1814
  }
1615
1815
  function renderRawStep(step, index, ctx) {
1616
- if (step.action === "goto") return [` await page.goto(${quote2(urlForCode(step.value ?? step.pageUrl, ctx))});`];
1816
+ const { page } = ctx;
1817
+ if (step.action === "goto") return [` await ${page}.goto(${quote2(urlForCode(step.value ?? step.pageUrl, ctx))});`];
1617
1818
  if (step.action === "assert" || step.action === "assertVisible") return renderAssertStep(step, index, ctx);
1618
- if (step.action === "press" && !step.target) return [` await page.keyboard.press(${quote2(step.value ?? "Enter")});`];
1819
+ if (step.action === "press" && !step.target) {
1820
+ return [` await ${page}.keyboard.press(${quote2(step.value ?? "Enter")});`];
1821
+ }
1619
1822
  const loc = locatorForStep(step, index, ctx);
1620
1823
  switch (step.action) {
1621
1824
  case "click":
@@ -1641,19 +1844,7 @@ function renderRawStep(step, index, ctx) {
1641
1844
  case "hover":
1642
1845
  return [` await ${loc}.hover();`];
1643
1846
  case "fill": {
1644
- if (step.redacted || ctx.options.values === "env") {
1645
- const envVar = `PIWI_TEST_VALUE_${index}`;
1646
- ctx.envNames.push(envVar);
1647
- if (step.redacted) {
1648
- ctx.warnings.push({
1649
- step: index,
1650
- code: "redacted-value",
1651
- detail: envVar,
1652
- message: `A password was typed here; the spec reads it from ${envVar}.`
1653
- });
1654
- }
1655
- return [` await ${loc}.fill(process.env.${envVar} ?? '');`];
1656
- }
1847
+ if (readsEnv(step, ctx)) return [` await ${loc}.fill(process.env.${envVarFor(step, index, ctx)} ?? '');`];
1657
1848
  return [` await ${loc}.fill(${quote2(step.value ?? "")});`];
1658
1849
  }
1659
1850
  case "check":
@@ -1674,16 +1865,102 @@ function callIdentifiersAreSafe(entry) {
1674
1865
  if (entry.importName != null && !IDENTIFIER_RE.test(entry.importName)) return false;
1675
1866
  return true;
1676
1867
  }
1677
- function objectArgExpr(param, match) {
1868
+ var RESERVED_WORDS = /* @__PURE__ */ new Set([
1869
+ "await",
1870
+ "break",
1871
+ "case",
1872
+ "catch",
1873
+ "class",
1874
+ "const",
1875
+ "continue",
1876
+ "debugger",
1877
+ "default",
1878
+ "delete",
1879
+ "do",
1880
+ "else",
1881
+ "enum",
1882
+ "export",
1883
+ "extends",
1884
+ "false",
1885
+ "finally",
1886
+ "for",
1887
+ "function",
1888
+ "if",
1889
+ "implements",
1890
+ "import",
1891
+ "in",
1892
+ "instanceof",
1893
+ "interface",
1894
+ "let",
1895
+ "new",
1896
+ "null",
1897
+ "package",
1898
+ "private",
1899
+ "protected",
1900
+ "public",
1901
+ "return",
1902
+ "static",
1903
+ "super",
1904
+ "switch",
1905
+ "throw",
1906
+ "true",
1907
+ "try",
1908
+ "typeof",
1909
+ "var",
1910
+ "void",
1911
+ "while",
1912
+ "with",
1913
+ "yield"
1914
+ ]);
1915
+ function isPageExpression(text) {
1916
+ if (typeof text !== "string") return false;
1917
+ const segments = text.split(".");
1918
+ return segments.every((segment) => IDENTIFIER_RE.test(segment)) && (segments[0] === "this" || !RESERVED_WORDS.has(segments[0]));
1919
+ }
1920
+ function pageExpression(options) {
1921
+ const page = options.page ?? "page";
1922
+ if (!isPageExpression(page)) {
1923
+ throw new RangeError(
1924
+ `Not a page expression (an identifier or a member chain such as app.page): ${JSON.stringify(page)}`
1925
+ );
1926
+ }
1927
+ if (options.format !== "body" && page.split(".")[0] === "this") {
1928
+ throw new RangeError(
1929
+ `The ${options.format ?? "file"} format takes the test's parameter from the page expression, which starts with this: ${JSON.stringify(page)}`
1930
+ );
1931
+ }
1932
+ return page;
1933
+ }
1934
+ function objectArgExpr(param, match, envArgs) {
1678
1935
  const entries = (param.fields ?? []).map((field) => {
1679
- const raw = match.args[`${param.name}.${field}`];
1680
- return raw == null ? null : `${IDENTIFIER_RE.test(field) ? field : quote2(field)}: ${quote2(raw)}`;
1936
+ const key = `${param.name}.${field}`;
1937
+ const env = envArgs.get(key);
1938
+ const raw = match.args[key];
1939
+ if (env == null && raw == null) return null;
1940
+ return `${IDENTIFIER_RE.test(field) ? field : quote2(field)}: ${env ?? quote2(raw)}`;
1681
1941
  }).filter((pair) => pair != null);
1682
1942
  return entries.length === 0 ? "{}" : `{ ${entries.join(", ")} }`;
1683
1943
  }
1684
- function argExpr(entry, match) {
1944
+ function envArgsOf(match, steps, ctx) {
1945
+ const envArgs = /* @__PURE__ */ new Map();
1946
+ const params = new Map(match.entry.params.map((p) => [p.name, p]));
1947
+ const sources = match.entry.paramSources.filter((source) => source.from === "value").sort((a, b) => a.stepIndex - b.stepIndex);
1948
+ for (const source of sources) {
1949
+ const type = params.get(source.param)?.type;
1950
+ if (source.path ? type !== "object" : type !== "string") continue;
1951
+ const index = match.matchedIndices[source.stepIndex];
1952
+ const step = index == null ? void 0 : steps[index];
1953
+ if (index == null || !step || !readsEnv(step, ctx)) continue;
1954
+ const key = source.path ? `${source.param}.${source.path}` : source.param;
1955
+ if (!envArgs.has(key)) envArgs.set(key, `process.env.${envVarFor(step, index, ctx)} ?? ''`);
1956
+ }
1957
+ return envArgs;
1958
+ }
1959
+ function argExpr(entry, match, envArgs) {
1685
1960
  return entry.params.map((p) => {
1686
- if (p.type === "object") return objectArgExpr(p, match);
1961
+ if (p.type === "object") return objectArgExpr(p, match, envArgs);
1962
+ const env = envArgs.get(p.name);
1963
+ if (env != null) return env;
1687
1964
  const raw = match.args[p.name];
1688
1965
  if (raw == null) return p.type === "number" ? "0" : p.type === "boolean" ? "false" : "''";
1689
1966
  if (p.type === "number") return String(Number(raw) || 0);
@@ -1691,19 +1968,20 @@ function argExpr(entry, match) {
1691
1968
  return quote2(raw);
1692
1969
  }).join(", ");
1693
1970
  }
1694
- function renderFunctionCall(match) {
1971
+ function renderFunctionCall(match, page, envArgs) {
1695
1972
  const { entry } = match;
1696
- const args = argExpr(entry, match);
1973
+ const args = argExpr(entry, match, envArgs);
1697
1974
  if (entry.kind === "page-object-method" && entry.receiver) {
1698
1975
  return ` await ${entry.receiver}.${entry.name}(${args});`;
1699
1976
  }
1700
- return ` await ${entry.name}(page${args ? `, ${args}` : ""});`;
1977
+ return ` await ${entry.name}(${page}${args ? `, ${args}` : ""});`;
1701
1978
  }
1702
- function renderImports(usedEntries) {
1979
+ function renderImports(usedEntries, given) {
1703
1980
  const lines = [];
1704
1981
  const seenModules = /* @__PURE__ */ new Set();
1705
1982
  for (const entry of usedEntries) {
1706
1983
  if (entry.kind === "page-object-method" && entry.receiver && entry.importName) {
1984
+ if (given.has(entry.receiver)) continue;
1707
1985
  const moduleKey = `${entry.module}#${entry.importName}`;
1708
1986
  if (!seenModules.has(moduleKey)) {
1709
1987
  seenModules.add(moduleKey);
@@ -1716,14 +1994,14 @@ function renderImports(usedEntries) {
1716
1994
  }
1717
1995
  return lines;
1718
1996
  }
1719
- function renderInstantiations(usedEntries) {
1720
- const seen = /* @__PURE__ */ new Set();
1997
+ function renderInstantiations(usedEntries, page, declared) {
1998
+ const seen = new Set(declared);
1721
1999
  const lines = [];
1722
2000
  for (const entry of usedEntries) {
1723
2001
  if (entry.kind !== "page-object-method" || !entry.receiver || !entry.importName) continue;
1724
2002
  if (seen.has(entry.receiver)) continue;
1725
2003
  seen.add(entry.receiver);
1726
- lines.push(` const ${entry.receiver} = new ${entry.importName}(page);`);
2004
+ lines.push(` const ${entry.receiver} = new ${entry.importName}(${page});`);
1727
2005
  }
1728
2006
  return lines;
1729
2007
  }
@@ -1752,10 +2030,35 @@ function samePage(a, b) {
1752
2030
  const kb = pageKey(b);
1753
2031
  return ka === null || kb === null || ka === kb;
1754
2032
  }
1755
- function viewportLine(viewport) {
1756
- return ` await page.setViewportSize({ width: ${Math.round(viewport.width)}, height: ${Math.round(viewport.height)} });`;
2033
+ function viewportLine(viewport, page) {
2034
+ return ` await ${page}.setViewportSize({ width: ${Math.round(viewport.width)}, height: ${Math.round(viewport.height)} });`;
2035
+ }
2036
+ function pageStepTitle(url, origin) {
2037
+ if (url.startsWith("/")) return url.split(/[?#]/)[0] || "/";
2038
+ try {
2039
+ const parsed = new URL(url);
2040
+ return parsed.origin === origin ? parsed.pathname : `${parsed.host}${parsed.pathname}`;
2041
+ } catch {
2042
+ return url;
2043
+ }
2044
+ }
2045
+ function wrapInPageSteps(lines, starts, origin) {
2046
+ if (starts.length === 0) return { lines, at: (index) => index };
2047
+ const out = lines.slice(0, starts[0].line);
2048
+ starts.forEach((start, n) => {
2049
+ const end = starts[n + 1]?.line ?? lines.length;
2050
+ out.push(` await test.step(${quote2(pageStepTitle(start.url, origin))}, async () => {`);
2051
+ out.push(...lines.slice(start.line, end).map((line) => ` ${line}`));
2052
+ out.push(" });");
2053
+ });
2054
+ const at = (index) => {
2055
+ const opened = starts.filter((start) => start.line <= index).length;
2056
+ return opened === 0 ? index : index + 2 * opened - 1;
2057
+ };
2058
+ return { lines: out, at };
1757
2059
  }
1758
2060
  function renderSpec(session, options = {}) {
2061
+ const page = pageExpression(options);
1759
2062
  const { steps } = session;
1760
2063
  const viewportAt = new Map((session.viewports ?? []).map((v) => [v.step, v]));
1761
2064
  const title = options.title ?? "recorded flow";
@@ -1763,6 +2066,7 @@ function renderSpec(session, options = {}) {
1763
2066
  const ctx = {
1764
2067
  options,
1765
2068
  origin: originOf(session.startUrl) ?? originOf(steps[0]?.pageUrl ?? ""),
2069
+ page,
1766
2070
  warnings: [],
1767
2071
  envNames: []
1768
2072
  };
@@ -1770,23 +2074,41 @@ function renderSpec(session, options = {}) {
1770
2074
  const matchedSpans = [];
1771
2075
  const usedEntries = [];
1772
2076
  const stepStarts = [];
2077
+ const pageStarts = [];
2078
+ const enterPageOf = (index) => {
2079
+ const url = steps[index]?.pageUrl;
2080
+ if (options.testSteps !== "page" || url == null) return;
2081
+ const last = pageStarts[pageStarts.length - 1];
2082
+ if (!last || !samePage(last.url, url)) pageStarts.push({ line: bodyLines.length, url });
2083
+ };
1773
2084
  const checkNextPage = (last) => {
1774
2085
  if (!options.urlChecks) return;
1775
2086
  const next = steps[last + 1];
1776
2087
  const current = steps[last];
1777
2088
  if (!next || !current || next.action === "goto" || samePage(current.pageUrl, next.pageUrl)) return;
1778
2089
  const pattern = pageUrlPattern(next.pageUrl);
1779
- if (pattern) bodyLines.push(` await expect(page).toHaveURL(${pattern});`);
2090
+ enterPageOf(last + 1);
2091
+ if (pattern) bodyLines.push(` await expect(${page}).toHaveURL(${pattern});`);
1780
2092
  const acts = next.action !== "assert" && next.action !== "assertVisible";
1781
2093
  const chosen = acts && next.target ? chooseLocator(next.target, options) : null;
1782
- if (chosen) bodyLines.push(` await expect(page.${chosen.text}).toHaveCount(1);`);
2094
+ if (chosen) bodyLines.push(` await expect(${page}.${chosen.text}).toHaveCount(1);`);
2095
+ };
2096
+ const calls = /* @__PURE__ */ new Map();
2097
+ const callAt = (at) => {
2098
+ if (calls.has(at)) return calls.get(at);
2099
+ const step = steps[at];
2100
+ const found = step && step.action !== "goto" && catalog.length > 0 ? matchFunctionAt(steps, at, catalog) : null;
2101
+ const match = found && ![...viewportAt.keys()].some((v) => v > at && v <= Math.max(...found.matchedIndices)) ? found : null;
2102
+ calls.set(at, match);
2103
+ return match;
1783
2104
  };
1784
2105
  let pos = 0;
1785
2106
  let sawGoto = false;
1786
2107
  while (pos < steps.length) {
1787
2108
  const step = steps[pos];
2109
+ enterPageOf(pos);
1788
2110
  const viewport = pos > 0 ? viewportAt.get(pos) : void 0;
1789
- if (viewport) bodyLines.push(viewportLine(viewport));
2111
+ if (viewport) bodyLines.push(viewportLine(viewport, page));
1790
2112
  stepStarts[pos] = bodyLines.length;
1791
2113
  if (step.action === "goto") {
1792
2114
  bodyLines.push(...renderRawStep(step, pos, ctx));
@@ -1794,47 +2116,65 @@ function renderSpec(session, options = {}) {
1794
2116
  pos++;
1795
2117
  continue;
1796
2118
  }
1797
- const found = catalog.length > 0 ? matchFunctionAt(steps, pos, catalog) : null;
1798
- const match = found && ![...viewportAt.keys()].some((at) => at > pos && at <= Math.max(...found.matchedIndices)) ? found : null;
2119
+ const match = callAt(pos);
1799
2120
  if (match) {
1800
- bodyLines.push(renderFunctionCall(match));
2121
+ bodyLines.push(renderFunctionCall(match, page, envArgsOf(match, steps, ctx)));
1801
2122
  usedEntries.push(match.entry);
1802
2123
  const last = Math.max(...match.matchedIndices);
1803
2124
  for (let i = pos + 1; i <= last; i++) stepStarts[i] = stepStarts[pos];
1804
2125
  matchedSpans.push({ startStep: pos, endStep: last, functionName: match.entry.name });
1805
- checkNextPage(last);
1806
2126
  pos = last + 1;
1807
2127
  continue;
1808
2128
  }
1809
2129
  bodyLines.push(...renderRawStep(step, pos, ctx));
1810
- checkNextPage(pos);
2130
+ if (!callAt(pos + 1)) checkNextPage(pos);
1811
2131
  pos++;
1812
2132
  }
1813
2133
  const start = viewportAt.get(0);
1814
2134
  const opening = [
1815
- ...start ? [viewportLine(start)] : [],
1816
- ...!sawGoto && session.startUrl ? [` await page.goto(${quote2(urlForCode(session.startUrl, ctx))});`] : []
2135
+ ...start ? [viewportLine(start, page)] : [],
2136
+ ...!sawGoto && session.startUrl ? [` await ${page}.goto(${quote2(urlForCode(session.startUrl, ctx))});`] : []
1817
2137
  ];
1818
2138
  bodyLines.unshift(...opening);
1819
2139
  const bodyOffset = opening.length;
1820
- const importLines = renderImports(usedEntries);
1821
- const instantiationLines = renderInstantiations(usedEntries);
2140
+ const wrapped = wrapInPageSteps(
2141
+ bodyLines,
2142
+ pageStarts.map((start2, n) => ({ line: n === 0 ? 0 : start2.line + bodyOffset, url: start2.url })),
2143
+ ctx.origin
2144
+ );
2145
+ const fixtureReceivers = options.format === "body" ? [] : [
2146
+ ...new Set(
2147
+ usedEntries.flatMap(
2148
+ (e) => e.kind === "page-object-method" && e.receiver && options.fixtures?.has(e.receiver) ? [e.receiver] : []
2149
+ )
2150
+ )
2151
+ ];
2152
+ const given = /* @__PURE__ */ new Set([...options.declaredNames ?? [], ...fixtureReceivers]);
2153
+ const importLines = renderImports(usedEntries, given);
2154
+ const instantiationLines = renderInstantiations(usedEntries, page, given);
1822
2155
  const failLine = expectFailLine(options);
1823
2156
  const envLines = options.values === "env" && ctx.envNames.length > 0 ? [` // Typed values come from ${[...new Set(ctx.envNames)].join(", ")}.`] : [];
1824
- const testBody = [...failLine ? [failLine] : [], ...envLines, ...instantiationLines, ...bodyLines];
1825
- const bodyStart = testBody.length - bodyLines.length + bodyOffset;
1826
- const linesFrom = (first) => steps.map((_, i) => first + bodyStart + (stepStarts[i] ?? 0) + 1);
2157
+ const testBody = [...failLine ? [failLine] : [], ...envLines, ...instantiationLines, ...wrapped.lines];
2158
+ const bodyStart = testBody.length - wrapped.lines.length;
2159
+ const result = (lines2, header) => ({
2160
+ code: lines2.join("\n"),
2161
+ imports: importLines,
2162
+ matchedSpans,
2163
+ warnings: ctx.warnings,
2164
+ stepLines: steps.map((_, i) => header + bodyStart + wrapped.at(bodyOffset + (stepStarts[i] ?? 0)) + 1)
2165
+ });
2166
+ const needsLines = (indent) => options.bodyImports === "none" ? [] : importLines.map((line) => `${indent}// Needs: ${line}`);
1827
2167
  if (options.format === "body") {
1828
- const needs = importLines.map((line) => ` // Needs: ${line}`);
1829
- return {
1830
- code: [...needs, ...testBody, ""].join("\n"),
1831
- matchedSpans,
1832
- warnings: ctx.warnings,
1833
- stepLines: linesFrom(needs.length)
1834
- };
2168
+ const needs = needsLines(" ");
2169
+ return result([...needs, ...testBody, ""], needs.length);
1835
2170
  }
1836
2171
  const details = renderDetails(options);
1837
- const testOpening = details.length > 0 ? [`test(${quote2(title)}, {`, ...details, `}, async ({ page }) => {`] : [`test(${quote2(title)}, async ({ page }) => {`];
2172
+ const fixture = [.../* @__PURE__ */ new Set([page.split(".")[0], ...fixtureReceivers])].join(", ");
2173
+ const testOpening = details.length > 0 ? [`test(${quote2(title)}, {`, ...details, `}, async ({ ${fixture} }) => {`] : [`test(${quote2(title)}, async ({ ${fixture} }) => {`];
2174
+ if (options.format === "test") {
2175
+ const needs = needsLines("");
2176
+ return result([...needs, ...testOpening, ...testBody, `});`, ``], needs.length + testOpening.length);
2177
+ }
1838
2178
  const lines = [
1839
2179
  // Package name interpolated rather than adjoining the preceding keyword
1840
2180
  // directly, so this generated-code line does not itself look like a real
@@ -1847,8 +2187,7 @@ function renderSpec(session, options = {}) {
1847
2187
  `});`,
1848
2188
  ``
1849
2189
  ];
1850
- const header = 1 + importLines.length + 1 + testOpening.length;
1851
- return { code: lines.join("\n"), matchedSpans, warnings: ctx.warnings, stepLines: linesFrom(header) };
2190
+ return result(lines, 1 + importLines.length + 1 + testOpening.length);
1852
2191
  }
1853
2192
 
1854
2193
  // ../core/src/steps.ts
@@ -2231,7 +2570,7 @@ Code:
2231
2570
  --test-import <mod> Module test and expect come from (default @playwright/test)
2232
2571
  --absolute-urls Keep recorded URLs instead of paths for your baseURL
2233
2572
  --no-url-checks Do not wait for each new page's URL
2234
- --env-values Read typed values from PIWI_TEST_VALUE_<n> variables
2573
+ --env-values Read typed values from variables named after their fields (E2E_EMAIL)
2235
2574
  --fail Mark the test as expected to fail (test.fail())
2236
2575
  --fail-reason <text> The reason written beside test.fail()
2237
2576
  --tag <tag> Add a tag; repeat for more
@@ -2391,8 +2730,8 @@ ${USAGE3}`);
2391
2730
  }
2392
2731
  } else {
2393
2732
  try {
2394
- if (fs4.statSync(args.file).size > MAX_STEPS_FILE_BYTES) throw new Error("is too large to be a steps file");
2395
- text = fs4.readFileSync(args.file, "utf-8");
2733
+ if (fs5.statSync(args.file).size > MAX_STEPS_FILE_BYTES) throw new Error("is too large to be a steps file");
2734
+ text = fs5.readFileSync(args.file, "utf-8");
2396
2735
  } catch (e) {
2397
2736
  const reason = e.code === "ENOENT" ? "does not exist" : e.message;
2398
2737
  console.error(`piwi codegen: ${args.file} ${reason}`);
@@ -2424,8 +2763,8 @@ ${USAGE3}`);
2424
2763
  return EXIT_OK3;
2425
2764
  }
2426
2765
  try {
2427
- fs4.mkdirSync(path7.dirname(args.out), { recursive: true });
2428
- fs4.writeFileSync(args.out, result.code, { flag: args.force ? "w" : "wx" });
2766
+ fs5.mkdirSync(path8.dirname(args.out), { recursive: true });
2767
+ fs5.writeFileSync(args.out, result.code, { flag: args.force ? "w" : "wx" });
2429
2768
  } catch (e) {
2430
2769
  const exists = e.code === "EEXIST";
2431
2770
  console.error(`piwi codegen: ${exists ? `${args.out} exists \u2014 pass --force to replace it` : e.message}`);
@@ -2477,148 +2816,6 @@ function estimateFlakeSessionMs(arms, medianDurationMs) {
2477
2816
  return total;
2478
2817
  }
2479
2818
 
2480
- // src/internal/config/desktop.ts
2481
- var fs5 = __toESM(require("fs"));
2482
- var os = __toESM(require("os"));
2483
- var path8 = __toESM(require("path"));
2484
- function defaultDesktopConfigPath() {
2485
- return path8.join(os.homedir(), ".piwi", "desktop.json");
2486
- }
2487
- function readDesktopConfig(filePath) {
2488
- try {
2489
- const parsed = JSON.parse(fs5.readFileSync(filePath, "utf8"));
2490
- const url = parsed?.url;
2491
- const token = parsed?.token;
2492
- if (typeof url !== "string" || !url) return null;
2493
- if (typeof token !== "string" || !token) return null;
2494
- return { url, token };
2495
- } catch {
2496
- return null;
2497
- }
2498
- }
2499
-
2500
- // src/internal/config/env.ts
2501
- var DEFAULTS = {
2502
- projectName: "default-project",
2503
- uploadTraces: true,
2504
- uploadReport: true,
2505
- liveFileUploads: true,
2506
- collectScmInfo: true,
2507
- collectCiInfo: true,
2508
- collectPerformanceMetrics: true,
2509
- captureLocators: true,
2510
- capturePageState: true,
2511
- // Off by default: the page inventory reads control and link names from every
2512
- // visited page, so it stays opt-in until a project turns it on.
2513
- capturePageInventory: false,
2514
- // Off by default: JavaScript coverage slows the page, so a scheduled job opts in.
2515
- captureCodeReach: false,
2516
- captureServerTraces: true,
2517
- captureResources: true,
2518
- leakCheck: "report",
2519
- sampleAriaOnPass: true,
2520
- uploadManifest: true,
2521
- defaultCapture: true,
2522
- streaming: true,
2523
- streamingBatchSize: 5,
2524
- streamingBatchDelay: 2e3,
2525
- maxStreamBufferBytes: 100 * 1024 * 1024,
2526
- submitTimeout: 15 * 60 * 1e3,
2527
- failOnFlakyTests: false,
2528
- username: null,
2529
- password: null,
2530
- apiKey: null,
2531
- verbose: false
2532
- };
2533
- var PIWI_ENV_KEYS = {
2534
- serverUrl: "PIWI_DASHBOARD_URL",
2535
- projectName: "PIWI_PROJECT_NAME",
2536
- verbose: "PIWI_VERBOSE",
2537
- apiKey: "PIWI_API_KEY",
2538
- username: "PIWI_USERNAME",
2539
- password: "PIWI_PASSWORD",
2540
- environment: "PIWI_ENVIRONMENT",
2541
- label: "PIWI_LABEL",
2542
- runLabel: "PIWI_RUN_LABEL",
2543
- keep: "PIWI_KEEP",
2544
- streaming: "PIWI_STREAMING",
2545
- streamingBatchSize: "PIWI_STREAMING_BATCH_SIZE",
2546
- streamingBatchDelay: "PIWI_STREAMING_BATCH_DELAY",
2547
- maxStreamBufferBytes: "PIWI_MAX_STREAM_BUFFER_BYTES",
2548
- submitTimeout: "PIWI_SUBMIT_TIMEOUT",
2549
- liveFileUploads: "PIWI_LIVE_FILE_UPLOADS",
2550
- failOnFlakyTests: "PIWI_FAIL_ON_FLAKY_TESTS",
2551
- uploadTraces: "PIWI_UPLOAD_TRACES",
2552
- uploadReport: "PIWI_UPLOAD_REPORT",
2553
- captureLocators: "PIWI_CAPTURE_LOCATORS",
2554
- capturePageState: "PIWI_CAPTURE_PAGE_STATE",
2555
- capturePageInventory: "PIWI_CAPTURE_PAGE_INVENTORY",
2556
- captureCodeReach: "PIWI_CAPTURE_CODE_REACH",
2557
- codeReachRoots: "PIWI_CODE_REACH_ROOTS",
2558
- captureServerTraces: "PIWI_CAPTURE_SERVER_TRACES",
2559
- captureResources: "PIWI_CAPTURE_RESOURCES",
2560
- leakCheck: "PIWI_LEAK_CHECK",
2561
- sampleAriaOnPass: "PIWI_SAMPLE_ARIA_ON_PASS",
2562
- uploadManifest: "PIWI_UPLOAD_MANIFEST",
2563
- defaultCapture: "PIWI_DEFAULT_CAPTURE",
2564
- inspectOnFailure: "PIWI_INSPECT_ON_FAIL",
2565
- pickLocatorOnFailure: "PIWI_PICK_LOCATOR_ON_FAIL",
2566
- outputFile: "PIWI_OUTPUT_FILE",
2567
- aiMode: "PIWI_AI",
2568
- aiDir: "PIWI_AI_DIR",
2569
- aiOnMiss: "PIWI_AI_ON_MISS",
2570
- aiMaxSteps: "PIWI_AI_MAX_FLOW_STEPS",
2571
- aiMaxSnapshotChars: "PIWI_AI_MAX_SNAPSHOT_CHARS",
2572
- aiOptionalProbeTimeout: "PIWI_AI_OPTIONAL_PROBE_TIMEOUT",
2573
- aiResponseWaitTimeout: "PIWI_AI_RESPONSE_WAIT_TIMEOUT",
2574
- aiScreenshotFallback: "PIWI_AI_SCREENSHOT_FALLBACK"
2575
- };
2576
- var PIWI_DESKTOP_CONFIG_ENV = "PIWI_DESKTOP_CONFIG";
2577
- var PIWI_SHARD_ENV = "PIWI_SHARD";
2578
- var PIWI_PROBE_ENV = {
2579
- flag: "PIWI_PROBE",
2580
- plan: "PIWI_PROBE_PLAN",
2581
- results: "PIWI_PROBE_RESULTS",
2582
- secret: "PIWI_PROBE_SECRET"
2583
- };
2584
- var PIWI_FLAKE_ENV = {
2585
- plan: "PIWI_FLAKE_PLAN",
2586
- results: "PIWI_FLAKE_RESULTS"
2587
- };
2588
- var ENV_FALLBACK_SPECS = [
2589
- { option: "serverUrl", env: PIWI_ENV_KEYS.serverUrl, kind: "string" },
2590
- { option: "projectName", env: PIWI_ENV_KEYS.projectName, kind: "string" },
2591
- { option: "apiKey", env: PIWI_ENV_KEYS.apiKey, kind: "string" },
2592
- { option: "username", env: PIWI_ENV_KEYS.username, kind: "string" },
2593
- { option: "password", env: PIWI_ENV_KEYS.password, kind: "string" },
2594
- { option: "environment", env: PIWI_ENV_KEYS.environment, kind: "string" },
2595
- { option: "label", env: PIWI_ENV_KEYS.label, kind: "string" },
2596
- { option: "runLabel", env: PIWI_ENV_KEYS.runLabel, kind: "string" },
2597
- { option: "keep", env: PIWI_ENV_KEYS.keep, kind: "bool" },
2598
- { option: "streaming", env: PIWI_ENV_KEYS.streaming, kind: "bool" },
2599
- { option: "streamingBatchSize", env: PIWI_ENV_KEYS.streamingBatchSize, kind: "number" },
2600
- { option: "streamingBatchDelay", env: PIWI_ENV_KEYS.streamingBatchDelay, kind: "number" },
2601
- { option: "maxStreamBufferBytes", env: PIWI_ENV_KEYS.maxStreamBufferBytes, kind: "number" },
2602
- { option: "submitTimeout", env: PIWI_ENV_KEYS.submitTimeout, kind: "number" },
2603
- { option: "liveFileUploads", env: PIWI_ENV_KEYS.liveFileUploads, kind: "bool" },
2604
- { option: "failOnFlakyTests", env: PIWI_ENV_KEYS.failOnFlakyTests, kind: "bool" },
2605
- { option: "uploadTraces", env: PIWI_ENV_KEYS.uploadTraces, kind: "bool" },
2606
- { option: "uploadReport", env: PIWI_ENV_KEYS.uploadReport, kind: "bool" },
2607
- { option: "captureLocators", env: PIWI_ENV_KEYS.captureLocators, kind: "bool" },
2608
- { option: "capturePageState", env: PIWI_ENV_KEYS.capturePageState, kind: "bool" },
2609
- { option: "capturePageInventory", env: PIWI_ENV_KEYS.capturePageInventory, kind: "bool" },
2610
- { option: "captureCodeReach", env: PIWI_ENV_KEYS.captureCodeReach, kind: "bool" },
2611
- { option: "captureServerTraces", env: PIWI_ENV_KEYS.captureServerTraces, kind: "bool" },
2612
- { option: "captureResources", env: PIWI_ENV_KEYS.captureResources, kind: "bool" },
2613
- { option: "leakCheck", env: PIWI_ENV_KEYS.leakCheck, kind: "string" },
2614
- { option: "sampleAriaOnPass", env: PIWI_ENV_KEYS.sampleAriaOnPass, kind: "bool" },
2615
- { option: "uploadManifest", env: PIWI_ENV_KEYS.uploadManifest, kind: "bool" },
2616
- { option: "defaultCapture", env: PIWI_ENV_KEYS.defaultCapture, kind: "bool" },
2617
- { option: "inspectOnFailure", env: PIWI_ENV_KEYS.inspectOnFailure, kind: "bool" },
2618
- { option: "pickLocatorOnFailure", env: PIWI_ENV_KEYS.pickLocatorOnFailure, kind: "bool" },
2619
- { option: "outputFile", env: PIWI_ENV_KEYS.outputFile, kind: "string" }
2620
- ];
2621
-
2622
2819
  // ../core/src/flake-verdict.ts
2623
2820
  var FLAKE_SIGNIFICANCE = 0.05;
2624
2821
  var FLAKE_REPRODUCED_RATE = 0.5;
@@ -3462,7 +3659,7 @@ var fs9 = __toESM(require("fs"));
3462
3659
  function formatGateResult(result) {
3463
3660
  const { facts } = result;
3464
3661
  const lines = [
3465
- result.passed ? `\u2714 Piwi gate passed \u2014 ${facts.projectName} run #${facts.runId}` : `\u2716 Piwi gate failed \u2014 ${facts.projectName} run #${facts.runId}`,
3662
+ result.verdict === "inconclusive" ? `? Piwi gate inconclusive \u2014 ${facts.projectName} run #${facts.runId}` : result.passed ? `\u2714 Piwi gate passed \u2014 ${facts.projectName} run #${facts.runId}` : `\u2716 Piwi gate failed \u2014 ${facts.projectName} run #${facts.runId}`,
3466
3663
  ` ${facts.totalTests} tests, ${facts.failedTests} failed, ${facts.newRegressions} new, ${facts.newFlaky} newly flaky, ${facts.flakyTests} flaky`
3467
3664
  ];
3468
3665
  if (facts.quarantinedFailures > 0) {
@@ -3474,6 +3671,10 @@ function formatGateResult(result) {
3474
3671
  const { leaks, newLeaks } = facts.resources;
3475
3672
  lines.push(` ${leaks.length} ${leaks.length === 1 ? "leak" : "leaks"}, ${newLeaks.length} new`);
3476
3673
  }
3674
+ if (facts.incident) {
3675
+ lines.push(` ? The run is an environment incident: ${facts.incident.reason}`);
3676
+ lines.push(" Re-run once the environment is back, or clear the flag on the run page if it is wrong.");
3677
+ }
3477
3678
  for (const violation of result.violations) lines.push(` \u2716 ${violation.message}`);
3478
3679
  for (const warning of result.warnings ?? []) lines.push(` \u26A0 ${warning}`);
3479
3680
  lines.push(` ${facts.runUrl}`);
@@ -3484,6 +3685,7 @@ function formatGateResult(result) {
3484
3685
  var EXIT_OK4 = 0;
3485
3686
  var EXIT_VIOLATED = 1;
3486
3687
  var EXIT_ERROR5 = 2;
3688
+ var EXIT_INCONCLUSIVE = 3;
3487
3689
  var USAGE5 = `
3488
3690
  piwi gate \u2014 fail a CI job on the dashboard's analysis of a run
3489
3691
 
@@ -3519,7 +3721,7 @@ Other:
3519
3721
  --json Print the raw result as JSON instead of a summary
3520
3722
  -h, --help Show this help
3521
3723
 
3522
- Exit codes: 0 satisfied, 1 violated, 2 could not evaluate.
3724
+ Exit codes: 0 satisfied, 1 violated, 2 could not evaluate, 3 inconclusive (an environment incident).
3523
3725
  `.trim();
3524
3726
  function readOption4(argv, name) {
3525
3727
  const withEquals = argv.find((arg) => arg.startsWith(`${name}=`));
@@ -3580,7 +3782,7 @@ function parseGateArgs(argv, env) {
3580
3782
  };
3581
3783
  }
3582
3784
  async function requestGate(args) {
3583
- const headers = { "Content-Type": "application/json" };
3785
+ const headers = { "Content-Type": "application/json", "X-Piwi-Client": "cli" };
3584
3786
  if (args.apiKey) headers["X-API-Key"] = args.apiKey;
3585
3787
  const res = await fetch(`${args.serverUrl}/api/test-runs/${args.runId}/gate`, {
3586
3788
  method: "POST",
@@ -3619,6 +3821,7 @@ async function runGate(argv, env = process.env) {
3619
3821
  } else {
3620
3822
  console.log(formatGateResult(result));
3621
3823
  }
3824
+ if (result.verdict === "inconclusive") return EXIT_INCONCLUSIVE;
3622
3825
  return result.passed ? EXIT_OK4 : EXIT_VIOLATED;
3623
3826
  }
3624
3827
 
@@ -3840,6 +4043,58 @@ function ensureGitignoreEntry(existing, entry = ".env") {
3840
4043
  // src/cli/skills.ts
3841
4044
  var fs11 = __toESM(require("fs"));
3842
4045
  var path13 = __toESM(require("path"));
4046
+
4047
+ // ../core/src/skill-stamp.ts
4048
+ var SKILL_VERSION_KEY = "piwi-version";
4049
+ var SKILL_HASH_KEY = "piwi-hash";
4050
+ var FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---/;
4051
+ function fnv32(input, seed) {
4052
+ let h = seed >>> 0;
4053
+ for (let i = 0; i < input.length; i++) {
4054
+ h ^= input.charCodeAt(i);
4055
+ h = Math.imul(h, 16777619);
4056
+ }
4057
+ return (h >>> 0).toString(16).padStart(8, "0");
4058
+ }
4059
+ function hash16(input) {
4060
+ return fnv32(input, 2166136261) + fnv32(input, 16777619);
4061
+ }
4062
+ function withoutHashLine(markdown) {
4063
+ return markdown.replace(new RegExp(`^${SKILL_HASH_KEY}:.*\\r?\\n`, "m"), "");
4064
+ }
4065
+ function skillContentHash(markdown) {
4066
+ return hash16(withoutHashLine(markdown).replace(/\r\n/g, "\n"));
4067
+ }
4068
+ function stampSkill(template, version) {
4069
+ const match = FRONT_MATTER.exec(template);
4070
+ if (!match) return template;
4071
+ const front = match[1].split(/\r?\n/).filter((line) => !line.startsWith(`${SKILL_VERSION_KEY}:`) && !line.startsWith(`${SKILL_HASH_KEY}:`));
4072
+ const versionLine = `${SKILL_VERSION_KEY}: ${version}`;
4073
+ const versioned = template.replace(match[0], () => `---
4074
+ ${[...front, versionLine].join("\n")}
4075
+ ---`);
4076
+ const hash = skillContentHash(versioned);
4077
+ return versioned.replace(`${versionLine}
4078
+ ---`, () => `${versionLine}
4079
+ ${SKILL_HASH_KEY}: ${hash}
4080
+ ---`);
4081
+ }
4082
+ function readSkillStamp(markdown) {
4083
+ const lines = FRONT_MATTER.exec(markdown)?.[1]?.split(/\r?\n/) ?? [];
4084
+ const read = (key) => {
4085
+ const line = lines.find((l) => l.startsWith(`${key}:`));
4086
+ return line ? line.slice(key.length + 1).trim() || null : null;
4087
+ };
4088
+ return { version: read(SKILL_VERSION_KEY), hash: read(SKILL_HASH_KEY) };
4089
+ }
4090
+ function classifyInstalledSkill(installed, stamped) {
4091
+ if (installed.replace(/\r\n/g, "\n") === stamped.replace(/\r\n/g, "\n")) return "current";
4092
+ const stamp = readSkillStamp(installed);
4093
+ if (!stamp.hash) return "unstamped";
4094
+ return skillContentHash(installed) === stamp.hash ? "outdated" : "edited";
4095
+ }
4096
+
4097
+ // src/cli/skills.ts
3843
4098
  var SETUP_SKILL = "setup-piwi";
3844
4099
  var WORKFLOW_SKILLS = [
3845
4100
  "investigate-failure",
@@ -3871,6 +4126,14 @@ function readFrontMatter(markdown) {
3871
4126
  }
3872
4127
  return out;
3873
4128
  }
4129
+ function readTemplatesVersion(templatesDir) {
4130
+ try {
4131
+ const pkg = JSON.parse(fs11.readFileSync(path13.join(templatesDir, "..", "package.json"), "utf-8"));
4132
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
4133
+ } catch {
4134
+ return "0.0.0";
4135
+ }
4136
+ }
3874
4137
  function templatePath(templatesDir, slug) {
3875
4138
  return path13.join(templatesDir, "skills", slug, "SKILL.md");
3876
4139
  }
@@ -3895,23 +4158,37 @@ function installSkills(opts) {
3895
4158
  results.push({ step, status: "error", detail: `no template found for "${slug}"` });
3896
4159
  continue;
3897
4160
  }
3898
- const contents = fs11.readFileSync(source, "utf-8");
4161
+ const version = opts.version ?? readTemplatesVersion(opts.templatesDir);
4162
+ const contents = stampSkill(fs11.readFileSync(source, "utf-8"), version);
3899
4163
  const exists = fs11.existsSync(dest);
3900
- if (exists && !opts.force) {
3901
- const identical = fs11.readFileSync(dest, "utf-8") === contents;
3902
- results.push({
3903
- step,
3904
- file: relDest,
3905
- status: identical ? "already" : "skipped",
3906
- detail: identical ? "skill already installed" : "skill exists and differs \u2014 pass --force to overwrite"
3907
- });
3908
- continue;
4164
+ let detail = "installed skill";
4165
+ if (exists) {
4166
+ const installed = fs11.readFileSync(dest, "utf-8");
4167
+ const state = classifyInstalledSkill(installed, contents);
4168
+ const from = readSkillStamp(installed).version;
4169
+ if (state === "current") {
4170
+ results.push({ step, file: relDest, status: "already", detail: `skill already installed (${version})` });
4171
+ continue;
4172
+ }
4173
+ if (state === "outdated") {
4174
+ detail = `outdated (${from ?? "unknown version"}) \u2014 updated to ${version}`;
4175
+ } else if (!opts.force) {
4176
+ results.push({
4177
+ step,
4178
+ file: relDest,
4179
+ status: "skipped",
4180
+ detail: state === "edited" ? `edited since ${from ?? "it was"} installed \u2014 pass --force to replace it with ${version}` : `skill exists and differs \u2014 pass --force to replace it with ${version}`
4181
+ });
4182
+ continue;
4183
+ } else {
4184
+ detail = state === "edited" ? `edited \u2014 replaced with ${version}` : `replaced with ${version}`;
4185
+ }
3909
4186
  }
3910
4187
  if (!opts.dryRun) {
3911
4188
  fs11.mkdirSync(path13.dirname(dest), { recursive: true });
3912
4189
  fs11.writeFileSync(dest, contents);
3913
4190
  }
3914
- results.push({ step, file: relDest, status: exists ? "updated" : "created", detail: "installed skill" });
4191
+ results.push({ step, file: relDest, status: exists ? "updated" : "created", detail });
3915
4192
  }
3916
4193
  return results;
3917
4194
  }
@@ -3929,7 +4206,8 @@ Commands:
3929
4206
  Options for "add":
3930
4207
  --dir <path> Directory to install into (default: ${DEFAULT_SKILLS_DIR})
3931
4208
  --cwd <path> Project root to operate on (default: current directory)
3932
- --force Overwrite a skill file that already exists
4209
+ --force Replace a skill that was edited since it was installed
4210
+ (an untouched skill from an older release is always updated)
3933
4211
  --dry-run Report what would be written without writing
3934
4212
  --json Print the results as JSON
3935
4213
 
@@ -4617,7 +4895,7 @@ async function runRunImpact(args, env) {
4617
4895
  printWarnings(impact);
4618
4896
  if (impact.impact.widened) {
4619
4897
  console.error(
4620
- `piwi run: impact widened to the full suite (${impact.impact.unmappedSourceFiles.length} unmapped source file(s))`
4898
+ `piwi run: impact widened to the full suite (${impact.impact.unmappedSourceFiles.length} changed file(s) not fully mapped to tests)`
4621
4899
  );
4622
4900
  return spawnPlaywrightForRun(args.pkgRunner, args.extra, env);
4623
4901
  }
@@ -5966,7 +6244,10 @@ async function runPreflight(argv, env = process.env, cwd = process.cwd()) {
5966
6244
  console.error("piwi preflight: no test reaches this change \u2014 nothing to run");
5967
6245
  return code;
5968
6246
  }
5969
- const runCode = await spawnPlaywrightForRun(args.pkgRunner, [...runArgs, ...args.extra], env);
6247
+ const runCode = await spawnPlaywrightForRun(args.pkgRunner, [...runArgs, ...args.extra], {
6248
+ ...env,
6249
+ ...originEnv("preflight")
6250
+ });
5970
6251
  if (runCode !== 0) code = EXIT_BREAKS;
5971
6252
  }
5972
6253
  return code;