verikun 0.29.0 → 0.30.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/suite.js CHANGED
@@ -21,6 +21,7 @@
21
21
  // The lane itself is executed by cli.ts as a child PROCESS, because exec.ts is spawnSync
22
22
  // throughout: tests awaited inside one process would not overlap device I/O at all.
23
23
  Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.deviceWaitMs = deviceWaitMs;
24
25
  exports.sortTestFiles = sortTestFiles;
25
26
  exports.orderTests = orderTests;
26
27
  exports.readDurationHints = readDurationHints;
@@ -41,12 +42,40 @@ const wait_1 = require("./wait");
41
42
  const version_1 = require("./version");
42
43
  /** The implicit single lane, so serial and parallel share one code path. */
43
44
  const SERIAL_LANE = { id: '', label: '' };
45
+ /** Default for `VERIKUN_SUITE_DEVICE_WAIT_MIN`. Long enough for the server to take over a
46
+ * crashed client's idle lease (5 min) and for its sweep's first few readmission attempts. */
47
+ const DEFAULT_DEVICE_WAIT_MIN = 10;
48
+ /**
49
+ * How long a suite waits when NO lane can get a device — every phone left the pool, or other
50
+ * runs hold them all — before it stops with exit 3 and the unrun tests in `notRun`. `0` stops
51
+ * at once. A lane that waits while another lane is still working is never bounded by this:
52
+ * the queue drains through the working lane. Mirrors `claimTtlMs`, except that an empty
53
+ * value means "unset" rather than zero. Exported for the unit suite.
54
+ */
55
+ function deviceWaitMs(env = process.env) {
56
+ const raw = env.VERIKUN_SUITE_DEVICE_WAIT_MIN?.trim();
57
+ const n = raw ? Number(raw) : NaN;
58
+ return (Number.isFinite(n) && n >= 0 ? n : DEFAULT_DEVICE_WAIT_MIN) * 60_000;
59
+ }
44
60
  /** Gap between the two health probes below. Long enough to outlast a USB
45
61
  * re-enumeration or a simulator relaunch, short enough not to pad a real abort. */
46
62
  const PROBE_RETRY_MS = 1000;
47
63
  /** How often an idle lane re-checks the queue while another lane is still working.
48
64
  * Only reached on the tail of a suite, and only when a requeue is still possible. */
49
65
  const IDLE_POLL_MS = 25;
66
+ /** A lane waiting for a device re-checks at the probe cadence, doubling up to this many
67
+ * cadences between checks — 30s in production, which is how quickly a readmitted phone is
68
+ * noticed. Derived from `probeRetryMs` so the unit suite's cadence shrinks with it. */
69
+ const PARK_BACKOFF_STEPS = 30;
70
+ /** How long a lane that is LOOKING for a device holds a test's header. Whether the look got
71
+ * a phone is only known when the child answers, and a refusal answers in well under a second
72
+ * (one health read, one lease): printed at once, every refused look announced a test that
73
+ * never started. */
74
+ const PROBE_HEADER_DELAY_MS = 2_000;
75
+ /** How many times ONE test may be re-run, without spending a retry, because the server
76
+ * evicted it. Enough for two independent blips; a phone that keeps vanishing under the same
77
+ * test is a problem worth a red row, so the next eviction counts as an ordinary attempt. */
78
+ const MAX_FREE_RERUNS = 2;
50
79
  /**
51
80
  * An environment-flavoured failure is only FATAL if the toolchain is STILL broken when
52
81
  * we re-probe. This distinction is load-bearing: a transient uiautomator dump failure
@@ -183,6 +212,7 @@ function toSuiteResult(file, r, durationMs) {
183
212
  ...(r.planSteps === undefined ? {} : { planSteps: r.planSteps }),
184
213
  modelRepairs: r.modelRepairs,
185
214
  ...(r.ok ? {} : { failure: failure ?? 'failed' }),
215
+ ...(r.evicted ? { evicted: true } : {}),
186
216
  };
187
217
  }
188
218
  /** Compact one attempt for the `attempts` evidence array (pure). */
