@plune-ai/cli 0.17.0 → 0.18.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((resolve8) => setTimeout(resolve8, ms));
515
+ defaultSleep = (ms) => new Promise((resolve9) => setTimeout(resolve9, ms));
516
516
  }
517
517
  });
518
518
 
@@ -874,7 +874,7 @@ var init_embedder = __esm({
874
874
  throw new Error(
875
875
  "The `semantic-similarity` and RAG assertions need local embeddings, which ship separately: install @huggingface/transformers to enable them. Every other assertion works without it."
876
876
  );
877
- }).then(({ pipeline }) => pipeline("feature-extraction", MODEL_ID));
877
+ }).then(({ pipeline: pipeline2 }) => pipeline2("feature-extraction", MODEL_ID));
878
878
  return this.extractor;
879
879
  }
880
880
  };
@@ -1621,6 +1621,76 @@ var init_concurrency = __esm({
1621
1621
  }
1622
1622
  });
1623
1623
 
1624
+ // src/types/test-case-schema.ts
1625
+ var import_zod2, TEST_CASE_SCHEMA_VERSION, externalKeySchema, manualStepSchema, assertionResultRecordSchema, manualCaseSchema, automatedCaseSchema, evalCaseSchema, testCaseBaseSchema, testCaseSchema;
1626
+ var init_test_case_schema = __esm({
1627
+ "src/types/test-case-schema.ts"() {
1628
+ "use strict";
1629
+ init_cjs_shims();
1630
+ import_zod2 = require("zod");
1631
+ TEST_CASE_SCHEMA_VERSION = 2;
1632
+ externalKeySchema = import_zod2.z.object({
1633
+ kind: import_zod2.z.enum(["playwright-id", "path-title", "allure-history", "cairn-stable", "eval-id", "qase", "testrail"]),
1634
+ value: import_zod2.z.string().min(1)
1635
+ });
1636
+ manualStepSchema = import_zod2.z.object({
1637
+ action: import_zod2.z.string().min(1),
1638
+ expected: import_zod2.z.string().min(1).optional()
1639
+ });
1640
+ assertionResultRecordSchema = import_zod2.z.object({
1641
+ type: import_zod2.z.string().min(1),
1642
+ passed: import_zod2.z.boolean(),
1643
+ score: import_zod2.z.number().optional(),
1644
+ reason: import_zod2.z.string().optional()
1645
+ });
1646
+ manualCaseSchema = import_zod2.z.object({
1647
+ type: import_zod2.z.literal("manual"),
1648
+ steps: import_zod2.z.array(manualStepSchema).min(1),
1649
+ expected: import_zod2.z.string().min(1).optional()
1650
+ });
1651
+ automatedCaseSchema = import_zod2.z.object({
1652
+ type: import_zod2.z.literal("automated"),
1653
+ specRef: import_zod2.z.string().min(1)
1654
+ });
1655
+ evalCaseSchema = import_zod2.z.object({
1656
+ type: import_zod2.z.literal("eval"),
1657
+ prompt: import_zod2.z.string().min(1),
1658
+ goldenSetId: import_zod2.z.string().min(1).optional(),
1659
+ assertions: import_zod2.z.array(assertionResultRecordSchema).min(1)
1660
+ });
1661
+ testCaseBaseSchema = import_zod2.z.object({
1662
+ schemaVersion: import_zod2.z.literal(TEST_CASE_SCHEMA_VERSION),
1663
+ id: import_zod2.z.string().min(1),
1664
+ externalKeys: import_zod2.z.array(externalKeySchema).optional(),
1665
+ /** ADR 0045 / plune#1246 — optional, purely additive; schemaVersion stays 2. */
1666
+ is_flaky: import_zod2.z.boolean().optional(),
1667
+ /** ADR 0045 / plune#1246 — optional, purely additive; schemaVersion stays 2. */
1668
+ is_muted: import_zod2.z.boolean().optional()
1669
+ });
1670
+ testCaseSchema = import_zod2.z.discriminatedUnion("type", [
1671
+ testCaseBaseSchema.merge(manualCaseSchema),
1672
+ testCaseBaseSchema.merge(automatedCaseSchema),
1673
+ testCaseBaseSchema.merge(evalCaseSchema)
1674
+ ]);
1675
+ }
1676
+ });
1677
+
1678
+ // src/types/results.ts
1679
+ function rowStatus(row) {
1680
+ if (row.inconclusive !== void 0) return row.inconclusive;
1681
+ if (row.error !== void 0) return "errored";
1682
+ return row.assertions.every((a) => a.passed) ? "passed" : "failed";
1683
+ }
1684
+ var ROW_INCONCLUSIVE;
1685
+ var init_results = __esm({
1686
+ "src/types/results.ts"() {
1687
+ "use strict";
1688
+ init_cjs_shims();
1689
+ init_test_case_schema();
1690
+ ROW_INCONCLUSIVE = ["blocked", "skipped"];
1691
+ }
1692
+ });
1693
+
1624
1694
  // src/orchestrator/run.ts
1625
1695
  function resolvePrompt(template, vars) {
1626
1696
  return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_match, key) => {
@@ -1754,10 +1824,25 @@ function resolveTemplate(ev, baseDir) {
1754
1824
  }
1755
1825
  throw new RunConfigError(`Eval "${ev.id}" has neither prompt nor prompt_file`);
1756
1826
  }
1757
- function classify(row) {
1758
- if (row.error !== void 0) return "errored";
1759
- if (row.assertions.some((a) => !a.passed)) return "failed";
1760
- return "passed";
1827
+ function summarize(evals, durationMs) {
1828
+ const summary = {
1829
+ total: 0,
1830
+ passed: 0,
1831
+ failed: 0,
1832
+ errored: 0,
1833
+ blocked: 0,
1834
+ skipped: 0,
1835
+ cost_usd: 0,
1836
+ duration_ms: durationMs
1837
+ };
1838
+ for (const ev of evals) {
1839
+ for (const row of ev.rows) {
1840
+ summary.total += 1;
1841
+ summary[rowStatus(row)] += 1;
1842
+ summary.cost_usd += row.usage?.cost_usd ?? 0;
1843
+ }
1844
+ }
1845
+ return summary;
1761
1846
  }
1762
1847
  async function runOrchestration(config, options, deps) {
1763
1848
  const started = deps.now();
@@ -1792,26 +1877,12 @@ async function runOrchestration(config, options, deps) {
1792
1877
  noCache
1793
1878
  })
1794
1879
  );
1795
- const passed = rowResults.every((r) => classify(r) === "passed");
1880
+ const passed = rowResults.every((r) => rowStatus(r) === "passed");
1796
1881
  evalResults.push({ id: ev.id, tags: ev.tags ?? [], rows: rowResults, passed });
1797
1882
  if (options.bail === true && !passed) break;
1798
1883
  }
1799
1884
  const finished = deps.now();
