@glubean/cli 0.2.2 → 0.2.4
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/contracts.d.ts +21 -1
- package/dist/commands/contracts.d.ts.map +1 -1
- package/dist/commands/contracts.js +110 -44
- package/dist/commands/contracts.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +34 -0
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/run.d.ts +19 -0
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +627 -395
- package/dist/commands/run.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +9 -1
- package/dist/main.js.map +1 -1
- package/package.json +6 -5
- package/templates/README.md +0 -7
- package/dist/lib/env.d.ts +0 -29
- package/dist/lib/env.d.ts.map +0 -1
- package/dist/lib/env.js +0 -59
- package/dist/lib/env.js.map +0 -1
package/dist/commands/run.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { bootstrap, evaluateThresholds, MetricCollector, ProjectRunner, buildRunContext, } from "@glubean/runner";
|
|
2
2
|
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
|
3
3
|
import { stat, readdir, readFile, writeFile, mkdir, rm } from "node:fs/promises";
|
|
4
|
-
import { pathToFileURL } from "node:url";
|
|
5
4
|
import { glob } from "node:fs/promises";
|
|
6
5
|
import { loadConfig, mergeRunOptions, toSharedRunConfig } from "../lib/config.js";
|
|
7
|
-
import {
|
|
6
|
+
import { loadProjectEnv } from "@glubean/runner";
|
|
8
7
|
import { resolveEnvFileName } from "../lib/active_env.js";
|
|
9
8
|
import { shouldSkipTest } from "../lib/skip.js";
|
|
10
9
|
import { CLI_VERSION } from "../version.js";
|
|
11
10
|
import { extractContractCases, extractFromSource } from "@glubean/scanner/static";
|
|
12
|
-
import { extractContractFromFile } from "@glubean/scanner";
|
|
11
|
+
import { extractContractFromFile, loadProjectOverlays } from "@glubean/scanner";
|
|
12
|
+
import { applyEnvTemplating } from "@glubean/runner";
|
|
13
13
|
// ANSI color codes for pretty output
|
|
14
14
|
const colors = {
|
|
15
15
|
reset: "\x1b[0m",
|
|
@@ -54,10 +54,24 @@ const DEFAULT_EXTENSIONS = ["ts"];
|
|
|
54
54
|
function isGlob(target) {
|
|
55
55
|
return /[*?{[]/.test(target);
|
|
56
56
|
}
|
|
57
|
-
|
|
57
|
+
// Test files: anything that may CONTRIBUTE runnable tests OR overlay
|
|
58
|
+
// registrations during a run. `.bootstrap.ts` files don't produce
|
|
59
|
+
// runnables themselves, but they MUST be loaded so `contract.bootstrap()`
|
|
60
|
+
// calls execute and register overlays before discovery runs (attachment-
|
|
61
|
+
// model §7.4). `discoverTests()` is responsible for distinguishing
|
|
62
|
+
// bootstrap-only files from runnable-emitting ones.
|
|
63
|
+
const TEST_FILE_SUFFIXES = [
|
|
64
|
+
".test.ts",
|
|
65
|
+
".contract.ts",
|
|
66
|
+
".flow.ts",
|
|
67
|
+
".bootstrap.ts",
|
|
68
|
+
];
|
|
58
69
|
function isGlubeanTestFile(name) {
|
|
59
70
|
return TEST_FILE_SUFFIXES.some((suffix) => name.endsWith(suffix));
|
|
60
71
|
}
|
|
72
|
+
function isBootstrapOnlyFile(name) {
|
|
73
|
+
return name.endsWith(".bootstrap.ts");
|
|
74
|
+
}
|
|
61
75
|
async function walkTestFiles(dir, result) {
|
|
62
76
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
63
77
|
for (const entry of entries) {
|
|
@@ -104,6 +118,14 @@ async function resolveTestFiles(target) {
|
|
|
104
118
|
return [abs];
|
|
105
119
|
}
|
|
106
120
|
export async function discoverTests(filePath) {
|
|
121
|
+
// `.bootstrap.ts` files register overlays as a side-effect of import; they
|
|
122
|
+
// produce no runnable tests of their own. We don't even need to import here
|
|
123
|
+
// because the project-wide `loadProjectOverlays()` ran before discovery.
|
|
124
|
+
// Returning [] here keeps these files in the walker (so filtered runs
|
|
125
|
+
// still evaluate them transitively) without surfacing phantom test entries.
|
|
126
|
+
if (isBootstrapOnlyFile(filePath)) {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
107
129
|
const content = await readFile(filePath, "utf-8");
|
|
108
130
|
if (filePath.includes(".contract.") || filePath.includes(".flow.")) {
|
|
109
131
|
// Runtime extraction via shared function (supports .with() syntax).
|
|
@@ -128,20 +150,18 @@ export async function discoverTests(filePath) {
|
|
|
128
150
|
}
|
|
129
151
|
}
|
|
130
152
|
// Each flow has a single orchestrator Test (setup → steps → teardown).
|
|
131
|
-
// Discover it as one runnable entry with the flow id.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
});
|
|
144
|
-
}
|
|
153
|
+
// Discover it as one runnable entry with the flow id. Post-Phase 2f
|
|
154
|
+
// flows live as `kind: "flow"` entries inside `result.attachments`.
|
|
155
|
+
for (const att of result.attachments) {
|
|
156
|
+
if (att.kind !== "flow")
|
|
157
|
+
continue;
|
|
158
|
+
results.push({
|
|
159
|
+
exportName: att.exportName,
|
|
160
|
+
meta: {
|
|
161
|
+
id: att.flow.id,
|
|
162
|
+
description: att.flow.description,
|
|
163
|
+
},
|
|
164
|
+
});
|
|
145
165
|
}
|
|
146
166
|
if (results.length > 0)
|
|
147
167
|
return results;
|
|
@@ -308,7 +328,13 @@ export async function runCommand(target, options = {}) {
|
|
|
308
328
|
process.exit(1);
|
|
309
329
|
}
|
|
310
330
|
}
|
|
311
|
-
|
|
331
|
+
// Canonical env loading: reads both .env and .env.secrets, expands
|
|
332
|
+
// `${NAME}` references (same file forward refs, cross-file refs, and
|
|
333
|
+
// process.env fallback), splits back into {vars, secrets} with secrets
|
|
334
|
+
// winning on collision. See @glubean/runner:loadProjectEnv.
|
|
335
|
+
const { vars: envVars, secrets } = await loadProjectEnv(rootDir, envFileName);
|
|
336
|
+
// Warn separately on the missing-secrets case so users get a visual
|
|
337
|
+
// signal — loadProjectEnv itself treats missing files as silent empties.
|
|
312
338
|
const secretsPath = resolve(rootDir, `${envFileName}.secrets`);
|
|
313
339
|
let secretsExist = true;
|
|
314
340
|
try {
|
|
@@ -317,7 +343,6 @@ export async function runCommand(target, options = {}) {
|
|
|
317
343
|
catch {
|
|
318
344
|
secretsExist = false;
|
|
319
345
|
}
|
|
320
|
-
const secrets = secretsExist ? await loadEnvFile(secretsPath) : {};
|
|
321
346
|
if (!secretsExist && Object.keys(envVars).length > 0) {
|
|
322
347
|
console.warn(`${colors.yellow}Warning: secrets file '${envFileName}.secrets' not found in ${rootDir}${colors.reset}`);
|
|
323
348
|
}
|
|
@@ -369,6 +394,29 @@ export async function runCommand(target, options = {}) {
|
|
|
369
394
|
process.exit(1);
|
|
370
395
|
}
|
|
371
396
|
}
|
|
397
|
+
// ── Bootstrap plugins BEFORE discovery ─────────────────────────────────
|
|
398
|
+
// CLI's `discoverTests` dynamically imports each .contract.ts / .test.ts
|
|
399
|
+
// in this process. If the file uses plugin-registered names like
|
|
400
|
+
// `contract.graphql.with(...)` before the plugin's manifest is installed,
|
|
401
|
+
// the import throws ("Cannot read properties of undefined (reading
|
|
402
|
+
// 'with')"). ProjectRunner calls bootstrap() too, but that happens AFTER
|
|
403
|
+
// our discovery — too late. Matching MCP's `glubean_openapi` pattern here:
|
|
404
|
+
// explicit bootstrap before any parent-process contract file import. The
|
|
405
|
+
// call is idempotent (bootstrap tracks loadState internally), so
|
|
406
|
+
// ProjectRunner's internal call is a no-op second visit.
|
|
407
|
+
await bootstrap(rootDir);
|
|
408
|
+
// ── Eager-load overlay registrations (attachment-model §7.4) ────────────
|
|
409
|
+
// A filtered run (e.g. `glubean run path/to/single.contract.ts`) would
|
|
410
|
+
// otherwise miss sibling `*.bootstrap.ts` overlay registrations that
|
|
411
|
+
// wrap cases in the filtered-in contract. §7.4 mandates eager loading
|
|
412
|
+
// before any test runs so overlay registration is deterministic.
|
|
413
|
+
// Idempotent: ProjectRunner re-invokes the same helper (no-op second
|
|
414
|
+
// visit thanks to mtime-keyed module cache).
|
|
415
|
+
const overlayLoad = await loadProjectOverlays(rootDir);
|
|
416
|
+
for (const err of overlayLoad.errors) {
|
|
417
|
+
console.error(`${colors.yellow}⚠ Bootstrap overlay failed to load:${colors.reset} ${err.file}`);
|
|
418
|
+
console.error(`${colors.dim}${err.error}${colors.reset}`);
|
|
419
|
+
}
|
|
372
420
|
// ── Discover tests across all files ─────────────────────────────────────
|
|
373
421
|
console.log(`${colors.dim}Discovering tests...${colors.reset}`);
|
|
374
422
|
const allFileTests = [];
|
|
@@ -452,6 +500,113 @@ export async function runCommand(target, options = {}) {
|
|
|
452
500
|
console.log(`${colors.dim}${parts.join(" + ")} (${testsToRun.length}/${totalDiscovered} tests)${colors.reset}`);
|
|
453
501
|
}
|
|
454
502
|
console.log(`\n${colors.bold}Running ${testsToRun.length} test(s)...${colors.reset}\n`);
|
|
503
|
+
// ── Spike 3: runner input channels (attachment-model §8) ────────────────
|
|
504
|
+
// `--input-json` / `--bootstrap-json` / `--force-standalone` apply to a
|
|
505
|
+
// single targeted case; require --filter to resolve to exactly one test.
|
|
506
|
+
// Maps are JSON-encoded `{ [testId]: <value> }` and passed to the harness
|
|
507
|
+
// subprocess via env vars (which the harness reads in `setExplicitInput`
|
|
508
|
+
// / `setBootstrapInput` / `setForceStandalone` calls before user import).
|
|
509
|
+
const hasInputFlag = options.inputJson !== undefined ||
|
|
510
|
+
options.bootstrapJson !== undefined ||
|
|
511
|
+
options.forceStandalone === true;
|
|
512
|
+
if (hasInputFlag) {
|
|
513
|
+
// §5.1 invariant: explicit input always wins; overlay (and therefore
|
|
514
|
+
// its bootstrap-params channel) is NOT invoked. Per the proposal's
|
|
515
|
+
// "no run-bootstrap-for-side-effects-then-use-my-input mode" rule,
|
|
516
|
+
// the two channels are exclusive at the surface boundary too —
|
|
517
|
+
// dispatcher would silently drop the bootstrap input otherwise.
|
|
518
|
+
if (options.inputJson !== undefined &&
|
|
519
|
+
options.bootstrapJson !== undefined) {
|
|
520
|
+
console.error(`\n${colors.red}❌ --input-json and --bootstrap-json are mutually exclusive.${colors.reset}\n` +
|
|
521
|
+
`${colors.dim}Per attachment-model §5.1: explicit input bypasses the overlay, so bootstrap params would be ignored. Pick one channel per run.${colors.reset}\n`);
|
|
522
|
+
process.exit(1);
|
|
523
|
+
}
|
|
524
|
+
if (testsToRun.length !== 1) {
|
|
525
|
+
console.error(`\n${colors.red}❌ --input-json / --bootstrap-json / --force-standalone require ` +
|
|
526
|
+
`--filter to match exactly one testId. Matched ${testsToRun.length} tests.${colors.reset}\n`);
|
|
527
|
+
if (testsToRun.length > 1) {
|
|
528
|
+
const ids = testsToRun.map((t) => t.test.meta.id).slice(0, 10);
|
|
529
|
+
console.error(`${colors.dim}First matches: ${ids.join(", ")}${ids.length < testsToRun.length ? "…" : ""}${colors.reset}`);
|
|
530
|
+
}
|
|
531
|
+
process.exit(1);
|
|
532
|
+
}
|
|
533
|
+
const targetTestId = testsToRun[0].test.meta.id;
|
|
534
|
+
async function resolveJsonFlag(raw, flagName) {
|
|
535
|
+
let text;
|
|
536
|
+
if (raw.startsWith("@")) {
|
|
537
|
+
const filePath = resolve(raw.slice(1));
|
|
538
|
+
try {
|
|
539
|
+
text = await readFile(filePath, "utf-8");
|
|
540
|
+
}
|
|
541
|
+
catch (err) {
|
|
542
|
+
console.error(`\n${colors.red}❌ ${flagName}: could not read ${filePath}: ` +
|
|
543
|
+
`${err instanceof Error ? err.message : String(err)}${colors.reset}\n`);
|
|
544
|
+
process.exit(1);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
text = raw;
|
|
549
|
+
}
|
|
550
|
+
try {
|
|
551
|
+
return JSON.parse(text);
|
|
552
|
+
}
|
|
553
|
+
catch (err) {
|
|
554
|
+
console.error(`\n${colors.red}❌ ${flagName}: invalid JSON: ` +
|
|
555
|
+
`${err instanceof Error ? err.message : String(err)}${colors.reset}\n`);
|
|
556
|
+
process.exit(1);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
// Templating env: project-loaded vars+secrets + process.env. Secrets
|
|
560
|
+
// win over vars; process.env wins over both (matches `loadProjectEnv`'s
|
|
561
|
+
// own precedence). §8 — substitution happens before schema validation.
|
|
562
|
+
const templatingEnv = {
|
|
563
|
+
...envVars,
|
|
564
|
+
...secrets,
|
|
565
|
+
...process.env,
|
|
566
|
+
};
|
|
567
|
+
if (options.inputJson !== undefined) {
|
|
568
|
+
const parsed = await resolveJsonFlag(options.inputJson, "--input-json");
|
|
569
|
+
let templated;
|
|
570
|
+
try {
|
|
571
|
+
templated = applyEnvTemplating(parsed, templatingEnv);
|
|
572
|
+
}
|
|
573
|
+
catch (err) {
|
|
574
|
+
console.error(`\n${colors.red}❌ --input-json: ${err instanceof Error ? err.message : String(err)}${colors.reset}\n`);
|
|
575
|
+
process.exit(1);
|
|
576
|
+
}
|
|
577
|
+
process.env["GLUBEAN_RUNNER_EXPLICIT_INPUT_MAP"] = JSON.stringify({
|
|
578
|
+
[targetTestId]: templated,
|
|
579
|
+
});
|
|
580
|
+
console.log(`${colors.dim} --input-json: ${targetTestId}${colors.reset}`);
|
|
581
|
+
}
|
|
582
|
+
if (options.bootstrapJson !== undefined) {
|
|
583
|
+
const parsed = await resolveJsonFlag(options.bootstrapJson, "--bootstrap-json");
|
|
584
|
+
let templated;
|
|
585
|
+
try {
|
|
586
|
+
templated = applyEnvTemplating(parsed, templatingEnv);
|
|
587
|
+
}
|
|
588
|
+
catch (err) {
|
|
589
|
+
console.error(`\n${colors.red}❌ --bootstrap-json: ${err instanceof Error ? err.message : String(err)}${colors.reset}\n`);
|
|
590
|
+
process.exit(1);
|
|
591
|
+
}
|
|
592
|
+
process.env["GLUBEAN_RUNNER_BOOTSTRAP_INPUT_MAP"] = JSON.stringify({
|
|
593
|
+
[targetTestId]: templated,
|
|
594
|
+
});
|
|
595
|
+
console.log(`${colors.dim} --bootstrap-json: ${targetTestId}${colors.reset}`);
|
|
596
|
+
}
|
|
597
|
+
if (options.forceStandalone === true) {
|
|
598
|
+
process.env["GLUBEAN_RUNNER_FORCE_STANDALONE_IDS"] = JSON.stringify([
|
|
599
|
+
targetTestId,
|
|
600
|
+
]);
|
|
601
|
+
console.warn(`${colors.yellow}⚠ --force-standalone enabled for ${targetTestId} (debug)${colors.reset}`);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
else {
|
|
605
|
+
// Clear stale state from prior runs in the same process.
|
|
606
|
+
delete process.env["GLUBEAN_RUNNER_EXPLICIT_INPUT_MAP"];
|
|
607
|
+
delete process.env["GLUBEAN_RUNNER_BOOTSTRAP_INPUT_MAP"];
|
|
608
|
+
delete process.env["GLUBEAN_RUNNER_FORCE_STANDALONE_IDS"];
|
|
609
|
+
}
|
|
455
610
|
if (options.pick) {
|
|
456
611
|
process.env.GLUBEAN_PICK = options.pick;
|
|
457
612
|
console.log(`${colors.dim} pick: ${options.pick}${colors.reset}`);
|
|
@@ -460,10 +615,9 @@ export async function runCommand(target, options = {}) {
|
|
|
460
615
|
delete process.env.GLUBEAN_PICK;
|
|
461
616
|
}
|
|
462
617
|
const shared = toSharedRunConfig(effectiveRun);
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
});
|
|
618
|
+
// Note: TestExecutor construction is delegated to ProjectRunner below
|
|
619
|
+
// (it builds one via TestExecutor.fromSharedConfig with identical cwd +
|
|
620
|
+
// inspectBrk params when no executor option is passed).
|
|
467
621
|
let passed = 0;
|
|
468
622
|
let failed = 0;
|
|
469
623
|
let skipped = 0;
|
|
@@ -490,40 +644,15 @@ export async function runCommand(target, options = {}) {
|
|
|
490
644
|
group.push(entry);
|
|
491
645
|
fileGroups.set(entry.filePath, group);
|
|
492
646
|
}
|
|
493
|
-
// ── Session
|
|
647
|
+
// ── Session + execution + teardown via ProjectRunner ─────────────────────
|
|
648
|
+
//
|
|
649
|
+
// Replaces the prior inline RunOrchestrator + per-file TestExecutor loop
|
|
650
|
+
// (~540 lines) with a single event-stream consumer. Per-event presentation
|
|
651
|
+
// handlers (trace / assertion / step / etc.) are byte-for-byte unchanged;
|
|
652
|
+
// only the outer wiring swaps from direct executor.run(...) to the facade.
|
|
653
|
+
//
|
|
654
|
+
// See internal/30-execution/2026-04-23-rf-1b-cli-migration/execution-log.md.
|
|
494
655
|
const sessionState = {};
|
|
495
|
-
const sessionFile = options.noSession
|
|
496
|
-
? undefined
|
|
497
|
-
: discoverSessionFile(startDir, rootDir);
|
|
498
|
-
const orchestrator = new RunOrchestrator(executor);
|
|
499
|
-
if (sessionFile) {
|
|
500
|
-
console.log(`${colors.dim}Session: ${relative(process.cwd(), sessionFile)}${colors.reset}`);
|
|
501
|
-
let sessionFailed = false;
|
|
502
|
-
for await (const event of orchestrator.runSessionSetup(sessionFile, { vars: envVars, secrets, interactive }, toSingleExecutionOptions(shared))) {
|
|
503
|
-
if (event.type === "session:set") {
|
|
504
|
-
sessionState[event.key] = event.value;
|
|
505
|
-
}
|
|
506
|
-
else if (event.type === "status" && event.status === "failed") {
|
|
507
|
-
sessionFailed = true;
|
|
508
|
-
console.log(` ${colors.red}✗ Session setup failed${event.error ? `: ${event.error}` : ""}${colors.reset}`);
|
|
509
|
-
}
|
|
510
|
-
else if (event.type === "log") {
|
|
511
|
-
console.log(` ${colors.dim}[session] ${event.message}${colors.reset}`);
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
if (sessionFailed) {
|
|
515
|
-
// Best-effort teardown before exiting
|
|
516
|
-
for await (const _event of orchestrator.runSessionTeardown(sessionFile, { vars: envVars, secrets }, sessionState, toSingleExecutionOptions(shared))) {
|
|
517
|
-
// Silently consume teardown events
|
|
518
|
-
}
|
|
519
|
-
console.log(`\n${colors.red}Session setup failed. All tests skipped.${colors.reset}`);
|
|
520
|
-
process.exit(1);
|
|
521
|
-
}
|
|
522
|
-
const keyCount = Object.keys(sessionState).length;
|
|
523
|
-
if (keyCount > 0) {
|
|
524
|
-
console.log(`${colors.dim} ${keyCount} session value${keyCount > 1 ? "s" : ""} set${colors.reset}`);
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
656
|
const compactUrl = (url) => {
|
|
528
657
|
try {
|
|
529
658
|
const u = new URL(url);
|
|
@@ -543,372 +672,475 @@ export async function runCommand(target, options = {}) {
|
|
|
543
672
|
return `${colors.dim}${status}${colors.reset}`;
|
|
544
673
|
return `${colors.green}${status}${colors.reset}`;
|
|
545
674
|
};
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
675
|
+
// Per-test state, scoped across file:event boundaries. Reset on each
|
|
676
|
+
// "start" event inside file:event handlers.
|
|
677
|
+
let currentGroupFilePath = "";
|
|
678
|
+
let currentTestMap;
|
|
679
|
+
let testId = "";
|
|
680
|
+
let testName = "";
|
|
681
|
+
let testItem = null;
|
|
682
|
+
let startTime = Date.now();
|
|
683
|
+
let testEvents = [];
|
|
684
|
+
let assertions = [];
|
|
685
|
+
let success = false;
|
|
686
|
+
let errorMsg;
|
|
687
|
+
let peakMemoryMB;
|
|
688
|
+
let stepAssertionCount = 0;
|
|
689
|
+
let stepTraceLines = [];
|
|
690
|
+
let testStarted = false;
|
|
691
|
+
const addLogEntry = (type, message, data) => {
|
|
692
|
+
if (effectiveRun.logFile) {
|
|
693
|
+
logEntries.push({
|
|
694
|
+
timestamp: new Date().toISOString(),
|
|
695
|
+
testId,
|
|
696
|
+
testName,
|
|
697
|
+
type,
|
|
698
|
+
message,
|
|
699
|
+
data,
|
|
700
|
+
});
|
|
550
701
|
}
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
702
|
+
};
|
|
703
|
+
const finalizeTest = () => {
|
|
704
|
+
if (!testStarted)
|
|
705
|
+
return;
|
|
706
|
+
testStarted = false;
|
|
707
|
+
const duration = Date.now() - startTime;
|
|
708
|
+
const allAssertionsPassed = assertions.every((a) => a.passed);
|
|
709
|
+
const finalSuccess = success && allAssertionsPassed;
|
|
710
|
+
collectedRuns.push({
|
|
711
|
+
testId,
|
|
712
|
+
testName,
|
|
713
|
+
tags: testItem?.meta.tags,
|
|
714
|
+
filePath: currentGroupFilePath,
|
|
715
|
+
events: testEvents,
|
|
716
|
+
success: finalSuccess,
|
|
717
|
+
durationMs: duration,
|
|
718
|
+
groupId: testItem?.meta.groupId,
|
|
719
|
+
});
|
|
720
|
+
addLogEntry("result", finalSuccess ? "PASSED" : "FAILED", {
|
|
721
|
+
duration,
|
|
722
|
+
success: finalSuccess,
|
|
723
|
+
peakMemoryMB,
|
|
724
|
+
});
|
|
725
|
+
const peakMB = peakMemoryMB ? parseFloat(peakMemoryMB) : 0;
|
|
726
|
+
if (peakMB > overallPeakMemoryMB) {
|
|
727
|
+
overallPeakMemoryMB = peakMB;
|
|
558
728
|
}
|
|
559
|
-
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
filePath: groupFilePath,
|
|
573
|
-
events: [{ type: "status", status: "skipped", reason: skipReason }],
|
|
574
|
-
success: true,
|
|
575
|
-
durationMs: 0,
|
|
576
|
-
groupId: ft.test.meta.groupId,
|
|
577
|
-
});
|
|
578
|
-
continue;
|
|
579
|
-
}
|
|
580
|
-
runnableTests.push(ft);
|
|
729
|
+
const testHttpCalls = testEvents.filter((e) => e.type === "trace").length;
|
|
730
|
+
const testSteps = testEvents.filter((e) => e.type === "step_end").length;
|
|
731
|
+
const miniStats = [];
|
|
732
|
+
miniStats.push(`${duration}ms`);
|
|
733
|
+
if (testHttpCalls > 0)
|
|
734
|
+
miniStats.push(`${testHttpCalls} calls`);
|
|
735
|
+
if (assertions.length > 0)
|
|
736
|
+
miniStats.push(`${assertions.length} checks`);
|
|
737
|
+
if (testSteps > 0)
|
|
738
|
+
miniStats.push(`${testSteps} steps`);
|
|
739
|
+
if (finalSuccess) {
|
|
740
|
+
console.log(` ${colors.green}✓ PASSED${colors.reset} ${colors.dim}(${miniStats.join(", ")})${colors.reset}`);
|
|
741
|
+
passed++;
|
|
581
742
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
const exportNames = {};
|
|
586
|
-
for (const ft of runnableTests) {
|
|
587
|
-
exportNames[ft.test.meta.id] = ft.exportName;
|
|
743
|
+
else {
|
|
744
|
+
console.log(` ${colors.red}✗ FAILED${colors.reset} ${colors.dim}(${miniStats.join(", ")})${colors.reset}`);
|
|
745
|
+
failed++;
|
|
588
746
|
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
return sum +
|
|
593
|
-
(normalizePositiveTimeoutMs(ft.test.meta.timeout) ??
|
|
594
|
-
shared.perTestTimeoutMs ?? 30_000);
|
|
595
|
-
}, 0);
|
|
596
|
-
let testId = "";
|
|
597
|
-
let testName = "";
|
|
598
|
-
let testItem = null;
|
|
599
|
-
let startTime = Date.now();
|
|
600
|
-
let testEvents = [];
|
|
601
|
-
let assertions = [];
|
|
602
|
-
let success = false;
|
|
603
|
-
let errorMsg;
|
|
604
|
-
let peakMemoryMB;
|
|
605
|
-
let stepAssertionCount = 0;
|
|
606
|
-
let stepTraceLines = [];
|
|
607
|
-
let testStarted = false;
|
|
608
|
-
const addLogEntry = (type, message, data) => {
|
|
609
|
-
if (effectiveRun.logFile) {
|
|
610
|
-
logEntries.push({
|
|
611
|
-
timestamp: new Date().toISOString(),
|
|
612
|
-
testId,
|
|
613
|
-
testName,
|
|
614
|
-
type,
|
|
615
|
-
message,
|
|
616
|
-
data,
|
|
617
|
-
});
|
|
747
|
+
if (peakMB > MEMORY_WARNING_THRESHOLD_MB) {
|
|
748
|
+
if (peakMB > CLOUD_MEMORY_LIMITS.free) {
|
|
749
|
+
console.log(` ${colors.yellow}⚠ Memory (${peakMemoryMB} MB) exceeds Free cloud runner limit (${CLOUD_MEMORY_LIMITS.free} MB).${colors.reset}`);
|
|
618
750
|
}
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
if (!testStarted)
|
|
622
|
-
return;
|
|
623
|
-
testStarted = false;
|
|
624
|
-
const duration = Date.now() - startTime;
|
|
625
|
-
const allAssertionsPassed = assertions.every((a) => a.passed);
|
|
626
|
-
const finalSuccess = success && allAssertionsPassed;
|
|
627
|
-
collectedRuns.push({
|
|
628
|
-
testId,
|
|
629
|
-
testName,
|
|
630
|
-
tags: testItem?.meta.tags,
|
|
631
|
-
filePath: groupFilePath,
|
|
632
|
-
events: testEvents,
|
|
633
|
-
success: finalSuccess,
|
|
634
|
-
durationMs: duration,
|
|
635
|
-
groupId: testItem?.meta.groupId,
|
|
636
|
-
});
|
|
637
|
-
addLogEntry("result", finalSuccess ? "PASSED" : "FAILED", {
|
|
638
|
-
duration,
|
|
639
|
-
success: finalSuccess,
|
|
640
|
-
peakMemoryMB,
|
|
641
|
-
});
|
|
642
|
-
const peakMB = peakMemoryMB ? parseFloat(peakMemoryMB) : 0;
|
|
643
|
-
if (peakMB > overallPeakMemoryMB) {
|
|
644
|
-
overallPeakMemoryMB = peakMB;
|
|
751
|
+
else {
|
|
752
|
+
console.log(` ${colors.yellow}⚠ Memory (${peakMemoryMB} MB) is approaching Free cloud runner limit (${CLOUD_MEMORY_LIMITS.free} MB).${colors.reset}`);
|
|
645
753
|
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
754
|
+
}
|
|
755
|
+
for (const assertion of assertions) {
|
|
756
|
+
if (!assertion.passed) {
|
|
757
|
+
console.log(` ${colors.red}✗ ${assertion.message}${colors.reset}`);
|
|
758
|
+
if (assertion.expected !== undefined || assertion.actual !== undefined) {
|
|
759
|
+
if (assertion.expected !== undefined) {
|
|
760
|
+
console.log(` ${colors.dim}Expected: ${JSON.stringify(assertion.expected)}${colors.reset}`);
|
|
761
|
+
}
|
|
762
|
+
if (assertion.actual !== undefined) {
|
|
763
|
+
console.log(` ${colors.dim}Actual: ${JSON.stringify(assertion.actual)}${colors.reset}`);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
if (errorMsg) {
|
|
769
|
+
console.log(` ${colors.red}Error: ${errorMsg}${colors.reset}`);
|
|
770
|
+
}
|
|
771
|
+
};
|
|
772
|
+
// Pre-filter tests by capability profile so file:start can emit the
|
|
773
|
+
// ⊘ lines inline (preserves the pre-migration output layout where these
|
|
774
|
+
// lines appear between the file header and the first runnable test of
|
|
775
|
+
// the file). `runnableByFile` is what actually feeds ProjectRunner.
|
|
776
|
+
const fileCapabilitySkips = new Map();
|
|
777
|
+
const runnableByFile = new Map();
|
|
778
|
+
for (const [filePath, fileTests] of fileGroups) {
|
|
779
|
+
const skips = [];
|
|
780
|
+
const runnable = [];
|
|
781
|
+
for (const ft of fileTests) {
|
|
782
|
+
const reason = shouldSkipTest(ft.test.meta, capabilityProfile);
|
|
783
|
+
if (reason) {
|
|
784
|
+
skips.push({ ft, reason });
|
|
659
785
|
}
|
|
660
786
|
else {
|
|
661
|
-
|
|
662
|
-
failed++;
|
|
787
|
+
runnable.push(ft);
|
|
663
788
|
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
789
|
+
}
|
|
790
|
+
if (skips.length > 0)
|
|
791
|
+
fileCapabilitySkips.set(filePath, skips);
|
|
792
|
+
if (runnable.length > 0)
|
|
793
|
+
runnableByFile.set(filePath, runnable);
|
|
794
|
+
}
|
|
795
|
+
// Flatten in fileGroups insertion order so ProjectRunner processes files
|
|
796
|
+
// in the same order the old inline loop did.
|
|
797
|
+
const runnableTests = [];
|
|
798
|
+
for (const filePath of fileGroups.keys()) {
|
|
799
|
+
const runnable = runnableByFile.get(filePath);
|
|
800
|
+
if (runnable)
|
|
801
|
+
runnableTests.push(...runnable);
|
|
802
|
+
}
|
|
803
|
+
// Files ProjectRunner actually started. Any fileGroups entry that never
|
|
804
|
+
// gets file:start is a fail-fast skip — handled post run:complete.
|
|
805
|
+
const startedFiles = new Set();
|
|
806
|
+
const runner = new ProjectRunner({
|
|
807
|
+
rootDir,
|
|
808
|
+
sharedConfig: shared,
|
|
809
|
+
sessionStartDir: startDir,
|
|
810
|
+
vars: envVars,
|
|
811
|
+
secrets,
|
|
812
|
+
// Cast — CLI's DiscoveredTestMeta.requires is a plain `string | undefined`
|
|
813
|
+
// (scanner output, openly typed). ProjectRunnerTest narrows it to the
|
|
814
|
+
// CaseRequires literal union. Widening happens upstream at scanner.
|
|
815
|
+
tests: runnableTests.map((t) => ({
|
|
816
|
+
filePath: t.filePath,
|
|
817
|
+
exportName: t.exportName,
|
|
818
|
+
meta: t.test.meta,
|
|
819
|
+
})),
|
|
820
|
+
noSession: !!options.noSession,
|
|
821
|
+
interactive,
|
|
822
|
+
...(options.inspectBrk !== undefined && { inspectBrk: options.inspectBrk }),
|
|
823
|
+
metricCollector,
|
|
824
|
+
});
|
|
825
|
+
for await (const ev of runner.run()) {
|
|
826
|
+
switch (ev.type) {
|
|
827
|
+
case "bootstrap:start":
|
|
828
|
+
case "bootstrap:done":
|
|
829
|
+
case "discovery:done":
|
|
830
|
+
case "session:setup:start":
|
|
831
|
+
case "session:teardown:start":
|
|
832
|
+
case "session:teardown:done":
|
|
833
|
+
// Silent — either internal plumbing, or already covered by a more
|
|
834
|
+
// specific event (e.g. session:discovered already printed the
|
|
835
|
+
// "Session: <path>" header before setup:start arrived).
|
|
836
|
+
break;
|
|
837
|
+
case "bootstrap:failed":
|
|
838
|
+
console.error(`\n${colors.red}Bootstrap failed: ${ev.error.message}${colors.reset}`);
|
|
839
|
+
process.exit(1);
|
|
840
|
+
break;
|
|
841
|
+
case "session:discovered":
|
|
842
|
+
if (ev.sessionFile) {
|
|
843
|
+
console.log(`${colors.dim}Session: ${relative(process.cwd(), ev.sessionFile)}${colors.reset}`);
|
|
667
844
|
}
|
|
668
|
-
|
|
669
|
-
|
|
845
|
+
break;
|
|
846
|
+
case "session:setup:event": {
|
|
847
|
+
const se = ev.event;
|
|
848
|
+
if (se.type === "session:set") {
|
|
849
|
+
sessionState[se.key] = se.value;
|
|
670
850
|
}
|
|
851
|
+
else if (se.type === "status" && se.status === "failed") {
|
|
852
|
+
console.log(` ${colors.red}✗ Session setup failed${se.error ? `: ${se.error}` : ""}${colors.reset}`);
|
|
853
|
+
}
|
|
854
|
+
else if (se.type === "log") {
|
|
855
|
+
console.log(` ${colors.dim}[session] ${se.message}${colors.reset}`);
|
|
856
|
+
}
|
|
857
|
+
break;
|
|
671
858
|
}
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
if (assertion.expected !== undefined) {
|
|
677
|
-
console.log(` ${colors.dim}Expected: ${JSON.stringify(assertion.expected)}${colors.reset}`);
|
|
678
|
-
}
|
|
679
|
-
if (assertion.actual !== undefined) {
|
|
680
|
-
console.log(` ${colors.dim}Actual: ${JSON.stringify(assertion.actual)}${colors.reset}`);
|
|
681
|
-
}
|
|
682
|
-
}
|
|
859
|
+
case "session:setup:done": {
|
|
860
|
+
const count = ev.stateKeys.length;
|
|
861
|
+
if (count > 0) {
|
|
862
|
+
console.log(`${colors.dim} ${count} session value${count > 1 ? "s" : ""} set${colors.reset}`);
|
|
683
863
|
}
|
|
864
|
+
break;
|
|
684
865
|
}
|
|
685
|
-
|
|
686
|
-
console.log(
|
|
866
|
+
case "session:setup:failed":
|
|
867
|
+
console.log(`\n${colors.red}Session setup failed. All tests skipped.${colors.reset}`);
|
|
868
|
+
process.exit(1);
|
|
869
|
+
break;
|
|
870
|
+
case "session:teardown:event": {
|
|
871
|
+
const te = ev.event;
|
|
872
|
+
if (te.type === "log") {
|
|
873
|
+
console.log(` ${colors.dim}[session] ${te.message}${colors.reset}`);
|
|
874
|
+
}
|
|
875
|
+
else if (te.type === "status" && te.status === "failed") {
|
|
876
|
+
console.log(` ${colors.yellow}⚠ Session teardown failed${te.error ? `: ${te.error}` : ""}${colors.reset}`);
|
|
877
|
+
}
|
|
878
|
+
break;
|
|
687
879
|
}
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
stepAssertionCount = 0;
|
|
717
|
-
stepTraceLines = [];
|
|
718
|
-
testStarted = true;
|
|
719
|
-
const tags = testItem?.meta.tags?.length
|
|
720
|
-
? ` ${colors.dim}[${testItem.meta.tags.join(", ")}]${colors.reset}`
|
|
721
|
-
: "";
|
|
722
|
-
console.log(` ${colors.cyan}●${colors.reset} ${testName}${tags}`);
|
|
723
|
-
if (testItem?.meta.description) {
|
|
724
|
-
console.log(` ${colors.dim}${testItem.meta.description}${colors.reset}`);
|
|
880
|
+
case "file:start": {
|
|
881
|
+
currentGroupFilePath = ev.filePath;
|
|
882
|
+
startedFiles.add(ev.filePath);
|
|
883
|
+
const runnable = runnableByFile.get(ev.filePath) ?? [];
|
|
884
|
+
currentTestMap = new Map(runnable.map((ft) => [ft.test.meta.id, ft]));
|
|
885
|
+
if (isMultiFile) {
|
|
886
|
+
const relPath = relative(process.cwd(), ev.filePath);
|
|
887
|
+
console.log(`${colors.bold}📁 ${relPath}${colors.reset}`);
|
|
888
|
+
}
|
|
889
|
+
// Inline capability-skip display — preserves pre-migration layout
|
|
890
|
+
// where ⊘ lines sit between the file header and the first runnable
|
|
891
|
+
// test of the file.
|
|
892
|
+
const skips = fileCapabilitySkips.get(ev.filePath);
|
|
893
|
+
if (skips) {
|
|
894
|
+
for (const { ft, reason } of skips) {
|
|
895
|
+
skipped++;
|
|
896
|
+
const name = ft.test.meta.name || ft.test.meta.id;
|
|
897
|
+
console.log(` ${colors.yellow}⊘${colors.reset} ${name} ${colors.dim}— skipped (${reason})${colors.reset}`);
|
|
898
|
+
collectedRuns.push({
|
|
899
|
+
testId: ft.test.meta.id,
|
|
900
|
+
testName: name,
|
|
901
|
+
tags: ft.test.meta.tags,
|
|
902
|
+
filePath: ev.filePath,
|
|
903
|
+
events: [{ type: "status", status: "skipped", reason }],
|
|
904
|
+
success: true,
|
|
905
|
+
durationMs: 0,
|
|
906
|
+
groupId: ft.test.meta.groupId,
|
|
907
|
+
});
|
|
725
908
|
}
|
|
726
|
-
break;
|
|
727
909
|
}
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
910
|
+
break;
|
|
911
|
+
}
|
|
912
|
+
case "file:event": {
|
|
913
|
+
const event = ev.event;
|
|
914
|
+
switch (event.type) {
|
|
915
|
+
case "start": {
|
|
916
|
+
const entry = currentTestMap?.get(event.id);
|
|
917
|
+
testId = event.id;
|
|
918
|
+
testName = entry?.test.meta.name || event.name || event.id;
|
|
919
|
+
testItem = entry?.test || null;
|
|
920
|
+
startTime = Date.now();
|
|
921
|
+
testEvents = [];
|
|
922
|
+
assertions = [];
|
|
923
|
+
success = false;
|
|
924
|
+
errorMsg = undefined;
|
|
925
|
+
peakMemoryMB = undefined;
|
|
926
|
+
stepAssertionCount = 0;
|
|
927
|
+
stepTraceLines = [];
|
|
928
|
+
testStarted = true;
|
|
929
|
+
const tags = testItem?.meta.tags?.length
|
|
930
|
+
? ` ${colors.dim}[${testItem.meta.tags.join(", ")}]${colors.reset}`
|
|
931
|
+
: "";
|
|
932
|
+
console.log(` ${colors.cyan}●${colors.reset} ${testName}${tags}`);
|
|
933
|
+
if (testItem?.meta.description) {
|
|
934
|
+
console.log(` ${colors.dim}${testItem.meta.description}${colors.reset}`);
|
|
935
|
+
}
|
|
936
|
+
break;
|
|
733
937
|
}
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
938
|
+
case "status":
|
|
939
|
+
success = event.status === "completed";
|
|
940
|
+
if (event.error) {
|
|
941
|
+
errorMsg = event.error;
|
|
942
|
+
addLogEntry("error", event.error);
|
|
943
|
+
}
|
|
944
|
+
if (event.peakMemoryMB)
|
|
945
|
+
peakMemoryMB = event.peakMemoryMB;
|
|
946
|
+
finalizeTest();
|
|
947
|
+
break;
|
|
948
|
+
case "error":
|
|
949
|
+
success = false;
|
|
950
|
+
if (!errorMsg)
|
|
951
|
+
errorMsg = event.message;
|
|
952
|
+
addLogEntry("error", event.message);
|
|
953
|
+
break;
|
|
954
|
+
case "log":
|
|
955
|
+
addLogEntry("log", event.message);
|
|
956
|
+
if (event.message.startsWith("Loading test module:"))
|
|
957
|
+
break;
|
|
958
|
+
console.log(` ${colors.dim}${event.message}${colors.reset}`);
|
|
959
|
+
break;
|
|
960
|
+
case "assertion":
|
|
961
|
+
assertions.push({
|
|
962
|
+
passed: event.passed,
|
|
963
|
+
message: event.message,
|
|
964
|
+
actual: event.actual,
|
|
965
|
+
expected: event.expected,
|
|
966
|
+
});
|
|
967
|
+
stepAssertionCount++;
|
|
968
|
+
addLogEntry("assertion", event.message, {
|
|
969
|
+
passed: event.passed,
|
|
970
|
+
actual: event.actual,
|
|
971
|
+
expected: event.expected,
|
|
972
|
+
});
|
|
973
|
+
if (effectiveRun.verbose) {
|
|
974
|
+
const icon = event.passed ? `${colors.green}✓${colors.reset}` : `${colors.red}✗${colors.reset}`;
|
|
975
|
+
console.log(` ${icon} ${colors.dim}${event.message}${colors.reset}`);
|
|
976
|
+
}
|
|
977
|
+
break;
|
|
978
|
+
case "trace": {
|
|
979
|
+
const traceTarget = event.data.target ?? `${event.data.method ?? "?"} ${event.data.url ?? "?"}`;
|
|
980
|
+
const traceDuration = event.data.durationMs ?? event.data.duration ?? 0;
|
|
981
|
+
const traceProtocol = event.data.protocol ?? "http";
|
|
982
|
+
const traceMsg = `${traceTarget} → ${event.data.status} (${traceDuration}ms)`;
|
|
983
|
+
addLogEntry("trace", traceMsg, event.data);
|
|
984
|
+
traceCollector.push({
|
|
985
|
+
testId,
|
|
986
|
+
protocol: traceProtocol,
|
|
987
|
+
target: traceTarget,
|
|
988
|
+
method: event.data.method,
|
|
989
|
+
url: event.data.url,
|
|
990
|
+
status: event.data.status,
|
|
991
|
+
});
|
|
992
|
+
const displayTarget = event.data.method && event.data.url
|
|
993
|
+
? `${colors.dim}${event.data.method}${colors.reset} ${compactUrl(event.data.url)}`
|
|
994
|
+
: `${colors.dim}${traceTarget}${colors.reset}`;
|
|
995
|
+
const compactTrace = `${displayTarget} ${colors.dim}→${colors.reset} ${colorStatus(event.data.status)} ${colors.dim}${traceDuration}ms${colors.reset}`;
|
|
996
|
+
stepTraceLines.push(compactTrace);
|
|
997
|
+
console.log(` ${colors.dim}↳${colors.reset} ${compactTrace}`);
|
|
998
|
+
if (effectiveRun.verbose && event.data.requestBody) {
|
|
999
|
+
console.log(` ${colors.dim}req: ${JSON.stringify(event.data.requestBody).slice(0, 120)}${colors.reset}`);
|
|
1000
|
+
}
|
|
1001
|
+
if (effectiveRun.verbose && event.data.responseBody) {
|
|
1002
|
+
const body = JSON.stringify(event.data.responseBody);
|
|
1003
|
+
console.log(` ${colors.dim}res: ${body.slice(0, 120)}${body.length > 120 ? "…" : ""}${colors.reset}`);
|
|
1004
|
+
}
|
|
747
1005
|
break;
|
|
748
|
-
console.log(` ${colors.dim}${event.message}${colors.reset}`);
|
|
749
|
-
break;
|
|
750
|
-
case "assertion":
|
|
751
|
-
assertions.push({
|
|
752
|
-
passed: event.passed,
|
|
753
|
-
message: event.message,
|
|
754
|
-
actual: event.actual,
|
|
755
|
-
expected: event.expected,
|
|
756
|
-
});
|
|
757
|
-
stepAssertionCount++;
|
|
758
|
-
addLogEntry("assertion", event.message, {
|
|
759
|
-
passed: event.passed,
|
|
760
|
-
actual: event.actual,
|
|
761
|
-
expected: event.expected,
|
|
762
|
-
});
|
|
763
|
-
if (effectiveRun.verbose) {
|
|
764
|
-
const icon = event.passed ? `${colors.green}✓${colors.reset}` : `${colors.red}✗${colors.reset}`;
|
|
765
|
-
console.log(` ${icon} ${colors.dim}${event.message}${colors.reset}`);
|
|
766
1006
|
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
protocol: traceProtocol,
|
|
777
|
-
target: traceTarget,
|
|
778
|
-
method: event.data.method,
|
|
779
|
-
url: event.data.url,
|
|
780
|
-
status: event.data.status,
|
|
781
|
-
});
|
|
782
|
-
const displayTarget = event.data.method && event.data.url
|
|
783
|
-
? `${colors.dim}${event.data.method}${colors.reset} ${compactUrl(event.data.url)}`
|
|
784
|
-
: `${colors.dim}${traceTarget}${colors.reset}`;
|
|
785
|
-
const compactTrace = `${displayTarget} ${colors.dim}→${colors.reset} ${colorStatus(event.data.status)} ${colors.dim}${traceDuration}ms${colors.reset}`;
|
|
786
|
-
stepTraceLines.push(compactTrace);
|
|
787
|
-
console.log(` ${colors.dim}↳${colors.reset} ${compactTrace}`);
|
|
788
|
-
if (effectiveRun.verbose && event.data.requestBody) {
|
|
789
|
-
console.log(` ${colors.dim}req: ${JSON.stringify(event.data.requestBody).slice(0, 120)}${colors.reset}`);
|
|
1007
|
+
case "action": {
|
|
1008
|
+
const a = event.data;
|
|
1009
|
+
if (a.category === "http:request")
|
|
1010
|
+
break;
|
|
1011
|
+
const statusColor = a.status === "ok" ? colors.green : a.status === "error" ? colors.red : colors.yellow;
|
|
1012
|
+
const statusIcon = a.status === "ok" ? "✓" : a.status === "error" ? "✗" : "⏱";
|
|
1013
|
+
addLogEntry("action", `[${a.category}] ${a.target} ${a.duration}ms ${a.status}`, a);
|
|
1014
|
+
console.log(` ${colors.dim}↳${colors.reset} ${colors.cyan}${a.category}${colors.reset} ${a.target} ${colors.dim}${a.duration}ms${colors.reset} ${statusColor}${statusIcon}${colors.reset}`);
|
|
1015
|
+
break;
|
|
790
1016
|
}
|
|
791
|
-
|
|
792
|
-
const
|
|
793
|
-
|
|
1017
|
+
case "event": {
|
|
1018
|
+
const evData = event.data;
|
|
1019
|
+
addLogEntry("event", `[${evData.type}]`, evData);
|
|
1020
|
+
if (effectiveRun.verbose) {
|
|
1021
|
+
const summary = JSON.stringify(evData.data).slice(0, 80);
|
|
1022
|
+
console.log(` ${colors.dim}[${evData.type}] ${summary}${colors.reset}`);
|
|
1023
|
+
}
|
|
1024
|
+
break;
|
|
794
1025
|
}
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
1026
|
+
case "metric": {
|
|
1027
|
+
// ProjectRunner already accumulates into metricCollector (passed
|
|
1028
|
+
// in above). CLI only handles verbose display + log entry.
|
|
1029
|
+
const unit = event.unit ? ` ${event.unit}` : "";
|
|
1030
|
+
const tagStr = event.tags
|
|
1031
|
+
? ` ${colors.dim}{${Object.entries(event.tags)
|
|
1032
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
1033
|
+
.join(", ")}}${colors.reset}`
|
|
1034
|
+
: "";
|
|
1035
|
+
const metricMsg = `${event.name} = ${event.value}${unit}`;
|
|
1036
|
+
addLogEntry("metric", metricMsg, {
|
|
1037
|
+
name: event.name,
|
|
1038
|
+
value: event.value,
|
|
1039
|
+
unit: event.unit,
|
|
1040
|
+
tags: event.tags,
|
|
1041
|
+
});
|
|
1042
|
+
if (effectiveRun.verbose) {
|
|
1043
|
+
console.log(` ${colors.blue}📊 ${metricMsg}${colors.reset}${tagStr}`);
|
|
1044
|
+
}
|
|
800
1045
|
break;
|
|
801
|
-
const statusColor = a.status === "ok" ? colors.green : a.status === "error" ? colors.red : colors.yellow;
|
|
802
|
-
const statusIcon = a.status === "ok" ? "✓" : a.status === "error" ? "✗" : "⏱";
|
|
803
|
-
addLogEntry("action", `[${a.category}] ${a.target} ${a.duration}ms ${a.status}`, a);
|
|
804
|
-
console.log(` ${colors.dim}↳${colors.reset} ${colors.cyan}${a.category}${colors.reset} ${a.target} ${colors.dim}${a.duration}ms${colors.reset} ${statusColor}${statusIcon}${colors.reset}`);
|
|
805
|
-
break;
|
|
806
|
-
}
|
|
807
|
-
case "event": {
|
|
808
|
-
const ev = event.data;
|
|
809
|
-
addLogEntry("event", `[${ev.type}]`, ev);
|
|
810
|
-
if (effectiveRun.verbose) {
|
|
811
|
-
const summary = JSON.stringify(ev.data).slice(0, 80);
|
|
812
|
-
console.log(` ${colors.dim}[${ev.type}] ${summary}${colors.reset}`);
|
|
813
1046
|
}
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
.
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
1047
|
+
case "step_start":
|
|
1048
|
+
stepAssertionCount = 0;
|
|
1049
|
+
stepTraceLines = [];
|
|
1050
|
+
console.log(` ${colors.cyan}┌${colors.reset} ${colors.dim}step ${event.index + 1}/${event.total}${colors.reset} ${colors.bold}${event.name}${colors.reset}`);
|
|
1051
|
+
break;
|
|
1052
|
+
case "step_end": {
|
|
1053
|
+
const stepIcon = event.status === "passed"
|
|
1054
|
+
? `${colors.green}✓${colors.reset}`
|
|
1055
|
+
: event.status === "failed"
|
|
1056
|
+
? `${colors.red}✗${colors.reset}`
|
|
1057
|
+
: `${colors.yellow}○${colors.reset}`;
|
|
1058
|
+
const stepParts = [];
|
|
1059
|
+
if (event.durationMs !== undefined)
|
|
1060
|
+
stepParts.push(`${event.durationMs}ms`);
|
|
1061
|
+
if (event.assertions > 0)
|
|
1062
|
+
stepParts.push(`${event.assertions} assertions`);
|
|
1063
|
+
const httpInStep = stepTraceLines.length;
|
|
1064
|
+
if (httpInStep > 0)
|
|
1065
|
+
stepParts.push(`${httpInStep} API call${httpInStep > 1 ? "s" : ""}`);
|
|
1066
|
+
console.log(` ${colors.cyan}└${colors.reset} ${stepIcon} ${colors.dim}${stepParts.join(" · ")}${colors.reset}`);
|
|
1067
|
+
if (event.error) {
|
|
1068
|
+
console.log(` ${colors.red}${event.error}${colors.reset}`);
|
|
1069
|
+
}
|
|
1070
|
+
break;
|
|
833
1071
|
}
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
stepParts.push(`${event.durationMs}ms`);
|
|
850
|
-
if (event.assertions > 0)
|
|
851
|
-
stepParts.push(`${event.assertions} assertions`);
|
|
852
|
-
const httpInStep = stepTraceLines.length;
|
|
853
|
-
if (httpInStep > 0)
|
|
854
|
-
stepParts.push(`${httpInStep} API call${httpInStep > 1 ? "s" : ""}`);
|
|
855
|
-
console.log(` ${colors.cyan}└${colors.reset} ${stepIcon} ${colors.dim}${stepParts.join(" · ")}${colors.reset}`);
|
|
856
|
-
if (event.error) {
|
|
857
|
-
console.log(` ${colors.red}${event.error}${colors.reset}`);
|
|
1072
|
+
case "summary":
|
|
1073
|
+
runStats.httpRequestTotal += event.data.httpRequestTotal;
|
|
1074
|
+
runStats.httpErrorTotal += event.data.httpErrorTotal;
|
|
1075
|
+
runStats.assertionTotal += event.data.assertionTotal;
|
|
1076
|
+
runStats.assertionFailed += event.data.assertionFailed;
|
|
1077
|
+
runStats.warningTotal += event.data.warningTotal;
|
|
1078
|
+
runStats.warningTriggered += event.data.warningTriggered;
|
|
1079
|
+
runStats.stepTotal += event.data.stepTotal;
|
|
1080
|
+
runStats.stepPassed += event.data.stepPassed;
|
|
1081
|
+
runStats.stepFailed += event.data.stepFailed;
|
|
1082
|
+
break;
|
|
1083
|
+
case "warning": {
|
|
1084
|
+
const warnIcon = event.condition ? `${colors.green}✓${colors.reset}` : `${colors.yellow}⚠${colors.reset}`;
|
|
1085
|
+
console.log(` ${warnIcon} ${colors.yellow}${event.message}${colors.reset}`);
|
|
1086
|
+
break;
|
|
858
1087
|
}
|
|
859
|
-
|
|
1088
|
+
case "schema_validation":
|
|
1089
|
+
if (effectiveRun.verbose) {
|
|
1090
|
+
const icon = event.success ? `${colors.green}✓${colors.reset}` : `${colors.red}✗${colors.reset}`;
|
|
1091
|
+
console.log(` ${icon} ${colors.dim}schema: ${event.label}${colors.reset}`);
|
|
1092
|
+
}
|
|
1093
|
+
break;
|
|
1094
|
+
case "session:set":
|
|
1095
|
+
// ProjectRunner accumulates internally for cross-file forwarding;
|
|
1096
|
+
// CLI keeps its copy only for symmetry with pre-migration code
|
|
1097
|
+
// paths (useful e.g. for debug logging).
|
|
1098
|
+
sessionState[event.key] = event.value;
|
|
1099
|
+
continue;
|
|
860
1100
|
}
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
case "warning": {
|
|
873
|
-
const warnIcon = event.condition ? `${colors.green}✓${colors.reset}` : `${colors.yellow}⚠${colors.reset}`;
|
|
874
|
-
console.log(` ${warnIcon} ${colors.yellow}${event.message}${colors.reset}`);
|
|
875
|
-
break;
|
|
1101
|
+
if (testStarted)
|
|
1102
|
+
testEvents.push(event);
|
|
1103
|
+
break;
|
|
1104
|
+
}
|
|
1105
|
+
case "file:complete":
|
|
1106
|
+
// Mirror the old inline loop's tail cleanup: if the harness died
|
|
1107
|
+
// mid-test or emitted no start event, promote the leftover state
|
|
1108
|
+
// to a visible failure row.
|
|
1109
|
+
if (!testStarted && errorMsg) {
|
|
1110
|
+
console.log(` ${colors.red}✗ ${errorMsg}${colors.reset}`);
|
|
1111
|
+
failed++;
|
|
876
1112
|
}
|
|
877
|
-
|
|
878
|
-
if (
|
|
879
|
-
|
|
880
|
-
|
|
1113
|
+
if (testStarted) {
|
|
1114
|
+
if (!errorMsg)
|
|
1115
|
+
errorMsg = "Process exited before test completed";
|
|
1116
|
+
finalizeTest();
|
|
1117
|
+
}
|
|
1118
|
+
break;
|
|
1119
|
+
case "run:complete":
|
|
1120
|
+
// Fail-fast skip display: any file ProjectRunner never started
|
|
1121
|
+
// (because the failure limit kicked in between file groups) gets
|
|
1122
|
+
// the old "○ (skipped — fail-fast)" lines here, preserving the
|
|
1123
|
+
// pre-migration output layout.
|
|
1124
|
+
if (failureLimit !== undefined && ev.failedCount >= failureLimit) {
|
|
1125
|
+
for (const [filePath, fileTests] of fileGroups) {
|
|
1126
|
+
if (startedFiles.has(filePath))
|
|
1127
|
+
continue;
|
|
1128
|
+
if (isMultiFile) {
|
|
1129
|
+
const relPath = relative(process.cwd(), filePath);
|
|
1130
|
+
console.log(`${colors.bold}📁 ${relPath}${colors.reset}`);
|
|
1131
|
+
}
|
|
1132
|
+
for (const { test } of fileTests) {
|
|
1133
|
+
skipped++;
|
|
1134
|
+
const name = test.meta.name || test.meta.id;
|
|
1135
|
+
console.log(` ${colors.yellow}○${colors.reset} ${name} ${colors.dim}(skipped — fail-fast)${colors.reset}`);
|
|
1136
|
+
}
|
|
881
1137
|
}
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
}
|
|
889
|
-
if (testStarted)
|
|
890
|
-
testEvents.push(event);
|
|
891
|
-
}
|
|
892
|
-
if (!testStarted && errorMsg) {
|
|
893
|
-
// Harness failed before emitting any test start (e.g. module load error)
|
|
894
|
-
console.log(` ${colors.red}✗ ${errorMsg}${colors.reset}`);
|
|
895
|
-
failed++;
|
|
896
|
-
}
|
|
897
|
-
if (testStarted) {
|
|
898
|
-
if (!errorMsg)
|
|
899
|
-
errorMsg = "Process exited before test completed";
|
|
900
|
-
finalizeTest();
|
|
901
|
-
}
|
|
902
|
-
}
|
|
903
|
-
// ── Session teardown ───────────────────────────────────────────────────
|
|
904
|
-
if (sessionFile) {
|
|
905
|
-
for await (const event of orchestrator.runSessionTeardown(sessionFile, { vars: envVars, secrets }, sessionState, toSingleExecutionOptions(shared))) {
|
|
906
|
-
if (event.type === "log") {
|
|
907
|
-
console.log(` ${colors.dim}[session] ${event.message}${colors.reset}`);
|
|
908
|
-
}
|
|
909
|
-
else if (event.type === "status" && event.status === "failed") {
|
|
910
|
-
console.log(` ${colors.yellow}⚠ Session teardown failed${event.error ? `: ${event.error}` : ""}${colors.reset}`);
|
|
911
|
-
}
|
|
1138
|
+
}
|
|
1139
|
+
break;
|
|
1140
|
+
case "run:failed":
|
|
1141
|
+
// Terminal failure — actual exit already happened in
|
|
1142
|
+
// bootstrap:failed / session:setup:failed above.
|
|
1143
|
+
break;
|
|
912
1144
|
}
|
|
913
1145
|
}
|
|
914
1146
|
const totalDurationMs = Date.now() - totalStartTime;
|