@ontrails/testing 1.0.0-beta.2 → 1.0.0-beta.21

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.
Files changed (91) hide show
  1. package/CHANGELOG.md +396 -5
  2. package/README.md +86 -22
  3. package/package.json +34 -5
  4. package/src/all-established.ts +168 -0
  5. package/src/all.ts +51 -12
  6. package/src/assertions.ts +253 -0
  7. package/src/cli.ts +6 -0
  8. package/src/composes.ts +433 -0
  9. package/src/context.ts +200 -14
  10. package/src/contracts.ts +45 -21
  11. package/src/detours.ts +155 -18
  12. package/src/effective-examples.ts +414 -0
  13. package/src/errors.ts +47 -0
  14. package/src/examples.ts +302 -177
  15. package/src/harness-cli.ts +83 -58
  16. package/src/harness-http.ts +341 -0
  17. package/src/harness-mcp.ts +46 -16
  18. package/src/http.ts +10 -0
  19. package/src/index.ts +22 -14
  20. package/src/logger.ts +3 -1
  21. package/src/mcp.ts +6 -0
  22. package/src/scenario.ts +375 -0
  23. package/src/signals.ts +221 -0
  24. package/src/surface-parity.ts +389 -0
  25. package/src/trail.ts +1 -1
  26. package/src/types.ts +24 -52
  27. package/.turbo/turbo-build.log +0 -1
  28. package/.turbo/turbo-lint.log +0 -3
  29. package/.turbo/turbo-typecheck.log +0 -1
  30. package/dist/all.d.ts +0 -30
  31. package/dist/all.d.ts.map +0 -1
  32. package/dist/all.js +0 -47
  33. package/dist/all.js.map +0 -1
  34. package/dist/assertions.d.ts +0 -49
  35. package/dist/assertions.d.ts.map +0 -1
  36. package/dist/assertions.js +0 -84
  37. package/dist/assertions.js.map +0 -1
  38. package/dist/context.d.ts +0 -19
  39. package/dist/context.d.ts.map +0 -1
  40. package/dist/context.js +0 -33
  41. package/dist/context.js.map +0 -1
  42. package/dist/contracts.d.ts +0 -16
  43. package/dist/contracts.d.ts.map +0 -1
  44. package/dist/contracts.js +0 -56
  45. package/dist/contracts.js.map +0 -1
  46. package/dist/detours.d.ts +0 -12
  47. package/dist/detours.d.ts.map +0 -1
  48. package/dist/detours.js +0 -30
  49. package/dist/detours.js.map +0 -1
  50. package/dist/examples.d.ts +0 -22
  51. package/dist/examples.d.ts.map +0 -1
  52. package/dist/examples.js +0 -187
  53. package/dist/examples.js.map +0 -1
  54. package/dist/harness-cli.d.ts +0 -21
  55. package/dist/harness-cli.d.ts.map +0 -1
  56. package/dist/harness-cli.js +0 -213
  57. package/dist/harness-cli.js.map +0 -1
  58. package/dist/harness-mcp.d.ts +0 -21
  59. package/dist/harness-mcp.d.ts.map +0 -1
  60. package/dist/harness-mcp.js +0 -50
  61. package/dist/harness-mcp.js.map +0 -1
  62. package/dist/hike.d.ts +0 -32
  63. package/dist/hike.d.ts.map +0 -1
  64. package/dist/hike.js +0 -169
  65. package/dist/hike.js.map +0 -1
  66. package/dist/index.d.ts +0 -14
  67. package/dist/index.d.ts.map +0 -1
  68. package/dist/index.js +0 -16
  69. package/dist/index.js.map +0 -1
  70. package/dist/logger.d.ts +0 -15
  71. package/dist/logger.d.ts.map +0 -1
  72. package/dist/logger.js +0 -87
  73. package/dist/logger.js.map +0 -1
  74. package/dist/trail.d.ts +0 -20
  75. package/dist/trail.d.ts.map +0 -1
  76. package/dist/trail.js +0 -80
  77. package/dist/trail.js.map +0 -1
  78. package/dist/types.d.ts +0 -80
  79. package/dist/types.d.ts.map +0 -1
  80. package/dist/types.js +0 -5
  81. package/dist/types.js.map +0 -1
  82. package/src/__tests__/context.test.ts +0 -60
  83. package/src/__tests__/contracts.test.ts +0 -68
  84. package/src/__tests__/detours.test.ts +0 -55
  85. package/src/__tests__/examples.test.ts +0 -176
  86. package/src/__tests__/hike.test.ts +0 -164
  87. package/src/__tests__/logger.test.ts +0 -136
  88. package/src/__tests__/trail.test.ts +0 -99
  89. package/src/hike.ts +0 -283
  90. package/tsconfig.json +0 -9
  91. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,168 @@
