@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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"wire-internal.mjs","names":[],"sources":["../src/wire-internal.ts"],"sourcesContent":["import { Effect, Encoding, Option, Predicate, Result } from 'effect'\nimport * as Schema from 'effect/Schema'\nimport type { HttpClientRequest } from 'effect/unstable/http'\n\n// Header names that carry credentials or session state. Fixtures must never\n// contain them and replay ledgers redact them.\nconst credentialHeaderNames: ReadonlySet<string> = new Set([\n 'authorization',\n 'proxy-authorization',\n 'cookie',\n 'set-cookie',\n 'x-api-key',\n 'api-key',\n 'x-goog-api-key',\n 'x-auth-token',\n 'x-access-token',\n 'x-amz-security-token',\n 'x-vercel-oidc-token',\n 'x-csrf-token'\n])\n\nconst credentialHeaderPattern = /(api[-_]?key|secret|password|cookie|authorization)/i\n\n// A `-`/`_`-separated name segment that is exactly `token`, `key`, or `auth`\n// (`x-auth-token`, `private-token`, `x-figma-token`, `x-*-key`). Plural\n// segments such as `x-ratelimit-remaining-tokens` do not match.\nconst credentialHeaderSegmentPattern = /(^|[-_])(token|key|auth)([-_]|$)/i\n\n/**\n * True for header names that carry credentials or session state. Used by the\n * fixture secret scan, recorder drop rules, and ledger redaction.\n */\nexport const isCredentialHeaderName = (name: string): boolean => {\n const lower = name.toLowerCase()\n\n return (\n credentialHeaderNames.has(lower) ||\n credentialHeaderPattern.test(lower) ||\n credentialHeaderSegmentPattern.test(lower)\n )\n}\n\nexport const redactedHeaderValue = '<redacted>'\n\n// Credential text patterns. The fixture secret scan (`scanFixtureForSecrets`) tests them and the\n// runner's report sanitizer redacts with them, so both share this one definition.\n\n/** `Bearer <token>` with a token-shaped value. */\nexport const bearerPattern = /\\bbearer\\s+[A-Za-z0-9._~+/=-]{8,}/i\n\n/** PEM private key header. */\nconst privateKeyPattern = /-----BEGIN [A-Z ]*PRIVATE KEY-----/\n\n/** Common API-key prefixes and JSON Web Tokens. */\nconst tokenPrefixPatterns: ReadonlyArray<RegExp> = [\n // OpenAI/Anthropic/DeepSeek-style secret keys (sk-..., sk-ant-..., sk-proj-...)\n /\\bsk-[A-Za-z0-9_-]{16,}/,\n /\\b[sr]k_(live|test)_[A-Za-z0-9]{16,}/,\n /\\bxai-[A-Za-z0-9]{20,}/,\n /\\bvck_[A-Za-z0-9]{16,}/,\n /\\bgh[pousr]_[A-Za-z0-9]{20,}/,\n /\\bgithub_pat_[A-Za-z0-9_]{20,}/,\n /\\bAKIA[0-9A-Z]{16}\\b/,\n /\\bAIza[0-9A-Za-z_-]{35}/,\n /\\bxox[abprs]-[A-Za-z0-9-]{10,}/,\n // JSON Web Tokens (OIDC/OAuth access tokens)\n /\\beyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}/\n]\n\n/** Common API-key prefixes, JSON Web Tokens, and PEM private keys. */\nexport const apiKeyPatterns: ReadonlyArray<RegExp> = [...tokenPrefixPatterns, privateKeyPattern]\n\nconst credentialParamNames =\n 'api[_-]?key|key|token|access[_-]?token|refresh[_-]?token|id[_-]?token|auth|secret|password|client[_-]?secret|x-amz-signature|x-amz-credential|x-amz-security-token'\n\n/**\n * Query-string or form-encoded credential parameter, anchored at the start of\n * the text or after `?`/`&` (URLs and `application/x-www-form-urlencoded` bodies).\n */\nexport const credentialParamPattern = new RegExp(`(?:^|[?&])(${credentialParamNames})=[^&#]+`, 'i')\n\n// Every `name=` of a credential parameter, found on its own: the pattern stops at `=` and never\n// consumes the value, so a later `?name=` or `&name=` is always found, whatever the value holds.\nconst credentialParamNamesAt = new RegExp(`(?:^|[?&])(${credentialParamNames})=`, 'gi')\n\n// What ends a raw parameter value: a character that cannot appear raw inside a URL query value\n// (RFC 3986): `&`, `#`, whitespace, `\"`, `<`, `>`. `?` and `'` are NOT boundaries (both are legal\n// raw inside a query value), and neither is any percent-encoded delimiter.\nconst valueBoundary = /[&#\\s\"<>]/\n\n/**\n * The exact synthetic credential values a `PortFixture` may carry, keyed by lower-case parameter\n * name: SigV4 presigned-URL placeholders for S3-compatible ports (the R2 conformance fixtures sign\n * with them). `scanPortFixtureForSecrets` exempts a credential parameter only when its name is a\n * key here and its whole raw value (up to `&`, `#`, whitespace, `\"`, `<`, `>`, or the end),\n * percent-decoded, equals that key's value exactly; anything else inside the value (a raw `?`, an\n * encoded delimiter, any suffix), another parameter name, or another scope is flagged.\n * `scanFixtureForSecrets` exempts nothing. Never build a placeholder by prefixing or suffixing a\n * real value.\n */\nexport const syntheticPortCredentialParams = Object.freeze({\n 'x-amz-signature': 'yolk-synthetic-signature',\n 'x-amz-credential': 'yolk-synthetic-access-key-id/20260930/auto/s3/aws4_request'\n})\n\nconst exactPlaceholders = new Map<string, string>(Object.entries(syntheticPortCredentialParams))\n\n/**\n * `text` with `%XX` escapes decoded, repeatedly (at most three layers, then left as it is). The R2\n * guard (`findR2PortFixtureSecrets` in `@yolk-sdk/connectors`) applies at most the same three\n * percent-decoding rounds (plus its escape rounds): keep the percent depth in step.\n */\nconst percentDecodedLayers = (text: string): string => {\n let current = text\n\n for (let round = 0; round < 3 && /%[0-9A-Fa-f]{2}/.test(current); round++) {\n current = current.replace(/%([0-9A-Fa-f]{2})/g, (_match, hex: string) =>\n String.fromCharCode(Number.parseInt(hex, 16))\n )\n }\n\n return current\n}\n\n/** The raw, undecoded value starting at `start`: up to the first structural boundary. */\nconst rawParamValueAt = (text: string, start: number): string => {\n const rest = text.slice(start)\n const end = rest.search(valueBoundary)\n\n return end === -1 ? rest : rest.slice(0, end)\n}\n\n/**\n * True when `text` carries a credential query or form parameter that is not an exact synthetic\n * placeholder (`syntheticPortCredentialParams`). Every `name=` is found and judged on its own: its\n * raw value runs to the next structural boundary (see `rawParamValueAt`), is percent-decoded\n * (`percentDecodedLayers`), and is exempt only when it equals that name's placeholder exactly. It\n * flags at least whatever `credentialParamPattern` flags (a `name=` followed by any character but\n * `&` or `#`), except the exact placeholders.\n */\nexport const hasLiveCredentialParam = (text: string): boolean =>\n [...text.matchAll(credentialParamNamesAt)].some(match => {\n const name = (match[1] ?? '').toLowerCase()\n const start = match.index + match[0].length\n const value = rawParamValueAt(text, start)\n const next = text.charAt(start)\n const present = value.length > 0 || (next !== '' && next !== '&' && next !== '#')\n\n return present && exactPlaceholders.get(name) !== percentDecodedLayers(value)\n })\n\n// Singular credential field names (snake, kebab, or camel case), including the AWS-style\n// `accessKeyId` / `secretAccessKey` / `sessionToken` of S3-compatible signing inputs. Plural usage\n// counters such as `max_tokens` or `prompt_tokens` never match.\nconst credentialFieldNames =\n '(?:access|refresh|id|auth|api|session|private|bearer|oauth)[_-]?token|token|client[_-]?secret|secret(?:[_-]?key)?|private[_-]?key|password|passwd|api[_-]?key|access[_-]?key[_-]?id|secret[_-]?access[_-]?key|authorization'\n\n/** A JSON object key (or similar field name) that holds a credential. */\nexport const credentialFieldPattern = new RegExp(`^(${credentialFieldNames})$`, 'i')\n\nconst globally = (pattern: RegExp): RegExp =>\n new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`)\n\n// Redaction forms of the patterns above. They are deliberately broader than the scan (any bearer\n// value, parameters after whitespace or punctuation, `name: value` field pairs) because\n// over-redacting a report message is harmless.\nconst bearerRedaction = /\\bbearer\\s+\\S+/gi\n\nconst apiKeyRedactions: ReadonlyArray<RegExp> = [\n ...tokenPrefixPatterns.map(globally),\n /-----BEGIN [A-Z ]*PRIVATE KEY-----[\\s\\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g\n]\n\nconst credentialParamRedaction = new RegExp(\n `(^|[?&\\\\s;,(])(${credentialParamNames})=[^&#\\\\s;,)]+`,\n 'gi'\n)\n\n// `name: value` / `\"name\": \"value\"` / `name=value`. An unquoted value may carry an auth scheme\n// (`Authorization: Basic <token>`); a value already redacted as `Bearer <redacted>` is kept.\nconst credentialFieldRedaction = new RegExp(\n `(^|[^A-Za-z0-9_-])([\"']?)(${credentialFieldNames})\\\\2(\\\\s*[:=]\\\\s*)(?!bearer <redacted>)(?:\"(?:[^\"\\\\\\\\]|\\\\\\\\.)*\"?|'[^']*'?|(?:(?:basic|bearer|digest|negotiate|token)\\\\s+)?[^\\\\s,;&}\\\\]]+)`,\n 'gi'\n)\n\n// Candidate unquoted `Name:` header-like names inside one line (colon only). Quoted keys such\n// as `\"x-api-key\": \"...\"` are handled by `quotedCredentialHeaderRedaction`; `name=value` pairs\n// are left to the quote-aware field and parameter passes.\nconst headerLikeNamePattern = /(?:^|[^A-Za-z0-9_-])([A-Za-z][A-Za-z0-9_-]*)\\s*:/g\n\n/**\n * Redact the rest of a line after the first header-like `Name:` that `isCredentialHeaderName`\n * accepts (`X-Api-Key: ...`, `Proxy-Authorization: Basic ...`, `Cookie: ...`), so every header the\n * fixture scan flags is also redacted in report text. Runs on the raw text before any other pass,\n * so a partially matched value (for example a key-shaped cookie name) never shields the rest of\n * the line.\n */\nconst redactCredentialHeaderLines = (text: string): string =>\n text\n .split(/(\\r\\n|\\r|\\n)/)\n .map(line => {\n headerLikeNamePattern.lastIndex = 0\n\n for (let match = headerLikeNamePattern.exec(line); match !== null;) {\n const name = match[1]\n\n if (name !== undefined && isCredentialHeaderName(name)) {\n return `${line.slice(0, match.index + match[0].length)} ${redactedHeaderValue}`\n }\n\n match = headerLikeNamePattern.exec(line)\n }\n\n return line\n })\n .join('')\n\n// A quoted key that `isCredentialHeaderName` accepts (`\"x-api-key\": \"...\"`,\n// `'Proxy-Authorization': '...'`): only the value is redacted, so JSON stays balanced.\nconst quotedCredentialHeaderRedaction =\n /([\"'])([A-Za-z][A-Za-z0-9_-]*)\\1(\\s*:\\s*)(\"(?:[^\"\\\\]|\\\\.)*\"?|'[^']*'?|[^\\s,;&{}[\\]]+)/g\n\nconst redactQuotedCredentialHeaders = (text: string): string =>\n text.replace(\n quotedCredentialHeaderRedaction,\n (match, quote: string, name: string, separator: string) =>\n isCredentialHeaderName(name)\n ? `${quote}${name}${quote}${separator}${redactedHeaderValue}`\n : match\n )\n\n/**\n * Redact every credential pattern shared with the fixture secret scan: credential header lines\n * (first, to the end of the line), quoted credential header keys (value only), bearer tokens,\n * API-key prefixes, JWTs, private keys, credential field pairs (`\"api_key\": \"...\"`,\n * `password=\"...\"`), and credential query/form parameters. Best effort; used on report messages.\n * Known gap: escaped JSON inside a quoted string value (`\"body\":\"{\\\\\"x-api-key\\\\\":...}\"`) is not\n * parsed.\n * Quote-aware field redaction runs before parameter redaction so a quoted value with spaces is\n * removed whole.\n */\nexport const redactCredentialText = (text: string): string => {\n let result = redactCredentialHeaderLines(text).replace(\n bearerRedaction,\n `Bearer ${redactedHeaderValue}`\n )\n\n for (const pattern of apiKeyRedactions) {\n result = result.replace(pattern, redactedHeaderValue)\n }\n\n return redactQuotedCredentialHeaders(result)\n .replace(\n credentialFieldRedaction,\n (_match, prefix: string, quote: string, name: string, separator: string) =>\n `${prefix}${quote}${name}${quote}${separator}${redactedHeaderValue}`\n )\n .replace(\n credentialParamRedaction,\n (_match, prefix: string, name: string) => `${prefix}${name}=${redactedHeaderValue}`\n )\n}\n\nexport const redactHeaders = (headers: Readonly<Record<string, string>>) => {\n const redacted: Record<string, string> = {}\n\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = isCredentialHeaderName(name) ? redactedHeaderValue : value\n }\n\n return redacted\n}\n\nconst compareStrings = (left: string, right: string): number => {\n if (left < right) {\n return -1\n }\n\n return left > right ? 1 : 0\n}\n\n/**\n * Canonical absolute URL used for matching: hash removed and query parameters\n * sorted by name, then value. Unparseable input is returned unchanged.\n */\nexport const normalizeWireUrl = (input: string): string => {\n if (!URL.canParse(input)) {\n return input\n }\n\n const url = new URL(input)\n url.hash = ''\n\n const params = [...url.searchParams.entries()].sort(\n ([leftName, leftValue], [rightName, rightValue]) =>\n leftName === rightName\n ? compareStrings(leftValue, rightValue)\n : compareStrings(leftName, rightName)\n )\n\n url.search = ''\n\n for (const [name, value] of params) {\n url.searchParams.append(name, value)\n }\n\n return url.toString()\n}\n\n/**\n * Match an optional URL filter. A pattern ending in `*` is a prefix match on\n * the normalized URL; otherwise the normalized URLs must be equal.\n */\nexport const urlMatchesPattern = (pattern: string, normalizedUrl: string): boolean =>\n pattern.endsWith('*')\n ? normalizedUrl.startsWith(pattern.slice(0, -1))\n : normalizeWireUrl(pattern) === normalizedUrl\n\n/** Request body as UTF-8 text when it is an in-memory body; otherwise undefined. */\nexport const requestBodyText = (\n request: HttpClientRequest.HttpClientRequest\n): string | undefined => {\n const body = request.body\n\n if (Predicate.isTagged(body, 'Uint8Array')) {\n return new TextDecoder().decode(body.body)\n }\n\n if (Predicate.isTagged(body, 'Raw') && Predicate.isString(body.body)) {\n return body.body\n }\n\n return undefined\n}\n\n/** Exact bytes of standard base64 text; `None` when the text is not valid base64. */\nexport const decodeBase64Bytes = (text: string): Option.Option<Uint8Array> =>\n Result.getSuccess(Encoding.decodeBase64(text))\n\n/**\n * Decode bytes as UTF-8 only when they are valid UTF-8 on their own. A leading\n * byte-order mark is kept so that re-encoding yields the same bytes.\n */\nexport const decodeUtf8Strict = (bytes: Uint8Array): Option.Option<string> =>\n Option.liftThrowable(() =>\n new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes)\n )()\n\n/** Lossless recording of bytes: readable text when valid UTF-8, otherwise base64. */\nexport const recordBytes = (\n bytes: Uint8Array\n): { readonly text: string } | { readonly base64: string } =>\n Option.match(decodeUtf8Strict(bytes), {\n onNone: () => ({ base64: Encoding.encodeBase64(bytes) }),\n onSome: text => ({ text })\n })\n\nconst decodeJsonText = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Json))\n\n/** Parse text as JSON; `None` when it is not valid JSON. */\nexport const parseJsonText = (text: string): Effect.Effect<Option.Option<Schema.Json>> =>\n decodeJsonText(text).pipe(Effect.option)\n\nexport const headerRecord = (headers: Readonly<Record<string, string | undefined>>) => {\n const record: Record<string, string> = {}\n\n for (const [name, value] of Object.entries(headers)) {\n if (value !== undefined) {\n record[name.toLowerCase()] = value\n }\n }\n\n return record\n}\n\nexport const mediaType = (contentType: string | undefined): string | undefined =>\n contentType?.split(';', 1)[0]?.trim().toLowerCase()\n\n// Web `Response` rejects a body for these statuses.\nconst nullBodyStatuses: ReadonlySet<number> = new Set([101, 103, 204, 205, 304])\n\nexport const isNullBodyStatus = (status: number): boolean => nullBodyStatuses.has(status)\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yolk-sdk/conformance",
|
|
3
|
+
"version": "0.1.0-canary.96",
|
|
4
|
+
"description": "Experimental Effect toolkit to record, replay, and fault-inject HTTP wire fixtures, and to define and run conformance cases.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/magoz/yolk-sdk.git",
|
|
11
|
+
"directory": "packages/conformance"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/magoz/yolk-sdk/issues"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/magoz/yolk-sdk#readme",
|
|
17
|
+
"keywords": [
|
|
18
|
+
"conformance",
|
|
19
|
+
"fixtures",
|
|
20
|
+
"replay",
|
|
21
|
+
"http",
|
|
22
|
+
"effect"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=22"
|
|
26
|
+
},
|
|
27
|
+
"exports": {
|
|
28
|
+
"./package.json": "./package.json",
|
|
29
|
+
"./fixture": {
|
|
30
|
+
"types": "./dist/fixture.d.mts",
|
|
31
|
+
"import": "./dist/fixture.mjs",
|
|
32
|
+
"default": "./dist/fixture.mjs"
|
|
33
|
+
},
|
|
34
|
+
"./replay": {
|
|
35
|
+
"types": "./dist/replay.d.mts",
|
|
36
|
+
"import": "./dist/replay.mjs",
|
|
37
|
+
"default": "./dist/replay.mjs"
|
|
38
|
+
},
|
|
39
|
+
"./record": {
|
|
40
|
+
"types": "./dist/record.d.mts",
|
|
41
|
+
"import": "./dist/record.mjs",
|
|
42
|
+
"default": "./dist/record.mjs"
|
|
43
|
+
},
|
|
44
|
+
"./case": {
|
|
45
|
+
"types": "./dist/case.d.mts",
|
|
46
|
+
"import": "./dist/case.mjs",
|
|
47
|
+
"default": "./dist/case.mjs"
|
|
48
|
+
},
|
|
49
|
+
"./runner": {
|
|
50
|
+
"types": "./dist/runner.d.mts",
|
|
51
|
+
"import": "./dist/runner.mjs",
|
|
52
|
+
"default": "./dist/runner.mjs"
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"files": [
|
|
56
|
+
"src/**/*.ts",
|
|
57
|
+
"!src/**/*.test.ts",
|
|
58
|
+
"!src/**/*.test.tsx",
|
|
59
|
+
"dist/**/*",
|
|
60
|
+
"README.md"
|
|
61
|
+
],
|
|
62
|
+
"publishConfig": {
|
|
63
|
+
"access": "public",
|
|
64
|
+
"provenance": true
|
|
65
|
+
},
|
|
66
|
+
"dependencies": {
|
|
67
|
+
"effect": "4.0.0-rc.115"
|
|
68
|
+
},
|
|
69
|
+
"scripts": {
|
|
70
|
+
"build": "tsdown",
|
|
71
|
+
"check": "tsc -p tsconfig.json --noEmit",
|
|
72
|
+
"test": "vitest run --passWithNoTests",
|
|
73
|
+
"test:run": "vitest run --passWithNoTests"
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/case.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conformance cases: small, named, pure Effect programs that each prove one
|
|
3
|
+
* claim about how an outside service really behaves on the wire.
|
|
4
|
+
*
|
|
5
|
+
* A case only states what it needs (its `R`); the same case runs unchanged
|
|
6
|
+
* against replayed fixtures, an in-process emulator, a local emulator
|
|
7
|
+
* process, or a real practice account. Only the layers a host provides
|
|
8
|
+
* change. See `@yolk-sdk/conformance/runner` for the safety policy.
|
|
9
|
+
*
|
|
10
|
+
* @experimental
|
|
11
|
+
*/
|
|
12
|
+
import { Data, Effect, Equal, Result } from 'effect'
|
|
13
|
+
import * as Schema from 'effect/Schema'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What a case does to the account it runs against:
|
|
17
|
+
*
|
|
18
|
+
* - `read`: only reads.
|
|
19
|
+
* - `write-reversible`: writes but leaves the account as it found it (it
|
|
20
|
+
* creates its own records and cleans them up, or the write is rejected and
|
|
21
|
+
* changes nothing).
|
|
22
|
+
* - `write-irreversible`: a write that cannot be undone (for example sending
|
|
23
|
+
* an email). Never automated against a live account.
|
|
24
|
+
*/
|
|
25
|
+
export const ConformanceSafety = Schema.Literals(['read', 'write-reversible', 'write-irreversible'])
|
|
26
|
+
|
|
27
|
+
export type ConformanceSafety = typeof ConformanceSafety.Type
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Dotted lower-case case id: two or more segments of `a-z`, `0-9`, and inner
|
|
31
|
+
* hyphens, for example `vendor.stream.plain-text`.
|
|
32
|
+
*/
|
|
33
|
+
export const ConformanceCaseId = Schema.String.check(
|
|
34
|
+
Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)+$/)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
const CalendarDate = Schema.String.check(Schema.isPattern(/^\d{4}-\d{2}-\d{2}$/))
|
|
38
|
+
|
|
39
|
+
/** When and where a person last watched the claim hold against the real service. */
|
|
40
|
+
export const ConformanceObservation = Schema.Struct({
|
|
41
|
+
/** Synthetic account label, for example `synthetic`; never a real account name. */
|
|
42
|
+
account: Schema.NonEmptyString,
|
|
43
|
+
/** Calendar date of the observation (`YYYY-MM-DD`, UTC). */
|
|
44
|
+
date: CalendarDate
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
export type ConformanceObservation = typeof ConformanceObservation.Type
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* One wire claim. `run` succeeds when the claim holds and fails (with a
|
|
51
|
+
* `ConformanceMismatch`, its own error, or a port's error) when it does not.
|
|
52
|
+
*/
|
|
53
|
+
export type ConformanceCase<E = never, R = never> = {
|
|
54
|
+
/** Dotted lower-case id (see `ConformanceCaseId`). */
|
|
55
|
+
readonly id: string
|
|
56
|
+
readonly title?: string
|
|
57
|
+
readonly safety: ConformanceSafety
|
|
58
|
+
/** What the service's documentation claims. */
|
|
59
|
+
readonly docs: string
|
|
60
|
+
/** What the wire actually does (the claim this case proves). */
|
|
61
|
+
readonly wire: string
|
|
62
|
+
/** Last live observation. Absent means the claim is unverified against the real service. */
|
|
63
|
+
readonly observed?: ConformanceObservation
|
|
64
|
+
/** Ids of the fixtures (`WireFixture`s or `PortFixture`s) that back replay of this case. */
|
|
65
|
+
readonly fixtures: ReadonlyArray<string>
|
|
66
|
+
readonly run: Effect.Effect<void, E, R>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const ConformanceCaseMetadata = Schema.Struct({
|
|
70
|
+
id: ConformanceCaseId,
|
|
71
|
+
title: Schema.optionalKey(Schema.NonEmptyString),
|
|
72
|
+
safety: ConformanceSafety,
|
|
73
|
+
docs: Schema.NonEmptyString,
|
|
74
|
+
wire: Schema.NonEmptyString,
|
|
75
|
+
observed: Schema.optionalKey(ConformanceObservation),
|
|
76
|
+
fixtures: Schema.Array(Schema.NonEmptyString)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const validateMetadata = Schema.decodeUnknownResult(ConformanceCaseMetadata)
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Thrown by `defineConformanceCase` for an invalid definition. A programmer
|
|
83
|
+
* error, surfaced when the defining module loads (like `makeTool`).
|
|
84
|
+
*/
|
|
85
|
+
export class ConformanceCaseInvalid extends Data.TaggedError('ConformanceCaseInvalid')<{
|
|
86
|
+
readonly caseId: string
|
|
87
|
+
readonly reason: string
|
|
88
|
+
}> {
|
|
89
|
+
override get message(): string {
|
|
90
|
+
return `Invalid conformance case ${JSON.stringify(this.caseId)}: ${this.reason}`
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Define a conformance case. The metadata (id format, safety, non-empty
|
|
96
|
+
* `docs`/`wire`, observation date, fixture ids) is validated here and an
|
|
97
|
+
* invalid definition throws `ConformanceCaseInvalid`: definitions are
|
|
98
|
+
* module-level constants, so a bad one fails fast when the module loads
|
|
99
|
+
* instead of surfacing mid-run.
|
|
100
|
+
*/
|
|
101
|
+
export const defineConformanceCase = <E = never, R = never>(
|
|
102
|
+
spec: ConformanceCase<E, R>
|
|
103
|
+
): ConformanceCase<E, R> => {
|
|
104
|
+
const { run: _run, ...metadata } = spec
|
|
105
|
+
const result = validateMetadata(metadata)
|
|
106
|
+
|
|
107
|
+
if (Result.isFailure(result)) {
|
|
108
|
+
throw new ConformanceCaseInvalid({
|
|
109
|
+
caseId: spec.id,
|
|
110
|
+
reason: new Schema.SchemaError(result.failure.issue).message
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return spec
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A wire claim did not hold. Model-free: `expected` / `actual` are optional
|
|
119
|
+
* JSON values chosen by the case author; keep them small and synthetic.
|
|
120
|
+
*/
|
|
121
|
+
export class ConformanceMismatch extends Data.TaggedError('ConformanceMismatch')<{
|
|
122
|
+
readonly message: string
|
|
123
|
+
readonly expected?: Schema.Json
|
|
124
|
+
readonly actual?: Schema.Json
|
|
125
|
+
}> {}
|
|
126
|
+
|
|
127
|
+
export type ConformanceMismatchDetails = {
|
|
128
|
+
readonly expected?: Schema.Json
|
|
129
|
+
readonly actual?: Schema.Json
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const mismatch = (message: string, details: ConformanceMismatchDetails = {}) =>
|
|
133
|
+
new ConformanceMismatch({ message, ...details })
|
|
134
|
+
|
|
135
|
+
/** Succeed when `condition` holds; otherwise fail with a `ConformanceMismatch`. */
|
|
136
|
+
export const expectConformance = (
|
|
137
|
+
condition: boolean,
|
|
138
|
+
message: string,
|
|
139
|
+
details?: ConformanceMismatchDetails
|
|
140
|
+
): Effect.Effect<void, ConformanceMismatch> =>
|
|
141
|
+
condition ? Effect.void : Effect.fail(mismatch(message, details))
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Succeed when `actual` and `expected` are structurally equal JSON values
|
|
145
|
+
* (effect `Equal.equals`: same primitives, same array order, same object keys
|
|
146
|
+
* and values); otherwise fail with a `ConformanceMismatch` carrying both.
|
|
147
|
+
*/
|
|
148
|
+
export const expectEqual = (
|
|
149
|
+
actual: Schema.Json,
|
|
150
|
+
expected: Schema.Json,
|
|
151
|
+
message: string
|
|
152
|
+
): Effect.Effect<void, ConformanceMismatch> =>
|
|
153
|
+
expectConformance(Equal.equals(actual, expected), message, { expected, actual })
|