@feasibleone/blong-chain 1.0.0 → 1.1.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/dist/index.js ADDED
@@ -0,0 +1,663 @@
1
+ /**
2
+ * Parallel Test Executor
3
+ *
4
+ * Implements the new parallel test execution framework with:
5
+ * - Thenable proxies for automatic dependency detection
6
+ * - Parallel execution with configurable concurrency
7
+ * - Dependency graph tracking
8
+ * - Live progress tracking
9
+ * - Enhanced error reporting
10
+ * - Latency metrics
11
+ */
12
+ import assert from 'node:assert';
13
+ import { EventEmitter } from 'node:events';
14
+ import PQueue from 'p-queue';
15
+ /**
16
+ * Creates a thenable proxy for a given context path.
17
+ * The proxy acts as a Promise and supports nested property access.
18
+ *
19
+ * @param path - The context path (e.g., 'setupData' or 'setupData.user.name')
20
+ * @param promiseManager - The promise manager to get/create promises
21
+ * @returns A thenable proxy that can be awaited or have properties accessed
22
+ */
23
+ function createThenableProxy(path, promiseManager) {
24
+ // Get or create the promise for this path
25
+ const promiseEntry = promiseManager.getOrCreate(path);
26
+ // Create a proxy that intercepts property access
27
+ const proxy = new Proxy(promiseEntry.promise, {
28
+ get(target, prop) {
29
+ // Promise methods: delegate to the real promise
30
+ if (prop === 'then' || prop === 'catch' || prop === 'finally') {
31
+ return target[prop].bind(target);
32
+ }
33
+ // Symbol properties (like Symbol.toStringTag)
34
+ if (typeof prop === 'symbol') {
35
+ return target[prop];
36
+ }
37
+ // Property access: return nested thenable proxy
38
+ return createThenableProxy(`${path}.${prop}`, promiseManager);
39
+ },
40
+ });
41
+ return proxy;
42
+ }
43
+ /**
44
+ * Manages promises for all context paths.
45
+ * Provides lazy creation and caching of promises.
46
+ */
47
+ class PromiseManager {
48
+ promises = new Map();
49
+ realContext;
50
+ constructor(realContext) {
51
+ this.realContext = realContext;
52
+ }
53
+ /**
54
+ * Gets an existing promise or creates a new one for the given path
55
+ */
56
+ getOrCreate(path) {
57
+ if (this.promises.has(path)) {
58
+ return this.promises.get(path);
59
+ }
60
+ let resolve;
61
+ let reject;
62
+ const promise = new Promise((res, rej) => {
63
+ resolve = res;
64
+ reject = rej;
65
+ });
66
+ const entry = {
67
+ promise,
68
+ resolve: resolve,
69
+ reject: reject,
70
+ };
71
+ this.promises.set(path, entry);
72
+ // Check if this is a top-level step that has already completed
73
+ const parts = path.split('.');
74
+ const stepName = parts[0];
75
+ if (parts.length === 1 && stepName in this.realContext) {
76
+ // Step already completed, resolve immediately
77
+ entry.resolve(this.realContext[stepName]);
78
+ }
79
+ else if (parts.length > 1 && stepName in this.realContext) {
80
+ // Nested property of a completed step
81
+ const value = this._getNestedValue(this.realContext[stepName], parts.slice(1).join('.'));
82
+ entry.resolve(value);
83
+ }
84
+ else {
85
+ // Step hasn't completed yet, check if parent is already resolved
86
+ this._autoResolveIfParentResolved(path, entry);
87
+ }
88
+ return entry;
89
+ }
90
+ /**
91
+ * If parent path is already resolved, resolve this child path immediately
92
+ */
93
+ _autoResolveIfParentResolved(path, entry) {
94
+ const parts = path.split('.');
95
+ if (parts.length <= 1)
96
+ return; // No parent
97
+ // Check each parent level from most specific to least
98
+ for (let i = parts.length - 1; i > 0; i--) {
99
+ const parentPath = parts.slice(0, i).join('.');
100
+ const parentEntry = this.promises.get(parentPath);
101
+ if (parentEntry) {
102
+ // Wait for parent to resolve, then resolve child
103
+ parentEntry.promise
104
+ .then(parentValue => {
105
+ // Navigate to the child value
106
+ const childPath = parts.slice(i).join('.');
107
+ const childValue = this._getNestedValue(parentValue, childPath);
108
+ // Resolve the child promise
109
+ entry.resolve(childValue);
110
+ })
111
+ .catch(error => {
112
+ // Parent rejected, reject child too
113
+ entry.reject(error);
114
+ });
115
+ return;
116
+ }
117
+ }
118
+ }
119
+ /**
120
+ * Gets nested value from an object by path
121
+ */
122
+ _getNestedValue(obj, path) {
123
+ const parts = path.split('.');
124
+ let current = obj;
125
+ for (const part of parts) {
126
+ if (current && typeof current === 'object') {
127
+ current = current[part];
128
+ }
129
+ else {
130
+ return undefined;
131
+ }
132
+ }
133
+ return current;
134
+ }
135
+ /**
136
+ * Checks if a promise exists for the given path
137
+ */
138
+ has(path) {
139
+ return this.promises.has(path);
140
+ }
141
+ /**
142
+ * Resolves all promises related to a step's output
143
+ */
144
+ resolveStep(stepName, output) {
145
+ // Resolve the main step promise
146
+ if (this.promises.has(stepName)) {
147
+ this.promises.get(stepName).resolve(output);
148
+ }
149
+ // Resolve nested property promises
150
+ if (typeof output === 'object' && output !== null) {
151
+ this._resolveNestedProperties(stepName, output);
152
+ }
153
+ }
154
+ /**
155
+ * Recursively resolves promises for nested properties
156
+ */
157
+ _resolveNestedProperties(basePath, obj, depth = 0) {
158
+ // Limit recursion depth to avoid infinite loops
159
+ if (depth > 10)
160
+ return;
161
+ for (const [key, value] of Object.entries(obj)) {
162
+ const nestedPath = `${basePath}.${key}`;
163
+ if (this.promises.has(nestedPath)) {
164
+ this.promises.get(nestedPath).resolve(value);
165
+ }
166
+ // Recursively resolve deeper properties
167
+ if (typeof value === 'object' && value !== null) {
168
+ this._resolveNestedProperties(nestedPath, value, depth + 1);
169
+ }
170
+ }
171
+ }
172
+ /**
173
+ * Rejects a promise for a given path
174
+ */
175
+ reject(path, error) {
176
+ if (this.promises.has(path)) {
177
+ this.promises.get(path).reject(error);
178
+ }
179
+ }
180
+ }
181
+ /**
182
+ * Creates a context proxy that returns thenable proxies for all properties
183
+ * except $meta, which is always available directly.
184
+ *
185
+ * Also tracks which properties are accessed for dependency detection.
186
+ */
187
+ function createContextProxy(realContext, promiseManager, currentStep, dependencyTracker) {
188
+ return new Proxy(realContext, {
189
+ get(target, prop) {
190
+ // Special case: $meta is always available directly
191
+ if (prop === '$meta') {
192
+ return target.$meta;
193
+ }
194
+ // Track dependency if we're inside a step execution
195
+ if (currentStep && typeof prop === 'string') {
196
+ dependencyTracker.trackAccess(currentStep, prop);
197
+ }
198
+ // Return thenable proxy for step outputs
199
+ if (typeof prop === 'string') {
200
+ return createThenableProxy(prop, promiseManager);
201
+ }
202
+ return target[prop];
203
+ },
204
+ });
205
+ }
206
+ /**
207
+ * Tracks dependency relationships between steps
208
+ */
209
+ class DependencyTracker {
210
+ dependencies = new Map();
211
+ /**
212
+ * Records that a step accessed a property
213
+ */
214
+ trackAccess(fromStep, property) {
215
+ if (!this.dependencies.has(fromStep)) {
216
+ this.dependencies.set(fromStep, new Set());
217
+ }
218
+ this.dependencies.get(fromStep).add(property);
219
+ }
220
+ /**
221
+ * Gets all dependencies for a step
222
+ */
223
+ getDependencies(stepName) {
224
+ return Array.from(this.dependencies.get(stepName) || []);
225
+ }
226
+ /**
227
+ * Gets all dependency edges as graph edges
228
+ */
229
+ getEdges() {
230
+ const edges = [];
231
+ for (const [from, properties] of this.dependencies.entries()) {
232
+ for (const property of properties) {
233
+ // Extract the base step name from the property path
234
+ const to = property.split('.')[0];
235
+ edges.push({ from, to, property });
236
+ }
237
+ }
238
+ return edges;
239
+ }
240
+ }
241
+ /**
242
+ * Captures source location information for error reporting
243
+ */
244
+ function captureSourceLocation(fn) {
245
+ try {
246
+ const stack = new Error().stack || '';
247
+ const lines = stack.split('\n');
248
+ // Find the first line that's not from this file
249
+ for (let i = 2; i < lines.length; i++) {
250
+ const line = lines[i];
251
+ if (!line.includes('executor.ts') && !line.includes('executor.js')) {
252
+ // Try to parse: "at functionName (file:line:column)"
253
+ const match = line.match(/\((.+):(\d+):(\d+)\)/);
254
+ if (match) {
255
+ return {
256
+ file: match[1],
257
+ line: parseInt(match[2], 10),
258
+ column: parseInt(match[3], 10),
259
+ };
260
+ }
261
+ // Try alternative format: "at file:line:column"
262
+ const altMatch = line.match(/at (.+):(\d+):(\d+)/);
263
+ if (altMatch) {
264
+ return {
265
+ file: altMatch[1],
266
+ line: parseInt(altMatch[2], 10),
267
+ column: parseInt(altMatch[3], 10),
268
+ };
269
+ }
270
+ }
271
+ }
272
+ }
273
+ catch (error) {
274
+ // If parsing fails, return unknown location
275
+ }
276
+ return {
277
+ file: 'unknown',
278
+ line: 0,
279
+ column: 0,
280
+ };
281
+ }
282
+ /**
283
+ * Main test executor class
284
+ */
285
+ export class TestExecutor extends EventEmitter {
286
+ config;
287
+ queue;
288
+ dependencyTracker = new DependencyTracker();
289
+ // Progress tracking
290
+ progress = {
291
+ testName: 'test',
292
+ startTime: 0,
293
+ status: 'pending',
294
+ totalSteps: 0,
295
+ completedSteps: 0,
296
+ failedSteps: 0,
297
+ steps: new Map(),
298
+ groups: [],
299
+ };
300
+ // Dependency graph
301
+ graph = {
302
+ nodes: new Map(),
303
+ edges: [],
304
+ };
305
+ // Latency tracking
306
+ latencyMetrics = new Map();
307
+ // Real context (actual values)
308
+ realContext = {};
309
+ // Promise manager (needs realContext, initialized in constructor)
310
+ promiseManager;
311
+ // Test framework context for nested test output
312
+ testContext;
313
+ constructor(config = {}) {
314
+ super();
315
+ this.config = {
316
+ concurrency: config.concurrency ?? 10,
317
+ captureStackTraces: config.captureStackTraces ?? false,
318
+ framework: config.framework,
319
+ };
320
+ this.queue = new PQueue({ concurrency: this.config.concurrency });
321
+ // Initialize promise manager with reference to realContext
322
+ this.promiseManager = new PromiseManager(this.realContext);
323
+ }
324
+ /**
325
+ * Executes an array of test steps
326
+ */
327
+ async execute(steps, $meta, testContext) {
328
+ // Store test context for nested execution
329
+ this.testContext = testContext;
330
+ // Clear and initialize context with $meta (preserve reference for PromiseManager)
331
+ Object.keys(this.realContext).forEach(key => delete this.realContext[key]);
332
+ this.realContext.$meta = $meta;
333
+ // Initialize progress
334
+ this.progress.testName = steps.name || 'test';
335
+ this.progress.startTime = Date.now();
336
+ this.progress.status = 'running';
337
+ // Count total steps
338
+ this.progress.totalSteps = this._countSteps(steps);
339
+ // Emit test start event
340
+ this.emit('test:start', this.progress);
341
+ try {
342
+ // Execute all steps
343
+ await this._executeSteps(steps, [], this.testContext);
344
+ // Mark as completed
345
+ this.progress.status = 'completed';
346
+ this.progress.endTime = Date.now();
347
+ // Build final dependency graph
348
+ this.graph.edges = this.dependencyTracker.getEdges();
349
+ }
350
+ catch (error) {
351
+ this.progress.status = 'failed';
352
+ this.progress.endTime = Date.now();
353
+ throw error;
354
+ }
355
+ finally {
356
+ this.emit('test:end', this.progress);
357
+ }
358
+ }
359
+ /**
360
+ * Recursively executes steps, handling both functions and nested arrays
361
+ */
362
+ async _executeSteps(steps, groupPath, parentTestContext) {
363
+ const stepPromises = [];
364
+ for (const step of steps) {
365
+ if (Array.isArray(step)) {
366
+ // Check if it's an empty array (checkpoint)
367
+ if (step.length === 0) {
368
+ // Checkpoint: wait for all parallel steps to complete before continuing
369
+ await Promise.all(stepPromises);
370
+ stepPromises.length = 0;
371
+ continue;
372
+ }
373
+ // Nested array - wait for current level to complete first
374
+ await Promise.all(stepPromises);
375
+ stepPromises.length = 0;
376
+ const nestedGroupPath = [...groupPath, step.name || `group-${groupPath.length}`];
377
+ // If we have a test context, use it to create nested test scope
378
+ if (this.testContext && parentTestContext) {
379
+ const nestedName = step.name || `group-${groupPath.length}`;
380
+ await this.testContext.test.call(parentTestContext, nestedName, async (nestedContext) => {
381
+ await this._executeSteps(step, nestedGroupPath, nestedContext);
382
+ });
383
+ }
384
+ else if (this.testContext && groupPath.length === 0) {
385
+ // Top-level nested array
386
+ const nestedName = step.name || `group-${groupPath.length}`;
387
+ await this.testContext.test(nestedName, async (nestedContext) => {
388
+ await this._executeSteps(step, nestedGroupPath, nestedContext);
389
+ });
390
+ }
391
+ else {
392
+ // No test context, execute directly
393
+ await this._executeSteps(step, nestedGroupPath, parentTestContext);
394
+ }
395
+ }
396
+ else if (typeof step === 'function') {
397
+ // Execute function step in parallel
398
+ const promise = this._executeStep(step, groupPath, parentTestContext);
399
+ stepPromises.push(promise);
400
+ }
401
+ }
402
+ // Wait for remaining steps at this level
403
+ await Promise.all(stepPromises);
404
+ }
405
+ /**
406
+ * Executes a single step function
407
+ */
408
+ async _executeStep(fn, groupPath, parentTestContext) {
409
+ const stepName = fn.name || 'anonymous';
410
+ // Capture source location if enabled
411
+ const sourceLocation = this.config.captureStackTraces
412
+ ? captureSourceLocation(fn)
413
+ : undefined;
414
+ // Initialize step progress
415
+ const stepProgress = {
416
+ stepName,
417
+ displayName: stepName,
418
+ groupPath,
419
+ status: 'pending',
420
+ dependencies: [],
421
+ dependents: [],
422
+ sourceLocation,
423
+ };
424
+ this.progress.steps.set(stepName, stepProgress);
425
+ // Initialize dependency graph node
426
+ this.graph.nodes.set(stepName, {
427
+ stepName,
428
+ groupPath,
429
+ status: 'pending',
430
+ });
431
+ // Initialize latency tracking
432
+ const latency = {
433
+ stepName,
434
+ queuedAt: Date.now(),
435
+ queueTime: 0,
436
+ waitTime: 0,
437
+ executionTime: 0,
438
+ totalTime: 0,
439
+ };
440
+ this.latencyMetrics.set(stepName, latency);
441
+ // Wrap execution function for potential test context wrapping
442
+ const executeStepFn = async () => {
443
+ latency.startedAt = Date.now();
444
+ latency.queueTime = latency.startedAt - latency.queuedAt;
445
+ stepProgress.status = 'running';
446
+ stepProgress.startTime = latency.startedAt;
447
+ this.graph.nodes.get(stepName).status = 'running';
448
+ this.graph.nodes.get(stepName).startTime = latency.startedAt;
449
+ this.emit('step:start', stepName, stepProgress);
450
+ try {
451
+ // Create tracking context
452
+ const context = createContextProxy(this.realContext, this.promiseManager, stepName, this.dependencyTracker);
453
+ // Execute the step
454
+ const result = await fn(assert, context);
455
+ // Store result in real context
456
+ this.realContext[stepName] = result;
457
+ // Resolve all promises for this step
458
+ this.promiseManager.resolveStep(stepName, result);
459
+ // Update progress - calculate latency metrics
460
+ latency.completedAt = Date.now();
461
+ latency.totalTime = latency.completedAt - latency.queuedAt;
462
+ latency.executionTime = latency.completedAt - latency.startedAt;
463
+ latency.queueTime = latency.startedAt - latency.queuedAt;
464
+ latency.waitTime = 0; // TODO: More sophisticated wait time tracking
465
+ stepProgress.status = 'completed';
466
+ stepProgress.endTime = latency.completedAt;
467
+ stepProgress.duration = latency.totalTime;
468
+ stepProgress.queueTime = latency.queueTime;
469
+ stepProgress.executionTime = latency.executionTime;
470
+ stepProgress.waitTime = latency.waitTime;
471
+ stepProgress.result = result;
472
+ stepProgress.dependencies = this.dependencyTracker.getDependencies(stepName);
473
+ this.graph.nodes.get(stepName).status = 'completed';
474
+ this.graph.nodes.get(stepName).endTime = latency.completedAt;
475
+ this.progress.completedSteps++;
476
+ this.emit('step:end', stepName, stepProgress);
477
+ }
478
+ catch (error) {
479
+ // Handle error
480
+ latency.completedAt = Date.now();
481
+ latency.totalTime = latency.completedAt - latency.queuedAt;
482
+ latency.executionTime = latency.completedAt - latency.startedAt;
483
+ latency.queueTime = latency.startedAt - latency.queuedAt;
484
+ latency.waitTime = 0;
485
+ stepProgress.status = 'failed';
486
+ stepProgress.endTime = latency.completedAt;
487
+ stepProgress.duration = latency.totalTime;
488
+ stepProgress.dependencies = this.dependencyTracker.getDependencies(stepName);
489
+ const stepError = {
490
+ message: error.message,
491
+ stack: error.stack || '',
492
+ context: { ...this.realContext },
493
+ };
494
+ stepProgress.error = stepError;
495
+ this.graph.nodes.get(stepName).status = 'failed';
496
+ this.graph.nodes.get(stepName).endTime = latency.completedAt;
497
+ this.graph.nodes.get(stepName).error = error;
498
+ this.progress.failedSteps++;
499
+ this.emit('step:error', stepName, error, stepProgress);
500
+ // Reject promises for this step
501
+ this.promiseManager.reject(stepName, error);
502
+ throw error;
503
+ }
504
+ };
505
+ // If we have test context, wrap in nested test
506
+ if (this.testContext && parentTestContext) {
507
+ await this.queue.add(async () => {
508
+ try {
509
+ await this.testContext.test.call(parentTestContext, stepName, async () => {
510
+ await executeStepFn();
511
+ });
512
+ }
513
+ catch (error) {
514
+ // Error already handled in executeStepFn, don't rethrow to break the queue
515
+ // The test framework will report it
516
+ }
517
+ });
518
+ }
519
+ else if (this.testContext && groupPath.length === 0) {
520
+ // Top-level step with test context
521
+ await this.queue.add(async () => {
522
+ try {
523
+ await this.testContext.test(stepName, async () => {
524
+ await executeStepFn();
525
+ });
526
+ }
527
+ catch (error) {
528
+ // Error already handled in executeStepFn, don't rethrow to break the queue
529
+ }
530
+ });
531
+ }
532
+ else {
533
+ // No test context or not at top level
534
+ await this.queue.add(executeStepFn);
535
+ }
536
+ }
537
+ /**
538
+ * Counts total number of steps (including nested)
539
+ */
540
+ _countSteps(steps) {
541
+ let count = 0;
542
+ for (const step of steps) {
543
+ if (Array.isArray(step)) {
544
+ count += this._countSteps(step);
545
+ }
546
+ else if (typeof step === 'function') {
547
+ count++;
548
+ }
549
+ }
550
+ return count;
551
+ }
552
+ /**
553
+ * Gets the current progress snapshot
554
+ */
555
+ getProgress() {
556
+ return this.progress;
557
+ }
558
+ /**
559
+ * Gets the dependency graph
560
+ */
561
+ getDependencyGraph() {
562
+ return this.graph;
563
+ }
564
+ /**
565
+ * Gets latency metrics
566
+ */
567
+ getLatencyReport() {
568
+ const totalDuration = this.progress.endTime
569
+ ? this.progress.endTime - this.progress.startTime
570
+ : 0;
571
+ // Calculate critical path
572
+ const criticalPath = this._calculateCriticalPath();
573
+ // Calculate parallel efficiency
574
+ const totalStepTime = Array.from(this.latencyMetrics.values()).reduce((sum, l) => sum + l.executionTime, 0);
575
+ const parallelEfficiency = totalDuration > 0 ? totalStepTime / totalDuration : 0;
576
+ // Identify bottlenecks
577
+ const bottlenecks = this._identifyBottlenecks();
578
+ return {
579
+ testName: this.progress.testName,
580
+ totalDuration,
581
+ steps: this.latencyMetrics,
582
+ criticalPath,
583
+ parallelEfficiency,
584
+ bottlenecks,
585
+ };
586
+ }
587
+ /**
588
+ * Calculates the critical path (longest dependency chain)
589
+ */
590
+ _calculateCriticalPath() {
591
+ // Build adjacency list
592
+ const adjacency = new Map();
593
+ for (const edge of this.graph.edges) {
594
+ if (!adjacency.has(edge.to)) {
595
+ adjacency.set(edge.to, []);
596
+ }
597
+ adjacency.get(edge.to).push(edge.from);
598
+ }
599
+ // Find longest path using DFS
600
+ const visited = new Set();
601
+ let longestPath = [];
602
+ const dfs = (node, path) => {
603
+ if (visited.has(node))
604
+ return;
605
+ visited.add(node);
606
+ const newPath = [...path, node];
607
+ const children = adjacency.get(node) || [];
608
+ if (children.length === 0) {
609
+ // Leaf node - check if this is the longest path
610
+ if (newPath.length > longestPath.length) {
611
+ longestPath = newPath;
612
+ }
613
+ }
614
+ else {
615
+ for (const child of children) {
616
+ dfs(child, newPath);
617
+ }
618
+ }
619
+ visited.delete(node);
620
+ };
621
+ // Start DFS from all roots (nodes with no dependencies)
622
+ const allNodes = new Set(this.graph.nodes.keys());
623
+ const dependentNodes = new Set(this.graph.edges.map(e => e.from));
624
+ const roots = Array.from(allNodes).filter(n => !dependentNodes.has(n));
625
+ for (const root of roots) {
626
+ dfs(root, []);
627
+ }
628
+ return longestPath.reverse(); // Reverse to get correct order
629
+ }
630
+ /**
631
+ * Identifies bottleneck steps that blocked many other steps
632
+ */
633
+ _identifyBottlenecks() {
634
+ const bottlenecks = new Map();
635
+ // Count how many steps each step blocks
636
+ for (const edge of this.graph.edges) {
637
+ if (!bottlenecks.has(edge.to)) {
638
+ bottlenecks.set(edge.to, new Set());
639
+ }
640
+ bottlenecks.get(edge.to).add(edge.from);
641
+ }
642
+ // Sort by number of blocked steps
643
+ const result = Array.from(bottlenecks.entries())
644
+ .map(([stepName, blockedSteps]) => ({
645
+ stepName,
646
+ executionTime: this.latencyMetrics.get(stepName)?.executionTime || 0,
647
+ blockedSteps: Array.from(blockedSteps),
648
+ }))
649
+ .sort((a, b) => b.blockedSteps.length - a.blockedSteps.length)
650
+ .slice(0, 5); // Top 5 bottlenecks
651
+ return result;
652
+ }
653
+ /**
654
+ * Type-safe event emitter
655
+ */
656
+ on(event, handler) {
657
+ return super.on(event, handler);
658
+ }
659
+ emit(event, ...args) {
660
+ return super.emit(event, ...args);
661
+ }
662
+ }
663
+ //# sourceMappingURL=index.js.map