1
+ /**
2
+ * testAllEstablished - contract suite plus shipped surface projection checks.
3
+ *
4
+ * This helper intentionally lives behind a surface subpath so root
5
+ * `@ontrails/testing` imports do not pull CLI, MCP, or HTTP peers into
6
+ * contract-only consumers.
7
+ */
8
+
9
+ import { describe, expect, test } from 'bun:test';
10
+
11
+ import type { Topo, TrailContext } from '@ontrails/core';
12
+ import { validateEstablishedTopo } from '@ontrails/core';
13
+
14
+ import { registerContractSuite } from './all.js';
15
+ import type { TestExecutionOptions } from './context.js';
16
+ import { createCliHarness } from './harness-cli.js';
17
+ import type { CliHarnessOptions } from './harness-cli.js';
18
+ import { createHttpHarness } from './harness-http.js';
19
+ import type { HttpHarnessOptions } from './harness-http.js';
20
+ import { createMcpHarness } from './harness-mcp.js';
21
+ import type { McpHarnessOptions } from './harness-mcp.js';
22
+
23
+ export interface TestAllEstablishedOptions {
24
+ readonly cli?: Omit<CliHarnessOptions, 'graph'> | undefined;
25
+ readonly createPermit?:
26
+ | ((trail: {
27
+ readonly permit?:
28
+ | { readonly scopes: readonly string[] }
29
+ | 'public'
30
+ | undefined;
31
+ }) =>
32
+ | {
33
+ readonly id: string;
34
+ readonly scopes: readonly string[];
35
+ }
36
+ | undefined)
37
+ | undefined;
38
+ readonly ctx?: Partial<TrailContext> | undefined;
39
+ readonly http?: Omit<HttpHarnessOptions, 'graph'> | undefined;
40
+ readonly mcp?: Omit<McpHarnessOptions, 'graph'> | undefined;
41
+ readonly resources?: Record<string, unknown> | undefined;
42
+ readonly strictPermits?: boolean | undefined;
43
+ }
44
+
45
+ type EstablishedInput =
46
+ | Partial<TrailContext>
47
+ | TestAllEstablishedOptions
48
+ | (() => Partial<TrailContext> | TestAllEstablishedOptions);
49
+
50
+ const isEstablishedOptions = (
51
+ input: Partial<TrailContext> | TestAllEstablishedOptions | undefined
52
+ ): input is TestAllEstablishedOptions =>
53
+ input !== undefined &&
54
+ (Object.hasOwn(input, 'cli') ||
55
+ Object.hasOwn(input, 'createPermit') ||
56
+ Object.hasOwn(input, 'ctx') ||
57
+ Object.hasOwn(input, 'http') ||
58
+ Object.hasOwn(input, 'mcp') ||
59
+ Object.hasOwn(input, 'resources') ||
60
+ Object.hasOwn(input, 'strictPermits'));
61
+
62
+ const normalizeEstablishedOptions = (
63
+ input?: Partial<TrailContext> | TestAllEstablishedOptions
64
+ ): TestAllEstablishedOptions =>
65
+ isEstablishedOptions(input) ? input : { ctx: input };
66
+
67
+ const toExecutionOptions = (
68
+ options: TestAllEstablishedOptions
69
+ ): TestExecutionOptions => ({
70
+ ...(options.createPermit === undefined
71
+ ? {}
72
+ : { createPermit: options.createPermit }),
73
+ ...(options.ctx === undefined ? {} : { ctx: options.ctx }),
74
+ ...(options.resources === undefined ? {} : { resources: options.resources }),
75
+ ...(options.strictPermits === undefined
76
+ ? {}
77
+ : { strictPermits: options.strictPermits }),
78
+ });
79
+
80
+ const toCliHarnessOptions = (
81
+ topo: Topo,
82
+ options: TestAllEstablishedOptions
83
+ ) => {
84
+ const cliOptions = {
85
+ graph: topo,
86
+ ...options.cli,
87
+ };
88
+
89
+ if (options.ctx !== undefined) {
90
+ cliOptions.ctx = options.ctx;
91
+ }
92
+
93
+ return cliOptions;
94
+ };
95
+
96
+ const toMcpHarnessOptions = (
97
+ topo: Topo,
98
+ options: TestAllEstablishedOptions
99
+ ) => ({
100
+ graph: topo,
101
+ ...options.mcp,
102
+ });
103
+
104
+ const toHttpHarnessOptions = (
105
+ topo: Topo,
106
+ options: TestAllEstablishedOptions
107
+ ) => {
108
+ const httpOptions = {
109
+ graph: topo,
110
+ ...options.http,
111
+ };
112
+
113
+ if (options.ctx !== undefined) {
114
+ httpOptions.ctx = options.ctx;
115
+ }
116
+
117
+ return httpOptions;
118
+ };
119
+
120
+ const registerEstablishedSurfaceSuite = (
121
+ topo: Topo,
122
+ resolveInput: () =>
123
+ | Partial<TrailContext>
124
+ | TestAllEstablishedOptions
125
+ | undefined
126
+ ): void => {
127
+ describe('surfaces', () => {
128
+ test('CLI projection validates established topo', () => {
129
+ const options = normalizeEstablishedOptions(resolveInput());
130
+ expect(() =>
131
+ createCliHarness(toCliHarnessOptions(topo, options))
132
+ ).not.toThrow();
133
+ });
134
+
135
+ test('MCP projection validates established topo', () => {
136
+ const options = normalizeEstablishedOptions(resolveInput());
137
+ expect(() =>
138
+ createMcpHarness(toMcpHarnessOptions(topo, options))
139
+ ).not.toThrow();
140
+ });
141
+
142
+ test('HTTP projection validates established topo', () => {
143
+ const options = normalizeEstablishedOptions(resolveInput());
144
+ expect(() =>
145
+ createHttpHarness(toHttpHarnessOptions(topo, options))
146
+ ).not.toThrow();
147
+ });
148
+ });
149
+ };
150
+
151
+ export const testAllEstablished = (
152
+ topo: Topo,
153
+ optionsOrFactory?: EstablishedInput
154
+ ): void => {
155
+ const resolveInput =
156
+ typeof optionsOrFactory === 'function'
157
+ ? optionsOrFactory
158
+ : () => optionsOrFactory;
159
+
160
+ registerContractSuite(
161
+ topo,
162
+ () => toExecutionOptions(normalizeEstablishedOptions(resolveInput())),
163
+ validateEstablishedTopo
164
+ );
165
+ registerEstablishedSurfaceSuite(topo, resolveInput);
166
+ };
167
+
168
+ export type { TestAllInput } from './all.js';
package/src/all.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
- * testAll — single-line governance suite for any Topo.
2
+ * testAll — single-line contract suite for any Topo.
3
3
  *
