@somewhere-tech/cli 0.36.0 → 0.36.2
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/commands/browser.js +37 -5
- package/dist/commands/browser.js.map +1 -1
- package/dist/commands/deploy.js +54 -25
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/init.js +42 -6
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/verify.js +273 -55
- package/dist/commands/verify.js.map +1 -1
- package/dist/lib/client.js +11 -1
- package/dist/lib/client.js.map +1 -1
- package/dist/lib/config.js +124 -1
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/verify-progress.js +15 -0
- package/dist/lib/verify-progress.js.map +1 -0
- package/dist/lib/verify-report.js +17 -0
- package/dist/lib/verify-report.js.map +1 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/runtime/VENDOR.json +2 -2
- package/runtime/browser-probes.mjs +2 -5
package/dist/commands/verify.js
CHANGED
|
@@ -2,11 +2,25 @@ import { randomBytes } from 'node:crypto';
|
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { dirname, resolve } from 'node:path';
|
|
4
4
|
import { ApiClient, CliApiError } from '../lib/client.js';
|
|
5
|
-
import { getToken, loadConfig, loadProjectConfig, loadTempSession } from '../lib/config.js';
|
|
5
|
+
import { canonicalProjectRoot, getToken, loadConfig, loadProjectConfig, loadTempSession, tempProjectFor } from '../lib/config.js';
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
339
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
674
|
-
//
|
|
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:
|
|
808
|
+
reason: `${abandoned.get(actor)}${'error' in again ? ` (the second close failed: ${again.error})` : ''}; ${UNCONFIRMED_BACKSTOP}`,
|
|
684
809
|
});
|
|
685
810
|
}
|
|
686
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
-
|
|
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
|
-
|
|
906
|
-
|
|
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) {
|
|
@@ -1009,10 +1224,13 @@ projects: a login approved for "Only these projects" cannot open named browsers
|
|
|
1009
1224
|
project = await linkedVerifyTarget(url, client, linkedProjectId);
|
|
1010
1225
|
if (!project) {
|
|
1011
1226
|
const temporary = loadTempSession();
|
|
1012
|
-
|
|
1227
|
+
// This directory's throwaway first; the unscoped one an older CLI
|
|
1228
|
+
// recorded is only a candidate the URL must still match.
|
|
1229
|
+
const temporaryProject = tempProjectFor(temporary, canonicalProjectRoot(process.cwd())) ?? temporary?.project;
|
|
1230
|
+
if (temporaryProject?.project_id && temporary?.token
|
|
1013
1231
|
&& (!temporary.temp_expires_at || Date.parse(temporary.temp_expires_at) > Date.now())) {
|
|
1014
1232
|
const tempClient = new ApiClient(temporary.token);
|
|
1015
|
-
const tempProject = await linkedVerifyTarget(url, tempClient,
|
|
1233
|
+
const tempProject = await linkedVerifyTarget(url, tempClient, temporaryProject.project_id);
|
|
1016
1234
|
if (tempProject) {
|
|
1017
1235
|
project = tempProject;
|
|
1018
1236
|
client = tempClient;
|
|
@@ -1046,7 +1264,7 @@ projects: a login approved for "Only these projects" cannot open named browsers
|
|
|
1046
1264
|
process.once('SIGTERM', onSignal);
|
|
1047
1265
|
let report;
|
|
1048
1266
|
try {
|
|
1049
|
-
report = await journeyRun.run();
|
|
1267
|
+
report = await withVerifyProgress(() => journeyRun.run(), !opts.json);
|
|
1050
1268
|
}
|
|
1051
1269
|
finally {
|
|
1052
1270
|
process.removeListener('SIGINT', onSignal);
|
|
@@ -1055,7 +1273,7 @@ projects: a login approved for "Only these projects" cannot open named browsers
|
|
|
1055
1273
|
if (opts.json)
|
|
1056
1274
|
console.log(JSON.stringify(report, null, 2));
|
|
1057
1275
|
else
|
|
1058
|
-
for (const line of
|
|
1276
|
+
for (const line of formatVerifyOutput(report))
|
|
1059
1277
|
console.log(line);
|
|
1060
1278
|
process.exit(report.passed ? 0 : 1);
|
|
1061
1279
|
}
|
|
@@ -1066,11 +1284,11 @@ projects: a login approved for "Only these projects" cannot open named browsers
|
|
|
1066
1284
|
...(cliCookies.length ? { cookies: [...(loadedFlow.cookies ?? []), ...cliCookies] } : {}),
|
|
1067
1285
|
};
|
|
1068
1286
|
assertSessionSeedSize(flow);
|
|
1069
|
-
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);
|
|
1070
1288
|
if (opts.json)
|
|
1071
1289
|
console.log(JSON.stringify(report, null, 2));
|
|
1072
1290
|
else
|
|
1073
|
-
for (const line of
|
|
1291
|
+
for (const line of formatVerifyOutput(report))
|
|
1074
1292
|
console.log(line);
|
|
1075
1293
|
process.exit(report.passed ? 0 : 1);
|
|
1076
1294
|
}
|