@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/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 as any, {
53
- get(target: Promise<T>, prop: string | symbol): any {
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 (target as any)[prop].bind(target);
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 (target as any)[prop];
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: any, path: string): any {
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: any, depth = 0): void {
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: any, prop: string | symbol): any {
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(fn: Function): ISourceLocation {
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 (error) {
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?: import('./test-types.js').ITestFrameworkContext;
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?: import('./test-types.js').ITestFrameworkContext,
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 as any);
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?: unknown,
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
- // Check if it's an empty array (checkpoint)
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
- // Checkpoint: wait for all parallel steps to complete before continuing
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
- // Nested array - wait for current level to complete first
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 (this.testContext.test as any).call(
618
+ await this.testContext.test.call(
525
619
  parentTestContext,
526
620
  nestedName,
527
621
  async (nestedContext: unknown) => {
528
- await this._executeSteps(step, nestedGroupPath, nestedContext);
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(step, nestedGroupPath, nestedContext);
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
- const executeStepFn = async () => {
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
- this.config.rerun?.enabled ? (this.config.rerun.maxRetries ?? DEFAULT_MAX_RETRIES) : 0;
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(assert, context);
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 (this.testContext!.test as any).call(
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 (error) {
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 (error) {
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
- // Recursively collect from nested arrays
769
- const nested = this._collectStepNames(step);
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 as any);
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.6.0",
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 = (assert: unknown, context: ITestContext) => unknown | Promise<unknown>;
46
+ export type StepFunction = (
47
+ assert: typeof nodeAssert,
48
+ context: ITestContext,
49
+ ) => unknown | Promise<unknown>;
41
50
 
42
51
  /**
43
- * Array of test steps that can be nested for sequential execution
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 StepArray = (StepFunction | StepArray)[] & {name?: string};
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
  *