@plune-ai/cli 0.26.0 → 0.27.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/README.md CHANGED
@@ -392,6 +392,17 @@ The shard that is the Nth also prints `plune: run closed` just above that line,
392
392
  and leaves the run open, as `PLUNE_SHARED_RUN` does: close it with `plune run finish`.
393
393
  - With `--fail-on-failures`, the shard that closes the run gates on its summary; the others have no verdict yet.
394
394
 
395
+ ### A time limit for the import
396
+
397
+ The whole `plune run import` stops after 600 seconds by default, so a platform that stops answering cannot hold a CI job until its own timeout kills it. Raise it for a very large report, or turn it off with `0`:
398
+
399
+ ```bash
400
+ PLUNE_IMPORT_TIMEOUT=1200 plune run import ./playwright.json # seconds, 0 to 86400; 0 is no limit
401
+ plune run import ./playwright.json --timeout 1200 # the same, as a flag (it wins)
402
+ ```
403
+
404
+ At the limit the command sends `terminate` for the run it opened (not for a run other jobs share), says in one line that the platform may have received part of the results, and exits 1. Import the report again afterwards.
405
+
395
406
  ### Many screenshots: upload concurrency and retries
396
407
 
397
408
  Files go to Plune up to 100 to a request: `uploads-batch` announces them (with the SHA-256 of each, read as a stream,
package/dist/cli.cjs CHANGED
@@ -2579,6 +2579,7 @@ function readEnv(env = process.env) {
2579
2579
  const warnings = [];
2580
2580
  const uploadConcurrency = bounded(env, "PLUNE_UPLOAD_CONCURRENCY", UPLOAD_CONCURRENCY, warnings);
2581
2581
  const uploadRetries = bounded(env, "PLUNE_UPLOAD_RETRIES", UPLOAD_RETRIES, warnings);
2582
+ const importTimeout = bounded(env, "PLUNE_IMPORT_TIMEOUT", IMPORT_TIMEOUT, warnings);
2582
2583
  const title = value(env, "PLUNE_RUN_TITLE");
2583
2584
  const environment = value(env, "PLUNE_ENV");
2584
2585
  const labels = list(env, "PLUNE_LABELS");
@@ -2610,6 +2611,7 @@ function readEnv(env = process.env) {
2610
2611
  keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED") || shard !== void 0 || shardError !== void 0,
2611
2612
  ...shard !== void 0 ? { shard } : {},
2612
2613
  ...shardError !== void 0 ? { shardError } : {},
2614
+ ...importTimeout !== void 0 ? { importTimeout } : {},
2613
2615
  ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0),
2614
2616
  warnings
2615
2617
  };
@@ -2637,7 +2639,7 @@ function ciCommit(reported, env = process.env) {
2637
2639
  ...branch !== void 0 ? { branch } : {}
2638
2640
  };
2639
2641
  }