@@ -193,6 +223,7 @@ function toSuiteAttempt(r) {
193
223
  durationMs: r.durationMs,
194
224
  costUsd: r.costUsd,
195
225
  ...(r.failure ? { failure: r.failure } : {}),
226
+ ...(r.evicted ? { evicted: true } : {}),
196
227
  };
197
228
  }
198
229
  /**
@@ -208,7 +239,9 @@ function mergeSuiteAttempts(attempts) {
208
239
  return last;
209
240
  const round = (n) => Number(n.toFixed(4));
210
241
  const prior = attempts.slice(0, -1).map(toSuiteAttempt);
211
- const flaky = last.ok && prior.some((a) => !a.ok);
242
+ // An eviction is not a flake: the phone left, the test did not fail. Counting it would send
243
+ // someone to stabilise a test whose only fault was being mid-run when a cable came loose.
244
+ const flaky = last.ok && prior.some((a) => !a.ok && !a.evicted);
212
245
  return {
213
246
  ...last,
214
247
  durationMs: attempts.reduce((a, t) => a + t.durationMs, 0),
@@ -302,6 +335,10 @@ function laneCount(available, tests, flags) {
302
335
  // Never open a lane with nothing to run — it only adds a device to the report.
303
336
  return Math.max(1, Math.min(available, tests, n ?? available));
304
337
  }
338
+ /** A wait, for a human: `10m`, `4.2m`, `12s`. */
339
+ function duration(ms) {
340
+ return ms >= 60_000 ? `${Math.round(ms / 6_000) / 10}m` : `${Math.round(ms / 100) / 10}s`;
341
+ }
305
342
  /** Consecutive environment failures that retire a lane even when the probe says the
306
343
  * box is fine. The backstop for a POOL: `deps.preflight` on a remote lane can only ask
307
344
  * whether the SERVER answers, so one dead device behind a healthy server would
@@ -344,6 +381,18 @@ async function cmdSuite(dirArg, flags, deps) {
344
381
  let spentUsd = 0;
345
382
  let retiredLanes = 0;
346
383
  let started = 0;
384
+ /**
385
+ * Attempts that travel WITH a file when a lane hands it back for want of a device, so the
386
+ * lane that picks it up carries on where it stopped: the same attempt count, the same
387
+ * evidence, the same free re-runs used. Deleted the moment a lane takes the file again.
388
+ */
389
+ const carried = new Map();
390
+ /** Lease slots this suite has in flight, per server — our own half of the device gate. */
391
+ const reserved = new Map();
392
+ /** When an attempt last got a device and finished. With no lane busy, a suite whose last
393
+ * progress is `waitMs` old is waiting on a device that is not coming. */
394
+ let lastProgressAt = Date.now();
395
+ const waitMs = deps.deviceWaitMs ?? deviceWaitMs();
347
396
  const tag = (lane) => (parallel && lane.label ? `[suite ${lane.label}]` : '[suite]');
