@yolk-sdk/conformance 0.1.0-canary.96

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/src/runner.ts ADDED
@@ -0,0 +1,559 @@
1
+ /**
2
+ * Conformance runner: decides which cases may run against a target (safety
3
+ * policy), runs each allowed case in isolation with freshly built layers, and
4
+ * produces a plain-data report.
5
+ *
6
+ * The runner never performs network I/O itself; whatever the per-case layer
7
+ * provides (a replay `HttpClient`, an emulator, or a host's live client) is
8
+ * what the case talks to.
9
+ *
10
+ * @experimental
11
+ */
12
+ import { Cause, Clock, Effect, Exit, Option, Predicate, type Layer } from 'effect'
13
+ import { ConformanceMismatch, type ConformanceCase, type ConformanceSafety } from './case.ts'
14
+ import { conformanceFixtureEvidence, fixtureAgeDays, type ConformanceFixture } from './fixture.ts'
15
+ import { redactCredentialText } from './wire-internal.ts'
16
+
17
+ /**
18
+ * Where cases run. `replay`, `in-process`, and `emulated` never touch a real
19
+ * account, so every case runs. `live` talks to a real practice account and is
20
+ * gated by the safety policy (see `conformanceSkipReason`).
21
+ */
22
+ export type ConformanceTarget =
23
+ | { readonly kind: 'replay' | 'in-process' | 'emulated' }
24
+ | {
25
+ readonly kind: 'live'
26
+ /** Synthetic, non-identifying account label (for example `practice`). */
27
+ readonly account: string
28
+ /** `reversible` lets `write-reversible` cases run. Default `none`. */
29
+ readonly allowWrites?: 'none' | 'reversible'
30
+ /** Exact ids of `write-irreversible` cases a person explicitly started. */
31
+ readonly allowIrreversible?: ReadonlyArray<string>
32
+ }
33
+
34
+ export type ConformanceTargetKind = ConformanceTarget['kind']
35
+
36
+ export type ConformanceSkipReason = 'writes-not-allowed' | 'manual-only'
37
+
38
+ /**
39
+ * The safety policy. `undefined` means the case may run.
40
+ *
41
+ * - `replay` / `in-process` / `emulated`: every case runs.
42
+ * - `live`: `read` runs; `write-reversible` runs only with
43
+ * `allowWrites: 'reversible'` (else `writes-not-allowed`);
44
+ * `write-irreversible` runs only when its exact id is in
45
+ * `allowIrreversible` (else `manual-only`), independent of `allowWrites`.
46
+ */
47
+ export const conformanceSkipReason = (
48
+ target: ConformanceTarget,
49
+ testCase: { readonly id: string; readonly safety: ConformanceSafety }
50
+ ): ConformanceSkipReason | undefined => {
51
+ if (target.kind !== 'live') {
52
+ return undefined
53
+ }
54
+
55
+ switch (testCase.safety) {
56
+ case 'read':
57
+ return undefined
58
+ case 'write-reversible':
59
+ return target.allowWrites === 'reversible' ? undefined : 'writes-not-allowed'
60
+ case 'write-irreversible':
61
+ return (target.allowIrreversible ?? []).includes(testCase.id) ? undefined : 'manual-only'
62
+ }
63
+ }
64
+
65
+ /** Non-fatal findings attached to a case result. */
66
+ export type ConformanceWarning =
67
+ /** The case has no `observed`: its claim was never watched against the real service. */
68
+ | { readonly kind: 'unverified-case' }
69
+ /** `observed.date` is older than the max age (`ageDays` absent when unreadable). */
70
+ | { readonly kind: 'stale-observation'; readonly ageDays?: number }
71
+ /** A referenced fixture is a synthetic placeholder (`evidence: 'unverified'`, or a `PortFixture` without `observed`). */
72
+ | { readonly kind: 'unverified-fixture'; readonly fixtureId: string }
73
+ /** A referenced fixture is older than the max age (`ageDays` absent when unreadable). */
74
+ | { readonly kind: 'stale-fixture'; readonly fixtureId: string; readonly ageDays?: number }
75
+ /** A referenced fixture id is not among the supplied fixtures. */
76
+ | { readonly kind: 'missing-fixture'; readonly fixtureId: string }
77
+
78
+ export type ConformanceWarningKind = ConformanceWarning['kind']
79
+
80
+ /**
81
+ * Sanitized failure. `message` is a `ConformanceMismatch`'s case-authored
82
+ * message (credential patterns redacted) or, for every other failure, layer
83
+ * failure, and defect, the error's own message passed through
84
+ * `sanitizeConformanceMessage` (credential patterns and header lines redacted, JSON
85
+ * spans elided, whitespace collapsed, length capped). `tag` is the error's
86
+ * `_tag` only when it is identifier-like. Request bodies, headers, and
87
+ * `ConformanceMismatch` `expected`/`actual` details are never copied.
88
+ */
89
+ export type ConformanceFailure = {
90
+ /** `failure`: typed error (including layer build errors); `defect`: unexpected die. */
91
+ readonly kind: 'failure' | 'defect'
92
+ readonly tag?: string
93
+ readonly message: string
94
+ }
95
+
96
+ export type ConformanceCaseStatus = 'passed' | 'failed' | 'skipped'
97
+
98
+ export type ConformanceCaseResult = {
99
+ readonly id: string
100
+ readonly safety: ConformanceSafety
101
+ readonly status: ConformanceCaseStatus
102
+ readonly skipReason?: ConformanceSkipReason
103
+ readonly failure?: ConformanceFailure
104
+ readonly durationMs: number
105
+ readonly warnings: ReadonlyArray<ConformanceWarning>
106
+ }
107
+
108
+ export type ConformanceReport = {
109
+ readonly target: { readonly kind: ConformanceTargetKind; readonly account?: string }
110
+ /** ISO timestamp of the run start. */
111
+ readonly startedAt: string
112
+ readonly results: ReadonlyArray<ConformanceCaseResult>
113
+ readonly summary: {
114
+ readonly passed: number
115
+ readonly failed: number
116
+ readonly skipped: number
117
+ }
118
+ }
119
+
120
+ /** What a case (or a union of cases) needs from its layer. */
121
+ export type ConformanceCaseRequirements<C> = C extends {
122
+ readonly run: Effect.Effect<void, unknown, infer R>
123
+ }
124
+ ? R
125
+ : never
126
+
127
+ type RunSettings = {
128
+ readonly target: ConformanceTarget
129
+ /**
130
+ * Fixtures to check referenced ids against (evidence, staleness, missing ids): HTTP
131
+ * `WireFixture`s and `PortFixture`s alike.
132
+ */
133
+ readonly fixtures?: ReadonlyArray<ConformanceFixture>
134
+ /** Reference time for staleness and `startedAt`. Defaults to the Effect `Clock`. */
135
+ readonly now?: Date
136
+ /** Fixtures and observations older than this many whole days are stale. Default 30. */
137
+ readonly maxFixtureAgeDays?: number
138
+ /** Cases run in parallel up to this limit. Default 1: live accounts are shared. */
139
+ readonly concurrency?: number
140
+ }
141
+
142
+ /**
143
+ * Options for `runConformance` over cases of type `C` (a union when the cases
144
+ * differ).
145
+ *
146
+ * `layer` is called once per case that runs and must provide everything that
147
+ * case needs (`ConformanceCaseRequirements<C>`; providing more is fine). It is
148
+ * called and built inside the case's failure boundary, with a fresh memo map
149
+ * in its own scope, so state the layer allocates when it is built (replay
150
+ * consumption, ledgers, emulator state) never leaks between cases, even when
151
+ * the factory returns the same `Layer` value. Services captured in a shared
152
+ * value (for example one `Layer.succeed(service, instance)`) or supplied by
153
+ * the caller's environment are not rebuilt and stay shared. Build failures
154
+ * (`LE`) and a throwing factory are reported as failed cases. `LR` is
155
+ * whatever the layers still need from the caller (for example a live
156
+ * `HttpClient`) and becomes the requirement of the whole run.
157
+ */
158
+ export type ConformanceRunOptions<C, LE = never, LR = never> = RunSettings & {
159
+ readonly layer: (testCase: C) => Layer.Layer<ConformanceCaseRequirements<C>, LE, LR>
160
+ }
161
+
162
+ const staleAge = (
163
+ date: string,
164
+ now: Date,
165
+ maxAgeDays: number
166
+ ): Option.Option<number | undefined> => {
167
+ const age = fixtureAgeDays({ recordedAt: date }, now)
168
+
169
+ if (Number.isNaN(age)) {
170
+ return Option.some(undefined)
171
+ }
172
+
173
+ return age > maxAgeDays ? Option.some(age) : Option.none()
174
+ }
175
+
176
+ const withAge = <W extends { readonly kind: string }>(
177
+ warning: W,
178
+ ageDays: number | undefined
179
+ ): W | (W & { readonly ageDays: number }) =>
180
+ ageDays === undefined ? warning : { ...warning, ageDays }
181
+
182
+ /**
183
+ * Warnings for one case. Case-level warnings always apply; fixture-level ones
184
+ * only on non-live targets and only when `fixtures` were supplied.
185
+ */
186
+ export const conformanceCaseWarnings = (
187
+ testCase: Pick<ConformanceCase<unknown, unknown>, 'observed' | 'fixtures'>,
188
+ context: {
189
+ readonly target: ConformanceTarget
190
+ readonly now: Date
191
+ readonly fixtures?: ReadonlyArray<ConformanceFixture> | undefined
192
+ readonly maxFixtureAgeDays?: number | undefined
193
+ }
194
+ ): ReadonlyArray<ConformanceWarning> => {
195
+ const maxAgeDays = context.maxFixtureAgeDays ?? 30
196
+ const warnings: Array<ConformanceWarning> = []
197
+
198
+ if (testCase.observed === undefined) {
199
+ warnings.push({ kind: 'unverified-case' })
200
+ } else {
201
+ const stale = staleAge(testCase.observed.date, context.now, maxAgeDays)
202
+
203
+ if (Option.isSome(stale)) {
204
+ warnings.push(withAge({ kind: 'stale-observation' }, stale.value))
205
+ }
206
+ }
207
+
208
+ const fixtures = context.fixtures
209
+
210
+ if (context.target.kind === 'live' || fixtures === undefined) {
211
+ return warnings
212
+ }
213
+
214
+ for (const fixtureId of testCase.fixtures) {
215
+ const fixture = fixtures.find(candidate => candidate.id === fixtureId)
216
+
217
+ if (fixture === undefined) {
218
+ warnings.push({ kind: 'missing-fixture', fixtureId })
219
+ continue
220
+ }
221
+
222
+ const { evidence, date } = conformanceFixtureEvidence(fixture)
223
+
224
+ if (evidence === 'unverified') {
225
+ warnings.push({ kind: 'unverified-fixture', fixtureId })
226
+ }
227
+
228
+ // A synthetic `PortFixture` carries no date: it is unverified, not stale.
229
+ const stale = date === undefined ? Option.none() : staleAge(date, context.now, maxAgeDays)
230
+
231
+ if (Option.isSome(stale)) {
232
+ warnings.push(withAge({ kind: 'stale-fixture', fixtureId }, stale.value))
233
+ }
234
+ }
235
+
236
+ return warnings
237
+ }
238
+
239
+ const maxFailureMessageLength = 300
240
+
241
+ // Only identifier-like tags are reported; anything else (spaces, punctuation, payload text) is
242
+ // dropped.
243
+ const reportableTagPattern = /^[A-Za-z][A-Za-z0-9_]*$/
244
+
245
+ const collapseAndCap = (message: string): string => {
246
+ const compact = message.replace(/\s+/g, ' ').trim()
247
+
248
+ return compact.length > maxFailureMessageLength
249
+ ? `${compact.slice(0, maxFailureMessageLength - 3)}...`
250
+ : compact
251
+ }
252
+
253
+ /**
254
+ * End index of the balanced `{...}` / `[...]` segment opening at `start` (strings respected);
255
+ * `undefined` when it never closes or closes with the wrong bracket.
256
+ */
257
+ const balancedSegmentEnd = (text: string, start: number): number | undefined => {
258
+ const expected: Array<string> = []
259
+ let inString = false
260
+
261
+ for (let index = start; index < text.length; index++) {
262
+ const char = text[index]
263
+
264
+ if (inString) {
265
+ if (char === '\\') {
266
+ index++
267
+ } else if (char === '"') {
268
+ inString = false
269
+ }
270
+
271
+ continue
272
+ }
273
+
274
+ if (char === '"') {
275
+ inString = true
276
+ } else if (char === '{') {
277
+ expected.push('}')
278
+ } else if (char === '[') {
279
+ expected.push(']')
280
+ } else if (char === '}' || char === ']') {
281
+ if (expected.pop() !== char) {
282
+ return undefined
283
+ }
284
+
285
+ if (expected.length === 0) {
286
+ return index
287
+ }
288
+ }
289
+ }
290
+
291
+ return undefined
292
+ }
293
+
294
+ // Replace every balanced `{...}` / `[...]` segment with `[json]`. An unbalanced opening bracket
295
+ // (for example a truncated body) elides the rest of the message.
296
+ const elideJsonSpans = (text: string): string => {
297
+ let output = ''
298
+ let index = 0
299
+
300
+ while (index < text.length) {
301
+ const char = text[index]
302
+
303
+ if (char !== '{' && char !== '[') {
304
+ output += char
305
+ index++
306
+ continue
307
+ }
308
+
309
+ const end = balancedSegmentEnd(text, index)
310
+ output += '[json]'
311
+
312
+ if (end === undefined) {
313
+ return output
314
+ }
315
+
316
+ index = end + 1
317
+ }
318
+
319
+ return output
320
+ }
321
+
322
+ /**
323
+ * Best-effort sanitizer for failure messages copied into a `ConformanceReport`. Redacts the
324
+ * credential patterns shared with the fixture secret scan (bearer tokens, API-key prefixes, JWTs,
325
+ * private keys, credential field pairs, credential query/form parameters, and credential header
326
+ * lines such as `Cookie:` or `X-Api-Key:` to the end of the line), replaces JSON-looking spans
327
+ * (balanced `{...}` / `[...]`) with `[json]`, collapses whitespace, and caps the length at 300
328
+ * characters. Hosts should still keep secrets out of error messages.
329
+ */
330
+ export const sanitizeConformanceMessage = (message: string): string =>
331
+ collapseAndCap(elideJsonSpans(redactCredentialText(message)))
332
+
333
+ // A `ConformanceMismatch` message is written by the case author: keep it readable (no JSON
334
+ // elision), but still redact credential patterns.
335
+ const sanitizeMismatchMessage = (message: string): string =>
336
+ collapseAndCap(redactCredentialText(message))
337
+
338
+ const stringProperty = (value: unknown, key: string): string | undefined => {
339
+ if (!Predicate.hasProperty(value, key)) {
340
+ return undefined
341
+ }
342
+
343
+ const property = value[key]
344
+
345
+ return Predicate.isString(property) && property.length > 0 ? property : undefined
346
+ }
347
+
348
+ const describeValue = (kind: ConformanceFailure['kind'], value: unknown): ConformanceFailure => {
349
+ const rawTag = stringProperty(value, '_tag')
350
+
351
+ // Identifier-like and not credential-shaped (e.g. a `vck_...` value is never echoed).
352
+ const tag =
353
+ rawTag !== undefined &&
354
+ reportableTagPattern.test(rawTag) &&
355
+ redactCredentialText(rawTag) === rawTag
356
+ ? rawTag
357
+ : undefined
358
+
359
+ const rawMessage =
360
+ stringProperty(value, 'message') ??
361
+ (Predicate.isString(value) && value.length > 0 ? value : undefined)
362
+
363
+ const message =
364
+ rawMessage === undefined
365
+ ? (tag ?? (kind === 'defect' ? 'unexpected defect' : 'case failed'))
366
+ : value instanceof ConformanceMismatch
367
+ ? sanitizeMismatchMessage(rawMessage)
368
+ : sanitizeConformanceMessage(rawMessage)
369
+
370
+ const failure = { kind, message }
371
+
372
+ return tag === undefined ? failure : { ...failure, tag }
373
+ }
374
+
375
+ const describeCause = <E>(cause: Cause.Cause<E>): ConformanceFailure => {
376
+ const error = Cause.findErrorOption(cause)
377
+
378
+ if (Option.isSome(error)) {
379
+ return describeValue('failure', error.value)
380
+ }
381
+
382
+ return describeValue('defect', Cause.squash(cause))
383
+ }
384
+
385
+ type Counts = { passed: number; failed: number; skipped: number }
386
+
387
+ const summarize = (results: ReadonlyArray<ConformanceCaseResult>): Counts => {
388
+ const counts: Counts = { passed: 0, failed: 0, skipped: 0 }
389
+
390
+ for (const result of results) {
391
+ counts[result.status] += 1
392
+ }
393
+
394
+ return counts
395
+ }
396
+
397
+ // Pins object literals to the result type so `status` stays a literal.
398
+ const identityResult = (result: ConformanceCaseResult): ConformanceCaseResult => result
399
+
400
+ const reportTarget = (target: ConformanceTarget): ConformanceReport['target'] =>
401
+ target.kind === 'live' ? { kind: target.kind, account: target.account } : { kind: target.kind }
402
+
403
+ /**
404
+ * Run cases against a target and report. Skipped cases never build their
405
+ * layer. Each running case gets a fresh layer and runs under `Effect.exit`:
406
+ * failures, layer build failures, defects, and a throwing `layer` factory
407
+ * become `failed` results and the run continues. Interruption is not
408
+ * captured: interrupting the run (or a case interrupting itself) interrupts
409
+ * the whole run.
410
+ *
411
+ * `C` is inferred as the union of the given case types, so cases with
412
+ * different errors and requirements can share one run without annotations.
413
+ */
414
+ export function runConformance<C extends ConformanceCase<unknown, unknown>, LE = never, LR = never>(
415
+ cases: ReadonlyArray<C>,
416
+ options: ConformanceRunOptions<C, LE, LR>
417
+ ): Effect.Effect<ConformanceReport, never, LR>
418
+ // Implementation signature: one case type whose `run` needs exactly `R`, which
419
+ // is what the public signature's `ConformanceCaseRequirements<C>` denotes.
420
+ export function runConformance<E, R, LE, LR>(
421
+ cases: ReadonlyArray<ConformanceCase<E, R>>,
422
+ options: RunSettings & {
423
+ readonly layer: (testCase: ConformanceCase<E, R>) => Layer.Layer<R, LE, LR>
424
+ }
425
+ ): Effect.Effect<ConformanceReport, never, LR> {
426
+ return Effect.gen(function* () {
427
+ const now = options.now ?? new Date(yield* Clock.currentTimeMillis)
428
+
429
+ const warningContext = {
430
+ target: options.target,
431
+ now,
432
+ fixtures: options.fixtures,
433
+ maxFixtureAgeDays: options.maxFixtureAgeDays
434
+ }
435
+
436
+ const runCase = (testCase: ConformanceCase<E, R>) =>
437
+ Effect.gen(function* () {
438
+ const base = {
439
+ id: testCase.id,
440
+ safety: testCase.safety,
441
+ warnings: conformanceCaseWarnings(testCase, warningContext)
442
+ }
443
+
444
+ const skipReason = conformanceSkipReason(options.target, testCase)
445
+
446
+ if (skipReason !== undefined) {
447
+ return identityResult({ ...base, status: 'skipped', skipReason, durationMs: 0 })
448
+ }
449
+
450
+ const started = yield* Clock.currentTimeMillis
451
+
452
+ // The factory runs inside the exit boundary: a throwing factory fails this case only.
453
+ const exit = yield* Effect.suspend(() =>
454
+ testCase.run.pipe(Effect.provide(options.layer(testCase), { local: true }))
455
+ ).pipe(Effect.exit)
456
+
457
+ const durationMs = (yield* Clock.currentTimeMillis) - started
458
+
459
+ if (Exit.isSuccess(exit)) {
460
+ return identityResult({ ...base, status: 'passed', durationMs })
461
+ }
462
+
463
+ if (Cause.hasInterruptsOnly(exit.cause)) {
464
+ return yield* Effect.interrupt
465
+ }
466
+
467
+ return identityResult({
468
+ ...base,
469
+ status: 'failed',
470
+ failure: describeCause(exit.cause),
471
+ durationMs
472
+ })
473
+ })
474
+
475
+ const results = yield* Effect.forEach(cases, runCase, {
476
+ concurrency: options.concurrency ?? 1
477
+ })
478
+
479
+ return {
480
+ target: reportTarget(options.target),
481
+ startedAt: now.toISOString(),
482
+ results,
483
+ summary: summarize(results)
484
+ }
485
+ })
486
+ }
487
+
488
+ /** True when any case failed. Skipped cases never fail a report. */
489
+ export const conformanceReportFailed = (report: ConformanceReport): boolean =>
490
+ report.summary.failed > 0
491
+
492
+ const statusLabel: Record<ConformanceCaseStatus, string> = {
493
+ passed: 'PASS',
494
+ failed: 'FAIL',
495
+ skipped: 'SKIP'
496
+ }
497
+
498
+ const formatAge = (ageDays: number | undefined): string =>
499
+ ageDays === undefined ? '(unreadable date)' : `(${ageDays}d)`
500
+
501
+ const formatWarning = (warning: ConformanceWarning): string => {
502
+ switch (warning.kind) {
503
+ case 'unverified-case':
504
+ return warning.kind
505
+ case 'stale-observation':
506
+ return `${warning.kind}${formatAge(warning.ageDays)}`
507
+ case 'unverified-fixture':
508
+ case 'missing-fixture':
509
+ return `${warning.kind}:${warning.fixtureId}`
510
+ case 'stale-fixture':
511
+ return `${warning.kind}:${warning.fixtureId}${formatAge(warning.ageDays)}`
512
+ }
513
+ }
514
+
515
+ const formatDetail = (result: ConformanceCaseResult): string | undefined => {
516
+ if (result.skipReason !== undefined) {
517
+ return result.skipReason
518
+ }
519
+
520
+ if (result.failure === undefined) {
521
+ return undefined
522
+ }
523
+
524
+ const prefix = result.failure.kind === 'defect' ? 'defect ' : ''
525
+ const tag = result.failure.tag === undefined ? '' : `${result.failure.tag}: `
526
+
527
+ return `${prefix}${tag}${result.failure.message}`
528
+ }
529
+
530
+ /**
531
+ * Compact plain text (no colors): one line per case with status, id, safety,
532
+ * skip reason or failure message, and warnings, then a summary line.
533
+ */
534
+ export const formatConformanceReport = (report: ConformanceReport): string => {
535
+ const lines = report.results.map(result => {
536
+ const detail = formatDetail(result)
537
+
538
+ const warnings =
539
+ result.warnings.length === 0
540
+ ? undefined
541
+ : `warnings: ${result.warnings.map(formatWarning).join(', ')}`
542
+
543
+ return [statusLabel[result.status], result.id, `[${result.safety}]`, detail, warnings]
544
+ .filter(Predicate.isNotUndefined)
545
+ .join(' ')
546
+ })
547
+
548
+ const target =
549
+ report.target.account === undefined
550
+ ? report.target.kind
551
+ : `${report.target.kind} (account ${report.target.account})`
552
+
553
+ const { passed, failed, skipped } = report.summary
554
+
555
+ return [
556
+ ...lines,
557
+ `${passed} passed, ${failed} failed, ${skipped} skipped; target ${target}; started ${report.startedAt}`
558
+ ].join('\n')
559
+ }