@plune-ai/cli 0.12.0 → 0.14.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.cjs CHANGED
@@ -512,7 +512,7 @@ var init_retry = __esm({
512
512
  "use strict";
513
513
  init_cjs_shims();
514
514
  init_errors2();
515
- defaultSleep = (ms) => new Promise((resolve7) => setTimeout(resolve7, ms));
515
+ defaultSleep = (ms) => new Promise((resolve8) => setTimeout(resolve8, ms));
516
516
  }
517
517
  });
518
518
 
@@ -2159,6 +2159,8 @@ function saveToken(token) {
2159
2159
  return file;
2160
2160
  }
2161
2161
  function loadToken() {
2162
+ const fromEnv = process.env["PLUNE_TOKEN"]?.trim();
2163
+ if (fromEnv !== void 0 && fromEnv !== "") return fromEnv;
2162
2164
  try {
2163
2165
  const parsed = JSON.parse((0, import_node_fs.readFileSync)(credentialsFile(), "utf-8"));
2164
2166
  return typeof parsed.token === "string" && parsed.token.length > 0 ? parsed.token : null;
@@ -2184,6 +2186,35 @@ var init_credentials = __esm({
2184
2186
  });
2185
2187
 
2186
2188
  // src/reporter-core/client.ts
2189
+ function nodeTransport(url, init, idleMs = IDLE_MS) {
2190
+ return new Promise((resolve8, reject) => {
2191
+ const target = new URL(url);
2192
+ const send = target.protocol === "https:" ? import_node_https.request : import_node_http.request;
2193
+ const req = send(target, { method: init.method, headers: init.headers }, (res) => {
2194
+ const chunks = [];
2195
+ res.on("data", (chunk2) => chunks.push(chunk2));
2196
+ res.on("error", reject);
2197
+ res.on("end", () => {
2198
+ const status = res.statusCode ?? 0;
2199
+ const text2 = Buffer.concat(chunks).toString("utf8");
2200
+ resolve8({
2201
+ ok: status >= 200 && status <= 299,
2202
+ status,
2203
+ headers: {
2204
+ get: (name) => {
2205
+ const value2 = res.headers[name.toLowerCase()];
2206
+ return value2 === void 0 ? null : Array.isArray(value2) ? value2.join(", ") : value2;
2207
+ }
2208
+ },
2209
+ json: async () => JSON.parse(text2)
2210
+ });
2211
+ });
2212
+ });
2213
+ req.setTimeout(idleMs, () => req.destroy(new Error(`no answer from ${target.origin} in ${idleMs} ms`)));
2214
+ req.on("error", reject);
2215
+ req.end(init.body);
2216
+ });
2217
+ }
2187
2218
  async function detailOf(res) {
2188
2219
  try {
2189
2220
  const body = await res.json();
@@ -2206,7 +2237,7 @@ function classify3(status) {
2206
2237
  return "other";
2207
2238
  }
2208
2239
  function createClient(opts) {
2209
- const doFetch = opts.fetchImpl ?? fetch;
2240
+ const doFetch = opts.fetchImpl ?? nodeTransport;
2210
2241
  const wait = opts.wait ?? sleep;
2211
2242
  const base = opts.apiUrl.replace(/\/+$/, "");
2212
2243
  const scrub = (text2) => opts.token === "" ? text2 : text2.split(opts.token).join("***");
@@ -2251,12 +2282,15 @@ function createClient(opts) {
2251
2282
  del: (path14) => send("DELETE", path14)
2252
2283
  };
2253
2284
  }
2254
- var RETRY_DELAYS_MS, sleep;
2285
+ var import_node_http, import_node_https, RETRY_DELAYS_MS, IDLE_MS, sleep;
2255
2286
  var init_client = __esm({
2256
2287
  "src/reporter-core/client.ts"() {
2257
2288
  "use strict";
2258
2289
  init_cjs_shims();
2290
+ import_node_http = require("http");
2291
+ import_node_https = require("https");
2259
2292
  RETRY_DELAYS_MS = [300, 900];
2293
+ IDLE_MS = 3e5;
2260
2294
  sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2261
2295
  }
2262
2296
  });
@@ -2365,6 +2399,23 @@ function boundKey(key) {
2365
2399
  function boundTitle(title) {
2366
2400
  return title.length <= TITLE_MAX ? title : `${title.slice(0, TITLE_MAX - 1)}\u2026`;
2367
2401
  }
2402
+ function packed(results) {
2403
+ const out = [];
2404
+ let part = [];
2405
+ let bytes = ENVELOPE_BYTES;
2406
+ for (const result of results) {
2407
+ const size = Buffer.byteLength(JSON.stringify(result));
2408
+ if (part.length > 0 && bytes + 1 + size > BATCH_BYTES) {
2409
+ out.push(part);
2410
+ part = [];
2411
+ bytes = ENVELOPE_BYTES;
2412
+ }
2413
+ bytes += (part.length > 0 ? 1 : 0) + size;
2414
+ part.push(result);
2415
+ }
2416
+ if (part.length > 0) out.push(part);
2417
+ return out;
2418
+ }
2368
2419
  function chunk(items, size) {
2369
2420
  const out = [];
2370
2421
  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
@@ -2566,27 +2617,32 @@ async function startRun(passed, declared) {
2566
2617
  progress();
2567
2618
  return;
2568
2619
  }
2569
- const out = await client.post(
2570
- `/v1/runs/${runId}/results`,
2571
- { results: submissions }
2572
- );
2573
- if (out.ok) {
2574
- stats.accepted += out.body.counts.accepted ?? 0;
2575
- stats.duplicate += out.body.counts.duplicate ?? 0;
2576
- stats.conflict += out.body.counts.conflict ?? 0;
2577
- stats.rejected += out.body.counts.rejected ?? 0;
2578
- progress();
2579
- return;
2580
- }
2581
- if (out.kind === "auth") {
2582
- log('plune: the API token was refused \u2014 run "plune login" with a fresh one.');
2583
- offline = true;
2584
- } else if (out.kind === "conflict") {
2585
- log(`plune: this run is closed \u2014 ${out.detail}`);
2586
- } else {
2587
- log(`plune: could not send results (${out.detail || out.kind}).`);
2620
+ for (const part of packed(submissions)) {
2621
+ if (offline) {
2622
+ defer(part);
2623
+ continue;
2624
+ }
2625
+ const out = await client.post(`/v1/runs/${runId}/results`, {
2626
+ results: part
2627
+ });
2628
+ if (out.ok) {
2629
+ stats.accepted += out.body.counts.accepted ?? 0;
2630
+ stats.duplicate += out.body.counts.duplicate ?? 0;
2631
+ stats.conflict += out.body.counts.conflict ?? 0;
2632
+ stats.rejected += out.body.counts.rejected ?? 0;
2633
+ progress();
2634
+ continue;
2635
+ }
2636
+ if (out.kind === "auth") {
2637
+ log('plune: the API token was refused \u2014 run "plune login" with a fresh one.');
2638
+ offline = true;
2639
+ } else if (out.kind === "conflict") {
2640
+ log(`plune: this run is closed \u2014 ${out.detail}`);
2641
+ } else {
2642
+ log(`plune: could not send results (${out.detail || out.kind}).`);
2643
+ }
2644
+ defer(part);
2588
2645
  }
2589
- defer(submissions);
2590
2646
  }
2591
2647
  function progress() {
2592
2648
  const total = expected?.length ?? 0;
@@ -2602,9 +2658,12 @@ async function startRun(passed, declared) {
2602
2658
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
2603
2659
  if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
2604
2660
  if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
2605
- if (stats.deferred > 0) parts.push(`${stats.deferred} written to ${fallbackPath}`);
2661
+ if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
2606
2662
  if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
2607
2663
  log(`plune: ${parts.join(" \xB7 ")}`);
2664
+ if (stats.deferred > 0 && process.env["GITHUB_ACTIONS"] === "true") {
2665
+ log(`::warning::${stats.deferred} result(s) were not delivered to Plune \u2014 see the reporting step's log.`);
2666
+ }
2608
2667
  }
2609
2668
  return {
2610
2669
  get runId() {
@@ -2650,7 +2709,7 @@ async function startRun(passed, declared) {
2650
2709
  }
2651
2710
  };
2652
2711
  }
2653
- var import_node_crypto2, RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX, KEY_MAX, TITLE_MAX;
2712
+ var import_node_crypto2, RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX, KEY_MAX, TITLE_MAX, BATCH_BYTES, ENVELOPE_BYTES;
2654
2713
  var init_session = __esm({
2655
2714
  "src/reporter-core/session.ts"() {
2656
2715
  "use strict";
@@ -2668,6 +2727,8 @@ var init_session = __esm({
2668
2727
  EXPECTED_MAX = 1e4;
2669
2728
  KEY_MAX = 1024;
2670
2729
  TITLE_MAX = 300;
2730
+ BATCH_BYTES = 8 * 1024 * 1024;
2731
+ ENVELOPE_BYTES = Buffer.byteLength(JSON.stringify({ results: [] }));
2671
2732
  }
2672
2733
  });
2673
2734
 
@@ -2763,14 +2824,14 @@ async function handleRunExec(options) {
2763
2824
  if (command === void 0) throw new RunCommandError("Nothing to run \u2014 pass a command after `--`.");
2764
2825
  const run = await handleRunStart(options);
2765
2826
  const spawnImpl = options.spawnImpl ?? import_node_child_process.spawn;
2766
- const code = await new Promise((resolve7) => {
2827
+ const code = await new Promise((resolve8) => {
2767
2828
  const child = spawnImpl(command, args, {
2768
2829
  stdio: "inherit",
2769
2830
  shell: process.platform === "win32",
2770
2831
  env: { ...process.env, PLUNE_RUN: run.externalKey, PLUNE_PROCEED: "1" }
2771
2832
  });
2772
- child.on("close", (exitCode) => resolve7(exitCode ?? 1));
2773
- child.on("error", () => resolve7(1));
2833
+ child.on("close", (exitCode) => resolve8(exitCode ?? 1));
2834
+ child.on("error", () => resolve8(1));
2774
2835
  });
2775
2836
  await handleRunFinish(run.id, options);
2776
2837
  return code;
@@ -3102,21 +3163,163 @@ var init_junit = __esm({
3102
3163
  }
3103
3164
  });
3104
3165
 
3166
+ // src/reporter-core/failure-detail.ts
3167
+ function failureOf(attempt) {
3168
+ const error = chosen(attempt);
3169
+ const headline = error === void 0 ? void 0 : firstLine(error.text);
3170
+ const steps = chainOf(attempt.steps);
3171
+ const location = error === void 0 ? void 0 : fromRoot(placeOf(error, attempt.testFile), attempt.repoRoot);
3172
+ const artifacts = attempt.attachments.filter((a) => a.path !== void 0 && a.name !== "" && !a.name.startsWith("_") && a.contentType !== METADATA_TYPE).map(({ name, contentType }) => contentType === void 0 ? { name } : { name, contentType });
3173
+ const ciUrl = webLink(attempt.buildHref);
3174
+ const failure = {
3175
+ ...headline !== void 0 ? { headline } : {},
3176
+ ...steps.length > 0 ? { steps } : {},
3177
+ ...location !== void 0 ? { location } : {},
3178
+ ...artifacts.length > 0 ? { artifacts } : {},
3179
+ ...ciUrl !== void 0 ? { ciUrl } : {}
3180
+ };
3181
+ return Object.keys(failure).length === 0 ? void 0 : failure;
3182
+ }
3183
+ function repoRootOf(dir, stopAt) {
3184
+ for (let here = (0, import_node_path2.resolve)(dir); ; here = (0, import_node_path2.dirname)(here)) {
3185
+ if ((0, import_node_fs2.existsSync)((0, import_node_path2.join)(here, ".git"))) return here;
3186
+ if (here === stopAt || (0, import_node_path2.dirname)(here) === here) return void 0;
3187
+ }
3188
+ }
3189
+ function errorContextOf(attempt, home = (0, import_node_os2.homedir)()) {
3190
+ let text2 = (0, import_node_util.stripVTControlCharacters)(
3191
+ attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n")
3192
+ ).replaceAll("\x1B", "");
3193
+ const root = attempt.repoRoot === void 0 ? void 0 : pathIn(attempt.repoRoot);
3194
+ if (root !== void 0) text2 = text2.replace(new RegExp(`${root.source}[\\\\/]`, root.flags), "");
3195
+ const own = pathIn(home);
3196
+ if (own !== void 0) text2 = text2.replace(new RegExp(`${own.source}(?![\\w.-])`, own.flags), "~");
3197
+ if (attempt.repoRoot === void 0) text2 = text2.replace(ANY_HOME, "~");
3198
+ return cut(text2);
3199
+ }
3200
+ function pathIn(path14) {
3201
+ const parts = path14.split(/[\\/]+/);
3202
+ while (parts.length > 1 && parts.at(-1) === "") parts.pop();
3203
+ if (!parts.some((part) => part !== "" && !/^[A-Za-z]:$/.test(part))) return void 0;
3204
+ const escaped = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]");
3205
+ return new RegExp(`(?:file:\\/\\/\\/?)?${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
3206
+ }
3207
+ function cut(text2) {
3208
+ if (Buffer.byteLength(text2) <= TEXT_LIMIT) return text2;
3209
+ const lines = text2.split("\n");
3210
+ const size = lines.map((line) => Buffer.byteLength(line) + 1);
3211
+ const budget = TEXT_LIMIT - Buffer.byteLength(omitted(lines.length));
3212
+ let used = 0;
3213
+ let head = 0;
3214
+ let tail = lines.length;
3215
+ while (head < tail && used + size[head] <= budget / 2) used += size[head++];
3216
+ while (tail > head && used + size[tail - 1] <= budget) used += size[--tail];
3217
+ return [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n");
3218
+ }
3219
+ function chosen({ status, errors }) {
3220
+ if (status === "timedOut") {
3221
+ const timeout = errors.findIndex((e) => firstLine(e.text)?.startsWith("Test timeout of ") === true);
3222
+ if (timeout !== -1 && timeout + 1 < errors.length) return errors[timeout + 1];
3223
+ }
3224
+ return errors[0];
3225
+ }
3226
+ function chainOf(steps) {
3227
+ const chain = [];
3228
+ for (let failed = steps.find((s) => s.failed); failed !== void 0; failed = failed.steps.find((s) => s.failed)) {
3229
+ chain.push(failed.title);
3230
+ }
3231
+ return chain;
3232
+ }
3233
+ function placeOf(error, testFile) {
3234
+ const own = normal(testFile);
3235
+ let first;
3236
+ for (const line of (0, import_node_util.stripVTControlCharacters)(error.text).split("\n")) {
3237
+ const frame = FRAME.exec(line);
3238
+ if (frame === null) continue;
3239
+ const place = { file: fileOf(frame[1]), line: Number(frame[2]), column: Number(frame[3]) };
3240
+ if (normal(place.file) === own) return place;
3241
+ if (first === void 0 && !normal(place.file).includes("/node_modules/")) first = place;
3242
+ }
3243
+ return error.location ?? first;
3244
+ }
3245
+ function fileOf(raw) {
3246
+ if (!raw.startsWith("file://")) return raw;
3247
+ const path14 = decodeURIComponent(raw.slice("file://".length));
3248
+ return /^\/[A-Za-z]:\//.test(path14) ? path14.slice(1) : path14;
3249
+ }
3250
+ function fromRoot(place, root) {
3251
+ if (place === void 0 || root === void 0 || !Number.isInteger(place.line) || place.line < 1) return void 0;
3252
+ const base = normal(root).replace(/\/+$/, "");
3253
+ const file = normal(place.file);
3254
+ if (!file.startsWith(`${base}/`)) return void 0;
3255
+ const relative2 = file.slice(base.length + 1);
3256
+ if (!REPO_RELATIVE.test(relative2)) return void 0;
3257
+ const column = place.column !== void 0 && Number.isInteger(place.column) && place.column >= 1 ? place.column : void 0;
3258
+ return column === void 0 ? { file: relative2, line: place.line } : { file: relative2, line: place.line, column };
3259
+ }
3260
+ function webLink(href) {
3261
+ if (href === void 0) return void 0;
3262
+ try {
3263
+ const { protocol } = new URL(href);
3264
+ return protocol === "http:" || protocol === "https:" ? href : void 0;
3265
+ } catch {
3266
+ return void 0;
3267
+ }
3268
+ }
3269
+ var import_node_fs2, import_node_os2, import_node_path2, import_node_util, TEXT_LIMIT, ANY_HOME, omitted, METADATA_TYPE, REPO_RELATIVE, FRAME, firstLine, normal;
3270
+ var init_failure_detail = __esm({
3271
+ "src/reporter-core/failure-detail.ts"() {
3272
+ "use strict";
3273
+ init_cjs_shims();
3274
+ import_node_fs2 = require("fs");
3275
+ import_node_os2 = require("os");
3276
+ import_node_path2 = require("path");
3277
+ import_node_util = require("util");
3278
+ TEXT_LIMIT = 524288;
3279
+ ANY_HOME = /(?<=^|[\s'"`(=,[])(?:file:\/\/\/?)?(?:\/home\/|\/Users\/|[A-Za-z]:[\\/]Users[\\/])[^\\/\s'"`:*?<>|]+/g;
3280
+ omitted = (lines) => `\u2026[omitted ${lines} lines]\u2026`;
3281
+ METADATA_TYPE = "application/plune.metadata+json";
3282
+ REPO_RELATIVE = /^(?![A-Za-z][A-Za-z0-9+.-]*:)(?![\\/~])(?!(?:.*[\\/])?\.\.(?:[\\/]|$)).+$/;
3283
+ FRAME = /^\s+at (?:.*? \()?(.+?):(\d+):(\d+)\)?$/;
3284
+ firstLine = (text2) => (0, import_node_util.stripVTControlCharacters)(text2).split("\n").map((line) => line.trim()).find((line) => line !== "");
3285
+ normal = (path14) => import_node_path2.posix.normalize(path14.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
3286
+ }
3287
+ });
3288
+
3105
3289
  // src/importers/playwright-json.ts
3106
3290
  function statedIdOf(test, title) {
3107
3291
  const annotation = test.annotations?.find((a) => a.type === "PluneId")?.description;
3108
3292
  if (annotation !== void 0 && annotation !== "") return annotation;
3109
3293
  return TOKEN.exec(title)?.[1];
3110
3294
  }
3111
- function errorTextOf2(result) {
3112
- return (result.errors ?? []).map((e) => e.stack ?? e.message ?? e.value ?? "").filter((text2) => text2 !== "").join("\n\n");
3295
+ function attemptOf(result, file, ctx) {
3296
+ const steps = (list2) => (list2 ?? []).map((s) => ({ title: s.title ?? "", failed: s.error !== void 0, steps: steps(s.steps) }));
3297
+ return {
3298
+ status: result.status ?? "unknown",
3299
+ errors: (result.errors ?? []).map(({ message, value: value2, location: at }) => ({
3300
+ text: message ?? value2 ?? "",
3301
+ ...typeof at?.file === "string" && typeof at.line === "number" ? { location: { file: at.file, line: at.line, ...typeof at.column === "number" ? { column: at.column } : {} } } : {}
3302
+ })),
3303
+ steps: steps(result.steps),
3304
+ attachments: (result.attachments ?? []).map(({ name, contentType, path: path14 }) => ({
3305
+ name: name ?? "",
3306
+ ...contentType !== void 0 ? { contentType } : {},
3307
+ ...path14 !== void 0 ? { path: path14 } : {}
3308
+ })),
3309
+ ...ctx.buildHref !== void 0 ? { buildHref: ctx.buildHref } : {},
3310
+ // Spec files are posix and relative to the root dir; the core reads either slash.
3311
+ testFile: ctx.rootDir === void 0 ? file : `${ctx.rootDir.replace(/[\\/]+$/, "")}/${file}`,
3312
+ ...ctx.repoRoot !== void 0 ? { repoRoot: ctx.repoRoot } : {}
3313
+ };
3113
3314
  }
3114
- function pendingFrom(spec, test, result, file, titles) {
3315
+ function pendingFrom(spec, test, result, file, titles, ctx) {
3115
3316
  const pathTitle = [file, ...titles].join("#");
3116
3317
  const seed = test.id ?? pathTitle;
3117
3318
  const stated = statedIdOf(test, spec.title ?? "");
3118
3319
  const expected = test.expectedStatus;
3119
- const errorContext = errorTextOf2(result);
3320
+ const attempt = attemptOf(result, file, ctx);
3321
+ const errorContext = errorContextOf(attempt);
3322
+ const failure = NOT_FAILED.has(attempt.status) ? void 0 : failureOf(attempt);
3120
3323
  const keys = [
3121
3324
  ...test.id !== void 0 ? [{ kind: "playwright-id", value: test.id }] : [],
3122
3325
  { kind: "path-title", value: pathTitle }
@@ -3144,19 +3347,20 @@ function pendingFrom(spec, test, result, file, titles) {
3144
3347
  ...result.workerIndex !== void 0 ? { worker: String(result.workerIndex) } : {}
3145
3348
  }
3146
3349
  } : {},
3147
- ...errorContext !== "" ? { errorContext } : {}
3350
+ ...errorContext !== "" ? { errorContext } : {},
3351
+ ...failure !== void 0 ? { failure } : {}
3148
3352
  };
3149
3353
  }
3150
- function walk(suite, describes, depth, out) {
3354
+ function walk(suite, describes, depth, ctx, out) {
3151
3355
  const here = depth === 0 ? [] : [...describes, suite.title ?? ""];
3152
3356
  for (const spec of suite.specs ?? []) {
3153
3357
  const file = spec.file ?? suite.file ?? suite.title ?? "";
3154
3358
  const titles = [...here, spec.title ?? ""];
3155
3359
  for (const test of spec.tests ?? []) {
3156
- for (const result of test.results ?? []) out.push(pendingFrom(spec, test, result, file, titles));
3360
+ for (const result of test.results ?? []) out.push(pendingFrom(spec, test, result, file, titles, ctx));
3157
3361
  }
3158
3362
  }
3159
- for (const child of suite.suites ?? []) walk(child, here, depth + 1, out);
3363
+ for (const child of suite.suites ?? []) walk(child, here, depth + 1, ctx, out);
3160
3364
  }
3161
3365
  function readPlaywrightJson(source, file) {
3162
3366
  let parsed;
@@ -3168,22 +3372,34 @@ function readPlaywrightJson(source, file) {
3168
3372
  if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.suites)) {
3169
3373
  throw new JsonReportError(file, 'no "suites" array \u2014 is this a Playwright JSON report?');
3170
3374
  }
3375
+ const { config, suites } = parsed;
3376
+ const rootDir = typeof config?.rootDir === "string" ? config.rootDir : void 0;
3377
+ const buildHref = config?.metadata?.ci?.buildHref;
3378
+ const repoRoot = rootDir !== void 0 && (0, import_node_fs3.existsSync)(rootDir) ? repoRootOf(rootDir) : void 0;
3379
+ const ctx = {
3380
+ ...rootDir !== void 0 ? { rootDir } : {},
3381
+ ...repoRoot !== void 0 ? { repoRoot } : {},
3382
+ ...typeof buildHref === "string" ? { buildHref } : {}
3383
+ };
3171
3384
  const out = [];
3172
- for (const suite of parsed.suites) walk(suite, [], 0, out);
3173
- return out;
3385
+ for (const suite of suites) walk(suite, [], 0, ctx, out);
3386
+ const ciUrl = webLink(ctx.buildHref);
3387
+ return ciUrl === void 0 ? { results: out } : { results: out, ciUrl };
3174
3388
  }
3175
3389
  function looksLikePlaywrightJson(source) {
3176
- const head = source.slice(0, 4096);
3177
- return head.trimStart().startsWith("{") && head.includes('"suites"');
3390
+ return /^\s*\{/.test(source) && source.includes('"suites"');
3178
3391
  }
3179
- var SOURCE2, TOKEN, JsonReportError;
3392
+ var import_node_fs3, SOURCE2, TOKEN, NOT_FAILED, JsonReportError;
3180
3393
  var init_playwright_json = __esm({
3181
3394
  "src/importers/playwright-json.ts"() {
3182
3395
  "use strict";
3183
3396
  init_cjs_shims();
3397
+ import_node_fs3 = require("fs");
3398
+ init_failure_detail();
3184
3399
  init_result_key();
3185
3400
  SOURCE2 = "playwright";
3186
3401
  TOKEN = /@P([A-Za-z0-9_-]+)/;
3402
+ NOT_FAILED = /* @__PURE__ */ new Set(["passed", "skipped"]);
3187
3403
  JsonReportError = class extends Error {
3188
3404
  constructor(file, detail) {
3189
3405
  super(`${file} \u2014 ${detail}`);
@@ -3214,11 +3430,9 @@ function detectFormat(source, file) {
3214
3430
  );
3215
3431
  }
3216
3432
  function readReport(source, file, format) {
3217
- const chosen = format ?? detectFormat(source, file);
3218
- return {
3219
- format: chosen,
3220
- results: chosen === "junit" ? readJUnit(source, file) : readPlaywrightJson(source, file)
3221
- };
3433
+ const chosen2 = format ?? detectFormat(source, file);
3434
+ if (chosen2 === "junit") return { format: chosen2, results: readJUnit(source, file) };
3435
+ return { format: chosen2, ...readPlaywrightJson(source, file) };
3222
3436
  }
3223
3437
  var IMPORT_FORMATS, UnknownFormatError;
3224
3438
  var init_importers = __esm({
@@ -3253,7 +3467,7 @@ async function handleRunImport(options) {
3253
3467
  } catch {
3254
3468
  throw new RunCommandError(`Cannot read ${options.file}.`);
3255
3469
  }
3256
- const { format, results } = readReport(source, options.file, options.format);
3470
+ const { format, results, ciUrl } = readReport(source, options.file, options.format);
3257
3471
  if (results.length === 0) {
3258
3472
  throw new RunCommandError(`No test cases in ${options.file} \u2014 it parsed, but there is nothing in it.`);
3259
3473
  }
@@ -3269,7 +3483,8 @@ async function handleRunImport(options) {
3269
3483
  ...options.key !== void 0 ? { externalKey: options.key } : {},
3270
3484
  ...options.create === true ? { offerDiscovered: true } : {},
3271
3485
  kind: "automated",
3272
- meta: { runner: format },
3486
+ // The CI run the report names, never this machine's: whoever imports may be another job (#790).
3487
+ meta: { runner: format, ...ciUrl !== void 0 ? { ciUrl } : {} },
3273
3488
  log: write
3274
3489
  },
3275
3490
  // The file is the complete list of what ran, so the platform can resolve every test in one
@@ -3310,7 +3525,9 @@ function describe(result, apiUrl) {
3310
3525
  // «Imported 555 result(s): 0 accepted, 0 already there, 0 unmatched» was a sentence whose two
3311
3526
  // halves disagreed, and readers stop at the verb — a run that reached nobody read as one that
3312
3527
  // worked (#622).
3313
- `Read ${result.parsed} result(s) from a ${result.format} report: ${result.accepted} accepted, ${result.duplicate} already there, ${result.unresolved} unmatched.`
3528
+ // `not delivered` always, zero included (#790): a number that only appears when it is bad is one
3529
+ // nobody learns to look for. The `[0-9]+ unmatched` the workflows read stays whole.
3530
+ `Read ${result.parsed} result(s) from a ${result.format} report: ${result.accepted} accepted, ${result.duplicate} already there, ${result.unresolved} unmatched, ${result.deferred} not delivered.`
3314
3531
  ];
3315
3532
  if (result.conflict > 0 || result.rejected > 0) {
3316
3533
  lines.push(`${result.conflict} conflicted and ${result.rejected} were refused.`);
@@ -4029,7 +4246,7 @@ var init_sync = __esm({
4029
4246
  init_credentials();
4030
4247
  NotLoggedInError = class extends Error {
4031
4248
  constructor() {
4032
- super('Not logged in. Run "plune login" first to save your API token.');
4249
+ super('Not logged in. Run "plune login" first, or set PLUNE_TOKEN.');
4033
4250
  this.name = "NotLoggedInError";
4034
4251
  }
4035
4252
  };
@@ -4306,7 +4523,7 @@ var init_ingest = __esm({
4306
4523
  init_cairn_artifact();
4307
4524
  IngestNotLoggedInError = class extends Error {
4308
4525
  constructor() {
4309
- super('Not logged in. Run "plune login" first to save your API token.');
4526
+ super('Not logged in. Run "plune login" first, or set PLUNE_TOKEN.');
4310
4527
  this.name = "IngestNotLoggedInError";
4311
4528
  }
4312
4529
  };
@@ -4472,6 +4689,68 @@ var init_cases = __esm({
4472
4689
  }
4473
4690
  });
4474
4691
 
4692
+ // src/cli/commands/plan.ts
4693
+ var plan_exports = {};
4694
+ __export(plan_exports, {
4695
+ formatGrep: () => formatGrep,
4696
+ grepFragment: () => grepFragment,
4697
+ handlePlanGrep: () => handlePlanGrep
4698
+ });
4699
+ function grepFragment(c) {
4700
+ const pathTitle = c.externalKeys.find((k) => k.kind === "path-title")?.value;
4701
+ const segments = pathTitle?.split("#") ?? [c.title];
4702
+ return (segments.length > 1 ? segments.slice(1) : segments).flatMap((s) => s.split(/ [>›] /)).map(escape).join(BOUNDARY);
4703
+ }
4704
+ function formatGrep(cases) {
4705
+ if (cases.length === 0) return "(?!)";
4706
+ return cases.map(grepFragment).join("|");
4707
+ }
4708
+ async function safeErrorDetail4(res) {
4709
+ try {
4710
+ const body = await res.json();
4711
+ return typeof body.error === "string" ? body.error : "";
4712
+ } catch {
4713
+ return "";
4714
+ }
4715
+ }
4716
+ async function handlePlanGrep(deps) {
4717
+ const apiUrl = resolveApiUrl(deps.apiUrl);
4718
+ const token = (deps.loadToken ?? loadToken)();
4719
+ if (token === null) throw new NotLoggedInError();
4720
+ const doFetch = deps.fetchImpl ?? fetch;
4721
+ let res;
4722
+ try {
4723
+ res = await doFetch(`${apiUrl}/v1/plans/${encodeURIComponent(deps.planId)}/cases`, {
4724
+ headers: { authorization: `Bearer ${token}` }
4725
+ });
4726
+ } catch {
4727
+ throw new SyncNetworkError(apiUrl);
4728
+ }
4729
+ if (!res.ok) {
4730
+ const detail = await safeErrorDetail4(res);
4731
+ if (res.status === 401) throw new TokenRejectedError2();
4732
+ throw new SyncHttpError(res.status, detail);
4733
+ }
4734
+ const { cases } = await res.json();
4735
+ return {
4736
+ pattern: formatGrep(cases),
4737
+ cases: cases.length,
4738
+ keyed: cases.filter((c) => c.externalKeys.some((k) => k.kind === "path-title")).length
4739
+ };
4740
+ }
4741
+ var escape, BOUNDARY;
4742
+ var init_plan = __esm({
4743
+ "src/cli/commands/plan.ts"() {
4744
+ "use strict";
4745
+ init_cjs_shims();
4746
+ init_api_url();
4747
+ init_credentials();
4748
+ init_sync();
4749
+ escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4750
+ BOUNDARY = "[ >#]+";
4751
+ }
4752
+ });
4753
+
4475
4754
  // src/cli.ts
4476
4755
  var cli_exports = {};
4477
4756
  __export(cli_exports, {
@@ -4481,11 +4760,11 @@ module.exports = __toCommonJS(cli_exports);
4481
4760
  init_cjs_shims();
4482
4761
  init_api_url();
4483
4762
  var import_commander = require("commander");
4484
- var import_node_fs2 = require("fs");
4485
- var import_node_path2 = require("path");
4763
+ var import_node_fs4 = require("fs");
4764
+ var import_node_path3 = require("path");
4486
4765
  var import_node_url = require("url");
4487
- var __dirname = (0, import_node_path2.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
4488
- var pkg = JSON.parse((0, import_node_fs2.readFileSync)((0, import_node_path2.join)(__dirname, "..", "package.json"), "utf-8"));
4766
+ var __dirname = (0, import_node_path3.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
4767
+ var pkg = JSON.parse((0, import_node_fs4.readFileSync)((0, import_node_path3.join)(__dirname, "..", "package.json"), "utf-8"));
4489
4768
  function toFormat(s) {
4490
4769
  return s === "json" || s === "markdown" ? s : "console";
4491
4770
  }
@@ -4494,7 +4773,7 @@ function shouldColor(outputPath) {
4494
4773
  }
4495
4774
  function writeReport(text2, outputPath) {
4496
4775
  if (outputPath !== void 0) {
4497
- (0, import_node_fs2.writeFileSync)(outputPath, text2);
4776
+ (0, import_node_fs4.writeFileSync)(outputPath, text2);
4498
4777
  } else {
4499
4778
  process.stdout.write(text2 + "\n");
4500
4779
  }
@@ -4560,7 +4839,7 @@ function createProgram() {
4560
4839
  Promise.resolve().then(() => (init_errors(), errors_exports)),
4561
4840
  Promise.resolve().then(() => (init_errors2(), errors_exports2))
4562
4841
  ]);
4563
- loadEnv2(configPath !== void 0 ? (0, import_node_path2.dirname)((0, import_node_path2.resolve)(configPath)) : process.cwd());
4842
+ loadEnv2(configPath !== void 0 ? (0, import_node_path3.dirname)((0, import_node_path3.resolve)(configPath)) : process.cwd());
4564
4843
  try {
4565
4844
  const result = await handleRun2({
4566
4845
  dryRun: options.dryRun,
@@ -4691,7 +4970,8 @@ function createProgram() {
4691
4970
  Promise.resolve().then(() => (init_run_import(), run_import_exports)),
4692
4971
  Promise.resolve().then(() => (init_importers(), importers_exports))
4693
4972
  ]);
4694
- const format = options.format;
4973
+ const run = command.parent;
4974
+ const format = options.format ?? (run?.getOptionValueSource("format") === "cli" ? run.opts().format : void 0);
4695
4975
  if (format !== void 0 && !IMPORT_FORMATS2.includes(format)) {
4696
4976
  process.stderr.write(
4697
4977
  `Unknown --format "${format}". Use ${IMPORT_FORMATS2.join(" or ")}.
@@ -4724,7 +5004,7 @@ function createProgram() {
4724
5004
  Promise.resolve().then(() => (init_report(), report_exports)),
4725
5005
  Promise.resolve().then(() => (init_reporters(), reporters_exports))
4726
5006
  ]);
4727
- const cwd = globals.config !== void 0 ? (0, import_node_path2.dirname)((0, import_node_path2.resolve)(globals.config)) : process.cwd();
5007
+ const cwd = globals.config !== void 0 ? (0, import_node_path3.dirname)((0, import_node_path3.resolve)(globals.config)) : process.cwd();
4728
5008
  loadEnv2(cwd);
4729
5009
  try {
4730
5010
  const result = handleReport2({ cwd });
@@ -4822,7 +5102,7 @@ function createProgram() {
4822
5102
  const globals = command.optsWithGlobals();
4823
5103
  const verbose = globals.verbose === true;
4824
5104
  const { handleSync: handleSync2, reportSyncFailure: reportSyncFailure2 } = await Promise.resolve().then(() => (init_sync(), sync_exports));
4825
- const cwd = globals.config !== void 0 ? (0, import_node_path2.dirname)((0, import_node_path2.resolve)(globals.config)) : process.cwd();
5105
+ const cwd = globals.config !== void 0 ? (0, import_node_path3.dirname)((0, import_node_path3.resolve)(globals.config)) : process.cwd();
4826
5106
  try {
4827
5107
  const { id, url } = await handleSync2({
4828
5108
  cwd,
@@ -4905,6 +5185,30 @@ function createProgram() {
4905
5185
  failUnexpected(err, verbose);
4906
5186
  }
4907
5187
  });
5188
+ const planCommand = program.command("plan").description("Test plans on the platform \u2014 what a plan collects, as something a runner can take");
5189
+ planCommand.command("grep").argument("<id>", "The plan").description(
5190
+ "Print the plan's cases as one --grep pattern for the runner (GET /v1/plans/:id/cases)"
5191
+ ).action(async (id, _options, command) => {
5192
+ const verbose = command.optsWithGlobals().verbose === true;
5193
+ const { handlePlanGrep: handlePlanGrep2 } = await Promise.resolve().then(() => (init_plan(), plan_exports));
5194
+ const { reportCasesFailure: reportCasesFailure2 } = await Promise.resolve().then(() => (init_cases(), cases_exports));
5195
+ try {
5196
+ const out = await handlePlanGrep2({ planId: id });
5197
+ process.stdout.write(out.pattern + "\n");
5198
+ process.stderr.write(
5199
+ out.cases === 0 ? "plune: the plan matches no case \u2014 the pattern matches nothing.\n" : `plune: ${out.cases} case(s) in the plan, ${out.keyed} with a test key; the rest by title.
5200
+ `
5201
+ );
5202
+ } catch (err) {
5203
+ const code = reportCasesFailure2(err, (s) => process.stderr.write(s));
5204
+ if (code !== null) {
5205
+ maybeStack(err, verbose);
5206
+ process.exitCode = code;
5207
+ return;
5208
+ }
5209
+ failUnexpected(err, verbose);
5210
+ }
5211
+ });
4908
5212
  program.on("command:*", (operands) => {
4909
5213
  process.stderr.write(`Unknown command: ${operands.join(" ")}
4910
5214
  `);
@@ -4916,7 +5220,7 @@ function isMainModule() {
4916
5220
  const entry = process.argv[1];
4917
5221
  if (entry === void 0) return false;
4918
5222
  try {
4919
- return (0, import_node_fs2.realpathSync)(entry) === (0, import_node_fs2.realpathSync)((0, import_node_url.fileURLToPath)(importMetaUrl));
5223
+ return (0, import_node_fs4.realpathSync)(entry) === (0, import_node_fs4.realpathSync)((0, import_node_url.fileURLToPath)(importMetaUrl));
4920
5224
  } catch {
4921
5225
  return false;
4922
5226
  }