@ontrails/testing 0.2.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 +807 -0
- package/README.md +157 -0
- package/package.json +57 -0
- package/src/all-established.ts +168 -0
- package/src/all.ts +94 -0
- package/src/assertions.ts +361 -0
- package/src/cli.ts +6 -0
- package/src/composes.ts +433 -0
- package/src/context.ts +228 -0
- package/src/contracts.ts +109 -0
- package/src/detours.ts +181 -0
- package/src/effective-examples.ts +408 -0
- package/src/errors.ts +47 -0
- package/src/examples.ts +439 -0
- package/src/harness-cli.ts +335 -0
- package/src/harness-http.ts +341 -0
- package/src/harness-mcp.ts +98 -0
- package/src/http.ts +10 -0
- package/src/index.ts +48 -0
- package/src/logger.ts +127 -0
- package/src/mcp.ts +6 -0
- package/src/scenario.ts +375 -0
- package/src/signals.ts +221 -0
- package/src/surface-parity.ts +389 -0
- package/src/trail.ts +116 -0
- package/src/types.ts +89 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP integration test harness.
|
|
3
|
+
*
|
|
4
|
+
* Builds MCP tools from a graph, invokes them directly (no transport),
|
|
5
|
+
* and returns the MCP tool response.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { deriveMcpTools } from '@ontrails/mcp';
|
|
9
|
+
import type {
|
|
10
|
+
DeriveMcpToolsOptions,
|
|
11
|
+
McpExtra,
|
|
12
|
+
McpToolDefinition,
|
|
13
|
+
} from '@ontrails/mcp';
|
|
14
|
+
import type { Topo } from '@ontrails/core';
|
|
15
|
+
|
|
16
|
+
/** Options for creating an MCP harness. */
|
|
17
|
+
export interface McpHarnessOptions extends DeriveMcpToolsOptions {
|
|
18
|
+
readonly extra?: Partial<McpExtra> | undefined;
|
|
19
|
+
readonly graph: Topo;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A test harness for MCP tools. */
|
|
23
|
+
export interface McpHarness {
|
|
24
|
+
/** Call an MCP tool by name with arguments. */
|
|
25
|
+
callTool(
|
|
26
|
+
name: string,
|
|
27
|
+
args: Record<string, unknown>
|
|
28
|
+
): Promise<McpHarnessResult>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The result of an MCP harness tool invocation. */
|
|
32
|
+
export interface McpHarnessResult {
|
|
33
|
+
readonly content: unknown;
|
|
34
|
+
readonly isError: boolean;
|
|
35
|
+
readonly meta?: Record<string, unknown> | undefined;
|
|
36
|
+
readonly structuredContent?: Record<string, unknown> | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// createMcpHarness
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Create an MCP harness for integration testing.
|
|
45
|
+
*
|
|
46
|
+
* Builds MCP tools from the graph's topo and provides a `callTool()` method
|
|
47
|
+
* that invokes tools directly without any transport boundary.
|
|
48
|
+
*
|
|
49
|
+
* ```ts
|
|
50
|
+
* import { createMcpHarness } from '@ontrails/testing/mcp';
|
|
51
|
+
*
|
|
52
|
+
* const harness = createMcpHarness({ graph });
|
|
53
|
+
* const result = await harness.callTool("myapp_entity_show", { name: "Alpha" });
|
|
54
|
+
* expect(result.isError).toBe(false);
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export const createMcpHarness = (options: McpHarnessOptions): McpHarness => {
|
|
58
|
+
const { extra, graph, ...deriveOptions } = options;
|
|
59
|
+
const toolsResult = deriveMcpTools(graph, deriveOptions);
|
|
60
|
+
if (toolsResult.isErr()) {
|
|
61
|
+
throw toolsResult.error;
|
|
62
|
+
}
|
|
63
|
+
const toolMap = new Map<string, McpToolDefinition>();
|
|
64
|
+
for (const tool of toolsResult.value) {
|
|
65
|
+
toolMap.set(tool.name, tool);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
async callTool(
|
|
70
|
+
name: string,
|
|
71
|
+
args: Record<string, unknown>
|
|
72
|
+
): Promise<McpHarnessResult> {
|
|
73
|
+
const tool = toolMap.get(name);
|
|
74
|
+
if (tool === undefined) {
|
|
75
|
+
return {
|
|
76
|
+
content: [{ text: `Unknown tool: ${name}`, type: 'text' }],
|
|
77
|
+
isError: true,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const result = await tool.handler(args, {
|
|
82
|
+
abortSignal: extra?.abortSignal,
|
|
83
|
+
authorization: extra?.authorization,
|
|
84
|
+
permit: extra?.permit,
|
|
85
|
+
progressToken: extra?.progressToken,
|
|
86
|
+
sendProgress: extra?.sendProgress,
|
|
87
|
+
sessionId: extra?.sessionId,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
content: result.content,
|
|
92
|
+
isError: result.isError ?? false,
|
|
93
|
+
meta: result._meta,
|
|
94
|
+
structuredContent: result.structuredContent,
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
};
|
package/src/http.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Contract-driven testing
|
|
2
|
+
export { testAll } from './all.js';
|
|
3
|
+
export { testExamples } from './examples.js';
|
|
4
|
+
export { testComposes } from './composes.js';
|
|
5
|
+
export { testTrail } from './trail.js';
|
|
6
|
+
export { testContracts } from './contracts.js';
|
|
7
|
+
export { testDetours } from './detours.js';
|
|
8
|
+
|
|
9
|
+
// Assertions
|
|
10
|
+
export {
|
|
11
|
+
assertErrorMatch,
|
|
12
|
+
assertFullMatch,
|
|
13
|
+
assertPartialMatch,
|
|
14
|
+
assertSchemaMatch,
|
|
15
|
+
errResultMatch,
|
|
16
|
+
expectErr,
|
|
17
|
+
expectOk,
|
|
18
|
+
okResultMatch,
|
|
19
|
+
} from './assertions.js';
|
|
20
|
+
|
|
21
|
+
// Scenario testing
|
|
22
|
+
export { executeScenarioSteps, ref, scenario } from './scenario.js';
|
|
23
|
+
|
|
24
|
+
// Mock factories
|
|
25
|
+
export {
|
|
26
|
+
createComposeContext,
|
|
27
|
+
createTestContext,
|
|
28
|
+
defaultCreatePermit,
|
|
29
|
+
} from './context.js';
|
|
30
|
+
export { createTestLogger } from './logger.js';
|
|
31
|
+
|
|
32
|
+
// Types
|
|
33
|
+
export type { CreateComposeContextOptions } from './context.js';
|
|
34
|
+
export type {
|
|
35
|
+
PermittedTrail,
|
|
36
|
+
MinimalPermit,
|
|
37
|
+
TestExecutionOptions,
|
|
38
|
+
} from './context.js';
|
|
39
|
+
export type { TestComposeOptions } from './composes.js';
|
|
40
|
+
|
|
41
|
+
export type {
|
|
42
|
+
ComposeScenario,
|
|
43
|
+
RefToken,
|
|
44
|
+
ScenarioStep,
|
|
45
|
+
TestScenario,
|
|
46
|
+
TestLogger,
|
|
47
|
+
TestTrailContextOptions,
|
|
48
|
+
} from './types.js';
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test logger that captures log records for assertion.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { LogLevel, LogRecord } from '@ontrails/observability';
|
|
6
|
+
|
|
7
|
+
import type { TestLogger } from './types.js';
|
|
8
|
+
|
|
9
|
+
type LogMetadata = Readonly<Record<string, unknown>>;
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Level ordering for filtering
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
const LEVEL_ORDER: Record<string, number> = {
|
|
16
|
+
debug: 1,
|
|
17
|
+
error: 4,
|
|
18
|
+
fatal: 5,
|
|
19
|
+
info: 2,
|
|
20
|
+
silent: 6,
|
|
21
|
+
trace: 0,
|
|
22
|
+
warn: 3,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Internal factory (shared between root and child loggers)
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
const createTestLoggerInternal = (
|
|
30
|
+
name: string,
|
|
31
|
+
minLevel: LogLevel,
|
|
32
|
+
sharedEntries: LogRecord[],
|
|
33
|
+
baseMetadata: LogMetadata
|
|
34
|
+
): TestLogger => {
|
|
35
|
+
const minOrder = LEVEL_ORDER[minLevel] ?? 0;
|
|
36
|
+
|
|
37
|
+
const shouldLog = (level: LogLevel): boolean =>
|
|
38
|
+
(LEVEL_ORDER[level] ?? 0) >= minOrder;
|
|
39
|
+
|
|
40
|
+
const log = (
|
|
41
|
+
level: LogLevel,
|
|
42
|
+
message: string,
|
|
43
|
+
metadata?: LogMetadata
|
|
44
|
+
): void => {
|
|
45
|
+
if (!shouldLog(level)) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const record: LogRecord = {
|
|
49
|
+
category: name,
|
|
50
|
+
level,
|
|
51
|
+
message,
|
|
52
|
+
metadata: { ...baseMetadata, ...metadata },
|
|
53
|
+
timestamp: new Date(),
|
|
54
|
+
};
|
|
55
|
+
sharedEntries.push(record);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
assertLogged(level: LogLevel, messageSubstring: string): void {
|
|
60
|
+
const match = sharedEntries.find(
|
|
61
|
+
(r) => r.level === level && r.message.includes(messageSubstring)
|
|
62
|
+
);
|
|
63
|
+
if (match === undefined) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Expected a log entry with level="${level}" containing "${messageSubstring}", but none was found. ` +
|
|
66
|
+
`Entries: ${JSON.stringify(sharedEntries.map((r) => ({ level: r.level, message: r.message })))}`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
child(metadata: LogMetadata): TestLogger {
|
|
72
|
+
const merged = { ...baseMetadata, ...metadata };
|
|
73
|
+
return createTestLoggerInternal(name, minLevel, sharedEntries, merged);
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
clear(): void {
|
|
77
|
+
sharedEntries.length = 0;
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
debug(message: string, metadata?: LogMetadata): void {
|
|
81
|
+
log('debug', message, metadata);
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
get entries(): readonly LogRecord[] {
|
|
85
|
+
return sharedEntries;
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
error(message: string, metadata?: LogMetadata): void {
|
|
89
|
+
log('error', message, metadata);
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
fatal(message: string, metadata?: LogMetadata): void {
|
|
93
|
+
log('fatal', message, metadata);
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
find(predicate: (record: LogRecord) => boolean): readonly LogRecord[] {
|
|
97
|
+
return sharedEntries.filter(predicate);
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
info(message: string, metadata?: LogMetadata): void {
|
|
101
|
+
log('info', message, metadata);
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
name,
|
|
105
|
+
|
|
106
|
+
trace(message: string, metadata?: LogMetadata): void {
|
|
107
|
+
log('trace', message, metadata);
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
warn(message: string, metadata?: LogMetadata): void {
|
|
111
|
+
log('warn', message, metadata);
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// createTestLogger
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Create a test logger that captures all log records in an array.
|
|
122
|
+
*
|
|
123
|
+
* Records are not printed. Use `entries`, `find()`, and `assertLogged()`
|
|
124
|
+
* to inspect what was logged during a test.
|
|
125
|
+
*/
|
|
126
|
+
export const createTestLogger = (options?: { level?: LogLevel }): TestLogger =>
|
|
127
|
+
createTestLoggerInternal('test', options?.level ?? 'trace', [], {});
|
package/src/mcp.ts
ADDED
package/src/scenario.ts
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-step scenario runner for composition testing.
|
|
3
|
+
*
|
|
4
|
+
* Scenarios express multi-trail flows as structured data — arrays of steps
|
|
5
|
+
* with compose-step references via `ref()`. Each step invokes a trail through
|
|
6
|
+
* the normal execution pipeline (validation, layers, implementation, Result).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, test } from 'bun:test';
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
AnyTrail,
|
|
13
|
+
ComposeBatchOptions,
|
|
14
|
+
ComposeFn,
|
|
15
|
+
ExecuteTrailOptions,
|
|
16
|
+
ResourceOverrideMap,
|
|
17
|
+
Result,
|
|
18
|
+
Topo,
|
|
19
|
+
} from '@ontrails/core';
|
|
20
|
+
import {
|
|
21
|
+
buildComposeValidationSchema,
|
|
22
|
+
claimNextComposeBatchIndex,
|
|
23
|
+
createComposeBatchValidationResults,
|
|
24
|
+
executeTrail,
|
|
25
|
+
InternalError,
|
|
26
|
+
normalizeComposeBatchConcurrency,
|
|
27
|
+
Result as R,
|
|
28
|
+
} from '@ontrails/core';
|
|
29
|
+
|
|
30
|
+
import { assertPartialMatch, expectOk } from './assertions.js';
|
|
31
|
+
import { createTestContext, createMockResources } from './context.js';
|
|
32
|
+
import type { RefToken, ScenarioStep } from './types.js';
|
|
33
|
+
|
|
34
|
+
type ScenarioComposeTarget = string | { readonly id: string };
|
|
35
|
+
type ScenarioComposeCall = readonly [ScenarioComposeTarget, unknown];
|
|
36
|
+
type TestingExecuteTrailOptions = ExecuteTrailOptions & {
|
|
37
|
+
readonly validationSchema?: ReturnType<typeof buildComposeValidationSchema>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// ref() — compose-step reference marker
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Create a reference marker for compose-step data in scenario inputs.
|
|
46
|
+
*
|
|
47
|
+
* `ref('create.id')` resolves to the `id` field of the step aliased as
|
|
48
|
+
* `create`. Dot-paths are supported for nested access.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```typescript
|
|
52
|
+
* scenario('Fork flow', app, [
|
|
53
|
+
* { compose: createGist, input: { name: 'Hello' }, as: 'original' },
|
|
54
|
+
* { compose: forkGist, input: { id: ref('original.id') } },
|
|
55
|
+
* ]);
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export const ref = (path: string): RefToken => ({ __ref: true, path });
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Internals
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/** Type guard for RefToken. */
|
|
65
|
+
const isRef = (value: unknown): value is RefToken =>
|
|
66
|
+
typeof value === 'object' &&
|
|
67
|
+
value !== null &&
|
|
68
|
+
'__ref' in value &&
|
|
69
|
+
(value as Record<string, unknown>)['__ref'] === true &&
|
|
70
|
+
'path' in value;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve a dot-path against the outputs map.
|
|
74
|
+
*
|
|
75
|
+
* `ref('create.id')` splits into step name `create` and field path `id`.
|
|
76
|
+
* The first segment is the step alias; remaining segments are property lookups.
|
|
77
|
+
*/
|
|
78
|
+
/**
|
|
79
|
+
* Walk remaining segments of a dot-path, drilling into the step output.
|
|
80
|
+
*/
|
|
81
|
+
const drillPath = (
|
|
82
|
+
path: string,
|
|
83
|
+
segments: readonly string[],
|
|
84
|
+
start: unknown
|
|
85
|
+
): unknown => {
|
|
86
|
+
let current: unknown = start;
|
|
87
|
+
for (let i = 1; i < segments.length; i += 1) {
|
|
88
|
+
const segment = segments[i];
|
|
89
|
+
if (segment === undefined) {
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
if (typeof current !== 'object' || current === null) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`ref('${path}'): cannot access '${segment}' on ${typeof current}`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
current = (current as Record<string, unknown>)[segment];
|
|
98
|
+
}
|
|
99
|
+
return current;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const resolvePath = (path: string, outputs: Map<string, unknown>): unknown => {
|
|
103
|
+
const segments = path.split('.');
|
|
104
|
+
const [stepName] = segments;
|
|
105
|
+
if (stepName === undefined) {
|
|
106
|
+
throw new Error(`ref(): empty path`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const stepOutput = outputs.get(stepName);
|
|
110
|
+
if (stepOutput === undefined) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`ref('${path}'): no step output found for alias '${stepName}'`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return drillPath(path, segments, stepOutput);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Recursively walk a value, replacing RefToken instances with resolved values.
|
|
121
|
+
*/
|
|
122
|
+
export const deriveRefs = (
|
|
123
|
+
value: unknown,
|
|
124
|
+
outputs: Map<string, unknown>
|
|
125
|
+
): unknown => {
|
|
126
|
+
if (isRef(value)) {
|
|
127
|
+
return resolvePath(value.path, outputs);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (Array.isArray(value)) {
|
|
131
|
+
return value.map((item) => deriveRefs(item, outputs));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (typeof value === 'object' && value !== null) {
|
|
135
|
+
const result: Record<string, unknown> = {};
|
|
136
|
+
for (const [key, val] of Object.entries(value)) {
|
|
137
|
+
result[key] = deriveRefs(val, outputs);
|
|
138
|
+
}
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return value;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// scenario() — the public API
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Define a multi-step scenario test for composition flows.
|
|
151
|
+
*
|
|
152
|
+
* Each step invokes a trail through the normal execution pipeline.
|
|
153
|
+
* Steps can reference prior step outputs via `ref()`. If any step
|
|
154
|
+
* fails, the scenario stops and reports which step failed.
|
|
155
|
+
*
|
|
156
|
+
* @example
|
|
157
|
+
* ```typescript
|
|
158
|
+
* scenario('Create and show', app, [
|
|
159
|
+
* { compose: createItem, input: { name: 'Test' }, as: 'created' },
|
|
160
|
+
* { compose: showItem, input: { id: ref('created.id') },
|
|
161
|
+
* expectedMatch: { found: true } },
|
|
162
|
+
* ]);
|
|
163
|
+
* ```
|
|
164
|
+
*/
|
|
165
|
+
/** Assert the result of a step against its expectations and record output. */
|
|
166
|
+
const assertStepExpectations = async (
|
|
167
|
+
step: ScenarioStep,
|
|
168
|
+
result: Result<unknown, Error>,
|
|
169
|
+
outputs: Map<string, unknown>
|
|
170
|
+
): Promise<void> => {
|
|
171
|
+
const value = expectOk(result);
|
|
172
|
+
if (step.expected !== undefined) {
|
|
173
|
+
const { expect } = await import('bun:test');
|
|
174
|
+
expect(value).toEqual(deriveRefs(step.expected, outputs));
|
|
175
|
+
} else if (step.expectedMatch !== undefined) {
|
|
176
|
+
assertPartialMatch(result, deriveRefs(step.expectedMatch, outputs));
|
|
177
|
+
}
|
|
178
|
+
if (step.as !== undefined) {
|
|
179
|
+
outputs.set(step.as, value);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const executeUnlimitedComposeBatch = async (
|
|
184
|
+
calls: readonly ScenarioComposeCall[],
|
|
185
|
+
runCall: (
|
|
186
|
+
call: ScenarioComposeCall,
|
|
187
|
+
branchIndex: number
|
|
188
|
+
) => Promise<Result<unknown, Error>>
|
|
189
|
+
): Promise<Result<unknown, Error>[]> =>
|
|
190
|
+
await Promise.all(
|
|
191
|
+
calls.map((call, branchIndex) => runCall(call, branchIndex))
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const executeLimitedComposeBatch = async (
|
|
195
|
+
calls: readonly ScenarioComposeCall[],
|
|
196
|
+
runCall: (
|
|
197
|
+
call: ScenarioComposeCall,
|
|
198
|
+
branchIndex: number
|
|
199
|
+
) => Promise<Result<unknown, Error>>,
|
|
200
|
+
limit: number
|
|
201
|
+
): Promise<Result<unknown, Error>[]> => {
|
|
202
|
+
const results = Array.from<Result<unknown, Error>>({ length: calls.length });
|
|
203
|
+
const nextIndex = { value: 0 };
|
|
204
|
+
|
|
205
|
+
const runWorker = async () => {
|
|
206
|
+
while (true) {
|
|
207
|
+
const branchIndex = claimNextComposeBatchIndex(nextIndex, calls);
|
|
208
|
+
if (branchIndex === undefined) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const call = calls[branchIndex];
|
|
213
|
+
if (call === undefined) {
|
|
214
|
+
// Defensive: `claimNextComposeBatchIndex` only returns indices within
|
|
215
|
+
// bounds, so this slot should always be populated. If it ever isn't,
|
|
216
|
+
// surface a clear InternalError in place of the missing slot and keep
|
|
217
|
+
// the worker loop running so sibling branches still get processed.
|
|
218
|
+
results[branchIndex] = R.err(
|
|
219
|
+
new InternalError(
|
|
220
|
+
`unreachable: concurrent compose batch call missing at index ${branchIndex}`
|
|
221
|
+
)
|
|
222
|
+
);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
results[branchIndex] = await runCall(call, branchIndex);
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
await Promise.all(Array.from({ length: limit }, runWorker));
|
|
231
|
+
return results;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Build a compose function that resolves trails from the topo and executes
|
|
236
|
+
* them through the standard pipeline. Mirrors the pattern in composes.ts
|
|
237
|
+
* `executeFromMap` but without recording or injection.
|
|
238
|
+
*/
|
|
239
|
+
const createScenarioCompose = (
|
|
240
|
+
app: Topo,
|
|
241
|
+
resources?: ResourceOverrideMap
|
|
242
|
+
): ComposeFn => {
|
|
243
|
+
const invokeCompose = async (
|
|
244
|
+
idOrTrail: ScenarioComposeTarget,
|
|
245
|
+
input: unknown,
|
|
246
|
+
self: ComposeFn
|
|
247
|
+
) => {
|
|
248
|
+
const id = typeof idOrTrail === 'string' ? idOrTrail : idOrTrail.id;
|
|
249
|
+
const trailDef: AnyTrail | undefined = app.get(id);
|
|
250
|
+
if (trailDef === undefined) {
|
|
251
|
+
return R.err(
|
|
252
|
+
new InternalError(`compose: trail "${id}" not found in topo`)
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
const baseCtx = createTestContext();
|
|
256
|
+
const options: TestingExecuteTrailOptions = {
|
|
257
|
+
ctx: { ...baseCtx, compose: self },
|
|
258
|
+
resources,
|
|
259
|
+
topo: app,
|
|
260
|
+
validationSchema: buildComposeValidationSchema(trailDef),
|
|
261
|
+
};
|
|
262
|
+
return await executeTrail(trailDef, input, options);
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const executeComposeBatch = async (
|
|
266
|
+
calls: readonly ScenarioComposeCall[],
|
|
267
|
+
self: ComposeFn,
|
|
268
|
+
options?: ComposeBatchOptions
|
|
269
|
+
): Promise<Result<unknown, Error>[]> => {
|
|
270
|
+
if (calls.length === 0) {
|
|
271
|
+
return [];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const concurrency = normalizeComposeBatchConcurrency(options);
|
|
275
|
+
if (concurrency.isErr()) {
|
|
276
|
+
return createComposeBatchValidationResults(calls, concurrency.error);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const runCall = async (
|
|
280
|
+
[target, batchInput]: ScenarioComposeCall,
|
|
281
|
+
_branchIndex: number
|
|
282
|
+
) => await invokeCompose(target, batchInput, self);
|
|
283
|
+
|
|
284
|
+
const limit = concurrency.value ?? calls.length;
|
|
285
|
+
return limit >= calls.length
|
|
286
|
+
? await executeUnlimitedComposeBatch(calls, runCall)
|
|
287
|
+
: await executeLimitedComposeBatch(calls, runCall, limit);
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const compose = async function compose(
|
|
291
|
+
idOrTrail: ScenarioComposeTarget | readonly ScenarioComposeCall[],
|
|
292
|
+
inputOrOptions?: unknown
|
|
293
|
+
) {
|
|
294
|
+
if (Array.isArray(idOrTrail)) {
|
|
295
|
+
return await executeComposeBatch(
|
|
296
|
+
idOrTrail,
|
|
297
|
+
compose as ComposeFn,
|
|
298
|
+
inputOrOptions as ComposeBatchOptions | undefined
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return await invokeCompose(
|
|
303
|
+
idOrTrail as ScenarioComposeTarget,
|
|
304
|
+
inputOrOptions,
|
|
305
|
+
compose as ComposeFn
|
|
306
|
+
);
|
|
307
|
+
} as ComposeFn;
|
|
308
|
+
|
|
309
|
+
return compose;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Execute a single scenario step: run the trail, assert expectations,
|
|
314
|
+
* and record outputs.
|
|
315
|
+
*/
|
|
316
|
+
const executeStep = async (
|
|
317
|
+
step: ScenarioStep,
|
|
318
|
+
index: number,
|
|
319
|
+
app: Topo,
|
|
320
|
+
outputs: Map<string, unknown>,
|
|
321
|
+
resources?: ResourceOverrideMap
|
|
322
|
+
): Promise<void> => {
|
|
323
|
+
if (step.as !== undefined && outputs.has(step.as)) {
|
|
324
|
+
throw new Error(
|
|
325
|
+
`scenario: duplicate step alias "${step.as}" — each alias must be unique`
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const scenarioCompose = createScenarioCompose(app, resources);
|
|
330
|
+
const baseCtx = createTestContext();
|
|
331
|
+
const resolvedInput = deriveRefs(step.input, outputs);
|
|
332
|
+
const result = await executeTrail(step.compose, resolvedInput, {
|
|
333
|
+
ctx: { ...baseCtx, compose: scenarioCompose },
|
|
334
|
+
resources,
|
|
335
|
+
topo: app,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
if (result.isErr()) {
|
|
339
|
+
throw new Error(
|
|
340
|
+
`Step ${String(index + 1)} ("${step.as ?? step.compose.id}") failed: ${result.error.message}`
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
await assertStepExpectations(step, result, outputs);
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Execute scenario steps sequentially, resolving mock resources once upfront.
|
|
349
|
+
*
|
|
350
|
+
* Exported for direct use in tests that need to assert on step execution
|
|
351
|
+
* without the describe/test wrapper that `scenario()` provides.
|
|
352
|
+
*/
|
|
353
|
+
export const executeScenarioSteps = async (
|
|
354
|
+
app: Topo,
|
|
355
|
+
steps: readonly ScenarioStep[]
|
|
356
|
+
): Promise<void> => {
|
|
357
|
+
const outputs = new Map<string, unknown>();
|
|
358
|
+
const resources = await createMockResources(app);
|
|
359
|
+
|
|
360
|
+
for (const [index, step] of steps.entries()) {
|
|
361
|
+
await executeStep(step, index, app, outputs, resources);
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
export const scenario = (
|
|
366
|
+
name: string,
|
|
367
|
+
app: Topo,
|
|
368
|
+
steps: readonly ScenarioStep[]
|
|
369
|
+
): void => {
|
|
370
|
+
describe(name, () => {
|
|
371
|
+
test('executes all steps', async () => {
|
|
372
|
+
await executeScenarioSteps(app, steps);
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
};
|