@somewhere-tech/cli 0.36.1 → 0.37.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.
@@ -6,7 +6,21 @@ import { canonicalProjectRoot, getToken, loadConfig, loadProjectConfig, loadTemp
6
6
  import { normalizeBrowserActions, } from '../lib/browser-actions.js';
7
7
  import { isLoopbackUrl, runLocalBrowser } from '../lib/browser-run.js';
8
8
  import { dim, error, green, red, teal } from '../lib/output.js';
9
+ import { withVerifyProgress } from '../lib/verify-progress.js';
10
+ import { saveVerifyReport } from '../lib/verify-report.js';
11
+ import { readBrowserIncomplete, readBrowserLifecycle, formatStepDuration } from './browser.js';
12
+ /** The local browser's own bound (no platform execution cap applies). */
9
13
  const VERIFY_TIMEOUT_MS = 90_000;
14
+ /** The execution cap a hosted verify run asks for: one connected run, long
15
+ * enough for a 30-action segment at a slow but progressing pace. */
16
+ const VERIFY_BUDGET_MS = 180_000;
17
+ /** The platform's accepted execution-cap range. */
18
+ const VERIFY_MIN_BUDGET_MS = 10_000;
19
+ /** Beyond the execution cap, the wait covers slot admission, launch and first
20
+ * navigation, the final capture and release. */
21
+ const VERIFY_TRANSPORT_HEADROOM_MS = 45_000;
22
+ /** How long the client waits for a hosted run's answer. */
23
+ const VERIFY_TRANSPORT_TIMEOUT_MS = VERIFY_BUDGET_MS + VERIFY_TRANSPORT_HEADROOM_MS;
10
24
  const DEFAULT_VIEWPORTS = ['desktop', 'mobile'];
11
25
  /** Say that the initial navigation was repeated, and only what was observed. */
12
26
  function navigationRetryNote(retries) {
@@ -264,13 +278,67 @@ function reportPassed(report) {
264
278
  return shot.length > 0;
265
279
  return !shot.error && !!(shot.path ?? shot.url ?? shot.fs_path ?? shot.scratch_url);
266
280
  });
267
- return report.passed !== false
281
+ return report.passed !== false && !readBrowserLifecycle(report)
268
282
  && (report.page_errors?.length ?? 0) === 0
269
283
  && (report.console_errors?.length ?? 0) === 0
270
284
  && (report.failed_requests?.length ?? 0) === 0
271
285
  && !(report.request_expectations ?? []).some((item) => !item.ok)
272
286
  && screenshotHealthy;
273
287
  }
