@feasibleone/blong-chain 1.6.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/dist/examples/demo.test.js.map +1 -1
- package/dist/examples/error-demo.test.js.map +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +206 -18
- package/dist/index.js.map +1 -1
- package/dist/index.test.js.map +1 -1
- package/dist/package.json +3 -1
- package/dist/showcase.test.js.map +1 -1
- package/dist/snapshot-context.test.d.ts +30 -0
- package/dist/snapshot-context.test.d.ts.map +1 -0
- package/dist/snapshot-context.test.js.map +1 -0
- package/dist/test-types.d.ts +76 -3
- package/dist/test-types.d.ts.map +1 -1
- package/dist/test-types.js +0 -3
- package/dist/test-types.js.map +1 -1
- package/dist/timing-analysis.test.js.map +1 -1
- package/index.ts +229 -44
- package/package.json +5 -4
- package/test-types.ts +60 -3
package/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import type {
|
|
|
25
25
|
ITestContext,
|
|
26
26
|
ITestEvents,
|
|
27
27
|
ITestExecutorConfig,
|
|
28
|
+
ITestFrameworkContext,
|
|
28
29
|
ITestLatency,
|
|
29
30
|
ITestLogger,
|
|
30
31
|
ITestProgress,
|
|
@@ -49,16 +50,16 @@ function createThenableProxy<T = unknown>(
|
|
|
49
50
|
const promiseEntry = promiseManager.getOrCreate(path);
|
|
50
51
|
|
|
51
52
|
// Create a proxy that intercepts property access
|
|
52
|
-
const proxy = new Proxy(promiseEntry.promise
|
|
53
|
-
get(target: Promise<T>, prop: string | symbol)
|
|
53
|
+
const proxy = new Proxy(promiseEntry.promise, {
|
|
54
|
+
get(target: Promise<T> & Record<symbol, unknown>, prop: string | symbol) {
|
|
54
55
|
// Promise methods: delegate to the real promise
|
|
55
56
|
if (prop === 'then' || prop === 'catch' || prop === 'finally') {
|
|
56
|
-
return
|
|
57
|
+
return target[prop].bind(target);
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
// Symbol properties (like Symbol.toStringTag)
|
|
60
61
|
if (typeof prop === 'symbol') {
|
|
61
|
-
return
|
|
62
|
+
return target[prop];
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
// Property access: return nested thenable proxy
|
|
@@ -161,12 +162,12 @@ class PromiseManager {
|
|
|
161
162
|
/**
|
|
162
163
|
* Gets nested value from an object by path
|
|
163
164
|
*/
|
|
164
|
-
private _getNestedValue(obj:
|
|
165
|
+
private _getNestedValue(obj: unknown, path: string): unknown {
|
|
165
166
|
const parts = path.split('.');
|
|
166
167
|
let current = obj;
|
|
167
168
|
for (const part of parts) {
|
|
168
169
|
if (current && typeof current === 'object') {
|
|
169
|
-
current = current[part];
|
|
170
|
+
current = (current as Record<string, unknown>)[part];
|
|
170
171
|
} else {
|
|
171
172
|
return undefined;
|
|
172
173
|
}
|
|
@@ -199,11 +200,11 @@ class PromiseManager {
|
|
|
199
200
|
/**
|
|
200
201
|
* Recursively resolves promises for nested properties
|
|
201
202
|
*/
|
|
202
|
-
private _resolveNestedProperties(basePath: string, obj:
|
|
203
|
+
private _resolveNestedProperties(basePath: string, obj: unknown, depth = 0): void {
|
|
203
204
|
// Limit recursion depth to avoid infinite loops
|
|
204
205
|
if (depth > 10) return;
|
|
205
206
|
|
|
206
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
207
|
+
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
|
207
208
|
const nestedPath = `${basePath}.${key}`;
|
|
208
209
|
|
|
209
210
|
if (this.promises.has(nestedPath)) {
|
|
@@ -240,7 +241,7 @@ function createContextProxy(
|
|
|
240
241
|
dependencyTracker: DependencyTracker,
|
|
241
242
|
): ITestContext {
|
|
242
243
|
return new Proxy(realContext as ITestContext, {
|
|
243
|
-
get(target:
|
|
244
|
+
get(target: ITestContext & Record<symbol, unknown>, prop: string | symbol) {
|
|
244
245
|
// Special case: $meta is always available directly
|
|
245
246
|
if (prop === '$meta') {
|
|
246
247
|
return target.$meta;
|
|
@@ -325,7 +326,7 @@ class DependencyTracker {
|
|
|
325
326
|
/**
|
|
326
327
|
* Captures source location information for error reporting
|
|
327
328
|
*/
|
|
328
|
-
function captureSourceLocation(
|
|
329
|
+
function captureSourceLocation(): ISourceLocation {
|
|
329
330
|
try {
|
|
330
331
|
const stack = new Error().stack || '';
|
|
331
332
|
const lines = stack.split('\n');
|
|
@@ -355,7 +356,7 @@ function captureSourceLocation(fn: Function): ISourceLocation {
|
|
|
355
356
|
}
|
|
356
357
|
}
|
|
357
358
|
}
|
|
358
|
-
} catch
|
|
359
|
+
} catch {
|
|
359
360
|
// If parsing fails, return unknown location
|
|
360
361
|
}
|
|
361
362
|
|
|
@@ -369,6 +370,42 @@ function captureSourceLocation(fn: Function): ISourceLocation {
|
|
|
369
370
|
/** Default number of retry attempts per failing step when `rerun.enabled` is true */
|
|
370
371
|
const DEFAULT_MAX_RETRIES = 1;
|
|
371
372
|
|
|
373
|
+
// ============================================================================
|
|
374
|
+
// Masking helpers (also used by assert.snapshot and checkpoint snapshots)
|
|
375
|
+
// ============================================================================
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Deep-clone `value` and replace the leaf at each dot-path in `paths` with
|
|
379
|
+
* `'<masked>'`. Supports `'*'` as a wildcard in any path segment, meaning
|
|
380
|
+
* "apply to every direct child of the current object".
|
|
381
|
+
*
|
|
382
|
+
* Examples:
|
|
383
|
+
* maskPaths({id: '1', name: 'A'}, ['id'])
|
|
384
|
+
* → {id: '<masked>', name: 'A'}
|
|
385
|
+
* maskPaths({a: {id: '1'}, b: {id: '2'}}, ['*.id'])
|
|
386
|
+
* → {a: {id: '<masked>'}, b: {id: '<masked>'}}
|
|
387
|
+
*/
|
|
388
|
+
function maskPaths(value: unknown, paths: string[]): unknown {
|
|
389
|
+
if (value === null || value === undefined || typeof value !== 'object') return value;
|
|
390
|
+
const clone = JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
|
|
391
|
+
for (const path of paths) setAtPath(clone, path.split('.'));
|
|
392
|
+
return clone;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function setAtPath(obj: unknown, parts: string[]): void {
|
|
396
|
+
if (typeof obj !== 'object' || obj === null || parts.length === 0) return;
|
|
397
|
+
const [head, ...tail] = parts;
|
|
398
|
+
if (head === '__proto__' || head === 'constructor' || head === 'prototype') return;
|
|
399
|
+
const record = obj as Record<string, unknown>;
|
|
400
|
+
if (tail.length === 0) {
|
|
401
|
+
if (Object.prototype.hasOwnProperty.call(record, head)) record[head] = '<masked>';
|
|
402
|
+
} else if (head === '*') {
|
|
403
|
+
for (const key of Object.keys(record)) setAtPath(record[key], tail);
|
|
404
|
+
} else {
|
|
405
|
+
setAtPath(record[head], tail);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
372
409
|
/**
|
|
373
410
|
* Main test executor class
|
|
374
411
|
*/
|
|
@@ -406,7 +443,7 @@ export class TestExecutor extends EventEmitter {
|
|
|
406
443
|
private promiseManager: PromiseManager;
|
|
407
444
|
|
|
408
445
|
// Test framework context for nested test output
|
|
409
|
-
private testContext?:
|
|
446
|
+
private testContext?: ITestFrameworkContext;
|
|
410
447
|
|
|
411
448
|
// Track step names to detect duplicates
|
|
412
449
|
private stepNamesUsed = new Set<string>();
|
|
@@ -420,6 +457,9 @@ export class TestExecutor extends EventEmitter {
|
|
|
420
457
|
framework: config.framework,
|
|
421
458
|
log: config.log,
|
|
422
459
|
rerun: config.rerun,
|
|
460
|
+
mask: config.mask,
|
|
461
|
+
maskFn: config.maskFn,
|
|
462
|
+
autoSnapshot: config.autoSnapshot,
|
|
423
463
|
};
|
|
424
464
|
this.log = config.log;
|
|
425
465
|
|
|
@@ -435,7 +475,7 @@ export class TestExecutor extends EventEmitter {
|
|
|
435
475
|
async execute(
|
|
436
476
|
steps: StepArray,
|
|
437
477
|
$meta: IMeta,
|
|
438
|
-
testContext?:
|
|
478
|
+
testContext?: ITestFrameworkContext,
|
|
439
479
|
): Promise<void> {
|
|
440
480
|
// Store test context for nested execution
|
|
441
481
|
this.testContext = testContext;
|
|
@@ -475,7 +515,7 @@ export class TestExecutor extends EventEmitter {
|
|
|
475
515
|
|
|
476
516
|
try {
|
|
477
517
|
// Execute all steps
|
|
478
|
-
await this._executeSteps(steps, [], this.testContext
|
|
518
|
+
await this._executeSteps(steps, [], this.testContext);
|
|
479
519
|
|
|
480
520
|
// Mark as completed
|
|
481
521
|
this.progress.status = 'completed';
|
|
@@ -498,21 +538,75 @@ export class TestExecutor extends EventEmitter {
|
|
|
498
538
|
private async _executeSteps(
|
|
499
539
|
steps: StepArray,
|
|
500
540
|
groupPath: string[],
|
|
501
|
-
parentTestContext?:
|
|
541
|
+
parentTestContext?: ITestFrameworkContext,
|
|
502
542
|
): Promise<void> {
|
|
503
543
|
const stepPromises: Promise<void>[] = [];
|
|
544
|
+
const namedPromises = new Map<string, Promise<void>>();
|
|
545
|
+
let checkpointIndex = 0;
|
|
504
546
|
|
|
505
547
|
for (const step of steps) {
|
|
506
548
|
if (Array.isArray(step)) {
|
|
507
|
-
//
|
|
549
|
+
// Distinguish by element type:
|
|
550
|
+
// [] empty array → sync barrier (existing behaviour)
|
|
551
|
+
// ['*'] / ['s1','s2'] → snapshot checkpoint (new)
|
|
552
|
+
// [fn, ...] nested → nested step group (existing behaviour)
|
|
508
553
|
if (step.length === 0) {
|
|
509
|
-
//
|
|
554
|
+
// Sync barrier — wait for all parallel steps in this batch
|
|
510
555
|
await Promise.all(stepPromises);
|
|
511
556
|
stepPromises.length = 0;
|
|
512
557
|
continue;
|
|
513
558
|
}
|
|
514
559
|
|
|
515
|
-
|
|
560
|
+
if (step.every(s => typeof s === 'string')) {
|
|
561
|
+
// Snapshot checkpoint: await relevant steps, then snapshot
|
|
562
|
+
const checkpoint = step as unknown as string[];
|
|
563
|
+
|
|
564
|
+
if (checkpoint.length === 1 && checkpoint[0] === '*') {
|
|
565
|
+
// ['*'] — wait for entire current batch
|
|
566
|
+
await Promise.all(stepPromises);
|
|
567
|
+
stepPromises.length = 0;
|
|
568
|
+
} else {
|
|
569
|
+
// ['step1','step2'] — wait only for the named steps
|
|
570
|
+
const namedToWait = checkpoint
|
|
571
|
+
.map(name => namedPromises.get(name))
|
|
572
|
+
.filter((p): p is Promise<void> => p !== undefined);
|
|
573
|
+
await Promise.all(namedToWait);
|
|
574
|
+
// stepPromises is NOT cleared — other steps keep running
|
|
575
|
+
}
|
|
576
|
+
const cpName =
|
|
577
|
+
(checkpoint as {name?: string}).name ??
|
|
578
|
+
(checkpoint.length === 1 && checkpoint[0] === '*'
|
|
579
|
+
? `context`
|
|
580
|
+
: checkpoint.join('-'));
|
|
581
|
+
// Disambiguate when the same name is used more than once
|
|
582
|
+
const snapshotName =
|
|
583
|
+
checkpointIndex === 0 ? cpName : `${cpName}-${checkpointIndex}`;
|
|
584
|
+
checkpointIndex++;
|
|
585
|
+
|
|
586
|
+
const stepsToSnapshot =
|
|
587
|
+
checkpoint.length === 1 && checkpoint[0] === '*'
|
|
588
|
+
? [...this.progress.steps.entries()]
|
|
589
|
+
.filter(([, s]) => s.status === 'completed')
|
|
590
|
+
.map(([name]) => name)
|
|
591
|
+
: checkpoint.filter(name =>
|
|
592
|
+
Object.prototype.hasOwnProperty.call(this.realContext, name),
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
const contextSnapshot = Object.fromEntries(
|
|
596
|
+
stepsToSnapshot.map(name => [
|
|
597
|
+
name,
|
|
598
|
+
this._applyMask(this.realContext[name]),
|
|
599
|
+
]),
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
const snapshotTarget = parentTestContext;
|
|
603
|
+
if (snapshotTarget && typeof snapshotTarget.matchSnapshot === 'function') {
|
|
604
|
+
snapshotTarget.matchSnapshot(contextSnapshot, snapshotName);
|
|
605
|
+
}
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Nested step group — wait for current batch first
|
|
516
610
|
await Promise.all(stepPromises);
|
|
517
611
|
stepPromises.length = 0;
|
|
518
612
|
|
|
@@ -521,18 +615,26 @@ export class TestExecutor extends EventEmitter {
|
|
|
521
615
|
// If we have a test context, use it to create nested test scope
|
|
522
616
|
if (this.testContext && parentTestContext) {
|
|
523
617
|
const nestedName = step.name || `group-${groupPath.length}`;
|
|
524
|
-
await
|
|
618
|
+
await this.testContext.test.call(
|
|
525
619
|
parentTestContext,
|
|
526
620
|
nestedName,
|
|
527
621
|
async (nestedContext: unknown) => {
|
|
528
|
-
await this._executeSteps(
|
|
622
|
+
await this._executeSteps(
|
|
623
|
+
step,
|
|
624
|
+
nestedGroupPath,
|
|
625
|
+
nestedContext as ITestFrameworkContext,
|
|
626
|
+
);
|
|
529
627
|
},
|
|
530
628
|
);
|
|
531
629
|
} else if (this.testContext && groupPath.length === 0) {
|
|
532
630
|
// Top-level nested array
|
|
533
631
|
const nestedName = step.name || `group-${groupPath.length}`;
|
|
534
632
|
await this.testContext.test(nestedName, async (nestedContext: unknown) => {
|
|
535
|
-
await this._executeSteps(
|
|
633
|
+
await this._executeSteps(
|
|
634
|
+
step,
|
|
635
|
+
nestedGroupPath,
|
|
636
|
+
nestedContext as ITestFrameworkContext,
|
|
637
|
+
);
|
|
536
638
|
});
|
|
537
639
|
} else {
|
|
538
640
|
// No test context, execute directly
|
|
@@ -541,7 +643,9 @@ export class TestExecutor extends EventEmitter {
|
|
|
541
643
|
} else if (typeof step === 'function') {
|
|
542
644
|
// Execute function step in parallel
|
|
543
645
|
const promise = this._executeStep(step, groupPath, parentTestContext);
|
|
646
|
+
const stepName = step.name || 'anonymous';
|
|
544
647
|
stepPromises.push(promise);
|
|
648
|
+
namedPromises.set(stepName, promise);
|
|
545
649
|
}
|
|
546
650
|
}
|
|
547
651
|
|
|
@@ -563,9 +667,7 @@ export class TestExecutor extends EventEmitter {
|
|
|
563
667
|
this._checkForDuplicateStepName(stepName);
|
|
564
668
|
|
|
565
669
|
// Capture source location if enabled
|
|
566
|
-
const sourceLocation = this.config.captureStackTraces
|
|
567
|
-
? captureSourceLocation(fn)
|
|
568
|
-
: undefined;
|
|
670
|
+
const sourceLocation = this.config.captureStackTraces ? captureSourceLocation() : undefined;
|
|
569
671
|
|
|
570
672
|
// Initialize step progress
|
|
571
673
|
const stepProgress: IStepProgress = {
|
|
@@ -599,7 +701,59 @@ export class TestExecutor extends EventEmitter {
|
|
|
599
701
|
this.latencyMetrics.set(stepName, latency);
|
|
600
702
|
|
|
601
703
|
// Wrap execution function for potential test context wrapping
|
|
602
|
-
|
|
704
|
+
// When a TAP sub-test context is supplied, assert is augmented with:
|
|
705
|
+
// assert.snapshot(value, 'name', opts?) — explicit snapshot
|
|
706
|
+
// assert.snapshot({mask?: []}) — deferred: snapshot the
|
|
707
|
+
// step's return value
|
|
708
|
+
// assert.snapshot() — deferred, no extra mask
|
|
709
|
+
// Deferred snapshots are taken after fn() returns, under the step name.
|
|
710
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
711
|
+
const self = this;
|
|
712
|
+
const executeStepFn = async (stepTestContext?: ITestFrameworkContext) => {
|
|
713
|
+
const hasSnapshotTarget =
|
|
714
|
+
stepTestContext !== undefined &&
|
|
715
|
+
typeof stepTestContext.matchSnapshot === 'function';
|
|
716
|
+
|
|
717
|
+
// Tracks a deferred assert.snapshot() call made inside the step.
|
|
718
|
+
const snapshotRequest: {deferred?: {mask?: string[]}} = {};
|
|
719
|
+
|
|
720
|
+
const stepAssert = hasSnapshotTarget
|
|
721
|
+
? new Proxy(assert, {
|
|
722
|
+
get(target, prop) {
|
|
723
|
+
if (prop === 'matchSnapshot') return stepTestContext?.matchSnapshot;
|
|
724
|
+
if (prop === 'snapshot')
|
|
725
|
+
return (
|
|
726
|
+
valueOrOpts?: unknown,
|
|
727
|
+
nameOrNothing?: unknown,
|
|
728
|
+
opts?: {mask?: string[]},
|
|
729
|
+
) => {
|
|
730
|
+
if (typeof nameOrNothing === 'string') {
|
|
731
|
+
// Explicit: assert.snapshot(value, 'name', opts?)
|
|
732
|
+
if (!valueOrOpts)
|
|
733
|
+
throw new assert.AssertionError({
|
|
734
|
+
message: `snapshot "${nameOrNothing}": value is falsy`,
|
|
735
|
+
});
|
|
736
|
+
const masked = self._applyMask(valueOrOpts, opts?.mask);
|
|
737
|
+
stepTestContext.matchSnapshot!(
|
|
738
|
+
masked,
|
|
739
|
+
nameOrNothing as string,
|
|
740
|
+
);
|
|
741
|
+
} else {
|
|
742
|
+
// Deferred: assert.snapshot() or assert.snapshot({mask})
|
|
743
|
+
const deferOpts =
|
|
744
|
+
typeof valueOrOpts === 'object' &&
|
|
745
|
+
valueOrOpts !== null &&
|
|
746
|
+
!Array.isArray(valueOrOpts)
|
|
747
|
+
? (valueOrOpts as {mask?: string[]})
|
|
748
|
+
: {};
|
|
749
|
+
snapshotRequest.deferred = {mask: deferOpts.mask};
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
return (target as unknown as Record<string, unknown>)[prop as string];
|
|
753
|
+
},
|
|
754
|
+
})
|
|
755
|
+
: assert;
|
|
756
|
+
|
|
603
757
|
latency.startedAt = Date.now();
|
|
604
758
|
latency.queueTime = latency.startedAt - latency.queuedAt;
|
|
605
759
|
|
|
@@ -620,14 +774,17 @@ export class TestExecutor extends EventEmitter {
|
|
|
620
774
|
);
|
|
621
775
|
|
|
622
776
|
// Execute the step (with optional retry loop)
|
|
623
|
-
const maxRetries =
|
|
624
|
-
|
|
777
|
+
const maxRetries = this.config.rerun?.enabled
|
|
778
|
+
? (this.config.rerun.maxRetries ?? DEFAULT_MAX_RETRIES)
|
|
779
|
+
: 0;
|
|
625
780
|
let result: unknown;
|
|
626
781
|
let lastError: Error | undefined;
|
|
627
782
|
|
|
628
783
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
784
|
+
// Reset per-attempt deferred snapshot flag
|
|
785
|
+
delete snapshotRequest.deferred;
|
|
629
786
|
try {
|
|
630
|
-
result = await fn(
|
|
787
|
+
result = await fn(stepAssert, context);
|
|
631
788
|
lastError = undefined;
|
|
632
789
|
break;
|
|
633
790
|
} catch (err) {
|
|
@@ -645,6 +802,24 @@ export class TestExecutor extends EventEmitter {
|
|
|
645
802
|
throw lastError;
|
|
646
803
|
}
|
|
647
804
|
|
|
805
|
+
// Handle deferred assert.snapshot() — called inside step with no explicit value
|
|
806
|
+
if (snapshotRequest.deferred !== undefined && hasSnapshotTarget) {
|
|
807
|
+
if (!result)
|
|
808
|
+
throw new assert.AssertionError({
|
|
809
|
+
message: `snapshot "${stepName}": step returned a falsy value`,
|
|
810
|
+
});
|
|
811
|
+
const masked = self._applyMask(result, snapshotRequest.deferred.mask);
|
|
812
|
+
stepTestContext.matchSnapshot!(masked, stepName);
|
|
813
|
+
} else if (self.config.autoSnapshot && hasSnapshotTarget) {
|
|
814
|
+
// Auto-snapshot: capture result automatically, no assert.snapshot() needed
|
|
815
|
+
if (!result)
|
|
816
|
+
throw new assert.AssertionError({
|
|
817
|
+
message: `snapshot "${stepName}": step returned a falsy value`,
|
|
818
|
+
});
|
|
819
|
+
const masked = self._applyMask(result);
|
|
820
|
+
stepTestContext.matchSnapshot!(masked, stepName);
|
|
821
|
+
}
|
|
822
|
+
|
|
648
823
|
// Store result in real context
|
|
649
824
|
this.realContext[stepName] = result;
|
|
650
825
|
|
|
@@ -711,14 +886,14 @@ export class TestExecutor extends EventEmitter {
|
|
|
711
886
|
if (this.testContext && parentTestContext) {
|
|
712
887
|
await this.queue.add(async () => {
|
|
713
888
|
try {
|
|
714
|
-
await
|
|
889
|
+
await this.testContext!.test.call(
|
|
715
890
|
parentTestContext,
|
|
716
891
|
stepName,
|
|
717
|
-
async () => {
|
|
718
|
-
await executeStepFn();
|
|
892
|
+
async (stepT: unknown) => {
|
|
893
|
+
await executeStepFn(stepT as ITestFrameworkContext);
|
|
719
894
|
},
|
|
720
895
|
);
|
|
721
|
-
} catch
|
|
896
|
+
} catch {
|
|
722
897
|
// Error already handled in executeStepFn, don't rethrow to break the queue
|
|
723
898
|
// The test framework will report it
|
|
724
899
|
}
|
|
@@ -727,16 +902,16 @@ export class TestExecutor extends EventEmitter {
|
|
|
727
902
|
// Top-level step with test context
|
|
728
903
|
await this.queue.add(async () => {
|
|
729
904
|
try {
|
|
730
|
-
await this.testContext!.test(stepName, async () => {
|
|
731
|
-
await executeStepFn();
|
|
905
|
+
await this.testContext!.test(stepName, async (stepT: unknown) => {
|
|
906
|
+
await executeStepFn(stepT as ITestFrameworkContext);
|
|
732
907
|
});
|
|
733
|
-
} catch
|
|
908
|
+
} catch {
|
|
734
909
|
// Error already handled in executeStepFn, don't rethrow to break the queue
|
|
735
910
|
}
|
|
736
911
|
});
|
|
737
912
|
} else {
|
|
738
913
|
// No test context or not at top level
|
|
739
|
-
await this.queue.add(executeStepFn);
|
|
914
|
+
await this.queue.add(executeStepFn as () => Promise<void>);
|
|
740
915
|
}
|
|
741
916
|
}
|
|
742
917
|
|
|
@@ -748,7 +923,7 @@ export class TestExecutor extends EventEmitter {
|
|
|
748
923
|
|
|
749
924
|
for (const step of steps) {
|
|
750
925
|
if (Array.isArray(step)) {
|
|
751
|
-
count += this._countSteps(step);
|
|
926
|
+
count += this._countSteps(step as StepArray);
|
|
752
927
|
} else if (typeof step === 'function') {
|
|
753
928
|
count++;
|
|
754
929
|
}
|
|
@@ -765,8 +940,10 @@ export class TestExecutor extends EventEmitter {
|
|
|
765
940
|
|
|
766
941
|
for (const step of steps) {
|
|
767
942
|
if (Array.isArray(step)) {
|
|
768
|
-
//
|
|
769
|
-
|
|
943
|
+
// Skip checkpoint markers (string-only arrays) — they are not steps
|
|
944
|
+
if (step.every(s => typeof s === 'string')) continue;
|
|
945
|
+
// Recursively collect from nested step groups
|
|
946
|
+
const nested = this._collectStepNames(step as StepArray);
|
|
770
947
|
nested.forEach(name => stepNames.add(name));
|
|
771
948
|
} else if (typeof step === 'function') {
|
|
772
949
|
const stepName = step.name || 'anonymous';
|
|
@@ -790,6 +967,18 @@ export class TestExecutor extends EventEmitter {
|
|
|
790
967
|
this.stepNamesUsed.add(stepName);
|
|
791
968
|
}
|
|
792
969
|
|
|
970
|
+
/**
|
|
971
|
+
* Applies the chain-level `mask` (and optional per-call `extraPaths`) to
|
|
972
|
+
* `value`. Returns the original reference unchanged when no masking is
|
|
973
|
+
* configured. Falls back to the deprecated `maskFn` when supplied.
|
|
974
|
+
*/
|
|
975
|
+
private _applyMask(value: unknown, extraPaths?: string[]): unknown {
|
|
976
|
+
const paths = [...(this.config.mask ?? []), ...(extraPaths ?? [])];
|
|
977
|
+
if (!paths.length && !this.config.maskFn) return value;
|
|
978
|
+
if (this.config.maskFn) return this.config.maskFn(value, paths);
|
|
979
|
+
return maskPaths(value, paths);
|
|
980
|
+
}
|
|
981
|
+
|
|
793
982
|
/**
|
|
794
983
|
* Gets the current progress snapshot
|
|
795
984
|
*/
|
|
@@ -920,7 +1109,7 @@ export class TestExecutor extends EventEmitter {
|
|
|
920
1109
|
* Type-safe event emitter
|
|
921
1110
|
*/
|
|
922
1111
|
on<E extends keyof ITestEvents>(event: E, handler: ITestEvents[E]): this {
|
|
923
|
-
return super.on(event, handler
|
|
1112
|
+
return super.on(event, handler);
|
|
924
1113
|
}
|
|
925
1114
|
|
|
926
1115
|
emit<E extends keyof ITestEvents>(event: E, ...args: Parameters<ITestEvents[E]>): boolean {
|
|
@@ -930,7 +1119,3 @@ export class TestExecutor extends EventEmitter {
|
|
|
930
1119
|
|
|
931
1120
|
// Export all types
|
|
932
1121
|
export type * from './test-types.js';
|
|
933
|
-
|
|
934
|
-
// Export snapshot helper
|
|
935
|
-
export {maskPaths, snapshot} from './snapshot.js';
|
|
936
|
-
export type {ISnapshotContext, ISnapshotOptions} from './snapshot.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feasibleone/blong-chain",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Parallel testing with automatic dependencies",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"blong",
|
|
@@ -12,8 +12,7 @@
|
|
|
12
12
|
},
|
|
13
13
|
"type": "module",
|
|
14
14
|
"exports": {
|
|
15
|
-
".": "./index.ts"
|
|
16
|
-
"./snapshot": "./snapshot.ts"
|
|
15
|
+
".": "./index.ts"
|
|
17
16
|
},
|
|
18
17
|
"dependencies": {
|
|
19
18
|
"p-queue": "^9.1.0"
|
|
@@ -24,12 +23,14 @@
|
|
|
24
23
|
"@rushstack/heft-typescript-plugin": "^1.3.1",
|
|
25
24
|
"@types/node": "^24",
|
|
26
25
|
"tap": "^21.6.3",
|
|
27
|
-
"typescript": "^5.9.3"
|
|
26
|
+
"typescript": "^5.9.3",
|
|
27
|
+
"@feasibleone/blong-dev": "1.0.0"
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
30
|
"build": "heft build --clean",
|
|
31
31
|
"ci-all": "npm run ci-unit && npm run ci-examples",
|
|
32
32
|
"ci-examples": "npm run test:examples",
|
|
33
|
+
"ci-lint": "blong-dev lint",
|
|
33
34
|
"ci-publish": "node ../../common/scripts/install-run-rush-pnpm.js publish --access public --provenance",
|
|
34
35
|
"ci-unit": "npm run test;./test-examples-ci.sh",
|
|
35
36
|
"test": "tap *.test.ts",
|
package/test-types.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import nodeAssert from 'node:assert';
|
|
2
|
+
|
|
3
|
+
declare module 'node:assert' {
|
|
4
|
+
function snapshot(value?: unknown, name?: string, opts?: {mask?: string[]}): void;
|
|
5
|
+
}
|
|
6
|
+
|
|
1
7
|
/**
|
|
2
8
|
* Type definitions for the Blong parallel test framework
|
|
3
9
|
*/
|
|
@@ -37,12 +43,32 @@ export interface ITestContext {
|
|
|
37
43
|
* @param context - Test context with $meta and outputs from previous steps
|
|
38
44
|
* @returns The output to be stored in context under the function's name
|
|
39
45
|
*/
|
|
40
|
-
export type StepFunction = (
|
|
46
|
+
export type StepFunction = (
|
|
47
|
+
assert: typeof nodeAssert,
|
|
48
|
+
context: ITestContext,
|
|
49
|
+
) => unknown | Promise<unknown>;
|
|
41
50
|
|
|
42
51
|
/**
|
|
43
|
-
*
|
|
52
|
+
* A snapshot checkpoint — an array of step-name strings placed inside the
|
|
53
|
+
* steps array. At runtime, when the executor encounters it the current batch
|
|
54
|
+
* is awaited and the listed step results are snapshotted into the TAP context.
|
|
55
|
+
*
|
|
56
|
+
* - `['*']` — snapshot ALL completed steps' results into one context object
|
|
57
|
+
* - `['step1', 'step2']` — snapshot only those specific steps
|
|
58
|
+
* - `[]` — sync barrier only, no snapshot (existing behaviour)
|
|
59
|
+
*
|
|
60
|
+
* The array may carry an optional `.name` to give the snapshot a stable name:
|
|
61
|
+
* ```
|
|
62
|
+
* const cp = Object.assign(['*'], {name: 'provisioning-complete'});
|
|
63
|
+
* ```
|
|
44
64
|
*/
|
|
45
|
-
export type
|
|
65
|
+
export type CheckpointMarker = string[] & {name?: string};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Array of test steps. May contain step functions, nested step groups, or
|
|
69
|
+
* checkpoint markers (string arrays).
|
|
70
|
+
*/
|
|
71
|
+
export type StepArray = (StepFunction | StepArray | CheckpointMarker)[] & {name?: string};
|
|
46
72
|
|
|
47
73
|
/**
|
|
48
74
|
* Meta information passed through test execution
|
|
@@ -61,6 +87,8 @@ export interface IMeta {
|
|
|
61
87
|
export interface ITestFrameworkContext {
|
|
62
88
|
/** Creates a nested test scope for proper indentation */
|
|
63
89
|
test: (name: string, fn: (t: unknown) => void | Promise<void>) => unknown;
|
|
90
|
+
/** Captures a snapshot of a value under the given name */
|
|
91
|
+
matchSnapshot?: (value: unknown, name: string) => void;
|
|
64
92
|
}
|
|
65
93
|
|
|
66
94
|
// ============================================================================
|
|
@@ -340,6 +368,35 @@ export interface ITestExecutorConfig {
|
|
|
340
368
|
framework?: unknown;
|
|
341
369
|
/** Logger instance for reporting step failures */
|
|
342
370
|
log?: ITestLogger;
|
|
371
|
+
/**
|
|
372
|
+
* Chain-level mask paths. Applied to ALL snapshot operations in this chain:
|
|
373
|
+
* `autoSnapshot`, `assert.snapshot()`, and checkpoint snapshots.
|
|
374
|
+
*
|
|
375
|
+
* Supports:
|
|
376
|
+
* - Simple name: `'id'` — masks the `id` field in the snapshotted value
|
|
377
|
+
* - Dot-path: `'user.id'` — masks a nested field
|
|
378
|
+
* - Wildcard prefix: `'*.id'` — masks `id` inside every direct child of the
|
|
379
|
+
* snapshotted object (useful for context-level snapshots where each child
|
|
380
|
+
* is a step result)
|
|
381
|
+
*
|
|
382
|
+
* Per-call `{mask: [...]}` options are merged on top of this chain-level mask.
|
|
383
|
+
*/
|
|
384
|
+
mask?: string[];
|
|
385
|
+
/**
|
|
386
|
+
* When `true`, automatically snapshot every step's return value under the
|
|
387
|
+
* step's function name after it completes. The chain-level `mask` is applied
|
|
388
|
+
* before snapshotting. No `assert.snapshot()` calls are needed in step
|
|
389
|
+
* functions — the framework captures everything automatically.
|
|
390
|
+
*
|
|
391
|
+
* Requires a TAP (or compatible) test context to be passed to `execute()`.
|
|
392
|
+
*/
|
|
393
|
+
autoSnapshot?: boolean;
|
|
394
|
+
/**
|
|
395
|
+
* @deprecated Use `mask` (string array) instead.
|
|
396
|
+
* Custom masking function for advanced scenarios not covered by `mask`.
|
|
397
|
+
* When both are provided, `maskFn` is used and `mask` is passed to it.
|
|
398
|
+
*/
|
|
399
|
+
maskFn?: (value: unknown, paths: string[]) => unknown;
|
|
343
400
|
/**
|
|
344
401
|
* Automatic rerun configuration for failing steps (Phase 1).
|
|
345
402
|
*
|