@glubean/runner 0.1.28 → 0.1.29
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/executor.d.ts +9 -10
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +3 -0
- package/dist/executor.js.map +1 -1
- package/dist/harness.d.ts.map +1 -1
- package/dist/harness.js +396 -495
- package/dist/harness.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/dist/network_budget.d.ts +0 -9
- package/dist/network_budget.d.ts.map +0 -1
- package/dist/network_budget.js +0 -34
- package/dist/network_budget.js.map +0 -1
- package/dist/network_policy.d.ts +0 -12
- package/dist/network_policy.d.ts.map +0 -1
- package/dist/network_policy.js +0 -92
- package/dist/network_policy.js.map +0 -1
package/dist/harness.js
CHANGED
|
@@ -6,11 +6,10 @@
|
|
|
6
6
|
* tsx harness.ts --testUrl=<url> --testId=<id>
|
|
7
7
|
*/
|
|
8
8
|
import { parseArgs } from "node:util";
|
|
9
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
9
10
|
import { inferJsonSchema, truncateDeep } from "./schema_inference.js";
|
|
10
11
|
/* eslint-enable no-var */
|
|
11
12
|
import ky from "ky";
|
|
12
|
-
import { classifyHostnameBlockReason, classifyIpBlockReason, isAllowedPort, isAllowedProtocol, isIpLiteral, resolveUrlPort, } from "./network_policy.js";
|
|
13
|
-
import { applyResponseByteBudget } from "./network_budget.js";
|
|
14
13
|
import { Expectation } from "@glubean/sdk/expect";
|
|
15
14
|
// Global error handlers for async errors that escape try/catch
|
|
16
15
|
process.on("uncaughtException", (error) => {
|
|
@@ -40,6 +39,7 @@ const { values: args } = parseArgs({
|
|
|
40
39
|
testIds: { type: "string" },
|
|
41
40
|
exportName: { type: "string" },
|
|
42
41
|
exportNames: { type: "string" },
|
|
42
|
+
concurrency: { type: "string" },
|
|
43
43
|
emitFullTrace: { type: "boolean", default: false },
|
|
44
44
|
inferSchema: { type: "boolean", default: false },
|
|
45
45
|
truncateArrays: { type: "boolean", default: false },
|
|
@@ -60,6 +60,12 @@ const testId = args.testId;
|
|
|
60
60
|
* module-level state (e.g. shared `let` variables between tests).
|
|
61
61
|
*/
|
|
62
62
|
const testIds = args.testIds ? args.testIds.split(",") : undefined;
|
|
63
|
+
/**
|
|
64
|
+
* Max concurrency for parallel batch mode.
|
|
65
|
+
* When > 1 and testIds is set, parallel-marked tests run concurrently via p-queue.
|
|
66
|
+
* Default 1 (sequential).
|
|
67
|
+
*/
|
|
68
|
+
const batchConcurrency = Math.max(1, parseInt(args.concurrency, 10) || 1);
|
|
63
69
|
/** Optional export name for fallback lookup (used by test.pick/test.each). */
|
|
64
70
|
const exportName = args.exportName;
|
|
65
71
|
/** Optional testId→exportName mapping for batch mode fallback (test.pick). */
|
|
@@ -118,39 +124,76 @@ function parseRuntimeTestMetadata(input) {
|
|
|
118
124
|
return { id, tags };
|
|
119
125
|
}
|
|
120
126
|
const runtimeTest = parseRuntimeTestMetadata(contextData.test);
|
|
121
|
-
|
|
122
|
-
if (!input || typeof input !== "object")
|
|
123
|
-
return undefined;
|
|
124
|
-
const candidate = input;
|
|
125
|
-
if (candidate.mode !== "shared_serverless")
|
|
126
|
-
return undefined;
|
|
127
|
-
const allowedPorts = Array.isArray(candidate.allowedPorts)
|
|
128
|
-
? candidate.allowedPorts.filter((p) => typeof p === "number" && Number.isFinite(p) && p > 0 && p <= 65535).map((p) => Math.floor(p))
|
|
129
|
-
: [];
|
|
130
|
-
return {
|
|
131
|
-
mode: "shared_serverless",
|
|
132
|
-
maxRequests: Number(candidate.maxRequests) > 0 ? Math.floor(Number(candidate.maxRequests)) : 300,
|
|
133
|
-
maxConcurrentRequests: Number(candidate.maxConcurrentRequests) > 0
|
|
134
|
-
? Math.floor(Number(candidate.maxConcurrentRequests))
|
|
135
|
-
: 20,
|
|
136
|
-
requestTimeoutMs: Number(candidate.requestTimeoutMs) > 0 ? Math.floor(Number(candidate.requestTimeoutMs)) : 30_000,
|
|
137
|
-
maxResponseBytes: Number(candidate.maxResponseBytes) > 0
|
|
138
|
-
? Math.floor(Number(candidate.maxResponseBytes))
|
|
139
|
-
: 20 * 1024 * 1024,
|
|
140
|
-
allowedPorts: allowedPorts.length > 0 ? Array.from(new Set(allowedPorts)) : [80, 443, 8080, 8443],
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
const networkPolicy = parseNetworkPolicy(contextData.networkPolicy);
|
|
144
|
-
// Memory monitoring state
|
|
127
|
+
// Memory monitoring state (per-process — not isolated per-test)
|
|
145
128
|
let peakMemoryBytes = 0;
|
|
146
129
|
let memoryCheckInterval;
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Per-test execution context via AsyncLocalStorage
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
/**
|
|
134
|
+
* Holds all mutable state scoped to a single test execution.
|
|
135
|
+
* Accessed via `testContext.getStore()!` from anywhere in the async call chain
|
|
136
|
+
* (ky hooks, globalFetch override, ctx.assert, etc.) without explicit passing.
|
|
137
|
+
*
|
|
138
|
+
* Analogous to Java's ThreadLocal but tracks Node.js async continuations.
|
|
139
|
+
*/
|
|
140
|
+
class TestRunContext {
|
|
141
|
+
testId;
|
|
142
|
+
// Step-level assertion tracking
|
|
143
|
+
stepFailedAssertions = 0;
|
|
144
|
+
stepAssertionTotal = 0;
|
|
145
|
+
currentStepIndex = null;
|
|
146
|
+
// HTTP request counters
|
|
147
|
+
httpRequestTotal = 0;
|
|
148
|
+
httpErrorTotal = 0;
|
|
149
|
+
// Runtime identity
|
|
150
|
+
testMeta;
|
|
151
|
+
constructor(testId, meta) {
|
|
152
|
+
this.testId = testId;
|
|
153
|
+
this.testMeta = meta;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const testContext = new AsyncLocalStorage();
|
|
157
|
+
/**
|
|
158
|
+
* Get the current test's execution context.
|
|
159
|
+
* Returns undefined when not inside a test (e.g., module load phase).
|
|
160
|
+
*/
|
|
161
|
+
function currentTestCtx() {
|
|
162
|
+
return testContext.getStore();
|
|
163
|
+
}
|
|
164
|
+
// Accessor helpers — read from ALS when inside a test, safe defaults otherwise.
|
|
165
|
+
// These replace direct reads of the old global variables.
|
|
166
|
+
function getStepIndex() {
|
|
167
|
+
return currentTestCtx()?.currentStepIndex ?? null;
|
|
168
|
+
}
|
|
169
|
+
function getStepAssertionTotal() {
|
|
170
|
+
return currentTestCtx()?.stepAssertionTotal ?? 0;
|
|
171
|
+
}
|
|
172
|
+
function getStepFailedAssertions() {
|
|
173
|
+
return currentTestCtx()?.stepFailedAssertions ?? 0;
|
|
174
|
+
}
|
|
175
|
+
function incrAssertions(passed) {
|
|
176
|
+
const trc = currentTestCtx();
|
|
177
|
+
if (trc) {
|
|
178
|
+
trc.stepAssertionTotal++;
|
|
179
|
+
if (!passed)
|
|
180
|
+
trc.stepFailedAssertions++;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Emit a JSON event to stdout, auto-injecting `testId` when inside a test context.
|
|
185
|
+
* Use this for all test-scoped event output to ensure concurrent events can be
|
|
186
|
+
* attributed to the correct test.
|
|
187
|
+
*/
|
|
188
|
+
function emitEvent(event) {
|
|
189
|
+
const trc = currentTestCtx();
|
|
190
|
+
if (trc) {
|
|
191
|
+
console.log(JSON.stringify({ ...event, testId: trc.testId }));
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
console.log(JSON.stringify(event));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
154
197
|
/**
|
|
155
198
|
* Start monitoring memory usage.
|
|
156
199
|
* Samples memory every 100ms and tracks peak usage.
|
|
@@ -296,14 +339,14 @@ function runSchemaValidation(data, schema, label, severity) {
|
|
|
296
339
|
issues = [{ message: "Schema has neither safeParse nor parse method" }];
|
|
297
340
|
}
|
|
298
341
|
// Emit schema_validation event (always, regardless of success/severity)
|
|
299
|
-
|
|
342
|
+
emitEvent({
|
|
300
343
|
type: "schema_validation",
|
|
301
344
|
label,
|
|
302
345
|
success,
|
|
303
346
|
severity,
|
|
304
347
|
...(issues.length > 0 && { issues }),
|
|
305
|
-
...(
|
|
306
|
-
})
|
|
348
|
+
...(getStepIndex() !== null && { stepIndex: getStepIndex() }),
|
|
349
|
+
});
|
|
307
350
|
if (!success) {
|
|
308
351
|
const issuesSummary = issues
|
|
309
352
|
.map((i) => {
|
|
@@ -379,18 +422,18 @@ const ctx = {
|
|
|
379
422
|
},
|
|
380
423
|
set: (key, value) => {
|
|
381
424
|
sessionData[key] = value;
|
|
382
|
-
|
|
425
|
+
emitEvent({ type: "session:set", key, value, ts: Date.now() });
|
|
383
426
|
},
|
|
384
427
|
entries: () => ({ ...sessionData }),
|
|
385
428
|
},
|
|
386
429
|
// Logging function
|
|
387
430
|
log: (message, data) => {
|
|
388
|
-
|
|
431
|
+
emitEvent({
|
|
389
432
|
type: "log",
|
|
390
433
|
message,
|
|
391
434
|
data,
|
|
392
|
-
...(
|
|
393
|
-
})
|
|
435
|
+
...(getStepIndex() !== null && { stepIndex: getStepIndex() }),
|
|
436
|
+
});
|
|
394
437
|
},
|
|
395
438
|
// Assertion function with overloads
|
|
396
439
|
// Overload 1: assert(condition: boolean, message?: string, details?: AssertionDetails)
|
|
@@ -420,19 +463,16 @@ const ctx = {
|
|
|
420
463
|
(passed ? "Assertion passed" : "Assertion failed");
|
|
421
464
|
}
|
|
422
465
|
// Track per-step assertion stats (used for step retry logic + step_end event)
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
stepFailedAssertions++;
|
|
426
|
-
}
|
|
427
|
-
console.log(JSON.stringify({
|
|
466
|
+
incrAssertions(passed);
|
|
467
|
+
emitEvent({
|
|
428
468
|
type: "assertion",
|
|
429
469
|
passed,
|
|
430
470
|
message,
|
|
431
471
|
// Truncate actual/expected on pass to save tokens; keep full on fail for debugging
|
|
432
472
|
actual: passed && truncateArrays ? truncateDeep(actual) : actual,
|
|
433
473
|
expected: passed && truncateArrays ? truncateDeep(expected) : expected,
|
|
434
|
-
...(
|
|
435
|
-
})
|
|
474
|
+
...(getStepIndex() !== null && { stepIndex: getStepIndex() }),
|
|
475
|
+
});
|
|
436
476
|
},
|
|
437
477
|
// Fluent assertion API (Jest-style, soft-by-default)
|
|
438
478
|
expect: (actual) => {
|
|
@@ -448,12 +488,12 @@ const ctx = {
|
|
|
448
488
|
// Warning function — soft check, never affects test pass/fail.
|
|
449
489
|
// condition=true means OK; condition=false triggers warning.
|
|
450
490
|
warn: (condition, message) => {
|
|
451
|
-
|
|
491
|
+
emitEvent({
|
|
452
492
|
type: "warning",
|
|
453
493
|
condition,
|
|
454
494
|
message,
|
|
455
|
-
...(
|
|
456
|
-
})
|
|
495
|
+
...(getStepIndex() !== null && { stepIndex: getStepIndex() }),
|
|
496
|
+
});
|
|
457
497
|
},
|
|
458
498
|
// Schema validation function
|
|
459
499
|
validate: (data, schema, label, options) => {
|
|
@@ -462,11 +502,11 @@ const ctx = {
|
|
|
462
502
|
},
|
|
463
503
|
// API tracing function
|
|
464
504
|
trace: (request) => {
|
|
465
|
-
|
|
505
|
+
emitEvent({
|
|
466
506
|
type: "trace",
|
|
467
507
|
data: request,
|
|
468
|
-
...(
|
|
469
|
-
})
|
|
508
|
+
...(getStepIndex() !== null && { stepIndex: getStepIndex() }),
|
|
509
|
+
});
|
|
470
510
|
// Backward compat: also emit as a typed action for timeline/filtering
|
|
471
511
|
let pathname;
|
|
472
512
|
try {
|
|
@@ -485,30 +525,30 @@ const ctx = {
|
|
|
485
525
|
},
|
|
486
526
|
// Action recording function
|
|
487
527
|
action: (a) => {
|
|
488
|
-
|
|
528
|
+
emitEvent({
|
|
489
529
|
type: "action",
|
|
490
530
|
data: a,
|
|
491
|
-
...(
|
|
492
|
-
})
|
|
531
|
+
...(getStepIndex() !== null && { stepIndex: getStepIndex() }),
|
|
532
|
+
});
|
|
493
533
|
},
|
|
494
534
|
// Structured event emission
|
|
495
535
|
event: (ev) => {
|
|
496
|
-
|
|
536
|
+
emitEvent({
|
|
497
537
|
type: "event",
|
|
498
538
|
data: ev,
|
|
499
|
-
...(
|
|
500
|
-
})
|
|
539
|
+
...(getStepIndex() !== null && { stepIndex: getStepIndex() }),
|
|
540
|
+
});
|
|
501
541
|
},
|
|
502
542
|
// Metric reporting function
|
|
503
543
|
metric: (name, value, options) => {
|
|
504
|
-
|
|
544
|
+
emitEvent({
|
|
505
545
|
type: "metric",
|
|
506
546
|
name,
|
|
507
547
|
value,
|
|
508
548
|
unit: options?.unit,
|
|
509
549
|
tags: options?.tags,
|
|
510
|
-
...(
|
|
511
|
-
})
|
|
550
|
+
...(getStepIndex() !== null && { stepIndex: getStepIndex() }),
|
|
551
|
+
});
|
|
512
552
|
},
|
|
513
553
|
/**
|
|
514
554
|
* Skip the current test with an optional reason.
|
|
@@ -525,11 +565,11 @@ const ctx = {
|
|
|
525
565
|
*/
|
|
526
566
|
fail: (message) => {
|
|
527
567
|
// Emit a failed assertion so the failure reason appears in events
|
|
528
|
-
|
|
568
|
+
emitEvent({
|
|
529
569
|
type: "assertion",
|
|
530
570
|
passed: false,
|
|
531
571
|
message,
|
|
532
|
-
})
|
|
572
|
+
});
|
|
533
573
|
throw new FailError(message);
|
|
534
574
|
},
|
|
535
575
|
/**
|
|
@@ -570,10 +610,7 @@ const ctx = {
|
|
|
570
610
|
* @param ms Timeout in milliseconds
|
|
571
611
|
*/
|
|
572
612
|
setTimeout: (ms) => {
|
|
573
|
-
|
|
574
|
-
type: "timeout_update",
|
|
575
|
-
timeout: ms,
|
|
576
|
-
}));
|
|
613
|
+
emitEvent({ type: "timeout_update", timeout: ms });
|
|
577
614
|
},
|
|
578
615
|
/**
|
|
579
616
|
* Current execution retry count (0 for first attempt).
|
|
@@ -594,17 +631,7 @@ const ctx = {
|
|
|
594
631
|
return process.memoryUsage();
|
|
595
632
|
},
|
|
596
633
|
};
|
|
597
|
-
|
|
598
|
-
// Auto-tracing HTTP client (ctx.http) — powered by ky
|
|
599
|
-
// ---------------------------------------------------------------------------
|
|
600
|
-
// Track request start time. We use a simple variable instead of a WeakMap
|
|
601
|
-
// because ky may clone/recreate the Request object between beforeRequest and
|
|
602
|
-
// afterResponse hooks, breaking reference equality in a WeakMap.
|
|
603
|
-
let lastRequestStartTime = 0;
|
|
604
|
-
let httpRequestTotal = 0;
|
|
605
|
-
let httpErrorTotal = 0;
|
|
606
|
-
// Captured in beforeRequest when emitFullTrace is on
|
|
607
|
-
let lastRequestBody = undefined;
|
|
634
|
+
const requestTraceMap = new WeakMap();
|
|
608
635
|
/** Max serialized body size (chars) to include in trace events. */
|
|
609
636
|
const TRACE_BODY_MAX_SIZE = 1_048_576; // 1MB
|
|
610
637
|
/**
|
|
@@ -650,187 +677,39 @@ function truncateBody(body) {
|
|
|
650
677
|
* Reset per-test counters for file-level batch mode.
|
|
651
678
|
* Called before each test when running multiple tests in a single process.
|
|
652
679
|
*/
|
|
680
|
+
/**
|
|
681
|
+
* Reset per-process state between tests in batch mode.
|
|
682
|
+
* Per-test state is now handled by TestRunContext via AsyncLocalStorage —
|
|
683
|
+
* each test gets a fresh instance, so no reset is needed for those.
|
|
684
|
+
*/
|
|
653
685
|
function resetTestCounters() {
|
|
654
|
-
stepFailedAssertions = 0;
|
|
655
|
-
stepAssertionTotal = 0;
|
|
656
|
-
currentStepIndex = null;
|
|
657
|
-
httpRequestTotal = 0;
|
|
658
|
-
httpErrorTotal = 0;
|
|
659
686
|
peakMemoryBytes = 0;
|
|
660
687
|
}
|
|
661
|
-
const MAX_NETWORK_WARNINGS_PER_CODE = 3;
|
|
662
|
-
const networkWarningCounts = new Map();
|
|
663
|
-
let networkRequestCount = 0;
|
|
664
|
-
let networkInFlightCount = 0;
|
|
665
|
-
let networkResponseBytes = 0;
|
|
666
|
-
function emitNetworkWarning(code, message) {
|
|
667
|
-
const nextCount = (networkWarningCounts.get(code) ?? 0) + 1;
|
|
668
|
-
networkWarningCounts.set(code, nextCount);
|
|
669
|
-
if (nextCount <= MAX_NETWORK_WARNINGS_PER_CODE) {
|
|
670
|
-
ctx.warn(false, `[network_guard:${code}] ${message}`);
|
|
671
|
-
}
|
|
672
|
-
else if (nextCount === MAX_NETWORK_WARNINGS_PER_CODE + 1) {
|
|
673
|
-
ctx.warn(false, `[network_guard:${code}] further warnings suppressed`);
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
async function resolveHostIps(hostname) {
|
|
677
|
-
const dns = await import("node:dns/promises");
|
|
678
|
-
const ips = new Set();
|
|
679
|
-
try {
|
|
680
|
-
const aRecords = await dns.resolve4(hostname);
|
|
681
|
-
for (const ip of aRecords)
|
|
682
|
-
ips.add(ip);
|
|
683
|
-
}
|
|
684
|
-
catch {
|
|
685
|
-
// Ignore; try AAAA next.
|
|
686
|
-
}
|
|
687
|
-
try {
|
|
688
|
-
const aaaaRecords = await dns.resolve6(hostname);
|
|
689
|
-
for (const ip of aaaaRecords)
|
|
690
|
-
ips.add(ip);
|
|
691
|
-
}
|
|
692
|
-
catch {
|
|
693
|
-
// Ignore; caller decides behavior when no records are resolved.
|
|
694
|
-
}
|
|
695
|
-
return Array.from(ips);
|
|
696
|
-
}
|
|
697
|
-
function toRequestUrl(input) {
|
|
698
|
-
if (input instanceof Request)
|
|
699
|
-
return new URL(input.url);
|
|
700
|
-
if (input instanceof URL)
|
|
701
|
-
return input;
|
|
702
|
-
return new URL(input);
|
|
703
|
-
}
|
|
704
|
-
async function enforceNetworkPolicy(url) {
|
|
705
|
-
if (!networkPolicy)
|
|
706
|
-
return;
|
|
707
|
-
if (!isAllowedProtocol(url.protocol)) {
|
|
708
|
-
emitNetworkWarning("protocol_blocked", `Blocked protocol ${url.protocol} for ${url.href}`);
|
|
709
|
-
throw new Error(`Network policy blocked protocol ${url.protocol}. Only http/https are allowed.`);
|
|
710
|
-
}
|
|
711
|
-
const port = resolveUrlPort(url);
|
|
712
|
-
if (!isAllowedPort(port, networkPolicy.allowedPorts)) {
|
|
713
|
-
emitNetworkWarning("port_blocked", `Blocked port ${port} for ${url.href}`);
|
|
714
|
-
throw new Error(`Network policy blocked destination port ${port}.`);
|
|
715
|
-
}
|
|
716
|
-
const hostname = url.hostname.toLowerCase();
|
|
717
|
-
const hostnameReason = classifyHostnameBlockReason(hostname);
|
|
718
|
-
if (hostnameReason) {
|
|
719
|
-
emitNetworkWarning(hostnameReason, `Blocked hostname ${hostname} for ${url.href}`);
|
|
720
|
-
throw new Error(`Network policy blocked sensitive hostname ${hostname}.`);
|
|
721
|
-
}
|
|
722
|
-
if (isIpLiteral(hostname)) {
|
|
723
|
-
const ipReason = classifyIpBlockReason(hostname);
|
|
724
|
-
if (ipReason) {
|
|
725
|
-
emitNetworkWarning(ipReason, `Blocked destination IP ${hostname} for ${url.href}`);
|
|
726
|
-
throw new Error(`Network policy blocked destination IP ${hostname}.`);
|
|
727
|
-
}
|
|
728
|
-
return;
|
|
729
|
-
}
|
|
730
|
-
const resolvedIps = await resolveHostIps(hostname);
|
|
731
|
-
if (resolvedIps.length === 0) {
|
|
732
|
-
emitNetworkWarning("dns_resolution_failed", `Could not resolve ${hostname} for ${url.href}`);
|
|
733
|
-
throw new Error(`Network policy could not resolve host ${hostname}. Request denied.`);
|
|
734
|
-
}
|
|
735
|
-
for (const ip of resolvedIps) {
|
|
736
|
-
const ipReason = classifyIpBlockReason(ip);
|
|
737
|
-
if (ipReason) {
|
|
738
|
-
emitNetworkWarning(ipReason, `Blocked resolved IP ${ip} (${hostname}) for ${url.href}`);
|
|
739
|
-
throw new Error(`Network policy blocked resolved destination ${ip} for host ${hostname}.`);
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
const originalFetch = globalThis.fetch.bind(globalThis);
|
|
744
|
-
globalThis.fetch = async (input, init) => {
|
|
745
|
-
if (!networkPolicy) {
|
|
746
|
-
return originalFetch(input, init);
|
|
747
|
-
}
|
|
748
|
-
const requestUrl = toRequestUrl(input);
|
|
749
|
-
if (networkRequestCount >= networkPolicy.maxRequests) {
|
|
750
|
-
emitNetworkWarning("request_limit_exceeded", `Request limit exceeded (${networkPolicy.maxRequests})`);
|
|
751
|
-
throw new Error(`Network policy exceeded max outbound requests (${networkPolicy.maxRequests}).`);
|
|
752
|
-
}
|
|
753
|
-
if (networkInFlightCount >= networkPolicy.maxConcurrentRequests) {
|
|
754
|
-
emitNetworkWarning("concurrency_limit_exceeded", `In-flight request limit exceeded (${networkPolicy.maxConcurrentRequests})`);
|
|
755
|
-
throw new Error(`Network policy exceeded max concurrent outbound requests (${networkPolicy.maxConcurrentRequests}).`);
|
|
756
|
-
}
|
|
757
|
-
// Reserve counters before await to avoid TOCTOU races when user code issues
|
|
758
|
-
// concurrent requests in a single Promise.all frame.
|
|
759
|
-
networkRequestCount++;
|
|
760
|
-
networkInFlightCount++;
|
|
761
|
-
const timeoutController = new AbortController();
|
|
762
|
-
let timedOutByPolicy = false;
|
|
763
|
-
const parentSignal = (() => {
|
|
764
|
-
if (!init || typeof init !== "object" || !("signal" in init)) {
|
|
765
|
-
return undefined;
|
|
766
|
-
}
|
|
767
|
-
const candidate = init.signal;
|
|
768
|
-
return candidate instanceof AbortSignal ? candidate : undefined;
|
|
769
|
-
})();
|
|
770
|
-
const onParentAbort = () => timeoutController.abort(parentSignal?.reason);
|
|
771
|
-
if (parentSignal) {
|
|
772
|
-
if (parentSignal.aborted) {
|
|
773
|
-
timeoutController.abort(parentSignal.reason);
|
|
774
|
-
}
|
|
775
|
-
else {
|
|
776
|
-
parentSignal.addEventListener("abort", onParentAbort, { once: true });
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
const timeoutId = setTimeout(() => {
|
|
780
|
-
timedOutByPolicy = true;
|
|
781
|
-
timeoutController.abort(new Error(`Network request timed out after ${networkPolicy.requestTimeoutMs}ms`));
|
|
782
|
-
}, networkPolicy.requestTimeoutMs);
|
|
783
|
-
try {
|
|
784
|
-
await enforceNetworkPolicy(requestUrl);
|
|
785
|
-
const response = await originalFetch(input, {
|
|
786
|
-
...init,
|
|
787
|
-
signal: timeoutController.signal,
|
|
788
|
-
});
|
|
789
|
-
return applyResponseByteBudget(response, {
|
|
790
|
-
requestUrl,
|
|
791
|
-
maxResponseBytes: networkPolicy.maxResponseBytes,
|
|
792
|
-
getUsedResponseBytes: () => networkResponseBytes,
|
|
793
|
-
addUsedResponseBytes: (delta) => {
|
|
794
|
-
networkResponseBytes += delta;
|
|
795
|
-
},
|
|
796
|
-
emitWarning: emitNetworkWarning,
|
|
797
|
-
});
|
|
798
|
-
}
|
|
799
|
-
catch (error) {
|
|
800
|
-
if (error instanceof Error && error.name === "AbortError") {
|
|
801
|
-
if (timedOutByPolicy) {
|
|
802
|
-
throw new Error(`Network request timed out after ${networkPolicy.requestTimeoutMs}ms`);
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
throw error;
|
|
806
|
-
}
|
|
807
|
-
finally {
|
|
808
|
-
clearTimeout(timeoutId);
|
|
809
|
-
if (parentSignal) {
|
|
810
|
-
parentSignal.removeEventListener("abort", onParentAbort);
|
|
811
|
-
}
|
|
812
|
-
networkInFlightCount = Math.max(0, networkInFlightCount - 1);
|
|
813
|
-
}
|
|
814
|
-
};
|
|
815
688
|
const kyInstance = ky.create({
|
|
816
689
|
throwHttpErrors: false,
|
|
817
690
|
hooks: {
|
|
818
691
|
beforeRequest: [
|
|
819
692
|
(_request, options) => {
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
693
|
+
requestTraceMap.set(options, {
|
|
694
|
+
startTime: performance.now(),
|
|
695
|
+
body: emitFullTrace
|
|
696
|
+
? (options.json ?? options.body ?? undefined)
|
|
697
|
+
: undefined,
|
|
698
|
+
});
|
|
825
699
|
},
|
|
826
700
|
],
|
|
827
701
|
afterResponse: [
|
|
828
702
|
async (request, _options, response) => {
|
|
829
|
-
const
|
|
703
|
+
const trace = requestTraceMap.get(_options);
|
|
704
|
+
const duration = Math.round(performance.now() - (trace?.startTime ?? performance.now()));
|
|
830
705
|
// Increment HTTP counters for summary
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
706
|
+
{
|
|
707
|
+
const t = currentTestCtx();
|
|
708
|
+
if (t) {
|
|
709
|
+
t.httpRequestTotal++;
|
|
710
|
+
if (response.status >= 400)
|
|
711
|
+
t.httpErrorTotal++;
|
|
712
|
+
}
|
|
834
713
|
}
|
|
835
714
|
// Build trace data — enriched when emitFullTrace is on
|
|
836
715
|
const traceData = {
|
|
@@ -846,8 +725,8 @@ const kyInstance = ky.create({
|
|
|
846
725
|
}
|
|
847
726
|
if (emitFullTrace) {
|
|
848
727
|
traceData.requestHeaders = Object.fromEntries(request.headers.entries());
|
|
849
|
-
if (
|
|
850
|
-
traceData.requestBody = truncateBody(
|
|
728
|
+
if (trace?.body !== undefined) {
|
|
729
|
+
traceData.requestBody = truncateBody(trace.body);
|
|
851
730
|
}
|
|
852
731
|
traceData.responseHeaders = Object.fromEntries(response.headers.entries());
|
|
853
732
|
// Clone the response to read the body without consuming the original stream
|
|
@@ -880,7 +759,7 @@ const kyInstance = ky.create({
|
|
|
880
759
|
catch {
|
|
881
760
|
// Ignore clone/parse errors — trace still emits without body
|
|
882
761
|
}
|
|
883
|
-
|
|
762
|
+
// Per-request state is on the options object; no global cleanup needed.
|
|
884
763
|
}
|
|
885
764
|
ctx.trace(traceData);
|
|
886
765
|
// Auto-metric for response time
|
|
@@ -1028,7 +907,16 @@ globalThis.__glubeanRuntime = {
|
|
|
1028
907
|
vars: withEnvFallback(rawVars),
|
|
1029
908
|
secrets: withEnvFallback(rawSecrets),
|
|
1030
909
|
http: wrappedHttp,
|
|
1031
|
-
|
|
910
|
+
// Getter: returns per-test metadata from ALS when inside a test,
|
|
911
|
+
// falls back to the initial runtimeTest (module load phase).
|
|
912
|
+
get test() {
|
|
913
|
+
return currentTestCtx()?.testMeta ?? runtimeTest;
|
|
914
|
+
},
|
|
915
|
+
set test(value) {
|
|
916
|
+
const trc = currentTestCtx();
|
|
917
|
+
if (trc)
|
|
918
|
+
trc.testMeta = value;
|
|
919
|
+
},
|
|
1032
920
|
action: ctx.action,
|
|
1033
921
|
event: ctx.event,
|
|
1034
922
|
log: ctx.log,
|
|
@@ -1058,25 +946,30 @@ try {
|
|
|
1058
946
|
session: ctx.session,
|
|
1059
947
|
log: ctx.log,
|
|
1060
948
|
};
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
949
|
+
// Session setup/teardown needs an ALS context so network policy
|
|
950
|
+
// counters (in globalFetch override) work correctly.
|
|
951
|
+
const sessionTrc = new TestRunContext("__session__", { id: "__session__", tags: [] });
|
|
952
|
+
await testContext.run(sessionTrc, async () => {
|
|
953
|
+
try {
|
|
954
|
+
if (sessionMode === "setup") {
|
|
955
|
+
await def.setup(sessionCtx);
|
|
956
|
+
}
|
|
957
|
+
else if (sessionMode === "teardown" && typeof def.teardown === "function") {
|
|
958
|
+
await def.teardown(sessionCtx);
|
|
959
|
+
}
|
|
960
|
+
emitEvent({ type: "status", status: "completed" });
|
|
1064
961
|
}
|
|
1065
|
-
|
|
1066
|
-
|
|
962
|
+
catch (err) {
|
|
963
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
964
|
+
const stack = err instanceof Error ? err.stack : undefined;
|
|
965
|
+
emitEvent({
|
|
966
|
+
type: "status",
|
|
967
|
+
status: "failed",
|
|
968
|
+
error: message,
|
|
969
|
+
...(stack && { stack }),
|
|
970
|
+
});
|
|
1067
971
|
}
|
|
1068
|
-
|
|
1069
|
-
}
|
|
1070
|
-
catch (err) {
|
|
1071
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1072
|
-
const stack = err instanceof Error ? err.stack : undefined;
|
|
1073
|
-
console.log(JSON.stringify({
|
|
1074
|
-
type: "status",
|
|
1075
|
-
status: "failed",
|
|
1076
|
-
error: message,
|
|
1077
|
-
...(stack && { stack }),
|
|
1078
|
-
}));
|
|
1079
|
-
}
|
|
972
|
+
});
|
|
1080
973
|
process.exit(0);
|
|
1081
974
|
}
|
|
1082
975
|
// ── Normal test mode: testId or testIds required ──
|
|
@@ -1089,56 +982,51 @@ try {
|
|
|
1089
982
|
}
|
|
1090
983
|
if (testIds) {
|
|
1091
984
|
// ── File-level batch mode ──
|
|
1092
|
-
//
|
|
1093
|
-
//
|
|
985
|
+
// Runs multiple tests in a single process, preserving module-level state.
|
|
986
|
+
// When batchConcurrency > 1, tests run concurrently via p-queue
|
|
987
|
+
// (opt-in via test.each({ parallel: true }) + --concurrency flag).
|
|
1094
988
|
let hasFailure = false;
|
|
1095
|
-
|
|
989
|
+
const runOneTest = async (id) => {
|
|
1096
990
|
resetTestCounters();
|
|
1097
991
|
let testObj = findTestById(userModule, id);
|
|
1098
|
-
// Fallback for non-deterministic tests (test.pick): the testId from
|
|
1099
|
-
// discovery may differ from this run's random selection. Use the stable
|
|
1100
|
-
// exportName to locate the test.
|
|
1101
992
|
if (!testObj && exportNamesMap[id]) {
|
|
1102
993
|
testObj = findTestByExport(userModule, exportNamesMap[id]);
|
|
1103
994
|
}
|
|
1104
995
|
if (!testObj) {
|
|
1105
|
-
console.log(JSON.stringify({
|
|
1106
|
-
|
|
1107
|
-
id,
|
|
1108
|
-
name: id,
|
|
1109
|
-
}));
|
|
1110
|
-
console.log(JSON.stringify({
|
|
1111
|
-
type: "status",
|
|
1112
|
-
status: "failed",
|
|
1113
|
-
id,
|
|
1114
|
-
error: `Test "${id}" not found in module`,
|
|
1115
|
-
}));
|
|
996
|
+
console.log(JSON.stringify({ type: "start", id, name: id, testId: id }));
|
|
997
|
+
console.log(JSON.stringify({ type: "status", status: "failed", id, testId: id, error: `Test "${id}" not found in module` }));
|
|
1116
998
|
hasFailure = true;
|
|
1117
|
-
|
|
999
|
+
return;
|
|
1118
1000
|
}
|
|
1119
1001
|
try {
|
|
1120
1002
|
await executeNewTest(testObj);
|
|
1121
1003
|
}
|
|
1122
1004
|
catch (error) {
|
|
1123
1005
|
if (error instanceof SkipError) {
|
|
1124
|
-
console.log(JSON.stringify({
|
|
1125
|
-
type: "status",
|
|
1126
|
-
status: "skipped",
|
|
1127
|
-
id,
|
|
1128
|
-
reason: error.reason,
|
|
1129
|
-
}));
|
|
1006
|
+
console.log(JSON.stringify({ type: "status", status: "skipped", id, testId: id, reason: error.reason }));
|
|
1130
1007
|
}
|
|
1131
1008
|
else {
|
|
1132
1009
|
hasFailure = true;
|
|
1133
1010
|
console.log(JSON.stringify({
|
|
1134
|
-
type: "status",
|
|
1135
|
-
status: "failed",
|
|
1136
|
-
id,
|
|
1011
|
+
type: "status", status: "failed", id, testId: id,
|
|
1137
1012
|
error: error instanceof Error ? error.message : String(error),
|
|
1138
1013
|
stack: error instanceof Error ? error.stack : undefined,
|
|
1139
1014
|
}));
|
|
1140
1015
|
}
|
|
1141
1016
|
}
|
|
1017
|
+
};
|
|
1018
|
+
if (batchConcurrency > 1) {
|
|
1019
|
+
const { default: PQueue } = await import("p-queue");
|
|
1020
|
+
const queue = new PQueue({ concurrency: batchConcurrency });
|
|
1021
|
+
for (const id of testIds) {
|
|
1022
|
+
void queue.add(() => runOneTest(id));
|
|
1023
|
+
}
|
|
1024
|
+
await queue.onIdle();
|
|
1025
|
+
}
|
|
1026
|
+
else {
|
|
1027
|
+
for (const id of testIds) {
|
|
1028
|
+
await runOneTest(id);
|
|
1029
|
+
}
|
|
1142
1030
|
}
|
|
1143
1031
|
process.exit(hasFailure ? 1 : 0);
|
|
1144
1032
|
}
|
|
@@ -1159,11 +1047,11 @@ try {
|
|
|
1159
1047
|
}
|
|
1160
1048
|
catch (error) {
|
|
1161
1049
|
if (error instanceof SkipError) {
|
|
1162
|
-
console.log(JSON.stringify({ type: "status", status: "skipped", id: resolved.id, reason: error.reason }));
|
|
1050
|
+
console.log(JSON.stringify({ type: "status", status: "skipped", id: resolved.id, testId: resolved.id, reason: error.reason }));
|
|
1163
1051
|
}
|
|
1164
1052
|
else {
|
|
1165
1053
|
hasFailure = true;
|
|
1166
|
-
console.log(JSON.stringify({ type: "status", status: "failed", id: resolved.id, error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined }));
|
|
1054
|
+
console.log(JSON.stringify({ type: "status", status: "failed", id: resolved.id, testId: resolved.id, error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined }));
|
|
1167
1055
|
}
|
|
1168
1056
|
}
|
|
1169
1057
|
}
|
|
@@ -1185,11 +1073,14 @@ try {
|
|
|
1185
1073
|
}
|
|
1186
1074
|
}
|
|
1187
1075
|
catch (error) {
|
|
1076
|
+
// Include testId when known (single test mode)
|
|
1077
|
+
const tid = testId && testId !== "*" ? testId : undefined;
|
|
1188
1078
|
// Check if this is a skip error
|
|
1189
1079
|
if (error instanceof SkipError) {
|
|
1190
1080
|
console.log(JSON.stringify({
|
|
1191
1081
|
type: "status",
|
|
1192
1082
|
status: "skipped",
|
|
1083
|
+
...(tid && { testId: tid }),
|
|
1193
1084
|
reason: error.reason,
|
|
1194
1085
|
}));
|
|
1195
1086
|
process.exit(0); // Exit cleanly for skipped tests
|
|
@@ -1198,6 +1089,7 @@ catch (error) {
|
|
|
1198
1089
|
console.log(JSON.stringify({
|
|
1199
1090
|
type: "status",
|
|
1200
1091
|
status: "failed",
|
|
1092
|
+
...(tid && { testId: tid }),
|
|
1201
1093
|
error: error instanceof Error ? error.message : String(error),
|
|
1202
1094
|
stack: error instanceof Error ? error.stack : undefined,
|
|
1203
1095
|
}));
|
|
@@ -1274,222 +1166,231 @@ async function withFixtures(fixtures, baseCtx, runTest) {
|
|
|
1274
1166
|
*/
|
|
1275
1167
|
async function executeNewTest(test) {
|
|
1276
1168
|
const testTags = normalizeTestTags(test.meta.tags);
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
}
|
|
1300
|
-
else {
|
|
1301
|
-
let state = undefined;
|
|
1302
|
-
let stepFailed = false;
|
|
1303
|
-
try {
|
|
1304
|
-
if (test.setup) {
|
|
1305
|
-
console.log(JSON.stringify({
|
|
1306
|
-
type: "log",
|
|
1307
|
-
message: "Running setup...",
|
|
1308
|
-
}));
|
|
1309
|
-
state = await test.setup(effectiveCtx);
|
|
1169
|
+
const testMeta = { id: test.meta.id, tags: testTags };
|
|
1170
|
+
const trc = new TestRunContext(test.meta.id, testMeta);
|
|
1171
|
+
// Run the test body inside an AsyncLocalStorage context so all
|
|
1172
|
+
// downstream code (ctx.assert, ky hooks, globalFetch) can access
|
|
1173
|
+
// the per-test state via currentTestCtx().
|
|
1174
|
+
await testContext.run(trc, async () => {
|
|
1175
|
+
// Runtime metadata is now served via ALS getter on __glubeanRuntime.test
|
|
1176
|
+
emitEvent({
|
|
1177
|
+
type: "start",
|
|
1178
|
+
id: test.meta.id,
|
|
1179
|
+
name: test.meta.name || test.meta.id,
|
|
1180
|
+
tags: testTags,
|
|
1181
|
+
...(retryCount > 0 && { retryCount }),
|
|
1182
|
+
});
|
|
1183
|
+
// Start memory monitoring
|
|
1184
|
+
startMemoryMonitoring();
|
|
1185
|
+
try {
|
|
1186
|
+
// Core test body — receives the effective ctx (base or fixture-augmented)
|
|
1187
|
+
const runTestBody = async (effectiveCtx) => {
|
|
1188
|
+
if (test.type === "simple") {
|
|
1189
|
+
if (!test.fn) {
|
|
1190
|
+
throw new Error(`Invalid test "${test.meta.id}": missing fn`);
|
|
1310
1191
|
}
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1192
|
+
await test.fn(effectiveCtx);
|
|
1193
|
+
}
|
|
1194
|
+
else {
|
|
1195
|
+
let state = undefined;
|
|
1196
|
+
let stepFailed = false;
|
|
1197
|
+
try {
|
|
1198
|
+
if (test.setup) {
|
|
1199
|
+
emitEvent({
|
|
1200
|
+
type: "log",
|
|
1201
|
+
message: "Running setup...",
|
|
1202
|
+
});
|
|
1203
|
+
state = await test.setup(effectiveCtx);
|
|
1204
|
+
}
|
|
1205
|
+
if (test.steps) {
|
|
1206
|
+
for (let i = 0; i < test.steps.length; i++) {
|
|
1207
|
+
const step = test.steps[i];
|
|
1208
|
+
// If a previous step failed, skip remaining steps
|
|
1209
|
+
if (stepFailed) {
|
|
1210
|
+
emitEvent({
|
|
1211
|
+
type: "step_end",
|
|
1212
|
+
index: i,
|
|
1213
|
+
name: step.meta.name,
|
|
1214
|
+
status: "skipped",
|
|
1215
|
+
durationMs: 0,
|
|
1216
|
+
assertions: 0,
|
|
1217
|
+
failedAssertions: 0,
|
|
1218
|
+
});
|
|
1219
|
+
continue;
|
|
1220
|
+
}
|
|
1221
|
+
// Reset per-step assertion counters and set step scope
|
|
1222
|
+
{
|
|
1223
|
+
const trc = currentTestCtx();
|
|
1224
|
+
trc.stepFailedAssertions = 0;
|
|
1225
|
+
trc.stepAssertionTotal = 0;
|
|
1226
|
+
trc.currentStepIndex = i;
|
|
1227
|
+
}
|
|
1228
|
+
const stepStart = performance.now();
|
|
1229
|
+
emitEvent({
|
|
1230
|
+
type: "step_start",
|
|
1318
1231
|
index: i,
|
|
1319
1232
|
name: step.meta.name,
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
? Math.floor(stepTimeout)
|
|
1347
|
-
: 0;
|
|
1348
|
-
const stepTimeoutMs = configuredStepTimeout > 0 ? configuredStepTimeout : undefined;
|
|
1349
|
-
const maxAttempts = configuredRetries + 1;
|
|
1350
|
-
let attemptsUsed = 0;
|
|
1351
|
-
let lastFailedAssertions = 0;
|
|
1352
|
-
let lastAssertions = 0;
|
|
1353
|
-
let timeoutFailure = false;
|
|
1354
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1355
|
-
attemptsUsed = attempt;
|
|
1356
|
-
stepError = undefined;
|
|
1357
|
-
stepReturnState = undefined;
|
|
1358
|
-
stepFailedAssertions = 0;
|
|
1359
|
-
stepAssertionTotal = 0;
|
|
1360
|
-
timeoutFailure = false;
|
|
1361
|
-
let stepTimeoutId;
|
|
1362
|
-
try {
|
|
1363
|
-
const stepResult = step.fn(effectiveCtx, state);
|
|
1364
|
-
// Note: timed-out step bodies cannot be force-cancelled in JS.
|
|
1365
|
-
// We treat timeout as terminal (no further retries) to avoid
|
|
1366
|
-
// overlapping attempts mutating shared step context.
|
|
1367
|
-
const result = stepTimeoutMs === undefined ? await stepResult : await Promise.race([
|
|
1368
|
-
stepResult,
|
|
1369
|
-
new Promise((_, reject) => {
|
|
1370
|
-
stepTimeoutId = setTimeout(() => {
|
|
1371
|
-
reject(new StepTimeoutError(step.meta.name, stepTimeoutMs));
|
|
1372
|
-
}, stepTimeoutMs);
|
|
1373
|
-
}),
|
|
1374
|
-
]);
|
|
1375
|
-
if (result !== undefined) {
|
|
1376
|
-
state = result;
|
|
1377
|
-
stepReturnState = result;
|
|
1233
|
+
total: test.steps.length,
|
|
1234
|
+
});
|
|
1235
|
+
let stepError;
|
|
1236
|
+
let stepReturnState = undefined;
|
|
1237
|
+
const retries = step.meta.retries;
|
|
1238
|
+
const configuredRetries = typeof retries === "number" && Number.isFinite(retries)
|
|
1239
|
+
? Math.max(0, Math.floor(retries))
|
|
1240
|
+
: 0;
|
|
1241
|
+
const stepTimeout = step.meta.timeout;
|
|
1242
|
+
const configuredStepTimeout = typeof stepTimeout === "number" && Number.isFinite(stepTimeout)
|
|
1243
|
+
? Math.floor(stepTimeout)
|
|
1244
|
+
: 0;
|
|
1245
|
+
const stepTimeoutMs = configuredStepTimeout > 0 ? configuredStepTimeout : undefined;
|
|
1246
|
+
const maxAttempts = configuredRetries + 1;
|
|
1247
|
+
let attemptsUsed = 0;
|
|
1248
|
+
let lastFailedAssertions = 0;
|
|
1249
|
+
let lastAssertions = 0;
|
|
1250
|
+
let timeoutFailure = false;
|
|
1251
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1252
|
+
attemptsUsed = attempt;
|
|
1253
|
+
stepError = undefined;
|
|
1254
|
+
stepReturnState = undefined;
|
|
1255
|
+
{
|
|
1256
|
+
const trc = currentTestCtx();
|
|
1257
|
+
trc.stepFailedAssertions = 0;
|
|
1258
|
+
trc.stepAssertionTotal = 0;
|
|
1378
1259
|
}
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1260
|
+
timeoutFailure = false;
|
|
1261
|
+
let stepTimeoutId;
|
|
1262
|
+
try {
|
|
1263
|
+
const stepResult = step.fn(effectiveCtx, state);
|
|
1264
|
+
// Note: timed-out step bodies cannot be force-cancelled in JS.
|
|
1265
|
+
// We treat timeout as terminal (no further retries) to avoid
|
|
1266
|
+
// overlapping attempts mutating shared step context.
|
|
1267
|
+
const result = stepTimeoutMs === undefined ? await stepResult : await Promise.race([
|
|
1268
|
+
stepResult,
|
|
1269
|
+
new Promise((_, reject) => {
|
|
1270
|
+
stepTimeoutId = setTimeout(() => {
|
|
1271
|
+
reject(new StepTimeoutError(step.meta.name, stepTimeoutMs));
|
|
1272
|
+
}, stepTimeoutMs);
|
|
1273
|
+
}),
|
|
1274
|
+
]);
|
|
1275
|
+
if (result !== undefined) {
|
|
1276
|
+
state = result;
|
|
1277
|
+
stepReturnState = result;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
catch (err) {
|
|
1281
|
+
stepError = err instanceof Error ? err.message : String(err);
|
|
1282
|
+
timeoutFailure = err instanceof StepTimeoutError;
|
|
1283
|
+
}
|
|
1284
|
+
finally {
|
|
1285
|
+
if (stepTimeoutId !== undefined) {
|
|
1286
|
+
clearTimeout(stepTimeoutId);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
lastFailedAssertions = getStepFailedAssertions();
|
|
1290
|
+
lastAssertions = getStepAssertionTotal();
|
|
1291
|
+
const attemptFailed = !!stepError || getStepFailedAssertions() > 0;
|
|
1292
|
+
if (!attemptFailed) {
|
|
1293
|
+
break;
|
|
1294
|
+
}
|
|
1295
|
+
// Timeouts are terminal to avoid overlapping attempts from
|
|
1296
|
+
// dangling async operations in the timed-out step body.
|
|
1297
|
+
if (timeoutFailure) {
|
|
1298
|
+
break;
|
|
1299
|
+
}
|
|
1300
|
+
if (attempt < maxAttempts) {
|
|
1301
|
+
const reason = stepError ? stepError : `${getStepFailedAssertions()} failed assertion(s)`;
|
|
1302
|
+
emitEvent({
|
|
1303
|
+
type: "log",
|
|
1304
|
+
stepIndex: i,
|
|
1305
|
+
message: `Retrying step "${step.meta.name}" (${attempt + 1}/${maxAttempts}) after failure: ${reason}`,
|
|
1306
|
+
});
|
|
1387
1307
|
}
|
|
1388
1308
|
}
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
console.log(JSON.stringify({
|
|
1403
|
-
type: "log",
|
|
1404
|
-
stepIndex: i,
|
|
1405
|
-
message: `Retrying step "${step.meta.name}" (${attempt + 1}/${maxAttempts}) after failure: ${reason}`,
|
|
1406
|
-
}));
|
|
1407
|
-
}
|
|
1408
|
-
}
|
|
1409
|
-
const durationMs = Math.round(performance.now() - stepStart);
|
|
1410
|
-
const failed = !!stepError || lastFailedAssertions > 0;
|
|
1411
|
-
// Serialize return state with size guard (max 4 KB)
|
|
1412
|
-
let returnStatePayload = undefined;
|
|
1413
|
-
if (stepReturnState !== undefined) {
|
|
1414
|
-
try {
|
|
1415
|
-
const serialized = JSON.stringify(stepReturnState);
|
|
1416
|
-
if (serialized.length <= 4096) {
|
|
1417
|
-
returnStatePayload = stepReturnState;
|
|
1309
|
+
const durationMs = Math.round(performance.now() - stepStart);
|
|
1310
|
+
const failed = !!stepError || lastFailedAssertions > 0;
|
|
1311
|
+
// Serialize return state with size guard (max 4 KB)
|
|
1312
|
+
let returnStatePayload = undefined;
|
|
1313
|
+
if (stepReturnState !== undefined) {
|
|
1314
|
+
try {
|
|
1315
|
+
const serialized = JSON.stringify(stepReturnState);
|
|
1316
|
+
if (serialized.length <= 4096) {
|
|
1317
|
+
returnStatePayload = stepReturnState;
|
|
1318
|
+
}
|
|
1319
|
+
else {
|
|
1320
|
+
returnStatePayload = `[truncated: ${serialized.length} bytes]`;
|
|
1321
|
+
}
|
|
1418
1322
|
}
|
|
1419
|
-
|
|
1420
|
-
returnStatePayload =
|
|
1323
|
+
catch {
|
|
1324
|
+
returnStatePayload = "[non-serializable]";
|
|
1421
1325
|
}
|
|
1422
1326
|
}
|
|
1423
|
-
|
|
1424
|
-
|
|
1327
|
+
emitEvent({
|
|
1328
|
+
type: "step_end",
|
|
1329
|
+
index: i,
|
|
1330
|
+
name: step.meta.name,
|
|
1331
|
+
status: failed ? "failed" : "passed",
|
|
1332
|
+
durationMs,
|
|
1333
|
+
assertions: lastAssertions,
|
|
1334
|
+
failedAssertions: lastFailedAssertions,
|
|
1335
|
+
attempts: attemptsUsed,
|
|
1336
|
+
retriesUsed: Math.max(0, attemptsUsed - 1),
|
|
1337
|
+
...(stepError && { error: stepError }),
|
|
1338
|
+
...(returnStatePayload !== undefined && {
|
|
1339
|
+
returnState: returnStatePayload,
|
|
1340
|
+
}),
|
|
1341
|
+
});
|
|
1342
|
+
currentTestCtx().currentStepIndex = null;
|
|
1343
|
+
if (failed) {
|
|
1344
|
+
stepFailed = true;
|
|
1345
|
+
// Don't throw here — let the loop continue to emit skip events
|
|
1425
1346
|
}
|
|
1426
1347
|
}
|
|
1427
|
-
console.log(JSON.stringify({
|
|
1428
|
-
type: "step_end",
|
|
1429
|
-
index: i,
|
|
1430
|
-
name: step.meta.name,
|
|
1431
|
-
status: failed ? "failed" : "passed",
|
|
1432
|
-
durationMs,
|
|
1433
|
-
assertions: lastAssertions,
|
|
1434
|
-
failedAssertions: lastFailedAssertions,
|
|
1435
|
-
attempts: attemptsUsed,
|
|
1436
|
-
retriesUsed: Math.max(0, attemptsUsed - 1),
|
|
1437
|
-
...(stepError && { error: stepError }),
|
|
1438
|
-
...(returnStatePayload !== undefined && {
|
|
1439
|
-
returnState: returnStatePayload,
|
|
1440
|
-
}),
|
|
1441
|
-
}));
|
|
1442
|
-
currentStepIndex = null;
|
|
1443
|
-
if (failed) {
|
|
1444
|
-
stepFailed = true;
|
|
1445
|
-
// Don't throw here — let the loop continue to emit skip events
|
|
1446
|
-
}
|
|
1447
1348
|
}
|
|
1448
1349
|
}
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
}
|
|
1350
|
+
finally {
|
|
1351
|
+
if (test.teardown) {
|
|
1352
|
+
try {
|
|
1353
|
+
emitEvent({
|
|
1354
|
+
type: "log",
|
|
1355
|
+
message: "Running teardown...",
|
|
1356
|
+
});
|
|
1357
|
+
await test.teardown(effectiveCtx, state);
|
|
1358
|
+
}
|
|
1359
|
+
catch (teardownError) {
|
|
1360
|
+
emitEvent({
|
|
1361
|
+
type: "log",
|
|
1362
|
+
message: `Teardown error: ${teardownError instanceof Error ? teardownError.message : String(teardownError)}`,
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1464
1365
|
}
|
|
1465
1366
|
}
|
|
1367
|
+
// If any step failed (assertion or throw), mark overall test as failed
|
|
1368
|
+
if (stepFailed) {
|
|
1369
|
+
throw new Error("One or more steps failed");
|
|
1370
|
+
}
|
|
1466
1371
|
}
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1372
|
+
};
|
|
1373
|
+
// Resolve test.extend() fixtures (if any) and run the test body
|
|
1374
|
+
if (test.fixtures && Object.keys(test.fixtures).length > 0) {
|
|
1375
|
+
await withFixtures(test.fixtures, ctx, runTestBody);
|
|
1471
1376
|
}
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1377
|
+
else {
|
|
1378
|
+
await runTestBody(ctx);
|
|
1379
|
+
}
|
|
1380
|
+
// Stop monitoring and get peak memory
|
|
1381
|
+
const peakBytes = stopMemoryMonitoring();
|
|
1382
|
+
emitEvent({
|
|
1383
|
+
type: "status",
|
|
1384
|
+
status: "completed",
|
|
1385
|
+
id: test.meta.id,
|
|
1386
|
+
peakMemoryBytes: peakBytes,
|
|
1387
|
+
peakMemoryMB: (peakBytes / 1024 / 1024).toFixed(2),
|
|
1388
|
+
});
|
|
1476
1389
|
}
|
|
1477
|
-
|
|
1478
|
-
|
|
1390
|
+
catch (error) {
|
|
1391
|
+
stopMemoryMonitoring();
|
|
1392
|
+
throw error;
|
|
1479
1393
|
}
|
|
1480
|
-
|
|
1481
|
-
const peakBytes = stopMemoryMonitoring();
|
|
1482
|
-
console.log(JSON.stringify({
|
|
1483
|
-
type: "status",
|
|
1484
|
-
status: "completed",
|
|
1485
|
-
id: test.meta.id,
|
|
1486
|
-
peakMemoryBytes: peakBytes,
|
|
1487
|
-
peakMemoryMB: (peakBytes / 1024 / 1024).toFixed(2),
|
|
1488
|
-
}));
|
|
1489
|
-
}
|
|
1490
|
-
catch (error) {
|
|
1491
|
-
stopMemoryMonitoring();
|
|
1492
|
-
throw error;
|
|
1493
|
-
}
|
|
1394
|
+
}); // end testContext.run()
|
|
1494
1395
|
}
|
|
1495
1396
|
//# sourceMappingURL=harness.js.map
|