288
+ /** Something genuinely went wrong in the part of the run that executed: a step
289
+ * that ran and failed, a page error, a console error, or a failed request.
290
+ * An expected request that was not seen is not in this list: on a capped run
291
+ * it may belong to an action that never started. */
292
+ function executedPartFailed(report) {
293
+ return (report.steps ?? []).some((step) => step.ran !== false && (step.ok ?? step.passed) === false)
294
+ || (report.page_errors?.length ?? 0) > 0
295
+ || (report.console_errors?.length ?? 0) > 0
296
+ || (report.failed_requests?.length ?? 0) > 0;
297
+ }
298
+ /**
299
+ * The run's execution-cap stop in the caller's numbering, or null when there
300
+ * is none or when anything failed before it (then it is judged as a failure:
301
+ * an incomplete never hides an earlier genuine failure). `offset` is 1 when the
302
+ * CLI inserted a leading goto the caller did not write.
303
+ */
304
+ export function mapIncomplete(report, sent, offset) {
305
+ const incomplete = readBrowserIncomplete(report);
306
+ if (!incomplete || report.passed === true || executedPartFailed(report))
307
+ return null;
308
+ if (incomplete.next_step >= sent.length || incomplete.completed > incomplete.next_step)
309
+ return null;
310
+ const callerTotal = sent.length - offset;
311
+ const callerNext = Math.max(0, incomplete.next_step - offset);
312
+ return {
313
+ reason: 'execution_cap',
314
+ completed: Math.max(0, incomplete.completed - offset),
315
+ next_step: incomplete.next_step - offset + 1,
316
+ next_action: actionName(sent[incomplete.next_step], `action ${incomplete.next_step + 1}`),
317
+ not_run: callerTotal - callerNext,
318
+ budget_ms: incomplete.budget_ms,
319
+ elapsed_ms: incomplete.elapsed_ms,
320
+ };
321
+ }
322
+ function incompleteSentence(item, where) {
323
+ const next = item.next_step === 0 ? `the goto for its path (${item.next_action})` : `step ${item.next_step} (${item.next_action})`;
324
+ return `${where} reached its ${Math.round(item.budget_ms / 1000)}s execution cap after ${item.completed} action${item.completed === 1 ? '' : 's'}; ${next} and every later action did not run and have no verdict. Nothing failed before the cap.`;
325
+ }
326
+ function transportUnknown(cause) {
327
+ if (!(cause instanceof CliApiError))
328
+ return null;
329
+ const noResponse = cause.statusCode === 0 && ['TIMEOUT', 'NETWORK_ERROR', 'SERVER_SLOW'].includes(cause.code);
330
+ const partialSuccess = cause.statusCode >= 200 && cause.statusCode < 300 && cause.meta.responseBodyIncomplete === true;
331
+ if (!noResponse && !partialSuccess)
332
+ return null;
333
+ return {
334
+ passed: false,
335
+ lifecycle: { status: ['TIMEOUT', 'SERVER_SLOW', 'RESPONSE_BODY_TIMEOUT'].includes(cause.code) ? 'transport_timeout' : 'transport_error', outcome_unknown: true },
336
+ transport_error: { code: cause.code, message: cause.message, status: cause.statusCode },
337
+ };
338
+ }
339
+ function unknownNote(items) {
340
+ return `Outcome unknown at ${items.map((item) => `${item.viewport} (${item.detail.status})`).join(', ')}. In-flight actions or cleanup may have completed; do not automatically replay actions.`;
341
+ }
274
342
  function shapeVerificationReport(target, flow, runs) {
275
343
  const steps = [];
276
344
  const pageErrors = [];
@@ -280,8 +348,25 @@ function shapeVerificationReport(target, flow, runs) {
280
348
  const screenshots = [];
281
349
  const layout = [];
282
350
  const navigationRetries = [];
283
- for (const { viewport, report, actions: runActions } of runs) {
351
+ const incomplete = [];
352
+ const cappedViewports = new Set();
353
+ const lifecycle = [];
354
+ const transportErrors = [];
355
+ const unknownViewports = new Set();
356
+ for (const { viewport, report, actions: runActions, offset } of runs) {
357
+ const unknown = readBrowserLifecycle(report);
358
+ if (unknown) {
359
+ lifecycle.push({ viewport: viewport.label, detail: unknown });
360
+ unknownViewports.add(viewport.label);
361
+ }
362
+ if (report.transport_error)
363
+ transportErrors.push({ viewport: viewport.label, detail: report.transport_error });
284
364
  const actions = runActions ?? flow.actions;
365
+ const capped = mapIncomplete(report, actions, offset ?? 0);
366
+ if (capped) {
367
+ incomplete.push({ viewport: viewport.label, detail: capped });
368
+ cappedViewports.add(viewport.label);
369
+ }
285
370
  if (typeof report.accessibility_layout === 'string' && report.accessibility_layout) {
286
371
  layout.push({ viewport: viewport.label, detail: report.accessibility_layout });
287
372
  }
@@ -298,6 +383,7 @@ function shapeVerificationReport(target, flow, runs) {
298
383
  name: step.phase === 'start_navigation' ? 'open the start page (before any action)' : 'restore the session page (before any action)',
299
384
  passed: (step.ok ?? step.passed) !== false,
300
385
  ...(step.error ? { error: step.error } : {}),
386
+ ...(typeof step.duration_ms === 'number' && Number.isFinite(step.duration_ms) && step.duration_ms >= 0 ? { duration_ms: step.duration_ms } : {}),
301
387
  });
302
388
  continue;
303
389
  }
@@ -307,14 +393,16 @@ function shapeVerificationReport(target, flow, runs) {
307
393
  // verification steps limited to the caller's flow on both halves.
308
394
  if (sourceIndex >= actions.length && step.action === 'screenshot')
309
395
  continue;
310
- const passed = (step.ok ?? step.passed) !== false;
396
+ const notRun = step.ran === false;
397
+ const passed = !notRun && (step.ok ?? step.passed) !== false;
311
398
  steps.push({
312
399
  viewport: viewport.label,
313
400
  step: sourceIndex + 1,
314
401
  name: actionName(actions[sourceIndex], step.action ?? 'page load'),
315
402
  passed,
403
+ ...(notRun ? { ran: false } : {}),
316
404
  ...(step.error ? { error: step.error } : {}),
317
- ...(typeof step.duration_ms === 'number' ? { duration_ms: step.duration_ms } : {}),
405
+ ...(typeof step.duration_ms === 'number' && Number.isFinite(step.duration_ms) && step.duration_ms >= 0 ? { duration_ms: step.duration_ms } : {}),
318
406
  ...(step.value !== undefined ? { value: step.value } : step.result !== undefined ? { value: step.result } : {}),
319
407
  });
320
408
  }
@@ -335,8 +423,12 @@ function shapeVerificationReport(target, flow, runs) {
335
423
  }
336
424
  }
337
425
  }
338
- const failingStep = steps.find((step) => !step.passed);
339
- const failedExpectation = expectations.find((item) => !item.detail.ok);
426
+ // A step that did not run is unjudged only inside a clean execution-cap stop;
427
+ // anywhere else the run did not do what it was asked.
428
+ const failingStep = steps.find((step) => !step.passed && (step.ran !== false || (!cappedViewports.has(step.viewport) && !unknownViewports.has(step.viewport))));
429
+ // A declared request not seen on a capped run may belong to an action that
430
+ // never started: unobserved, not failed.
431
+ const failedExpectation = expectations.find((item) => !item.detail.ok && !cappedViewports.has(item.viewport) && !unknownViewports.has(item.viewport));
340
432
  const undeclared = failedRequests
