@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/LICENSE +21 -0
- package/README.md +316 -0
- package/dist/case.d.mts +89 -0
- package/dist/case.d.mts.map +1 -0
- package/dist/case.mjs +101 -0
- package/dist/case.mjs.map +1 -0
- package/dist/fixture.d.mts +432 -0
- package/dist/fixture.d.mts.map +1 -0
- package/dist/fixture.mjs +391 -0
- package/dist/fixture.mjs.map +1 -0
- package/dist/record.d.mts +96 -0
- package/dist/record.d.mts.map +1 -0
- package/dist/record.mjs +212 -0
- package/dist/record.mjs.map +1 -0
- package/dist/replay.d.mts +316 -0
- package/dist/replay.d.mts.map +1 -0
- package/dist/replay.mjs +272 -0
- package/dist/replay.mjs.map +1 -0
- package/dist/runner.d.mts +163 -0
- package/dist/runner.d.mts.map +1 -0
- package/dist/runner.mjs +264 -0
- package/dist/runner.mjs.map +1 -0
- package/dist/wire-internal.d.mts +90 -0
- package/dist/wire-internal.d.mts.map +1 -0
- package/dist/wire-internal.mjs +212 -0
- package/dist/wire-internal.mjs.map +1 -0
- package/package.json +75 -0
- package/src/case.ts +153 -0
- package/src/fixture.ts +625 -0
- package/src/record.ts +377 -0
- package/src/replay.ts +617 -0
- package/src/runner.ts +559 -0
- package/src/wire-internal.ts +382 -0
package/dist/replay.mjs
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { decodeBase64Bytes, headerRecord, isNullBodyStatus, normalizeWireUrl, parseJsonText, redactHeaders, requestBodyText, urlMatchesPattern } from "./wire-internal.mjs";
|
|
2
|
+
import { isWireBase64BodyResponse, isWireStreamResponse } from "./fixture.mjs";
|
|
3
|
+
import { Cause, Context, Data, Effect, Exit, Fiber, Layer, Match, Option, Predicate, Ref, Result } from "effect";
|
|
4
|
+
import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http";
|
|
5
|
+
//#region src/replay.ts
|
|
6
|
+
/**
|
|
7
|
+
* Offline replay of recorded wire fixtures as an Effect `HttpClient`.
|
|
8
|
+
*
|
|
9
|
+
* Replay never performs network I/O. Requests are matched by method and
|
|
10
|
+
* normalized absolute URL (hash removed, query parameters sorted); each
|
|
11
|
+
* recorded exchange is consumed once, in recorded order among exchanges with
|
|
12
|
+
* the same method and URL. A request with no remaining match fails closed with
|
|
13
|
+
* a typed `HttpClientError` and is still written to the ledger.
|
|
14
|
+
*
|
|
15
|
+
* @experimental
|
|
16
|
+
*/
|
|
17
|
+
const WireFault = Data.taggedEnum();
|
|
18
|
+
/** Cause attached to a body stream failed by `FailAfterChunks`. */
|
|
19
|
+
var WireTransportFault = class extends Data.TaggedError("WireTransportFault") {
|
|
20
|
+
get message() {
|
|
21
|
+
return `injected transport failure after ${this.afterChunks} chunks`;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
var ReplayLedger = class extends Context.Service()("@yolk-sdk/conformance/ReplayLedger") {};
|
|
25
|
+
const requestKey = (method, normalizedUrl) => `${method.toUpperCase()} ${normalizedUrl}`;
|
|
26
|
+
const faultMatches = (match, method, normalizedUrl) => (match?.method === void 0 || match.method.toUpperCase() === method.toUpperCase()) && (match?.url === void 0 || urlMatchesPattern(match.url, normalizedUrl));
|
|
27
|
+
const candidatesFrom = (fixtures) => {
|
|
28
|
+
const candidates = [];
|
|
29
|
+
for (const fixture of fixtures) fixture.exchanges.forEach((exchange, exchangeIndex) => {
|
|
30
|
+
candidates.push({
|
|
31
|
+
id: candidates.length,
|
|
32
|
+
key: requestKey(exchange.request.method, normalizeWireUrl(exchange.request.url)),
|
|
33
|
+
fixtureId: fixture.id,
|
|
34
|
+
exchangeIndex,
|
|
35
|
+
exchange
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
return candidates;
|
|
39
|
+
};
|
|
40
|
+
const chunkBytes = (chunk) => Predicate.isString(chunk) ? Option.some(new TextEncoder().encode(chunk)) : decodeBase64Bytes(chunk.base64);
|
|
41
|
+
const chunkFaultProblem = (fault, recorded) => {
|
|
42
|
+
if (!Number.isSafeInteger(fault.chunks) || fault.chunks < 0) return `${fault._tag} needs a non-negative integer chunk count`;
|
|
43
|
+
return (WireFault.$is("FailAfterChunks")(fault) ? fault.chunks <= recorded : fault.chunks < recorded) ? void 0 : `${fault._tag} after ${fault.chunks} chunk(s) cannot apply to a response with ${recorded} recorded chunk(s)`;
|
|
44
|
+
};
|
|
45
|
+
/** Resolve the exact bytes to replay, or why the recording cannot be replayed as requested. */
|
|
46
|
+
const replayBody = (response, fault) => {
|
|
47
|
+
if (isWireStreamResponse(response)) {
|
|
48
|
+
const chunks = Option.all(response.chunks.map(chunkBytes));
|
|
49
|
+
if (Option.isNone(chunks)) return Result.fail("a recorded chunk is not valid base64");
|
|
50
|
+
const problem = fault === void 0 ? void 0 : chunkFaultProblem(fault, chunks.value.length);
|
|
51
|
+
return problem === void 0 ? Result.succeed({
|
|
52
|
+
kind: "chunks",
|
|
53
|
+
chunks: chunks.value,
|
|
54
|
+
fault
|
|
55
|
+
}) : Result.fail(problem);
|
|
56
|
+
}
|
|
57
|
+
if (fault !== void 0) return Result.fail(`${fault._tag} cannot apply to a whole-body response`);
|
|
58
|
+
if (isWireBase64BodyResponse(response)) return Option.match(decodeBase64Bytes(response.bodyBase64), {
|
|
59
|
+
onNone: () => Result.fail("the recorded bodyBase64 is not valid base64"),
|
|
60
|
+
onSome: (bytes) => Result.succeed({
|
|
61
|
+
kind: "body",
|
|
62
|
+
body: new Uint8Array(bytes)
|
|
63
|
+
})
|
|
64
|
+
});
|
|
65
|
+
return Result.succeed({
|
|
66
|
+
kind: "body",
|
|
67
|
+
body: response.body
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Strictly pull-driven body: one `Uint8Array` per recorded chunk (including
|
|
72
|
+
* empty ones), and chunk `k` is produced only when the consumer pulls it,
|
|
73
|
+
* never ahead of demand. The fault (already validated against the chunk count)
|
|
74
|
+
* applies when the consumer pulls past `fault.chunks` chunks.
|
|
75
|
+
*/
|
|
76
|
+
const bodyReadable = (chunks, fault) => {
|
|
77
|
+
let next = 0;
|
|
78
|
+
let faultApplied = false;
|
|
79
|
+
let cancelled = false;
|
|
80
|
+
let held;
|
|
81
|
+
const emitNext = (controller) => {
|
|
82
|
+
const chunk = chunks[next];
|
|
83
|
+
if (chunk === void 0) {
|
|
84
|
+
controller.close();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
next += 1;
|
|
88
|
+
controller.enqueue(chunk);
|
|
89
|
+
};
|
|
90
|
+
return new ReadableStream({
|
|
91
|
+
pull: (controller) => {
|
|
92
|
+
if (fault === void 0 || faultApplied || next !== fault.chunks) {
|
|
93
|
+
emitNext(controller);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
faultApplied = true;
|
|
97
|
+
return Match.valueTags(fault, {
|
|
98
|
+
FailAfterChunks: ({ chunks: afterChunks }) => controller.error(new WireTransportFault({ afterChunks })),
|
|
99
|
+
TruncateAfterChunks: () => controller.close(),
|
|
100
|
+
HoldAfterChunks: ({ release }) => {
|
|
101
|
+
const fiber = Effect.runFork(release);
|
|
102
|
+
held = fiber;
|
|
103
|
+
if (cancelled) {
|
|
104
|
+
held = void 0;
|
|
105
|
+
return Effect.runPromise(Fiber.interrupt(fiber));
|
|
106
|
+
}
|
|
107
|
+
return Effect.runPromise(Fiber.await(fiber)).then((exit) => {
|
|
108
|
+
held = void 0;
|
|
109
|
+
if (cancelled) return;
|
|
110
|
+
if (Exit.isSuccess(exit)) emitNext(controller);
|
|
111
|
+
else controller.error(Cause.squash(exit.cause));
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
},
|
|
116
|
+
cancel: () => {
|
|
117
|
+
cancelled = true;
|
|
118
|
+
if (held === void 0) return;
|
|
119
|
+
return Effect.runPromise(Fiber.interrupt(held));
|
|
120
|
+
}
|
|
121
|
+
}, { highWaterMark: 0 });
|
|
122
|
+
};
|
|
123
|
+
const webResponse = (status, headers, body) => {
|
|
124
|
+
const init = {
|
|
125
|
+
status,
|
|
126
|
+
headers: { ...headers }
|
|
127
|
+
};
|
|
128
|
+
if (isNullBodyStatus(status)) return new Response(null, init);
|
|
129
|
+
if (body.kind === "body") return new Response(body.body, init);
|
|
130
|
+
return new Response(bodyReadable(body.chunks, body.fault), init);
|
|
131
|
+
};
|
|
132
|
+
const unmatchedError = (request) => new HttpClientError.HttpClientError({ reason: new HttpClientError.TransportError({
|
|
133
|
+
request,
|
|
134
|
+
description: "replay has no remaining recorded exchange"
|
|
135
|
+
}) });
|
|
136
|
+
const invalidRecordingError = (request, cause) => new HttpClientError.HttpClientError({ reason: new HttpClientError.TransportError({
|
|
137
|
+
request,
|
|
138
|
+
cause,
|
|
139
|
+
description: "recorded response could not be replayed"
|
|
140
|
+
}) });
|
|
141
|
+
/**
|
|
142
|
+
* Cause attached to the `HttpClientError` (reason `TransportError`) of a request
|
|
143
|
+
* whose recording could not be replayed. Retrying clients may retry past it;
|
|
144
|
+
* check the ledger for an `invalid` outcome.
|
|
145
|
+
*/
|
|
146
|
+
var WireReplayInvalid = class extends Data.TaggedError("WireReplayInvalid") {
|
|
147
|
+
get message() {
|
|
148
|
+
return this.reason;
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
/**
|
|
152
|
+
* Build a replay `HttpClient` and its ledger over the given fixtures. Each
|
|
153
|
+
* call has independent consumption, attempt, and ledger state.
|
|
154
|
+
*/
|
|
155
|
+
const makeReplayHttpClient = (fixtures, options = {}) => Effect.gen(function* () {
|
|
156
|
+
const candidates = candidatesFrom(fixtures);
|
|
157
|
+
const faults = options.faults ?? [];
|
|
158
|
+
const state = yield* Ref.make({
|
|
159
|
+
consumed: /* @__PURE__ */ new Set(),
|
|
160
|
+
attempts: /* @__PURE__ */ new Map(),
|
|
161
|
+
entries: []
|
|
162
|
+
});
|
|
163
|
+
const decide = (method, normalizedUrl, entry) => Ref.modify(state, (current) => {
|
|
164
|
+
const key = requestKey(method, normalizedUrl);
|
|
165
|
+
const attempt = (current.attempts.get(key) ?? 0) + 1;
|
|
166
|
+
const attempts = new Map(current.attempts).set(key, attempt);
|
|
167
|
+
const fields = {
|
|
168
|
+
...entry,
|
|
169
|
+
attempt,
|
|
170
|
+
match: { outcome: "unmatched" }
|
|
171
|
+
};
|
|
172
|
+
const candidate = candidates.find((item) => item.key === key && !current.consumed.has(item.id));
|
|
173
|
+
if (candidate === void 0) return [{ kind: "unmatched" }, {
|
|
174
|
+
...current,
|
|
175
|
+
attempts,
|
|
176
|
+
entries: [...current.entries, fields]
|
|
177
|
+
}];
|
|
178
|
+
const statusFault = faults.filter(WireFault.$is("StatusOnAttempt")).find((fault) => fault.attempt === attempt && faultMatches(fault.match, method, normalizedUrl));
|
|
179
|
+
if (statusFault !== void 0) {
|
|
180
|
+
fields.match = { outcome: "injected" };
|
|
181
|
+
fields.fault = statusFault._tag;
|
|
182
|
+
return [{
|
|
183
|
+
kind: "status",
|
|
184
|
+
fault: statusFault
|
|
185
|
+
}, {
|
|
186
|
+
...current,
|
|
187
|
+
attempts,
|
|
188
|
+
entries: [...current.entries, fields]
|
|
189
|
+
}];
|
|
190
|
+
}
|
|
191
|
+
const response = candidate.exchange.response;
|
|
192
|
+
const chunkFault = faults.filter((fault) => !WireFault.$is("StatusOnAttempt")(fault)).find((fault) => (fault.attempt === void 0 || fault.attempt === attempt) && faultMatches(fault.match, method, normalizedUrl));
|
|
193
|
+
const body = replayBody(response, chunkFault);
|
|
194
|
+
const ref = {
|
|
195
|
+
fixtureId: candidate.fixtureId,
|
|
196
|
+
exchangeIndex: candidate.exchangeIndex
|
|
197
|
+
};
|
|
198
|
+
if (Result.isFailure(body)) {
|
|
199
|
+
fields.match = {
|
|
200
|
+
outcome: "invalid",
|
|
201
|
+
...ref,
|
|
202
|
+
reason: body.failure
|
|
203
|
+
};
|
|
204
|
+
return [{
|
|
205
|
+
kind: "invalid",
|
|
206
|
+
reason: body.failure
|
|
207
|
+
}, {
|
|
208
|
+
...current,
|
|
209
|
+
attempts,
|
|
210
|
+
entries: [...current.entries, fields]
|
|
211
|
+
}];
|
|
212
|
+
}
|
|
213
|
+
fields.match = {
|
|
214
|
+
outcome: "matched",
|
|
215
|
+
...ref
|
|
216
|
+
};
|
|
217
|
+
if (chunkFault !== void 0) fields.fault = chunkFault._tag;
|
|
218
|
+
return [{
|
|
219
|
+
kind: "exchange",
|
|
220
|
+
response,
|
|
221
|
+
body: body.success
|
|
222
|
+
}, {
|
|
223
|
+
consumed: new Set(current.consumed).add(candidate.id),
|
|
224
|
+
attempts,
|
|
225
|
+
entries: [...current.entries, fields]
|
|
226
|
+
}];
|
|
227
|
+
});
|
|
228
|
+
return {
|
|
229
|
+
client: HttpClient.make((request, url) => Effect.gen(function* () {
|
|
230
|
+
const normalizedUrl = normalizeWireUrl(url.toString());
|
|
231
|
+
const bodyText = requestBodyText(request);
|
|
232
|
+
const entry = {
|
|
233
|
+
method: request.method,
|
|
234
|
+
url: normalizedUrl,
|
|
235
|
+
headers: redactHeaders(headerRecord(request.headers))
|
|
236
|
+
};
|
|
237
|
+
if (bodyText !== void 0) {
|
|
238
|
+
entry.bodyText = bodyText;
|
|
239
|
+
const bodyJson = yield* parseJsonText(bodyText);
|
|
240
|
+
if (Option.isSome(bodyJson)) entry.bodyJson = bodyJson.value;
|
|
241
|
+
}
|
|
242
|
+
const decision = yield* decide(request.method, normalizedUrl, entry);
|
|
243
|
+
if (decision.kind === "unmatched") return yield* Effect.fail(unmatchedError(request));
|
|
244
|
+
if (decision.kind === "invalid") return yield* Effect.fail(invalidRecordingError(request, new WireReplayInvalid({ reason: decision.reason })));
|
|
245
|
+
const source = yield* Effect.try({
|
|
246
|
+
try: () => decision.kind === "status" ? webResponse(decision.fault.status, decision.fault.headers ?? {}, {
|
|
247
|
+
kind: "body",
|
|
248
|
+
body: decision.fault.body ?? ""
|
|
249
|
+
}) : webResponse(decision.response.status, decision.response.headers, decision.body),
|
|
250
|
+
catch: (cause) => invalidRecordingError(request, cause)
|
|
251
|
+
});
|
|
252
|
+
return HttpClientResponse.fromWeb(request, source);
|
|
253
|
+
})),
|
|
254
|
+
ledger: {
|
|
255
|
+
entries: Ref.get(state).pipe(Effect.map((current) => current.entries)),
|
|
256
|
+
remaining: Ref.get(state).pipe(Effect.map((current) => candidates.filter((candidate) => !current.consumed.has(candidate.id)).map((candidate) => ({
|
|
257
|
+
fixtureId: candidate.fixtureId,
|
|
258
|
+
exchangeIndex: candidate.exchangeIndex
|
|
259
|
+
}))))
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
const ReplayHttpClient = {
|
|
264
|
+
/**
|
|
265
|
+
* Layer providing a replay `HttpClient` and its `ReplayLedger`. Each layer
|
|
266
|
+
* build gets fresh consumption and ledger state.
|
|
267
|
+
*/
|
|
268
|
+
layer: (fixtures, options = {}) => Layer.unwrap(makeReplayHttpClient(fixtures, options).pipe(Effect.map(({ client, ledger }) => Layer.mergeAll(Layer.succeed(HttpClient.HttpClient, client), Layer.succeed(ReplayLedger, ledger))))) };
|
|
269
|
+
//#endregion
|
|
270
|
+
export { ReplayHttpClient, ReplayLedger, WireFault, WireReplayInvalid, WireTransportFault, makeReplayHttpClient };
|
|
271
|
+
|
|
272
|
+
//# sourceMappingURL=replay.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"replay.mjs","names":[],"sources":["../src/replay.ts"],"sourcesContent":["/**\n * Offline replay of recorded wire fixtures as an Effect `HttpClient`.\n *\n * Replay never performs network I/O. Requests are matched by method and\n * normalized absolute URL (hash removed, query parameters sorted); each\n * recorded exchange is consumed once, in recorded order among exchanges with\n * the same method and URL. A request with no remaining match fails closed with\n * a typed `HttpClientError` and is still written to the ledger.\n *\n * @experimental\n */\nimport {\n Cause,\n Context,\n Data,\n Effect,\n Exit,\n Fiber,\n Layer,\n Match,\n Option,\n Predicate,\n Ref,\n Result\n} from 'effect'\nimport type * as Schema from 'effect/Schema'\nimport {\n HttpClient,\n HttpClientError,\n HttpClientResponse,\n type HttpClientRequest\n} from 'effect/unstable/http'\nimport {\n isWireBase64BodyResponse,\n isWireStreamResponse,\n type WireChunk,\n type WireExchange,\n type WireFixture,\n type WireHeaders,\n type WireResponse\n} from './fixture.ts'\nimport {\n decodeBase64Bytes,\n headerRecord,\n isNullBodyStatus,\n normalizeWireUrl,\n parseJsonText,\n redactHeaders,\n requestBodyText,\n urlMatchesPattern\n} from './wire-internal.ts'\n\n/**\n * Optional fault filter. `method` compares case-insensitively. `url` matches\n * the normalized request URL exactly, or as a prefix when it ends with `*`.\n * An omitted field matches every request.\n */\nexport type WireFaultMatch = {\n readonly method?: string\n readonly url?: string\n}\n\n/**\n * Wire faults injected by the replay client. `attempt` is 1-based and counted\n * per method + normalized URL.\n *\n * - `StatusOnAttempt`: respond with this status instead of consuming a recorded\n * exchange, so the next attempt still gets the recording. It fires only when\n * an unconsumed recorded exchange exists for the method + normalized URL;\n * unknown or exhausted requests still fail closed as `unmatched`.\n * - `FailAfterChunks`: emit the first `chunks` recorded chunks, then fail the\n * body stream the way a dropped connection does under `FetchHttpClient`.\n * - `TruncateAfterChunks`: emit the first `chunks` recorded chunks, then end\n * the body cleanly.\n * - `HoldAfterChunks`: emit the first `chunks` recorded chunks, run `release`,\n * then emit the rest.\n *\n * `attempt` omitted means every matching attempt. A chunk fault that cannot\n * take effect fails the request with a typed `HttpClientError` (and a ledger\n * `invalid` outcome) instead of silently doing nothing: any chunk fault matched\n * against a whole-body response, `TruncateAfterChunks`/`HoldAfterChunks` with\n * `chunks` >= the recorded chunk count, or `FailAfterChunks` with `chunks` >\n * the recorded chunk count. That error's reason is a `TransportError`, so\n * transient-retry policies may retry past it: assert the ledger has no\n * `invalid` outcome when testing through a retrying client.\n */\nexport type WireFault = Data.TaggedEnum<{\n StatusOnAttempt: {\n readonly match?: WireFaultMatch\n readonly attempt: number\n readonly status: number\n readonly headers?: WireHeaders\n readonly body?: string\n }\n FailAfterChunks: {\n readonly match?: WireFaultMatch\n readonly attempt?: number\n readonly chunks: number\n }\n TruncateAfterChunks: {\n readonly match?: WireFaultMatch\n readonly attempt?: number\n readonly chunks: number\n }\n HoldAfterChunks: {\n readonly match?: WireFaultMatch\n readonly attempt?: number\n readonly chunks: number\n readonly release: Effect.Effect<void>\n }\n}>\n\nexport const WireFault = Data.taggedEnum<WireFault>()\n\nexport type WireFaultTag = WireFault['_tag']\n\ntype ChunkFault = Exclude<WireFault, { readonly _tag: 'StatusOnAttempt' }>\n\n/** Cause attached to a body stream failed by `FailAfterChunks`. */\nexport class WireTransportFault extends Data.TaggedError('WireTransportFault')<{\n readonly afterChunks: number\n}> {\n override get message(): string {\n return `injected transport failure after ${this.afterChunks} chunks`\n }\n}\n\n/**\n * How a replayed request was answered: a recorded exchange, a response\n * injected by a `StatusOnAttempt` fault, nothing (fail closed), or a matched\n * exchange that could not be replayed (`invalid`: a chunk fault that cannot\n * take effect, or undecodable base64). `invalid` requests fail with a typed\n * `HttpClientError` and do not consume the exchange.\n */\nexport type ReplayLedgerMatch =\n | { readonly outcome: 'matched'; readonly fixtureId: string; readonly exchangeIndex: number }\n | { readonly outcome: 'injected' }\n | { readonly outcome: 'unmatched' }\n | {\n readonly outcome: 'invalid'\n readonly fixtureId: string\n readonly exchangeIndex: number\n /** Why the recording could not be replayed; never contains request or response data. */\n readonly reason: string\n }\n\nexport type ReplayLedgerEntry = {\n readonly method: string\n /** Normalized absolute URL. */\n readonly url: string\n /** Request headers with credential headers replaced by `<redacted>`. */\n readonly headers: Readonly<Record<string, string>>\n readonly bodyText?: string\n /** Parsed request body when `bodyText` is valid JSON. */\n readonly bodyJson?: Schema.Json\n /** 1-based attempt number for this method + URL. */\n readonly attempt: number\n readonly match: ReplayLedgerMatch\n /** Tag of the fault that shaped this response, if any (never set for a fault that did not apply). */\n readonly fault?: WireFaultTag\n}\n\nexport type ReplayExchangeRef = {\n readonly fixtureId: string\n readonly exchangeIndex: number\n}\n\nexport type ReplayLedgerApi = {\n /** Every request seen by the replay client, in arrival order. */\n readonly entries: Effect.Effect<ReadonlyArray<ReplayLedgerEntry>>\n /** Recorded exchanges not consumed yet, in fixture order. */\n readonly remaining: Effect.Effect<ReadonlyArray<ReplayExchangeRef>>\n}\n\nexport class ReplayLedger extends Context.Service<ReplayLedger, ReplayLedgerApi>()(\n '@yolk-sdk/conformance/ReplayLedger'\n) {}\n\nexport type ReplayHttpClientOptions = {\n readonly faults?: ReadonlyArray<WireFault>\n}\n\ntype Candidate = {\n readonly id: number\n readonly key: string\n readonly fixtureId: string\n readonly exchangeIndex: number\n readonly exchange: WireExchange\n}\n\ntype ReplayState = {\n readonly consumed: ReadonlySet<number>\n readonly attempts: ReadonlyMap<string, number>\n readonly entries: ReadonlyArray<ReplayLedgerEntry>\n}\n\n// Bytes to replay: one whole body, or one Uint8Array per recorded chunk.\ntype ReplayBody =\n | { readonly kind: 'body'; readonly body: string | Uint8Array<ArrayBuffer> }\n | {\n readonly kind: 'chunks'\n readonly chunks: ReadonlyArray<Uint8Array>\n readonly fault: ChunkFault | undefined\n }\n\ntype ReplayDecision =\n | { readonly kind: 'status'; readonly fault: Extract<WireFault, { _tag: 'StatusOnAttempt' }> }\n | {\n readonly kind: 'exchange'\n readonly response: WireResponse\n readonly body: ReplayBody\n }\n | { readonly kind: 'invalid'; readonly reason: string }\n | { readonly kind: 'unmatched' }\n\ntype LedgerEntryFields = {\n method: string\n url: string\n headers: Readonly<Record<string, string>>\n bodyText?: string\n bodyJson?: Schema.Json\n attempt: number\n match: ReplayLedgerMatch\n fault?: WireFaultTag\n}\n\nconst requestKey = (method: string, normalizedUrl: string) =>\n `${method.toUpperCase()} ${normalizedUrl}`\n\nconst faultMatches = (\n match: WireFaultMatch | undefined,\n method: string,\n normalizedUrl: string\n): boolean =>\n (match?.method === undefined || match.method.toUpperCase() === method.toUpperCase()) &&\n (match?.url === undefined || urlMatchesPattern(match.url, normalizedUrl))\n\nconst candidatesFrom = (fixtures: ReadonlyArray<WireFixture>): ReadonlyArray<Candidate> => {\n const candidates: Array<Candidate> = []\n\n for (const fixture of fixtures) {\n fixture.exchanges.forEach((exchange, exchangeIndex) => {\n candidates.push({\n id: candidates.length,\n key: requestKey(exchange.request.method, normalizeWireUrl(exchange.request.url)),\n fixtureId: fixture.id,\n exchangeIndex,\n exchange\n })\n })\n }\n\n return candidates\n}\n\nconst chunkBytes = (chunk: WireChunk): Option.Option<Uint8Array> =>\n Predicate.isString(chunk)\n ? Option.some(new TextEncoder().encode(chunk))\n : decodeBase64Bytes(chunk.base64)\n\nconst chunkFaultProblem = (fault: ChunkFault, recorded: number): string | undefined => {\n if (!Number.isSafeInteger(fault.chunks) || fault.chunks < 0) {\n return `${fault._tag} needs a non-negative integer chunk count`\n }\n\n // `FailAfterChunks` may fail after the last chunk; truncating or holding\n // there would change nothing.\n const applies = WireFault.$is('FailAfterChunks')(fault)\n ? fault.chunks <= recorded\n : fault.chunks < recorded\n\n return applies\n ? undefined\n : `${fault._tag} after ${fault.chunks} chunk(s) cannot apply to a response with ${recorded} recorded chunk(s)`\n}\n\n/** Resolve the exact bytes to replay, or why the recording cannot be replayed as requested. */\nconst replayBody = (\n response: WireResponse,\n fault: ChunkFault | undefined\n): Result.Result<ReplayBody, string> => {\n if (isWireStreamResponse(response)) {\n const chunks = Option.all(response.chunks.map(chunkBytes))\n\n if (Option.isNone(chunks)) {\n return Result.fail('a recorded chunk is not valid base64')\n }\n\n const problem = fault === undefined ? undefined : chunkFaultProblem(fault, chunks.value.length)\n\n return problem === undefined\n ? Result.succeed({ kind: 'chunks', chunks: chunks.value, fault })\n : Result.fail(problem)\n }\n\n if (fault !== undefined) {\n return Result.fail(`${fault._tag} cannot apply to a whole-body response`)\n }\n\n if (isWireBase64BodyResponse(response)) {\n return Option.match(decodeBase64Bytes(response.bodyBase64), {\n onNone: () => Result.fail('the recorded bodyBase64 is not valid base64'),\n // Copy into an ArrayBuffer-backed view, as `Response` requires.\n onSome: bytes => Result.succeed({ kind: 'body', body: new Uint8Array(bytes) })\n })\n }\n\n return Result.succeed({ kind: 'body', body: response.body })\n}\n\n/**\n * Strictly pull-driven body: one `Uint8Array` per recorded chunk (including\n * empty ones), and chunk `k` is produced only when the consumer pulls it,\n * never ahead of demand. The fault (already validated against the chunk count)\n * applies when the consumer pulls past `fault.chunks` chunks.\n */\nconst bodyReadable = (\n chunks: ReadonlyArray<Uint8Array>,\n fault: ChunkFault | undefined\n): ReadableStream<Uint8Array> => {\n let next = 0\n let faultApplied = false\n let cancelled = false\n let held: Fiber.Fiber<void> | undefined\n\n const emitNext = (controller: ReadableStreamDefaultController<Uint8Array>): void => {\n const chunk = chunks[next]\n\n if (chunk === undefined) {\n controller.close()\n\n return\n }\n\n next += 1\n controller.enqueue(chunk)\n }\n\n return new ReadableStream<Uint8Array>(\n {\n pull: controller => {\n if (fault === undefined || faultApplied || next !== fault.chunks) {\n emitNext(controller)\n\n return\n }\n\n faultApplied = true\n\n return Match.valueTags(fault, {\n FailAfterChunks: ({ chunks: afterChunks }) =>\n controller.error(new WireTransportFault({ afterChunks })),\n TruncateAfterChunks: () => controller.close(),\n HoldAfterChunks: ({ release }) => {\n // Keep the release fiber so cancelling the body interrupts it (and runs its finalizers).\n // `runFork` starts `release` synchronously, so a cancel can land before `held` is set.\n const fiber = Effect.runFork(release)\n held = fiber\n\n if (cancelled) {\n held = undefined\n\n return Effect.runPromise(Fiber.interrupt(fiber))\n }\n\n return Effect.runPromise(Fiber.await(fiber)).then(exit => {\n held = undefined\n\n if (cancelled) return\n\n if (Exit.isSuccess(exit)) {\n emitNext(controller)\n } else {\n controller.error(Cause.squash(exit.cause))\n }\n })\n }\n })\n },\n cancel: () => {\n cancelled = true\n\n if (held === undefined) return\n\n return Effect.runPromise(Fiber.interrupt(held))\n }\n },\n { highWaterMark: 0 }\n )\n}\n\nconst webResponse = (status: number, headers: WireHeaders, body: ReplayBody): Response => {\n const init = { status, headers: { ...headers } }\n\n if (isNullBodyStatus(status)) {\n return new Response(null, init)\n }\n\n if (body.kind === 'body') {\n return new Response(body.body, init)\n }\n\n return new Response(bodyReadable(body.chunks, body.fault), init)\n}\n\nconst unmatchedError = (request: HttpClientRequest.HttpClientRequest) =>\n new HttpClientError.HttpClientError({\n reason: new HttpClientError.TransportError({\n request,\n description: 'replay has no remaining recorded exchange'\n })\n })\n\nconst invalidRecordingError = (request: HttpClientRequest.HttpClientRequest, cause: unknown) =>\n new HttpClientError.HttpClientError({\n reason: new HttpClientError.TransportError({\n request,\n cause,\n description: 'recorded response could not be replayed'\n })\n })\n\n/**\n * Cause attached to the `HttpClientError` (reason `TransportError`) of a request\n * whose recording could not be replayed. Retrying clients may retry past it;\n * check the ledger for an `invalid` outcome.\n */\nexport class WireReplayInvalid extends Data.TaggedError('WireReplayInvalid')<{\n readonly reason: string\n}> {\n override get message(): string {\n return this.reason\n }\n}\n\n/**\n * Build a replay `HttpClient` and its ledger over the given fixtures. Each\n * call has independent consumption, attempt, and ledger state.\n */\nexport const makeReplayHttpClient = (\n fixtures: ReadonlyArray<WireFixture>,\n options: ReplayHttpClientOptions = {}\n): Effect.Effect<{ readonly client: HttpClient.HttpClient; readonly ledger: ReplayLedgerApi }> =>\n Effect.gen(function* () {\n const candidates = candidatesFrom(fixtures)\n const faults = options.faults ?? []\n\n const state = yield* Ref.make<ReplayState>({\n consumed: new Set(),\n attempts: new Map(),\n entries: []\n })\n\n const decide = (\n method: string,\n normalizedUrl: string,\n entry: Omit<LedgerEntryFields, 'attempt' | 'match' | 'fault'>\n ) =>\n Ref.modify(state, (current): [ReplayDecision, ReplayState] => {\n const key = requestKey(method, normalizedUrl)\n const attempt = (current.attempts.get(key) ?? 0) + 1\n const attempts = new Map(current.attempts).set(key, attempt)\n const fields: LedgerEntryFields = { ...entry, attempt, match: { outcome: 'unmatched' } }\n\n const candidate = candidates.find(\n item => item.key === key && !current.consumed.has(item.id)\n )\n\n // Fail closed first: faults never answer unknown or exhausted requests.\n if (candidate === undefined) {\n return [\n { kind: 'unmatched' },\n { ...current, attempts, entries: [...current.entries, fields] }\n ]\n }\n\n const statusFault = faults\n .filter(WireFault.$is('StatusOnAttempt'))\n .find(\n fault => fault.attempt === attempt && faultMatches(fault.match, method, normalizedUrl)\n )\n\n if (statusFault !== undefined) {\n fields.match = { outcome: 'injected' }\n fields.fault = statusFault._tag\n\n return [\n { kind: 'status', fault: statusFault },\n { ...current, attempts, entries: [...current.entries, fields] }\n ]\n }\n\n const response = candidate.exchange.response\n\n const chunkFault = faults\n .filter((fault): fault is ChunkFault => !WireFault.$is('StatusOnAttempt')(fault))\n .find(\n fault =>\n (fault.attempt === undefined || fault.attempt === attempt) &&\n faultMatches(fault.match, method, normalizedUrl)\n )\n\n const body = replayBody(response, chunkFault)\n const ref = { fixtureId: candidate.fixtureId, exchangeIndex: candidate.exchangeIndex }\n\n if (Result.isFailure(body)) {\n // The fault (or recording) did not apply: record the failure, not the\n // fault, and leave the exchange unconsumed.\n fields.match = { outcome: 'invalid', ...ref, reason: body.failure }\n\n return [\n { kind: 'invalid', reason: body.failure },\n { ...current, attempts, entries: [...current.entries, fields] }\n ]\n }\n\n fields.match = { outcome: 'matched', ...ref }\n\n if (chunkFault !== undefined) {\n fields.fault = chunkFault._tag\n }\n\n return [\n { kind: 'exchange', response, body: body.success },\n {\n consumed: new Set(current.consumed).add(candidate.id),\n attempts,\n entries: [...current.entries, fields]\n }\n ]\n })\n\n const client = HttpClient.make((request, url) =>\n Effect.gen(function* () {\n const normalizedUrl = normalizeWireUrl(url.toString())\n const bodyText = requestBodyText(request)\n\n const entry: Omit<LedgerEntryFields, 'attempt' | 'match' | 'fault'> = {\n method: request.method,\n url: normalizedUrl,\n headers: redactHeaders(headerRecord(request.headers))\n }\n\n if (bodyText !== undefined) {\n entry.bodyText = bodyText\n\n const bodyJson = yield* parseJsonText(bodyText)\n\n if (Option.isSome(bodyJson)) {\n entry.bodyJson = bodyJson.value\n }\n }\n\n const decision = yield* decide(request.method, normalizedUrl, entry)\n\n if (decision.kind === 'unmatched') {\n return yield* Effect.fail(unmatchedError(request))\n }\n\n if (decision.kind === 'invalid') {\n return yield* Effect.fail(\n invalidRecordingError(request, new WireReplayInvalid({ reason: decision.reason }))\n )\n }\n\n const source = yield* Effect.try({\n try: () =>\n decision.kind === 'status'\n ? webResponse(decision.fault.status, decision.fault.headers ?? {}, {\n kind: 'body',\n body: decision.fault.body ?? ''\n })\n : webResponse(decision.response.status, decision.response.headers, decision.body),\n catch: cause => invalidRecordingError(request, cause)\n })\n\n return HttpClientResponse.fromWeb(request, source)\n })\n )\n\n const ledger: ReplayLedgerApi = {\n entries: Ref.get(state).pipe(Effect.map(current => current.entries)),\n remaining: Ref.get(state).pipe(\n Effect.map(current =>\n candidates\n .filter(candidate => !current.consumed.has(candidate.id))\n .map(candidate => ({\n fixtureId: candidate.fixtureId,\n exchangeIndex: candidate.exchangeIndex\n }))\n )\n )\n }\n\n return { client, ledger }\n })\n\nexport const ReplayHttpClient = {\n /**\n * Layer providing a replay `HttpClient` and its `ReplayLedger`. Each layer\n * build gets fresh consumption and ledger state.\n */\n layer: (\n fixtures: ReadonlyArray<WireFixture>,\n options: ReplayHttpClientOptions = {}\n ): Layer.Layer<HttpClient.HttpClient | ReplayLedger> =>\n Layer.unwrap(\n makeReplayHttpClient(fixtures, options).pipe(\n Effect.map(({ client, ledger }) =>\n Layer.mergeAll(\n Layer.succeed(HttpClient.HttpClient, client),\n Layer.succeed(ReplayLedger, ledger)\n )\n )\n )\n )\n} as const\n"],"mappings":"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"}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { ConformanceCase, ConformanceSafety } from "./case.mjs";
|
|
2
|
+
import { ConformanceFixture } from "./fixture.mjs";
|
|
3
|
+
import { Effect, Layer } from "effect";
|
|
4
|
+
|
|
5
|
+
//#region src/runner.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* Where cases run. `replay`, `in-process`, and `emulated` never touch a real
|
|
8
|
+
* account, so every case runs. `live` talks to a real practice account and is
|
|
9
|
+
* gated by the safety policy (see `conformanceSkipReason`).
|
|
10
|
+
*/
|
|
11
|
+
type ConformanceTarget = {
|
|
12
|
+
readonly kind: 'replay' | 'in-process' | 'emulated';
|
|
13
|
+
} | {
|
|
14
|
+
readonly kind: 'live'; /** Synthetic, non-identifying account label (for example `practice`). */
|
|
15
|
+
readonly account: string; /** `reversible` lets `write-reversible` cases run. Default `none`. */
|
|
16
|
+
readonly allowWrites?: 'none' | 'reversible'; /** Exact ids of `write-irreversible` cases a person explicitly started. */
|
|
17
|
+
readonly allowIrreversible?: ReadonlyArray<string>;
|
|
18
|
+
};
|
|
19
|
+
type ConformanceTargetKind = ConformanceTarget['kind'];
|
|
20
|
+
type ConformanceSkipReason = 'writes-not-allowed' | 'manual-only';
|
|
21
|
+
/**
|
|
22
|
+
* The safety policy. `undefined` means the case may run.
|
|
23
|
+
*
|
|
24
|
+
* - `replay` / `in-process` / `emulated`: every case runs.
|
|
25
|
+
* - `live`: `read` runs; `write-reversible` runs only with
|
|
26
|
+
* `allowWrites: 'reversible'` (else `writes-not-allowed`);
|
|
27
|
+
* `write-irreversible` runs only when its exact id is in
|
|
28
|
+
* `allowIrreversible` (else `manual-only`), independent of `allowWrites`.
|
|
29
|
+
*/
|
|
30
|
+
declare const conformanceSkipReason: (target: ConformanceTarget, testCase: {
|
|
31
|
+
readonly id: string;
|
|
32
|
+
readonly safety: ConformanceSafety;
|
|
33
|
+
}) => ConformanceSkipReason | undefined;
|
|
34
|
+
/** Non-fatal findings attached to a case result. */
|
|
35
|
+
type ConformanceWarning = /** The case has no `observed`: its claim was never watched against the real service. */{
|
|
36
|
+
readonly kind: 'unverified-case';
|
|
37
|
+
} /** `observed.date` is older than the max age (`ageDays` absent when unreadable). */ | {
|
|
38
|
+
readonly kind: 'stale-observation';
|
|
39
|
+
readonly ageDays?: number;
|
|
40
|
+
} /** A referenced fixture is a synthetic placeholder (`evidence: 'unverified'`, or a `PortFixture` without `observed`). */ | {
|
|
41
|
+
readonly kind: 'unverified-fixture';
|
|
42
|
+
readonly fixtureId: string;
|
|
43
|
+
} /** A referenced fixture is older than the max age (`ageDays` absent when unreadable). */ | {
|
|
44
|
+
readonly kind: 'stale-fixture';
|
|
45
|
+
readonly fixtureId: string;
|
|
46
|
+
readonly ageDays?: number;
|
|
47
|
+
} /** A referenced fixture id is not among the supplied fixtures. */ | {
|
|
48
|
+
readonly kind: 'missing-fixture';
|
|
49
|
+
readonly fixtureId: string;
|
|
50
|
+
};
|
|
51
|
+
type ConformanceWarningKind = ConformanceWarning['kind'];
|
|
52
|
+
/**
|
|
53
|
+
* Sanitized failure. `message` is a `ConformanceMismatch`'s case-authored
|
|
54
|
+
* message (credential patterns redacted) or, for every other failure, layer
|
|
55
|
+
* failure, and defect, the error's own message passed through
|
|
56
|
+
* `sanitizeConformanceMessage` (credential patterns and header lines redacted, JSON
|
|
57
|
+
* spans elided, whitespace collapsed, length capped). `tag` is the error's
|
|
58
|
+
* `_tag` only when it is identifier-like. Request bodies, headers, and
|
|
59
|
+
* `ConformanceMismatch` `expected`/`actual` details are never copied.
|
|
60
|
+
*/
|
|
61
|
+
type ConformanceFailure = {
|
|
62
|
+
/** `failure`: typed error (including layer build errors); `defect`: unexpected die. */readonly kind: 'failure' | 'defect';
|
|
63
|
+
readonly tag?: string;
|
|
64
|
+
readonly message: string;
|
|
65
|
+
};
|
|
66
|
+
type ConformanceCaseStatus = 'passed' | 'failed' | 'skipped';
|
|
67
|
+
type ConformanceCaseResult = {
|
|
68
|
+
readonly id: string;
|
|
69
|
+
readonly safety: ConformanceSafety;
|
|
70
|
+
readonly status: ConformanceCaseStatus;
|
|
71
|
+
readonly skipReason?: ConformanceSkipReason;
|
|
72
|
+
readonly failure?: ConformanceFailure;
|
|
73
|
+
readonly durationMs: number;
|
|
74
|
+
readonly warnings: ReadonlyArray<ConformanceWarning>;
|
|
75
|
+
};
|
|
76
|
+
type ConformanceReport = {
|
|
77
|
+
readonly target: {
|
|
78
|
+
readonly kind: ConformanceTargetKind;
|
|
79
|
+
readonly account?: string;
|
|
80
|
+
}; /** ISO timestamp of the run start. */
|
|
81
|
+
readonly startedAt: string;
|
|
82
|
+
readonly results: ReadonlyArray<ConformanceCaseResult>;
|
|
83
|
+
readonly summary: {
|
|
84
|
+
readonly passed: number;
|
|
85
|
+
readonly failed: number;
|
|
86
|
+
readonly skipped: number;
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
/** What a case (or a union of cases) needs from its layer. */
|
|
90
|
+
type ConformanceCaseRequirements<C> = C extends {
|
|
91
|
+
readonly run: Effect.Effect<void, unknown, infer R>;
|
|
92
|
+
} ? R : never;
|
|
93
|
+
type RunSettings = {
|
|
94
|
+
readonly target: ConformanceTarget;
|
|
95
|
+
/**
|
|
96
|
+
* Fixtures to check referenced ids against (evidence, staleness, missing ids): HTTP
|
|
97
|
+
* `WireFixture`s and `PortFixture`s alike.
|
|
98
|
+
*/
|
|
99
|
+
readonly fixtures?: ReadonlyArray<ConformanceFixture>; /** Reference time for staleness and `startedAt`. Defaults to the Effect `Clock`. */
|
|
100
|
+
readonly now?: Date; /** Fixtures and observations older than this many whole days are stale. Default 30. */
|
|
101
|
+
readonly maxFixtureAgeDays?: number; /** Cases run in parallel up to this limit. Default 1: live accounts are shared. */
|
|
102
|
+
readonly concurrency?: number;
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* Options for `runConformance` over cases of type `C` (a union when the cases
|
|
106
|
+
* differ).
|
|
107
|
+
*
|
|
108
|
+
* `layer` is called once per case that runs and must provide everything that
|
|
109
|
+
* case needs (`ConformanceCaseRequirements<C>`; providing more is fine). It is
|
|
110
|
+
* called and built inside the case's failure boundary, with a fresh memo map
|
|
111
|
+
* in its own scope, so state the layer allocates when it is built (replay
|
|
112
|
+
* consumption, ledgers, emulator state) never leaks between cases, even when
|
|
113
|
+
* the factory returns the same `Layer` value. Services captured in a shared
|
|
114
|
+
* value (for example one `Layer.succeed(service, instance)`) or supplied by
|
|
115
|
+
* the caller's environment are not rebuilt and stay shared. Build failures
|
|
116
|
+
* (`LE`) and a throwing factory are reported as failed cases. `LR` is
|
|
117
|
+
* whatever the layers still need from the caller (for example a live
|
|
118
|
+
* `HttpClient`) and becomes the requirement of the whole run.
|
|
119
|
+
*/
|
|
120
|
+
type ConformanceRunOptions<C, LE = never, LR = never> = RunSettings & {
|
|
121
|
+
readonly layer: (testCase: C) => Layer.Layer<ConformanceCaseRequirements<C>, LE, LR>;
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* Warnings for one case. Case-level warnings always apply; fixture-level ones
|
|
125
|
+
* only on non-live targets and only when `fixtures` were supplied.
|
|
126
|
+
*/
|
|
127
|
+
declare const conformanceCaseWarnings: (testCase: Pick<ConformanceCase<unknown, unknown>, "observed" | "fixtures">, context: {
|
|
128
|
+
readonly target: ConformanceTarget;
|
|
129
|
+
readonly now: Date;
|
|
130
|
+
readonly fixtures?: ReadonlyArray<ConformanceFixture> | undefined;
|
|
131
|
+
readonly maxFixtureAgeDays?: number | undefined;
|
|
132
|
+
}) => ReadonlyArray<ConformanceWarning>;
|
|
133
|
+
/**
|
|
134
|
+
* Best-effort sanitizer for failure messages copied into a `ConformanceReport`. Redacts the
|
|
135
|
+
* credential patterns shared with the fixture secret scan (bearer tokens, API-key prefixes, JWTs,
|
|
136
|
+
* private keys, credential field pairs, credential query/form parameters, and credential header
|
|
137
|
+
* lines such as `Cookie:` or `X-Api-Key:` to the end of the line), replaces JSON-looking spans
|
|
138
|
+
* (balanced `{...}` / `[...]`) with `[json]`, collapses whitespace, and caps the length at 300
|
|
139
|
+
* characters. Hosts should still keep secrets out of error messages.
|
|
140
|
+
*/
|
|
141
|
+
declare const sanitizeConformanceMessage: (message: string) => string;
|
|
142
|
+
/**
|
|
143
|
+
* Run cases against a target and report. Skipped cases never build their
|
|
144
|
+
* layer. Each running case gets a fresh layer and runs under `Effect.exit`:
|
|
145
|
+
* failures, layer build failures, defects, and a throwing `layer` factory
|
|
146
|
+
* become `failed` results and the run continues. Interruption is not
|
|
147
|
+
* captured: interrupting the run (or a case interrupting itself) interrupts
|
|
148
|
+
* the whole run.
|
|
149
|
+
*
|
|
150
|
+
* `C` is inferred as the union of the given case types, so cases with
|
|
151
|
+
* different errors and requirements can share one run without annotations.
|
|
152
|
+
*/
|
|
153
|
+
declare function runConformance<C extends ConformanceCase<unknown, unknown>, LE = never, LR = never>(cases: ReadonlyArray<C>, options: ConformanceRunOptions<C, LE, LR>): Effect.Effect<ConformanceReport, never, LR>;
|
|
154
|
+
/** True when any case failed. Skipped cases never fail a report. */
|
|
155
|
+
declare const conformanceReportFailed: (report: ConformanceReport) => boolean;
|
|
156
|
+
/**
|
|
157
|
+
* Compact plain text (no colors): one line per case with status, id, safety,
|
|
158
|
+
* skip reason or failure message, and warnings, then a summary line.
|
|
159
|
+
*/
|
|
160
|
+
declare const formatConformanceReport: (report: ConformanceReport) => string;
|
|
161
|
+
//#endregion
|
|
162
|
+
export { ConformanceCaseRequirements, ConformanceCaseResult, ConformanceCaseStatus, ConformanceFailure, ConformanceReport, ConformanceRunOptions, ConformanceSkipReason, ConformanceTarget, ConformanceTargetKind, ConformanceWarning, ConformanceWarningKind, conformanceCaseWarnings, conformanceReportFailed, conformanceSkipReason, formatConformanceReport, runConformance, sanitizeConformanceMessage };
|
|
163
|
+
//# sourceMappingURL=runner.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runner.d.mts","names":[],"sources":["../src/runner.ts"],"mappings":";;;;;;;AA8BgD;AAGhD;;KAZY,iBAAA;EAAA,SACG,IAAA;AAAA;EAAA,SAEA,IAAA,UAWkB;EAAA,SATlB,OAAA,UASkB;EAAA,SAPlB,WAAA,0BAkBF;EAAA,SAhBE,iBAAA,GAAoB,aAAa;AAAA;AAAA,KAGpC,qBAAA,GAAwB,iBAAiB;AAAA,KAEzC,qBAAA;;;;;;;;;;cAWC,qBAAA,GACX,MAAA,EAAQ,iBAAA,EACR,QAAA;EAAA,SAAqB,EAAA;EAAA,SAAqB,MAAA,EAAQ,iBAAA;AAAA,MACjD,qBAAA;;KAgBS,kBAAA;WAEG,IAAA;AAAA;WAEA,IAAA;EAAA,SAAoC,OAAA;AAAA;WAEpC,IAAA;EAAA,SAAqC,SAAA;AAAA;WAErC,IAAA;EAAA,SAAgC,SAAA;EAAA,SAA4B,OAAA;AAAA;WAE5D,IAAA;EAAA,SAAkC,SAAA;AAAA;AAAA,KAErC,sBAAA,GAAyB,kBAAkB;AAerC;AAGlB;;;;AAAiC;AAEjC;;;AALkB,KAJN,kBAAA;EAYO,gGAVR,IAAA;EAAA,SACA,GAAA;EAAA,SACA,OAAA;AAAA;AAAA,KAGC,qBAAA;AAAA,KAEA,qBAAA;EAAA,SACD,EAAA;EAAA,SACA,MAAA,EAAQ,iBAAA;EAAA,SACR,MAAA,EAAQ,qBAAA;EAAA,SACR,UAAA,GAAa,qBAAA;EAAA,SACb,OAAA,GAAU,kBAAA;EAAA,SACV,UAAA;EAAA,SACA,QAAA,EAAU,aAAA,CAAc,kBAAA;AAAA;AAAA,KAGvB,iBAAA;EAAA,SACD,MAAA;IAAA,SAAmB,IAAA,EAAM,qBAAA;IAAA,SAAgC,OAAA;EAAA,GAJf;EAAA,SAM1C,SAAA;EAAA,SACA,OAAA,EAAS,aAAA,CAAc,qBAAA;EAAA,SACvB,OAAA;IAAA,SACE,MAAA;IAAA,SACA,MAAA;IAAA,SACA,OAAA;EAAA;AAAA;;KAKD,2BAAA,MAAiC,CAAA;EAAA,SAClC,GAAA,EAAK,MAAA,CAAO,MAAM;AAAA,IAEzB,CAAA;AAAA,KAGC,WAAA;EAAA,SACM,MAAA,EAAQ,iBAAA;EAhBR;;;;EAAA,SAqBA,QAAA,GAAW,aAAA,CAAc,kBAAA,GAlBvB;EAAA,SAoBF,GAAA,GAAM,IAAA,EAnBG;EAAA,SAqBT,iBAAA,WAhBC;EAAA,SAkBD,WAAA;AAAA;;;;;;;;;;AAfN;AACI;;;;;;KAiCG,qBAAA,8BAAmD,WAAA;EAAA,SACpD,KAAA,GAAQ,QAAA,EAAU,CAAA,KAAM,KAAA,CAAM,KAAA,CAAM,2BAAA,CAA4B,CAAA,GAAI,EAAA,EAAI,EAAA;AAAA;;;;;cA2BtE,uBAAA,GACX,QAAA,EAAU,IAAA,CAAK,eAAA,8CACf,OAAA;EAAA,SACW,MAAA,EAAQ,iBAAA;EAAA,SACR,GAAA,EAAK,IAAA;EAAA,SACL,QAAA,GAAW,aAAA,CAAc,kBAAA;EAAA,SACzB,iBAAA;AAAA,MAEV,aAAA,CAAc,kBAAA;AAvDK;AAmBtB;;;;;;;AAnBsB,cA+LT,0BAAA,GAA8B,OAAe;;;;;;;;;;;;iBAoF1C,cAAA,WAAyB,eAAA,2CAAA,CACvC,KAAA,EAAO,aAAA,CAAc,CAAA,GACrB,OAAA,EAAS,qBAAA,CAAsB,CAAA,EAAG,EAAA,EAAI,EAAA,IACrC,MAAA,CAAO,MAAA,CAAO,iBAAA,SAA0B,EAAA;;cAwE9B,uBAAA,GAA2B,MAAyB,EAAjB,iBAAiB;;;;;cA6CpD,uBAAA,GAA2B,MAAyB,EAAjB,iBAAiB"}
|