@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.
package/dist/cli/index.js CHANGED
@@ -788,10 +788,13 @@ async function runAi(argv, env = process.env) {
788
788
  }
789
789
 
790
790
  // src/cli/bug.ts
791
- var fs5 = __toESM(require("fs"));
792
- var path8 = __toESM(require("path"));
791
+ var fs6 = __toESM(require("fs"));
792
+ var path9 = __toESM(require("path"));
793
793
  var import_node_child_process2 = require("child_process");
794
794
 
795
+ // src/internal/config/env.ts
796
+ var path8 = __toESM(require("path"));
797
+
795
798
  // src/internal/config/desktop.ts
796
799
  var fs4 = __toESM(require("fs"));
797
800
  var os = __toESM(require("os"));
@@ -799,6 +802,14 @@ var path7 = __toESM(require("path"));
799
802
  function defaultDesktopConfigPath() {
800
803
  return path7.join(os.homedir(), ".piwi", "desktop.json");
801
804
  }
805
+ function processAlive(pid) {
806
+ try {
807
+ process.kill(pid, 0);
808
+ return true;
809
+ } catch (error) {
810
+ return error.code === "EPERM";
811
+ }
812
+ }
802
813
  function readDesktopConfig(filePath2) {
803
814
  try {
804
815
  const parsed = JSON.parse(fs4.readFileSync(filePath2, "utf8"));
@@ -806,12 +817,59 @@ function readDesktopConfig(filePath2) {
806
817
  const token = parsed?.token;
807
818
  if (typeof url !== "string" || !url) return null;
808
819
  if (typeof token !== "string" || !token) return null;
820
+ if (Number.isInteger(parsed?.pid) && parsed.pid > 0 && !processAlive(parsed.pid)) return null;
809
821
  return { url, token };
810
822
  } catch {
811
823
  return null;
812
824
  }
813
825
  }
814
826
 
827
+ // src/internal/config/dotenv.ts
828
+ var fs5 = __toESM(require("fs"));
829
+
830
+ // ../core/src/dotenv.ts
831
+ function parseDotEnv(text) {
832
+ const out = {};
833
+ for (const raw of text.split(/\r?\n/)) {
834
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(raw);
835
+ if (!m) continue;
836
+ let value = m[2];
837
+ const quote3 = value[0];
838
+ if ((quote3 === '"' || quote3 === "'") && value.length > 1 && value.endsWith(quote3)) {
839
+ value = value.slice(1, -1);
840
+ if (quote3 === '"') value = value.replace(/\\n/g, "\n").replace(/\\"/g, '"');
841
+ } else {
842
+ value = value.replace(/\s+#.*$/, "");
843
+ }
844
+ out[m[1]] = value;
845
+ }
846
+ return out;
847
+ }
848
+ function resolvePiwiConnection(sources) {
849
+ const own = {
850
+ serverUrl: sources.flags?.serverUrl || sources.env?.PIWI_DASHBOARD_URL,
851
+ apiKey: sources.flags?.apiKey || sources.env?.PIWI_API_KEY
852
+ };
853
+ const workspace = { serverUrl: sources.dotEnv?.PIWI_DASHBOARD_URL, apiKey: sources.dotEnv?.PIWI_API_KEY };
854
+ const project = sources.flags?.project || sources.env?.PIWI_PROJECT_NAME || sources.dotEnv?.PIWI_PROJECT_NAME || "";
855
+ const desktop = sources.desktop ?? null;
856
+ const trim = (url) => url.replace(/\/+$/, "");
857
+ const layer = own.serverUrl ? own : workspace.serverUrl ? workspace : null;
858
+ const serverUrl = layer ? trim(layer.serverUrl) : desktop ? trim(desktop.url) : "";
859
+ if (!serverUrl) return null;
860
+ const desktopKey = desktop && trim(desktop.url) === serverUrl ? desktop.token : null;
861
+ return { serverUrl, apiKey: layer?.apiKey || desktopKey || null, project };
862
+ }
863
+
864
+ // src/internal/config/dotenv.ts
865
+ function readDotEnv(filePath2) {
866
+ try {
867
+ return parseDotEnv(fs5.readFileSync(filePath2, "utf-8"));
868
+ } catch {
869
+ return {};
870
+ }
871
+ }
872
+
815
873
  // src/internal/config/env.ts
816
874
  var DEFAULTS = {
817
875
  projectName: "default-project",
@@ -848,6 +906,7 @@ var DEFAULTS = {
848
906
  };
849
907
  var PIWI_ENV_KEYS = {
850
908
  serverUrl: "PIWI_DASHBOARD_URL",
909
+ desktopApp: "PIWI_DESKTOP_APP",
851
910
  projectName: "PIWI_PROJECT_NAME",
852
911
  verbose: "PIWI_VERBOSE",
853
912
  apiKey: "PIWI_API_KEY",
@@ -1041,7 +1100,7 @@ function repositoryRoot(cwd) {
1041
1100
  encoding: "utf-8",
1042
1101
  stdio: ["ignore", "pipe", "ignore"]
1043
1102
  }).trim();
1044
- return out ? path8.resolve(out) : null;
1103
+ return out ? path9.resolve(out) : null;
1045
1104
  } catch {
1046
1105
  return null;
1047
1106
  }
@@ -1049,20 +1108,20 @@ function repositoryRoot(cwd) {
1049
1108
  function realFolder(dir) {
1050
1109
  const rest = [];
1051
1110
  let current = dir;
1052
- while (!fs5.existsSync(current) && path8.dirname(current) !== current) {
1053
- rest.unshift(path8.basename(current));
1054
- current = path8.dirname(current);
1111
+ while (!fs6.existsSync(current) && path9.dirname(current) !== current) {
1112
+ rest.unshift(path9.basename(current));
1113
+ current = path9.dirname(current);
1055
1114
  }
1056
- return path8.join(fs5.realpathSync(current), ...rest);
1115
+ return path9.join(fs6.realpathSync(current), ...rest);
1057
1116
  }
1058
1117
  function folderInRepository(root, dir) {
1059
- const relative9 = path8.relative(realFolder(root), realFolder(dir));
1060
- if (relative9.startsWith("..") || path8.isAbsolute(relative9)) return void 0;
1061
- return relative9.split(path8.sep).join("/");
1118
+ const relative9 = path9.relative(realFolder(root), realFolder(dir));
1119
+ if (relative9.startsWith("..") || path9.isAbsolute(relative9)) return void 0;
1120
+ return relative9.split(path9.sep).join("/");
1062
1121
  }
1063
1122
  function fileFilter(file) {
1064
1123
  const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1065
- return `${escape(path8.basename(path8.dirname(file)))}[\\\\/]${escape(path8.basename(file))}$`;
1124
+ return `${escape(path9.basename(path9.dirname(file)))}[\\\\/]${escape(path9.basename(file))}$`;
1066
1125
  }
1067
1126
  function runPlaywright(file, cwd, id, env) {
1068
1127
  return spawnPlaywright([fileFilter(file)], { ...env, ...originEnv("bug", id) }, { cwd, command: "piwi bug" });
@@ -1082,10 +1141,10 @@ ${USAGE2}`);
1082
1141
  return EXIT_ERROR2;
1083
1142
  }
1084
1143
  const root = args.write ? repositoryRoot(cwd) : null;
1085
- const outFile = args.out !== null ? path8.resolve(cwd, args.out) : null;
1144
+ const outFile = args.out !== null ? path9.resolve(cwd, args.out) : null;
1086
1145
  let spec;
1087
1146
  try {
1088
- spec = await fetchBugSpec(args, outFile && root ? folderInRepository(root, path8.dirname(outFile)) : void 0);
1147
+ spec = await fetchBugSpec(args, outFile && root ? folderInRepository(root, path9.dirname(outFile)) : void 0);
1089
1148
  } catch (e) {
1090
1149
  console.error(`piwi bug: ${e.message}`);
1091
1150
  return EXIT_ERROR2;
@@ -1100,16 +1159,16 @@ ${USAGE2}`);
1100
1159
  if (!outFile && !root) {
1101
1160
  console.error("piwi bug: not in a git repository \u2014 the bugs folder is read from the working directory");
1102
1161
  }
1103
- const file = outFile ?? path8.resolve(root ?? cwd, spec.path);
1162
+ const file = outFile ?? path9.resolve(root ?? cwd, spec.path);
1104
1163
  try {
1105
- fs5.mkdirSync(path8.dirname(file), { recursive: true });
1106
- fs5.writeFileSync(file, spec.code, { flag: args.force ? "w" : "wx" });
1164
+ fs6.mkdirSync(path9.dirname(file), { recursive: true });
1165
+ fs6.writeFileSync(file, spec.code, { flag: args.force ? "w" : "wx" });
1107
1166
  } catch (e) {
1108
1167
  const exists = e.code === "EEXIST";
1109
1168
  console.error(`piwi bug: ${exists ? `${file} exists \u2014 pass --force to replace it` : e.message}`);
1110
1169
  return EXIT_ERROR2;
1111
1170
  }
1112
- const shown = path8.relative(cwd, file) || file;
1171
+ const shown = path9.relative(cwd, file) || file;
1113
1172
  console.error(`piwi bug: wrote ${shown}`);
1114
1173
  if (!args.run) return EXIT_OK2;
1115
1174
  const code = await runPlaywright(file, cwd, args.id, env);
@@ -1127,8 +1186,8 @@ ${USAGE2}`);
1127
1186
  }
1128
1187
 
1129
1188
  // src/cli/codegen.ts
1130
- var fs6 = __toESM(require("fs"));
1131
- var path9 = __toESM(require("path"));
1189
+ var fs7 = __toESM(require("fs"));
1190
+ var path10 = __toESM(require("path"));
1132
1191
 
1133
1192
  // ../core/src/recording.ts
1134
1193
  var ASSERTION_MATCHERS = [
@@ -2168,7 +2227,7 @@ function renderSpec(session, options = {}) {
2168
2227
  if (viewport) bodyLines.push(viewportLine(viewport, page));
2169
2228
  stepStarts[pos] = bodyLines.length;
2170
2229
  if (step.action === "goto") {
2171
- bodyLines.push(...renderRawStep(step, pos, ctx));
2230
+ if (!(options.continuePage && pos === 0)) bodyLines.push(...renderRawStep(step, pos, ctx));
2172
2231
  sawGoto = true;
2173
2232
  pos++;
2174
2233
  continue;
@@ -2187,10 +2246,10 @@ function renderSpec(session, options = {}) {
2187
2246
  if (!callAt(pos + 1)) checkNextPage(pos);
2188
2247
  pos++;
2189
2248
  }
2190
- const start = viewportAt.get(0);
2249
+ const start = options.continuePage ? void 0 : viewportAt.get(0);
2191
2250
  const opening = [
2192
2251
  ...start ? [viewportLine(start, page)] : [],
2193
- ...!sawGoto && session.startUrl ? [` await ${page}.goto(${quote(urlForCode(session.startUrl, ctx))});`] : []
2252
+ ...!sawGoto && !options.continuePage && session.startUrl ? [` await ${page}.goto(${quote(urlForCode(session.startUrl, ctx))});`] : []
2194
2253
  ];
2195
2254
  bodyLines.unshift(...opening);
2196
2255
  const bodyOffset = opening.length;
@@ -2345,43 +2404,43 @@ var Checker = class {
2345
2404
  constructor() {
2346
2405
  this.errors = [];
2347
2406
  }
2348
- fail(path24, message) {
2349
- if (this.errors.length < 20) this.errors.push(`${path24}: ${message}`);
2407
+ fail(path25, message) {
2408
+ if (this.errors.length < 20) this.errors.push(`${path25}: ${message}`);
2350
2409
  }
2351
- text(v, path24, max, nullable) {
2410
+ text(v, path25, max, nullable) {
2352
2411
  if (v == null) {
2353
- if (!nullable) this.fail(path24, "is required");
2412
+ if (!nullable) this.fail(path25, "is required");
2354
2413
  return null;
2355
2414
  }
2356
2415
  if (typeof v !== "string") {
2357
- this.fail(path24, "must be a string");
2416
+ this.fail(path25, "must be a string");
2358
2417
  return null;
2359
2418
  }
2360
- if (v.length > max) this.fail(path24, `is longer than ${max} characters`);
2419
+ if (v.length > max) this.fail(path25, `is longer than ${max} characters`);
2361
2420
  return v;
2362
2421
  }
2363
- number(v, path24) {
2422
+ number(v, path25) {
2364
2423
  if (typeof v !== "number" || !Number.isFinite(v)) {
2365
- this.fail(path24, "must be a number");
2424
+ this.fail(path25, "must be a number");
2366
2425
  return 0;
2367
2426
  }
2368
2427
  return v;
2369
2428
  }
2370
2429
  };
2371
- function checkTarget(c, v, path24) {
2430
+ function checkTarget(c, v, path25) {
2372
2431
  if (v == null) return null;
2373
2432
  if (!isObject(v)) {
2374
- c.fail(path24, "must be an object");
2433
+ c.fail(path25, "must be an object");
2375
2434
  return null;
2376
2435
  }
2377
2436
  const alternativesIn = v.alternatives ?? [];
2378
2437
  const alternatives = [];
2379
- if (!Array.isArray(alternativesIn)) c.fail(`${path24}.alternatives`, "must be a list");
2438
+ if (!Array.isArray(alternativesIn)) c.fail(`${path25}.alternatives`, "must be a list");
2380
2439
  else if (alternativesIn.length > STEPS_LIMITS.alternatives)
2381
- c.fail(`${path24}.alternatives`, `holds more than ${STEPS_LIMITS.alternatives}`);
2440
+ c.fail(`${path25}.alternatives`, `holds more than ${STEPS_LIMITS.alternatives}`);
2382
2441
  else {
2383
2442
  alternativesIn.forEach((alt, i) => {
2384
- const at = `${path24}.alternatives[${i}]`;
2443
+ const at = `${path25}.alternatives[${i}]`;
2385
2444
  if (!isObject(alt)) return c.fail(at, "must be an object");
2386
2445
  const locator = c.text(alt.locator, `${at}.locator`, STEPS_LIMITS.textLength, false);
2387
2446
  const safe = locator ? safeLocator(locator) : null;
@@ -2395,47 +2454,47 @@ function checkTarget(c, v, path24) {
2395
2454
  });
2396
2455
  }
2397
2456
  return {
2398
- tagName: c.text(v.tagName, `${path24}.tagName`, 64, false) ?? "",
2399
- role: c.text(v.role, `${path24}.role`, 64, true),
2400
- accessibleName: c.text(v.accessibleName, `${path24}.accessibleName`, STEPS_LIMITS.textLength, true),
2401
- testId: c.text(v.testId, `${path24}.testId`, STEPS_LIMITS.textLength, true),
2402
- text: c.text(v.text, `${path24}.text`, STEPS_LIMITS.textLength, true),
2457
+ tagName: c.text(v.tagName, `${path25}.tagName`, 64, false) ?? "",
2458
+ role: c.text(v.role, `${path25}.role`, 64, true),
2459
+ accessibleName: c.text(v.accessibleName, `${path25}.accessibleName`, STEPS_LIMITS.textLength, true),
2460
+ testId: c.text(v.testId, `${path25}.testId`, STEPS_LIMITS.textLength, true),
2461
+ text: c.text(v.text, `${path25}.text`, STEPS_LIMITS.textLength, true),
2403
2462
  alternatives
2404
2463
  };
2405
2464
  }
2406
- function checkAssertion(c, v, path24) {
2465
+ function checkAssertion(c, v, path25) {
2407
2466
  if (!isObject(v)) {
2408
- c.fail(path24, "is required on an assert step");
2467
+ c.fail(path25, "is required on an assert step");
2409
2468
  return void 0;
2410
2469
  }
2411
2470
  const matcher = v.matcher;
2412
2471
  if (!ASSERTION_MATCHERS.includes(matcher)) {
2413
- c.fail(`${path24}.matcher`, `must be one of ${ASSERTION_MATCHERS.join(", ")}`);
2472
+ c.fail(`${path25}.matcher`, `must be one of ${ASSERTION_MATCHERS.join(", ")}`);
2414
2473
  return void 0;
2415
2474
  }
2416
- const expected = c.text(v.expected, `${path24}.expected`, STEPS_LIMITS.valueLength, !VALUE_MATCHERS.has(matcher));
2475
+ const expected = c.text(v.expected, `${path25}.expected`, STEPS_LIMITS.valueLength, !VALUE_MATCHERS.has(matcher));
2417
2476
  if (matcher === "toHaveCount" && expected != null && !/^\d{1,6}$/.test(expected)) {
2418
- c.fail(`${path24}.expected`, "must be a whole number of elements on a toHaveCount assertion");
2477
+ c.fail(`${path25}.expected`, "must be a whole number of elements on a toHaveCount assertion");
2419
2478
  }
2420
2479
  return {
2421
2480
  matcher,
2422
2481
  expected: VALUE_MATCHERS.has(matcher) ? expected : null,
2423
- actual: c.text(v.actual, `${path24}.actual`, STEPS_LIMITS.valueLength, true),
2482
+ actual: c.text(v.actual, `${path25}.actual`, STEPS_LIMITS.valueLength, true),
2424
2483
  negated: v.negated === true,
2425
- note: c.text(v.note, `${path24}.note`, STEPS_LIMITS.valueLength, true)
2484
+ note: c.text(v.note, `${path25}.note`, STEPS_LIMITS.valueLength, true)
2426
2485
  };
2427
2486
  }
2428
- function checkManual(c, v, path24) {
2487
+ function checkManual(c, v, path25) {
2429
2488
  if (!isObject(v)) {
2430
- c.fail(path24, "is required on a manual step");
2489
+ c.fail(path25, "is required on a manual step");
2431
2490
  return void 0;
2432
2491
  }
2433
- const description = c.text(v.description, `${path24}.description`, STEPS_LIMITS.valueLength, false);
2434
- if (description != null && !description.trim()) c.fail(`${path24}.description`, "must not be empty");
2492
+ const description = c.text(v.description, `${path25}.description`, STEPS_LIMITS.valueLength, false);
2493
+ if (description != null && !description.trim()) c.fail(`${path25}.description`, "must not be empty");
2435
2494
  return {
2436
2495
  description: description ?? "",
2437
- location: c.text(v.location, `${path24}.location`, STEPS_LIMITS.textLength, true),
2438
- call: c.text(v.call, `${path24}.call`, STEPS_LIMITS.textLength, true)
2496
+ location: c.text(v.location, `${path25}.location`, STEPS_LIMITS.textLength, true),
2497
+ call: c.text(v.call, `${path25}.call`, STEPS_LIMITS.textLength, true)
2439
2498
  };
2440
2499
  }
2441
2500
  function checkViewports(c, v, stepCount) {
@@ -2476,43 +2535,43 @@ function checkViewports(c, v, stepCount) {
2476
2535
  });
2477
2536
  return out;
2478
2537
  }
2479
- function checkStep(c, v, path24) {
2538
+ function checkStep(c, v, path25) {
2480
2539
  if (!isObject(v)) {
2481
- c.fail(path24, "must be an object");
2540
+ c.fail(path25, "must be an object");
2482
2541
  return null;
2483
2542
  }
2484
2543
  const action = v.action;
2485
2544
  if (!ACTIONS.has(action)) {
2486
- c.fail(`${path24}.action`, "is not a known action");
2545
+ c.fail(`${path25}.action`, "is not a known action");
2487
2546
  return null;
2488
2547
  }
2489
2548
  const step = {
2490
2549
  action: action === "assertVisible" ? "assert" : action,
2491
- target: checkTarget(c, v.target, `${path24}.target`),
2492
- value: c.text(v.value, `${path24}.value`, STEPS_LIMITS.valueLength, true),
2550
+ target: checkTarget(c, v.target, `${path25}.target`),
2551
+ value: c.text(v.value, `${path25}.value`, STEPS_LIMITS.valueLength, true),
2493
2552
  redacted: v.redacted === true,
2494
- pageUrl: c.text(v.pageUrl, `${path24}.pageUrl`, STEPS_LIMITS.valueLength, false) ?? "",
2495
- timestamp: v.timestamp == null ? 0 : c.number(v.timestamp, `${path24}.timestamp`)
2553
+ pageUrl: c.text(v.pageUrl, `${path25}.pageUrl`, STEPS_LIMITS.valueLength, false) ?? "",
2554
+ timestamp: v.timestamp == null ? 0 : c.number(v.timestamp, `${path25}.timestamp`)
2496
2555
  };
2497
2556
  if (step.redacted) step.value = null;
2498
2557
  if (action === "hover" || action === "dblclick") {
2499
2558
  step.value = null;
2500
- if (!step.target) c.fail(`${path24}.target`, `is required on a ${action} step`);
2559
+ if (!step.target) c.fail(`${path25}.target`, `is required on a ${action} step`);
2501
2560
  }
2502
- if (action === "setInputFiles" && !step.target) c.fail(`${path24}.target`, "is required on a setInputFiles step");
2561
+ if (action === "setInputFiles" && !step.target) c.fail(`${path25}.target`, "is required on a setInputFiles step");
2503
2562
  if (action === "dragTo") {
2504
2563
  step.value = null;
2505
- if (!step.target) c.fail(`${path24}.target`, "is required on a dragTo step");
2506
- step.dropTarget = checkTarget(c, v.dropTarget, `${path24}.dropTarget`);
2507
- if (!step.dropTarget) c.fail(`${path24}.dropTarget`, "is required on a dragTo step");
2564
+ if (!step.target) c.fail(`${path25}.target`, "is required on a dragTo step");
2565
+ step.dropTarget = checkTarget(c, v.dropTarget, `${path25}.dropTarget`);
2566
+ if (!step.dropTarget) c.fail(`${path25}.dropTarget`, "is required on a dragTo step");
2508
2567
  }
2509
2568
  if (action === "assertVisible") {
2510
2569
  step.assertion = { matcher: "toBeVisible", expected: null, actual: null, negated: false, note: null };
2511
2570
  } else if (action === "assert") {
2512
- step.assertion = checkAssertion(c, v.assertion, `${path24}.assertion`);
2571
+ step.assertion = checkAssertion(c, v.assertion, `${path25}.assertion`);
2513
2572
  } else if (action === "manual") {
2514
2573
  step.value = null;
2515
- step.manual = checkManual(c, v.manual, `${path24}.manual`);
2574
+ step.manual = checkManual(c, v.manual, `${path25}.manual`);
2516
2575
  }
2517
2576
  return step;
2518
2577
  }
@@ -2592,13 +2651,14 @@ async function fetchResolution(options, projectId) {
2592
2651
  }
2593
2652
  return await res.json();
2594
2653
  }
2595
- async function fetchImpact(options, projectId, changedFiles) {
2654
+ async function fetchImpact(options, projectId, changedFiles, changes = []) {
2596
2655
  const url = `${options.serverUrl}/api/projects/${projectId}/selections/impact`;
2597
2656
  const res = await fetch(url, {
2598
2657
  method: "POST",
2599
2658
  headers: { "Content-Type": "application/json", ...authHeaders(options.apiKey) },
2600
2659
  body: JSON.stringify({
2601
2660
  changedFiles,
2661
+ ...changes.length > 0 ? { changes } : {},
2602
2662
  format: options.format ?? "args",
2603
2663
  shard: options.shard ?? void 0,
2604
2664
  order: options.order ?? void 0
@@ -2811,8 +2871,8 @@ ${USAGE3}`);
2811
2871
  }
2812
2872
  } else {
2813
2873
  try {
2814
- if (fs6.statSync(args.file).size > MAX_STEPS_FILE_BYTES) throw new Error("is too large to be a steps file");
2815
- text = fs6.readFileSync(args.file, "utf-8");
2874
+ if (fs7.statSync(args.file).size > MAX_STEPS_FILE_BYTES) throw new Error("is too large to be a steps file");
2875
+ text = fs7.readFileSync(args.file, "utf-8");
2816
2876
  } catch (e) {
2817
2877
  const reason = e.code === "ENOENT" ? "does not exist" : e.message;
2818
2878
  console.error(`piwi codegen: ${args.file} ${reason}`);
@@ -2844,8 +2904,8 @@ ${USAGE3}`);
2844
2904
  return EXIT_OK3;
2845
2905
  }
2846
2906
  try {
2847
- fs6.mkdirSync(path9.dirname(args.out), { recursive: true });
2848
- fs6.writeFileSync(args.out, result.code, { flag: args.force ? "w" : "wx" });
2907
+ fs7.mkdirSync(path10.dirname(args.out), { recursive: true });
2908
+ fs7.writeFileSync(args.out, result.code, { flag: args.force ? "w" : "wx" });
2849
2909
  } catch (e) {
2850
2910
  const exists = e.code === "EEXIST";
2851
2911
  console.error(`piwi codegen: ${exists ? `${args.out} exists \u2014 pass --force to replace it` : e.message}`);
@@ -2859,7 +2919,7 @@ ${USAGE3}`);
2859
2919
  }
2860
2920
 
2861
2921
  // src/cli/coverage.ts
2862
- var path14 = __toESM(require("path"));
2922
+ var path15 = __toESM(require("path"));
2863
2923
 
2864
2924
  // ../core/src/codeowners.ts
2865
2925
  function patternParts(pattern) {
@@ -2897,10 +2957,10 @@ function pathPatternMatcher(pattern) {
2897
2957
  const { segments, floating } = patternParts(pattern);
2898
2958
  if (floating) {
2899
2959
  const only = segments[0];
2900
- return (path24) => path24.split("/").some((part) => segmentMatches(only, part));
2960
+ return (path25) => path25.split("/").some((part) => segmentMatches(only, part));
2901
2961
  }
2902
- return (path24) => {
2903
- const parts = path24.split("/");
2962
+ return (path25) => {
2963
+ const parts = path25.split("/");
2904
2964
  let at = new Uint8Array(parts.length);
2905
2965
  at[0] = 1;
2906
2966
  for (let i = 0; i < segments.length; i++) {
@@ -2966,8 +3026,8 @@ function parseCoverageScope(raw) {
2966
3026
  function compileCoverageScope(scope) {
2967
3027
  const include = scope.include.map(pathPatternMatcher);
2968
3028
  const exclude = scope.exclude.map(pathPatternMatcher);
2969
- return (path24) => {
2970
- const file = path24.replace(/\\/g, "/").replace(/^\.?\//, "");
3029
+ return (path25) => {
3030
+ const file = path25.replace(/\\/g, "/").replace(/^\.?\//, "");
2971
3031
  if (include.length > 0 && !include.some((matches) => matches(file))) return false;
2972
3032
  return !exclude.some((matches) => matches(file));
2973
3033
  };
@@ -2983,7 +3043,7 @@ function errorMessage(error) {
2983
3043
  }
2984
3044
 
2985
3045
  // src/internal/support/test-paths.ts
2986
- var path10 = __toESM(require("path"));
3046
+ var path11 = __toESM(require("path"));
2987
3047
 
2988
3048
  // src/internal/collect/metadata-collector.ts
2989
3049
  var DETACHED_HEAD = "HEAD";
@@ -3016,14 +3076,14 @@ function normalizeRef(ref) {
3016
3076
 
3017
3077
  // src/internal/coverage-upload/git-inventory.ts
3018
3078
  var import_node_child_process5 = require("child_process");
3019
- var fs8 = __toESM(require("fs"));
3020
- var path12 = __toESM(require("path"));
3079
+ var fs9 = __toESM(require("fs"));
3080
+ var path13 = __toESM(require("path"));
3021
3081
 
3022
3082
  // src/internal/collect/blob-ids.ts
3023
3083
  var import_node_child_process4 = require("child_process");
3024
3084
  var crypto2 = __toESM(require("crypto"));
3025
- var fs7 = __toESM(require("fs"));
3026
- var path11 = __toESM(require("path"));
3085
+ var fs8 = __toESM(require("fs"));
3086
+ var path12 = __toESM(require("path"));
3027
3087
  function gitHashObjects(root, paths) {
3028
3088
  return new Promise((resolve10) => {
3029
3089
  const child = (0, import_node_child_process4.execFile)(
@@ -3090,7 +3150,7 @@ async function readInventory(root, warn = () => {
3090
3150
  const changed = [];
3091
3151
  for (const file of (await git(root, ["diff", "--name-only", "-z", "HEAD"]).catch(() => "")).split("\0")) {
3092
3152
  if (!file || !inventory.has(file)) continue;
3093
- if (fs8.existsSync(path12.join(root, file))) changed.push(file);
3153
+ if (fs9.existsSync(path13.join(root, file))) changed.push(file);
3094
3154
  else inventory.delete(file);
3095
3155
  }
3096
3156
  const ids = await hashPaths(root, changed);
@@ -3109,8 +3169,8 @@ async function readInventory(root, warn = () => {
3109
3169
  }
3110
3170
 
3111
3171
  // src/internal/coverage-upload/report-files.ts
3112
- var fs9 = __toESM(require("fs"));
3113
- var path13 = __toESM(require("path"));
3172
+ var fs10 = __toESM(require("fs"));
3173
+ var path14 = __toESM(require("path"));
3114
3174
 
3115
3175
  // ../core/src/coverage.ts
3116
3176
  var MAX_LINE = 1e6;
@@ -3189,7 +3249,7 @@ var FileLines = class {
3189
3249
  this.functions.set(key, { start, end: last, ran });
3190
3250
  }
3191
3251
  }
3192
- toFile(path24) {
3252
+ toFile(path25) {
3193
3253
  const executable = [];
3194
3254
  const lines = [];
3195
3255
  const push = (ranges, line) => {
@@ -3209,7 +3269,7 @@ var FileLines = class {
3209
3269
  if (didRun) ran.push([start, end]);
3210
3270
  }
3211
3271
  return {
3212
- path: path24,
3272
+ path: path25,
3213
3273
  executable,
3214
3274
  lines,
3215
3275
  functions: normalizeFunctionRanges(ran),
@@ -3221,16 +3281,16 @@ var ReportFiles = class {
3221
3281
  constructor() {
3222
3282
  this.byPath = /* @__PURE__ */ new Map();
3223
3283
  }
3224
- of(path24) {
3225
- let file = this.byPath.get(path24);
3284
+ of(path25) {
3285
+ let file = this.byPath.get(path25);
3226
3286
  if (!file) {
3227
3287
  file = new FileLines();
3228
- this.byPath.set(path24, file);
3288
+ this.byPath.set(path25, file);
3229
3289
  }
3230
3290
  return file;
3231
3291
  }
3232
3292
  toFiles() {
3233
- return [...this.byPath].map(([path24, file]) => file.toFile(path24));
3293
+ return [...this.byPath].map(([path25, file]) => file.toFile(path25));
3234
3294
  }
3235
3295
  };
3236
3296
  var XML_ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
@@ -3569,23 +3629,23 @@ function normalizeSlashPath(raw) {
3569
3629
  }
3570
3630
  return (drive || (absolute ? "/" : "")) + out.join("/");
3571
3631
  }
3572
- function isAbsolutePath(path24) {
3573
- return path24.startsWith("/") || /^[A-Za-z]:\//.test(path24);
3632
+ function isAbsolutePath(path25) {
3633
+ return path25.startsWith("/") || /^[A-Za-z]:\//.test(path25);
3574
3634
  }
3575
- function relativeTo(path24, root) {
3635
+ function relativeTo(path25, root) {
3576
3636
  const drive = /^[A-Za-z]:/.test(root);
3577
3637
  const base = root.endsWith("/") ? root : `${root}/`;
3578
- const head = path24.slice(0, base.length);
3638
+ const head = path25.slice(0, base.length);
3579
3639
  const inside = drive ? head.toLowerCase() === base.toLowerCase() : head === base;
3580
3640
  if (!inside) return null;
3581
- const relative9 = path24.slice(base.length);
3641
+ const relative9 = path25.slice(base.length);
3582
3642
  return relative9 || null;
3583
3643
  }
3584
3644
  function reportPathCandidates(raw, { repoRoot, bases }) {
3585
3645
  const slash = (value) => value.replace(/\\/g, "/");
3586
3646
  const root = normalizeSlashPath(slash(repoRoot));
3587
- const path24 = slash(raw.trim());
3588
- if (!root || !path24 || path24.includes("\0")) return [];
3647
+ const path25 = slash(raw.trim());
3648
+ if (!root || !path25 || path25.includes("\0")) return [];
3589
3649
  const out = [];
3590
3650
  const add = (absolute) => {
3591
3651
  const normalized = normalizeSlashPath(absolute);
@@ -3593,12 +3653,12 @@ function reportPathCandidates(raw, { repoRoot, bases }) {
3593
3653
  const relative9 = normalized.startsWith("/_/") ? normalized.slice(3) : relativeTo(normalized, root);
3594
3654
  if (relative9 && !out.includes(relative9)) out.push(relative9);
3595
3655
  };
3596
- if (isAbsolutePath(path24)) {
3597
- add(path24);
3656
+ if (isAbsolutePath(path25)) {
3657
+ add(path25);
3598
3658
  } else {
3599
3659
  for (const base of bases) {
3600
3660
  const dir = slash(base);
3601
- if (isAbsolutePath(dir)) add(`${dir}/${path24}`);
3661
+ if (isAbsolutePath(dir)) add(`${dir}/${path25}`);
3602
3662
  }
3603
3663
  }
3604
3664
  return out;
@@ -3622,14 +3682,14 @@ function globSegmentRegExp(segment) {
3622
3682
  function expandPattern(pattern, cwd) {
3623
3683
  const normalized = pattern.replace(/\\/g, "/");
3624
3684
  if (!/[*?]/.test(normalized)) {
3625
- const file = path13.resolve(cwd, normalized);
3626
- return fs9.existsSync(file) && fs9.statSync(file).isFile() ? [file] : [];
3685
+ const file = path14.resolve(cwd, normalized);
3686
+ return fs10.existsSync(file) && fs10.statSync(file).isFile() ? [file] : [];
3627
3687
  }
3628
- const absolute = path13.isAbsolute(normalized) || /^[A-Za-z]:\//.test(normalized);
3688
+ const absolute = path14.isAbsolute(normalized) || /^[A-Za-z]:\//.test(normalized);
3629
3689
  const segments = normalized.split("/");
3630
3690
  const firstWild = segments.findIndex((s) => /[*?]/.test(s));
3631
3691
  const baseParts = segments.slice(0, firstWild);
3632
- const base = absolute ? baseParts.join("/") || "/" : path13.resolve(cwd, ...baseParts);
3692
+ const base = absolute ? baseParts.join("/") || "/" : path14.resolve(cwd, ...baseParts);
3633
3693
  const rest = segments.slice(firstWild).filter((s) => s !== "");
3634
3694
  const out = [];
3635
3695
  const walk = (dir, at) => {
@@ -3637,14 +3697,14 @@ function expandPattern(pattern, cwd) {
3637
3697
  const segment = rest[at];
3638
3698
  let entries;
3639
3699
  try {
3640
- entries = fs9.readdirSync(dir, { withFileTypes: true });
3700
+ entries = fs10.readdirSync(dir, { withFileTypes: true });
3641
3701
  } catch {
3642
3702
  return;
3643
3703
  }
3644
3704
  if (segment === "**") {
3645
3705
  walk(dir, at + 1);
3646
3706
  for (const entry of entries) {
3647
- if (entry.isDirectory() && !SKIPPED_DIRS.has(entry.name)) walk(path13.join(dir, entry.name), at);
3707
+ if (entry.isDirectory() && !SKIPPED_DIRS.has(entry.name)) walk(path14.join(dir, entry.name), at);
3648
3708
  }
3649
3709
  return;
3650
3710
  }
@@ -3652,7 +3712,7 @@ function expandPattern(pattern, cwd) {
3652
3712
  const last = at === rest.length - 1;
3653
3713
  for (const entry of entries) {
3654
3714
  if (!match.test(entry.name)) continue;
3655
- const full = path13.join(dir, entry.name);
3715
+ const full = path14.join(dir, entry.name);
3656
3716
  if (last && entry.isFile()) out.push(full);
3657
3717
  else if (!last && entry.isDirectory() && (!SKIPPED_DIRS.has(entry.name) || segment === entry.name)) {
3658
3718
  walk(full, at + 1);
@@ -3665,7 +3725,7 @@ function expandPattern(pattern, cwd) {
3665
3725
  async function readReport(file, format) {
3666
3726
  let reader = format ? new CoverageReportReader(format) : null;
3667
3727
  let head = "";
3668
- for await (const chunk of fs9.createReadStream(file, { encoding: "utf8", highWaterMark: 1024 * 1024 })) {
3728
+ for await (const chunk of fs10.createReadStream(file, { encoding: "utf8", highWaterMark: 1024 * 1024 })) {
3669
3729
  if (!reader) {
3670
3730
  head += chunk;
3671
3731
  if (head.length < 4096) continue;
@@ -3687,18 +3747,18 @@ async function readReport(file, format) {
3687
3747
  return reader.end();
3688
3748
  }
3689
3749
  function reportBases(report, reportFile, options) {
3690
- if (options.root) return [path13.resolve(options.cwd, options.root)];
3691
- const dir = path13.dirname(path13.resolve(reportFile));
3750
+ if (options.root) return [path14.resolve(options.cwd, options.root)];
3751
+ const dir = path14.dirname(path14.resolve(reportFile));
3692
3752
  const sources = report.sources.map(
3693
- (source) => path13.isAbsolute(source) || /^[A-Za-z]:[\\/]/.test(source) ? source : path13.resolve(dir, source)
3753
+ (source) => path14.isAbsolute(source) || /^[A-Za-z]:[\\/]/.test(source) ? source : path14.resolve(dir, source)
3694
3754
  );
3695
- return [...sources, options.cwd, dir, path13.dirname(dir), options.repoRoot];
3755
+ return [...sources, options.cwd, dir, path14.dirname(dir), options.repoRoot];
3696
3756
  }
3697
3757
  function resolveReport(report, reportFile, options, into) {
3698
3758
  const bases = reportBases(report, reportFile, options);
3699
3759
  for (const file of report.files) {
3700
3760
  const candidates = reportPathCandidates(file.path, { repoRoot: options.repoRoot, bases });
3701
- const match = candidates.find((candidate) => options.inventory.has(candidate)) ?? candidates.find((candidate) => fs9.existsSync(path13.join(options.repoRoot, candidate)));
3761
+ const match = candidates.find((candidate) => options.inventory.has(candidate)) ?? candidates.find((candidate) => fs10.existsSync(path14.join(options.repoRoot, candidate)));
3702
3762
  if (!match) {
3703
3763
  into.unmatched.push(file.path);
3704
3764
  continue;
@@ -3923,7 +3983,7 @@ async function uploadCoverage(args, env, cwd, warn = warnOnStderr) {
3923
3983
  commit: checkout.commit,
3924
3984
  branch,
3925
3985
  suite: args.suite,
3926
- reports: reports.map((report) => path14.relative(cwd, report) || report),
3986
+ reports: reports.map((report) => path15.relative(cwd, report) || report),
3927
3987
  files: files.length,
3928
3988
  unmatched: resolved.unmatched.length,
3929
3989
  outOfScope: resolved.outOfScope,
@@ -3995,7 +4055,7 @@ ${USAGE4}`);
3995
4055
  var import_node_child_process6 = require("child_process");
3996
4056
  var fs11 = __toESM(require("fs"));
3997
4057
  var os2 = __toESM(require("os"));
3998
- var path16 = __toESM(require("path"));
4058
+ var path17 = __toESM(require("path"));
3999
4059
 
4000
4060
  // ../core/src/error-parse.ts
4001
4061
  var ANSI_RE = new RegExp("\\u001B\\[[0-9;]*m", "g");
@@ -4197,52 +4257,10 @@ function withinBudget(startedAt, now, budgetMs) {
4197
4257
  }
4198
4258
 
4199
4259
  // src/internal/support/connection.ts
4200
- var fs10 = __toESM(require("fs"));
4201
- var path15 = __toESM(require("path"));
4202
-
4203
- // ../core/src/dotenv.ts
4204
- function parseDotEnv(text) {
4205
- const out = {};
4206
- for (const raw of text.split(/\r?\n/)) {
4207
- const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(raw);
4208
- if (!m) continue;
4209
- let value = m[2];
4210
- const quote3 = value[0];
4211
- if ((quote3 === '"' || quote3 === "'") && value.length > 1 && value.endsWith(quote3)) {
4212
- value = value.slice(1, -1);
4213
- if (quote3 === '"') value = value.replace(/\\n/g, "\n").replace(/\\"/g, '"');
4214
- } else {
4215
- value = value.replace(/\s+#.*$/, "");
4216
- }
4217
- out[m[1]] = value;
4218
- }
4219
- return out;
4220
- }
4221
- function resolvePiwiConnection(sources) {
4222
- const own = {
4223
- serverUrl: sources.flags?.serverUrl || sources.env?.PIWI_DASHBOARD_URL,
4224
- apiKey: sources.flags?.apiKey || sources.env?.PIWI_API_KEY
4225
- };
4226
- const workspace = { serverUrl: sources.dotEnv?.PIWI_DASHBOARD_URL, apiKey: sources.dotEnv?.PIWI_API_KEY };
4227
- const project = sources.flags?.project || sources.env?.PIWI_PROJECT_NAME || sources.dotEnv?.PIWI_PROJECT_NAME || "";
4228
- const desktop = sources.desktop ?? null;
4229
- const trim = (url) => url.replace(/\/+$/, "");
4230
- const layer = own.serverUrl ? own : workspace.serverUrl ? workspace : null;
4231
- const serverUrl = layer ? trim(layer.serverUrl) : desktop ? trim(desktop.url) : "";
4232
- if (!serverUrl) return null;
4233
- const desktopKey = desktop && trim(desktop.url) === serverUrl ? desktop.token : null;
4234
- return { serverUrl, apiKey: layer?.apiKey || desktopKey || null, project };
4235
- }
4236
-
4237
- // src/internal/support/connection.ts
4260
+ var path16 = __toESM(require("path"));
4238
4261
  function resolveCliConnection(flags, env, dirs) {
4239
4262
  let dotEnv = {};
4240
- for (const dir of dirs) {
4241
- try {
4242
- dotEnv = { ...parseDotEnv(fs10.readFileSync(path15.join(dir, ".env"), "utf-8")), ...dotEnv };
4243
- } catch {
4244
- }
4245
- }
4263
+ for (const dir of dirs) dotEnv = { ...readDotEnv(path16.join(dir, ".env")), ...dotEnv };
4246
4264
  return resolvePiwiConnection({
4247
4265
  flags,
4248
4266
  env,
@@ -4440,7 +4458,7 @@ function playwrightRunner(dir, log) {
4440
4458
  return {
4441
4459
  now: () => Date.now(),
4442
4460
  async runBatch(plan, arm, repeat, batch) {
4443
- const planFile = path16.join(dir, `${arm.id}.plan.json`);
4461
+ const planFile = path17.join(dir, `${arm.id}.plan.json`);
4444
4462
  if (!fs11.existsSync(planFile)) {
4445
4463
  fs11.writeFileSync(
4446
4464
  planFile,
@@ -4453,7 +4471,7 @@ function playwrightRunner(dir, log) {
4453
4471
  })
4454
4472
  );
4455
4473
  }
4456
- const resultsFile = path16.join(dir, `${arm.id}.${batch}.jsonl`);
4474
+ const resultsFile = path17.join(dir, `${arm.id}.${batch}.jsonl`);
4457
4475
  fs11.writeFileSync(resultsFile, "");
4458
4476
  const env = {
4459
4477
  ...process.env,
@@ -4732,8 +4750,8 @@ async function runFlake(argv, runner) {
4732
4750
  if (!args.json) {
4733
4751
  printHeader(plan, commit, estimate, args.budgetMs);
4734
4752
  }
4735
- const dir = fs11.mkdtempSync(path16.join(os2.tmpdir(), "piwi-flake-"));
4736
- const log = path16.join(dir, "playwright.log");
4753
+ const dir = fs11.mkdtempSync(path17.join(os2.tmpdir(), "piwi-flake-"));
4754
+ const log = path17.join(dir, "playwright.log");
4737
4755
  let session;
4738
4756
  try {
4739
4757
  session = await runSession(runner ?? playwrightRunner(dir, log), plan, args, (result) => {
@@ -4924,9 +4942,9 @@ function readCount(argv, name) {
4924
4942
  if (!Number.isFinite(n) || n < 0) throw new Error(`${name} expects a non-negative number, got "${raw}"`);
4925
4943
  return Math.floor(n);
4926
4944
  }
4927
- function readRunIdFromFile(path24) {
4945
+ function readRunIdFromFile(path25) {
4928
4946
  try {
4929
- const parsed = JSON.parse(fs12.readFileSync(path24, "utf-8"));
4947
+ const parsed = JSON.parse(fs12.readFileSync(path25, "utf-8"));
4930
4948
  const runId = Number(parsed.runId);
4931
4949
  return Number.isFinite(runId) && runId > 0 ? runId : null;
4932
4950
  } catch {
@@ -5018,7 +5036,7 @@ async function runGate(argv, env = process.env) {
5018
5036
 
5019
5037
  // src/cli/init.ts
5020
5038
  var fs15 = __toESM(require("fs"));
5021
- var path19 = __toESM(require("path"));
5039
+ var path20 = __toESM(require("path"));
5022
5040
 
5023
5041
  // src/cli/report.ts
5024
5042
  var STATUS_MARK = {
@@ -5036,7 +5054,7 @@ function formatStep(result) {
5036
5054
 
5037
5055
  // src/cli/detect.ts
5038
5056
  var fs13 = __toESM(require("fs"));
5039
- var path17 = __toESM(require("path"));
5057
+ var path18 = __toESM(require("path"));
5040
5058
  var CONFIG_NAMES = [
5041
5059
  "playwright.config.ts",
5042
5060
  "playwright.config.mts",
@@ -5054,7 +5072,7 @@ var LOCKFILES = [
5054
5072
  ];
5055
5073
  var REPORTER_PACKAGE = "@piwitests/reporter";
5056
5074
  function readPackageJson(root) {
5057
- const pkgPath = path17.join(root, "package.json");
5075
+ const pkgPath = path18.join(root, "package.json");
5058
5076
  try {
5059
5077
  return { pkg: JSON.parse(fs13.readFileSync(pkgPath, "utf-8")), pkgPath };
5060
5078
  } catch {
@@ -5065,13 +5083,13 @@ function detectPackageManager(root, pkg) {
5065
5083
  const declared = pkg?.packageManager?.split("@")[0];
5066
5084
  if (declared === "pnpm" || declared === "yarn" || declared === "bun" || declared === "npm") return declared;
5067
5085
  for (const [file, manager] of LOCKFILES) {
5068
- if (fs13.existsSync(path17.join(root, file))) return manager;
5086
+ if (fs13.existsSync(path18.join(root, file))) return manager;
5069
5087
  }
5070
5088
  return "npm";
5071
5089
  }
5072
5090
  function findConfig(root) {
5073
5091
  for (const name of CONFIG_NAMES) {
5074
- const candidate = path17.join(root, name);
5092
+ const candidate = path18.join(root, name);
5075
5093
  if (fs13.existsSync(candidate)) return candidate;
5076
5094
  }
5077
5095
  return null;
@@ -5082,14 +5100,14 @@ function langOf(configPath) {
5082
5100
  }
5083
5101
  function suggestProjectName(root, pkg) {
5084
5102
  const fromPkg = pkg?.name?.replace(/^@[^/]+\//, "").trim();
5085
- return fromPkg || path17.basename(root) || "default-project";
5103
+ return fromPkg || path18.basename(root) || "default-project";
5086
5104
  }
5087
5105
  function hasReporter(pkg) {
5088
5106
  if (!pkg) return false;
5089
5107
  return Boolean(pkg.dependencies?.[REPORTER_PACKAGE] || pkg.devDependencies?.[REPORTER_PACKAGE]);
5090
5108
  }
5091
5109
  function detectProject(root) {
5092
- const absRoot = path17.resolve(root);
5110
+ const absRoot = path18.resolve(root);
5093
5111
  const { pkg, pkgPath } = readPackageJson(absRoot);
5094
5112
  const configPath = findConfig(absRoot);
5095
5113
  return {
@@ -5118,7 +5136,10 @@ function installCommand(manager) {
5118
5136
 
5119
5137
  // src/cli/configure.ts
5120
5138
  var REPORTER_IMPORT = "import { wrapConfig } from '@piwitests/reporter'";
5121
- var MANUAL_HINT = "Could not find `export default defineConfig(...)`. Wrap your exported config by hand: import { wrapConfig } from '@piwitests/reporter', then export default wrapConfig(defineConfig({ ... }), { serverUrl, projectName }).";
5139
+ function manualHint(opts) {
5140
+ const options = optionsLiteral(opts).replace(/\s+/g, " ");
5141
+ return `Could not find \`export default defineConfig(...)\`. Wrap your exported config by hand: import { wrapConfig } from '@piwitests/reporter', then export default wrapConfig(defineConfig({ ... }), ${options}).`;
5142
+ }
5122
5143
  var quote2 = quoteJsString;
5123
5144
  function matchClosingParen(src, openIndex) {
5124
5145
  let depth = 0;
@@ -5157,6 +5178,7 @@ function optionsLiteral(opts) {
5157
5178
  const lines = [];
5158
5179
  if (opts.serverUrl) lines.push(` serverUrl: ${quote2(opts.serverUrl)},`);
5159
5180
  lines.push(` projectName: ${quote2(opts.projectName)},`);
5181
+ if (opts.desktopApp) lines.push(` desktopApp: ${quote2(opts.desktopApp)},`);
5160
5182
  return `{
5161
5183
  ${lines.join("\n")}
5162
5184
  }`;
@@ -5180,11 +5202,11 @@ function wrapPlaywrightConfig(source, opts) {
5180
5202
  return { text: source, status: "already", detail: "Piwi reporter is already wired into the config" };
5181
5203
  }
5182
5204
  const match = /export\s+default\s+defineConfig\s*\(/.exec(source);
5183
- if (!match) return { text: source, status: "manual", detail: MANUAL_HINT };
5205
+ if (!match) return { text: source, status: "manual", detail: manualHint(opts) };
5184
5206
  const openParen = match.index + match[0].length - 1;
5185
5207
  const closeParen = matchClosingParen(source, openParen);
5186
5208
  const callStart = source.indexOf("defineConfig", match.index);
5187
- if (closeParen === -1 || callStart === -1) return { text: source, status: "manual", detail: MANUAL_HINT };
5209
+ if (closeParen === -1 || callStart === -1) return { text: source, status: "manual", detail: manualHint(opts) };
5188
5210
  const callExpr = source.slice(callStart, closeParen + 1);
5189
5211
  let before = withReporterImport(source.slice(0, match.index));
5190
5212
  const after = source.slice(closeParen + 1);
@@ -5230,7 +5252,7 @@ function ensureGitignoreEntry(existing, entry = ".env") {
5230
5252
 
5231
5253
  // src/cli/skills.ts
5232
5254
  var fs14 = __toESM(require("fs"));
5233
- var path18 = __toESM(require("path"));
5255
+ var path19 = __toESM(require("path"));
5234
5256
 
5235
5257
  // ../core/src/skill-stamp.ts
5236
5258
  var SKILL_VERSION_KEY = "piwi-version";
@@ -5293,16 +5315,16 @@ var WORKFLOW_SKILLS = [
5293
5315
  "fix-a-reported-bug"
5294
5316
  ];
5295
5317
  var ALL_SKILLS = [SETUP_SKILL, ...WORKFLOW_SKILLS];
5296
- var DEFAULT_SKILLS_DIR = path18.join(".claude", "skills");
5318
+ var DEFAULT_SKILLS_DIR = path19.join(".claude", "skills");
5297
5319
  function findTemplatesDir(fromDir) {
5298
5320
  let dir = fromDir;
5299
5321
  for (let i = 0; i < 6; i++) {
5300
- if (fs14.existsSync(path18.join(dir, "templates", "skills"))) return path18.join(dir, "templates");
5301
- const parent = path18.dirname(dir);
5322
+ if (fs14.existsSync(path19.join(dir, "templates", "skills"))) return path19.join(dir, "templates");
5323
+ const parent = path19.dirname(dir);
5302
5324
  if (parent === dir) break;
5303
5325
  dir = parent;
5304
5326
  }
5305
- return path18.resolve(fromDir, "..", "..", "templates");
5327
+ return path19.resolve(fromDir, "..", "..", "templates");
5306
5328
  }
5307
5329
  function readFrontMatter(markdown) {
5308
5330
  const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(markdown);
@@ -5316,14 +5338,14 @@ function readFrontMatter(markdown) {
5316
5338
  }
5317
5339
  function readTemplatesVersion(templatesDir) {
5318
5340
  try {
5319
- const pkg = JSON.parse(fs14.readFileSync(path18.join(templatesDir, "..", "package.json"), "utf-8"));
5341
+ const pkg = JSON.parse(fs14.readFileSync(path19.join(templatesDir, "..", "package.json"), "utf-8"));
5320
5342
  return typeof pkg.version === "string" ? pkg.version : "0.0.0";
5321
5343
  } catch {
5322
5344
  return "0.0.0";
5323
5345
  }
5324
5346
  }
5325
5347
  function templatePath(templatesDir, slug) {
5326
- return path18.join(templatesDir, "skills", slug, "SKILL.md");
5348
+ return path19.join(templatesDir, "skills", slug, "SKILL.md");
5327
5349
  }
5328
5350
  function listSkills(templatesDir, slugs = ALL_SKILLS) {
5329
5351
  const infos = [];
@@ -5340,8 +5362,8 @@ function installSkills(opts) {
5340
5362
  for (const slug of opts.slugs) {
5341
5363
  const step = `skill:${slug}`;
5342
5364
  const source = templatePath(opts.templatesDir, slug);
5343
- const relDest = path18.join(opts.skillsDir, slug, "SKILL.md");
5344
- const dest = path18.join(opts.root, relDest);
5365
+ const relDest = path19.join(opts.skillsDir, slug, "SKILL.md");
5366
+ const dest = path19.join(opts.root, relDest);
5345
5367
  if (!fs14.existsSync(source)) {
5346
5368
  results.push({ step, status: "error", detail: `no template found for "${slug}"` });
5347
5369
  continue;
@@ -5373,7 +5395,7 @@ function installSkills(opts) {
5373
5395
  }
5374
5396
  }
5375
5397
  if (!opts.dryRun) {
5376
- fs14.mkdirSync(path18.dirname(dest), { recursive: true });
5398
+ fs14.mkdirSync(path19.dirname(dest), { recursive: true });
5377
5399
  fs14.writeFileSync(dest, contents);
5378
5400
  }
5379
5401
  results.push({ step, file: relDest, status: exists ? "updated" : "created", detail });
@@ -5446,7 +5468,7 @@ function runSkills(argv, templatesDir, cwd = process.cwd()) {
5446
5468
  }
5447
5469
  const results = installSkills({
5448
5470
  templatesDir,
5449
- root: path18.resolve(readOption5(rest, "--cwd") ?? cwd),
5471
+ root: path19.resolve(readOption5(rest, "--cwd") ?? cwd),
5450
5472
  skillsDir: readOption5(rest, "--dir") ?? DEFAULT_SKILLS_DIR,
5451
5473
  slugs: requested.length ? requested : ALL_SKILLS,
5452
5474
  force: rest.includes("--force"),
@@ -5468,6 +5490,7 @@ function runSkills(argv, templatesDir, cwd = process.cwd()) {
5468
5490
 
5469
5491
  // src/cli/init.ts
5470
5492
  var DEFAULT_SERVER_URL = "http://localhost:3000";
5493
+ var DESKTOP_APP_MODES = ["prefer", "fallback", "off"];
5471
5494
  var USAGE8 = `
5472
5495
  piwi init \u2014 wire a Playwright project up to a Piwi Dashboard
5473
5496
 
@@ -5484,9 +5507,14 @@ What it does (each step is idempotent and safe to re-run):
5484
5507
  Options:
5485
5508
  --server-url <url> Dashboard URL to write into the config (env PIWI_DASHBOARD_URL,
5486
5509
  default ${DEFAULT_SERVER_URL})
5510
+ --no-server-url Write no URL into the config: the reporter finds the Piwi desktop
5511
+ app running on this machine, or reads PIWI_DASHBOARD_URL
5487
5512
  --project <name> Project name to report under (default: your package/folder name)
5488
- --api-key <key> API key to write into .env (env PIWI_API_KEY). Omit to keep it a
5489
- .env.example placeholder you fill in yourself.
5513
+ --desktop-app <mode> Write desktopApp into the config: "prefer" sends local runs to the
5514
+ Piwi desktop app while it runs, and CI runs to --server-url
5515
+ --api-key <key> API key to write into .env (env PIWI_API_KEY), where the reporter
5516
+ reads it. Omit to keep it a .env.example placeholder you fill in
5517
+ yourself.
5490
5518
  --cwd <path> Project root to operate on (default: current directory)
5491
5519
  --skills <list> Comma-separated skills to install, or "all" / "none"
5492
5520
  (default: ${WORKFLOW_SKILLS.join(", ")})
@@ -5517,13 +5545,15 @@ function resolveSkillSlugs(raw) {
5517
5545
  return raw.split(",").map((slug) => slug.trim()).filter(Boolean);
5518
5546
  }
5519
5547
  function parseInitArgs(argv, env, cwd) {
5520
- const root = path19.resolve(readOption6(argv, "--cwd") ?? cwd);
5548
+ const root = path20.resolve(readOption6(argv, "--cwd") ?? cwd);
5521
5549
  const detected = detectProject(root);
5522
5550
  return {
5523
5551
  root,
5524
- serverUrl: (readOption6(argv, "--server-url") ?? env.PIWI_DASHBOARD_URL ?? DEFAULT_SERVER_URL).replace(/\/$/, ""),
5552
+ serverUrl: argv.includes("--no-server-url") ? "" : (readOption6(argv, "--server-url") ?? env.PIWI_DASHBOARD_URL ?? DEFAULT_SERVER_URL).replace(/\/$/, ""),
5525
5553
  projectName: readOption6(argv, "--project") ?? detected.suggestedProjectName,
5526
- apiKey: readOption6(argv, "--api-key") ?? env.PIWI_API_KEY ?? null,
5554
+ desktopApp: argv.includes("--desktop-app") ? readOption6(argv, "--desktop-app") ?? "" : null,
5555
+ // The desktop app needs no key, and a key with no URL keeps the reporter from finding it.
5556
+ apiKey: argv.includes("--no-server-url") ? null : readOption6(argv, "--api-key") ?? env.PIWI_API_KEY ?? null,
5527
5557
  skillsDir: readOption6(argv, "--skills-dir") ?? DEFAULT_SKILLS_DIR,
5528
5558
  skillSlugs: resolveSkillSlugs(readOption6(argv, "--skills")),
5529
5559
  install: !argv.includes("--no-install"),
@@ -5582,18 +5612,29 @@ function stepConfig(project, opts) {
5582
5612
  status: "manual",
5583
5613
  detail: "No playwright.config found \u2014 create one, then re-run `npx @piwitests/reporter init`"
5584
5614
  };
5585
- const rel = path19.relative(project.root, project.configPath) || path19.basename(project.configPath);
5586
- const edit = wrapPlaywrightConfig(readFileOr(project.configPath, ""), {
5615
+ const rel = path20.relative(project.root, project.configPath) || path20.basename(project.configPath);
5616
+ const source = readFileOr(project.configPath, "");
5617
+ const desktopApp = opts.desktopApp ?? void 0;
5618
+ const edit = wrapPlaywrightConfig(source, {
5587
5619
  serverUrl: opts.serverUrl,
5588
- projectName: opts.projectName
5620
+ projectName: opts.projectName,
5621
+ desktopApp
5589
5622
  });
5590
5623
  if (edit.status === "updated" && !opts.dryRun) fs15.writeFileSync(project.configPath, edit.text);
5624
+ if (edit.status === "already" && desktopApp && !new RegExp(`\\bdesktopApp\\s*:\\s*['"\`]${desktopApp}['"\`]`).test(source)) {
5625
+ return {
5626
+ step,
5627
+ file: rel,
5628
+ status: "manual",
5629
+ detail: `The Piwi reporter is already wired in \u2014 add \`desktopApp: '${desktopApp}'\` to its options`
5630
+ };
5631
+ }
5591
5632
  return { step, file: rel, status: edit.status, detail: edit.detail };
5592
5633
  }
5593
5634
  function stepFixtures(project, opts) {
5594
5635
  const step = "fixtures";
5595
- const rel = path19.join("tests", project.configLang === "js" ? "fixtures.js" : "fixtures.ts");
5596
- const dest = path19.join(project.root, rel);
5636
+ const rel = path20.join("tests", project.configLang === "js" ? "fixtures.js" : "fixtures.ts");
5637
+ const dest = path20.join(project.root, rel);
5597
5638
  if (fs15.existsSync(dest)) {
5598
5639
  const current = readFileOr(dest, "");
5599
5640
  if (/piwiFixtures/.test(current))
@@ -5606,7 +5647,7 @@ function stepFixtures(project, opts) {
5606
5647
  };
5607
5648
  }
5608
5649
  if (!opts.dryRun) {
5609
- fs15.mkdirSync(path19.dirname(dest), { recursive: true });
5650
+ fs15.mkdirSync(path20.dirname(dest), { recursive: true });
5610
5651
  fs15.writeFileSync(dest, fixturesContents());
5611
5652
  }
5612
5653
  return {
@@ -5618,7 +5659,7 @@ function stepFixtures(project, opts) {
5618
5659
  }
5619
5660
  function stepEnv(project, opts) {
5620
5661
  const results = [];
5621
- const examplePath = path19.join(project.root, ".env.example");
5662
+ const examplePath = path20.join(project.root, ".env.example");
5622
5663
  const example = upsertEnvKeys(readFileOr(examplePath, ""), [
5623
5664
  ["PIWI_DASHBOARD_URL", opts.serverUrl],
5624
5665
  ["PIWI_API_KEY", ""]
@@ -5631,11 +5672,10 @@ function stepEnv(project, opts) {
5631
5672
  detail: example.added.length ? `Recorded ${example.added.join(", ")}` : "connection template already present"
5632
5673
  });
5633
5674
  if (opts.apiKey) {
5634
- const envPath = path19.join(project.root, ".env");
5635
- const env = upsertEnvKeys(readFileOr(envPath, ""), [
5636
- ["PIWI_DASHBOARD_URL", opts.serverUrl],
5637
- ["PIWI_API_KEY", opts.apiKey]
5638
- ]);
5675
+ const envPath = path20.join(project.root, ".env");
5676
+ const entries = [["PIWI_API_KEY", opts.apiKey]];
5677
+ if (opts.serverUrl) entries.unshift(["PIWI_DASHBOARD_URL", opts.serverUrl]);
5678
+ const env = upsertEnvKeys(readFileOr(envPath, ""), entries);
5639
5679
  if (env.added.length && !opts.dryRun) fs15.writeFileSync(envPath, env.text);
5640
5680
  results.push({
5641
5681
  step: "env",
@@ -5643,7 +5683,7 @@ function stepEnv(project, opts) {
5643
5683
  status: env.added.length ? "updated" : "already",
5644
5684
  detail: env.added.length ? `Wrote ${env.added.join(", ")} (keep .env out of git)` : "already set"
5645
5685
  });
5646
- const gitignorePath = path19.join(project.root, ".gitignore");
5686
+ const gitignorePath = path20.join(project.root, ".gitignore");
5647
5687
  const gitignore = ensureGitignoreEntry(readFileOr(gitignorePath, ""));
5648
5688
  if (gitignore.added && !opts.dryRun) fs15.writeFileSync(gitignorePath, gitignore.text);
5649
5689
  results.push({
@@ -5657,16 +5697,26 @@ function stepEnv(project, opts) {
5657
5697
  }
5658
5698
  function nextSteps(opts, steps) {
5659
5699
  const out = [];
5660
- if (!opts.apiKey) {
5700
+ if (!opts.apiKey && opts.serverUrl) {
5661
5701
  out.push(
5662
- "If your dashboard has authentication on, create an API key (Settings \u2192 Account \u2192 API keys), put it in .env as PIWI_API_KEY, and keep .env out of git."
5702
+ "If your dashboard has authentication on, create an API key (Settings \u2192 Account \u2192 API keys) and put it in .env as PIWI_API_KEY: the reporter reads it from the .env next to your Playwright config. Keep .env out of git; in CI, set PIWI_API_KEY as a secret."
5663
5703
  );
5664
5704
  }
5665
5705
  if (steps.some((s) => s.status === "manual")) {
5666
5706
  out.push("Finish the steps marked [manual] above \u2014 each one carries the exact change to make.");
5667
5707
  }
5668
5708
  out.push("In your specs, import `test` (and `expect`) from your fixtures file instead of `@playwright/test`.");
5669
- out.push(`Run \`npx playwright test\` \u2014 the run should appear at ${opts.serverUrl}.`);
5709
+ if (!opts.serverUrl) {
5710
+ out.push(
5711
+ "Run `npx playwright test` \u2014 the run appears in the Piwi desktop app while it is running. Elsewhere, set PIWI_DASHBOARD_URL to the dashboard the runs go to."
5712
+ );
5713
+ } else if (opts.desktopApp === "prefer") {
5714
+ out.push(
5715
+ `Run \`npx playwright test\` \u2014 the run appears in the Piwi desktop app while it is running, and at ${opts.serverUrl} on CI or with the app closed.`
5716
+ );
5717
+ } else {
5718
+ out.push(`Run \`npx playwright test\` \u2014 the run should appear at ${opts.serverUrl}.`);
5719
+ }
5670
5720
  out.push(
5671
5721
  "For a machine-readable check, set PIWI_OUTPUT_FILE=piwi-run.json and read runUrl from that file after the run."
5672
5722
  );
@@ -5678,6 +5728,10 @@ async function runInit(argv, env = process.env, cwd = process.cwd()) {
5678
5728
  return 0;
5679
5729
  }
5680
5730
  const opts = parseInitArgs(argv, env, cwd);
5731
+ if (opts.desktopApp !== null && !DESKTOP_APP_MODES.includes(opts.desktopApp)) {
5732
+ console.error(`--desktop-app must be one of ${DESKTOP_APP_MODES.join(", ")} (got "${opts.desktopApp}")`);
5733
+ return 1;
5734
+ }
5681
5735
  const project = detectProject(opts.root);
5682
5736
  const steps = [];
5683
5737
  if (!opts.skillsOnly) {
@@ -5707,9 +5761,10 @@ async function runInit(argv, env = process.env, cwd = process.cwd()) {
5707
5761
  project: {
5708
5762
  root: project.root,
5709
5763
  packageManager: project.packageManager,
5710
- configPath: project.configPath ? path19.relative(project.root, project.configPath) : null,
5764
+ configPath: project.configPath ? path20.relative(project.root, project.configPath) : null,
5711
5765
  projectName: opts.projectName,
5712
- serverUrl: opts.serverUrl
5766
+ serverUrl: opts.serverUrl,
5767
+ desktopApp: opts.desktopApp
5713
5768
  },
5714
5769
  steps,
5715
5770
  nextSteps: guidance
@@ -5732,1197 +5787,1280 @@ Piwi setup${opts.dryRun ? " (dry run \u2014 nothing written)" : ""} for "${opts.
5732
5787
 
5733
5788
  // src/cli/select.ts
5734
5789
  var fs17 = __toESM(require("fs"));
5735
- var path21 = __toESM(require("path"));
5790
+ var path22 = __toESM(require("path"));
5736
5791
  var import_node_child_process7 = require("child_process");
5737
5792
 
5738
- // src/cli/add-reporter.ts
5739
- var fs16 = __toESM(require("fs"));
5740
- var path20 = __toESM(require("path"));
5741
- var import_node_module2 = require("module");
5742
- var REPORTER_PACKAGE2 = "@piwitests/reporter";
5743
- var ADD_REPORTER_FLAG = "--add-reporter";
5744
- var ADD_REPORTER_MIN = { major: 1, minor: 63 };
5745
- function playwrightSupportsAddReporter(version) {
5746
- const match = version ? /^(\d+)\.(\d+)/.exec(version) : null;
5747
- if (!match) return false;
5748
- const major = Number(match[1]);
5749
- const minor = Number(match[2]);
5750
- return major > ADD_REPORTER_MIN.major || major === ADD_REPORTER_MIN.major && minor >= ADD_REPORTER_MIN.minor;
5793
+ // ../core/src/diff-anchors.ts
5794
+ var RENAME_FROM = `rename from `;
5795
+ var RENAME_TO = "rename to ";
5796
+ var HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
5797
+ var INDEX_LINE = /^index ([0-9a-f]+)\.\.([0-9a-f]+)(?: |$)/i;
5798
+ function blobOf(id) {
5799
+ return /^0+$/.test(id) ? void 0 : id.toLowerCase();
5751
5800
  }
5752
- function configHasPiwiReporter(source) {
5753
- return new RegExp(REPORTER_PACKAGE2.replace(/[/\\]/g, "\\$&")).test(source);
5801
+ function stripPrefix(path25) {
5802
+ const unquoted = path25.startsWith('"') && path25.endsWith('"') ? path25.slice(1, -1) : path25;
5803
+ return unquoted.replace(/^[ab]\//, "");
5754
5804
  }
5755
- function decideAddReporter(configSource, playwrightVersion) {
5756
- if (configSource === null || configHasPiwiReporter(configSource)) return { args: [], log: null };
5757
- if (playwrightSupportsAddReporter(playwrightVersion)) {
5758
- return {
5759
- args: [ADD_REPORTER_FLAG, REPORTER_PACKAGE2],
5760
- log: `piwi run: the Playwright config has no Piwi reporter \u2014 appending ${ADD_REPORTER_FLAG} ${REPORTER_PACKAGE2}`
5761
- };
5762
- }
5763
- return {
5764
- args: [],
5765
- log: `piwi run: the Playwright config has no Piwi reporter and Playwright ${playwrightVersion ?? "unknown"} predates ${ADD_REPORTER_FLAG} (1.63) \u2014 results will not reach the dashboard`
5805
+ function parseUnifiedDiff(text) {
5806
+ const files = [];
5807
+ let file = null;
5808
+ let hunk = null;
5809
+ let oldLine = 0;
5810
+ let newLine = 0;
5811
+ let oldLeft = 0;
5812
+ let newLeft = 0;
5813
+ const current = () => {
5814
+ if (!file) {
5815
+ file = { path: "", status: "modified", hunks: [] };
5816
+ files.push(file);
5817
+ }
5818
+ return file;
5766
5819
  };
5767
- }
5768
- function resolveConfigPath(cwd, playwrightArgs2) {
5769
- for (let i = 0; i < playwrightArgs2.length; i++) {
5770
- const arg = playwrightArgs2[i];
5771
- if (arg === "--config" || arg === "-c") {
5772
- const next = playwrightArgs2[i + 1];
5773
- if (next) return path20.resolve(cwd, next);
5774
- } else if (arg.startsWith("--config=")) {
5775
- return path20.resolve(cwd, arg.slice("--config=".length));
5820
+ for (const line of text.split(/\r?\n/)) {
5821
+ if (hunk) {
5822
+ if (line.startsWith("-") && oldLeft > 0) {
5823
+ hunk.removed.push({ line: oldLine++, text: line.slice(1) });
5824
+ oldLeft--;
5825
+ } else if (line.startsWith("+") && newLeft > 0) {
5826
+ hunk.added.push({ line: newLine++, text: line.slice(1) });
5827
+ newLeft--;
5828
+ } else if ((line.startsWith(" ") || line === "") && oldLeft > 0 && newLeft > 0) {
5829
+ oldLine++;
5830
+ newLine++;
5831
+ oldLeft--;
5832
+ newLeft--;
5833
+ } else if (!line.startsWith("\\")) hunk = null;
5834
+ if (hunk && oldLeft === 0 && newLeft === 0) hunk = null;
5835
+ if (hunk || !line.startsWith("@@")) continue;
5776
5836
  }
5777
- }
5778
- return detectProject(cwd).configPath;
5779
- }
5780
- function readConfigSource(configPath) {
5781
- if (!configPath) return null;
5782
- try {
5783
- return fs16.readFileSync(configPath, "utf-8");
5784
- } catch {
5785
- return null;
5786
- }
5787
- }
5788
- function readInstalledPlaywrightVersion(cwd) {
5789
- try {
5790
- const require2 = (0, import_node_module2.createRequire)(path20.join(cwd, "noop.js"));
5791
- for (const id of ["@playwright/test/package.json", "playwright/package.json"]) {
5792
- try {
5793
- return require2(id).version ?? null;
5794
- } catch {
5795
- }
5837
+ const header = HUNK_HEADER.exec(line);
5838
+ if (header) {
5839
+ oldLine = Number(header[1]);
5840
+ newLine = Number(header[3]);
5841
+ oldLeft = header[2] === void 0 ? 1 : Number(header[2]);
5842
+ newLeft = header[4] === void 0 ? 1 : Number(header[4]);
5843
+ hunk = { oldStart: oldLine, newStart: newLine, removed: [], added: [] };
5844
+ current().hunks.push(hunk);
5845
+ if (oldLeft === 0 && newLeft === 0) hunk = null;
5846
+ continue;
5796
5847
  }
5797
- } catch {
5848
+ if (line.startsWith("diff --git ")) {
5849
+ const m = /^diff --git (\S+|"[^"]+") (\S+|"[^"]+")$/.exec(line);
5850
+ file = { path: m ? stripPrefix(m[2]) : "", status: "modified", hunks: [] };
5851
+ files.push(file);
5852
+ } else if (line.startsWith("--- ")) {
5853
+ const f = file && !file.hunks.length ? file : (file = null, current());
5854
+ const old = line.slice(4).trim();
5855
+ if (old === "/dev/null") f.status = "added";
5856
+ else if (!f.path) f.path = stripPrefix(old);
5857
+ } else if (line.startsWith("+++ ")) {
5858
+ const next = line.slice(4).trim();
5859
+ if (next === "/dev/null") current().status = "deleted";
5860
+ else current().path = stripPrefix(next);
5861
+ } else if (line.startsWith(RENAME_FROM)) {
5862
+ current().oldPath = line.slice(RENAME_FROM.length);
5863
+ current().status = "renamed";
5864
+ } else if (line.startsWith(RENAME_TO)) current().path = line.slice(RENAME_TO.length);
5865
+ else if (INDEX_LINE.test(line)) {
5866
+ const [, oldId, newId] = INDEX_LINE.exec(line);
5867
+ const f = current();
5868
+ const oldBlob = blobOf(oldId);
5869
+ const newBlob = blobOf(newId);
5870
+ if (oldBlob) f.oldBlob = oldBlob;
5871
+ if (newBlob) f.newBlob = newBlob;
5872
+ } else if (line.startsWith("new file mode")) current().status = "added";
5873
+ else if (line.startsWith("deleted file mode")) current().status = "deleted";
5798
5874
  }
5799
- return null;
5875
+ return files.filter((f) => f.path);
5800
5876
  }
5801
- function computeAddReporterArgs(cwd, playwrightArgs2) {
5802
- const source = readConfigSource(resolveConfigPath(cwd, playwrightArgs2));
5803
- return decideAddReporter(source, readInstalledPlaywrightVersion(cwd));
5877
+ var DEFAULT_TRANSLATION_GLOBS = ["**/{locales,locale,i18n,lang,translations}/**", "**/*.resx"];
5878
+ var ANCHOR_ATTRIBUTES = [
5879
+ "id",
5880
+ "name",
5881
+ "aria-label",
5882
+ "placeholder",
5883
+ "alt",
5884
+ "title",
5885
+ "role",
5886
+ "value",
5887
+ "label"
5888
+ ];
5889
+ var TRANSLATION_EXTENSIONS = /\.(json|ya?ml|properties|po|resx)$/i;
5890
+ var MARKUP_FILE = /\.(vue|svelte|html?|cshtml|razor|hbs|handlebars|jsx|tsx|astro|njk|liquid|erb|php)$/i;
5891
+ var MIN_LENGTH = 2;
5892
+ var MAX_LENGTH = 200;
5893
+ function expandBraces(glob) {
5894
+ const m = /\{([^{}]*)\}/.exec(glob);
5895
+ if (!m) return [glob];
5896
+ return m[1].split(",").flatMap((alt) => expandBraces(glob.slice(0, m.index) + alt + glob.slice(m.index + m[0].length)));
5804
5897
  }
5805
-
5806
- // src/cli/select.ts
5807
- var EXIT_OK6 = 0;
5808
- var EXIT_ERROR7 = 2;
5809
- var USAGE9 = `
5810
- piwi select / piwi run \u2014 run a saved selection of tests
5811
-
5812
- Usage:
5813
- npx @piwitests/reporter select <key> [options] print the Playwright args
5814
- npx @piwitests/reporter run <key> [options] [-- <playwright args>]
5815
- npx @piwitests/reporter run impact --base <ref> run the tests your diff impacts
5816
-
5817
- Connection:
5818
- --server-url <url> Dashboard URL (env PIWI_DASHBOARD_URL)
5819
- --api-key <key> API key (env PIWI_API_KEY)
5820
- --project <name|id> Project (env PIWI_PROJECT_NAME)
5821
-
5822
- Selection:
5823
- --format <fmt> args (file:line, default) | grep | files | json
5824
- --budget <duration> Cap total time, e.g. 5m, 90s, 300000 (ms)
5825
- --shard <i/n> Keep only shard i of n, balanced by duration, lock-aware
5826
- --fail-fast Order the least-reliable tests first (fail-fast)
5827
- --base <ref> For "impact": the ref to diff the working tree against
5828
-
5829
- Behavior:
5830
- --strict Fail (exit 2) instead of falling back when unreachable
5831
- --pkg-runner <cmd> Package runner for the printed command (default npx)
5832
- --json Print the full resolution as JSON (select only)
5833
- -h, --help Show this help
5834
-
5835
- Exit codes: 0 ok, 1 the test run failed (run only), 2 could not resolve.
5836
- `.trim();
5837
- var IMPACT_KEY = "impact";
5838
- var VALUE_FLAGS3 = /* @__PURE__ */ new Set([
5839
- "--server-url",
5840
- "--api-key",
5841
- "--project",
5842
- "--format",
5843
- "--budget",
5844
- "--shard",
5845
- "--base",
5846
- "--pkg-runner"
5847
- ]);
5848
- function readOption7(argv, name) {
5849
- const withEquals = argv.find((arg) => arg.startsWith(`${name}=`));
5850
- if (withEquals) return withEquals.slice(name.length + 1);
5851
- const index = argv.indexOf(name);
5852
- if (index === -1) return void 0;
5853
- const value = argv[index + 1];
5854
- return value && !value.startsWith("-") ? value : void 0;
5898
+ function pathGlobToRegExp(glob) {
5899
+ const parts = expandBraces(glob).map((g) => {
5900
+ let re = "";
5901
+ for (let i = 0; i < g.length; i++) {
5902
+ const c = g[i];
5903
+ if (c === "*") {
5904
+ if (g[i + 1] === "*") {
5905
+ i++;
5906
+ if (g[i + 1] === "/") {
5907
+ i++;
5908
+ re += "(?:.*/)?";
5909
+ } else re += ".*";
5910
+ } else re += "[^/]*";
5911
+ } else if (c === "?") re += "[^/]";
5912
+ else if ("\\^$.|+()[]{}".includes(c)) re += "\\" + c;
5913
+ else re += c;
5914
+ }
5915
+ return re;
5916
+ });
5917
+ return new RegExp(`^(?:${parts.join("|")})$`);
5855
5918
  }
5856
- function findKey(argv) {
5857
- for (let i = 0; i < argv.length; i++) {
5858
- const tok = argv[i];
5859
- if (tok.startsWith("-")) {
5860
- if (!tok.includes("=") && VALUE_FLAGS3.has(tok)) i++;
5919
+ function isTranslationFile(path25, globs = DEFAULT_TRANSLATION_GLOBS) {
5920
+ const posix = path25.replace(/\\/g, "/");
5921
+ if (!TRANSLATION_EXTENSIONS.test(posix)) return false;
5922
+ return globs.some((g) => pathGlobToRegExp(g).test(posix));
5923
+ }
5924
+ function keepable(value) {
5925
+ const v = value.trim();
5926
+ if (v.length < MIN_LENGTH || v.length > MAX_LENGTH) return false;
5927
+ if (/^[-+]?[\d.,]+$/.test(v)) return false;
5928
+ return new RegExp("\\p{L}", "u").test(v);
5929
+ }
5930
+ function readQuoted(text, start) {
5931
+ const quote3 = text[start];
5932
+ let value = "";
5933
+ for (let i = start + 1; i < text.length; i++) {
5934
+ const ch = text[i];
5935
+ if (ch === "\\" && i + 1 < text.length) {
5936
+ const next = text[i + 1];
5937
+ value += next === "n" ? "\n" : next === "t" ? " " : next;
5938
+ i++;
5861
5939
  continue;
5862
5940
  }
5863
- return tok;
5941
+ if (quote3 === "`" && ch === "$" && text[i + 1] === "{") return null;
5942
+ if (ch === quote3) return { value, end: i + 1 };
5943
+ value += ch;
5864
5944
  }
5865
- return void 0;
5945
+ return null;
5866
5946
  }
5867
- function parseDuration2(raw) {
5868
- const match = raw.trim().match(/^(\d+(?:\.\d+)?)(ms|s|m|h)?$/);
5869
- if (!match) return null;
5870
- const value = Number(match[1]);
5871
- const unit = match[2];
5872
- const factor = unit === "h" ? 36e5 : unit === "m" ? 6e4 : unit === "s" ? 1e3 : 1;
5873
- const ms = Math.round(value * factor);
5874
- return ms > 0 ? ms : null;
5947
+ function escapeRegExp(s) {
5948
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5875
5949
  }
5876
- function parseSelectArgs(argv, env) {
5877
- const dashIndex = argv.indexOf("--");
5878
- const own = dashIndex === -1 ? argv : argv.slice(0, dashIndex);
5879
- const extra = dashIndex === -1 ? [] : argv.slice(dashIndex + 1);
5880
- const serverUrl = (readOption7(own, "--server-url") ?? env.PIWI_DASHBOARD_URL ?? "").replace(/\/$/, "");
5881
- if (!serverUrl) throw new Error("No dashboard URL \u2014 pass --server-url or set PIWI_DASHBOARD_URL");
5882
- const key = findKey(own);
5883
- if (!key) throw new Error("No selection key \u2014 usage: piwi select <key>");
5884
- const budgetRaw = readOption7(own, "--budget");
5885
- let budgetMs = null;
5886
- if (budgetRaw !== void 0) {
5887
- budgetMs = parseDuration2(budgetRaw);
5888
- if (budgetMs === null) throw new Error(`--budget expects a duration like 5m or 90s, got "${budgetRaw}"`);
5889
- }
5890
- const shard = readOption7(own, "--shard") ?? null;
5891
- if (shard !== null && !/^\d+\s*\/\s*\d+$/.test(shard)) {
5892
- throw new Error(`--shard expects an "i/n" spec like 2/4, got "${shard}"`);
5950
+ function attributeTokens(text, attributes, line, spans) {
5951
+ const tokens = [];
5952
+ const names = attributes.map(escapeRegExp).join("|");
5953
+ const re = new RegExp(
5954
+ `(?<![\\w-])(?:v-bind:|:|\\[(?:attr\\.)?)?(["']?)(${names})\\1\\]?\\s*(=|:)\\s*(\\{\\s*)?(["'\`])`,
5955
+ "g"
5956
+ );
5957
+ let m;
5958
+ while ((m = re.exec(text)) !== null) {
5959
+ const bound = m[0].startsWith(":") || m[0].startsWith("v-bind:") || m[0].startsWith("[");
5960
+ const quoteAt = m.index + m[0].length - 1;
5961
+ const outer = readQuoted(text, quoteAt);
5962
+ if (!outer) continue;
5963
+ let value = outer.value;
5964
+ if (bound) {
5965
+ const inner = /^\s*(['"`])(.*)\1\s*$/.exec(value);
5966
+ if (!inner) continue;
5967
+ value = inner[2];
5968
+ }
5969
+ spans.push({ start: m.index, end: outer.end });
5970
+ re.lastIndex = outer.end;
5971
+ if (!keepable(value) || value.includes("${")) continue;
5972
+ const attribute = m[2];
5973
+ tokens.push({ kind: "attribute", group: `attribute:${attribute}`, value, attribute, line });
5893
5974
  }
5894
- return {
5895
- serverUrl,
5896
- apiKey: readOption7(own, "--api-key") ?? env.PIWI_API_KEY ?? null,
5897
- project: readOption7(own, "--project") ?? env.PIWI_PROJECT_NAME ?? "",
5898
- key,
5899
- format: readOption7(own, "--format") ?? "args",
5900
- budgetMs,
5901
- shard,
5902
- order: own.includes("--fail-fast") ? "failureLikelihood" : null,
5903
- base: readOption7(own, "--base") ?? null,
5904
- strict: own.includes("--strict"),
5905
- pkgRunner: readOption7(own, "--pkg-runner") ?? "npx",
5906
- json: own.includes("--json"),
5907
- extra
5908
- };
5909
- }
5910
- var CACHE_FILE = path21.join(".piwi", "selection-cache.json");
5911
- function cacheKey(args) {
5912
- return `${args.project.toLowerCase()}:${args.key}:${args.format}:${args.budgetMs ?? 0}:${args.shard ?? ""}:${args.order ?? ""}`;
5975
+ return tokens;
5913
5976
  }
5914
- function readCache(args) {
5915
- try {
5916
- const store = JSON.parse(fs17.readFileSync(CACHE_FILE, "utf-8"));
5917
- return store[cacheKey(args)] ?? null;
5918
- } catch {
5919
- return null;
5977
+ var TRANSLATION_CALL = /(?:(?<![\w$.])(?:\$?t|i18n\.t|translate|formatMessage)\s*\(\s*(?:\{\s*id\s*:\s*)?(['"`])([\w.:-]+)\1|\b\w*[Ll]ocalizer\s*\[\s*"([\w.:-]+)"\s*\])/g;
5978
+ function translationRefTokens(text, line, side, lookup, spans) {
5979
+ const tokens = [];
5980
+ for (const m of text.matchAll(TRANSLATION_CALL)) {
5981
+ spans.push({ start: m.index, end: m.index + m[0].length });
5982
+ const key = m[2] ?? m[3];
5983
+ const value = lookup?.(key, side);
5984
+ if (value === void 0 || !keepable(value)) continue;
5985
+ tokens.push({ kind: "translation", group: "translation-ref", value, key, line });
5920
5986
  }
5987
+ return tokens;
5921
5988
  }
5922
- function writeCache(args, resolution) {
5923
- try {
5924
- let store = {};
5925
- try {
5926
- store = JSON.parse(fs17.readFileSync(CACHE_FILE, "utf-8"));
5927
- } catch {
5989
+ function inSpans(spans, at) {
5990
+ return spans.some((s) => at >= s.start && at < s.end);
5991
+ }
5992
+ var NON_TEXT_LITERAL = /^(?:\.{0,2}\/|[a-z][\w+.-]*:\/\/|#[\w-]+$|@\/|~\/)|\.(?:m?[jt]sx?|vue|svelte|css|scss|json|png|svg|jpe?g)$/i;
5993
+ function literalTokens(text, line, spans, markup) {
5994
+ const tokens = [];
5995
+ if (/^\s*(?:import\b|export\s+\*|\/\/|\*|\/\*|#include|using\s)/.test(text)) return tokens;
5996
+ for (let i = 0; i < text.length; i++) {
5997
+ const ch = text[i];
5998
+ if (ch !== '"' && ch !== "'" && ch !== "`") continue;
5999
+ const lit = readQuoted(text, i);
6000
+ if (!lit) {
6001
+ if (ch === "'") continue;
6002
+ break;
5928
6003
  }
5929
- store[cacheKey(args)] = resolution;
5930
- fs17.mkdirSync(path21.dirname(CACHE_FILE), { recursive: true });
5931
- fs17.writeFileSync(CACHE_FILE, JSON.stringify(store, null, 2));
5932
- } catch {
6004
+ const start = i;
6005
+ i = lit.end - 1;
6006
+ if (inSpans(spans, start)) continue;
6007
+ const before = text.slice(0, start);
6008
+ if (markup && /[\w:@.[\]-]=\s*$/.test(before)) continue;
6009
+ if (/\b(?:from|require|import)\s*\(?\s*$/.test(before)) continue;
6010
+ const value = lit.value;
6011
+ if (!keepable(value) || NON_TEXT_LITERAL.test(value)) continue;
6012
+ if (/^[\w-]+(?:\.[\w-]+)+$/.test(value) && !/\s/.test(value)) continue;
6013
+ tokens.push({ kind: "literal", group: "literal", value, line });
5933
6014
  }
6015
+ return tokens;
5934
6016
  }
5935
- async function resolveWithCache(args) {
5936
- try {
5937
- const projectId = await resolveProjectId(args);
5938
- const resolution = await fetchResolution(args, projectId);
5939
- writeCache(args, resolution);
5940
- return { resolution, fromCache: false };
5941
- } catch (e) {
5942
- if (args.strict) throw e;
5943
- const cached = readCache(args);
5944
- if (cached) {
5945
- console.error(`piwi: dashboard unreachable, using cached resolution \u2014 ${e.message}`);
5946
- return { resolution: cached, fromCache: true };
5947
- }
5948
- return { error: e };
5949
- }
5950
- }
5951
- function printWarnings(resolution) {
5952
- for (const w of resolution.warnings) console.error(`piwi: warning [${w.code}] ${w.message}`);
5953
- }
5954
- function gitChangedFiles(base) {
5955
- const out = (0, import_node_child_process7.execFileSync)("git", ["diff", "--name-only", base], {
5956
- encoding: "utf-8",
5957
- stdio: ["ignore", "pipe", "ignore"]
5958
- });
5959
- return [
5960
- ...new Set(
5961
- out.split("\n").map((line) => line.trim()).filter(Boolean)
5962
- )
5963
- ];
5964
- }
5965
- async function loadImpact(args, projectId) {
5966
- const files = gitChangedFiles(args.base);
5967
- return fetchImpact(args, projectId, files);
5968
- }
5969
- async function runSelectImpact(args) {
5970
- let impact;
5971
- try {
5972
- const projectId = await resolveProjectId(args);
5973
- impact = await loadImpact(args, projectId);
5974
- } catch (e) {
5975
- console.error(`piwi select: ${e.message}`);
5976
- return EXIT_ERROR7;
5977
- }
5978
- printWarnings(impact);
5979
- console.error(
5980
- `piwi select: ${impact.impact.changedFiles} changed file(s) \u2192 ${impact.estimate.count} impacted test(s)${impact.impact.widened ? " (widened to full suite)" : ""}`
5981
- );
5982
- if (args.json) {
5983
- console.log(JSON.stringify(impact, null, 2));
5984
- return EXIT_OK6;
5985
- }
5986
- console.log(impact.impact.widened ? "" : impact.materialization.args.join(" "));
5987
- return EXIT_OK6;
6017
+ var CODE_LINE = /^(?:import|export|const|let|var|function|return|if|else|for|while|switch|case|default|class|interface|type|await|async|new|throw|try|catch|public|private|protected|static|using|namespace|@\w+\s*\()\b/;
6018
+ function textSegment(raw, line) {
6019
+ const value = raw.replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim();
6020
+ if (!keepable(value)) return null;
6021
+ if (/[{}]|@[\w(]|<%|%>/.test(value)) return null;
6022
+ return { kind: "text", group: "text", value, line };
5988
6023
  }
5989
- async function runSelect(argv, env = process.env) {
5990
- if (argv.includes("-h") || argv.includes("--help")) {
5991
- console.log(USAGE9);
5992
- return EXIT_OK6;
5993
- }
5994
- let args;
5995
- try {
5996
- args = parseSelectArgs(argv, env);
5997
- } catch (e) {
5998
- console.error(`piwi select: ${e.message}
5999
- `);
6000
- console.error(USAGE9);
6001
- return EXIT_ERROR7;
6024
+ function textTokens(text, line, markup) {
6025
+ const tokens = [];
6026
+ if (!markup) return tokens;
6027
+ const trimmed = text.trim();
6028
+ if (!trimmed) return tokens;
6029
+ if (!/[<>]/.test(trimmed)) {
6030
+ if (CODE_LINE.test(trimmed) || /[;=(){}[\]"'`]|^\/\/|^\*|^\/\*|^<!--|[,:]$/.test(trimmed)) return tokens;
6031
+ const token = textSegment(trimmed, line);
6032
+ return token ? [token] : tokens;
6002
6033
  }
6003
- if (args.key === IMPACT_KEY) {
6004
- if (!args.base) {
6005
- console.error("piwi select: impact needs a base ref \u2014 pass --base <ref> (e.g. --base origin/main)");
6006
- return EXIT_ERROR7;
6007
- }
6008
- return runSelectImpact(args);
6034
+ for (const m of text.matchAll(/>([^<>]+)</g)) {
6035
+ const token = textSegment(m[1], line);
6036
+ if (token) tokens.push(token);
6009
6037
  }
6010
- let resolution;
6011
- try {
6012
- const projectId = await resolveProjectId(args);
6013
- resolution = await fetchResolution(args, projectId);
6014
- } catch (e) {
6015
- console.error(`piwi select: ${e.message}`);
6016
- return EXIT_ERROR7;
6038
+ const lead = /^\s*([^<>]+)<\//.exec(text);
6039
+ if (lead && !/[=;(){}]/.test(lead[1])) {
6040
+ const token = textSegment(lead[1], line);
6041
+ if (token) tokens.push(token);
6017
6042
  }
6018
- printWarnings(resolution);
6019
- if (resolution.estimate.count === 0) {
6020
- console.error(`piwi select: "${args.key}" resolved to 0 tests`);
6021
- return EXIT_ERROR7;
6043
+ const tail = />([^<>]+)$/.exec(text);
6044
+ if (tail && !/[=;(){}"']/.test(tail[1])) {
6045
+ const token = textSegment(tail[1], line);
6046
+ if (token) tokens.push(token);
6022
6047
  }
6023
- if (args.json) console.log(JSON.stringify(resolution, null, 2));
6024
- else console.log(resolution.materialization.args.join(" "));
6025
- return EXIT_OK6;
6048
+ return tokens;
6026
6049
  }
6027
- function spawnPlaywrightForRun(pkgRunner, playwrightArgs2, env) {
6028
- const decision = computeAddReporterArgs(process.cwd(), playwrightArgs2);
6029
- if (decision.log) console.error(decision.log);
6030
- return spawnPlaywright([...decision.args, ...playwrightArgs2], env, { runner: pkgRunner, command: "piwi run" });
6050
+ function unquoteYaml(raw) {
6051
+ const v = raw.trim();
6052
+ if (v.startsWith('"') && v.endsWith('"') || v.startsWith("'") && v.endsWith("'")) {
6053
+ return v.slice(1, -1).replace(/\\"/g, '"').replace(/''/g, "'");
6054
+ }
6055
+ return v.replace(/\s+#.*$/, "");
6031
6056
  }
6032
- function shardEnv(args) {
6033
- return args.shard ? { [PIWI_SHARD_ENV]: args.shard } : {};
6057
+ function decodeXml2(text) {
6058
+ return text.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
6034
6059
  }
6035
- async function runRunImpact(args, env) {
6036
- let projectId;
6037
- try {
6038
- projectId = await resolveProjectId(args);
6039
- } catch (e) {
6040
- if (args.strict) {
6041
- console.error(`piwi run: ${e.message}`);
6042
- return EXIT_ERROR7;
6043
- }
6044
- console.error(`piwi run: ${e.message} \u2014 running the full suite`);
6045
- return spawnPlaywrightForRun(args.pkgRunner, args.extra, env);
6046
- }
6047
- let impact;
6048
- try {
6049
- impact = await loadImpact(args, projectId);
6050
- } catch (e) {
6051
- if (args.strict) {
6052
- console.error(`piwi run: ${e.message}`);
6053
- return EXIT_ERROR7;
6054
- }
6055
- console.error(`piwi run: ${e.message} \u2014 running the full suite`);
6056
- return spawnPlaywrightForRun(args.pkgRunner, args.extra, env);
6057
- }
6058
- printWarnings(impact);
6059
- if (impact.impact.widened) {
6060
- console.error(
6061
- `piwi run: impact widened to the full suite (${impact.impact.unmappedSourceFiles.length} changed file(s) not fully mapped to tests)`
6062
- );
6063
- return spawnPlaywrightForRun(args.pkgRunner, args.extra, env);
6060
+ function parseTranslationFile(path25, content) {
6061
+ const lines = content.split(/\r?\n/);
6062
+ const out = [];
6063
+ const ext = /\.([a-z]+)$/i.exec(path25)?.[1]?.toLowerCase();
6064
+ if (ext === "json") {
6065
+ const stack = [];
6066
+ const path26 = (key) => [...stack.map((f) => f.key), key].join(".");
6067
+ lines.forEach((text, i) => {
6068
+ const t = text.trim();
6069
+ const top = stack[stack.length - 1];
6070
+ const pair = /^"((?:[^"\\]|\\.)*)"\s*:\s*(.*)$/.exec(t);
6071
+ if (pair) {
6072
+ const key = JSON.parse(`"${pair[1]}"`);
6073
+ const rest = pair[2];
6074
+ const str = /^"((?:[^"\\]|\\.)*)"/.exec(rest);
6075
+ if (str) out.push({ key: path26(key), value: JSON.parse(`"${str[1]}"`), line: i + 1 });
6076
+ else if (/^[[{]\s*$/.test(rest)) stack.push({ key, array: rest.startsWith("["), next: 0 });
6077
+ return;
6078
+ }
6079
+ if (top?.array) {
6080
+ const str = /^"((?:[^"\\]|\\.)*)"\s*,?$/.exec(t);
6081
+ if (str) {
6082
+ out.push({ key: path26(top.next++), value: JSON.parse(`"${str[1]}"`), line: i + 1 });
6083
+ return;
6084
+ }
6085
+ if (/^[[{]\s*$/.test(t)) {
6086
+ stack.push({ key: top.next++, array: t === "[", next: 0 });
6087
+ return;
6088
+ }
6089
+ }
6090
+ if (/^[\]}]/.test(t)) stack.pop();
6091
+ });
6092
+ return out;
6064
6093
  }
6065
- if (impact.estimate.count === 0) {
6066
- console.error(`piwi run: no tests impacted by ${impact.impact.changedFiles} changed file(s) \u2014 nothing to run`);
6067
- return EXIT_OK6;
6094
+ if (ext === "yaml" || ext === "yml") {
6095
+ const stack = [];
6096
+ lines.forEach((text, i) => {
6097
+ const m = /^(\s*)(?:"([^"]+)"|'([^']+)'|([^\s:#][^:#]*?))\s*:(?:\s+(.*))?$/.exec(text);
6098
+ if (!m) return;
6099
+ const indent = m[1].length;
6100
+ const key = (m[2] ?? m[3] ?? m[4]).trim();
6101
+ while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
6102
+ const raw = m[5]?.trim();
6103
+ if (!raw || raw.startsWith("#") || raw === "|" || raw === ">") {
6104
+ stack.push({ indent, key });
6105
+ return;
6106
+ }
6107
+ out.push({ key: [...stack.map((s) => s.key), key].join("."), value: unquoteYaml(raw), line: i + 1 });
6108
+ });
6109
+ return out;
6068
6110
  }
6069
- const runEnv = {
6070
- ...env,
6071
- PIWI_SELECTION: IMPACT_KEY,
6072
- PIWI_SELECTION_VERSION: "0",
6073
- PIWI_SELECTION_HASH: impact.resolvedHash,
6074
- PIWI_SELECTION_COUNT: String(impact.estimate.count),
6075
- ...shardEnv(args)
6076
- };
6077
- console.error(`piwi run: impact \u2192 ${impact.estimate.count} test(s)`);
6078
- return spawnPlaywrightForRun(args.pkgRunner, [...impact.materialization.args, ...args.extra], runEnv);
6079
- }
6080
- async function runRun(argv, env = process.env) {
6081
- if (argv.includes("-h") || argv.includes("--help")) {
6082
- console.log(USAGE9);
6083
- return EXIT_OK6;
6111
+ if (ext === "properties") {
6112
+ lines.forEach((text, i) => {
6113
+ const m = /^\s*([^#!\s][^=:]*?)\s*[=:]\s*(.*)$/.exec(text);
6114
+ if (m && !m[2].endsWith("\\")) out.push({ key: m[1], value: m[2], line: i + 1 });
6115
+ });
6116
+ return out;
6084
6117
  }
6085
- let args;
6086
- try {
6087
- args = parseSelectArgs(argv, env);
6088
- } catch (e) {
6089
- console.error(`piwi run: ${e.message}
6090
- `);
6091
- console.error(USAGE9);
6092
- return EXIT_ERROR7;
6118
+ if (ext === "po") {
6119
+ let msgid = null;
6120
+ lines.forEach((text, i) => {
6121
+ const id = /^msgid\s+"(.*)"\s*$/.exec(text);
6122
+ if (id) {
6123
+ msgid = id[1].replace(/\\"/g, '"');
6124
+ return;
6125
+ }
6126
+ const str = /^msgstr(?:\[\d+\])?\s+"(.*)"\s*$/.exec(text);
6127
+ if (str && msgid) out.push({ key: msgid, value: str[1].replace(/\\"/g, '"'), line: i + 1 });
6128
+ });
6129
+ return out;
6093
6130
  }
6094
- if (args.format === "json") {
6095
- console.error("piwi run: --format json cannot be run; use args, grep or files");
6096
- return EXIT_ERROR7;
6131
+ if (ext === "resx") {
6132
+ let name = null;
6133
+ lines.forEach((text, i) => {
6134
+ const data = /<data\s+name="([^"]+)"/.exec(text);
6135
+ if (data) name = decodeXml2(data[1]);
6136
+ const value = /<value>(.*)<\/value>/.exec(text);
6137
+ if (value && name) {
6138
+ out.push({ key: name, value: decodeXml2(value[1]), line: i + 1 });
6139
+ name = null;
6140
+ }
6141
+ });
6142
+ return out;
6097
6143
  }
6098
- if (args.key === IMPACT_KEY) {
6099
- if (!args.base) {
6100
- console.error("piwi run: impact needs a base ref \u2014 pass --base <ref> (e.g. --base origin/main)");
6101
- return EXIT_ERROR7;
6144
+ return out;
6145
+ }
6146
+ function translationLineToken(path25, text, line) {
6147
+ const ext = /\.([a-z]+)$/i.exec(path25)?.[1]?.toLowerCase();
6148
+ let key;
6149
+ let value;
6150
+ if (ext === "json") {
6151
+ const pair = /^\s*"((?:[^"\\]|\\.)*)"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(text);
6152
+ if (pair) {
6153
+ key = JSON.parse(`"${pair[1]}"`);
6154
+ value = JSON.parse(`"${pair[2]}"`);
6155
+ } else {
6156
+ const item = /^\s*"((?:[^"\\]|\\.)*)"\s*,?\s*$/.exec(text);
6157
+ if (item) value = JSON.parse(`"${item[1]}"`);
6102
6158
  }
6103
- return runRunImpact(args, env);
6104
- }
6105
- let outcome;
6106
- try {
6107
- outcome = await resolveWithCache(args);
6108
- } catch (e) {
6109
- console.error(`piwi run: ${e.message}`);
6110
- return EXIT_ERROR7;
6111
- }
6112
- if ("error" in outcome) {
6113
- console.error(`piwi run: ${outcome.error.message} \u2014 no cached resolution, running the full suite`);
6114
- return spawnPlaywrightForRun(args.pkgRunner, args.extra, env);
6115
- }
6116
- const { resolution } = outcome;
6117
- printWarnings(resolution);
6118
- if (resolution.estimate.count === 0) {
6119
- console.error(`piwi run: "${args.key}" resolved to 0 tests`);
6120
- return EXIT_ERROR7;
6121
- }
6122
- const runEnv = {
6123
- ...env,
6124
- PIWI_SELECTION: resolution.key ?? args.key,
6125
- PIWI_SELECTION_VERSION: String(resolution.version ?? 0),
6126
- PIWI_SELECTION_HASH: resolution.resolvedHash,
6127
- PIWI_SELECTION_COUNT: String(resolution.estimate.count),
6128
- ...shardEnv(args)
6129
- };
6130
- console.error(
6131
- `piwi run: ${args.key} \u2192 ${resolution.estimate.count} tests${resolution.materialization.format !== args.format ? ` (materialized as ${resolution.materialization.format})` : ""}`
6132
- );
6133
- return spawnPlaywrightForRun(args.pkgRunner, [...resolution.materialization.args, ...args.extra], runEnv);
6134
- }
6135
-
6136
- // src/cli/preflight.ts
6137
- var fs18 = __toESM(require("fs"));
6138
- var path22 = __toESM(require("path"));
6139
- var import_node_child_process8 = require("child_process");
6140
-
6141
- // ../core/src/diff-anchors.ts
6142
- var RENAME_FROM = `rename from `;
6143
- var RENAME_TO = "rename to ";
6144
- var HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
6145
- function stripPrefix(path24) {
6146
- const unquoted = path24.startsWith('"') && path24.endsWith('"') ? path24.slice(1, -1) : path24;
6147
- return unquoted.replace(/^[ab]\//, "");
6148
- }
6149
- function parseUnifiedDiff(text) {
6150
- const files = [];
6151
- let file = null;
6152
- let hunk = null;
6153
- let oldLine = 0;
6154
- let newLine = 0;
6155
- let oldLeft = 0;
6156
- let newLeft = 0;
6157
- const current = () => {
6158
- if (!file) {
6159
- file = { path: "", status: "modified", hunks: [] };
6160
- files.push(file);
6159
+ } else if (ext === "yaml" || ext === "yml") {
6160
+ const m = /^\s*(?:-\s+)?(?:"([^"]+)"|'([^']+)'|([^\s:#][^:#]*?))\s*:\s+(.+)$/.exec(text);
6161
+ if (m) {
6162
+ key = (m[1] ?? m[2] ?? m[3]).trim();
6163
+ value = unquoteYaml(m[4]);
6161
6164
  }
6162
- return file;
6163
- };
6164
- for (const line of text.split(/\r?\n/)) {
6165
- if (hunk) {
6166
- if (line.startsWith("-") && oldLeft > 0) {
6167
- hunk.removed.push({ line: oldLine++, text: line.slice(1) });
6168
- oldLeft--;
6169
- } else if (line.startsWith("+") && newLeft > 0) {
6170
- hunk.added.push({ line: newLine++, text: line.slice(1) });
6171
- newLeft--;
6172
- } else if ((line.startsWith(" ") || line === "") && oldLeft > 0 && newLeft > 0) {
6173
- oldLine++;
6174
- newLine++;
6175
- oldLeft--;
6176
- newLeft--;
6177
- } else if (!line.startsWith("\\")) hunk = null;
6178
- if (hunk && oldLeft === 0 && newLeft === 0) hunk = null;
6179
- if (hunk || !line.startsWith("@@")) continue;
6165
+ } else if (ext === "properties") {
6166
+ const m = /^\s*([^#!\s][^=:]*?)\s*[=:]\s*(.*)$/.exec(text);
6167
+ if (m) {
6168
+ key = m[1];
6169
+ value = m[2];
6180
6170
  }
6181
- const header = HUNK_HEADER.exec(line);
6182
- if (header) {
6183
- oldLine = Number(header[1]);
6184
- newLine = Number(header[3]);
6185
- oldLeft = header[2] === void 0 ? 1 : Number(header[2]);
6186
- newLeft = header[4] === void 0 ? 1 : Number(header[4]);
6187
- hunk = { oldStart: oldLine, newStart: newLine, removed: [], added: [] };
6188
- current().hunks.push(hunk);
6189
- if (oldLeft === 0 && newLeft === 0) hunk = null;
6190
- continue;
6171
+ } else if (ext === "po") {
6172
+ const m = /^msg(id|str(?:\[\d+\])?)\s+"(.*)"\s*$/.exec(text);
6173
+ if (m) {
6174
+ value = m[2].replace(/\\"/g, '"');
6175
+ if (m[1] === "id") key = value;
6191
6176
  }
6192
- if (line.startsWith("diff --git ")) {
6193
- const m = /^diff --git (\S+|"[^"]+") (\S+|"[^"]+")$/.exec(line);
6194
- file = { path: m ? stripPrefix(m[2]) : "", status: "modified", hunks: [] };
6195
- files.push(file);
6196
- } else if (line.startsWith("--- ")) {
6197
- const f = file && !file.hunks.length ? file : (file = null, current());
6198
- const old = line.slice(4).trim();
6199
- if (old === "/dev/null") f.status = "added";
6200
- else if (!f.path) f.path = stripPrefix(old);
6201
- } else if (line.startsWith("+++ ")) {
6202
- const next = line.slice(4).trim();
6203
- if (next === "/dev/null") current().status = "deleted";
6204
- else current().path = stripPrefix(next);
6205
- } else if (line.startsWith(RENAME_FROM)) {
6206
- current().oldPath = line.slice(RENAME_FROM.length);
6207
- current().status = "renamed";
6208
- } else if (line.startsWith(RENAME_TO)) current().path = line.slice(RENAME_TO.length);
6209
- else if (line.startsWith("new file mode")) current().status = "added";
6210
- else if (line.startsWith("deleted file mode")) current().status = "deleted";
6177
+ } else if (ext === "resx") {
6178
+ const m = /<value>(.*)<\/value>/.exec(text);
6179
+ if (m) value = decodeXml2(m[1]);
6211
6180
  }
6212
- return files.filter((f) => f.path);
6181
+ if (value === void 0 || !keepable(value)) return null;
6182
+ return { kind: "translation", group: `translation:${key ?? ""}`, value, key, line };
6213
6183
  }
6214
- var DEFAULT_TRANSLATION_GLOBS = ["**/{locales,locale,i18n,lang,translations}/**", "**/*.resx"];
6215
- var ANCHOR_ATTRIBUTES = [
6216
- "id",
6217
- "name",
6218
- "aria-label",
6219
- "placeholder",
6220
- "alt",
6221
- "title",
6222
- "role",
6223
- "value",
6224
- "label"
6225
- ];
6226
- var TRANSLATION_EXTENSIONS = /\.(json|ya?ml|properties|po|resx)$/i;
6227
- var MARKUP_FILE = /\.(vue|svelte|html?|cshtml|razor|hbs|handlebars|jsx|tsx|astro|njk|liquid|erb|php)$/i;
6228
- var MIN_LENGTH = 2;
6229
- var MAX_LENGTH = 200;
6230
- function expandBraces(glob) {
6231
- const m = /\{([^{}]*)\}/.exec(glob);
6232
- if (!m) return [glob];
6233
- return m[1].split(",").flatMap((alt) => expandBraces(glob.slice(0, m.index) + alt + glob.slice(m.index + m[0].length)));
6184
+ function lineTokens(path25, text, line, side, attributes, options) {
6185
+ const spans = [];
6186
+ const markup = MARKUP_FILE.test(path25);
6187
+ return [
6188
+ ...attributeTokens(text, attributes, line, spans),
6189
+ ...translationRefTokens(text, line, side, options.translations, spans),
6190
+ ...literalTokens(text, line, spans, markup),
6191
+ ...textTokens(text, line, markup)
6192
+ ];
6234
6193
  }
6235
- function pathGlobToRegExp(glob) {
6236
- const parts = expandBraces(glob).map((g) => {
6237
- let re = "";
6238
- for (let i = 0; i < g.length; i++) {
6239
- const c = g[i];
6240
- if (c === "*") {
6241
- if (g[i + 1] === "*") {
6242
- i++;
6243
- if (g[i + 1] === "/") {
6244
- i++;
6245
- re += "(?:.*/)?";
6246
- } else re += ".*";
6247
- } else re += "[^/]*";
6248
- } else if (c === "?") re += "[^/]";
6249
- else if ("\\^$.|+()[]{}".includes(c)) re += "\\" + c;
6250
- else re += c;
6251
- }
6252
- return re;
6253
- });
6254
- return new RegExp(`^(?:${parts.join("|")})$`);
6194
+ function keysByLine(path25, side, options) {
6195
+ const content = options.readFile?.(path25, side);
6196
+ if (content === void 0) return null;
6197
+ return new Map(parseTranslationFile(path25, content).map((e) => [e.line, e.key]));
6255
6198
  }
6256
- function isTranslationFile(path24, globs = DEFAULT_TRANSLATION_GLOBS) {
6257
- const posix = path24.replace(/\\/g, "/");
6258
- if (!TRANSLATION_EXTENSIONS.test(posix)) return false;
6259
- return globs.some((g) => pathGlobToRegExp(g).test(posix));
6199
+ function tokenId(t) {
6200
+ return `${t.group}\0${t.value}`;
6260
6201
  }
6261
- function keepable(value) {
6262
- const v = value.trim();
6263
- if (v.length < MIN_LENGTH || v.length > MAX_LENGTH) return false;
6264
- if (/^[-+]?[\d.,]+$/.test(v)) return false;
6265
- return new RegExp("\\p{L}", "u").test(v);
6202
+ function hunkAnchors(file, hunk, removed, added) {
6203
+ const addedLeft = /* @__PURE__ */ new Map();
6204
+ for (const t of added) addedLeft.set(tokenId(t), [...addedLeft.get(tokenId(t)) ?? [], t]);
6205
+ const gone = [];
6206
+ for (const t of removed) {
6207
+ const same = addedLeft.get(tokenId(t));
6208
+ if (same?.length) same.shift();
6209
+ else gone.push(t);
6210
+ }
6211
+ const fresh = [...addedLeft.values()].flat();
6212
+ const anchors = [];
6213
+ const seen = /* @__PURE__ */ new Set();
6214
+ for (const t of gone) {
6215
+ const peers = gone.filter((g) => g.group === t.group);
6216
+ const replacements = fresh.filter((a) => a.group === t.group);
6217
+ const rename = peers.length === 1 && replacements.length === 1 ? replacements[0] : void 0;
6218
+ const anchor = {
6219
+ file,
6220
+ line: rename ? rename.line : Math.max(1, hunk.newStart),
6221
+ oldLine: t.line,
6222
+ kind: t.kind,
6223
+ before: t.value
6224
+ };
6225
+ if (t.attribute) anchor.attribute = t.attribute;
6226
+ if (t.key) anchor.key = t.key;
6227
+ if (rename) anchor.after = rename.value;
6228
+ const id = `${anchor.kind}\0${anchor.attribute ?? ""}\0${anchor.key ?? ""}\0${anchor.before}\0${anchor.after ?? ""}`;
6229
+ if (seen.has(id)) continue;
6230
+ seen.add(id);
6231
+ anchors.push(anchor);
6232
+ }
6233
+ return anchors;
6266
6234
  }
6267
- function readQuoted(text, start) {
6268
- const quote3 = text[start];
6269
- let value = "";
6270
- for (let i = start + 1; i < text.length; i++) {
6271
- const ch = text[i];
6272
- if (ch === "\\" && i + 1 < text.length) {
6273
- const next = text[i + 1];
6274
- value += next === "n" ? "\n" : next === "t" ? " " : next;
6275
- i++;
6276
- continue;
6235
+ function extractDiffAnchors(files, options = {}) {
6236
+ const attributes = [.../* @__PURE__ */ new Set([...options.testIdAttributes ?? ["data-testid"], ...ANCHOR_ATTRIBUTES])];
6237
+ const globs = options.translationGlobs ?? DEFAULT_TRANSLATION_GLOBS;
6238
+ const anchors = [];
6239
+ for (const file of files) {
6240
+ if (options.isTestFile?.(file.path)) continue;
6241
+ const translation = isTranslationFile(file.path, globs);
6242
+ let oldKeys;
6243
+ let newKeys;
6244
+ for (const hunk of file.hunks) {
6245
+ let removed;
6246
+ let added;
6247
+ if (translation) {
6248
+ oldKeys ??= keysByLine(file.oldPath ?? file.path, "old", options);
6249
+ newKeys ??= keysByLine(file.path, "new", options);
6250
+ const read = (lines, keys) => lines.flatMap((l) => {
6251
+ const token = translationLineToken(file.path, l.text, l.line);
6252
+ if (!token) return [];
6253
+ const full = keys?.get(l.line);
6254
+ if (full) {
6255
+ token.key = full;
6256
+ token.group = `translation:${full}`;
6257
+ }
6258
+ return [token];
6259
+ });
6260
+ removed = read(hunk.removed, oldKeys);
6261
+ added = read(hunk.added, newKeys);
6262
+ } else {
6263
+ removed = hunk.removed.flatMap((l) => lineTokens(file.path, l.text, l.line, "old", attributes, options));
6264
+ added = hunk.added.flatMap((l) => lineTokens(file.path, l.text, l.line, "new", attributes, options));
6265
+ }
6266
+ anchors.push(...hunkAnchors(file.path, hunk, removed, added));
6277
6267
  }
6278
- if (quote3 === "`" && ch === "$" && text[i + 1] === "{") return null;
6279
- if (ch === quote3) return { value, end: i + 1 };
6280
- value += ch;
6281
6268
  }
6282
- return null;
6269
+ return anchors;
6283
6270
  }
6284
- function escapeRegExp(s) {
6285
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6271
+
6272
+ // ../core/src/locator-text-match.ts
6273
+ function normalizeWhiteSpace(text) {
6274
+ return text.replace(/[​­]/g, "").trim().replace(/\s+/g, " ");
6286
6275
  }
6287
- function attributeTokens(text, attributes, line, spans) {
6288
- const tokens = [];
6289
- const names = attributes.map(escapeRegExp).join("|");
6290
- const re = new RegExp(
6291
- `(?<![\\w-])(?:v-bind:|:|\\[(?:attr\\.)?)?(["']?)(${names})\\1\\]?\\s*(=|:)\\s*(\\{\\s*)?(["'\`])`,
6292
- "g"
6293
- );
6294
- let m;
6295
- while ((m = re.exec(text)) !== null) {
6296
- const bound = m[0].startsWith(":") || m[0].startsWith("v-bind:") || m[0].startsWith("[");
6297
- const quoteAt = m.index + m[0].length - 1;
6298
- const outer = readQuoted(text, quoteAt);
6299
- if (!outer) continue;
6300
- let value = outer.value;
6301
- if (bound) {
6302
- const inner = /^\s*(['"`])(.*)\1\s*$/.exec(value);
6303
- if (!inner) continue;
6304
- value = inner[2];
6305
- }
6306
- spans.push({ start: m.index, end: outer.end });
6307
- re.lastIndex = outer.end;
6308
- if (!keepable(value) || value.includes("${")) continue;
6309
- const attribute = m[2];
6310
- tokens.push({ kind: "attribute", group: `attribute:${attribute}`, value, attribute, line });
6276
+ function textMatcher(value, exact) {
6277
+ if (typeof value !== "string") {
6278
+ const re = value;
6279
+ return { matcher: (text) => re.test(text.full), kind: "regex" };
6311
6280
  }
6312
- return tokens;
6281
+ const needle = normalizeWhiteSpace(value);
6282
+ if (exact) return { matcher: (text) => text.normalized === needle, kind: "strict" };
6283
+ const lower = needle.toLowerCase();
6284
+ return { matcher: (text) => (text.lower ??= text.normalized.toLowerCase()).includes(lower), kind: "lax" };
6313
6285
  }
6314
- var TRANSLATION_CALL = /(?:(?<![\w$.])(?:\$?t|i18n\.t|translate|formatMessage)\s*\(\s*(?:\{\s*id\s*:\s*)?(['"`])([\w.:-]+)\1|\b\w*[Ll]ocalizer\s*\[\s*"([\w.:-]+)"\s*\])/g;
6315
- function translationRefTokens(text, line, side, lookup, spans) {
6316
- const tokens = [];
6317
- for (const m of text.matchAll(TRANSLATION_CALL)) {
6318
- spans.push({ start: m.index, end: m.index + m[0].length });
6319
- const key = m[2] ?? m[3];
6320
- const value = lookup?.(key, side);
6321
- if (value === void 0 || !keepable(value)) continue;
6322
- tokens.push({ kind: "translation", group: "translation-ref", value, key, line });
6323
- }
6324
- return tokens;
6286
+ function attributeMatcher(value, exact) {
6287
+ if (typeof value !== "string") return (actual) => !!actual.match(value);
6288
+ if (exact) return (actual) => actual === value;
6289
+ const lower = value.toLowerCase();
6290
+ return (actual) => actual.toLowerCase().includes(lower);
6325
6291
  }
6326
- function inSpans(spans, at) {
6327
- return spans.some((s) => at >= s.start && at < s.end);
6292
+ function nameMatcher(expected, exact) {
6293
+ if (typeof expected !== "string") return (name) => !!name.match(expected);
6294
+ const wanted = normalizeWhiteSpace(expected);
6295
+ if (exact) return (name) => name === wanted;
6296
+ const upper = wanted.toUpperCase();
6297
+ return (name) => name.toUpperCase().includes(upper);
6328
6298
  }
6329
- var NON_TEXT_LITERAL = /^(?:\.{0,2}\/|[a-z][\w+.-]*:\/\/|#[\w-]+$|@\/|~\/)|\.(?:m?[jt]sx?|vue|svelte|css|scss|json|png|svg|jpe?g)$/i;
6330
- function literalTokens(text, line, spans, markup) {
6331
- const tokens = [];
6332
- if (/^\s*(?:import\b|export\s+\*|\/\/|\*|\/\*|#include|using\s)/.test(text)) return tokens;
6333
- for (let i = 0; i < text.length; i++) {
6334
- const ch = text[i];
6335
- if (ch !== '"' && ch !== "'" && ch !== "`") continue;
6336
- const lit = readQuoted(text, i);
6337
- if (!lit) {
6338
- if (ch === "'") continue;
6339
- break;
6340
- }
6341
- const start = i;
6342
- i = lit.end - 1;
6343
- if (inSpans(spans, start)) continue;
6344
- const before = text.slice(0, start);
6345
- if (markup && /[\w:@.[\]-]=\s*$/.test(before)) continue;
6346
- if (/\b(?:from|require|import)\s*\(?\s*$/.test(before)) continue;
6347
- const value = lit.value;
6348
- if (!keepable(value) || NON_TEXT_LITERAL.test(value)) continue;
6349
- if (/^[\w-]+(?:\.[\w-]+)+$/.test(value) && !/\s/.test(value)) continue;
6350
- tokens.push({ kind: "literal", group: "literal", value, line });
6351
- }
6352
- return tokens;
6299
+ function textMatches(value, exact, text) {
6300
+ return textMatcher(value, exact).matcher({ full: text, normalized: normalizeWhiteSpace(text) });
6353
6301
  }
6354
- var CODE_LINE = /^(?:import|export|const|let|var|function|return|if|else|for|while|switch|case|default|class|interface|type|await|async|new|throw|try|catch|public|private|protected|static|using|namespace|@\w+\s*\()\b/;
6355
- function textSegment(raw, line) {
6356
- const value = raw.replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim();
6357
- if (!keepable(value)) return null;
6358
- if (/[{}]|@[\w(]|<%|%>/.test(value)) return null;
6359
- return { kind: "text", group: "text", value, line };
6302
+ function nameMatches(value, exact, name) {
6303
+ return nameMatcher(value, exact)(normalizeWhiteSpace(name));
6360
6304
  }
6361
- function textTokens(text, line, markup) {
6362
- const tokens = [];
6363
- if (!markup) return tokens;
6364
- const trimmed = text.trim();
6365
- if (!trimmed) return tokens;
6366
- if (!/[<>]/.test(trimmed)) {
6367
- if (CODE_LINE.test(trimmed) || /[;=(){}[\]"'`]|^\/\/|^\*|^\/\*|^<!--|[,:]$/.test(trimmed)) return tokens;
6368
- const token = textSegment(trimmed, line);
6369
- return token ? [token] : tokens;
6305
+ function attributeMatches(value, exact, actual) {
6306
+ return attributeMatcher(value, exact)(actual);
6307
+ }
6308
+
6309
+ // ../core/src/locator-break.ts
6310
+ var TEXT_KINDS = /* @__PURE__ */ new Set(["text", "translation", "literal"]);
6311
+ function isText(anchor) {
6312
+ return TEXT_KINDS.has(anchor.kind);
6313
+ }
6314
+ function hasAttribute(anchor, names) {
6315
+ return anchor.kind === "attribute" && !!anchor.attribute && names.includes(anchor.attribute);
6316
+ }
6317
+ function toRegExp(arg) {
6318
+ try {
6319
+ return new RegExp(arg.source, arg.flags.replace(/[gy]/g, ""));
6320
+ } catch {
6321
+ return null;
6370
6322
  }
6371
- for (const m of text.matchAll(/>([^<>]+)</g)) {
6372
- const token = textSegment(m[1], line);
6373
- if (token) tokens.push(token);
6323
+ }
6324
+ function optionsOf(arg) {
6325
+ return new Map(arg?.type === "object" ? arg.entries : []);
6326
+ }
6327
+ function isExact(opts) {
6328
+ const exact = opts.get("exact");
6329
+ return exact?.type === "boolean" && exact.value;
6330
+ }
6331
+ function escapeRegExp2(s) {
6332
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6333
+ }
6334
+ function renameValue(value, before, after, exact) {
6335
+ const v = normalizeWhiteSpace(value);
6336
+ const b = normalizeWhiteSpace(before);
6337
+ if (exact ? v === b : v.toLowerCase() === b.toLowerCase()) return after;
6338
+ const re = new RegExp(escapeRegExp2(b).replace(/ /g, "\\s+"), exact ? "" : "i");
6339
+ if (!re.test(value)) return null;
6340
+ return value.replace(re, () => after);
6341
+ }
6342
+ function withArg(call, index, arg) {
6343
+ return { method: call.method, args: call.args.map((a, i) => i === index ? arg : a) };
6344
+ }
6345
+ function withOption(call, index, key, arg) {
6346
+ const obj = call.args[index];
6347
+ if (obj?.type !== "object") return call;
6348
+ return withArg(call, index, {
6349
+ type: "object",
6350
+ entries: obj.entries.map(([k, v]) => [k, k === key ? arg : v])
6351
+ });
6352
+ }
6353
+ function stringSlot(arg, exact, rule, accepts, replace) {
6354
+ if (arg?.type === "regex") {
6355
+ const re = toRegExp(arg);
6356
+ return re ? { value: re, exact, rule, accepts } : null;
6374
6357
  }
6375
- const lead = /^\s*([^<>]+)<\//.exec(text);
6376
- if (lead && !/[=;(){}]/.test(lead[1])) {
6377
- const token = textSegment(lead[1], line);
6378
- if (token) tokens.push(token);
6358
+ if (arg?.type !== "string") return null;
6359
+ const value = arg.value;
6360
+ return {
6361
+ value,
6362
+ exact,
6363
+ rule,
6364
+ accepts,
6365
+ rewrite: (after, before) => replace({ type: "string", value: renameValue(value, before, after, exact) ?? value }),
6366
+ literal: (after, before) => [value, renameValue(value, before, after, exact) ?? value]
6367
+ };
6368
+ }
6369
+ function cssSlots(selector, testIds, replace) {
6370
+ const slots = [];
6371
+ const slot = (value, names, render, at, len) => {
6372
+ const edited = (after) => selector.slice(0, at) + render(after) + selector.slice(at + len);
6373
+ slots.push({
6374
+ value,
6375
+ exact: true,
6376
+ rule: "exact",
6377
+ accepts: (a) => hasAttribute(a, names),
6378
+ rewrite: (after) => replace(edited(after)),
6379
+ literal: (after) => [selector, edited(after)]
6380
+ });
6381
+ };
6382
+ for (const m of selector.matchAll(/#((?:[\w-]|\\.)+)/g)) {
6383
+ slot(
6384
+ m[1].replace(/\\(.)/g, "$1"),
6385
+ ["id"],
6386
+ (v) => `#${v.replace(/[^\w-]/g, (c) => `\\${c}`)}`,
6387
+ m.index,
6388
+ m[0].length
6389
+ );
6379
6390
  }
6380
- const tail = />([^<>]+)$/.exec(text);
6381
- if (tail && !/[=;(){}"']/.test(tail[1])) {
6382
- const token = textSegment(tail[1], line);
6383
- if (token) tokens.push(token);
6391
+ for (const m of selector.matchAll(/\[\s*([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\]\s]+))\s*\]/g)) {
6392
+ const attr = m[1];
6393
+ const names = testIds.includes(attr) ? testIds : attr === "id" || attr === "name" ? [attr] : null;
6394
+ if (!names) continue;
6395
+ const quote3 = m[2] !== void 0 ? '"' : m[3] !== void 0 ? "'" : "";
6396
+ const value = m[2] ?? m[3] ?? m[4];
6397
+ slot(value, [attr], (v) => `[${attr}=${quote3}${v}${quote3}]`, m.index, m[0].length);
6384
6398
  }
6385
- return tokens;
6399
+ return slots;
6386
6400
  }
6387
- function unquoteYaml(raw) {
6388
- const v = raw.trim();
6389
- if (v.startsWith('"') && v.endsWith('"') || v.startsWith("'") && v.endsWith("'")) {
6390
- return v.slice(1, -1).replace(/\\"/g, '"').replace(/''/g, "'");
6401
+ function directSlots(call, testIds) {
6402
+ const first = call.args[0];
6403
+ const opts = optionsOf(call.args[1]);
6404
+ const exact = isExact(opts);
6405
+ const slots = [];
6406
+ const argSlot = (rule, accepts, ex = exact) => stringSlot(first, ex, rule, accepts, (next) => withArg(call, 0, next));
6407
+ const hasTextSlot = (index) => {
6408
+ const hasText = optionsOf(call.args[index]).get("hasText");
6409
+ if (!hasText) return null;
6410
+ return stringSlot(hasText, false, "text", isText, (next) => withOption(call, index, "hasText", next));
6411
+ };
6412
+ const textOr = (attribute) => (a) => a.kind === "translation" || a.kind === "literal" || hasAttribute(a, [attribute]);
6413
+ switch (call.method) {
6414
+ case "getByTestId":
6415
+ slots.push(argSlot("exact", (a) => hasAttribute(a, testIds), true));
6416
+ break;
6417
+ case "getByRole":
6418
+ slots.push(
6419
+ stringSlot(
6420
+ opts.get("name"),
6421
+ exact,
6422
+ "name",
6423
+ (a) => isText(a) || hasAttribute(a, ["aria-label", "title", "alt", "value"]),
6424
+ (next) => withOption(call, 1, "name", next)
6425
+ )
6426
+ );
6427
+ break;
6428
+ case "getByText":
6429
+ slots.push(argSlot("text", isText));
6430
+ break;
6431
+ case "getByLabel":
6432
+ slots.push(argSlot("text", (a) => isText(a) || hasAttribute(a, ["aria-label"])));
6433
+ break;
6434
+ case "getByPlaceholder":
6435
+ slots.push(argSlot("text", textOr("placeholder")));
6436
+ break;
6437
+ case "getByAltText":
6438
+ slots.push(argSlot("text", textOr("alt")));
6439
+ break;
6440
+ case "getByTitle":
6441
+ slots.push(argSlot("text", textOr("title")));
6442
+ break;
6443
+ case "locator":
6444
+ if (first?.type === "string") {
6445
+ slots.push(...cssSlots(first.value, testIds, (next) => withArg(call, 0, { type: "string", value: next })));
6446
+ }
6447
+ slots.push(hasTextSlot(1));
6448
+ break;
6449
+ case "filter":
6450
+ slots.push(hasTextSlot(0));
6451
+ break;
6391
6452
  }
6392
- return v.replace(/\s+#.*$/, "");
6393
- }
6394
- function decodeXml2(text) {
6395
- return text.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
6453
+ return slots.filter((slot) => !!slot);
6396
6454
  }
6397
- function parseTranslationFile(path24, content) {
6398
- const lines = content.split(/\r?\n/);
6455
+ function nestedChains(call) {
6399
6456
  const out = [];
6400
- const ext = /\.([a-z]+)$/i.exec(path24)?.[1]?.toLowerCase();
6401
- if (ext === "json") {
6402
- const stack = [];
6403
- const path25 = (key) => [...stack.map((f) => f.key), key].join(".");
6404
- lines.forEach((text, i) => {
6405
- const t = text.trim();
6406
- const top = stack[stack.length - 1];
6407
- const pair = /^"((?:[^"\\]|\\.)*)"\s*:\s*(.*)$/.exec(t);
6408
- if (pair) {
6409
- const key = JSON.parse(`"${pair[1]}"`);
6410
- const rest = pair[2];
6411
- const str = /^"((?:[^"\\]|\\.)*)"/.exec(rest);
6412
- if (str) out.push({ key: path25(key), value: JSON.parse(`"${str[1]}"`), line: i + 1 });
6413
- else if (/^[[{]\s*$/.test(rest)) stack.push({ key, array: rest.startsWith("["), next: 0 });
6414
- return;
6415
- }
6416
- if (top?.array) {
6417
- const str = /^"((?:[^"\\]|\\.)*)"\s*,?$/.exec(t);
6418
- if (str) {
6419
- out.push({ key: path25(top.next++), value: JSON.parse(`"${str[1]}"`), line: i + 1 });
6420
- return;
6421
- }
6422
- if (/^[[{]\s*$/.test(t)) {
6423
- stack.push({ key: top.next++, array: t === "[", next: 0 });
6424
- return;
6457
+ call.args.forEach((arg, index) => {
6458
+ if (arg.type === "chain")
6459
+ out.push({ chain: arg.chain, put: (c) => withArg(call, index, { type: "chain", chain: c }) });
6460
+ if (arg.type === "object") {
6461
+ for (const [key, v] of arg.entries) {
6462
+ if (v.type === "chain") {
6463
+ out.push({ chain: v.chain, put: (c) => withOption(call, index, key, { type: "chain", chain: c }) });
6425
6464
  }
6426
6465
  }
6427
- if (/^[\]}]/.test(t)) stack.pop();
6428
- });
6429
- return out;
6430
- }
6431
- if (ext === "yaml" || ext === "yml") {
6432
- const stack = [];
6433
- lines.forEach((text, i) => {
6434
- const m = /^(\s*)(?:"([^"]+)"|'([^']+)'|([^\s:#][^:#]*?))\s*:(?:\s+(.*))?$/.exec(text);
6435
- if (!m) return;
6436
- const indent = m[1].length;
6437
- const key = (m[2] ?? m[3] ?? m[4]).trim();
6438
- while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
6439
- const raw = m[5]?.trim();
6440
- if (!raw || raw.startsWith("#") || raw === "|" || raw === ">") {
6441
- stack.push({ indent, key });
6442
- return;
6466
+ }
6467
+ });
6468
+ return out;
6469
+ }
6470
+ function chainSlots(chain, testIds) {
6471
+ const out = [];
6472
+ chain.calls.forEach((call, i) => {
6473
+ const replaceAt = (next) => ({ calls: chain.calls.map((c, j) => j === i ? next : c) });
6474
+ for (const slot of directSlots(call, testIds)) {
6475
+ const rewrite = slot.rewrite;
6476
+ out.push({ ...slot, rewrite: rewrite && ((after, before) => replaceAt(rewrite(after, before))) });
6477
+ }
6478
+ for (const n of nestedChains(call)) {
6479
+ for (const slot of chainSlots(n.chain, testIds)) {
6480
+ const rewrite = slot.rewrite;
6481
+ out.push({ ...slot, rewrite: rewrite && ((after, before) => replaceAt(n.put(rewrite(after, before)))) });
6443
6482
  }
6444
- out.push({ key: [...stack.map((s) => s.key), key].join("."), value: unquoteYaml(raw), line: i + 1 });
6445
- });
6446
- return out;
6483
+ }
6484
+ });
6485
+ return out;
6486
+ }
6487
+ function slotMatches(slot, text) {
6488
+ switch (slot.rule) {
6489
+ case "exact":
6490
+ return attributeMatches(slot.value, true, text);
6491
+ case "name":
6492
+ return nameMatches(slot.value, slot.exact, text);
6493
+ case "text":
6494
+ return textMatches(slot.value, slot.exact, text);
6447
6495
  }
6448
- if (ext === "properties") {
6449
- lines.forEach((text, i) => {
6450
- const m = /^\s*([^#!\s][^=:]*?)\s*[=:]\s*(.*)$/.exec(text);
6451
- if (m && !m[2].endsWith("\\")) out.push({ key: m[1], value: m[2], line: i + 1 });
6452
- });
6453
- return out;
6496
+ }
6497
+ var filePath = (path25) => path25.replace(/\\/g, "/").replace(/^\.\//, "");
6498
+ function sameFilePath(a, b) {
6499
+ const x = filePath(a);
6500
+ const y = filePath(b);
6501
+ return x === y || x.endsWith("/" + y) || y.endsWith("/" + x);
6502
+ }
6503
+ function reachOfIndex(index) {
6504
+ if (!index.reach.some((r) => r.origin === "client")) return void 0;
6505
+ const filesOf = /* @__PURE__ */ new Map();
6506
+ for (const r of index.reach) {
6507
+ const file = index.files[r.file];
6508
+ if (file === void 0) continue;
6509
+ for (const t of r.tests) {
6510
+ const id = index.tests[t]?.id;
6511
+ if (id === void 0) continue;
6512
+ const files = filesOf.get(id);
6513
+ if (files) files.push(file);
6514
+ else filesOf.set(id, [file]);
6515
+ }
6454
6516
  }
6455
- if (ext === "po") {
6456
- let msgid = null;
6457
- lines.forEach((text, i) => {
6458
- const id = /^msgid\s+"(.*)"\s*$/.exec(text);
6459
- if (id) {
6460
- msgid = id[1].replace(/\\"/g, '"');
6461
- return;
6462
- }
6463
- const str = /^msgstr(?:\[\d+\])?\s+"(.*)"\s*$/.exec(text);
6464
- if (str && msgid) out.push({ key: msgid, value: str[1].replace(/\\"/g, '"'), line: i + 1 });
6465
- });
6466
- return out;
6517
+ return (testId, file) => (filesOf.get(testId) ?? []).some((reached) => sameFilePath(reached, file));
6518
+ }
6519
+ function callSiteFile(callSite) {
6520
+ return callSite.replace(/:\d+(?::\d+)?$/, "");
6521
+ }
6522
+ function callSiteLine(callSite) {
6523
+ const m = /:(\d+)(?::\d+)?$/.exec(callSite);
6524
+ return m ? Number(m[1]) : null;
6525
+ }
6526
+ function locatorCallSiteFiles(index) {
6527
+ const files = /* @__PURE__ */ new Set();
6528
+ for (const entry of index.locators) {
6529
+ for (const use of entry.uses) for (const site of use.callSites) files.add(callSiteFile(site));
6467
6530
  }
6468
- if (ext === "resx") {
6469
- let name = null;
6470
- lines.forEach((text, i) => {
6471
- const data = /<data\s+name="([^"]+)"/.exec(text);
6472
- if (data) name = decodeXml2(data[1]);
6473
- const value = /<value>(.*)<\/value>/.exec(text);
6474
- if (value && name) {
6475
- out.push({ key: name, value: decodeXml2(value[1]), line: i + 1 });
6476
- name = null;
6531
+ return files;
6532
+ }
6533
+ var CONFIDENCE_RANK = { likely: 0, possible: 1 };
6534
+ function predictLocatorBreaks(anchors, index, options = {}) {
6535
+ const testIds = options.testIdAttributes ?? index.testIdAttributes ?? ["data-testid"];
6536
+ const siteFiles = [...locatorCallSiteFiles(index)];
6537
+ const live = anchors.filter((a) => !siteFiles.some((f) => sameFilePath(f, a.file)));
6538
+ if (!live.length) return [];
6539
+ const breaks = [];
6540
+ for (const entry of index.locators) {
6541
+ const chain = tryParseLocatorChain(entry.locator);
6542
+ if (!chain) continue;
6543
+ const slots = chainSlots(chain, testIds);
6544
+ if (!slots.length) continue;
6545
+ for (const anchor of live) {
6546
+ const hit = slots.filter(
6547
+ (slot) => slot.accepts(anchor) && slotMatches(slot, anchor.before) && (anchor.after === void 0 || !slotMatches(slot, anchor.after))
6548
+ );
6549
+ if (!hit.length) continue;
6550
+ const tests = [...new Set(entry.uses.map((u) => u.test))].map((t) => index.tests[t]).filter((t) => !!t);
6551
+ const found = {
6552
+ anchor,
6553
+ locator: entry.locator,
6554
+ entry,
6555
+ uses: entry.uses,
6556
+ tests,
6557
+ confidence: confidenceOf(anchor, tests, options.reach)
6558
+ };
6559
+ if (anchor.after !== void 0 && hit.every((slot) => slot.rewrite)) {
6560
+ let rewritten = chain;
6561
+ const replacements = [];
6562
+ for (const slot of hit) {
6563
+ const again = chainSlots(rewritten, testIds).find(
6564
+ (s) => s.rule === slot.rule && String(s.value) === String(slot.value) && s.rewrite
6565
+ );
6566
+ if (!again?.rewrite) continue;
6567
+ rewritten = again.rewrite(anchor.after, anchor.before);
6568
+ if (slot.literal) replacements.push(slot.literal(anchor.after, anchor.before));
6569
+ }
6570
+ const rendered = renderLocatorChain(rewritten);
6571
+ if (rendered !== entry.locator) {
6572
+ found.rewrite = rendered;
6573
+ found.replacements = replacements.filter(([a, b]) => a !== b);
6574
+ }
6477
6575
  }
6478
- });
6479
- return out;
6480
- }
6481
- return out;
6482
- }
6483
- function translationLineToken(path24, text, line) {
6484
- const ext = /\.([a-z]+)$/i.exec(path24)?.[1]?.toLowerCase();
6485
- let key;
6486
- let value;
6487
- if (ext === "json") {
6488
- const pair = /^\s*"((?:[^"\\]|\\.)*)"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(text);
6489
- if (pair) {
6490
- key = JSON.parse(`"${pair[1]}"`);
6491
- value = JSON.parse(`"${pair[2]}"`);
6492
- } else {
6493
- const item = /^\s*"((?:[^"\\]|\\.)*)"\s*,?\s*$/.exec(text);
6494
- if (item) value = JSON.parse(`"${item[1]}"`);
6495
- }
6496
- } else if (ext === "yaml" || ext === "yml") {
6497
- const m = /^\s*(?:-\s+)?(?:"([^"]+)"|'([^']+)'|([^\s:#][^:#]*?))\s*:\s+(.+)$/.exec(text);
6498
- if (m) {
6499
- key = (m[1] ?? m[2] ?? m[3]).trim();
6500
- value = unquoteYaml(m[4]);
6501
- }
6502
- } else if (ext === "properties") {
6503
- const m = /^\s*([^#!\s][^=:]*?)\s*[=:]\s*(.*)$/.exec(text);
6504
- if (m) {
6505
- key = m[1];
6506
- value = m[2];
6507
- }
6508
- } else if (ext === "po") {
6509
- const m = /^msg(id|str(?:\[\d+\])?)\s+"(.*)"\s*$/.exec(text);
6510
- if (m) {
6511
- value = m[2].replace(/\\"/g, '"');
6512
- if (m[1] === "id") key = value;
6576
+ breaks.push(found);
6513
6577
  }
6514
- } else if (ext === "resx") {
6515
- const m = /<value>(.*)<\/value>/.exec(text);
6516
- if (m) value = decodeXml2(m[1]);
6517
6578
  }
6518
- if (value === void 0 || !keepable(value)) return null;
6519
- return { kind: "translation", group: `translation:${key ?? ""}`, value, key, line };
6579
+ return breaks.sort(
6580
+ (a, b) => CONFIDENCE_RANK[a.confidence] - CONFIDENCE_RANK[b.confidence] || b.tests.length - a.tests.length
6581
+ );
6520
6582
  }
6521
- function lineTokens(path24, text, line, side, attributes, options) {
6522
- const spans = [];
6523
- const markup = MARKUP_FILE.test(path24);
6524
- return [
6525
- ...attributeTokens(text, attributes, line, spans),
6526
- ...translationRefTokens(text, line, side, options.translations, spans),
6527
- ...literalTokens(text, line, spans, markup),
6528
- ...textTokens(text, line, markup)
6529
- ];
6583
+ function confidenceOf(anchor, tests, reach) {
6584
+ if (anchor.kind === "literal") return "possible";
6585
+ if (!reach) return "likely";
6586
+ if (anchor.kind === "translation" && /\.(?:json|ya?ml|properties|po|resx)$/i.test(anchor.file)) return "likely";
6587
+ return tests.some((t) => reach(t.id, anchor.file)) ? "likely" : "possible";
6530
6588
  }
6531
- function keysByLine(path24, side, options) {
6532
- const content = options.readFile?.(path24, side);
6533
- if (content === void 0) return null;
6534
- return new Map(parseTranslationFile(path24, content).map((e) => [e.line, e.key]));
6589
+
6590
+ // ../core/src/suspect-lines.ts
6591
+ function deletionPoints(hunk) {
6592
+ const added = hunk.added.map(({ line }) => line).sort((a, b) => a - b);
6593
+ const addedLines = new Set(added);
6594
+ const points = [];
6595
+ let previous = -1;
6596
+ hunk.removed.forEach(({ line }, removedBefore) => {
6597
+ const opensRun = line !== previous + 1;
6598
+ previous = line;
6599
+ if (!opensRun) return;
6600
+ let before;
6601
+ if (line === hunk.oldStart && added.length === 0) before = line === 1 ? 0 : hunk.newStart;
6602
+ else {
6603
+ before = hunk.newStart + (line - 1 - hunk.oldStart) - removedBefore;
6604
+ for (const addedLine of added) {
6605
+ if (addedLine > before) break;
6606
+ before++;
6607
+ }
6608
+ }
6609
+ if (!addedLines.has(before + 1)) points.push(before);
6610
+ });
6611
+ return points;
6535
6612
  }
6536
- function tokenId(t) {
6537
- return `${t.group}\0${t.value}`;
6613
+ function hunkHeadLines(hunk) {
6614
+ const lines = hunk.added.map(({ line }) => [line, line]);
6615
+ for (const before of deletionPoints(hunk)) lines.push(before >= 1 ? [before, before + 1] : [1, 1]);
6616
+ return normalizeLineRanges(lines);
6538
6617
  }
6539
- function hunkAnchors(file, hunk, removed, added) {
6540
- const addedLeft = /* @__PURE__ */ new Map();
6541
- for (const t of added) addedLeft.set(tokenId(t), [...addedLeft.get(tokenId(t)) ?? [], t]);
6542
- const gone = [];
6543
- for (const t of removed) {
6544
- const same = addedLeft.get(tokenId(t));
6545
- if (same?.length) same.shift();
6546
- else gone.push(t);
6547
- }
6548
- const fresh = [...addedLeft.values()].flat();
6549
- const anchors = [];
6550
- const seen = /* @__PURE__ */ new Set();
6551
- for (const t of gone) {
6552
- const peers = gone.filter((g) => g.group === t.group);
6553
- const replacements = fresh.filter((a) => a.group === t.group);
6554
- const rename = peers.length === 1 && replacements.length === 1 ? replacements[0] : void 0;
6555
- const anchor = {
6556
- file,
6557
- line: rename ? rename.line : Math.max(1, hunk.newStart),
6558
- oldLine: t.line,
6559
- kind: t.kind,
6560
- before: t.value
6561
- };
6562
- if (t.attribute) anchor.attribute = t.attribute;
6563
- if (t.key) anchor.key = t.key;
6564
- if (rename) anchor.after = rename.value;
6565
- const id = `${anchor.kind}\0${anchor.attribute ?? ""}\0${anchor.key ?? ""}\0${anchor.before}\0${anchor.after ?? ""}`;
6566
- if (seen.has(id)) continue;
6567
- seen.add(id);
6568
- anchors.push(anchor);
6569
- }
6570
- return anchors;
6618
+ function hunkBaseLines(hunk) {
6619
+ return hunkHeadLines({ oldStart: hunk.newStart, newStart: hunk.oldStart, removed: hunk.added, added: hunk.removed });
6571
6620
  }
6572
- function extractDiffAnchors(files, options = {}) {
6573
- const attributes = [.../* @__PURE__ */ new Set([...options.testIdAttributes ?? ["data-testid"], ...ANCHOR_ATTRIBUTES])];
6574
- const globs = options.translationGlobs ?? DEFAULT_TRANSLATION_GLOBS;
6575
- const anchors = [];
6621
+ function diffImpactInput(files) {
6622
+ const changedFiles = /* @__PURE__ */ new Set();
6623
+ const changes = [];
6576
6624
  for (const file of files) {
6577
- if (options.isTestFile?.(file.path)) continue;
6578
- const translation = isTranslationFile(file.path, globs);
6579
- let oldKeys;
6580
- let newKeys;
6581
- for (const hunk of file.hunks) {
6582
- let removed;
6583
- let added;
6584
- if (translation) {
6585
- oldKeys ??= keysByLine(file.oldPath ?? file.path, "old", options);
6586
- newKeys ??= keysByLine(file.path, "new", options);
6587
- const read = (lines, keys) => lines.flatMap((l) => {
6588
- const token = translationLineToken(file.path, l.text, l.line);
6589
- if (!token) return [];
6590
- const full = keys?.get(l.line);
6591
- if (full) {
6592
- token.key = full;
6593
- token.group = `translation:${full}`;
6594
- }
6595
- return [token];
6596
- });
6597
- removed = read(hunk.removed, oldKeys);
6598
- added = read(hunk.added, newKeys);
6599
- } else {
6600
- removed = hunk.removed.flatMap((l) => lineTokens(file.path, l.text, l.line, "old", attributes, options));
6601
- added = hunk.added.flatMap((l) => lineTokens(file.path, l.text, l.line, "new", attributes, options));
6602
- }
6603
- anchors.push(...hunkAnchors(file.path, hunk, removed, added));
6604
- }
6625
+ changedFiles.add(file.path);
6626
+ if (file.oldPath) changedFiles.add(file.oldPath);
6627
+ if (file.status !== "modified" || !file.oldBlob) continue;
6628
+ const lines = normalizeLineRanges(file.hunks.flatMap(hunkBaseLines));
6629
+ if (lines.length > 0) changes.push({ filePath: file.path, blob: file.oldBlob, lines });
6605
6630
  }
6606
- return anchors;
6631
+ return { changedFiles: [...changedFiles], changes };
6607
6632
  }
6608
6633
 
6609
- // ../core/src/locator-text-match.ts
6610
- function normalizeWhiteSpace(text) {
6611
- return text.replace(/[​­]/g, "").trim().replace(/\s+/g, " ");
6634
+ // src/cli/add-reporter.ts
6635
+ var fs16 = __toESM(require("fs"));
6636
+ var path21 = __toESM(require("path"));
6637
+ var import_node_module2 = require("module");
6638
+ var REPORTER_PACKAGE2 = "@piwitests/reporter";
6639
+ var ADD_REPORTER_FLAG = "--add-reporter";
6640
+ var ADD_REPORTER_MIN = { major: 1, minor: 63 };
6641
+ function playwrightSupportsAddReporter(version) {
6642
+ const match = version ? /^(\d+)\.(\d+)/.exec(version) : null;
6643
+ if (!match) return false;
6644
+ const major = Number(match[1]);
6645
+ const minor = Number(match[2]);
6646
+ return major > ADD_REPORTER_MIN.major || major === ADD_REPORTER_MIN.major && minor >= ADD_REPORTER_MIN.minor;
6612
6647
  }
6613
- function textMatcher(value, exact) {
6614
- if (typeof value !== "string") {
6615
- const re = value;
6616
- return { matcher: (text) => re.test(text.full), kind: "regex" };
6617
- }
6618
- const needle = normalizeWhiteSpace(value);
6619
- if (exact) return { matcher: (text) => text.normalized === needle, kind: "strict" };
6620
- const lower = needle.toLowerCase();
6621
- return { matcher: (text) => (text.lower ??= text.normalized.toLowerCase()).includes(lower), kind: "lax" };
6648
+ function configHasPiwiReporter(source) {
6649
+ return new RegExp(REPORTER_PACKAGE2.replace(/[/\\]/g, "\\$&")).test(source);
6622
6650
  }
6623
- function attributeMatcher(value, exact) {
6624
- if (typeof value !== "string") return (actual) => !!actual.match(value);
6625
- if (exact) return (actual) => actual === value;
6626
- const lower = value.toLowerCase();
6627
- return (actual) => actual.toLowerCase().includes(lower);
6651
+ function decideAddReporter(configSource, playwrightVersion) {
6652
+ if (configSource === null || configHasPiwiReporter(configSource)) return { args: [], log: null };
6653
+ if (playwrightSupportsAddReporter(playwrightVersion)) {
6654
+ return {
6655
+ args: [ADD_REPORTER_FLAG, REPORTER_PACKAGE2],
6656
+ log: `piwi run: the Playwright config has no Piwi reporter \u2014 appending ${ADD_REPORTER_FLAG} ${REPORTER_PACKAGE2}`
6657
+ };
6658
+ }
6659
+ return {
6660
+ args: [],
6661
+ log: `piwi run: the Playwright config has no Piwi reporter and Playwright ${playwrightVersion ?? "unknown"} predates ${ADD_REPORTER_FLAG} (1.63) \u2014 results will not reach the dashboard`
6662
+ };
6628
6663
  }
6629
- function nameMatcher(expected, exact) {
6630
- if (typeof expected !== "string") return (name) => !!name.match(expected);
6631
- const wanted = normalizeWhiteSpace(expected);
6632
- if (exact) return (name) => name === wanted;
6633
- const upper = wanted.toUpperCase();
6634
- return (name) => name.toUpperCase().includes(upper);
6664
+ function resolveConfigPath(cwd, playwrightArgs2) {
6665
+ for (let i = 0; i < playwrightArgs2.length; i++) {
6666
+ const arg = playwrightArgs2[i];
6667
+ if (arg === "--config" || arg === "-c") {
6668
+ const next = playwrightArgs2[i + 1];
6669
+ if (next) return path21.resolve(cwd, next);
6670
+ } else if (arg.startsWith("--config=")) {
6671
+ return path21.resolve(cwd, arg.slice("--config=".length));
6672
+ }
6673
+ }
6674
+ return detectProject(cwd).configPath;
6635
6675
  }
6636
- function textMatches(value, exact, text) {
6637
- return textMatcher(value, exact).matcher({ full: text, normalized: normalizeWhiteSpace(text) });
6676
+ function readConfigSource(configPath) {
6677
+ if (!configPath) return null;
6678
+ try {
6679
+ return fs16.readFileSync(configPath, "utf-8");
6680
+ } catch {
6681
+ return null;
6682
+ }
6638
6683
  }
6639
- function nameMatches(value, exact, name) {
6640
- return nameMatcher(value, exact)(normalizeWhiteSpace(name));
6684
+ function readInstalledPlaywrightVersion(cwd) {
6685
+ try {
6686
+ const require2 = (0, import_node_module2.createRequire)(path21.join(cwd, "noop.js"));
6687
+ for (const id of ["@playwright/test/package.json", "playwright/package.json"]) {
6688
+ try {
6689
+ return require2(id).version ?? null;
6690
+ } catch {
6691
+ }
6692
+ }
6693
+ } catch {
6694
+ }
6695
+ return null;
6641
6696
  }
6642
- function attributeMatches(value, exact, actual) {
6643
- return attributeMatcher(value, exact)(actual);
6697
+ function computeAddReporterArgs(cwd, playwrightArgs2) {
6698
+ const source = readConfigSource(resolveConfigPath(cwd, playwrightArgs2));
6699
+ return decideAddReporter(source, readInstalledPlaywrightVersion(cwd));
6644
6700
  }
6645
6701
 
6646
- // ../core/src/locator-break.ts
6647
- var TEXT_KINDS = /* @__PURE__ */ new Set(["text", "translation", "literal"]);
6648
- function isText(anchor) {
6649
- return TEXT_KINDS.has(anchor.kind);
6650
- }
6651
- function hasAttribute(anchor, names) {
6652
- return anchor.kind === "attribute" && !!anchor.attribute && names.includes(anchor.attribute);
6702
+ // src/cli/select.ts
6703
+ var EXIT_OK6 = 0;
6704
+ var EXIT_ERROR7 = 2;
6705
+ var USAGE9 = `
6706
+ piwi select / piwi run \u2014 run a saved selection of tests
6707
+
6708
+ Usage:
6709
+ npx @piwitests/reporter select <key> [options] print the Playwright args
6710
+ npx @piwitests/reporter run <key> [options] [-- <playwright args>]
6711
+ npx @piwitests/reporter run impact --base <ref> run the tests your diff impacts
6712
+
6713
+ Connection:
6714
+ --server-url <url> Dashboard URL (env PIWI_DASHBOARD_URL)
6715
+ --api-key <key> API key (env PIWI_API_KEY)
6716
+ --project <name|id> Project (env PIWI_PROJECT_NAME)
6717
+
6718
+ Selection:
6719
+ --format <fmt> args (file:line, default) | grep | files | json
6720
+ --budget <duration> Cap total time, e.g. 5m, 90s, 300000 (ms)
6721
+ --shard <i/n> Keep only shard i of n, balanced by duration, lock-aware
6722
+ --fail-fast Order the least-reliable tests first (fail-fast)
6723
+ --base <ref> For "impact": the ref to diff the working tree against
6724
+
6725
+ Behavior:
6726
+ --strict Fail (exit 2) instead of falling back when unreachable
6727
+ --pkg-runner <cmd> Package runner for the printed command (default npx)
6728
+ --json Print the full resolution as JSON (select only)
6729
+ -h, --help Show this help
6730
+
6731
+ Exit codes: 0 ok, 1 the test run failed (run only), 2 could not resolve.
6732
+ `.trim();
6733
+ var IMPACT_KEY = "impact";
6734
+ var NOTHING_TO_RUN_ARGS = ["--pass-with-no-tests", "--grep-invert", "."];
6735
+ var VALUE_FLAGS3 = /* @__PURE__ */ new Set([
6736
+ "--server-url",
6737
+ "--api-key",
6738
+ "--project",
6739
+ "--format",
6740
+ "--budget",
6741
+ "--shard",
6742
+ "--base",
6743
+ "--pkg-runner"
6744
+ ]);
6745
+ function readOption7(argv, name) {
6746
+ const withEquals = argv.find((arg) => arg.startsWith(`${name}=`));
6747
+ if (withEquals) return withEquals.slice(name.length + 1);
6748
+ const index = argv.indexOf(name);
6749
+ if (index === -1) return void 0;
6750
+ const value = argv[index + 1];
6751
+ return value && !value.startsWith("-") ? value : void 0;
6653
6752
  }
6654
- function toRegExp(arg) {
6655
- try {
6656
- return new RegExp(arg.source, arg.flags.replace(/[gy]/g, ""));
6657
- } catch {
6658
- return null;
6753
+ function findKey(argv) {
6754
+ for (let i = 0; i < argv.length; i++) {
6755
+ const tok = argv[i];
6756
+ if (tok.startsWith("-")) {
6757
+ if (!tok.includes("=") && VALUE_FLAGS3.has(tok)) i++;
6758
+ continue;
6759
+ }
6760
+ return tok;
6659
6761
  }
6762
+ return void 0;
6660
6763
  }
6661
- function optionsOf(arg) {
6662
- return new Map(arg?.type === "object" ? arg.entries : []);
6663
- }
6664
- function isExact(opts) {
6665
- const exact = opts.get("exact");
6666
- return exact?.type === "boolean" && exact.value;
6667
- }
6668
- function escapeRegExp2(s) {
6669
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6670
- }
6671
- function renameValue(value, before, after, exact) {
6672
- const v = normalizeWhiteSpace(value);
6673
- const b = normalizeWhiteSpace(before);
6674
- if (exact ? v === b : v.toLowerCase() === b.toLowerCase()) return after;
6675
- const re = new RegExp(escapeRegExp2(b).replace(/ /g, "\\s+"), exact ? "" : "i");
6676
- if (!re.test(value)) return null;
6677
- return value.replace(re, () => after);
6678
- }
6679
- function withArg(call, index, arg) {
6680
- return { method: call.method, args: call.args.map((a, i) => i === index ? arg : a) };
6681
- }
6682
- function withOption(call, index, key, arg) {
6683
- const obj = call.args[index];
6684
- if (obj?.type !== "object") return call;
6685
- return withArg(call, index, {
6686
- type: "object",
6687
- entries: obj.entries.map(([k, v]) => [k, k === key ? arg : v])
6688
- });
6764
+ function parseDuration2(raw) {
6765
+ const match = raw.trim().match(/^(\d+(?:\.\d+)?)(ms|s|m|h)?$/);
6766
+ if (!match) return null;
6767
+ const value = Number(match[1]);
6768
+ const unit = match[2];
6769
+ const factor = unit === "h" ? 36e5 : unit === "m" ? 6e4 : unit === "s" ? 1e3 : 1;
6770
+ const ms = Math.round(value * factor);
6771
+ return ms > 0 ? ms : null;
6689
6772
  }
6690
- function stringSlot(arg, exact, rule, accepts, replace) {
6691
- if (arg?.type === "regex") {
6692
- const re = toRegExp(arg);
6693
- return re ? { value: re, exact, rule, accepts } : null;
6773
+ function parseSelectArgs(argv, env) {
6774
+ const dashIndex = argv.indexOf("--");
6775
+ const own = dashIndex === -1 ? argv : argv.slice(0, dashIndex);
6776
+ const extra = dashIndex === -1 ? [] : argv.slice(dashIndex + 1);
6777
+ const serverUrl = (readOption7(own, "--server-url") ?? env.PIWI_DASHBOARD_URL ?? "").replace(/\/$/, "");
6778
+ if (!serverUrl) throw new Error("No dashboard URL \u2014 pass --server-url or set PIWI_DASHBOARD_URL");
6779
+ const key = findKey(own);
6780
+ if (!key) throw new Error("No selection key \u2014 usage: piwi select <key>");
6781
+ const budgetRaw = readOption7(own, "--budget");
6782
+ let budgetMs = null;
6783
+ if (budgetRaw !== void 0) {
6784
+ budgetMs = parseDuration2(budgetRaw);
6785
+ if (budgetMs === null) throw new Error(`--budget expects a duration like 5m or 90s, got "${budgetRaw}"`);
6786
+ }
6787
+ const shard = readOption7(own, "--shard") ?? null;
6788
+ if (shard !== null && !/^\d+\s*\/\s*\d+$/.test(shard)) {
6789
+ throw new Error(`--shard expects an "i/n" spec like 2/4, got "${shard}"`);
6694
6790
  }
6695
- if (arg?.type !== "string") return null;
6696
- const value = arg.value;
6697
6791
  return {
6698
- value,
6699
- exact,
6700
- rule,
6701
- accepts,
6702
- rewrite: (after, before) => replace({ type: "string", value: renameValue(value, before, after, exact) ?? value }),
6703
- literal: (after, before) => [value, renameValue(value, before, after, exact) ?? value]
6792
+ serverUrl,
6793
+ apiKey: readOption7(own, "--api-key") ?? env.PIWI_API_KEY ?? null,
6794
+ project: readOption7(own, "--project") ?? env.PIWI_PROJECT_NAME ?? "",
6795
+ key,
6796
+ format: readOption7(own, "--format") ?? "args",
6797
+ budgetMs,
6798
+ shard,
6799
+ order: own.includes("--fail-fast") ? "failureLikelihood" : null,
6800
+ base: readOption7(own, "--base") ?? null,
6801
+ strict: own.includes("--strict"),
6802
+ pkgRunner: readOption7(own, "--pkg-runner") ?? "npx",
6803
+ json: own.includes("--json"),
6804
+ extra
6704
6805
  };
6705
6806
  }
6706
- function cssSlots(selector, testIds, replace) {
6707
- const slots = [];
6708
- const slot = (value, names, render, at, len) => {
6709
- const edited = (after) => selector.slice(0, at) + render(after) + selector.slice(at + len);
6710
- slots.push({
6711
- value,
6712
- exact: true,
6713
- rule: "exact",
6714
- accepts: (a) => hasAttribute(a, names),
6715
- rewrite: (after) => replace(edited(after)),
6716
- literal: (after) => [selector, edited(after)]
6717
- });
6718
- };
6719
- for (const m of selector.matchAll(/#((?:[\w-]|\\.)+)/g)) {
6720
- slot(
6721
- m[1].replace(/\\(.)/g, "$1"),
6722
- ["id"],
6723
- (v) => `#${v.replace(/[^\w-]/g, (c) => `\\${c}`)}`,
6724
- m.index,
6725
- m[0].length
6726
- );
6727
- }
6728
- for (const m of selector.matchAll(/\[\s*([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\]\s]+))\s*\]/g)) {
6729
- const attr = m[1];
6730
- const names = testIds.includes(attr) ? testIds : attr === "id" || attr === "name" ? [attr] : null;
6731
- if (!names) continue;
6732
- const quote3 = m[2] !== void 0 ? '"' : m[3] !== void 0 ? "'" : "";
6733
- const value = m[2] ?? m[3] ?? m[4];
6734
- slot(value, [attr], (v) => `[${attr}=${quote3}${v}${quote3}]`, m.index, m[0].length);
6735
- }
6736
- return slots;
6807
+ var CACHE_FILE = path22.join(".piwi", "selection-cache.json");
6808
+ function cacheKey(args) {
6809
+ return `${args.project.toLowerCase()}:${args.key}:${args.format}:${args.budgetMs ?? 0}:${args.shard ?? ""}:${args.order ?? ""}`;
6737
6810
  }
6738
- function directSlots(call, testIds) {
6739
- const first = call.args[0];
6740
- const opts = optionsOf(call.args[1]);
6741
- const exact = isExact(opts);
6742
- const slots = [];
6743
- const argSlot = (rule, accepts, ex = exact) => stringSlot(first, ex, rule, accepts, (next) => withArg(call, 0, next));
6744
- const hasTextSlot = (index) => {
6745
- const hasText = optionsOf(call.args[index]).get("hasText");
6746
- if (!hasText) return null;
6747
- return stringSlot(hasText, false, "text", isText, (next) => withOption(call, index, "hasText", next));
6748
- };
6749
- const textOr = (attribute) => (a) => a.kind === "translation" || a.kind === "literal" || hasAttribute(a, [attribute]);
6750
- switch (call.method) {
6751
- case "getByTestId":
6752
- slots.push(argSlot("exact", (a) => hasAttribute(a, testIds), true));
6753
- break;
6754
- case "getByRole":
6755
- slots.push(
6756
- stringSlot(
6757
- opts.get("name"),
6758
- exact,
6759
- "name",
6760
- (a) => isText(a) || hasAttribute(a, ["aria-label", "title", "alt", "value"]),
6761
- (next) => withOption(call, 1, "name", next)
6762
- )
6763
- );
6764
- break;
6765
- case "getByText":
6766
- slots.push(argSlot("text", isText));
6767
- break;
6768
- case "getByLabel":
6769
- slots.push(argSlot("text", (a) => isText(a) || hasAttribute(a, ["aria-label"])));
6770
- break;
6771
- case "getByPlaceholder":
6772
- slots.push(argSlot("text", textOr("placeholder")));
6773
- break;
6774
- case "getByAltText":
6775
- slots.push(argSlot("text", textOr("alt")));
6776
- break;
6777
- case "getByTitle":
6778
- slots.push(argSlot("text", textOr("title")));
6779
- break;
6780
- case "locator":
6781
- if (first?.type === "string") {
6782
- slots.push(...cssSlots(first.value, testIds, (next) => withArg(call, 0, { type: "string", value: next })));
6783
- }
6784
- slots.push(hasTextSlot(1));
6785
- break;
6786
- case "filter":
6787
- slots.push(hasTextSlot(0));
6788
- break;
6811
+ function readCache(args) {
6812
+ try {
6813
+ const store = JSON.parse(fs17.readFileSync(CACHE_FILE, "utf-8"));
6814
+ return store[cacheKey(args)] ?? null;
6815
+ } catch {
6816
+ return null;
6789
6817
  }
6790
- return slots.filter((slot) => !!slot);
6791
6818
  }
6792
- function nestedChains(call) {
6793
- const out = [];
6794
- call.args.forEach((arg, index) => {
6795
- if (arg.type === "chain")
6796
- out.push({ chain: arg.chain, put: (c) => withArg(call, index, { type: "chain", chain: c }) });
6797
- if (arg.type === "object") {
6798
- for (const [key, v] of arg.entries) {
6799
- if (v.type === "chain") {
6800
- out.push({ chain: v.chain, put: (c) => withOption(call, index, key, { type: "chain", chain: c }) });
6801
- }
6802
- }
6819
+ function writeCache(args, resolution) {
6820
+ try {
6821
+ let store = {};
6822
+ try {
6823
+ store = JSON.parse(fs17.readFileSync(CACHE_FILE, "utf-8"));
6824
+ } catch {
6803
6825
  }
6804
- });
6805
- return out;
6826
+ store[cacheKey(args)] = resolution;
6827
+ fs17.mkdirSync(path22.dirname(CACHE_FILE), { recursive: true });
6828
+ fs17.writeFileSync(CACHE_FILE, JSON.stringify(store, null, 2));
6829
+ } catch {
6830
+ }
6806
6831
  }
6807
- function chainSlots(chain, testIds) {
6808
- const out = [];
6809
- chain.calls.forEach((call, i) => {
6810
- const replaceAt = (next) => ({ calls: chain.calls.map((c, j) => j === i ? next : c) });
6811
- for (const slot of directSlots(call, testIds)) {
6812
- const rewrite = slot.rewrite;
6813
- out.push({ ...slot, rewrite: rewrite && ((after, before) => replaceAt(rewrite(after, before))) });
6814
- }
6815
- for (const n of nestedChains(call)) {
6816
- for (const slot of chainSlots(n.chain, testIds)) {
6817
- const rewrite = slot.rewrite;
6818
- out.push({ ...slot, rewrite: rewrite && ((after, before) => replaceAt(n.put(rewrite(after, before)))) });
6819
- }
6832
+ async function resolveWithCache(args) {
6833
+ try {
6834
+ const projectId = await resolveProjectId(args);
6835
+ const resolution = await fetchResolution(args, projectId);
6836
+ writeCache(args, resolution);
6837
+ return { resolution, fromCache: false };
6838
+ } catch (e) {
6839
+ if (args.strict) throw e;
6840
+ const cached = readCache(args);
6841
+ if (cached) {
6842
+ console.error(`piwi: dashboard unreachable, using cached resolution \u2014 ${e.message}`);
6843
+ return { resolution: cached, fromCache: true };
6820
6844
  }
6845
+ return { error: e };
6846
+ }
6847
+ }
6848
+ function printWarnings(resolution) {
6849
+ for (const w of resolution.warnings) console.error(`piwi: warning [${w.code}] ${w.message}`);
6850
+ }
6851
+ function git2(args) {
6852
+ return (0, import_node_child_process7.execFileSync)("git", ["-c", "core.quotePath=false", ...args], {
6853
+ encoding: "utf-8",
6854
+ stdio: ["ignore", "pipe", "ignore"],
6855
+ maxBuffer: 256 * 1024 * 1024
6821
6856
  });
6822
- return out;
6823
6857
  }
6824
- function slotMatches(slot, text) {
6825
- switch (slot.rule) {
6826
- case "exact":
6827
- return attributeMatches(slot.value, true, text);
6828
- case "name":
6829
- return nameMatches(slot.value, slot.exact, text);
6830
- case "text":
6831
- return textMatches(slot.value, slot.exact, text);
6858
+ function gitChange(base) {
6859
+ const names = git2(["diff", "--name-only", base]).split("\n").map((line) => line.trim()).filter(Boolean);
6860
+ let fromDiff = { changedFiles: [], changes: [] };
6861
+ try {
6862
+ fromDiff = diffImpactInput(
6863
+ parseUnifiedDiff(
6864
+ git2([
6865
+ "diff",
6866
+ "--full-index",
6867
+ "--unified=0",
6868
+ "--no-color",
6869
+ "--no-ext-diff",
6870
+ "--src-prefix=a/",
6871
+ "--dst-prefix=b/",
6872
+ base,
6873
+ "--"
6874
+ ])
6875
+ )
6876
+ );
6877
+ } catch {
6832
6878
  }
6879
+ return { changedFiles: [.../* @__PURE__ */ new Set([...names, ...fromDiff.changedFiles])], changes: fromDiff.changes };
6833
6880
  }
6834
- var filePath = (path24) => path24.replace(/\\/g, "/").replace(/^\.\//, "");
6835
- function sameFilePath(a, b) {
6836
- const x = filePath(a);
6837
- const y = filePath(b);
6838
- return x === y || x.endsWith("/" + y) || y.endsWith("/" + x);
6881
+ async function loadImpact(args, projectId) {
6882
+ const { changedFiles, changes } = gitChange(args.base);
6883
+ return fetchImpact(args, projectId, changedFiles, changes);
6839
6884
  }
6840
- function reachOfIndex(index) {
6841
- if (!index.reach.some((r) => r.origin === "client")) return void 0;
6842
- const filesOf = /* @__PURE__ */ new Map();
6843
- for (const r of index.reach) {
6844
- const file = index.files[r.file];
6845
- if (file === void 0) continue;
6846
- for (const t of r.tests) {
6847
- const id = index.tests[t]?.id;
6848
- if (id === void 0) continue;
6849
- const files = filesOf.get(id);
6850
- if (files) files.push(file);
6851
- else filesOf.set(id, [file]);
6885
+ function lineLevelNote(impact) {
6886
+ const files = impact.impact.lineLevelFiles ?? 0;
6887
+ return files > 0 ? `, ${files} file(s) narrowed by line coverage` : "";
6888
+ }
6889
+ async function runSelectImpact(args) {
6890
+ let impact;
6891
+ try {
6892
+ const projectId = await resolveProjectId(args);
6893
+ impact = await loadImpact(args, projectId);
6894
+ } catch (e) {
6895
+ console.error(`piwi select: ${e.message}`);
6896
+ return EXIT_ERROR7;
6897
+ }
6898
+ printWarnings(impact);
6899
+ console.error(
6900
+ `piwi select: ${impact.impact.changedFiles} changed file(s) \u2192 ${impact.estimate.count} impacted test(s)${impact.impact.widened ? " (widened to full suite)" : lineLevelNote(impact)}`
6901
+ );
6902
+ if (args.json) {
6903
+ console.log(JSON.stringify(impact, null, 2));
6904
+ return EXIT_OK6;
6905
+ }
6906
+ if (impact.impact.widened) console.log("");
6907
+ else if (impact.estimate.count === 0) {
6908
+ console.error(`piwi select: no tests impacted \u2014 printing arguments that run none`);
6909
+ console.log(NOTHING_TO_RUN_ARGS.join(" "));
6910
+ } else console.log(impact.materialization.args.join(" "));
6911
+ return EXIT_OK6;
6912
+ }
6913
+ async function runSelect(argv, env = process.env) {
6914
+ if (argv.includes("-h") || argv.includes("--help")) {
6915
+ console.log(USAGE9);
6916
+ return EXIT_OK6;
6917
+ }
6918
+ let args;
6919
+ try {
6920
+ args = parseSelectArgs(argv, env);
6921
+ } catch (e) {
6922
+ console.error(`piwi select: ${e.message}
6923
+ `);
6924
+ console.error(USAGE9);
6925
+ return EXIT_ERROR7;
6926
+ }
6927
+ if (args.key === IMPACT_KEY) {
6928
+ if (!args.base) {
6929
+ console.error("piwi select: impact needs a base ref \u2014 pass --base <ref> (e.g. --base origin/main)");
6930
+ return EXIT_ERROR7;
6852
6931
  }
6932
+ return runSelectImpact(args);
6853
6933
  }
6854
- return (testId, file) => (filesOf.get(testId) ?? []).some((reached) => sameFilePath(reached, file));
6934
+ let resolution;
6935
+ try {
6936
+ const projectId = await resolveProjectId(args);
6937
+ resolution = await fetchResolution(args, projectId);
6938
+ } catch (e) {
6939
+ console.error(`piwi select: ${e.message}`);
6940
+ return EXIT_ERROR7;
6941
+ }
6942
+ printWarnings(resolution);
6943
+ if (resolution.estimate.count === 0) {
6944
+ console.error(`piwi select: "${args.key}" resolved to 0 tests`);
6945
+ return EXIT_ERROR7;
6946
+ }
6947
+ if (args.json) console.log(JSON.stringify(resolution, null, 2));
6948
+ else console.log(resolution.materialization.args.join(" "));
6949
+ return EXIT_OK6;
6855
6950
  }
6856
- function callSiteFile(callSite) {
6857
- return callSite.replace(/:\d+(?::\d+)?$/, "");
6951
+ function spawnPlaywrightForRun(pkgRunner, playwrightArgs2, env) {
6952
+ const decision = computeAddReporterArgs(process.cwd(), playwrightArgs2);
6953
+ if (decision.log) console.error(decision.log);
6954
+ return spawnPlaywright([...decision.args, ...playwrightArgs2], env, { runner: pkgRunner, command: "piwi run" });
6858
6955
  }
6859
- function callSiteLine(callSite) {
6860
- const m = /:(\d+)(?::\d+)?$/.exec(callSite);
6861
- return m ? Number(m[1]) : null;
6956
+ function shardEnv(args) {
6957
+ return args.shard ? { [PIWI_SHARD_ENV]: args.shard } : {};
6862
6958
  }
6863
- function locatorCallSiteFiles(index) {
6864
- const files = /* @__PURE__ */ new Set();
6865
- for (const entry of index.locators) {
6866
- for (const use of entry.uses) for (const site of use.callSites) files.add(callSiteFile(site));
6959
+ async function runRunImpact(args, env) {
6960
+ let projectId;
6961
+ try {
6962
+ projectId = await resolveProjectId(args);
6963
+ } catch (e) {
6964
+ if (args.strict) {
6965
+ console.error(`piwi run: ${e.message}`);
6966
+ return EXIT_ERROR7;
6967
+ }
6968
+ console.error(`piwi run: ${e.message} \u2014 running the full suite`);
6969
+ return spawnPlaywrightForRun(args.pkgRunner, args.extra, env);
6867
6970
  }
6868
- return files;
6971
+ let impact;
6972
+ try {
6973
+ impact = await loadImpact(args, projectId);
6974
+ } catch (e) {
6975
+ if (args.strict) {
6976
+ console.error(`piwi run: ${e.message}`);
6977
+ return EXIT_ERROR7;
6978
+ }
6979
+ console.error(`piwi run: ${e.message} \u2014 running the full suite`);
6980
+ return spawnPlaywrightForRun(args.pkgRunner, args.extra, env);
6981
+ }
6982
+ printWarnings(impact);
6983
+ if (impact.impact.widened) {
6984
+ console.error(
6985
+ `piwi run: impact widened to the full suite (${impact.impact.unmappedSourceFiles.length} changed file(s) not fully mapped to tests)`
6986
+ );
6987
+ return spawnPlaywrightForRun(args.pkgRunner, args.extra, env);
6988
+ }
6989
+ if (impact.estimate.count === 0) {
6990
+ console.error(`piwi run: no tests impacted by ${impact.impact.changedFiles} changed file(s) \u2014 nothing to run`);
6991
+ return EXIT_OK6;
6992
+ }
6993
+ const runEnv = {
6994
+ ...env,
6995
+ PIWI_SELECTION: IMPACT_KEY,
6996
+ PIWI_SELECTION_VERSION: "0",
6997
+ PIWI_SELECTION_HASH: impact.resolvedHash,
6998
+ PIWI_SELECTION_COUNT: String(impact.estimate.count),
6999
+ ...shardEnv(args)
7000
+ };
7001
+ console.error(`piwi run: impact \u2192 ${impact.estimate.count} test(s)${lineLevelNote(impact)}`);
7002
+ return spawnPlaywrightForRun(args.pkgRunner, [...impact.materialization.args, ...args.extra], runEnv);
6869
7003
  }
6870
- var CONFIDENCE_RANK = { likely: 0, possible: 1 };
6871
- function predictLocatorBreaks(anchors, index, options = {}) {
6872
- const testIds = options.testIdAttributes ?? index.testIdAttributes ?? ["data-testid"];
6873
- const siteFiles = [...locatorCallSiteFiles(index)];
6874
- const live = anchors.filter((a) => !siteFiles.some((f) => sameFilePath(f, a.file)));
6875
- if (!live.length) return [];
6876
- const breaks = [];
6877
- for (const entry of index.locators) {
6878
- const chain = tryParseLocatorChain(entry.locator);
6879
- if (!chain) continue;
6880
- const slots = chainSlots(chain, testIds);
6881
- if (!slots.length) continue;
6882
- for (const anchor of live) {
6883
- const hit = slots.filter(
6884
- (slot) => slot.accepts(anchor) && slotMatches(slot, anchor.before) && (anchor.after === void 0 || !slotMatches(slot, anchor.after))
6885
- );
6886
- if (!hit.length) continue;
6887
- const tests = [...new Set(entry.uses.map((u) => u.test))].map((t) => index.tests[t]).filter((t) => !!t);
6888
- const found = {
6889
- anchor,
6890
- locator: entry.locator,
6891
- entry,
6892
- uses: entry.uses,
6893
- tests,
6894
- confidence: confidenceOf(anchor, tests, options.reach)
6895
- };
6896
- if (anchor.after !== void 0 && hit.every((slot) => slot.rewrite)) {
6897
- let rewritten = chain;
6898
- const replacements = [];
6899
- for (const slot of hit) {
6900
- const again = chainSlots(rewritten, testIds).find(
6901
- (s) => s.rule === slot.rule && String(s.value) === String(slot.value) && s.rewrite
6902
- );
6903
- if (!again?.rewrite) continue;
6904
- rewritten = again.rewrite(anchor.after, anchor.before);
6905
- if (slot.literal) replacements.push(slot.literal(anchor.after, anchor.before));
6906
- }
6907
- const rendered = renderLocatorChain(rewritten);
6908
- if (rendered !== entry.locator) {
6909
- found.rewrite = rendered;
6910
- found.replacements = replacements.filter(([a, b]) => a !== b);
6911
- }
6912
- }
6913
- breaks.push(found);
7004
+ async function runRun(argv, env = process.env) {
7005
+ if (argv.includes("-h") || argv.includes("--help")) {
7006
+ console.log(USAGE9);
7007
+ return EXIT_OK6;
7008
+ }
7009
+ let args;
7010
+ try {
7011
+ args = parseSelectArgs(argv, env);
7012
+ } catch (e) {
7013
+ console.error(`piwi run: ${e.message}
7014
+ `);
7015
+ console.error(USAGE9);
7016
+ return EXIT_ERROR7;
7017
+ }
7018
+ if (args.format === "json") {
7019
+ console.error("piwi run: --format json cannot be run; use args, grep or files");
7020
+ return EXIT_ERROR7;
7021
+ }
7022
+ if (args.key === IMPACT_KEY) {
7023
+ if (!args.base) {
7024
+ console.error("piwi run: impact needs a base ref \u2014 pass --base <ref> (e.g. --base origin/main)");
7025
+ return EXIT_ERROR7;
6914
7026
  }
7027
+ return runRunImpact(args, env);
6915
7028
  }
6916
- return breaks.sort(
6917
- (a, b) => CONFIDENCE_RANK[a.confidence] - CONFIDENCE_RANK[b.confidence] || b.tests.length - a.tests.length
7029
+ let outcome;
7030
+ try {
7031
+ outcome = await resolveWithCache(args);
7032
+ } catch (e) {
7033
+ console.error(`piwi run: ${e.message}`);
7034
+ return EXIT_ERROR7;
7035
+ }
7036
+ if ("error" in outcome) {
7037
+ console.error(`piwi run: ${outcome.error.message} \u2014 no cached resolution, running the full suite`);
7038
+ return spawnPlaywrightForRun(args.pkgRunner, args.extra, env);
7039
+ }
7040
+ const { resolution } = outcome;
7041
+ printWarnings(resolution);
7042
+ if (resolution.estimate.count === 0) {
7043
+ console.error(`piwi run: "${args.key}" resolved to 0 tests`);
7044
+ return EXIT_ERROR7;
7045
+ }
7046
+ const runEnv = {
7047
+ ...env,
7048
+ PIWI_SELECTION: resolution.key ?? args.key,
7049
+ PIWI_SELECTION_VERSION: String(resolution.version ?? 0),
7050
+ PIWI_SELECTION_HASH: resolution.resolvedHash,
7051
+ PIWI_SELECTION_COUNT: String(resolution.estimate.count),
7052
+ ...shardEnv(args)
7053
+ };
7054
+ console.error(
7055
+ `piwi run: ${args.key} \u2192 ${resolution.estimate.count} tests${resolution.materialization.format !== args.format ? ` (materialized as ${resolution.materialization.format})` : ""}`
6918
7056
  );
7057
+ return spawnPlaywrightForRun(args.pkgRunner, [...resolution.materialization.args, ...args.extra], runEnv);
6919
7058
  }
6920
- function confidenceOf(anchor, tests, reach) {
6921
- if (anchor.kind === "literal") return "possible";
6922
- if (!reach) return "likely";
6923
- if (anchor.kind === "translation" && /\.(?:json|ya?ml|properties|po|resx)$/i.test(anchor.file)) return "likely";
6924
- return tests.some((t) => reach(t.id, anchor.file)) ? "likely" : "possible";
6925
- }
7059
+
7060
+ // src/cli/preflight.ts
7061
+ var fs18 = __toESM(require("fs"));
7062
+ var path23 = __toESM(require("path"));
7063
+ var import_node_child_process8 = require("child_process");
6926
7064
 
6927
7065
  // ../core/src/locator-edit.ts
6928
7066
  function escapeForQuote(value, quote3) {
@@ -7046,15 +7184,15 @@ function parsePreflightArgs(argv) {
7046
7184
  }
7047
7185
  var CONFIG_NAMES2 = ["playwright.config.ts", "playwright.config.js", "playwright.config.mjs", "playwright.config.cjs"];
7048
7186
  function findPlaywrightRoot(from) {
7049
- let dir = path22.resolve(from);
7187
+ let dir = path23.resolve(from);
7050
7188
  for (; ; ) {
7051
- if (CONFIG_NAMES2.some((name) => fs18.existsSync(path22.join(dir, name)))) return dir;
7052
- const parent = path22.dirname(dir);
7189
+ if (CONFIG_NAMES2.some((name) => fs18.existsSync(path23.join(dir, name)))) return dir;
7190
+ const parent = path23.dirname(dir);
7053
7191
  if (parent === dir) return null;
7054
7192
  dir = parent;
7055
7193
  }
7056
7194
  }
7057
- function git2(cwd, args) {
7195
+ function git3(cwd, args) {
7058
7196
  return (0, import_node_child_process8.execFileSync)("git", args, {
7059
7197
  cwd,
7060
7198
  encoding: "utf-8",
@@ -7064,7 +7202,7 @@ function git2(cwd, args) {
7064
7202
  }
7065
7203
  function tryGit(cwd, args) {
7066
7204
  try {
7067
- return git2(cwd, args);
7205
+ return git3(cwd, args);
7068
7206
  } catch {
7069
7207
  return void 0;
7070
7208
  }
@@ -7079,7 +7217,7 @@ function translationLookup(repoRoot, base, locale) {
7079
7217
  const build = (side) => {
7080
7218
  const map = /* @__PURE__ */ new Map();
7081
7219
  for (const file of files) {
7082
- const content = side === "new" ? readText(path22.join(repoRoot, file)) : tryGit(repoRoot, ["show", `${base}:${file}`]);
7220
+ const content = side === "new" ? readText(path23.join(repoRoot, file)) : tryGit(repoRoot, ["show", `${base}:${file}`]);
7083
7221
  if (content === void 0) continue;
7084
7222
  for (const entry of parseTranslationFile(file, content)) {
7085
7223
  if (!map.has(entry.key)) map.set(entry.key, entry.value);
@@ -7103,20 +7241,20 @@ function readText(file) {
7103
7241
  return void 0;
7104
7242
  }
7105
7243
  }
7106
- var CACHE_FILE2 = path22.join(".piwi", "locator-index.json");
7244
+ var CACHE_FILE2 = path23.join(".piwi", "locator-index.json");
7107
7245
  function cacheKey2(connection, branch) {
7108
7246
  return `${connection.serverUrl}|${connection.project}|${branch ?? ""}`;
7109
7247
  }
7110
7248
  function readCache2(root, key) {
7111
7249
  try {
7112
- const store = JSON.parse(fs18.readFileSync(path22.join(root, CACHE_FILE2), "utf-8"));
7250
+ const store = JSON.parse(fs18.readFileSync(path23.join(root, CACHE_FILE2), "utf-8"));
7113
7251
  return store[key] ?? null;
7114
7252
  } catch {
7115
7253
  return null;
7116
7254
  }
7117
7255
  }
7118
7256
  function writeCache2(root, key, index) {
7119
- const file = path22.join(root, CACHE_FILE2);
7257
+ const file = path23.join(root, CACHE_FILE2);
7120
7258
  try {
7121
7259
  let store = {};
7122
7260
  try {
@@ -7124,7 +7262,7 @@ function writeCache2(root, key, index) {
7124
7262
  } catch {
7125
7263
  }
7126
7264
  store[key] = { savedAt: (/* @__PURE__ */ new Date()).toISOString(), index };
7127
- fs18.mkdirSync(path22.dirname(file), { recursive: true });
7265
+ fs18.mkdirSync(path23.dirname(file), { recursive: true });
7128
7266
  fs18.writeFileSync(file, JSON.stringify(store));
7129
7267
  } catch {
7130
7268
  }
@@ -7141,7 +7279,7 @@ function planSites(b, testRoot) {
7141
7279
  const sites = [...new Set(b.uses.flatMap((u) => u.callSites))];
7142
7280
  return sites.map((callSite) => {
7143
7281
  if (!b.rewrite || !b.replacements?.length) return { callSite, action: "none" };
7144
- const file = path22.resolve(testRoot, callSiteFile(callSite));
7282
+ const file = path23.resolve(testRoot, callSiteFile(callSite));
7145
7283
  const lineNo = callSiteLine(callSite);
7146
7284
  const text = readText(file);
7147
7285
  if (text === void 0 || lineNo === null) return { callSite, action: "elsewhere" };
@@ -7305,10 +7443,22 @@ async function runPreflight(argv, env = process.env, cwd = process.cwd()) {
7305
7443
  console.error("piwi preflight: not inside a git repository");
7306
7444
  return EXIT_ERROR8;
7307
7445
  }
7308
- const testRoot = path22.resolve(cwd, args.testRoot ?? findPlaywrightRoot(cwd) ?? cwd);
7446
+ const testRoot = path23.resolve(cwd, args.testRoot ?? findPlaywrightRoot(cwd) ?? cwd);
7309
7447
  let files;
7310
7448
  try {
7311
- files = parseUnifiedDiff(git2(repoRoot, ["diff", "--unified=0", "--no-color", "--no-ext-diff", args.base, "--"]));
7449
+ files = parseUnifiedDiff(
7450
+ git3(repoRoot, [
7451
+ "diff",
7452
+ "--full-index",
7453
+ "--unified=0",
7454
+ "--no-color",
7455
+ "--no-ext-diff",
7456
+ "--src-prefix=a/",
7457
+ "--dst-prefix=b/",
7458
+ args.base,
7459
+ "--"
7460
+ ])
7461
+ );
7312
7462
  } catch (e) {
7313
7463
  console.error(`piwi preflight: could not diff against ${args.base} \u2014 ${e.message.split("\n")[0]}`);
7314
7464
  return EXIT_ERROR8;
@@ -7342,7 +7492,7 @@ async function runPreflight(argv, env = process.env, cwd = process.cwd()) {
7342
7492
  testIdAttributes: index.testIdAttributes ?? void 0,
7343
7493
  isTestFile: (file) => TEST_FILE.test(file),
7344
7494
  translations: translationLookup(repoRoot, args.base, args.locale),
7345
- readFile: (file, side) => side === "new" ? readText(path22.join(repoRoot, file)) : tryGit(repoRoot, ["show", `${args.base}:${file}`])
7495
+ readFile: (file, side) => side === "new" ? readText(path23.join(repoRoot, file)) : tryGit(repoRoot, ["show", `${args.base}:${file}`])
7346
7496
  });
7347
7497
  const found = predictLocatorBreaks(anchors, index, { reach: await loadReach(connection, projectId, args.branch) });
7348
7498
  const replacementsOf = /* @__PURE__ */ new Map();
@@ -7360,14 +7510,15 @@ async function runPreflight(argv, env = process.env, cwd = process.cwd()) {
7360
7510
  return pb;
7361
7511
  });
7362
7512
  let impact = null;
7363
- const changed = files.map((f) => f.path);
7513
+ const { changedFiles: changed, changes } = diffImpactInput(files);
7364
7514
  const breakSpecs = [...new Set(breaks.flatMap((b) => b.tests.map((t) => t.file)))];
7365
7515
  if (projectId !== null && (changed.length || args.run && breakSpecs.length)) {
7366
7516
  try {
7367
7517
  impact = await fetchImpact(
7368
7518
  { ...connection, key: "impact", format: "args", pkgRunner: args.pkgRunner },
7369
7519
  projectId,
7370
- args.run ? [.../* @__PURE__ */ new Set([...changed, ...breakSpecs])] : changed
7520
+ args.run ? [.../* @__PURE__ */ new Set([...changed, ...breakSpecs])] : changed,
7521
+ changes
7371
7522
  );
7372
7523
  } catch (e) {
7373
7524
  console.error(`piwi preflight: could not ask which tests reach the changed files \u2014 ${e.message}`);
@@ -7387,8 +7538,8 @@ async function runPreflight(argv, env = process.env, cwd = process.cwd()) {
7387
7538
  if (args.json) console.log(JSON.stringify(result, null, 2));
7388
7539
  else {
7389
7540
  const rel = (file) => {
7390
- const abs = path22.isAbsolute(file) ? file : path22.resolve(testRoot, file);
7391
- const r = path22.relative(cwd, abs).split(path22.sep).join("/");
7541
+ const abs = path23.isAbsolute(file) ? file : path23.resolve(testRoot, file);
7542
+ const r = path23.relative(cwd, abs).split(path23.sep).join("/");
7392
7543
  return r.startsWith("..") ? file : r || file;
7393
7544
  };
7394
7545
  console.log(renderPreflight(result, rel));
@@ -7420,7 +7571,7 @@ async function runPreflight(argv, env = process.env, cwd = process.cwd()) {
7420
7571
  // src/cli/probe.ts
7421
7572
  var fs19 = __toESM(require("fs"));
7422
7573
  var os3 = __toESM(require("os"));
7423
- var path23 = __toESM(require("path"));
7574
+ var path24 = __toESM(require("path"));
7424
7575
  var EXIT_OK8 = 0;
7425
7576
  var EXIT_ERROR9 = 2;
7426
7577
  var USAGE11 = `
@@ -7484,10 +7635,10 @@ async function runProbe(argv) {
7484
7635
  return EXIT_OK8;
7485
7636
  }
7486
7637
  console.error(`piwi probe: ${plan.items.length} pair(s) to probe (budget ${plan.budget}).`);
7487
- const dir = fs19.mkdtempSync(path23.join(os3.tmpdir(), "piwi-probe-"));
7488
- const planFile = path23.join(dir, "plan.json");
7489
- const resultsFile = path23.join(dir, "results.jsonl");
7490
- const runOutputFile = path23.join(dir, "run.json");
7638
+ const dir = fs19.mkdtempSync(path24.join(os3.tmpdir(), "piwi-probe-"));
7639
+ const planFile = path24.join(dir, "plan.json");
7640
+ const resultsFile = path24.join(dir, "results.jsonl");
7641
+ const runOutputFile = path24.join(dir, "run.json");
7491
7642
  fs19.writeFileSync(planFile, JSON.stringify(plan));
7492
7643
  fs19.writeFileSync(resultsFile, "");
7493
7644
  const grep = plan.items.map((i) => i.testTitle).filter(Boolean).map(escapeRegex2).join("|");