4
4
  * Wraps topo validation, example execution, contract checks, and detour
5
- * verification into one describe block.
5
+ * contract validation into one describe block.
6
6
  */
7
7
 
8
8
  import { describe, expect, test } from 'bun:test';
@@ -11,17 +11,18 @@ import type { Topo, TrailContext } from '@ontrails/core';
11
11
  import { validateTopo } from '@ontrails/core';
12
12
 
13
13
  import { testContracts } from './contracts.js';
14
+ import type { TestExecutionOptions } from './context.js';
14
15
  import { testDetours } from './detours.js';
15
16
  import { testExamples } from './examples.js';
16
17
 
17
18
  /**
18
- * Run the full governance test suite for a Topo.
19
+ * Run the full contract test suite for a Topo.
19
20
  *
20
- * Generates a `governance` describe block containing:
21
+ * Generates a `contract` describe block containing:
21
22
  * - Structural validation via `validateTopo`
22
23
  * - Example execution via `testExamples`
23
24
  * - Output contract checks via `testContracts`
24
- * - Detour target verification via `testDetours`
25
+ * - Detour contract validation via `testDetours`
25
26
  *
26
27
  * Accepts either a static context or a factory function that produces a
27
28
  * fresh context per test (useful when the context contains mutable state
@@ -30,19 +31,53 @@ import { testExamples } from './examples.js';
30
31
  * @example
31
32
  * ```ts
32
33
  * import { testAll } from '@ontrails/testing';
33
- * import { app } from '../src/app.js';
34
+ * import { graph } from '../src/app.js';
34
35
  *
35
- * testAll(app);
36
+ * testAll(graph);
36
37
  * ```
37
38
  */