1800
- const summary = {
1801
- total: 0,
1802
- passed: 0,
1803
- failed: 0,
1804
- errored: 0,
1805
- cost_usd: 0,
1806
- duration_ms: finished - started
1807
- };
1808
- for (const ev of evalResults) {
1809
- for (const row of ev.rows) {
1810
- summary.total += 1;
1811
- summary[classify(row)] += 1;
1812
- summary.cost_usd += row.usage?.cost_usd ?? 0;
1813
- }
1814
- }
1885
+ const summary = summarize(evalResults, finished - started);
1815
1886
  return {
1816
1887
  schemaVersion: 1,
1817
1888
  plune_version: PLUNE_VERSION,
@@ -1834,6 +1905,7 @@ var init_run = __esm({
1834
1905
  init_judge();
1835
1906
  init_concurrency();
1836
1907
  init_errors2();
1908
+ init_results();
1837
1909
  DEFAULT_MAX_TOKENS2 = 1024;
1838
1910
  PLUNE_VERSION = "0.1.0";
1839
1911
  RunConfigError = class extends Error {
@@ -2027,8 +2099,10 @@ function renderConsole(result, opts = {}) {
2027
2099
  const passedTxt = s.green(`${summary.passed} passed`);
2028
2100
  const failedTxt = summary.failed > 0 ? s.red(`${summary.failed} failed`) : `${summary.failed} failed`;
2029
2101
  const erroredTxt = summary.errored > 0 ? s.yellow(`${summary.errored} errored`) : `${summary.errored} errored`;
2102
+ const blockedTxt = (summary.blocked ?? 0) > 0 ? ` \xB7 ${s.yellow(`${summary.blocked} blocked`)}` : "";
2103
+ const skippedTxt = (summary.skipped ?? 0) > 0 ? ` \xB7 ${s.dim(`${summary.skipped} skipped`)}` : "";
2030
2104
  lines.push(
2031
- `${passedTxt} \xB7 ${failedTxt} \xB7 ${erroredTxt} \xB7 ${summary.total} total \xB7 $${summary.cost_usd.toFixed(4)} \xB7 ${summary.duration_ms}ms`
2105
+ `${passedTxt} \xB7 ${failedTxt} \xB7 ${erroredTxt}${blockedTxt}${skippedTxt} \xB7 ${summary.total} total \xB7 $${summary.cost_usd.toFixed(4)} \xB7 ${summary.duration_ms}ms`
2032
2106
  );
2033
2107
  lines.push("");
2034
2108
  for (const ev of result.evals) {
@@ -2037,17 +2111,21 @@ function renderConsole(result, opts = {}) {
2037
2111
  lines.push(`${mark} ${s.bold(ev.id)}${tags}`);
2038
2112
  let passedCount = 0;
2039
2113
  for (const row of ev.rows) {
2114
+ const status = rowStatus(row);
2115
+ if (status === "passed") {
2116
+ passedCount += 1;
2117
+ continue;
2118
+ }
2119
+ lines.push(` ${s.dim("vars:")} ${JSON.stringify(row.vars)}`);
2120
+ if (status === "blocked" || status === "skipped") {
2121
+ lines.push(` ${s.yellow(status)} (not judged)`);
2122
+ continue;
2123
+ }
2040
2124
  if (row.error !== void 0) {
2041
- lines.push(` ${s.dim("vars:")} ${JSON.stringify(row.vars)}`);
2042
2125
  lines.push(` ${s.red("error:")} ${row.error.message}`);
2043
2126
  continue;
2044
2127
  }
2045
2128
  const failed = row.assertions.filter((a) => !a.passed);
2046
- if (failed.length === 0) {
2047
- passedCount += 1;
2048
- continue;
2049
- }
2050
- lines.push(` ${s.dim("vars:")} ${JSON.stringify(row.vars)}`);
2051
2129
  if (row.output !== null) {
2052
2130
  lines.push(` ${s.dim("output:")} ${truncate(row.output, maxOut)}`);
2053
2131
  }
@@ -2068,6 +2146,7 @@ var init_console = __esm({
2068
2146
  "src/reporters/console.ts"() {
2069
2147
  "use strict";
2070
2148
  init_cjs_shims();
2149
+ init_results();
2071
2150
  init_style();
2072
2151
  init_helpers();
2073
2152
  DEFAULT_MAX_OUTPUT = 500;
@@ -2086,11 +2165,6 @@ var init_json = __esm({
2086
2165
  });
2087
2166
 
2088
2167
  // src/reporters/markdown.ts
2089
- function classify2(row) {
2090
- if (row.error !== void 0) return "errored";
2091
- if (row.assertions.some((a) => !a.passed)) return "failed";
2092
- return "passed";
2093
- }
2094
2168
  function fence(content) {
2095
2169
  const runs = content.match(new RegExp(TICK + "+", "g"));
2096
2170
  const longest = runs ? runs.reduce((m, r) => Math.max(m, r.length), 0) : 0;
@@ -2111,18 +2185,22 @@ function renderMarkdown(result, opts = {}) {
2111
2185
  out.push(`| Passed | ${summary.passed} |`);
2112
2186
  out.push(`| Failed | ${summary.failed} |`);
2113
2187
  out.push(`| Errored | ${summary.errored} |`);
2188
+ if ((summary.blocked ?? 0) > 0) out.push(`| Blocked | ${summary.blocked} |`);
2189
+ if ((summary.skipped ?? 0) > 0) out.push(`| Skipped | ${summary.skipped} |`);
2114
2190
  out.push(`| Cost (USD) | ${summary.cost_usd.toFixed(4)} |`);
2115
2191
  out.push(`| Duration (ms) | ${summary.duration_ms} |`);
2116
2192
  out.push("");
2117
2193
  const failures = [];
2194
+ let allPassed = true;
2118
2195
  for (const ev of result.evals) {
2119
2196
  for (const row of ev.rows) {
2120
- const state = classify2(row);
2121
- if (state !== "passed") failures.push({ id: ev.id, row, state });
2197
+ const state = rowStatus(row);
2198
+ if (state !== "passed") allPassed = false;
2199
+ if (state === "failed" || state === "errored") failures.push({ id: ev.id, row, state });
2122
2200
  }
2123
2201
  }
2124
2202
  if (failures.length === 0) {
2125
- out.push("All evals passed.");
2203
+ if (allPassed) out.push("All evals passed.");
2126
2204
  return out.join("\n");
2127
2205
  }
2128
2206
  out.push("## Failures");
@@ -2153,6 +2231,7 @@ var init_markdown = __esm({
2153
2231
  "src/reporters/markdown.ts"() {
2154
2232
  "use strict";
2155
2233
  init_cjs_shims();
2234
+ init_results();
2156
2235
  init_helpers();
2157
2236
  DEFAULT_MAX_OUTPUT2 = 500;
2158
2237
  TICK = String.fromCharCode(96);
@@ -2226,7 +2305,7 @@ var init_credentials = __esm({
2226
2305
 
2227
2306
  // src/reporter-core/client.ts
2228
2307
  function nodeTransport(url, init, idleMs = IDLE_MS) {
2229
- return new Promise((resolve8, reject) => {
2308
+ return new Promise((resolve9, reject) => {
2230
2309
  const target = new URL(url);
2231
2310
  const send = target.protocol === "https:" ? import_node_https.request : import_node_http.request;
2232
2311
  const req = send(target, { method: init.method, headers: init.headers }, (res) => {
@@ -2236,7 +2315,7 @@ function nodeTransport(url, init, idleMs = IDLE_MS) {
2236
2315
  res.on("end", () => {
2237
2316
  const status = res.statusCode ?? 0;
2238
2317
  const text2 = Buffer.concat(chunks).toString("utf8");
2239
- resolve8({
2318
+ resolve9({
2240
2319
  ok: status >= 200 && status <= 299,
2241
2320
  status,
2242
2321
  headers: {
@@ -2254,12 +2333,42 @@ function nodeTransport(url, init, idleMs = IDLE_MS) {
2254
2333
  req.end(init.body);
2255
2334
  });
2256
2335
  }
2336
+ function nodePut(url, headers, source, idleMs) {
2337
+ return new Promise((resolve9, reject) => {
2338
+ const target = new URL(url);
2339
+ const send = target.protocol === "https:" ? import_node_https.request : import_node_http.request;
2340
+ const length = "body" in source ? source.body.length : source.size;
2341
+ let answered;
2342
+ const settle = (err) => answered !== void 0 ? resolve9(answered) : reject(err);
2343
+ const req = send(
2344
+ target,
2345
+ { method: "PUT", headers: { ...withoutLength(headers), "content-length": String(length) } },
2346
+ (res) => {
2347
+ const retry = res.headers["retry-after"];
2348
+ answered = { status: res.statusCode ?? 0, retryAfter: Array.isArray(retry) ? retry[0] ?? null : retry ?? null };
2349
+ res.on("error", settle);
2350
+ res.on("close", () => {
2351
+ resolve9(answered);
2352
+ req.destroy();
2353
+ });
2354
+ res.resume();
2355
+ }
2356
+ );
2357
+ req.setTimeout(idleMs, () => req.destroy(Object.assign(new Error("idle"), { code: "ETIMEDOUT" })));
2358
+ req.on("error", settle);
2359
+ if ("body" in source) req.end(source.body);
2360
+ else (0, import_node_stream.pipeline)((0, import_node_fs2.createReadStream)(source.path), req, (err) => err ? settle(err) : void 0);
2361
+ });
2362
+ }
2257
2363
  async function detailOf(res) {
2258
2364
  try {
2259
2365
  const body = await res.json();
2260
- return typeof body.error === "string" ? body.error : "";
2366
+ return {
2367
+ detail: typeof body.error === "string" ? body.error : "",
2368
+ ...typeof body.code === "string" && /^[a-z0-9_]{1,60}$/.test(body.code) ? { code: body.code } : {}
2369
+ };
2261
2370
  } catch {
2262
- return "";
2371
+ return { detail: "" };
2263
2372
  }
2264
2373
  }
2265
2374
  function retryAfterMs2(res) {
@@ -2268,7 +2377,7 @@ function retryAfterMs2(res) {
2268
2377
  const seconds = Number(raw.trim());
2269
2378
  return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1e3 : null;
2270
2379
  }
2271
- function classify3(status) {
2380
+ function classify(status) {
2272
2381
  if (status === 401 || status === 403) return "auth";
2273
2382
  if (status === 409) return "conflict";
2274
2383
  if (status === 400) return "invalid";
@@ -2280,7 +2389,7 @@ function createClient(opts) {
2280
2389
  const wait = opts.wait ?? sleep;
2281
2390
  const base = opts.apiUrl.replace(/\/+$/, "");
2282
2391
  const scrub = (text2) => opts.token === "" ? text2 : text2.split(opts.token).join("***");
2283
- async function send(method, path15, payload) {
2392
+ async function send(method, path17, payload) {
2284
2393
  let last = {
2285
2394
  kind: "unavailable",
2286
2395
  status: null,
@@ -2289,7 +2398,7 @@ function createClient(opts) {
2289
2398
  for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) {
2290
2399
  let res;
2291
2400
  try {
2292
- res = await doFetch(`${base}${path15}`, {
2401
+ res = await doFetch(`${base}${path17}`, {
2293
2402
  method,
2294
2403
  headers: { "content-type": payload?.type ?? "application/json", authorization: `Bearer ${opts.token}` },
2295
2404
  // A DELETE carries none. Sending `"undefined"` as a body is what a naive
@@ -2310,10 +2419,16 @@ function createClient(opts) {
2310
2419
  return { ok: false, kind: "other", status: res.status, detail: "the answer was not JSON" };
2311
2420
  }
2312
2421
  }
2313
- const detail = scrub(await detailOf(res));
2314
- const kind = classify3(res.status);
2315
- last = { kind, status: res.status, detail };
2422
+ const said = await detailOf(res);
2423
+ const kind = classify(res.status);
2424
+ last = { kind, status: res.status, detail: scrub(said.detail), ...said.code !== void 0 ? { code: said.code } : {} };
2316
2425
  if (kind !== "unavailable") break;
2426
+ if (res.status === 429 && retryAfterMs2(res) === null) {
2427
+ last.kind = "invalid";
2428
+ last.detail = "quota exceeded (no Retry-After)";
2429
+ if (said.detail) last.detail += ` \u2014 ${scrub(said.detail)}`;
2430
+ break;
2431
+ }
2317
2432
  if (attempt < RETRY_DELAYS_MS.length) {
2318
2433
  await wait(retryAfterMs2(res) ?? RETRY_DELAYS_MS[attempt]);
2319
2434
  }
@@ -2321,20 +2436,57 @@ function createClient(opts) {
2321
2436
  return { ok: false, ...last };
2322
2437
  }
2323
2438
  return {
2324
- post: (path15, body) => send("POST", path15, { body: JSON.stringify(body), type: "application/json" }),
2325
- upload: (path15, bytes, contentType) => send("POST", path15, { body: bytes, type: contentType }),
2326
- del: (path15) => send("DELETE", path15)
2439
+ post: (path17, body) => send("POST", path17, { body: JSON.stringify(body), type: "application/json" }),
2440
+ upload: (path17, bytes, contentType) => send("POST", path17, { body: bytes, type: contentType }),
2441
+ del: (path17) => send("DELETE", path17),
2442
+ async put(url, headers, source) {
2443
+ let last = { ok: false, status: null, code: "network" };
2444
+ for (let attempt = 0; attempt <= PUT_DELAYS_MS.length; attempt += 1) {
2445
+ try {
2446
+ let status;
2447
+ let retryAfter;
2448
+ if (opts.fetchImpl === void 0) ({ status, retryAfter } = await nodePut(url, headers, source, IDLE_MS));
2449
+ else {
2450
+ const body = "body" in source ? source.body : await (0, import_node_fs2.openAsBlob)(source.path);
2451
+ const res = await opts.fetchImpl(url, { method: "PUT", headers: withoutLength(headers), body });
2452
+ await res.arrayBuffer().catch(() => void 0);
2453
+ status = res.status;
2454
+ retryAfter = res.headers.get("retry-after");
2455
+ }
2456
+ if (status >= 200 && status <= 299) return { ok: true };
2457
+ last = { ok: false, status, code: `bucket_${status}` };
2458
+ if (status < 500 && status !== 429) return last;
2459
+ const seconds = retryAfter === null ? NaN : Number(retryAfter.trim());
2460
+ if (attempt < PUT_DELAYS_MS.length) {
2461
+ await wait(Number.isFinite(seconds) && seconds >= 0 ? Math.min(seconds, 60) * 1e3 : PUT_DELAYS_MS[attempt]);
2462
+ }
2463
+ continue;
2464
+ } catch (err) {
2465
+ last = { ok: false, status: null, code: codeOfError(err) };
2466
+ }
2467
+ if (attempt < PUT_DELAYS_MS.length) await wait(PUT_DELAYS_MS[attempt]);
2468
+ }
2469
+ return last;
2470
+ }
2327
2471
  };
2328
2472
  }
2329
- var import_node_http, import_node_https, RETRY_DELAYS_MS, IDLE_MS, sleep;
2473
+ var import_node_fs2, import_node_stream, import_node_http, import_node_https, RETRY_DELAYS_MS, IDLE_MS, PUT_DELAYS_MS, withoutLength, codeOfError, sleep;
2330
2474
  var init_client = __esm({
2331
2475
  "src/reporter-core/client.ts"() {
2332
2476
  "use strict";
2333
2477
  init_cjs_shims();
2478
+ import_node_fs2 = require("fs");
2479
+ import_node_stream = require("stream");
2334
2480
  import_node_http = require("http");
2335
2481
  import_node_https = require("https");
2336
2482
  RETRY_DELAYS_MS = [300, 900];
2337
2483
  IDLE_MS = 3e5;
2484
+ PUT_DELAYS_MS = [500, 1500, 4e3];
2485
+ withoutLength = (headers) => Object.fromEntries(Object.entries(headers).filter(([name]) => name.toLowerCase() !== "content-length"));
2486
+ codeOfError = (err) => {
2487
+ const code = err?.code;
2488
+ return typeof code === "string" && /^[A-Z0-9_]{2,40}$/.test(code) ? code : "network";
2489
+ };
2338
2490
  sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2339
2491
  }
2340
2492
  });
@@ -2476,7 +2628,7 @@ function failureOf(attempt, home = (0, import_node_os2.homedir)()) {
2476
2628
  }
2477
2629
  function repoRootOf(dir, stopAt) {
2478
2630
  for (let here = (0, import_node_path2.resolve)(dir); ; here = (0, import_node_path2.dirname)(here)) {
2479
- if ((0, import_node_fs2.existsSync)((0, import_node_path2.join)(here, ".git"))) return here;
2631
+ if ((0, import_node_fs3.existsSync)((0, import_node_path2.join)(here, ".git"))) return here;
2480
2632
  if (here === stopAt || (0, import_node_path2.dirname)(here) === here) return void 0;
2481
2633
  }
2482
2634
  }
@@ -2496,12 +2648,12 @@ function machineless(raw, repoRoot, home) {
2496
2648
  if (repoRoot === void 0) text2 = text2.replace(ANY_HOME, "~");
2497
2649
  return text2;
2498
2650
  }
2499
- function pathIn(path15) {
2500
- const parts = path15.split(/[\\/]+/);
2651
+ function pathIn(path17) {
2652
+ const parts = path17.split(/[\\/]+/);
2501
2653
  while (parts.length > 1 && parts.at(-1) === "") parts.pop();
2502
2654
  if (!parts.some((part) => part !== "" && !/^[A-Za-z]:$/.test(part))) return void 0;
2503
2655
  const joined = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]+");
2504
- const escaped = /^\/(?!\/)/.test(path15) ? joined.replace("[\\\\/]+", "\\\\*\\/") : joined;
2656
+ const escaped = /^\/(?!\/)/.test(path17) ? joined.replace("[\\\\/]+", "\\\\*\\/") : joined;
2505
2657
  const url = "(?:(?:file|webpack|webpack-internal):(?:\\\\*\\/){2,3})?";
2506
2658
  return new RegExp(`${url}${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
2507
2659
  }
@@ -2546,35 +2698,36 @@ function placeOf(error, testFile) {
2546
2698
  }
2547
2699
  function fileOf(raw) {
2548
2700
  if (!raw.startsWith("file://")) return raw;
2549
- let path15 = raw.slice("file://".length);
2701
+ let path17 = raw.slice("file://".length);
2550
2702
  try {
2551
- path15 = decodeURIComponent(path15);
2703
+ path17 = decodeURIComponent(path17);
2552
2704
  } catch {
2553
2705
  }
2554
- return /^\/[A-Za-z]:\//.test(path15) ? path15.slice(1) : path15;
2706
+ return /^\/[A-Za-z]:\//.test(path17) ? path17.slice(1) : path17;
2555
2707
  }
2556
2708
  function fromRoot(place, root) {
2557
2709
  if (place === void 0 || root === void 0 || !Number.isInteger(place.line) || place.line < 1) return void 0;
2558
2710
  const base = normal(root).replace(/\/+$/, "");
2559
2711
  const file = normal(place.file);
2560
2712
  if (!file.startsWith(`${base}/`)) return void 0;
2561
- const relative2 = file.slice(base.length + 1);
2562
- if (!REPO_RELATIVE.test(relative2)) return void 0;
2713
+ const relative3 = file.slice(base.length + 1);
2714
+ if (!REPO_RELATIVE.test(relative3)) return void 0;
2563
2715
  const column = place.column !== void 0 && Number.isInteger(place.column) && place.column >= 1 ? place.column : void 0;
2564
- return column === void 0 ? { file: relative2, line: place.line } : { file: relative2, line: place.line, column };
2716
+ return column === void 0 ? { file: relative3, line: place.line } : { file: relative3, line: place.line, column };
2565
2717
  }
2566
2718
  function webLink(href) {
2567
2719
  return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
2568
2720
  }
2569
- var import_node_fs2, import_node_os2, import_node_path2, import_node_util, mediaTypeOf, TEXT_TYPES, isTextType, PATH_START, TEXT_LIMIT, HEADLINE_LIMIT, jsonBytes, ANY_HOME, fileNameOf, omitted, METADATA_TYPE, REPO_RELATIVE, FRAME, FRAME_MAX, firstLine, normal;
2721
+ var import_node_fs3, import_node_os2, import_node_path2, import_node_util, printable, mediaTypeOf, TEXT_TYPES, isTextType, PATH_START, TEXT_LIMIT, HEADLINE_LIMIT, jsonBytes, ANY_HOME, fileNameOf, omitted, METADATA_TYPE, REPO_RELATIVE, FRAME, FRAME_MAX, firstLine, normal;
2570
2722
  var init_failure_detail = __esm({
2571
2723
  "src/reporter-core/failure-detail.ts"() {
2572
2724
  "use strict";
2573
2725
  init_cjs_shims();
2574
- import_node_fs2 = require("fs");
2726
+ import_node_fs3 = require("fs");
2575
2727
  import_node_os2 = require("os");
2576
2728
  import_node_path2 = require("path");
2577
2729
  import_node_util = require("util");
2730
+ printable = (text2) => text2.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?");
2578
2731
  mediaTypeOf = (contentType) => contentType?.split(";")[0]?.trim().toLowerCase() || void 0;
2579
2732
  TEXT_TYPES = /* @__PURE__ */ new Set(["application/json", "text/plain"]);
2580
2733
  isTextType = (contentType) => TEXT_TYPES.has(mediaTypeOf(contentType) ?? "");
@@ -2590,7 +2743,7 @@ var init_failure_detail = __esm({
2590
2743
  FRAME = /^\s+at (?:.*? \()?(.+?):(\d+):(\d+)\)?$/;
2591
2744
  FRAME_MAX = 8192;
2592
2745
  firstLine = (text2) => (0, import_node_util.stripVTControlCharacters)(text2).split("\n").map((line) => line.trim()).find((line) => line !== "");
2593
- normal = (path15) => import_node_path2.posix.normalize(path15.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
2746
+ normal = (path17) => import_node_path2.posix.normalize(path17.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
2594
2747
  }
2595
2748
  });
2596
2749
 
@@ -2610,7 +2763,13 @@ function nameOf(file) {
2610
2763
  }
2611
2764
  function kindOf(type) {
2612
2765
  if (type.startsWith("image/")) return "screenshot";
2613
- return TEXT_TYPES.has(type) ? "text" : void 0;
2766
+ return TEXT_TYPES.has(type) ? "text" : "file";
2767
+ }
2768
+ function linkOf(item) {
2769
+ const link = item;
2770
+ if (typeof link?.id !== "string" || typeof link.url !== "string") return void 0;
2771
+ const headers = typeof link.headers === "object" && link.headers !== null ? link.headers : {};
2772
+ return { id: link.id, url: link.url, headers };
2614
2773
  }
2615
2774
  function textTypeOf(contentType, type) {
2616
2775
  const charset = /;\s*charset\s*=\s*"?([^\s";]+)"?/i.exec(contentType)?.[1];
@@ -2683,7 +2842,7 @@ function warnIfDropped(sent, run, log) {
2683
2842
  `plune: this deployment stored none of ${names.join(", ")} \u2014 it is older than the fields. The run is otherwise reported in full.`
2684
2843
  );
2685
2844
  }
2686
- async function startRun(passed, declared) {
2845
+ async function startRun(passed, declared2) {
2687
2846
  const env = readEnv();
2688
2847
  const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
2689
2848
  const cfg = { ...env.config, ...stated };
@@ -2711,7 +2870,8 @@ async function startRun(passed, declared) {
2711
2870
  unoffered: 0,
2712
2871
  deferred: 0,
2713
2872
  screenshots: { uploaded: 0, skipped: 0, failed: 0 },
2714
- textFiles: { uploaded: 0, skipped: 0, failed: 0 }
2873
+ textFiles: { uploaded: 0, skipped: 0, failed: 0 },
2874
+ otherFiles: { uploaded: 0, skipped: 0, failed: 0 }
2715
2875
  };
2716
2876
  const resolved = /* @__PURE__ */ new Map();
2717
2877
  let buffer = [];
@@ -2722,7 +2882,13 @@ async function startRun(passed, declared) {
2722
2882
  let done = false;
2723
2883
  const shortened = /* @__PURE__ */ new Set();
2724
2884
  const skipsSaid = /* @__PURE__ */ new Set();
2725
- const countsOf = (kind) => kind === "text" ? stats.textFiles : stats.screenshots;
2885
+ const countsOf = (kind) => kind === "text" ? stats.textFiles : kind === "file" ? stats.otherFiles : stats.screenshots;
2886
+ let bucketSeen = false;
2887
+ let noStorage = 0;
2888
+ let uploadsMissing = false;
2889
+ const failedSaid = /* @__PURE__ */ new Set();
2890
+ const codesSaid = /* @__PURE__ */ new Set();
2891
+ const rooms = /* @__PURE__ */ new Map();
2726
2892
  function bound(keys) {
2727
2893
  return keys.map((key) => {
2728
2894
  const fit = boundKey(key);
@@ -2730,14 +2896,14 @@ async function startRun(passed, declared) {
2730
2896
  shortened.add(key.value);
2731
2897
  if (shortened.size === 1) {
2732
2898
  log(
2733
- `plune: a test name longer than ${KEY_MAX} characters was shortened to fit its key \u2014 "${key.value.slice(0, 80)}\u2026". Give the test a shorter name; until then this is its identity.`
2899
+ `plune: a test name longer than ${KEY_MAX} characters was shortened to fit its key \u2014 ${JSON.stringify(printable(key.value.slice(0, 80)))}\u2026 Give the test a shorter name; until then this is its identity.`
2734
2900
  );
2735
2901
  }
2736
2902
  }
2737
2903
  return fit;
2738
2904
  });
2739
2905
  }
2740
- const expected = declared?.map(bound);
2906
+ const expected = declared2?.map(bound);
2741
2907
  if (offline) {
2742
2908
  log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
2743
2909
  }
@@ -2860,20 +3026,49 @@ async function startRun(passed, declared) {
2860
3026
  }
2861
3027
  function skip(kind, name, why) {
2862
3028
  countsOf(kind).skipped += 1;
2863
- if (skipsSaid.has(`${kind}/${why}`)) return;
2864
- skipsSaid.add(`${kind}/${why}`);
2865
- log(`plune: ${KINDS[kind].label} "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
3029
+ if (why === "noStorage") noStorage += 1;
3030
+ const said = why === "noStorage" ? why : `${kind}/${why}`;
3031
+ if (skipsSaid.has(said)) return;
3032
+ skipsSaid.add(said);
3033
+ if (why === "noStorage") log(`plune: "${name}" not stored \u2014 ${SKIPPED[why]}.`);
3034
+ else log(`plune: ${KINDS[kind].label} "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
3035
+ }
3036
+ function ask(job, size) {
3037
+ return client.post(`/v1/results/${encodeURIComponent(job.id)}/files/uploads`, {
3038
+ files: [{ name: job.name, contentType: job.type, size }]
3039
+ });
3040
+ }
3041
+ function failed(job, why) {
3042
+ countsOf(job.kind).failed += 1;
3043
+ if (!failedSaid.has(job.kind)) {
3044
+ failedSaid.add(job.kind);
3045
+ log(`plune: could not upload a ${KINDS[job.kind].label} (${why}) \u2014 its result is reported without it.`);
3046
+ }
3047
+ }
3048
+ function failedToBucket(job, code) {
3049
+ countsOf(job.kind).failed += 1;
3050
+ if (codesSaid.has(code)) return;
3051
+ codesSaid.add(code);
3052
+ log(`plune: "${job.name}" not uploaded to your bucket (${code}) \u2014 its result is reported without it.`);
3053
+ }
3054
+ function refusedHere(job, size) {
3055
+ if (job.kind === "screenshot") {
3056
+ if (!SCREENSHOT_TYPES.has(job.type)) return "type";
3057
+ if (size > SCREENSHOT_BYTES) return "size";
3058
+ }
3059
+ if (job.kind === "text" && size > TEXT_BYTES) return "textSize";
3060
+ return void 0;
2866
3061
  }
2867
- async function uploadOne(job) {
3062
+ async function uploadDirect(job, size) {
2868
3063
  const { kind } = job;
2869
- const { label, bytes: ceiling, size } = KINDS[kind];
3064
+ if (kind === "file") return skip(kind, job.name, "noStorage");
3065
+ const { bytes: ceiling, size: tooBig } = KINDS[kind];
3066
+ if (size > ceiling) return skip(kind, job.name, tooBig);
2870
3067
  let bytes;
2871
3068
  if (job.body !== void 0) bytes = job.body;
2872
- else if (job.path === void 0) return skip(kind, job.name, "missing");
2873
3069
  else {
2874
3070
  try {
2875
- if ((await import_node_fs3.promises.stat(job.path)).size > ceiling) return skip(kind, job.name, size);
2876
- bytes = await import_node_fs3.promises.readFile(job.path);
3071
+ bytes = await import_node_fs4.promises.readFile(job.path);
2877
3072
  } catch {
2878
3073
  return skip(kind, job.name, "missing");
2879
3074
  }
@@ -2885,32 +3080,96 @@ async function startRun(passed, declared) {
2885
3080
  bytes,
2886
3081
  job.type
2887
3082
  );
2888
- const counts = countsOf(kind);
2889
- if (out.ok) {
2890
- counts.uploaded += 1;
2891
- return;
3083
+ if (out.ok) countsOf(kind).uploaded += 1;
3084
+ else failed(job, out.detail || out.kind);
3085
+ }
3086
+ async function uploadToBucket(job, size, first) {
3087
+ const source = job.body !== void 0 ? { body: job.body } : { path: job.path, size };
3088
+ let link = first;
3089
+ for (let renewed = false; ; ) {
3090
+ const put = await client.put(link.url, link.headers, source);
3091
+ if (put.ok) break;
3092
+ if (put.status !== 403 || renewed) return failedToBucket(job, put.code);
3093
+ renewed = true;
3094
+ const again = await ask(job, size);
3095
+ if (!again.ok) return failedToBucket(job, codeOf2(again));
3096
+ const fresh = linkOf(again.body?.files?.[0]);
3097
+ if (fresh === void 0) return failedToBucket(job, "bad_answer");
3098
+ link = fresh;
3099
+ }
3100
+ const done2 = await client.post(`/v1/files/${encodeURIComponent(link.id)}/complete`, {});
3101
+ if (!done2.ok) return failedToBucket(job, codeOf2(done2));
3102
+ countsOf(job.kind).uploaded += 1;
3103
+ }
3104
+ let free = UPLOADS_AT_ONCE;
3105
+ const waiting = [];
3106
+ async function gated(work) {
3107
+ if (free === 0) await new Promise((resolve9) => waiting.push(resolve9));
3108
+ else free -= 1;
3109
+ try {
3110
+ return await work();
3111
+ } finally {
3112
+ const next = waiting.shift();
3113
+ if (next !== void 0) next();
3114
+ else free += 1;
3115
+ }
3116
+ }
3117
+ async function plan(job) {
3118
+ const { kind } = job;
3119
+ let size;
3120
+ if (job.body !== void 0) size = job.body.length;
3121
+ else if (job.path === void 0) return void skip(kind, job.name, "missing");
3122
+ else {
3123
+ try {
3124
+ size = (await import_node_fs4.promises.stat(job.path)).size;
3125
+ } catch {
3126
+ return void skip(kind, job.name, "missing");
3127
+ }
3128
+ }
3129
+ if (size === 0) return void skip(kind, job.name, "empty");
3130
+ const direct = () => {
3131
+ if (kind === "file") return void skip(kind, job.name, "noStorage");
3132
+ const roomKey = `${job.id}/${kind}`;
3133
+ const left = rooms.get(roomKey) ?? KINDS[kind].room;
3134
+ if (left === 0) return void skip(kind, job.name, KINDS[kind].limit);
3135
+ rooms.set(roomKey, left - 1);
3136
+ return () => uploadDirect(job, size);
3137
+ };
3138
+ if (uploadsMissing) return direct();
3139
+ const out = await gated(() => ask(job, size));
3140
+ if (!out.ok) {
3141
+ if (out.status === 404 && out.detail === "" && out.code === void 0) {
3142
+ uploadsMissing = true;
3143
+ return direct();
3144
+ }
3145
+ if (out.code !== void 0) failedToBucket(job, out.code);
3146
+ else failed(job, out.detail || out.kind);
3147
+ return void 0;
2892
3148
  }
2893
- counts.failed += 1;
2894
- if (counts.failed === 1) {
2895
- log(`plune: could not upload a ${label} (${out.detail || out.kind}) \u2014 its result is reported without it.`);
3149
+ const item = out.body?.files?.[0];
3150
+ if (item?.skipped === "no_storage") return void skip(kind, job.name, refusedHere(job, size) ?? "noStorage");
3151
+ if (item?.direct === true) return direct();
3152
+ const link = linkOf(item);
3153
+ if (link === void 0) return void failed(job, "bad_answer");
3154
+ if (!bucketSeen) {
3155
+ bucketSeen = true;
3156
+ log(BUCKET_FOUND);
2896
3157
  }
3158
+ return () => uploadToBucket(job, size, link);
2897
3159
  }
2898
3160
  async function upload(items, filesByKey) {
2899
3161
  if (filesByKey.size === 0 || !Array.isArray(items)) return;
2900
3162
  const jobs = [];
2901
3163
  for (const item of items) {
2902
3164
  if (item?.status !== "accepted" || typeof item.id !== "string" || typeof item.resultKey !== "string") continue;
2903
- const room = { screenshot: KINDS.screenshot.room, text: KINDS.text.room };
2904
3165
  for (const file of filesByKey.get(item.resultKey) ?? []) {
2905
3166
  const type = mediaTypeOf(file.contentType) ?? (file.path === void 0 ? void 0 : TYPE_BY_EXTENSION[(0, import_node_path3.extname)(file.path).toLowerCase()]);
2906
- const kind = type === void 0 ? void 0 : kindOf(type);
2907
- if (type === void 0 || kind === void 0 || kind === "screenshot" && file.path === void 0) continue;
3167
+ if (type === void 0 || type === METADATA_TYPE2) continue;
3168
+ const kind = kindOf(type);
3169
+ if (kind !== "text" && file.path === void 0) continue;
2908
3170
  const name = nameOf(file);
2909
3171
  if (file.name.startsWith("_")) skip(kind, name, "hidden");
2910
- else if (kind === "screenshot" && !SCREENSHOT_TYPES.has(type)) skip(kind, name, "type");
2911
- else if (room[kind] === 0) skip(kind, name, KINDS[kind].limit);
2912
3172
  else {
2913
- room[kind] -= 1;
2914
3173
  jobs.push({
2915
3174
  id: item.id,
2916
3175
  kind,
@@ -2922,10 +3181,16 @@ async function startRun(passed, declared) {
2922
3181
  }
2923
3182
  }
2924
3183
  }
2925
- let next = 0;
3184
+ const byResult = /* @__PURE__ */ new Map();
3185
+ for (const job of jobs) byResult.set(job.id, [...byResult.get(job.id) ?? [], job]);
2926
3186
  await Promise.all(
2927
- Array.from({ length: Math.min(UPLOADS_AT_ONCE, jobs.length) }, async () => {
2928
- while (next < jobs.length) await uploadOne(jobs[next++]);
3187
+ [...byResult.values()].map(async (group) => {
3188
+ const work = [];
3189
+ for (const job of group) {
3190
+ const act = await plan(job);
3191
+ if (act !== void 0) work.push(gated(act));
3192
+ }
3193
+ await Promise.all(work);
2929
3194
  })
2930
3195
  );
2931
3196
  }
@@ -3003,8 +3268,20 @@ async function startRun(passed, declared) {
3003
3268
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
3004
3269
  if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
3005
3270
  if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
3006
- for (const said of [tally(stats.screenshots, "screenshot"), tally(stats.textFiles, "text file")]) {
3007
- if (said !== void 0) parts.push(said);
3271
+ const kinds = [stats.screenshots, stats.textFiles, stats.otherFiles];
3272
+ const sum = (what) => kinds.reduce((n, counts) => n + counts[what], 0);
3273
+ if (bucketSeen) {
3274
+ const other = sum("skipped") - noStorage;
3275
+ parts.push(
3276
+ `${sum("uploaded")} file(s) uploaded to your bucket` + (noStorage > 0 ? `, ${noStorage} skipped (no bucket connected)` : "") + (other > 0 ? `, ${other} skipped` : "") + (sum("failed") > 0 ? `, ${sum("failed")} failed to upload` : "")
3277
+ );
3278
+ } else {
3279
+ for (const said of [tally(stats.screenshots, "screenshot"), tally(stats.textFiles, "text file")]) {
3280
+ if (said !== void 0) parts.push(said);
3281
+ }
3282
+ const other = stats.otherFiles;
3283
+ if (other.skipped > 0) parts.push(`${other.skipped} file(s) not stored (no bucket connected)`);
3284
+ if (other.failed > 0) parts.push(`${other.failed} file(s) failed to upload`);
3008
3285
  }
3009
3286
  if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
3010
3287
  if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
@@ -3057,13 +3334,13 @@ async function startRun(passed, declared) {
3057
3334
  }
3058
3335
  };
3059
3336
  }
3060
- var import_node_crypto2, import_node_fs3, import_node_path3, import_node_util2, RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX, KEY_MAX, TITLE_MAX, COUNTED, SCREENSHOT_TYPES, SCREENSHOT_BYTES, SCREENSHOTS_PER_RESULT, TEXT_BYTES, TEXTS_PER_RESULT, UTF8, TYPE_BY_EXTENSION, UPLOADS_AT_ONCE, NAME_MAX, SKIPPED, KINDS, BATCH_BYTES, ENVELOPE_BYTES;
3337
+ var import_node_crypto2, import_node_fs4, import_node_path3, import_node_util2, RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX, KEY_MAX, TITLE_MAX, COUNTED, SCREENSHOT_TYPES, SCREENSHOT_BYTES, SCREENSHOTS_PER_RESULT, TEXT_BYTES, TEXTS_PER_RESULT, UTF8, TYPE_BY_EXTENSION, METADATA_TYPE2, UPLOADS_AT_ONCE, NAME_MAX, SKIPPED, BUCKET_FOUND, KINDS, codeOf2, BATCH_BYTES, ENVELOPE_BYTES;
3061
3338
  var init_session = __esm({
3062
3339
  "src/reporter-core/session.ts"() {
3063
3340
  "use strict";
3064
3341
  init_cjs_shims();
3065
3342
  import_node_crypto2 = require("crypto");
3066
- import_node_fs3 = require("fs");
3343
+ import_node_fs4 = require("fs");
3067
3344
  import_node_path3 = require("path");
3068
3345
  import_node_util2 = require("util");
3069
3346
  init_api_url();
@@ -3092,6 +3369,7 @@ var init_session = __esm({
3092
3369
  ".jpeg": "image/jpeg",
3093
3370
  ".webp": "image/webp"
3094
3371
  };
3372
+ METADATA_TYPE2 = "application/plune.metadata+json";
3095
3373
  UPLOADS_AT_ONCE = 4;
3096
3374
  NAME_MAX = 200;
3097
3375
  SKIPPED = {
@@ -3104,12 +3382,17 @@ var init_session = __esm({
3104
3382
  textSize: "Plune takes a text file of at most 512 KiB",
3105
3383
  empty: "the file is empty",
3106
3384
  binary: "the file holds a NUL byte, and Plune takes UTF-8 text without one",
3107
- encoding: "Plune takes UTF-8 text only"
3385
+ encoding: "Plune takes UTF-8 text only",
3386
+ noStorage: "no storage bucket is connected to this project"
3108
3387
  };
3388
+ BUCKET_FOUND = "plune: storage bucket found for this project \u2014 screenshots, text, trace and video go straight to it over short-lived links.";
3109
3389
  KINDS = {
3110
3390
  screenshot: { label: "screenshot", bytes: SCREENSHOT_BYTES, room: SCREENSHOTS_PER_RESULT, limit: "limit", size: "size" },
3111
- text: { label: "text file", bytes: TEXT_BYTES, room: TEXTS_PER_RESULT, limit: "textLimit", size: "textSize" }
3391
+ text: { label: "text file", bytes: TEXT_BYTES, room: TEXTS_PER_RESULT, limit: "textLimit", size: "textSize" },
3392
+ // Only the label is used: a file of this kind has no home in the database, so no ceiling of its own is known here.
3393
+ file: { label: "file", bytes: 0, room: 0, limit: "limit", size: "size" }
3112
3394
  };
3395
+ codeOf2 = (out) => out.code ?? (out.status === null ? "network" : `http_${out.status}`);
3113
3396
  BATCH_BYTES = 8 * 1024 * 1024;
3114
3397
  ENVELOPE_BYTES = Buffer.byteLength(JSON.stringify({ results: [] }));
3115
3398
  }
@@ -3200,21 +3483,21 @@ async function handleRunDelete(id, options = {}) {
3200
3483
  );
3201
3484
  }
3202
3485
  write(`Run ${id} deleted, with its results and the review-queue entries it produced.`);
3203
- write("Recoverable for six months \u2014 ask Plune to put it back.");
3486
+ write("Recoverable for 30 days \u2014 an owner of the project can put it back from Settings \u2192 Bin.");
3204
3487
  }
3205
3488
  async function handleRunExec(options) {
3206
3489
  const [command, ...args] = options.argv;
3207
3490
  if (command === void 0) throw new RunCommandError("Nothing to run \u2014 pass a command after `--`.");
3208
3491
  const run = await handleRunStart(options);
3209
3492
  const spawnImpl = options.spawnImpl ?? import_node_child_process.spawn;
3210
- const code = await new Promise((resolve8) => {
3493
+ const code = await new Promise((resolve9) => {
3211
3494
  const child = spawnImpl(command, args, {
3212
3495
  stdio: "inherit",
3213
3496
  shell: process.platform === "win32",
3214
3497
  env: { ...process.env, PLUNE_RUN: run.externalKey, PLUNE_PROCEED: "1" }
3215
3498
  });
3216
- child.on("close", (exitCode) => resolve8(exitCode ?? 1));
3217
- child.on("error", () => resolve8(1));
3499
+ child.on("close", (exitCode) => resolve9(exitCode ?? 1));
3500
+ child.on("error", () => resolve9(1));
3218
3501
  });
3219
3502
  await handleRunFinish(run.id, options);
3220
3503
  return code;
@@ -3345,6 +3628,346 @@ var init_result_key = __esm({
3345
3628
  }
3346
3629
  });
3347
3630
 
3631
+ // src/importers/attachments.ts
3632
+ function reportFolder(report) {
3633
+ const folder = path9.dirname(path9.resolve(report));
3634
+ try {
3635
+ return fs8.realpathSync(folder);
3636
+ } catch {
3637
+ return folder;
3638
+ }
3639
+ }
3640
+ function reportFolders(report) {
3641
+ return { lexical: path9.dirname(path9.resolve(report)), real: reportFolder(report) };
3642
+ }
3643
+ function within(folder, target) {
3644
+ const rel = path9.relative(folder, target);
3645
+ return rel === "" || rel !== ".." && !rel.startsWith(`..${path9.sep}`) && !path9.isAbsolute(rel);
3646
+ }
3647
+ function resolveAttachment(folder, ref, lexical = folder) {
3648
+ if (/^[\\/]{2}/.test(ref) || ref.includes("\0")) return { why: "outside" };
3649
+ const face = path9.resolve(lexical, ref);
3650
+ if (!within(lexical, face) && !within(folder, face)) return { why: "outside" };
3651
+ let real;
3652
+ try {
3653
+ real = fs8.realpathSync(path9.resolve(folder, ref));
3654
+ } catch {
3655
+ return { why: "absent" };
3656
+ }
3657
+ if (!within(folder, real)) return { why: "outside" };
3658
+ try {
3659
+ return fs8.statSync(real).isFile() ? { file: real } : { why: "not-a-file" };
3660
+ } catch {
3661
+ return { why: "absent" };
3662
+ }
3663
+ }
3664
+ function markersOf(test) {
3665
+ const refs = /* @__PURE__ */ new Set();
3666
+ for (const stream of test.children) {
3667
+ if (stream.name !== "system-out" && stream.name !== "system-err") continue;
3668
+ for (const match of stream.text.matchAll(MARKER)) {
3669
+ const ref = match[1]?.trim() ?? "";
3670
+ if (ref !== "") refs.add(ref);
3671
+ }
3672
+ }
3673
+ return [...refs];
3674
+ }
3675
+ function filesOfTest(test, folder, title, missing, options = {}) {
3676
+ const files = [];
3677
+ const all = markersOf(test);
3678
+ if (all.length > MARKERS_PER_TEST) {
3679
+ options.notes?.push(
3680
+ `plune: ${JSON.stringify(printable(title))}: ${all.length - MARKERS_PER_TEST} more markers ignored (at most ${MARKERS_PER_TEST} per test)`
3681
+ );
3682
+ }
3683
+ for (const ref of all.slice(0, MARKERS_PER_TEST)) {
3684
+ const found = resolveAttachment(folder, ref, options.lexical);
3685
+ if (!("file" in found)) {
3686
+ missing.push({ title, path: ref, ...options.resultKey !== void 0 ? { resultKey: options.resultKey } : {} });
3687
+ continue;
3688
+ }
3689
+ const real = found.file;
3690
+ if (!files.some((f) => f.path === real)) {
3691
+ files.push({ name: printable(ref.split(/[\\/]/).pop() || path9.basename(real)), contentType: typeOfFile(real), path: real });
3692
+ }
3693
+ }
3694
+ return files;
3695
+ }
3696
+ var fs8, path9, MARKER, TYPES, typeOfFile, MARKERS_PER_TEST;
3697
+ var init_attachments = __esm({
3698
+ "src/importers/attachments.ts"() {
3699
+ "use strict";
3700
+ init_cjs_shims();
3701
+ fs8 = __toESM(require("fs"), 1);
3702
+ path9 = __toESM(require("path"), 1);
3703
+ init_failure_detail();
3704
+ MARKER = /^[ \t]*\[\[ATTACHMENT\|([^\r\n]*?)\]\][ \t]*$/gm;
3705
+ TYPES = {
3706
+ ".png": "image/png",
3707
+ ".jpg": "image/jpeg",
3708
+ ".jpeg": "image/jpeg",
3709
+ ".webp": "image/webp",
3710
+ ".gif": "image/gif",
3711
+ ".svg": "image/svg+xml",
3712
+ ".json": "application/json",
3713
+ ".txt": "text/plain",
3714
+ ".log": "text/plain",
3715
+ ".md": "text/plain",
3716
+ ".csv": "text/csv",
3717
+ ".html": "text/html",
3718
+ ".htm": "text/html",
3719
+ ".xml": "application/xml",
3720
+ ".pdf": "application/pdf",
3721
+ ".zip": "application/zip",
3722
+ ".webm": "video/webm",
3723
+ ".mp4": "video/mp4",
3724
+ ".har": "application/json"
3725
+ };
3726
+ typeOfFile = (file) => TYPES[path9.extname(file).toLowerCase()] ?? "application/octet-stream";
3727
+ MARKERS_PER_TEST = 100;
3728
+ }
3729
+ });
3730
+
3731
+ // src/importers/allure.ts
3732
+ function isFolder(path17) {
3733
+ try {
3734
+ return fs9.statSync(path17).isDirectory();
3735
+ } catch {
3736
+ return false;
3737
+ }
3738
+ }
3739
+ function load(file, maxBytes) {
3740
+ let size;
3741
+ try {
3742
+ size = fs9.statSync(file).size;
3743
+ } catch (err) {
3744
+ throw new AllureReportError(`${printable(file)} \u2014 cannot read \u2014 ${printable(err instanceof Error ? err.message : String(err))}`);
3745
+ }
3746
+ if (size > maxBytes) {
3747
+ throw new AllureReportError(`${printable(file)} \u2014 ${size} bytes, over the ${maxBytes} an Allure result may have; not read.`);
3748
+ }
3749
+ let parsed;
3750
+ try {
3751
+ parsed = JSON.parse(fs9.readFileSync(file, "utf8"));
3752
+ } catch (err) {
3753
+ throw new AllureReportError(`${printable(file)} \u2014 not JSON \u2014 ${printable(err instanceof Error ? err.message : String(err))}`);
3754
+ }
3755
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
3756
+ throw new AllureReportError(`${printable(file)} \u2014 not an Allure result: expected a JSON object.`);
3757
+ }
3758
+ return parsed;
3759
+ }
3760
+ function listOf(v) {
3761
+ const out = [];
3762
+ if (!Array.isArray(v)) return out;
3763
+ for (const x of v) if (typeof x === "object" && x !== null && !Array.isArray(x)) out.push(x);
3764
+ return out;
3765
+ }
3766
+ function keyOf(r, name) {
3767
+ const full = textOf(r.fullName);
3768
+ if (name.endsWith("]") && full.includes("#")) return `${full.split("#")[0]}#${name}`;
3769
+ let base;
3770
+ if (full.includes(" \u203A ")) base = full.split(" \u203A ").join("#");
3771
+ else if (full.includes("#")) base = full;
3772
+ else if (labelOf(r, "package") !== "") base = `${labelOf(r, "package")}#${name}`;
3773
+ else base = full !== "" ? full : name;
3774
+ const params = listOf(r.parameters).map((p) => textOf(p.value));
3775
+ return params.length > 0 && !base.endsWith("]") ? `${base}[${params.join("-")}]` : base;
3776
+ }
3777
+ function labelGroup(r) {
3778
+ const out = [];
3779
+ for (const l of ["parentSuite", "suite", "subSuite"]) {
3780
+ const v = labelOf(r, l);
3781
+ if (v !== "" && out.at(-1) !== v) out.push(v);
3782
+ }
3783
+ return out;
3784
+ }
3785
+ function attachmentsOf(holder, out) {
3786
+ let deep = false;
3787
+ const todo = [{ h: holder, depth: 0 }];
3788
+ while (todo.length > 0) {
3789
+ const { h, depth } = todo.pop();
3790
+ for (const a of listOf(h.attachments)) out.push(a);
3791
+ const steps = listOf(h.steps);
3792
+ if (depth >= MAX_DEPTH) {
3793
+ deep ||= steps.length > 0;
3794
+ continue;
3795
+ }
3796
+ for (let i = steps.length - 1; i >= 0; i--) todo.push({ h: steps[i], depth: depth + 1 });
3797
+ }
3798
+ return deep;
3799
+ }
3800
+ function readAllure(dir, missing = [], notes = [], maxBytes = MAX_JSON_BYTES) {
3801
+ if (!isFolder(dir)) throw new AllureReportError(`${printable(dir)} \u2014 not a folder.`);
3802
+ const resultNames = namesIn(dir, RESULT);
3803
+ if (resultNames.length === 0) {
3804
+ throw new AllureReportError(
3805
+ `${printable(dir)} \u2014 no *-result.json files in it, so there is no Allure report to read. Point at the folder an Allure adapter wrote (pytest --alluredir, allure-results), or at a JUnit or Playwright JSON file.`
3806
+ );
3807
+ }
3808
+ const { lexical, real: folder } = reportFolders(path10.join(dir, "_"));
3809
+ const tests = resultNames.map((n) => load(path10.join(dir, n), maxBytes));
3810
+ const containers = namesIn(dir, CONTAINER).map((n) => load(path10.join(dir, n), maxBytes));
3811
+ const direct = /* @__PURE__ */ new Map();
3812
+ for (const c of containers) {
3813
+ for (const child of Array.isArray(c.children) ? c.children : []) {
3814
+ if (typeof child !== "string") continue;
3815
+ const list2 = direct.get(child);
3816
+ if (list2 === void 0) direct.set(child, [c]);
3817
+ else list2.push(c);
3818
+ }
3819
+ }
3820
+ const around = (uuid) => {
3821
+ const seen = /* @__PURE__ */ new Set();
3822
+ const todo = [uuid];
3823
+ while (todo.length > 0) {
3824
+ for (const c of direct.get(todo.pop()) ?? []) {
3825
+ if (seen.has(c)) continue;
3826
+ seen.add(c);
3827
+ if (typeof c.uuid === "string") todo.push(c.uuid);
3828
+ }
3829
+ }
3830
+ return [...seen];
3831
+ };
3832
+ const ctx = { folder, lexical, missing, notes, around, direct };
3833
+ const groups = /* @__PURE__ */ new Map();
3834
+ tests.forEach((r, i) => {
3835
+ const id = textOf(r.historyId);
3836
+ const g = id === "" ? `\0${i}` : id;
3837
+ const list2 = groups.get(g);
3838
+ if (list2 === void 0) groups.set(g, [r]);
3839
+ else list2.push(r);
3840
+ });
3841
+ const out = [];
3842
+ for (const attempts of groups.values()) {
3843
+ const ordered = attempts.map((r, i) => ({ r, i })).sort(
3844
+ (a, b) => stopOf(a.r) - stopOf(b.r) || (timeOf(a.r.start) ?? 0) - (timeOf(b.r.start) ?? 0) || compareText(textOf(a.r.uuid), textOf(b.r.uuid)) || a.i - b.i
3845
+ ).map((x) => x.r);
3846
+ ordered.forEach((r, retry) => out.push(pendingOf(r, retry, ctx)));
3847
+ }
3848
+ return out;
3849
+ }
3850
+ function pendingOf(r, retry, ctx) {
3851
+ const name = textOf(r.name);
3852
+ const uuid = textOf(r.uuid);
3853
+ const key = keyOf(r, name);
3854
+ const ancestors = uuid === "" ? [] : ctx.around(uuid);
3855
+ const suite = (uuid === "" ? [] : ctx.direct.get(uuid) ?? []).filter((c) => textOf(c.name) !== "" && c.name !== name).sort((a, b) => (Array.isArray(b.children) ? b.children.length : 0) - (Array.isArray(a.children) ? a.children.length : 0))[0];
3856
+ const group = suite !== void 0 ? [textOf(suite.name)] : labelGroup(r);
3857
+ const title = [...group, name].join(" \u203A ");
3858
+ const safeTitle = printable(title);
3859
+ const rkey = resultKey(key, retry);
3860
+ const files = [];
3861
+ const announced = /* @__PURE__ */ new Set();
3862
+ const ignored = /* @__PURE__ */ new Set();
3863
+ const announce = (att) => {
3864
+ const source = textOf(att.source);
3865
+ if (source === "" || announced.has(source) || ignored.has(source)) return;
3866
+ if (announced.size >= MARKERS_PER_TEST) {
3867
+ ignored.add(source);
3868
+ return;
3869
+ }
3870
+ announced.add(source);
3871
+ const found = resolveAttachment(ctx.folder, source, ctx.lexical);
3872
+ if (!("file" in found)) {
3873
+ ctx.missing.push({ title: safeTitle, resultKey: rkey, path: source });
3874
+ return;
3875
+ }
3876
+ if (files.some((f) => f.path === found.file)) return;
3877
+ const type = textOf(att.type).trim();
3878
+ const name2 = printable(textOf(att.name) !== "" ? textOf(att.name) : path10.basename(found.file));
3879
+ files.push({ name: name2, contentType: type !== "" ? type : typeOfFile(found.file), path: found.file });
3880
+ };
3881
+ const holders = [];
3882
+ for (const c of [...ancestors].reverse()) for (const h of listOf(c.befores)) holders.push(h);
3883
+ holders.push(r);
3884
+ for (const c of ancestors) for (const h of listOf(c.afters)) holders.push(h);
3885
+ const atts = [];
3886
+ let deep = false;
3887
+ for (const h of holders) deep = attachmentsOf(h, atts) || deep;
3888
+ atts.forEach(announce);
3889
+ if (ignored.size > 0) {
3890
+ ctx.notes.push(`plune: ${JSON.stringify(safeTitle)}: ${ignored.size} more markers ignored (at most ${MARKERS_PER_TEST} per test)`);
3891
+ }
3892
+ if (deep) ctx.notes.push(`plune: ${JSON.stringify(safeTitle)}: steps nested deeper than ${MAX_DEPTH} are ignored`);
3893
+ const status = textOf(r.status) !== "" ? textOf(r.status) : "unknown";
3894
+ const details = typeof r.statusDetails === "object" && r.statusDetails !== null ? r.statusDetails : {};
3895
+ const message = textOf(details.message);
3896
+ const trace = textOf(details.trace);
3897
+ const attempt = {
3898
+ status,
3899
+ errors: [{ text: [message, trace].filter((s) => s !== "").join("\n\n") }],
3900
+ steps: declared(r.steps),
3901
+ attachments: files.map(({ name: n, contentType, path: p }) => ({
3902
+ name: n,
3903
+ ...contentType !== void 0 ? { contentType } : {},
3904
+ ...p !== void 0 ? { path: p } : {}
3905
+ })),
3906
+ testFile: ""
3907
+ };
3908
+ const errorContext = errorContextOf(attempt);
3909
+ const failure = FAILED.has(status) ? failureOf(attempt) : void 0;
3910
+ const pkg2 = labelOf(r, "package");
3911
+ const where = (key.includes("#") ? key.split("#")[0] : pkg2)?.replace(/^[A-Za-z][A-Za-z0-9_-]+:(?![\\/])/, "");
3912
+ const stated = TOKEN.exec(name)?.[1];
3913
+ const started = timeOf(r.start);
3914
+ const stopped = timeOf(r.stop);
3915
+ const durationMs = started !== void 0 && stopped !== void 0 ? Math.max(0, stopped - started) : 0;
3916
+ return {
3917
+ resultKey: rkey,
3918
+ ...stated !== void 0 ? { testCaseId: stated } : {},
3919
+ keys: [{ kind: "path-title", value: key }],
3920
+ source: SOURCE,
3921
+ rawStatus: status,
3922
+ title,
3923
+ ...where !== void 0 && where !== "" ? { specRef: where } : {},
3924
+ ...started !== void 0 ? {
3925
+ execution: {
3926
+ startedAt: new Date(started).toISOString(),
3927
+ finishedAt: new Date(started + durationMs).toISOString(),
3928
+ durationMs,
3929
+ retry
3930
+ }
3931
+ } : {},
3932
+ ...errorContext !== "" ? { errorContext } : {},
3933
+ ...failure !== void 0 ? { failure } : {},
3934
+ ...files.length > 0 ? { files } : {}
3935
+ };
3936
+ }
3937
+ var fs9, path10, SOURCE, TOKEN, AllureReportError, RESULT, CONTAINER, MAX_JSON_BYTES, MAX_DEPTH, MAX_TIME, namesIn, timeOf, textOf, labelOf, FAILED, declared, stopOf, compareText;
3938
+ var init_allure = __esm({
3939
+ "src/importers/allure.ts"() {
3940
+ "use strict";
3941
+ init_cjs_shims();
3942
+ fs9 = __toESM(require("fs"), 1);
3943
+ path10 = __toESM(require("path"), 1);
3944
+ init_failure_detail();
3945
+ init_result_key();
3946
+ init_attachments();
3947
+ SOURCE = "allure";
3948
+ TOKEN = /@P([A-Za-z0-9_-]+)/;
3949
+ AllureReportError = class extends Error {
3950
+ constructor(message) {
3951
+ super(message);
3952
+ this.name = "AllureReportError";
3953
+ }
3954
+ };
3955
+ RESULT = /-result\.json$/;
3956
+ CONTAINER = /-container\.json$/;
3957
+ MAX_JSON_BYTES = 64 * 1024 * 1024;
3958
+ MAX_DEPTH = 256;
3959
+ MAX_TIME = 864e13;
3960
+ namesIn = (dir, pattern) => fs9.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isFile() && pattern.test(d.name)).map((d) => d.name).sort();
3961
+ timeOf = (v) => typeof v === "number" && Math.abs(v) <= MAX_TIME ? v : void 0;
3962
+ textOf = (v) => typeof v === "string" ? v : "";
3963
+ labelOf = (r, name) => textOf(listOf(r.labels).find((l) => l.name === name)?.value);
3964
+ FAILED = /* @__PURE__ */ new Set(["failed", "broken"]);
3965
+ declared = (steps, depth = 0) => depth >= MAX_DEPTH ? [] : listOf(steps).map((s) => ({ title: textOf(s.name), failed: FAILED.has(textOf(s.status)), steps: declared(s.steps, depth + 1) }));
3966
+ stopOf = (r) => timeOf(r.stop) ?? timeOf(r.start) ?? 0;
3967
+ compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0;
3968
+ }
3969
+ });
3970
+
3348
3971
  // src/importers/xml.ts
3349
3972
  function decode(raw) {
3350
3973
  if (!raw.includes("&")) return raw;
@@ -3409,9 +4032,9 @@ function parseXml(source, file) {
3409
4032
  if (end === -1) throw new XmlParseError(file, line, "a closing tag is never closed");
3410
4033
  const name2 = source.slice(i + 2, end).trim();
3411
4034
  const open = top();
3412
- if (open === root) throw new XmlParseError(file, line, `</${name2}> closes nothing`);
4035
+ if (open === root) throw new XmlParseError(file, line, `</${printable(name2)}> closes nothing`);
3413
4036
  if (open.name !== name2) {
3414
- throw new XmlParseError(file, line, `</${name2}> closes <${open.name}>, opened on line ${open.line}`);
4037
+ throw new XmlParseError(file, line, `</${printable(name2)}> closes <${printable(open.name)}>, opened on line ${open.line}`);
3415
4038
  }
3416
4039
  stack.pop();
3417
4040
  advance(end + 1);
@@ -3427,7 +4050,7 @@ function parseXml(source, file) {
3427
4050
  let selfClosing = false;
3428
4051
  for (; ; ) {
3429
4052
  while (j < source.length && SPACE.test(source[j])) j += 1;
3430
- if (j >= source.length) throw new XmlParseError(file, startLine, `<${name}> is never closed`);
4053
+ if (j >= source.length) throw new XmlParseError(file, startLine, `<${printable(name)}> is never closed`);
3431
4054
  if (source[j] === "/" && source[j + 1] === ">") {
3432
4055
  selfClosing = true;
3433
4056
  j += 2;
@@ -3440,19 +4063,19 @@ function parseXml(source, file) {
3440
4063
  const attrStart = j;
3441
4064
  while (j < source.length && !ATTR_NAME_END.test(source[j])) j += 1;
3442
4065
  const attr = source.slice(attrStart, j);
3443
- if (attr === "") throw new XmlParseError(file, startLine, `<${name}> has an attribute with no name`);
4066
+ if (attr === "") throw new XmlParseError(file, startLine, `<${printable(name)}> has an attribute with no name`);
3444
4067
  while (j < source.length && SPACE.test(source[j])) j += 1;
3445
4068
  if (source[j] !== "=") {
3446
- throw new XmlParseError(file, startLine, `<${name}> attribute "${attr}" has no value`);
4069
+ throw new XmlParseError(file, startLine, `<${printable(name)}> attribute "${printable(attr)}" has no value`);
3447
4070
  }
3448
4071
  j += 1;
3449
4072
  while (j < source.length && SPACE.test(source[j])) j += 1;
3450
4073
  const quote = source[j];
3451
4074
  if (quote !== '"' && quote !== "'") {
3452
- throw new XmlParseError(file, startLine, `<${name}> attribute "${attr}" is not quoted`);
4075
+ throw new XmlParseError(file, startLine, `<${printable(name)}> attribute "${printable(attr)}" is not quoted`);
3453
4076
  }
3454
4077
  const close = source.indexOf(quote, j + 1);
3455
- if (close === -1) throw new XmlParseError(file, startLine, `<${name}> attribute "${attr}" is never closed`);
4078
+ if (close === -1) throw new XmlParseError(file, startLine, `<${printable(name)}> attribute "${printable(attr)}" is never closed`);
3456
4079
  el.attrs[attr] = decode(source.slice(j + 1, close));
3457
4080
  j = close + 1;
3458
4081
  }
@@ -3462,7 +4085,7 @@ function parseXml(source, file) {
3462
4085
  }
3463
4086
  if (stack.length > 1) {
3464
4087
  const open = top();
3465
- throw new XmlParseError(file, open.line, `<${open.name}> is never closed`);
4088
+ throw new XmlParseError(file, open.line, `<${printable(open.name)}> is never closed`);
3466
4089
  }
3467
4090
  if (root.children.length === 0) throw new XmlParseError(file, 1, "no elements \u2014 is this an XML report?");
3468
4091
  return root;
@@ -3472,6 +4095,7 @@ var init_xml = __esm({
3472
4095
  "src/importers/xml.ts"() {
3473
4096
  "use strict";
3474
4097
  init_cjs_shims();
4098
+ init_failure_detail();
3475
4099
  XmlParseError = class extends Error {
3476
4100
  constructor(file, line, detail) {
3477
4101
  super(`${file}:${line} \u2014 ${detail}`);
@@ -3518,7 +4142,7 @@ function playwrightOf(classname, name) {
3518
4142
  if (!SPEC_FILE.test(classname)) return void 0;
3519
4143
  return { file: classname.replaceAll("\\", "/"), titles: name.split(" \u203A ") };
3520
4144
  }
3521
- function keyOf(classname, name, playwright) {
4145
+ function keyOf2(classname, name, playwright) {
3522
4146
  if (playwright !== void 0) return { kind: "path-title", value: [playwright.file, ...playwright.titles].join("#") };
3523
4147
  return { kind: "path-title", value: classname === "" ? name : `${classname}#${name}` };
3524
4148
  }
@@ -3551,8 +4175,9 @@ function executionOf(test, suite) {
3551
4175
  retry: 0
3552
4176
  };
3553
4177
  }
3554
- function readJUnit(source, file) {
4178
+ function readJUnit(source, file, missing = [], notes = []) {
3555
4179
  const doc = parseXml(source, file);
4180
+ const { lexical, real: folder } = reportFolders(file);
3556
4181
  const found = [];
3557
4182
  locate(doc, void 0, found);
3558
4183
  return found.map(({ test, suite }) => {
@@ -3560,36 +4185,41 @@ function readJUnit(source, file) {
3560
4185
  const classname = test.attrs["classname"] ?? test.attrs["class"] ?? "";
3561
4186
  const { status, detail } = outcomeOf(test);
3562
4187
  const playwright = playwrightOf(classname, name);
3563
- const key = keyOf(classname, name, playwright);
3564
- const stated = TOKEN.exec(playwright?.titles.at(-1) ?? name)?.[1];
4188
+ const key = keyOf2(classname, name, playwright);
4189
+ const stated = TOKEN2.exec(playwright?.titles.at(-1) ?? name)?.[1];
3565
4190
  const specRef = specRefOf(test, suite, classname);
3566
4191
  const errorContext = errorTextOf(detail);
3567
4192
  const execution = executionOf(test, suite);
4193
+ const title = titleOf(classname, name);
4194
+ const rkey = resultKey(key.value, 0);
4195
+ const files = filesOfTest(test, folder, title, missing, { lexical, resultKey: rkey, notes });
3568
4196
  return {
3569
- resultKey: resultKey(key.value, 0),
4197
+ resultKey: rkey,
3570
4198
  ...stated !== void 0 ? { testCaseId: stated } : {},
3571
4199
  keys: [key],
3572
- source: SOURCE,
4200
+ source: SOURCE2,
3573
4201
  rawStatus: status,
3574
- title: titleOf(classname, name),
4202
+ title,
3575
4203
  ...specRef !== void 0 ? { specRef } : {},
3576
4204
  ...execution !== void 0 ? { execution } : {},
3577
- ...errorContext !== "" ? { errorContext } : {}
4205
+ ...errorContext !== "" ? { errorContext } : {},
4206
+ ...files.length > 0 ? { files } : {}
3578
4207
  };
3579
4208
  });
3580
4209
  }
3581
4210
  function looksLikeJUnit(source) {
3582
4211
  return /<\s*testsuites?\b/.test(source.slice(0, 4096));
3583
4212
  }
3584
- var SOURCE, TOKEN, SPEC_FILE;
4213
+ var SOURCE2, TOKEN2, SPEC_FILE;
3585
4214
  var init_junit = __esm({
3586
4215
  "src/importers/junit.ts"() {
3587
4216
  "use strict";
3588
4217
  init_cjs_shims();
3589
4218
  init_result_key();
4219
+ init_attachments();
3590
4220
  init_xml();
3591
- SOURCE = "junit";
3592
- TOKEN = /@P([A-Za-z0-9_-]+)/;
4221
+ SOURCE2 = "junit";
4222
+ TOKEN2 = /@P([A-Za-z0-9_-]+)/;
3593
4223
  SPEC_FILE = /\.(?:spec|test)\.[cm]?[jt]sx?$/;
3594
4224
  }
3595
4225
  });
@@ -3598,7 +4228,7 @@ var init_junit = __esm({
3598
4228
  function statedIdOf(test, title) {
3599
4229
  const annotation = test.annotations?.find((a) => a.type === "PluneId")?.description;
3600
4230
  if (annotation !== void 0 && annotation !== "") return annotation;
3601
- return TOKEN2.exec(title)?.[1];
4231
+ return TOKEN3.exec(title)?.[1];
3602
4232
  }
3603
4233
  function attemptOf(result, file, ctx) {
3604
4234
  const steps = (list2) => (list2 ?? []).map((s) => ({ title: s.title ?? "", failed: s.error !== void 0, steps: steps(s.steps) }));
@@ -3609,10 +4239,10 @@ function attemptOf(result, file, ctx) {
3609
4239
  ...typeof at?.file === "string" && typeof at.line === "number" ? { location: { file: at.file, line: at.line, ...typeof at.column === "number" ? { column: at.column } : {} } } : {}
3610
4240
  })),
3611
4241
  steps: steps(result.steps),
3612
- attachments: (result.attachments ?? []).map(({ name, contentType, path: path15, body }) => ({
4242
+ attachments: (result.attachments ?? []).map(({ name, contentType, path: path17, body }) => ({
3613
4243
  name: name ?? "",
3614
4244
  ...contentType !== void 0 ? { contentType } : {},
3615
- ...path15 !== void 0 ? { path: path15 } : {},
4245
+ ...path17 !== void 0 ? { path: path17 } : {},
3616
4246
  // Decoded only where the platform would take it (plune-ai/plune#928): a report of screenshots kept as
3617
4247
  // bodies is megabytes of base64 that nothing here will read.
3618
4248
  ...typeof body === "string" && isTextType(contentType) ? { body: Buffer.from(body, "base64") } : {}
@@ -3645,7 +4275,7 @@ function pendingFrom(spec, test, result, file, titles, ctx) {
3645
4275
  keys,
3646
4276
  title: titles.join(" \u203A "),
3647
4277
  specRef: spec.line !== void 0 ? `${file}:${spec.line}` : file,
3648
- source: SOURCE2,
4278
+ source: SOURCE3,
3649
4279
  // Untouched, as everywhere: `timedOut` means what the project's map says it means, not what
3650
4280
  // this file decided.
3651
4281
  rawStatus: result.status ?? "unknown",
@@ -3680,7 +4310,7 @@ function readPlaywrightJson(source, file) {
3680
4310
  try {
3681
4311
  parsed = JSON.parse(source);
3682
4312
  } catch (err) {
3683
- throw new JsonReportError(file, `not JSON \u2014 ${err instanceof Error ? err.message : String(err)}`);
4313
+ throw new JsonReportError(file, `not JSON \u2014 ${printable(err instanceof Error ? err.message : String(err))}`);
3684
4314
  }
3685
4315
  if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.suites)) {
3686
4316
  throw new JsonReportError(file, 'no "suites" array \u2014 is this a Playwright JSON report?');
@@ -3688,7 +4318,7 @@ function readPlaywrightJson(source, file) {
3688
4318
  const { config, suites } = parsed;
3689
4319
  const rootDir = typeof config?.rootDir === "string" ? config.rootDir : void 0;
3690
4320
  const buildHref = config?.metadata?.ci?.buildHref;
3691
- const repoRoot = rootDir !== void 0 && (0, import_node_fs4.existsSync)(rootDir) ? repoRootOf(rootDir) : void 0;
4321
+ const repoRoot = rootDir !== void 0 && (0, import_node_fs5.existsSync)(rootDir) ? repoRootOf(rootDir) : void 0;
3692
4322
  const ctx = {
3693
4323
  ...rootDir !== void 0 ? { rootDir } : {},
3694
4324
  ...repoRoot !== void 0 ? { repoRoot } : {},
@@ -3707,20 +4337,20 @@ function readPlaywrightJson(source, file) {
3707
4337
  function looksLikePlaywrightJson(source) {
3708
4338
  return /^\s*\{/.test(source) && source.includes('"suites"');
3709
4339
  }
3710
- var import_node_fs4, SOURCE2, TOKEN2, NOT_FAILED, JsonReportError;
4340
+ var import_node_fs5, SOURCE3, TOKEN3, NOT_FAILED, JsonReportError;
3711
4341
  var init_playwright_json = __esm({
3712
4342
  "src/importers/playwright-json.ts"() {
3713
4343
  "use strict";
3714
4344
  init_cjs_shims();
3715
- import_node_fs4 = require("fs");
4345
+ import_node_fs5 = require("fs");
3716
4346
  init_failure_detail();
3717
4347
  init_result_key();
3718
- SOURCE2 = "playwright";
3719
- TOKEN2 = /@P([A-Za-z0-9_-]+)/;
4348
+ SOURCE3 = "playwright";
4349
+ TOKEN3 = /@P([A-Za-z0-9_-]+)/;
3720
4350
  NOT_FAILED = /* @__PURE__ */ new Set(["passed", "skipped"]);
3721
4351
  JsonReportError = class extends Error {
3722
4352
  constructor(file, detail) {
3723
- super(`${file} \u2014 ${detail}`);
4353
+ super(`${printable(file)} \u2014 ${detail}`);
3724
4354
  this.file = file;
3725
4355
  this.name = "JsonReportError";
3726
4356
  }
@@ -3732,11 +4362,14 @@ var init_playwright_json = __esm({
3732
4362
  // src/importers/index.ts
3733
4363
  var importers_exports = {};
3734
4364
  __export(importers_exports, {
4365
+ AllureReportError: () => AllureReportError,
3735
4366
  IMPORT_FORMATS: () => IMPORT_FORMATS,
3736
4367
  JsonReportError: () => JsonReportError,
3737
4368
  UnknownFormatError: () => UnknownFormatError,
3738
4369
  XmlParseError: () => XmlParseError,
3739
4370
  detectFormat: () => detectFormat,
4371
+ isFolder: () => isFolder,
4372
+ readFolder: () => readFolder,
3740
4373
  readReport: () => readReport
3741
4374
  });
3742
4375
  function detectFormat(source, file) {
@@ -3747,21 +4380,34 @@ function detectFormat(source, file) {
3747
4380
  'no <testsuite> and no Playwright "suites" \u2014 this is not a report we can read.'
3748
4381
  );
3749
4382
  }
4383
+ function readFolder(dir, format) {
4384
+ if (format !== void 0 && format !== "allure") {
4385
+ throw new UnknownFormatError(dir, `this is a folder, and only allure-results is read from one, not ${format}.`);
4386
+ }
4387
+ const missing = [];
4388
+ const notes = [];
4389
+ return { format: "allure", results: readAllure(dir, missing, notes), missing, notes };
4390
+ }
3750
4391
  function readReport(source, file, format) {
3751
4392
  const chosen2 = format ?? detectFormat(source, file);
3752
- if (chosen2 === "junit") return { format: chosen2, results: readJUnit(source, file) };
3753
- return { format: chosen2, ...readPlaywrightJson(source, file) };
4393
+ const missing = [];
4394
+ if (chosen2 === "allure") throw new UnknownFormatError(file, "allure-results is a folder, and this is a file.");
4395
+ const notes = [];
4396
+ if (chosen2 === "junit") return { format: chosen2, results: readJUnit(source, file, missing, notes), missing, notes };
4397
+ return { format: chosen2, ...readPlaywrightJson(source, file), missing, notes };
3754
4398
  }
3755
4399
  var IMPORT_FORMATS, UnknownFormatError;
3756
4400
  var init_importers = __esm({
3757
4401
  "src/importers/index.ts"() {
3758
4402
  "use strict";
3759
4403
  init_cjs_shims();
4404
+ init_allure();
3760
4405
  init_junit();
3761
4406
  init_playwright_json();
3762
4407
  init_xml();
3763
4408
  init_playwright_json();
3764
- IMPORT_FORMATS = ["junit", "playwright-json"];
4409
+ init_allure();
4410
+ IMPORT_FORMATS = ["junit", "playwright-json", "allure"];
3765
4411
  UnknownFormatError = class extends Error {
3766
4412
  constructor(file, detail) {
3767
4413
  super(`${file} \u2014 ${detail} Say which with --format ${IMPORT_FORMATS.join(" | ")}.`);
@@ -3779,16 +4425,25 @@ __export(run_import_exports, {
3779
4425
  async function handleRunImport(options) {
3780
4426
  const write = options.write ?? ((line) => void process.stdout.write(`${line}
3781
4427
  `));
3782
- let source;
3783
- try {
3784
- source = fs8.readFileSync(options.file, "utf8");
3785
- } catch {
3786
- throw new RunCommandError(`Cannot read ${options.file}.`);
4428
+ let read;
4429
+ if (isFolder(options.file)) {
4430
+ read = readFolder(options.file, options.format);
4431
+ } else {
4432
+ let source;
4433
+ try {
4434
+ source = fs10.readFileSync(options.file, "utf8");
4435
+ } catch {
4436
+ throw new RunCommandError(`Cannot read ${options.file}.`);
4437
+ }
4438
+ read = readReport(source, options.file, options.format);
3787
4439
  }
3788
- const { format, results, ciUrl, ci } = readReport(source, options.file, options.format);
4440
+ const { format, results, missing, notes, ciUrl, ci } = read;
3789
4441
  if (results.length === 0) {
3790
4442
  throw new RunCommandError(`No test cases in ${options.file} \u2014 it parsed, but there is nothing in it.`);
3791
4443
  }
4444
+ for (const m of missing) write(`plune: attachment not found \u2014 ${JSON.stringify(printable(m.title))}: ${printable(m.path)}`);
4445
+ for (const note of notes) write(note);
4446
+ const announced = (format === "junit" || format === "allure") && (missing.length > 0 || results.some((r) => (r.files?.length ?? 0) > 0));
3792
4447
  const env = readEnv();
3793
4448
  const apiUrl = resolveApiUrl(options.apiUrl ?? env.config.apiUrl);
3794
4449
  const token = options.token ?? env.config.token ?? loadToken() ?? "";
@@ -3833,6 +4488,13 @@ async function handleRunImport(options) {
3833
4488
  deferred: stats.deferred,
3834
4489
  screenshots: { ...stats.screenshots },
3835
4490
  textFiles: { ...stats.textFiles },
4491
+ missingAttachments: missing,
4492
+ filesUploaded: stats.screenshots.uploaded + stats.textFiles.uploaded + stats.otherFiles.uploaded,
4493
+ announced,
4494
+ filesNotSent: Math.max(
4495
+ 0,
4496
+ results.reduce((n, r) => n + (r.files?.length ?? 0), 0) - [stats.screenshots, stats.textFiles, stats.otherFiles].reduce((n, c) => n + c.uploaded + c.skipped + c.failed, 0)
4497
+ ),
3836
4498
  unlocatable: results.filter((r) => r.specRef === void 0).length,
3837
4499
  offering: options.create === true
3838
4500
  };
@@ -3851,21 +4513,11 @@ function describe(result, apiUrl) {
3851
4513
  // worked (#622).
3852
4514
  // `not delivered` always, zero included (#790): a number that only appears when it is bad is one
3853
4515
  // nobody learns to look for. The `[0-9]+ unmatched` the workflows read stays whole.
3854
- `Read ${result.parsed} result(s) from a ${result.format} report: ${result.accepted} accepted, ${result.duplicate} already there, ${result.unresolved} unmatched, ${result.deferred} not delivered.`
4516
+ `Read ${result.parsed} result(s) from ${result.format === "allure" ? "an" : "a"} ${result.format} report: ${result.accepted} accepted, ${result.duplicate} already there, ${result.unresolved} unmatched, ${result.deferred} not delivered.`
3855
4517
  ];
3856
4518
  if (result.conflict > 0 || result.rejected > 0) {
3857
4519
  lines.push(`${result.conflict} conflicted and ${result.rejected} were refused.`);
3858
4520
  }
3859
- for (const [counts, noun] of [
3860
- [result.screenshots, "screenshot"],
3861
- [result.textFiles, "text file"]
3862
- ]) {
3863
- if (counts.uploaded + counts.skipped + counts.failed > 0) {
3864
- lines.push(
3865
- `${counts.uploaded} ${noun}(s) uploaded to their results` + (counts.skipped > 0 ? `, ${counts.skipped} skipped` : "") + (counts.failed > 0 ? `, ${counts.failed} could not be uploaded` : "") + "."
3866
- );
3867
- }
3868
- }
3869
4521
  if (result.deferred > 0) {
3870
4522
  lines.push(
3871
4523
  `${result.deferred} could not be sent and are in the fallback file \u2014 "plune run report" retries them.`
@@ -3893,16 +4545,22 @@ function describe(result, apiUrl) {
3893
4545
  `${result.unlocatable} test(s) carry no file or class in the report, so they cannot be offered for review \u2014 a reviewer would have nowhere to look.`
3894
4546
  );
3895
4547
  }
4548
+ if (result.announced) {
4549
+ lines.push(
4550
+ `Imported ${result.accepted} results \xB7 ${result.filesUploaded} files uploaded \xB7 ${result.missingAttachments.length} files not found` + (result.filesNotSent > 0 ? ` \xB7 ${result.filesNotSent} files not sent (their result was not stored)` : "")
4551
+ );
4552
+ }
3896
4553
  if (result.runId !== null) lines.push(runUrl2(apiUrl, result.runId));
3897
4554
  return lines;
3898
4555
  }
3899
- var fs8, NEXT;
4556
+ var fs10, NEXT;
3900
4557
  var init_run_import = __esm({
3901
4558
  "src/cli/commands/run-import.ts"() {
3902
4559
  "use strict";
3903
4560
  init_cjs_shims();
3904
- fs8 = __toESM(require("fs"), 1);
4561
+ fs10 = __toESM(require("fs"), 1);
3905
4562
  init_session();
4563
+ init_failure_detail();
3906
4564
  init_importers();
3907
4565
  init_run_lifecycle();
3908
4566
  init_api_url();
@@ -3929,10 +4587,10 @@ function isRunResult(v) {
3929
4587
  }
3930
4588
  function handleReport(opts = {}) {
3931
4589
  const cwd = opts.cwd ?? process.cwd();
3932
- const file = path9.join(cwd, ".plune", "last-run.json");
4590
+ const file = path11.join(cwd, ".plune", "last-run.json");
3933
4591
  let raw;
3934
4592
  try {
3935
- raw = fs9.readFileSync(file, "utf8");
4593
+ raw = fs11.readFileSync(file, "utf8");
3936
4594
  } catch {
3937
4595
  throw new ReportNotFoundError('No saved run found. Run "plune run" first.');
3938
4596
  }
@@ -3947,13 +4605,13 @@ function handleReport(opts = {}) {
3947
4605
  }
3948
4606
  return parsed;
3949
4607
  }
3950
- var fs9, path9, ReportNotFoundError;
4608
+ var fs11, path11, ReportNotFoundError;
3951
4609
  var init_report = __esm({
3952
4610
  "src/cli/commands/report.ts"() {
3953
4611
  "use strict";
3954
4612
  init_cjs_shims();
3955
- fs9 = __toESM(require("fs"), 1);
3956
- path9 = __toESM(require("path"), 1);
4613
+ fs11 = __toESM(require("fs"), 1);
4614
+ path11 = __toESM(require("path"), 1);
3957
4615
  ReportNotFoundError = class extends Error {
3958
4616
  code = "NO_SAVED_RUN";
3959
4617
  constructor(message) {
@@ -3965,18 +4623,13 @@ var init_report = __esm({
3965
4623
  });
3966
4624
 
3967
4625
  // src/diff/diff.ts
3968
- function rowStatus(r) {
3969
- if (r.error !== void 0) return "errored";
3970
- if (r.assertions.some((a) => !a.passed)) return "failed";
3971
- return "passed";
3972
- }
3973
4626
  function evalStatus(e) {
3974
4627
  const statuses = e.rows.map(rowStatus);
3975
4628
  if (statuses.includes("failed")) return "failed";
3976
4629
  if (statuses.includes("errored")) return "errored";
3977
4630
  return "passed";
3978
4631
  }
3979
- function classify4(baseline, current) {
4632
+ function classify2(baseline, current) {
3980
4633
  if (current === "errored") return "errored";
3981
4634
  if (baseline === "absent") return current === "passed" ? "new-pass" : "new-fail";
3982
4635
  if (current === "absent") return "removed";
@@ -3990,7 +4643,7 @@ function diffRuns(baseline, current) {
3990
4643
  const evals = ids.map((id) => {
3991
4644
  const baselineP = baseMap.get(id) ?? "absent";
3992
4645
  const currentP = curMap.get(id) ?? "absent";
3993
- return { id, status: classify4(baselineP, currentP), baseline: baselineP, current: currentP };
4646
+ return { id, status: classify2(baselineP, currentP), baseline: baselineP, current: currentP };
3994
4647
  });
3995
4648
  const count2 = (s) => evals.filter((e) => e.status === s).length;
3996
4649
  const summary = {
@@ -4010,6 +4663,7 @@ var init_diff = __esm({
4010
4663
  "src/diff/diff.ts"() {
4011
4664
  "use strict";
4012
4665
  init_cjs_shims();
4666
+ init_results();
4013
4667
  }
4014
4668
  });
4015
4669
 
@@ -4027,7 +4681,7 @@ function isRunResult2(v) {
4027
4681
  function loadRunResult(file, label) {
4028
4682
  let raw;
4029
4683
  try {
4030
- raw = fs10.readFileSync(file, "utf8");
4684
+ raw = fs12.readFileSync(file, "utf8");
4031
4685
  } catch {
4032
4686
  throw new DiffInputError(`Cannot read ${label} run file: ${file}`);
4033
4687
  }
@@ -4047,12 +4701,12 @@ function handleDiff(opts) {
4047
4701
  const current = loadRunResult(opts.currentPath, "current");
4048
4702
  return diffRuns(baseline, current);
4049
4703
  }
4050
- var fs10, DiffInputError;
4704
+ var fs12, DiffInputError;
4051
4705
  var init_diff2 = __esm({
4052
4706
  "src/cli/commands/diff.ts"() {
4053
4707
  "use strict";
4054
4708
  init_cjs_shims();
4055
- fs10 = __toESM(require("fs"), 1);
4709
+ fs12 = __toESM(require("fs"), 1);
4056
4710
  init_diff();
4057
4711
  DiffInputError = class extends Error {
4058
4712
  code = "DIFF_INPUT";
@@ -4177,8 +4831,8 @@ async function runInitWizard(cwd) {
4177
4831
  process.exit(1);
4178
4832
  return;
4179
4833
  }
4180
- const outputPath = path10.join(cwd, "plune.yaml");
4181
- if (fs11.existsSync(outputPath)) {
4834
+ const outputPath = path12.join(cwd, "plune.yaml");
4835
+ if (fs13.existsSync(outputPath)) {
4182
4836
  const overwrite = await clack.confirm({
4183
4837
  message: "plune.yaml already exists. Overwrite?"
4184
4838
  });
@@ -4217,18 +4871,18 @@ async function runInitWizard(cwd) {
4217
4871
  `;
4218
4872
  const spin = clack.spinner();
4219
4873
  spin.start("Writing plune.yaml\u2026");
4220
- fs11.writeFileSync(outputPath, header + "\n" + (0, import_yaml2.stringify)(config), "utf8");
4874
+ fs13.writeFileSync(outputPath, header + "\n" + (0, import_yaml2.stringify)(config), "utf8");
4221
4875
  spin.stop("plune.yaml created!");
4222
4876
  clack.outro("plune.yaml is ready.");
4223
4877
  }
4224
- var clack, fs11, path10, import_yaml2;
4878
+ var clack, fs13, path12, import_yaml2;
4225
4879
  var init_wizard = __esm({
4226
4880
  "src/config/init/wizard.ts"() {
4227
4881
  "use strict";
4228
4882
  init_cjs_shims();
4229
4883
  clack = __toESM(require("@clack/prompts"), 1);
4230
- fs11 = __toESM(require("fs"), 1);
4231
- path10 = __toESM(require("path"), 1);
4884
+ fs13 = __toESM(require("fs"), 1);
4885
+ path12 = __toESM(require("path"), 1);
4232
4886
  import_yaml2 = require("yaml");
4233
4887
  init_errors();
4234
4888
  }
@@ -4236,21 +4890,21 @@ var init_wizard = __esm({
4236
4890
 
4237
4891
  // src/cli/templates/index.ts
4238
4892
  function writeTemplateFile(cwd, rel, content, force) {
4239
- const target = path11.join(cwd, rel);
4240
- if (!force && fs12.existsSync(target)) {
4893
+ const target = path13.join(cwd, rel);
4894
+ if (!force && fs14.existsSync(target)) {
4241
4895
  return "skipped";
4242
4896
  }
4243
- fs12.mkdirSync(path11.dirname(target), { recursive: true });
4244
- fs12.writeFileSync(target, content, "utf8");
4897
+ fs14.mkdirSync(path13.dirname(target), { recursive: true });
4898
+ fs14.writeFileSync(target, content, "utf8");
4245
4899
  return "written";
4246
4900
  }
4247
- var fs12, path11, ENV_EXAMPLE_TPL, EXAMPLE_JSONL_TPL, PLUNE_YAML_TPL;
4901
+ var fs14, path13, ENV_EXAMPLE_TPL, EXAMPLE_JSONL_TPL, PLUNE_YAML_TPL;
4248
4902
  var init_templates = __esm({
4249
4903
  "src/cli/templates/index.ts"() {
4250
4904
  "use strict";
4251
4905
  init_cjs_shims();
4252
- fs12 = __toESM(require("fs"), 1);
4253
- path11 = __toESM(require("path"), 1);
4906
+ fs14 = __toESM(require("fs"), 1);
4907
+ path13 = __toESM(require("path"), 1);
4254
4908
  ENV_EXAMPLE_TPL = `# Copy this file to .env and replace the placeholders with your real keys.
4255
4909
  # .env is gitignored; .env.example is safe to commit.
4256
4910
  # Plune reads whichever key matches your configured provider.
@@ -4432,64 +5086,70 @@ var init_logout = __esm({
4432
5086
  function parseRunResult(value2) {
4433
5087
  return runResultSchema.parse(value2);
4434
5088
  }
4435
- var import_zod2, RUN_RESULT_SCHEMA_VERSION, binaryVerdictSchema, assertionResultRecordSchema, usageSchema, runErrorSchema, rowResultSchema, summarySchema, evalResultSchema, runResultSchema;
5089
+ var import_zod3, RUN_RESULT_SCHEMA_VERSION, binaryVerdictSchema, assertionResultRecordSchema2, usageSchema, runErrorSchema, rowResultSchema, summarySchema, evalResultSchema, runResultSchema;
4436
5090
  var init_run_result_schema = __esm({
4437
5091
  "src/types/run-result-schema.ts"() {
4438
5092
  "use strict";
4439
5093
  init_cjs_shims();
4440
- import_zod2 = require("zod");
5094
+ import_zod3 = require("zod");
5095
+ init_results();
4441
5096
  RUN_RESULT_SCHEMA_VERSION = 1;
4442
- binaryVerdictSchema = import_zod2.z.object({
4443
- question: import_zod2.z.string().min(1),
4444
- passed: import_zod2.z.boolean()
5097
+ binaryVerdictSchema = import_zod3.z.object({
5098
+ question: import_zod3.z.string().min(1),
5099
+ passed: import_zod3.z.boolean()
4445
5100
  });
4446
- assertionResultRecordSchema = import_zod2.z.object({
4447
- type: import_zod2.z.string(),
4448
- passed: import_zod2.z.boolean(),
4449
- score: import_zod2.z.number().optional(),
4450
- reason: import_zod2.z.string().optional(),
4451
- binaryVerdicts: import_zod2.z.array(binaryVerdictSchema).optional()
5101
+ assertionResultRecordSchema2 = import_zod3.z.object({
5102
+ type: import_zod3.z.string(),
5103
+ passed: import_zod3.z.boolean(),
5104
+ score: import_zod3.z.number().optional(),
5105
+ reason: import_zod3.z.string().optional(),
5106
+ binaryVerdicts: import_zod3.z.array(binaryVerdictSchema).optional()
4452
5107
  });
4453
- usageSchema = import_zod2.z.object({
4454
- input_tokens: import_zod2.z.number(),
4455
- output_tokens: import_zod2.z.number(),
4456
- cost_usd: import_zod2.z.number()
5108
+ usageSchema = import_zod3.z.object({
5109
+ input_tokens: import_zod3.z.number(),
5110
+ output_tokens: import_zod3.z.number(),
5111
+ cost_usd: import_zod3.z.number()
4457
5112
  });
4458
- runErrorSchema = import_zod2.z.object({
4459
- code: import_zod2.z.string(),
4460
- message: import_zod2.z.string()
5113
+ runErrorSchema = import_zod3.z.object({
5114
+ code: import_zod3.z.string(),
5115
+ message: import_zod3.z.string()
4461
5116
  });
4462
- rowResultSchema = import_zod2.z.object({
4463
- vars: import_zod2.z.record(import_zod2.z.string(), import_zod2.z.unknown()),
4464
- output: import_zod2.z.string().nullable(),
4465
- cached: import_zod2.z.boolean(),
5117
+ rowResultSchema = import_zod3.z.object({
5118
+ vars: import_zod3.z.record(import_zod3.z.string(), import_zod3.z.unknown()),
5119
+ output: import_zod3.z.string().nullable(),
5120
+ cached: import_zod3.z.boolean(),
4466
5121
  usage: usageSchema.optional(),
4467
- latency_ms: import_zod2.z.number().optional(),
5122
+ latency_ms: import_zod3.z.number().optional(),
4468
5123
  error: runErrorSchema.optional(),
4469
- assertions: import_zod2.z.array(assertionResultRecordSchema)
5124
+ assertions: import_zod3.z.array(assertionResultRecordSchema2),
5125
+ /** ADR 0045. Optional and additive — the same enum the platform validates on `POST /v1/runs`. */
5126
+ inconclusive: import_zod3.z.enum(ROW_INCONCLUSIVE).optional()
4470
5127
  });
4471
- summarySchema = import_zod2.z.object({
4472
- total: import_zod2.z.number(),
4473
- passed: import_zod2.z.number(),
4474
- failed: import_zod2.z.number(),
4475
- errored: import_zod2.z.number(),
4476
- cost_usd: import_zod2.z.number(),
4477
- duration_ms: import_zod2.z.number()
5128
+ summarySchema = import_zod3.z.object({
5129
+ total: import_zod3.z.number(),
5130
+ passed: import_zod3.z.number(),
5131
+ failed: import_zod3.z.number(),
5132
+ errored: import_zod3.z.number(),
5133
+ /** plune#1245. Optional and additive — a RunResult written before them validates unchanged. */
5134
+ blocked: import_zod3.z.number().optional(),
5135
+ skipped: import_zod3.z.number().optional(),
5136
+ cost_usd: import_zod3.z.number(),
5137
+ duration_ms: import_zod3.z.number()
4478
5138
  });
4479
- evalResultSchema = import_zod2.z.object({
4480
- id: import_zod2.z.string(),
4481
- tags: import_zod2.z.array(import_zod2.z.string()),
4482
- rows: import_zod2.z.array(rowResultSchema),
4483
- passed: import_zod2.z.boolean()
5139
+ evalResultSchema = import_zod3.z.object({
5140
+ id: import_zod3.z.string(),
5141
+ tags: import_zod3.z.array(import_zod3.z.string()),
5142
+ rows: import_zod3.z.array(rowResultSchema),
5143
+ passed: import_zod3.z.boolean()
4484
5144
  });
4485
- runResultSchema = import_zod2.z.object({
4486
- schemaVersion: import_zod2.z.literal(RUN_RESULT_SCHEMA_VERSION),
4487
- plune_version: import_zod2.z.string(),
4488
- started_at: import_zod2.z.string(),
4489
- finished_at: import_zod2.z.string(),
4490
- config_hash: import_zod2.z.string(),
5145
+ runResultSchema = import_zod3.z.object({
5146
+ schemaVersion: import_zod3.z.literal(RUN_RESULT_SCHEMA_VERSION),
5147
+ plune_version: import_zod3.z.string(),
5148
+ started_at: import_zod3.z.string(),
5149
+ finished_at: import_zod3.z.string(),
5150
+ config_hash: import_zod3.z.string(),
4491
5151
  summary: summarySchema,
4492
- evals: import_zod2.z.array(evalResultSchema)
5152
+ evals: import_zod3.z.array(evalResultSchema)
4493
5153
  });
4494
5154
  }
4495
5155
  });
@@ -4506,10 +5166,10 @@ __export(sync_exports, {
4506
5166
  reportSyncFailure: () => reportSyncFailure
4507
5167
  });
4508
5168
  function readRun(cwd, file) {
4509
- const target = file !== void 0 ? path12.resolve(cwd, file) : path12.join(cwd, ".plune", "last-run.json");
5169
+ const target = file !== void 0 ? path14.resolve(cwd, file) : path14.join(cwd, ".plune", "last-run.json");
4510
5170
  let raw;
4511
5171
  try {
4512
- raw = fs13.readFileSync(target, "utf8");
5172
+ raw = fs15.readFileSync(target, "utf8");
4513
5173
  } catch {
4514
5174
  throw new SyncFileError(
4515
5175
  file !== void 0 ? `Run file not found: ${target}` : 'No run to sync. Run "plune run" first (or pass --file <path>).'
@@ -4573,13 +5233,13 @@ function reportSyncFailure(err, write) {
4573
5233
  }
4574
5234
  return null;
4575
5235
  }
4576
- var fs13, path12, NotLoggedInError, TokenRejectedError2, SyncFileError, SyncNetworkError, SyncHttpError;
5236
+ var fs15, path14, NotLoggedInError, TokenRejectedError2, SyncFileError, SyncNetworkError, SyncHttpError;
4577
5237
  var init_sync = __esm({
4578
5238
  "src/cli/commands/sync.ts"() {
4579
5239
  "use strict";
4580
5240
  init_cjs_shims();
4581
- fs13 = __toESM(require("fs"), 1);
4582
- path12 = __toESM(require("path"), 1);
5241
+ fs15 = __toESM(require("fs"), 1);
5242
+ path14 = __toESM(require("path"), 1);
4583
5243
  init_run_result_schema();
4584
5244
  init_api_url();
4585
5245
  init_credentials();
@@ -4624,14 +5284,14 @@ var init_sync = __esm({
4624
5284
 
4625
5285
  // src/cli/commands/cairn-artifact.ts
4626
5286
  async function findLatestCairnRun(runsDir = "runs") {
4627
- const entries = await fs14.readdir(runsDir, { withFileTypes: true }).catch(() => null);
5287
+ const entries = await fs16.readdir(runsDir, { withFileTypes: true }).catch(() => null);
4628
5288
  if (entries === null) throw new NoCairnRunsError(runsDir);
4629
5289
  let newest;
4630
5290
  for (const entry of entries) {
4631
5291
  if (!entry.isDirectory()) continue;
4632
- const dir = path13.join(runsDir, entry.name);
5292
+ const dir = path15.join(runsDir, entry.name);
4633
5293
  try {
4634
- const report = await fs14.lstat(path13.join(dir, "report.json"));
5294
+ const report = await fs16.lstat(path15.join(dir, "report.json"));
4635
5295
  if (!report.isFile()) continue;
4636
5296
  if (newest === void 0 || report.mtimeMs > newest.at) {
4637
5297
  newest = { dir, at: report.mtimeMs };
@@ -4644,13 +5304,13 @@ async function findLatestCairnRun(runsDir = "runs") {
4644
5304
  }
4645
5305
  async function readJson(file) {
4646
5306
  try {
4647
- return JSON.parse(await fs14.readFile(file, "utf8"));
5307
+ return JSON.parse(await fs16.readFile(file, "utf8"));
4648
5308
  } catch {
4649
5309
  return void 0;
4650
5310
  }
4651
5311
  }
4652
5312
  async function defaultReadEvidence(dir) {
4653
- const raw = await readJson(path13.join(dir, "api-evidence.json"));
5313
+ const raw = await readJson(path15.join(dir, "api-evidence.json"));
4654
5314
  if (!Array.isArray(raw)) return void 0;
4655
5315
  return raw.map((r) => {
4656
5316
  const rec = asRecord(r);
@@ -4692,7 +5352,7 @@ function casesFromApi(report, verdicts) {
4692
5352
  });
4693
5353
  }
4694
5354
  async function readCairnRun(dir, deps = {}) {
4695
- const raw = await readJson(path13.join(dir, "report.json"));
5355
+ const raw = await readJson(path15.join(dir, "report.json"));
4696
5356
  if (raw === void 0) throw new CairnRunNotFoundError(dir);
4697
5357
  const report = asRecord(raw);
4698
5358
  if (report["schemaVersion"] !== SUPPORTED_ARTIFACT_VERSION) {
@@ -4722,20 +5382,20 @@ async function readCairnRun(dir, deps = {}) {
4722
5382
  return {
4723
5383
  schemaVersion: INGEST_SCHEMA_VERSION,
4724
5384
  run: {
4725
- runId: str(report["runId"], path13.basename(dir)),
5385
+ runId: str(report["runId"], path15.basename(dir)),
4726
5386
  mode,
4727
5387
  target: str(report["url"], "unknown")
4728
5388
  },
4729
5389
  cases
4730
5390
  };
4731
5391
  }
4732
- var fs14, path13, SUPPORTED_ARTIFACT_VERSION, INGEST_SCHEMA_VERSION, NoCairnRunsError, CairnRunNotFoundError, CairnRunVersionError, CairnRunPartialError, CairnEvidenceAmbiguousError, asRecord, str;
5392
+ var fs16, path15, SUPPORTED_ARTIFACT_VERSION, INGEST_SCHEMA_VERSION, NoCairnRunsError, CairnRunNotFoundError, CairnRunVersionError, CairnRunPartialError, CairnEvidenceAmbiguousError, asRecord, str;
4733
5393
  var init_cairn_artifact = __esm({
4734
5394
  "src/cli/commands/cairn-artifact.ts"() {
4735
5395
  "use strict";
4736
5396
  init_cjs_shims();
4737
- fs14 = __toESM(require("fs/promises"), 1);
4738
- path13 = __toESM(require("path"), 1);
5397
+ fs16 = __toESM(require("fs/promises"), 1);
5398
+ path15 = __toESM(require("path"), 1);
4739
5399
  SUPPORTED_ARTIFACT_VERSION = 1;
4740
5400
  INGEST_SCHEMA_VERSION = 1;
4741
5401
  NoCairnRunsError = class extends Error {
@@ -4902,7 +5562,7 @@ __export(cases_exports, {
4902
5562
  function gitDirty(file) {
4903
5563
  try {
4904
5564
  const out = (0, import_node_child_process2.execFileSync)("git", ["status", "--porcelain", "--", file], {
4905
- cwd: path14.dirname(file),
5565
+ cwd: path16.dirname(file),
4906
5566
  stdio: ["ignore", "pipe", "ignore"],
4907
5567
  encoding: "utf8"
4908
5568
  });
@@ -4928,9 +5588,9 @@ async function handlePull(deps = {}) {
4928
5588
  const cwd = deps.cwd ?? process.cwd();
4929
5589
  const apiUrl = resolveApiUrl(deps.apiUrl);
4930
5590
  const token = tokenOf(deps);
4931
- const file = path14.resolve(cwd, deps.file ?? DEFAULT_CASES_FILE);
4932
- if (fs15.existsSync(file) && !deps.force && (deps.isDirty ?? gitDirty)(file))
4933
- throw new DirtyFileError(path14.relative(cwd, file) || file);
5591
+ const file = path16.resolve(cwd, deps.file ?? DEFAULT_CASES_FILE);
5592
+ if (fs17.existsSync(file) && !deps.force && (deps.isDirty ?? gitDirty)(file))
5593
+ throw new DirtyFileError(path16.relative(cwd, file) || file);
4934
5594
  const doFetch = deps.fetchImpl ?? fetch;
4935
5595
  const endpoint = deps.suiteId !== void 0 ? `${apiUrl}/v1/suites/${encodeURIComponent(deps.suiteId)}/markdown` : `${apiUrl}/v1/test-cases/markdown`;
4936
5596
  let res;
@@ -4945,18 +5605,18 @@ async function handlePull(deps = {}) {
4945
5605
  throw new SyncHttpError(res.status, detail);
4946
5606
  }
4947
5607
  const text2 = await res.text();
4948
- fs15.mkdirSync(path14.dirname(file), { recursive: true });
4949
- fs15.writeFileSync(file, text2, "utf8");
5608
+ fs17.mkdirSync(path16.dirname(file), { recursive: true });
5609
+ fs17.writeFileSync(file, text2, "utf8");
4950
5610
  return { file, cases: Number(res.headers.get("x-plune-cases") ?? "0") };
4951
5611
  }
4952
5612
  async function handlePush(deps = {}) {
4953
5613
  const cwd = deps.cwd ?? process.cwd();
4954
5614
  const apiUrl = resolveApiUrl(deps.apiUrl);
4955
5615
  const token = tokenOf(deps);
4956
- const file = path14.resolve(cwd, deps.file ?? DEFAULT_CASES_FILE);
5616
+ const file = path16.resolve(cwd, deps.file ?? DEFAULT_CASES_FILE);
4957
5617
  let text2;
4958
5618
  try {
4959
- text2 = fs15.readFileSync(file, "utf8");
5619
+ text2 = fs17.readFileSync(file, "utf8");
4960
5620
  } catch {
4961
5621
  throw new SyncFileError(
4962
5622
  `No document to push: ${file}. Run "plune pull" first (or pass a file).`
@@ -5007,18 +5667,18 @@ function reportCasesFailure(err, write) {
5007
5667
  }
5008
5668
  return null;
5009
5669
  }
5010
- var import_node_child_process2, fs15, path14, DEFAULT_CASES_FILE, DirtyFileError;
5670
+ var import_node_child_process2, fs17, path16, DEFAULT_CASES_FILE, DirtyFileError;
5011
5671
  var init_cases = __esm({
5012
5672
  "src/cli/commands/cases.ts"() {
5013
5673
  "use strict";
5014
5674
  init_cjs_shims();
5015
5675
  import_node_child_process2 = require("child_process");
5016
- fs15 = __toESM(require("fs"), 1);
5017
- path14 = __toESM(require("path"), 1);
5676
+ fs17 = __toESM(require("fs"), 1);
5677
+ path16 = __toESM(require("path"), 1);
5018
5678
  init_api_url();
5019
5679
  init_credentials();
5020
5680
  init_sync();
5021
- DEFAULT_CASES_FILE = path14.join("plune", "cases.md");
5681
+ DEFAULT_CASES_FILE = path16.join("plune", "cases.md");
5022
5682
  DirtyFileError = class extends Error {
5023
5683
  constructor(file) {
5024
5684
  super(`${file} has uncommitted changes; commit them or pass --force to overwrite.`);
@@ -5099,11 +5759,11 @@ module.exports = __toCommonJS(cli_exports);
5099
5759
  init_cjs_shims();
5100
5760
  init_api_url();
5101
5761
  var import_commander = require("commander");
5102
- var import_node_fs5 = require("fs");
5762
+ var import_node_fs6 = require("fs");
5103
5763
  var import_node_path4 = require("path");
5104
5764
  var import_node_url = require("url");
5105
5765
  var __dirname = (0, import_node_path4.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
5106
- var pkg = JSON.parse((0, import_node_fs5.readFileSync)((0, import_node_path4.join)(__dirname, "..", "package.json"), "utf-8"));
5766
+ var pkg = JSON.parse((0, import_node_fs6.readFileSync)((0, import_node_path4.join)(__dirname, "..", "package.json"), "utf-8"));
5107
5767
  function toFormat(s) {
5108
5768
  return s === "json" || s === "markdown" ? s : "console";
5109
5769
  }
@@ -5112,7 +5772,7 @@ function shouldColor(outputPath) {
5112
5772
  }
5113
5773
  function writeReport(text2, outputPath) {
5114
5774
  if (outputPath !== void 0) {
5115
- (0, import_node_fs5.writeFileSync)(outputPath, text2);
5775
+ (0, import_node_fs6.writeFileSync)(outputPath, text2);
5116
5776
  } else {
5117
5777
  process.stdout.write(text2 + "\n");
5118
5778
  }
@@ -5287,7 +5947,7 @@ function createProgram() {
5287
5947
  runCommand.command("delete").argument(
5288
5948
  "<id>",
5289
5949
  "The run id \u2014 the one `plune run import` printed, or the one in the dashboard URL"
5290
- ).description("Delete a run and everything it produced \u2014 recoverable for six months").action(async (id, _options, command) => {
5950
+ ).description("Delete a run and everything it produced \u2014 recoverable for 30 days").action(async (id, _options, command) => {
5291
5951
  try {
5292
5952
  const { handleRunDelete: handleRunDelete2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
5293
5953
  await handleRunDelete2(id);
@@ -5303,11 +5963,15 @@ function createProgram() {
5303
5963
  await failRunCommand(err, verboseOf(command));
5304
5964
  }
5305
5965
  });
5306
- runCommand.command("import").argument("<file>", "A JUnit XML or Playwright JSON report").description("Turn a report another runner wrote into a run in Plune \u2014 no provider key needed").option("--format <fmt>", "junit | playwright-json; detected from the file when omitted").option("--key <externalKey>", "The key several jobs share so their reports land in one run").option("--create", "Offer tests nothing matched to the review queue", false).action(
5966
+ runCommand.command("import").argument("<file>", "A JUnit XML or Playwright JSON report, or an Allure allure-results folder").description("Turn a report another runner wrote into a run in Plune \u2014 no provider key needed").option("--format <fmt>", "junit | playwright-json | allure; detected from the file (a folder is allure) when omitted").option("--key <externalKey>", "The key several jobs share so their reports land in one run").option("--create", "Offer tests nothing matched to the review queue", false).option(
5967
+ "--fail-on-missing-attachments",
5968
+ "Exit 1 when a file the report names ([[ATTACHMENT|path]] in JUnit, a source in allure-results) is not there; the results are sent either way",
5969
+ false
5970
+ ).action(
5307
5971
  async (file, options, command) => {
5308
5972
  const [
5309
5973
  { handleRunImport: handleRunImport2 },
5310
- { IMPORT_FORMATS: IMPORT_FORMATS2, UnknownFormatError: UnknownFormatError2, XmlParseError: XmlParseError2, JsonReportError: JsonReportError2 }
5974
+ { IMPORT_FORMATS: IMPORT_FORMATS2, UnknownFormatError: UnknownFormatError2, XmlParseError: XmlParseError2, JsonReportError: JsonReportError2, AllureReportError: AllureReportError2 }
5311
5975
  ] = await Promise.all([
5312
5976
  Promise.resolve().then(() => (init_run_import(), run_import_exports)),
5313
5977
  Promise.resolve().then(() => (init_importers(), importers_exports))
@@ -5323,14 +5987,15 @@ function createProgram() {
5323
5987
  return;
5324
5988
  }
5325
5989
  try {
5326
- await handleRunImport2({
5990
+ const imported = await handleRunImport2({
5327
5991
  file,
5328
5992
  ...format !== void 0 ? { format } : {},
5329
5993
  ...options.key !== void 0 ? { key: options.key } : {},
5330
5994
  ...options.create ? { create: true } : {}
5331
5995
  });
5996
+ if (options.failOnMissingAttachments && imported.missingAttachments.length > 0) process.exitCode = 1;
5332
5997
  } catch (err) {
5333
- if (err instanceof UnknownFormatError2 || err instanceof XmlParseError2 || err instanceof JsonReportError2) {
5998
+ if (err instanceof UnknownFormatError2 || err instanceof XmlParseError2 || err instanceof JsonReportError2 || err instanceof AllureReportError2) {
5334
5999
  process.stderr.write(err.message + "\n");
5335
6000
  process.exit(2);
5336
6001
  return;
@@ -5409,10 +6074,10 @@ function createProgram() {
5409
6074
  const { handleLogin: handleLogin2, reportLoginFailure: reportLoginFailure2 } = await Promise.resolve().then(() => (init_login(), login_exports));
5410
6075
  try {
5411
6076
  const token = options.token ?? await readTokenFromStdin();
5412
- const { path: path15, verified } = await handleLogin2({ token, skipVerify: options.skipVerify });
6077
+ const { path: path17, verified } = await handleLogin2({ token, skipVerify: options.skipVerify });
5413
6078
  process.stdout.write(
5414
- verified ? `Logged in. Token checked against the API and saved to ${path15}
5415
- ` : `Token saved to ${path15} WITHOUT being checked (--skip-verify).
6079
+ verified ? `Logged in. Token checked against the API and saved to ${path17}
6080
+ ` : `Token saved to ${path17} WITHOUT being checked (--skip-verify).
5416
6081
  `
5417
6082
  );
5418
6083
  } catch (err) {
@@ -5429,9 +6094,9 @@ function createProgram() {
5429
6094
  const verbose = command.optsWithGlobals().verbose === true;
5430
6095
  const { handleLogout: handleLogout2 } = await Promise.resolve().then(() => (init_logout(), logout_exports));
5431
6096
  try {
5432
- const { removed, path: path15 } = handleLogout2();
6097
+ const { removed, path: path17 } = handleLogout2();
5433
6098
  process.stdout.write(
5434
- removed ? `Logged out. Removed ${path15}
6099
+ removed ? `Logged out. Removed ${path17}
5435
6100
  ` : "Not logged in \u2014 nothing to remove.\n"
5436
6101
  );
5437
6102
  } catch (err) {
@@ -5560,7 +6225,7 @@ function isMainModule() {
5560
6225
  const entry = process.argv[1];
5561
6226
  if (entry === void 0) return false;
5562
6227
  try {
5563
- return (0, import_node_fs5.realpathSync)(entry) === (0, import_node_fs5.realpathSync)((0, import_node_url.fileURLToPath)(importMetaUrl));
6228
+ return (0, import_node_fs6.realpathSync)(entry) === (0, import_node_fs6.realpathSync)((0, import_node_url.fileURLToPath)(importMetaUrl));
5564
6229
  } catch {
5565
6230
  return false;
5566
6231
  }