348
397
  async function resetApp(lane, label) {
349
398
  // Returns the abort reason when the lane should stop (confirmed env break during reset).
@@ -376,21 +425,62 @@ async function cmdSuite(dirArg, flags, deps) {
376
425
  (0, output_1.err)(`${tag(lane)} WARN ${warn}`);
377
426
  await (0, wait_1.sleep)((deps.probeRetryMs ?? PROBE_RETRY_MS) * (attempt + 1));
378
427
  }
379
- /** Every attempt at one file, on one lane. */
380
- async function runOneTest(file, lane) {
381
- // Bounded: a lane that retires re-queues its file, so a plain dispatch counter can
382
- // print "(9/8)". Clamp rather than drop the position — it is the only progress signal
383
- // a parallel run gives, and an occasional repeat reads better than a lie.
384
- (0, output_1.err)(`${tag(lane)} ── (${Math.min(++started, files.length)}/${files.length}) ${file} ──`);
385
- const attemptRows = [];
428
+ /** Every attempt at one file, on one lane. `probing`: the lane is waiting for a device, and
429
+ * this dispatch is a look — see PROBE_HEADER_DELAY_MS. */
430
+ async function runOneTest(file, lane, probing = false) {
431
+ // A SERVER lane is a slot, not a device: whether a phone is free for it is the server's
432
+ // answer, asked before every dispatch (see SuiteDeps.serverSlots). Keyed on the wiring,
433
+ // never on `parallel` — `--concurrency 1` against a pooled server still leases per test.
434
+ const gated = !!(deps.serverSlots && lane.server);
435
+ const prior = carried.get(file);
436
+ carried.delete(file);
437
+ let announced = false;
438
+ const announce = () => {
439
+ if (announced)
440
+ return;
441
+ announced = true;
442
+ // Bounded: a lane that retires re-queues its file, so a plain dispatch counter can
443
+ // print "(9/8)". Clamp rather than drop the position — it is the only progress signal
444
+ // a parallel run gives, and an occasional repeat reads better than a lie. A file picked
445
+ // back up after a hand-back was counted when it first ran.
446
+ if (prior)
447
+ (0, output_1.err)(`${tag(lane)} ── ${file} (continued) ──`);
448
+ else
449
+ (0, output_1.err)(`${tag(lane)} ── (${Math.min(++started, files.length)}/${files.length}) ${file} ──`);
450
+ };
451
+ // A gated lane says nothing until it has a device, or a lane waiting for one would print
452
+ // a header every time it looked.
453
+ if (!gated)
454
+ announce();
455
+ const attemptRows = prior?.rows ?? [];
456
+ let freeReruns = prior?.freeReruns ?? 0;
457
+ /** The attempt whose "retry k/N" line a gated lane has printed — the header is said once
458
+ * per test, the retry line once per attempt. */
459
+ let saidFor = -1;
386
460
  let envFlavoured = false;
387
461
  const merge = () => attemptRows.length ? mergeSuiteAttempts(attemptRows) : undefined;
388
- for (let attempt = 0; attempt <= retries; attempt++) {
462
+ /** Did any attempt on THIS dispatch get a device and run? */
463
+ let ranHere = false;
464
+ /** No device for this attempt: nothing is recorded and nothing is spent — not a retry, not
465
+ * a streak tick — and whatever came before rides back to the queue with the file (#147). */
466
+ const handBack = (attempt, reason) => {
467
+ if (attemptRows.length || attempt > 0 || freeReruns > 0)
468
+ carried.set(file, { rows: attemptRows, attempt, freeReruns });
469
+ // A dispatch that never ran never held a place in the run order.
470
+ if (announced && !prior && !attemptRows.length)
471
+ started -= 1;
472
+ return { handBack: reason, ...(ranHere ? { ran: true } : {}) };
473
+ };
474
+ for (let attempt = prior?.attempt ?? 0; attempt <= retries; attempt++) {
389
475
  // The last attempt is where a retryable failure becomes the verdict: a confirmed
390
476
  // env break retires the lane, anything else stands as this test's failed row.
391
477
  const lastAttempt = attempt === retries;
392
- if (attempt > 0)
393
- (0, output_1.err)(`${tag(lane)} retry ${attempt}/${retries} for ${file}`);
478
+ const retryLine = () => {
479
+ if (attempt > 0)
480
+ (0, output_1.err)(`${tag(lane)} retry ${attempt}/${retries} for ${file}`);
481
+ };
482
+ if (!gated)
483
+ retryLine();
394
484
  // Re-isolate before EVERY attempt — between tests and between retries alike.
395
485
  const resetBreak = await resetApp(lane, attempt > 0 ? ' (retry)' : '');
396
486
  if (resetBreak) {
@@ -401,11 +491,71 @@ async function cmdSuite(dirArg, flags, deps) {
401
491
  // With no attempt row this test never ran, so the caller re-queues it.
402
492
  return { row: merge(), envBreak: `reset failed: ${resetBreak}` };
403
493
  }
494
+ let slot;
495
+ if (gated) {
496
+ const server = lane.server;
497
+ const slots = await deps.serverSlots(lane);
498
+ const inUse = reserved.get(server) ?? 0;
499
+ // Check and reserve with NO `await` in between: two lanes waking together must not
500
+ // both see the one free phone and both ask for it, because the loser's lease request
501
+ // is exactly what lets the server take over a quiet sibling's phone.
502
+ if (slots && slots.capacity <= inUse)
503
+ return handBack(attempt, slots.note);
504
+ reserved.set(server, inUse + 1);
505
+ slot = server;
506
+ }
507
+ const say = () => {
508
+ announce();
509
+ if (saidFor === attempt)
510
+ return;
511
+ saidFor = attempt;
512
+ retryLine();
513
+ };
514
+ // A look says nothing until its child has plainly got a phone; any other gated dispatch
515
+ // announces now, before the child's own output starts streaming.
516
+ const held = gated && probing ? setTimeout(say, PROBE_HEADER_DELAY_MS) : undefined;
517
+ if (gated && !probing)
518
+ say();
404
519
  const t0 = Date.now();
405
520
  try {
406
- const r = await deps.runTest((0, node_path_1.join)(dir, file), lane);
521
+ let r;
522
+ try {
523
+ r = await deps.runTest((0, node_path_1.join)(dir, file), lane);
524
+ }
525
+ finally {
526
+ clearTimeout(held);
527
+ // Only the child holds a lease; the re-probes below do not, so neither does the slot.
528
+ if (slot) {
529
+ const n = (reserved.get(slot) ?? 1) - 1;
530
+ if (n > 0)
531
+ reserved.set(slot, n);
532
+ else
533
+ reserved.delete(slot);
534
+ }
535
+ }
536
+ // Refused at the lease (the gate raced another run, or a job outside this suite holds
537
+ // the phones). The test never started, so this was never an attempt.
538
+ if (r.noDevice)
539
+ return handBack(attempt, r.failure?.reason ?? 'the server had no free device');
540
+ if (gated)
541
+ say(); // a look that got its phone, and finished inside the header's hold
542
+ ranHere = true;
543
+ lastProgressAt = Date.now();
407
544
  attemptRows.push(toSuiteResult(file, r, Date.now() - t0));
408
545
  envFlavoured = !!r.abortedForEnv;
546
+ // The server ended this run because its phone left the pool. Neither the test's fault
547
+ // nor this lane's: start it again as a fresh run without spending a retry. Only on a
548
+ // server lane, where each attempt is a new run token — the serial path's single token
549
+ // is as dead as its phone, and aborts on the re-probe below exactly as it did.
550
+ if (r.evicted && lane.server && freeReruns < MAX_FREE_RERUNS) {
551
+ freeReruns += 1;
552
+ const warn = `${file}: its device ${r.device ?? '(unknown)'} left the pool mid-run — re-run as a fresh run ` +
553
+ `(${freeReruns}/${MAX_FREE_RERUNS}, not counted toward --retries)`;
554
+ warnings.push(warn);
555
+ (0, output_1.err)(`${tag(lane)} WARN ${warn}`);
556
+ attempt -= 1;
557
+ continue;
558
+ }
409
559
  if (r.abortedForEnv) {
410
560
  const broken = await stillBroken(deps, lane);
411
561
  if (broken) {
@@ -423,6 +573,10 @@ async function cmdSuite(dirArg, flags, deps) {
423
573
  break;
424
574
  }
425
575
  catch (e) {
576
+ if (gated)
577
+ say(); // a look whose child threw inside the header's hold still ran a test
578
+ ranHere = true;
579
+ lastProgressAt = Date.now();
426
580
  // A test that THREW (device gone, server unreachable, bad file) still becomes a
427
581
  // failed row — one broken test must not vaporize the suite report for the tests
428
582
  // that already ran. Out of attempts, a confirmed env break retires the lane.
@@ -466,6 +620,47 @@ async function cmdSuite(dirArg, flags, deps) {
466
620
  }
467
621
  stop ??= { reason, kind: 'environment' };
468
622
  }
623
+ /**
624
+ * Wait for this lane's next look at the queue, re-checking every IDLE_POLL_MS whether to
625
+ * stop waiting altogether — so a waiting lane neither holds a finished suite open nor
626
+ * sleeps through its end. 'look' means try the queue again.
627
+ */
628
+ async function waitForDevice(park) {
629
+ for (;;) {
630
+ if (stop)
631
+ return 'done';
632
+ // Nothing left to run and nobody running: the suite finished without this lane.
633
+ if (!queue.length && busyLanes === 0)
634
+ return 'done';
635
+ // Nobody is running, and nothing has for the whole wait: every lane is waiting on a
636
+ // device that is not coming. That stops the SUITE, with one reason — not one retirement
637
+ // per lane, each claiming its tests "move to the others" who are waiting too.
638
+ if (busyLanes === 0 && Date.now() - lastProgressAt >= waitMs) {
639
+ // One last look first, unless one has happened since the deadline: backed off, the
640
+ // previous look may be half the wait old, and a phone that came back in between would
641
+ // otherwise never be seen — the suite exiting 3 to say none became free.
642
+ if (queue.length && park.lastLookAt < lastProgressAt + waitMs)
643
+ return 'look';
644
+ stop ??= {
645
+ reason: `no device became free${waitMs > 0 ? ` for ${duration(waitMs)}` : ''} — ${park.reason}`,
646
+ kind: 'environment',
647
+ };
648
+ return 'done';
649
+ }
650
+ if (queue.length && Date.now() >= park.nextAt)
651
+ return 'look';
652
+ await (0, wait_1.sleep)(IDLE_POLL_MS);
653
+ }
654
+ }
655
+ /** One warning per waiting spell, not one per look: how long, and the server's reason.
656
+ * `ran` is the test the lane finally got a device for — reported once it is over, which is
657
+ * the first moment the spell is known to have ended. */
658
+ function endPark(lane, park, ran) {
659
+ const waited = duration(Date.now() - park.since);
660
+ const warn = `${lane.label || 'the suite'} waited ${waited} for a free device — ${park.reason}`;
661
+ warnings.push(warn);
662
+ (0, output_1.err)(`${tag(lane)} ${ran ? `${ran} ran after this lane waited ${waited} for a free device` : `stopped waiting for a device after ${waited}`}`);
663
+ }
469
664
  /**
470
665
  * One lane, pulling from the shared queue until it is empty, the lane is retired, or
471
666
  * the suite stops. This — not a fixed partition — is what absorbs the duration spread:
@@ -474,9 +669,23 @@ async function cmdSuite(dirArg, flags, deps) {
474
669
  let busyLanes = 0;
475
670
  async function laneWorker(lane) {
476
671
  let envStreak = 0;
672
+ // Set while this lane has no device to run on. A PARKED lane is not busy and holds no
673
+ // file: whatever it could not start went back to the front of the queue for any lane.
674
+ let park;
675
+ const cadence = Math.max(IDLE_POLL_MS, deps.probeRetryMs ?? PROBE_RETRY_MS);
477
676
  for (;;) {
478
- if (stop)
677
+ if (stop) {
678
+ if (park)
679
+ endPark(lane, park);
479
680
  return;
681
+ }
682
+ if (park) {
683
+ if ((await waitForDevice(park)) === 'done') {
684
+ endPark(lane, park);
685
+ return;
686
+ }
687
+ park.lastLookAt = Date.now();
688
+ }
480
689
  const file = queue.shift();
481
690
  if (file === undefined) {
482
691
  // An empty queue is NOT the end while another lane is still working: a lane that
@@ -485,8 +694,12 @@ async function cmdSuite(dirArg, flags, deps) {
485
694
  // is the one outcome a gate must never produce. Poll rather than signal: the
486
695
  // wait only ever happens on the tail of a suite, and a condition variable here
487
696
  // would be more machinery than the case is worth.
488
- if (busyLanes === 0)
697
+ if (busyLanes === 0) {
698
+ // A waiting lane whose look lost the last file to a sibling still reports its wait.
699
+ if (park)
700
+ endPark(lane, park);
489
701
  return;
702
+ }
490
703
  await (0, wait_1.sleep)(IDLE_POLL_MS);
491
704
  continue;
492
705
  }
@@ -494,7 +707,7 @@ async function cmdSuite(dirArg, flags, deps) {
494
707
  let outcome;
495
708
  const startedTest = Date.now();
496
709
  try {
497
- outcome = await runOneTest(file, lane);
710
+ outcome = await runOneTest(file, lane, park !== undefined);
498
711
  }
499
712
  catch (e) {
500
713
  // The file is already off the queue, so an escaping throw would erase this test
@@ -507,6 +720,35 @@ async function cmdSuite(dirArg, flags, deps) {
507
720
  finally {
508
721
  busyLanes -= 1;
509
722
  }
723
+ if (outcome.handBack !== undefined) {
724
+ // No device: back to the FRONT, so whichever lane frees first runs it next and the
725
+ // longest-first order holds. This lane waits, backing off; it neither fails the test
726
+ // nor pulls the next one only to be refused again (#147).
727
+ queue.unshift(file);
728
+ const now = Date.now();
729
+ // A look that RAN a test was working, not waiting: that spell is over, and a new one
730
+ // starts — its run time is not waiting time.
731
+ if (park && outcome.ran) {
732
+ endPark(lane, park, file);
733
+ park = undefined;
734
+ }
735
+ if (!park) {
736
+ park = { since: now, reason: outcome.handBack, delayMs: cadence, nextAt: now + cadence, lastLookAt: now };
737
+ (0, output_1.err)(`${tag(lane)} no free device — ${outcome.handBack}; ${file} goes back to the queue and this lane waits for one`);
738
+ }
739
+ else {
740
+ // The LATEST reason: the pool may have changed since the spell began, and the give-up
741
+ // and the warning should name what the server says now.
742
+ park.reason = outcome.handBack;
743
+ park.delayMs = Math.min(park.delayMs * 2, cadence * PARK_BACKOFF_STEPS);
744
+ park.nextAt = now + park.delayMs;
745
+ }
746
+ continue;
747
+ }
748
+ if (park) {
749
+ endPark(lane, park, file);
750
+ park = undefined;
751
+ }
510
752
  if (outcome.row) {
511
753
  results.push(outcome.row);
512
754
  spentUsd += outcome.row.costUsd;
@@ -526,6 +768,8 @@ async function cmdSuite(dirArg, flags, deps) {
526
768
  // Only a ceiling that actually STOPS something is an abort. Without the queue
527
769
  // check, a suite whose last test tips the total over its budget reports ABORTED
528
770
  // with an empty `notRun` and exits 1 — a red gate on a run where every test passed.
771
+ // (A file handed back mid-retry carries its spend with it, so that spend is counted
772
+ // when its row lands — at most one attempt late.)
529
773
  if (maxSuiteCost !== undefined && spentUsd >= maxSuiteCost && queue.length > 0) {
530
774
  stop ??= {
531
775
  reason: `suite cost ceiling $${maxSuiteCost} reached (spent $${spentUsd.toFixed(4)})`,
@@ -546,6 +790,16 @@ async function cmdSuite(dirArg, flags, deps) {
546
790
  warnings.push(warn);
547
791
  (0, output_1.err)(`[suite] WARN ${warn}`);
548
792
  }
793
+ // A file handed back with attempts already on it RAN, so it is a row — it is only still
794
+ // queued because the suite stopped before any lane could take it again, and a file must be
795
+ // in exactly one of `tests` and `notRun`.
796
+ for (const [file, c] of carried) {
797
+ const at = queue.indexOf(file);
798
+ if (at < 0 || !c.rows.length)
799
+ continue;
800
+ queue.splice(at, 1);
801
+ results.push(mergeSuiteAttempts(c.rows));
802
+ }
549
803
  // Belt and braces: work left in the queue with nothing explaining why would be a
550
804
  // silently short suite. Every real path (retirement, budget) has already set `stop`.
551
805
  if (!stop && queue.length) {
package/dist/version.js CHANGED
@@ -3,4 +3,4 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.VERSION = void 0;
4
4
  // GENERATED by scripts/gen-version.mjs from package.json's "version" at build time
5
5
  // (the `prebuild` script). Do NOT edit by hand; bump package.json instead.
6
- exports.VERSION = '0.29.0';
6
+ exports.VERSION = '0.30.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "verikun",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Drive Android emulators/devices and iOS simulators for AI agents: tap, type, swipe, screenshot, and inspect the UI hierarchy by semantic identifiers — like Puppeteer for native apps.",
5
5
  "keywords": [
6
6
  "android",