38
- export const testAll = (
39
+ export type TestAllInput =
40
+ | Partial<TrailContext>
41
+ | TestExecutionOptions
42
+ | (() => Partial<TrailContext> | TestExecutionOptions);
43
+
44
+ const formatValidationFailure = (error: Error): string => {
45
+ const issues = (
46
+ error as {
47
+ context?: { issues?: readonly Record<string, unknown>[] };
48
+ }
49
+ ).context?.issues;
50
+
51
+ if (issues === undefined || issues.length === 0) {
52
+ return error.message;
53
+ }
54
+
55
+ const details = issues.map((issue) => {
56
+ const id = typeof issue['id'] === 'string' ? issue['id'] : undefined;
57
+ const message =
58
+ typeof issue['message'] === 'string' ? issue['message'] : undefined;
59
+ const rule = typeof issue['rule'] === 'string' ? issue['rule'] : undefined;
60
+
61
+ return [rule, id, message].filter(Boolean).join(': ');
62
+ });
63
+
64
+ return [error.message, ...details].join('\n');
65
+ };
66
+
67
+ const assertValidTopo = (result: ReturnType<typeof validateTopo>): void => {
68
+ if (result.isErr()) {
69
+ throw new Error(formatValidationFailure(result.error));
70
+ }
71
+ };
72
+
73
+ export const registerContractSuite = (
39
74
  topo: Topo,
40
- ctxOrFactory?: Partial<TrailContext> | (() => Partial<TrailContext>)
75
+ ctxOrFactory: TestAllInput | undefined,
76
+ validate: (topo: Topo) => ReturnType<typeof validateTopo>
41
77
  ): void => {
42
- describe('governance', () => {
78
+ describe('contract', () => {
43
79
  test('topo validates', () => {
44
- const result = validateTopo(topo);
45
- expect(result.isOk()).toBe(true);
80
+ expect(() => assertValidTopo(validate(topo))).not.toThrow();
46
81
  });
47
82
 
48
83
  // oxlint-disable-next-line jest/require-hook -- these generate describe/test blocks, not setup code
@@ -53,3 +88,7 @@ export const testAll = (
53
88
  testDetours(topo);
54
89
  });
55
90
  };
91
+
92
+ export const testAll = (topo: Topo, ctxOrFactory?: TestAllInput): void => {
93
+ registerContractSuite(topo, ctxOrFactory, validateTopo);
94
+ };
package/src/assertions.ts CHANGED
@@ -49,6 +49,40 @@ export const expectErr = <T, E>(result: Result<T, E>): E => {
49
49
  return (result as unknown as { error: E }).error;
50
50
  };
51
51
 
52
+ // ---------------------------------------------------------------------------
53
+ // Result Match Tokens
54
+ // ---------------------------------------------------------------------------
55
+
56
+ export interface OkResultMatch {
57
+ readonly __resultMatch: 'ok';
58
+ readonly value?: unknown | undefined;
59
+ }
60
+
61
+ export interface ErrResultMatch {
62
+ readonly __resultMatch: 'err';
63
+ readonly error?: unknown | undefined;
64
+ }
65
+
66
+ type ResultMatchToken = OkResultMatch | ErrResultMatch;
67
+
68
+ /**
69
+ * Create a partial-match token for `Result.ok(...)` values nested inside
70
+ * arrays or objects, such as the `Result[]` returned by batch `ctx.compose()`.
71
+ */
72
+ export const okResultMatch = (value?: unknown): OkResultMatch => ({
73
+ __resultMatch: 'ok',
74
+ value,
75
+ });
76
+
77
+ /**
78
+ * Create a partial-match token for `Result.err(...)` values nested inside
79
+ * arrays or objects, such as mixed-success batch `ctx.compose()`.
80
+ */
81
+ export const errResultMatch = (error?: unknown): ErrResultMatch => ({
82
+ __resultMatch: 'err',
83
+ error,
84
+ });
85
+
52
86
  // ---------------------------------------------------------------------------
53
87
  // Full Match
54
88
  // ---------------------------------------------------------------------------
@@ -87,6 +121,225 @@ export const assertSchemaMatch = (
87
121
  }
88
122
  };
89
123
 
124
+ // ---------------------------------------------------------------------------
125
+ // Partial Match
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /** Format a path for error messages. */
129
+ const formatLoc = (path: readonly string[]): string =>
130
+ path.length > 0 ? path.join('.') : 'root';
131
+
132
+ interface ResultLike {
133
+ readonly error?: unknown;
134
+ isErr(): boolean;
135
+ isOk(): boolean;
136
+ readonly value?: unknown;
137
+ }
138
+
139
+ const isResultMatchToken = (value: unknown): value is ResultMatchToken =>
140
+ typeof value === 'object' &&
141
+ value !== null &&
142
+ '__resultMatch' in value &&
143
+ ((value as Record<string, unknown>)['__resultMatch'] === 'ok' ||
144
+ (value as Record<string, unknown>)['__resultMatch'] === 'err');
145
+
146
+ const isResultLike = (value: unknown): value is ResultLike =>
147
+ typeof value === 'object' &&
148
+ value !== null &&
149
+ 'isOk' in value &&
150
+ typeof (value as Record<string, unknown>)['isOk'] === 'function' &&
151
+ 'isErr' in value &&
152
+ typeof (value as Record<string, unknown>)['isErr'] === 'function';
153
+
154
+ /** Find an unconsumed actual element that deep-matches the expected object. */
155
+ const findObjectMatch = (
156
+ actual: unknown[],
157
+ elem: object,
158
+ consumed: ReadonlySet<number>,
159
+ path: readonly string[],
160
+ index: number
161
+ ): number =>
162
+ actual.findIndex((a, idx) => {
163
+ if (consumed.has(idx)) {
164
+ return false;
165
+ }
166
+ try {
167
+ // oxlint-disable-next-line no-use-before-define -- mutual recursion with assertSubset
168
+ assertSubset(a, elem, [...path, `[${String(index)}]`]);
169
+ return true;
170
+ } catch {
171
+ return false;
172
+ }
173
+ });
174
+
175
+ /**
176
+ * Assert that every element in `expected` exists in `actual` (order-independent).
177
+ *
178
+ * Tracks consumed indices so that duplicate expected elements each require a
179
+ * distinct actual element — `['a', 'a']` does not match `['a']`.
180
+ */
181
+ const assertArraySubset = (
182
+ actual: unknown[],
183
+ expected: unknown[],
184
+ path: readonly string[],
185
+ loc: string
186
+ ): void => {
187
+ const consumed = new Set<number>();
188
+ for (let i = 0; i < expected.length; i += 1) {
189
+ const elem = expected[i];
190
+ const matchIndex =
191
+ typeof elem === 'object' && elem !== null
192
+ ? findObjectMatch(actual, elem, consumed, path, i)
193
+ : actual.findIndex((a, idx) => !consumed.has(idx) && a === elem);
194
+
195
+ if (matchIndex === -1) {
196
+ throw new Error(
197
+ `at ${loc}[${String(i)}]: expected array to contain ${JSON.stringify(elem)}`
198
+ );
199
+ }
200
+ consumed.add(matchIndex);
201
+ }
202
+ };
203
+
204
+ /** Assert that every key in `expected` exists in `actual` with a matching value. */
205
+ const assertObjectSubset = (
206
+ actual: Record<string, unknown>,
207
+ expected: Record<string, unknown>,
208
+ path: readonly string[]
209
+ ): void => {
210
+ for (const key of Object.keys(expected)) {
211
+ if (!(key in actual)) {
212
+ throw new Error(
213
+ `at ${[...path, key].join('.')}: key not found in actual`
214
+ );
215
+ }
216
+ // oxlint-disable-next-line no-use-before-define -- mutual recursion with assertSubset
217
+ assertSubset(actual[key], expected[key], [...path, key]);
218
+ }
219
+ };
220
+
221
+ /**
222
+ * Recursively assert that `actual` is a superset of `expected`.
223
+ *
224
+ * - **Scalars:** strict equality.
225
+ * - **Objects:** every key in `expected` must exist in `actual` with a matching
226
+ * value. Extra keys in `actual` are ignored.
227
+ * - **Arrays:** every element in `expected` must exist in `actual`
228
+ * (order-independent subset check).
229
+ * - **Nested objects:** recursive subset matching.
230
+ */
231
+ // oxlint-disable-next-line max-statements -- recursive dispatch across four type branches
232
+ const assertSubset = (
233
+ actual: unknown,
234
+ expected: unknown,
235
+ path: readonly string[]
236
+ ): void => {
237
+ const loc = formatLoc(path);
238
+
239
+ if (expected === null || expected === undefined) {
240
+ if (actual !== expected) {
241
+ throw new Error(
242
+ `at ${loc}: expected ${String(expected)}, got ${String(actual)}`
243
+ );
244
+ }
245
+ return;
246
+ }
247
+
248
+ if (isResultMatchToken(expected)) {
249
+ // oxlint-disable-next-line no-use-before-define -- result token matching delegates back into assertSubset
250
+ assertResultTokenMatch(actual, expected, path);
251
+ return;
252
+ }
253
+
254
+ if (Array.isArray(expected)) {
255
+ if (!Array.isArray(actual)) {
256
+ throw new TypeError(`at ${loc}: expected an array, got ${typeof actual}`);
257
+ }
258
+ assertArraySubset(actual, expected, path, loc);
259
+ return;
260
+ }
261
+
262
+ if (typeof expected === 'object') {
263
+ if (
264
+ typeof actual !== 'object' ||
265
+ actual === null ||
266
+ Array.isArray(actual)
267
+ ) {
268
+ throw new Error(`at ${loc}: expected an object, got ${typeof actual}`);
269
+ }
270
+ assertObjectSubset(
271
+ actual as Record<string, unknown>,
272
+ expected as Record<string, unknown>,
273
+ path
274
+ );
275
+ return;
276
+ }
277
+
278
+ if (actual !== expected) {
279
+ throw new Error(
280
+ `at ${loc}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`
281
+ );
282
+ }
283
+ };
284
+
285
+ const assertOkResultTokenMatch = (
286
+ actual: ResultLike,
287
+ expected: OkResultMatch,
288
+ loc: string,
289
+ path: readonly string[]
290
+ ): void => {
291
+ if (!actual.isOk()) {
292
+ throw new Error(`at ${loc}: expected Result.ok(...), got Result.err(...)`);
293
+ }
294
+ if (expected.value !== undefined) {
295
+ assertSubset(actual.value, expected.value, [...path, 'value']);
296
+ }
297
+ };
298
+
299
+ const assertErrResultTokenMatch = (
300
+ actual: ResultLike,
301
+ expected: ErrResultMatch,
302
+ loc: string,
303
+ path: readonly string[]
304
+ ): void => {
305
+ if (!actual.isErr()) {
306
+ throw new Error(`at ${loc}: expected Result.err(...), got Result.ok(...)`);
307
+ }
308
+ if (expected.error !== undefined) {
309
+ assertSubset(actual.error, expected.error, [...path, 'error']);
310
+ }
311
+ };
312
+
313
+ const assertResultTokenMatch = (
314
+ actual: unknown,
315
+ expected: ResultMatchToken,
316
+ path: readonly string[]
317
+ ): void => {
318
+ const loc = formatLoc(path);
319
+ if (!isResultLike(actual)) {
320
+ throw new TypeError(`at ${loc}: expected a Result-like value`);
321
+ }
322
+
323
+ if (expected.__resultMatch === 'ok') {
324
+ assertOkResultTokenMatch(actual, expected, loc, path);
325
+ return;
326
+ }
327
+
328
+ assertErrResultTokenMatch(actual, expected, loc, path);
329
+ };
330
+
331
+ /**
332
+ * Assert that the result is ok and its value is a superset of the expected
333
+ * partial output. Declared fields must match; extra fields are ignored.
334
+ */
335
+ export const assertPartialMatch = (
336
+ result: Result<unknown, Error>,
337
+ expectedMatch: unknown
338
+ ): void => {
339
+ const value = expectOk(result);
340
+ assertSubset(value, expectedMatch, []);
341
+ };
342
+
90
343
  // ---------------------------------------------------------------------------
91
344
  // Error Match
92
345
  // ---------------------------------------------------------------------------
package/src/cli.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { createCliHarness } from './harness-cli.js';
2
+ export type {
3
+ CliHarness,
4
+ CliHarnessOptions,
5
+ CliHarnessResult,
6
+ } from './harness-cli.js';