341
433
  .map((item) => undeclaredStatusOf(item.viewport, item.detail))
342
434
  .filter((item) => item !== null);
@@ -348,7 +440,7 @@ function shapeVerificationReport(target, flow, runs) {
348
440
  return !match || !undeclared.some((u) => u.viewport === item.viewport && String(u.status) === match[1]);
349
441
  });
350
442
  const retryNote = navigationRetryNote(navigationRetries);
351
- const failedScreenshot = runs.find(({ report }) => !report.screenshots?.some((shot) => {
443
+ const failedScreenshot = runs.find(({ viewport, report }) => !cappedViewports.has(viewport.label) && !unknownViewports.has(viewport.label) && !report.screenshots?.some((shot) => {
352
444
  if (typeof shot === 'string')
353
445
  return shot.length > 0;
354
446
  return !shot.error && !!(shot.path ?? shot.url ?? shot.fs_path ?? shot.scratch_url);
@@ -356,7 +448,9 @@ function shapeVerificationReport(target, flow, runs) {
356
448
  const passed = runs.every(({ report }) => reportPassed(report));
357
449
  let verdict;
358
450
  if (failingStep) {
359
- verdict = `FAIL — step ${failingStep.step} (${failingStep.name}) failed at ${failingStep.viewport}${failingStep.error ? `: ${failingStep.error}` : '.'}`;
451
+ verdict = failingStep.ran === false
452
+ ? `FAIL — step ${failingStep.step} (${failingStep.name}) did not run at ${failingStep.viewport}, and the run reported no valid execution-cap stop.`
453
+ : `FAIL — step ${failingStep.step} (${failingStep.name}) failed at ${failingStep.viewport}${failingStep.error ? `: ${failingStep.error}` : '.'}`;
360
454
  }
361
455
  else if (pageErrors.length) {
362
456
  verdict = `FAIL — page error at ${pageErrors[0].viewport}: ${String(pageErrors[0].detail)}`;
@@ -376,11 +470,24 @@ function shapeVerificationReport(target, flow, runs) {
376
470
  else if (failedScreenshot) {
377
471
  verdict = `FAIL — screenshot capture failed at ${failedScreenshot.viewport.label}.`;
378
472
  }
473
+ else if (lifecycle.length) {
474
+ verdict = `INCOMPLETE — ${unknownNote(lifecycle)}`;
475
+ }
476
+ else if (incomplete.length) {
477
+ verdict = `INCOMPLETE — ${incomplete.map((item) => incompleteSentence(item.detail, `the run at ${item.viewport}`)).join(' ')} Split the flow into shorter runs to check the rest; nothing was continued automatically.`;
478
+ }
379
479
  else {
380
480
  verdict = flow.actions.length
381
481
  ? `PASS — ${flow.actions.length} step${flow.actions.length === 1 ? '' : 's'} passed at ${runs.map((run) => run.viewport.label).join(' and ')}; page, console, and network healthy.`
382
482
  : `PASS — default page check passed at ${runs.map((run) => run.viewport.label).join(' and ')}; page, console, and network healthy.`;
383
483
  }
484
+ // Never print a pass for a run the platform did not pass.
485
+ if (!passed && verdict.startsWith('PASS')) {
486
+ const failedRun = runs.find(({ report }) => !reportPassed(report));
487
+ verdict = `FAIL — the platform reported the run at ${failedRun?.viewport.label ?? 'one viewport'} as not passed.`;
488
+ }
489
+ if (lifecycle.length && !verdict.startsWith('INCOMPLETE'))
490
+ verdict += ` INCOMPLETE — ${unknownNote(lifecycle)}`;
384
491
  verdict += retryNote;
385
492
  return {
386
493
  passed,
@@ -395,10 +502,10 @@ function shapeVerificationReport(target, flow, runs) {
395
502
  })),
396
503
  steps,
397
504
  health: {
398
- page: { passed: pageErrors.length === 0, errors: pageErrors },
399
- console: { passed: consoleErrors.length === 0, errors: consoleErrors },
505
+ page: { passed: lifecycle.length === 0 && pageErrors.length === 0, errors: pageErrors },
506
+ console: { passed: lifecycle.length === 0 && consoleErrors.length === 0, errors: consoleErrors },
400
507
  network: {
401
- passed: failedRequests.length === 0 && expectations.every((item) => item.detail.ok),
508
+ passed: lifecycle.length === 0 && failedRequests.length === 0 && expectations.every((item) => item.detail.ok),
402
509
  failed_requests: failedRequests,
403
510
  expectations,
404
511
  },
@@ -407,6 +514,9 @@ function shapeVerificationReport(target, flow, runs) {
407
514
  layout,
408
515
  undeclared_statuses: undeclared,
409
516
  navigation_retries: navigationRetries,
517
+ incomplete,
518
+ ...(lifecycle.length ? { lifecycle } : {}),
519
+ ...(transportErrors.length ? { transport_errors: transportErrors } : {}),
410
520
  };
411
521
  }
412
522
  export async function runVerification(target, flow, client) {
@@ -458,7 +568,13 @@ export async function runVerification(target, flow, client) {
458
568
  capture_after: true,
459
569
  inline: false,
460
570
  ...(!target.project_id ? { store: true } : {}),
461
- }, undefined, { timeoutMs: VERIFY_TIMEOUT_MS });
571
+ budget_ms: VERIFY_BUDGET_MS,
572
+ }, undefined, { timeoutMs: VERIFY_TRANSPORT_TIMEOUT_MS }).catch((cause) => {
573
+ const unknown = transportUnknown(cause);
574
+ if (unknown)
575
+ return unknown;
576
+ throw cause;
577
+ });
462
578
  return { viewport, report };
463
579
  }));
464
580
  return shapeVerificationReport(targetLabel, flow, runs);
@@ -477,7 +593,13 @@ export const VERIFY_JOURNEY_LIMITS = {
477
593
  actions_per_segment: 30,
478
594
  total_actions: 120,
479
595
  deadline_ms: 10 * 60 * 1000,
480
- segment_timeout_ms: VERIFY_TIMEOUT_MS,
596
+ /** The execution cap each segment asks for (budget_ms): one connected run. */
597
+ segment_budget_ms: VERIFY_BUDGET_MS,
598
+ /** A segment is not started with a smaller cap than this. */
599
+ min_segment_budget_ms: VERIFY_MIN_BUDGET_MS,
600
+ /** Added to the cap for the client's wait: admission, launch, capture, release. */
601
+ transport_headroom_ms: VERIFY_TRANSPORT_HEADROOM_MS,
602
+ segment_timeout_ms: VERIFY_TRANSPORT_TIMEOUT_MS,
481
603
  /** Separate from the journey budget: how long closing the actors' browsers
482
604
  * may take once the run stops, including waiting for a call in flight. */
483
605
  cleanup_budget_ms: 20_000,
@@ -618,11 +740,14 @@ export function createVerifyJourneyRun(target, journey, client, opts = {}) {
618
740
  const opened = new Set();
619
741
  // Actors whose last call ended without a platform answer (client timeout or
620
742
  // lost connection): the platform may still be running it.
621
- const abandoned = new Set();
743
+ const abandoned = new Map();
622
744
  // The one segment call in flight, if any.
623
745
  let inFlight = null;
624
746
  let stopReason = null;
625
747
  let closing = null;
748
+ // Actors whose healthy browser is kept after an execution-cap stop. Cleanup
749
+ // reports them and never closes them.
750
+ const kept = new Map();
626
751
  const stop = (code, message) => {
627
752
  stopReason ??= { code, message };
628
753
  };
@@ -646,7 +771,7 @@ export function createVerifyJourneyRun(target, journey, client, opts = {}) {
646
771
  const startedAt = now();
647
772
  const budgetEnd = startedAt + cleanupBudgetMs;
648
773
  const pending = inFlight;
649
- const actors = [...opened];
774
+ const actors = [...opened].filter((actor) => !kept.has(actor));
650
775
  // One concurrent pass: every opened browser at once, one shared budget.
651
776
  const first = await Promise.all(actors.map(async (actor) => [actor, await closeOne(actor, budgetEnd)]));
652
777
  const outcome = new Map();
@@ -670,8 +795,8 @@ export function createVerifyJourneyRun(target, journey, client, opts = {}) {
670
795
  : { actor: pending.actor, status: again.closed || earlier?.status === 'closed' ? 'closed' : 'already_closed' });
671
796
  }
672
797
  }
673
- // A call the client gave up on has no answer to wait for. Close once more
674
- // a little later (inside the budget) and say plainly it is unconfirmed.
798
+ // A transport or lifecycle outcome can settle after this close. Close once
799
+ // more inside the budget, but do not claim it proves final cleanup.
675
800
  for (const actor of actors.filter((name) => abandoned.has(name))) {
676
801
  const waitMs = Math.min(recloseAfterAbandonedMs, budgetEnd - now());
677
802
  if (waitMs > 0)
@@ -680,10 +805,11 @@ export function createVerifyJourneyRun(target, journey, client, opts = {}) {
680
805
  outcome.set(actor, {
681
806
  actor,
682
807
  status: 'unconfirmed',
683
- reason: `its call got no answer, so the platform may still start it${'error' in again ? ` (the second close failed: ${again.error})` : ''}; ${UNCONFIRMED_BACKSTOP}`,
808
+ reason: `${abandoned.get(actor)}${'error' in again ? ` (the second close failed: ${again.error})` : ''}; ${UNCONFIRMED_BACKSTOP}`,
684
809
  });
685
810
  }
686
- return { cleanup: actors.map((actor) => outcome.get(actor)), cleanup_ms: now() - startedAt };
811
+ const keptCleanup = [...kept.values()].map((item) => ({ actor: item.actor, status: 'kept_open', reason: item.close }));
812
+ return { cleanup: [...actors.map((actor) => outcome.get(actor)), ...keptCleanup], cleanup_ms: now() - startedAt };
687
813
  })();
688
814
  return closing;
689
815
  };
@@ -701,6 +827,7 @@ export function createVerifyJourneyRun(target, journey, client, opts = {}) {
701
827
  const started = new Set();
702
828
  // 'report': the segment ran and its own report failed (the step verdict
703
829
  // says why). 'error': the segment could not run or could not continue.
830
+ // 'incomplete': the segment reached its execution cap with nothing failed.
704
831
  let stopped = null;
705
832
  const deadlineMessage = `The journey reached its ${VERIFY_JOURNEY_LIMITS.deadline_ms / 60000}-minute limit.`;
706
833
  try {
@@ -728,7 +855,20 @@ export function createVerifyJourneyRun(target, journey, client, opts = {}) {
728
855
  }
729
856
  const actor = journey.actors[segment.as];
730
857
  const first = !started.has(segment.as);
731
- const actions = !first && segment.path ? [{ goto: segment.path }, ...segment.actions] : segment.actions;
858
+ const offset = !first && segment.path ? 1 : 0;
859
+ const actions = offset ? [{ goto: segment.path }, ...segment.actions] : segment.actions;
860
+ // One connected run per segment, capped so its answer (cap plus
861
+ // transport headroom) still arrives inside the journey's deadline. A
862
+ // segment that cannot get the minimum cap is not started at all.
863
+ const remaining = deadlineAt - now();
864
+ const budgetMs = Math.min(VERIFY_JOURNEY_LIMITS.segment_budget_ms, remaining - VERIFY_JOURNEY_LIMITS.transport_headroom_ms);
865
+ if (budgetMs < VERIFY_JOURNEY_LIMITS.min_segment_budget_ms) {
866
+ const message = `${deadlineMessage} Segment ${index + 1} was not started: ${Math.max(0, Math.floor(remaining / 1000))}s were left, less than the ${VERIFY_JOURNEY_LIMITS.min_segment_budget_ms / 1000}s minimum run plus ${VERIFY_JOURNEY_LIMITS.transport_headroom_ms / 1000}s for its answer.`;
867
+ stop('VERIFY_JOURNEY_DEADLINE', message);
868
+ stopped = { kind: 'error', segment: index + 1, as: segment.as, viewport: viewport.label, code: 'VERIFY_JOURNEY_DEADLINE', message };
869
+ segments.push({ ...base, error: { code: 'VERIFY_JOURNEY_DEADLINE', message } });
870
+ continue;
871
+ }
732
872
  const hasSeeds = actor.local_storage !== undefined || actor.cookies !== undefined || actor.headers !== undefined;
733
873
  const url = first ? (segment.path ?? target.url ?? (hasSeeds ? '/' : undefined)) : undefined;
734
874
  const body = {
@@ -741,14 +881,15 @@ export function createVerifyJourneyRun(target, journey, client, opts = {}) {
741
881
  viewport: viewport.wire,
742
882
  capture_after: true,
743
883
  inline: false,
884
+ budget_ms: budgetMs,
885
+ // A continued actor must be the same browser and page, or nothing runs.
886
+ ...(!first ? { require_existing_session: true } : {}),
744
887
  ...(first && actor.auth ? { auth: actor.auth } : {}),
745
888
  ...(first && actor.local_storage ? { local_storage: actor.local_storage } : {}),
746
889
  ...(first && actor.cookies ? { cookies: actor.cookies } : {}),
747
890
  ...(first && actor.headers ? { headers: actor.headers } : {}),
748
891
  };
749
- // Each wait is capped by what is left of the journey budget.
750
- const remaining = deadlineAt - now();
751
- const waitMs = Math.min(VERIFY_JOURNEY_LIMITS.segment_timeout_ms, remaining);
892
+ const waitMs = budgetMs + VERIFY_JOURNEY_LIMITS.transport_headroom_ms;
752
893
  opened.add(segment.as);
753
894
  started.add(segment.as);
754
895
  abandoned.delete(segment.as);
@@ -761,21 +902,33 @@ export function createVerifyJourneyRun(target, journey, client, opts = {}) {
761
902
  catch (cause) {
762
903
  const code = cause instanceof CliApiError ? cause.code : 'VERIFY_SEGMENT_FAILED';
763
904
  const message = cause instanceof Error ? cause.message : String(cause);
764
- if (code === 'TIMEOUT' || code === 'NETWORK_ERROR')
765
- abandoned.add(segment.as);
766
- const budgetCut = code === 'TIMEOUT' && waitMs < VERIFY_JOURNEY_LIMITS.segment_timeout_ms;
767
- const reported = budgetCut ? { code: 'VERIFY_JOURNEY_DEADLINE', message: `${deadlineMessage} Segment ${index + 1} was still running.` } : { code, message };
768
- if (budgetCut)
769
- stop(reported.code, reported.message);
770
- stopped = { kind: 'error', segment: index + 1, as: segment.as, viewport: viewport.label, ...reported };
771
- // A refusal (limit, authority, validation) happens before a browser runs.
772
- segments.push({ ...base, error: reported });
773
- continue;
905
+ const unknown = transportUnknown(cause);
906
+ if (unknown) {
907
+ abandoned.set(segment.as, 'its call got no answer, so the platform may still start it');
908
+ report = unknown;
909
+ }
910
+ else {
911
+ const refusedMessage = !first && (code === 'SESSION_EXPIRED' || code === 'SESSION_TARGET_LOST')
912
+ ? `${segment.as}'s ${code === 'SESSION_EXPIRED' ? 'browser ended' : 'page could not be identified'} between segments, so segment ${index + 1} did not run (nothing was started, seeded or navigated). ${message}`
913
+ : message;
914
+ stopped = { kind: 'error', segment: index + 1, as: segment.as, viewport: viewport.label, code, message: refusedMessage };
915
+ segments.push({ ...base, error: { code, message: refusedMessage } });
916
+ continue;
917
+ }
774
918
  }
775
919
  finally {
776
920
  inFlight = null;
777
921
  }
778
- runs.push({ viewport: { ...viewport, label }, report, actions });
922
+ runs.push({ viewport: { ...viewport, label }, report, actions, offset });
923
+ const unknown = readBrowserLifecycle(report);
924
+ if (unknown) {
925
+ if (!abandoned.has(segment.as))
926
+ abandoned.set(segment.as, `the platform reported ${unknown.status}; late allocation or cleanup may still settle`);
927
+ segments.push({ ...base, ran: !report.transport_error, outcome_unknown: true, lifecycle: unknown,
928
+ ...(report.transport_error ? { error: report.transport_error } : {}) });
929
+ stopped = { kind: 'report', segment: index + 1, as: segment.as, viewport: viewport.label, code: '', message: '' };
930
+ continue;
931
+ }
779
932
  // A continued actor whose browser was reaped would carry on signed out
780
933
  // in a fresh browser; that is a different person, so the journey stops.
781
934
  if (!first && report.session_note) {
@@ -784,6 +937,26 @@ export function createVerifyJourneyRun(target, journey, client, opts = {}) {
784
937
  segments.push({ ...base, ran: true, final_url: report.final_url, error: { code: stopped.code, message } });
785
938
  continue;
786
939
  }
940
+ const capped = mapIncomplete(report, actions, offset);
941
+ if (capped) {
942
+ // The browser is healthy and the platform kept it: leave it open for
943
+ // the caller rather than erasing the evidence in cleanup. A browser
944
+ // the platform reports closed is not claimed as kept.
945
+ const sessionId = sessions[segment.as];
946
+ if (report.session_closed !== true && report.idle_expires_at && report.absolute_expires_at) {
947
+ kept.set(segment.as, {
948
+ actor: segment.as,
949
+ session_id: sessionId,
950
+ ...(report.session_expires_at ? { session_expires_at: report.session_expires_at } : {}),
951
+ ...(report.idle_expires_at ? { idle_expires_at: report.idle_expires_at } : {}),
952
+ ...(report.absolute_expires_at ? { absolute_expires_at: report.absolute_expires_at } : {}),
953
+ close: `somewhere call browser '${JSON.stringify({ session_id: sessionId })}'`,
954
+ });
955
+ }
956
+ segments.push({ ...base, ran: true, passed: false, incomplete: capped, ...(report.session_closed === true ? { session_closed: true } : {}), ...(report.final_url ? { final_url: report.final_url } : {}) });
957
+ stopped = { kind: 'incomplete', segment: index + 1, as: segment.as, viewport: viewport.label, code: 'VERIFY_EXECUTION_CAP', message: '' };
958
+ continue;
959
+ }
787
960
  const passed = reportPassed(report);
788
961
  segments.push({ ...base, ran: true, passed, ...(report.final_url ? { final_url: report.final_url } : {}) });
789
962
  if (!passed)
@@ -795,11 +968,28 @@ export function createVerifyJourneyRun(target, journey, client, opts = {}) {
795
968
  }
796
969
  const shaped = shapeVerificationReport(target.url ?? target.project_id, { actions: [] }, runs);
797
970
  const names = Object.keys(journey.actors);
798
- const passed = !stopped && runs.length === journey.journey.length && shaped.passed;
971
+ let passed = !stopped && runs.length === journey.journey.length && shaped.passed;
799
972
  let verdict;
973
+ const cappedSegment = stopped?.kind === 'incomplete' ? segments.find((item) => item.segment === stopped?.segment) : undefined;
974
+ const keptSession = stopped?.kind === 'incomplete' ? kept.get(stopped.as) : undefined;
800
975
  if (stopped?.kind === 'error') {
801
976
  verdict = `FAIL — segment ${stopped.segment} (${stopped.as}, ${stopped.viewport}): ${stopped.message} [${stopped.code}]`;
802
977
  }
978
+ else if (stopped?.kind === 'incomplete' && cappedSegment?.incomplete) {
979
+ const later = journey.journey.length - stopped.segment;
980
+ verdict = `INCOMPLETE — ${incompleteSentence(cappedSegment.incomplete, `segment ${stopped.segment} (${stopped.as}, ${stopped.viewport})`)}`
981
+ + (later ? ` Segment${later === 1 ? '' : 's'} ${stopped.segment + 1}${later > 1 ? `–${journey.journey.length}` : ''} did not run.` : '')
982
+ + (keptSession
983
+ ? ` ${stopped.as}'s browser is kept open as session "${keptSession.session_id}" so its page can be inspected; `
984
+ + (keptSession.session_expires_at
985
+ ? `the platform closes it at ${keptSession.session_expires_at} unless it is closed first.`
986
+ : 'the platform did not report when it closes it.')
987
+ + ` Close it with: ${keptSession.close}`
988
+ : cappedSegment.session_closed === true
989
+ ? ` The platform closed ${stopped.as}'s browser, so there is no page left to inspect.`
990
+ : ` No kept browser is confirmed; see the cleanup result below.`)
991
+ + ' Nothing was continued automatically; split the segment to check the rest. [VERIFY_EXECUTION_CAP]';
992
+ }
803
993
  else if (!passed) {
804
994
  verdict = shaped.verdict.replace(/^FAIL — /, `FAIL — segment ${stopped?.segment ?? '?'} — `);
805
995
  }
@@ -808,6 +998,14 @@ export function createVerifyJourneyRun(target, journey, client, opts = {}) {
808
998
  + navigationRetryNote(shaped.navigation_retries);
809
999
  }
810
1000
  const { cleanup, cleanup_ms } = await closeAll();
1001
+ if (cleanup.some((item) => item.status === 'unconfirmed')) {
1002
+ passed = false;
1003
+ const lifecycle = cleanup.filter((item) => item.status === 'unconfirmed')
1004
+ .map((item) => ({ viewport: item.actor, detail: { status: 'cleanup_unknown', outcome_unknown: true } }));
1005
+ shaped.lifecycle = [...(shaped.lifecycle ?? []), ...lifecycle];
1006
+ const note = `INCOMPLETE — ${unknownNote(lifecycle)}`;
1007
+ verdict = verdict.startsWith('PASS') ? note : `${verdict} ${note}`;
1008
+ }
811
1009
  return {
812
1010
  ...shaped,
813
1011
  passed,
@@ -820,6 +1018,7 @@ export function createVerifyJourneyRun(target, journey, client, opts = {}) {
820
1018
  cleanup,
821
1019
  cleanup_ms,
822
1020
  cleanup_confirmed: cleanup.every((item) => item.status !== 'unconfirmed'),
1021
+ kept_sessions: [...kept.values()],
823
1022
  };
824
1023
  };
825
1024
  return { sessions, run, stop, closeAll };
@@ -892,18 +1091,19 @@ export function verifyFlowSchema() {
892
1091
  'x-limits': L,
893
1092
  };
894
1093
  }
895
- export function formatVerifyReport(report) {
896
- const lines = [report.passed ? green(report.verdict) : red(report.verdict)];
1094
+ export function formatVerifyReport(report, evidencePath) {
1095
+ const lines = [report.passed ? green(report.verdict) : report.verdict.startsWith('INCOMPLETE') ? teal(report.verdict) : red(report.verdict)];
897
1096
  for (const step of report.steps) {
898
- lines.push(`step ${step.step} ${step.passed ? green('✓') : red('✗')} [${step.viewport}] ${step.name}${step.error ? ` ${dim(`— ${step.error}`)}` : ''}`);
1097
+ lines.push(`step ${step.step} ${step.ran === false ? dim('not run') : step.passed ? green('✓') : red('✗')} [${step.viewport}] ${step.name}${formatStepDuration(step.duration_ms, step.ran)}${step.error ? ` ${dim(`— ${step.error}`)}` : ''}`);
899
1098
  }
900
- lines.push(`page_health: ${report.health.page.passed ? green('PASS') : red('FAIL')}`);
901
- lines.push(`console_health: ${report.health.console.passed ? green('PASS') : red('FAIL')}`);
902
- lines.push(`network_health: ${report.health.network.passed ? green('PASS') : red('FAIL')}`);
1099
+ lines.push(`page_health: ${report.health.page.errors?.length ? red('FAIL') : report.lifecycle?.length ? teal('UNKNOWN') : report.health.page.passed ? green('PASS') : red('FAIL')}`);
1100
+ lines.push(`console_health: ${report.health.console.errors?.length ? red('FAIL') : report.lifecycle?.length ? teal('UNKNOWN') : report.health.console.passed ? green('PASS') : red('FAIL')}`);
1101
+ lines.push(`network_health: ${report.health.network.failed_requests?.length ? red('FAIL') : report.lifecycle?.length ? teal('UNKNOWN') : report.health.network.passed ? green('PASS') : red('FAIL')}`);
903
1102
  for (const shot of report.screenshots) {
904
1103
  const location = shot.url ?? shot.scratch_url ?? shot.fs_path ?? shot.path ?? shot.error ?? '(missing)';
905
- lines.push(`screenshot: [${shot.viewport}] ${teal(location)}`);
906
- if (shot.fs_path && (shot.url || shot.scratch_url))
1104
+ const savedLink = evidencePath && (shot.url || shot.scratch_url);
1105
+ lines.push(`screenshot: [${shot.viewport}] ${savedLink ? 'link saved in report' : teal(location)}${shot.error && savedLink ? ` — ${shot.error}` : ''}`);
1106
+ if (shot.fs_path && !savedLink && (shot.url || shot.scratch_url))
907
1107
  lines.push(`screenshot_file: [${shot.viewport}] ${shot.fs_path}`);
908
1108
  }
909
1109
  for (const item of report.layout ?? [])
@@ -916,21 +1116,36 @@ export function formatVerifyReport(report) {
916
1116
  }
917
1117
  return lines;
918
1118
  }
919
- export function formatVerifyJourneyReport(report) {
920
- const lines = formatVerifyReport(report);
1119
+ export function formatVerifyJourneyReport(report, evidencePath) {
1120
+ const lines = formatVerifyReport(report, evidencePath);
921
1121
  const segmentLines = report.segments.map((segment) => {
922
- const mark = !segment.ran && !segment.error ? dim('not run') : segment.passed ? green('✓') : red('✗');
1122
+ const mark = segment.outcome_unknown ? teal('outcome unknown') : !segment.ran && !segment.error ? dim('not run') : segment.incomplete ? teal('incomplete') : segment.passed ? green('✓') : red('✗');
923
1123
  return `segment ${segment.segment} ${mark} ${segment.as} [${segment.viewport}]${segment.path ? ` ${segment.path}` : ''}${segment.error ? ` ${dim(`— ${segment.error.code}`)}` : ''}`;
924
1124
  });
925
- return [lines[0], ...segmentLines, ...lines.slice(1), `browser runs used: ${report.browser_runs}`, ...formatJourneyCleanup(report.cleanup, report.cleanup_ms)];
1125
+ return [lines[0], ...segmentLines, ...lines.slice(1), `browser runs used: ${report.browser_runs}${report.segments.some((segment) => segment.outcome_unknown && !segment.ran) ? '; additional browser execution is unconfirmed' : ''}`, ...formatJourneyCleanup(report.cleanup, report.cleanup_ms)];
1126
+ }
1127
+ /** Persist before shortening human output; JSON callers keep their original contract. */
1128
+ export function formatVerifyOutput(report, save = saveVerifyReport) {
1129
+ const format = (path) => 'segments' in report
1130
+ ? formatVerifyJourneyReport(report, path) : formatVerifyReport(report, path);
1131
+ try {
1132
+ const path = save(report);
1133
+ return [...format(path), `Full report and screenshot links: ${path}`];
1134
+ }
1135
+ catch {
1136
+ return ['Could not save the verification report; full screenshot links are shown below.', ...format()];
1137
+ }
926
1138
  }
927
1139
  export function formatJourneyCleanup(cleanup, cleanupMs) {
928
1140
  if (!cleanup.length)
929
1141
  return ['browsers: none were opened'];
930
1142
  const lines = [`browsers (cleanup ${Math.round(cleanupMs / 100) / 10}s of ${VERIFY_JOURNEY_LIMITS.cleanup_budget_ms / 1000}s budget): ${cleanup.map((item) => `${item.actor} ${item.status.replace('_', ' ')}`).join(', ')}`];
931
- for (const item of cleanup)
1143
+ for (const item of cleanup) {
932
1144
  if (item.status === 'unconfirmed' && item.reason)
933
1145
  lines.push(` ${item.actor}: ${item.reason}`);
1146
+ if (item.status === 'kept_open' && item.reason)
1147
+ lines.push(` ${item.actor}: kept open after the execution cap; close it with: ${item.reason}`);
1148
+ }
934
1149
  return lines;
935
1150
  }
936
1151
  export function registerVerify(program) {
@@ -1049,7 +1264,7 @@ projects: a login approved for "Only these projects" cannot open named browsers
1049
1264
  process.once('SIGTERM', onSignal);
1050
1265
  let report;
1051
1266
  try {
1052
- report = await journeyRun.run();
1267
+ report = await withVerifyProgress(() => journeyRun.run(), !opts.json);
1053
1268
  }
1054
1269
  finally {
1055
1270
  process.removeListener('SIGINT', onSignal);
@@ -1058,7 +1273,7 @@ projects: a login approved for "Only these projects" cannot open named browsers
1058
1273
  if (opts.json)
1059
1274
  console.log(JSON.stringify(report, null, 2));
1060
1275
  else
1061
- for (const line of formatVerifyJourneyReport(report))
1276
+ for (const line of formatVerifyOutput(report))
1062
1277
  console.log(line);
1063
1278
  process.exit(report.passed ? 0 : 1);
1064
1279
  }
@@ -1069,11 +1284,11 @@ projects: a login approved for "Only these projects" cannot open named browsers
1069
1284
  ...(cliCookies.length ? { cookies: [...(loadedFlow.cookies ?? []), ...cliCookies] } : {}),
1070
1285
  };
1071
1286
  assertSessionSeedSize(flow);
1072
- const report = await runVerification({ ...(project ? { project_id: project } : {}), ...(url ? { url } : {}) }, flow, client);
1287
+ const report = await withVerifyProgress(() => runVerification({ ...(project ? { project_id: project } : {}), ...(url ? { url } : {}) }, flow, client), !opts.json);
1073
1288
  if (opts.json)
1074
1289
  console.log(JSON.stringify(report, null, 2));
1075
1290
  else
1076
- for (const line of formatVerifyReport(report))
1291
+ for (const line of formatVerifyOutput(report))
1077
1292
  console.log(line);
1078
1293
  process.exit(report.passed ? 0 : 1);
1079
1294
  }