2640
- var path6, UNSUPPORTED_VARS, SHARDS_MAX, UPLOAD_CONCURRENCY, UPLOAD_RETRIES, PROVIDERS;
2642
+ var path6, UNSUPPORTED_VARS, SHARDS_MAX, UPLOAD_CONCURRENCY, UPLOAD_RETRIES, IMPORT_TIMEOUT, PROVIDERS;
2641
2643
  var init_env2 = __esm({
2642
2644
  "src/reporter-core/env.ts"() {
2643
2645
  "use strict";
@@ -2647,6 +2649,7 @@ var init_env2 = __esm({
2647
2649
  SHARDS_MAX = 1e3;
2648
2650
  UPLOAD_CONCURRENCY = { min: 1, max: 32, default: 16 };
2649
2651
  UPLOAD_RETRIES = { min: 0, max: 20, default: 6 };
2652
+ IMPORT_TIMEOUT = { min: 0, max: 86400, default: 600 };
2650
2653
  PROVIDERS = [
2651
2654
  { is: "GITHUB_ACTIONS", sha: "GITHUB_SHA", branch: ["GITHUB_HEAD_REF", "GITHUB_REF_NAME"] },
2652
2655
  { is: "GITLAB_CI", sha: "CI_COMMIT_SHA", branch: ["CI_COMMIT_REF_NAME"] },
@@ -5365,8 +5368,24 @@ var init_importers = __esm({
5365
5368
  // src/cli/commands/run-import.ts
5366
5369
  var run_import_exports = {};
5367
5370
  __export(run_import_exports, {
5371
+ ImportTimeoutError: () => ImportTimeoutError,
5368
5372
  handleRunImport: () => handleRunImport
5369
5373
  });
5374
+ async function timeoutMessage(limit, runId, ours, options) {
5375
+ const head = `plune: run import did not finish in ${limit} s (--timeout / PLUNE_IMPORT_TIMEOUT; 0 turns the limit off); the platform may have received part of the results`;
5376
+ if (runId === null) return `${head}; the run id is not known, so nothing was terminated - look for an open run with this key in Plune and terminate it there, then import again.`;
5377
+ if (!ours) return `${head}; run ${runId} is shared with other jobs and was left open - close it with "plune run finish ${runId}" once they are done, then import this report again.`;
5378
+ const wait = options.terminateWaitMs ?? 15e3;
5379
+ let timer;
5380
+ const gaveUp = new Promise((resolve10) => timer = setTimeout(() => resolve10("gaveup"), wait));
5381
+ const quiet = () => void 0;
5382
+ const closed = handleRunFinish(runId, { ...options, write: quiet, terminate: true, reason: `import timed out after ${limit} s` }).then(
5383
+ () => true,
5384
+ () => false
5385
+ );
5386
+ const done = await Promise.race([closed, gaveUp]).finally(() => clearTimeout(timer)) === true;
5387
+ return done ? `${head}; run ${runId} was terminated - import the report again.` : `${head}; run ${runId} could not be terminated from here - run "plune run finish ${runId} --terminate" (the platform also closes a silent run after 30 minutes), then import again.`;
5388
+ }
5370
5389
  async function handleRunImport(options) {
5371
5390
  const write = options.write ?? ((line) => void process.stdout.write(`${line}
5372
5391
  `));
@@ -5393,31 +5412,47 @@ async function handleRunImport(options) {
5393
5412
  const apiUrl = resolveApiUrl(options.apiUrl ?? env.config.apiUrl);
5394
5413
  const token = options.token ?? env.config.token ?? loadToken() ?? "";
5395
5414
  if (token === "") throw new NoTokenError();
5396
- const session = await startRun(
5397
- {
5398
- apiUrl,
5399
- token,
5400
- ...options.fetchImpl !== void 0 ? { fetchImpl: options.fetchImpl } : {},
5401
- ...options.key !== void 0 ? { externalKey: options.key } : {},
5402
- ...options.create === true ? { offerDiscovered: true } : {},
5403
- ...options.uploadConcurrency !== void 0 ? { uploadConcurrency: options.uploadConcurrency } : {},
5404
- kind: "automated",
5405
- // The CI run the report names, never this machine's: whoever imports may be another job (#790).
5406
- // The commit and branch likewise, where the report names a commit; a report that names none —
5407
- // JUnit's never does — takes this job's own, which is where the tests ran when import follows
5408
- // them in one job (plune-ai/plune#927).
5409
- meta: { runner: format, ...ciUrl !== void 0 ? { ciUrl } : {}, ...ciCommit(ci) },
5410
- log: write
5411
- },
5412
- // The file is the complete list of what ran, so the platform can resolve every test in one
5413
- // request and derive what never ran from the same list (AC-09/AC-10) — the lazy path would be
5414
- // a lookup per result.
5415
- results.map((r) => r.keys)
5416
- );
5417
- for (const result2 of results) await session.add(result2);
5415
+ let session;
5416
+ const send = async () => {
5417
+ session = await startRun(
5418
+ {
5419
+ apiUrl,
5420
+ token,
5421
+ ...options.fetchImpl !== void 0 ? { fetchImpl: options.fetchImpl } : {},
5422
+ ...options.key !== void 0 ? { externalKey: options.key } : {},
5423
+ ...options.create === true ? { offerDiscovered: true } : {},
5424
+ ...options.uploadConcurrency !== void 0 ? { uploadConcurrency: options.uploadConcurrency } : {},
5425
+ kind: "automated",
5426
+ // The CI run the report names, never this machine's: whoever imports may be another job (#790).
5427
+ // The commit and branch likewise, where the report names a commit; a report that names none —
5428
+ // JUnit's never does — takes this job's own, which is where the tests ran when import follows
5429
+ // them in one job (plune-ai/plune#927).
5430
+ meta: { runner: format, ...ciUrl !== void 0 ? { ciUrl } : {}, ...ciCommit(ci) },
5431
+ log: write
5432
+ },
5433
+ // The file is the complete list of what ran, so the platform can resolve every test in one
5434
+ // request and derive what never ran from the same list (AC-09/AC-10) — the lazy path would be
5435
+ // a lookup per result.
5436
+ results.map((r) => r.keys)
5437
+ );
5438
+ for (const result2 of results) await session.add(result2);
5439
+ return env.keepOpen ? session.leaveOpen({ holdShardLines: true }) : session.finish();
5440
+ };
5441
+ const limit = options.timeoutSec ?? env.importTimeout ?? IMPORT_TIMEOUT.default;
5442
+ const sent = send();
5418
5443
  let frozen;
5419
- if (env.keepOpen) frozen = await session.leaveOpen({ holdShardLines: true });
5420
- else frozen = await session.finish();
5444
+ if (limit === 0) frozen = await sent;
5445
+ else {
5446
+ sent.catch(() => void 0);
5447
+ let timer;
5448
+ const late = new Promise((resolve10) => timer = setTimeout(() => resolve10("late"), limit * 1e3));
5449
+ const first = await Promise.race([sent, late]).finally(() => clearTimeout(timer));
5450
+ if (first === "late") {
5451
+ throw new ImportTimeoutError(await timeoutMessage(limit, session?.runId ?? null, !env.keepOpen, { ...options, apiUrl, token }));
5452
+ }
5453
+ frozen = first;
5454
+ }
5455
+ if (session === void 0) throw new RunCommandError("The import did not start.");
5421
5456
  const stats = session.stats;
5422
5457
  const result = {
5423
5458
  runId: session.runId,
@@ -5512,7 +5547,7 @@ function describe(result, apiUrl) {
5512
5547
  if (result.runId !== null) lines.push(runUrl2(apiUrl, result.runId));
5513
5548
  return lines;
5514
5549
  }
5515
- var fs12, NEXT;
5550
+ var fs12, ImportTimeoutError, NEXT;
5516
5551
  var init_run_import = __esm({
5517
5552
  "src/cli/commands/run-import.ts"() {
5518
5553
  "use strict";
@@ -5526,6 +5561,8 @@ var init_run_import = __esm({
5526
5561
  init_credentials();
5527
5562
  init_gate();
5528
5563
  init_env2();
5564
+ ImportTimeoutError = class extends RunCommandError {
5565
+ };
5529
5566
  NEXT = {
5530
5567
  queue: "the review queue is full. Approve or reject what is waiting, then import this report again to offer the rest.",
5531
5568
  cases: "the project is at its test case limit. Delete cases you no longer need, or ask an operator to raise the limit, then import this report again to offer the rest.",
@@ -6862,6 +6899,13 @@ function createProgram() {
6862
6899
  if (!/^\d+$/.test(v) || Number(v) < 1 || Number(v) > 32) throw new import_commander.InvalidArgumentError("a whole number from 1 to 32.");
6863
6900
  return Number(v);
6864
6901
  }
6902
+ ).option(
6903
+ "--timeout <seconds>",
6904
+ "Give up on the whole import after this many seconds, 0 for no limit (default 600; PLUNE_IMPORT_TIMEOUT sets it too). On a timeout the run this import opened is terminated, and the exit code is 1",
6905
+ (v) => {
6906
+ if (!/^\d+$/.test(v) || Number(v) > 86400) throw new import_commander.InvalidArgumentError("a whole number of seconds from 0 to 86400.");
6907
+ return Number(v);
6908
+ }
6865
6909
  ).option("--fail-on-failures", FAIL_ON_FAILURES, false).action(
6866
6910
  async (file, options, command) => {
6867
6911
  const [
@@ -6888,6 +6932,7 @@ function createProgram() {
6888
6932
  ...options.key !== void 0 ? { key: options.key } : {},
6889
6933
  ...options.create ? { create: true } : {},
6890
6934
  ...options.uploadConcurrency !== void 0 ? { uploadConcurrency: options.uploadConcurrency } : {},
6935
+ ...options.timeout !== void 0 ? { timeoutSec: options.timeout } : {},
6891
6936
  ...options.failOnFailures ? { failOnFailures: true } : {}
6892
6937
  });
6893
6938
  if (options.failOnMissingAttachments && imported.missingAttachments.length > 0) process.exitCode = 1;
@@ -316,6 +316,7 @@ function shardOf(env) {
316
316
  }
317
317
  var UPLOAD_CONCURRENCY = { min: 1, max: 32, default: 16 };
318
318
  var UPLOAD_RETRIES = { min: 0, max: 20, default: 6 };
319
+ var IMPORT_TIMEOUT = { min: 0, max: 86400, default: 600 };
319
320
  function value(env, name) {
320
321
  const raw = env[name]?.trim();
321
322
  return raw === void 0 || raw === "" ? void 0 : raw;
@@ -353,6 +354,7 @@ function readEnv(env = process.env) {
353
354
  const warnings = [];
354
355
  const uploadConcurrency = bounded(env, "PLUNE_UPLOAD_CONCURRENCY", UPLOAD_CONCURRENCY, warnings);
355
356
  const uploadRetries = bounded(env, "PLUNE_UPLOAD_RETRIES", UPLOAD_RETRIES, warnings);
357
+ const importTimeout = bounded(env, "PLUNE_IMPORT_TIMEOUT", IMPORT_TIMEOUT, warnings);
356
358
  const title = value(env, "PLUNE_RUN_TITLE");
357
359
  const environment = value(env, "PLUNE_ENV");
358
360
  const labels = list(env, "PLUNE_LABELS");
@@ -384,6 +386,7 @@ function readEnv(env = process.env) {
384
386
  keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED") || shard !== void 0 || shardError !== void 0,
385
387
  ...shard !== void 0 ? { shard } : {},
386
388
  ...shardError !== void 0 ? { shardError } : {},
389
+ ...importTimeout !== void 0 ? { importTimeout } : {},
387
390
  ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0),
388
391
  warnings
389
392
  };
@@ -445,6 +445,8 @@ interface EnvSettings {
445
445
  shard?: ShardOf;
446
446
  /** `PLUNE_SHARD` was set to something that is not `k/N`. A configuration error: the caller refuses to start. */
447
447
  shardError?: string;
448
+ /** `PLUNE_IMPORT_TIMEOUT` in seconds, when set and usable. */
449
+ importTimeout?: number;
448
450
  /** Variables that were set and have nowhere to land. The caller says so once. */
449
451
  ignored: string[];
450
452
  /** Variables that were set to something unusable and fell back to their default. The caller says so once. */
@@ -445,6 +445,8 @@ interface EnvSettings {
445
445
  shard?: ShardOf;
446
446
  /** `PLUNE_SHARD` was set to something that is not `k/N`. A configuration error: the caller refuses to start. */
447
447
  shardError?: string;
448
+ /** `PLUNE_IMPORT_TIMEOUT` in seconds, when set and usable. */
449
+ importTimeout?: number;
448
450
  /** Variables that were set and have nowhere to land. The caller says so once. */
449
451
  ignored: string[];
450
452
  /** Variables that were set to something unusable and fell back to their default. The caller says so once. */
@@ -267,6 +267,7 @@ function shardOf(env) {
267
267
  }
268
268
  var UPLOAD_CONCURRENCY = { min: 1, max: 32, default: 16 };
269
269
  var UPLOAD_RETRIES = { min: 0, max: 20, default: 6 };
270
+ var IMPORT_TIMEOUT = { min: 0, max: 86400, default: 600 };
270
271
  function value(env, name) {
271
272
  const raw = env[name]?.trim();
272
273
  return raw === void 0 || raw === "" ? void 0 : raw;
@@ -304,6 +305,7 @@ function readEnv(env = process.env) {
304
305
  const warnings = [];
305
306
  const uploadConcurrency = bounded(env, "PLUNE_UPLOAD_CONCURRENCY", UPLOAD_CONCURRENCY, warnings);
306
307
  const uploadRetries = bounded(env, "PLUNE_UPLOAD_RETRIES", UPLOAD_RETRIES, warnings);
308
+ const importTimeout = bounded(env, "PLUNE_IMPORT_TIMEOUT", IMPORT_TIMEOUT, warnings);
307
309
  const title = value(env, "PLUNE_RUN_TITLE");
308
310
  const environment = value(env, "PLUNE_ENV");
309
311
  const labels = list(env, "PLUNE_LABELS");
@@ -335,6 +337,7 @@ function readEnv(env = process.env) {
335
337
  keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED") || shard !== void 0 || shardError !== void 0,
336
338
  ...shard !== void 0 ? { shard } : {},
337
339
  ...shardError !== void 0 ? { shardError } : {},
340
+ ...importTimeout !== void 0 ? { importTimeout } : {},
338
341
  ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0),
339
342
  warnings
340
343
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plune-ai/cli",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "AI-powered assertion testing for LLM apps — write assertions, run them against any provider, get a pass/fail report (local, CI, or diff).",
5
5
  "keywords": [
6
6
  "ai",