@ultimat3/testing 29.3.2 → 30.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/CLAUDE.md +56 -63
  2. package/README.md +281 -6
  3. package/package.json +27 -16
  4. package/src/ax-node.ts +96 -0
  5. package/src/cassette-errors.ts +91 -0
  6. package/src/cassette-format.ts +175 -0
  7. package/src/cassette-record.ts +120 -0
  8. package/src/cassette-secret-scan.ts +77 -0
  9. package/src/cassette.ts +166 -0
  10. package/src/cdp-browser.ts +5 -3
  11. package/src/cdp-e2e-page.ts +91 -3
  12. package/src/cdp-e2e-session.ts +18 -1
  13. package/src/define-exercise.ts +82 -0
  14. package/src/drill-budgets.ts +54 -0
  15. package/src/drill-errors.ts +45 -0
  16. package/src/drill-fleet.ts +251 -0
  17. package/src/drill-jobs.ts +79 -0
  18. package/src/drill-load.ts +166 -0
  19. package/src/drill-probe.ts +68 -0
  20. package/src/drill-run.ts +144 -0
  21. package/src/drill-services.ts +70 -0
  22. package/src/drill-sync.ts +108 -0
  23. package/src/drill-verdict.ts +256 -0
  24. package/src/drill-writes.ts +123 -0
  25. package/src/drills.ts +109 -0
  26. package/src/e2e-a11y-run.ts +189 -0
  27. package/src/e2e-a11y-tabs.ts +113 -0
  28. package/src/e2e-a11y.ts +153 -0
  29. package/src/e2e-driver.ts +46 -10
  30. package/src/e2e-page.ts +28 -3
  31. package/src/e2e-run.ts +1 -0
  32. package/src/e2e-spawn.ts +3 -2
  33. package/src/errors.ts +43 -0
  34. package/src/exercise-actor.ts +124 -0
  35. package/src/exercise-crash.ts +46 -0
  36. package/src/exercise-input.ts +182 -0
  37. package/src/exercise-override-input.ts +34 -0
  38. package/src/exercise-report.ts +282 -0
  39. package/src/exercise-request.ts +38 -0
  40. package/src/exercise-sweeps.ts +100 -0
  41. package/src/exercise.ts +448 -0
  42. package/src/factories.ts +10 -7
  43. package/src/factory-registry.ts +49 -15
  44. package/src/fake-identity-provider.ts +191 -0
  45. package/src/fault-proxy.ts +342 -0
  46. package/src/file-boundary.ts +4 -1
  47. package/src/fixture-jobs.ts +3 -1
  48. package/src/fixture-statements.ts +4 -1
  49. package/src/free-port.ts +59 -0
  50. package/src/index.ts +148 -3
  51. package/src/island-dom.ts +5 -0
  52. package/src/isolated-plugins.ts +1 -1
  53. package/src/job-crash-proof.ts +249 -0
  54. package/src/job-sim-driver.ts +164 -0
  55. package/src/job-sim-effects.ts +142 -0
  56. package/src/job-sim-errors.ts +72 -0
  57. package/src/job-sim-schedule.ts +154 -0
  58. package/src/job-sim-world.ts +198 -0
  59. package/src/job-simulate.ts +179 -0
  60. package/src/mock-route.ts +81 -0
  61. package/src/module-mock-boundary.ts +85 -0
  62. package/src/module-mock-error.ts +28 -0
  63. package/src/plan-oracle.ts +164 -0
  64. package/src/plan-sweep-fix.ts +123 -0
  65. package/src/plan-sweep-report.ts +84 -0
  66. package/src/plan-sweep.ts +148 -0
  67. package/src/preload.ts +24 -4
  68. package/src/registry-snapshot.ts +1 -1
  69. package/src/repro-stand-in.ts +67 -0
  70. package/src/repro.ts +158 -0
  71. package/src/request-log.ts +64 -0
  72. package/src/reusable-database.ts +1 -1
  73. package/src/scale-sweep-graph.ts +82 -0
  74. package/src/scale-sweep-run.ts +134 -0
  75. package/src/scale-sweep.ts +74 -0
  76. package/src/sealed-network.ts +63 -46
  77. package/src/seed-graph-error.ts +50 -0
  78. package/src/seed-graph-order.ts +98 -0
  79. package/src/seed-graph-pass.ts +68 -0
  80. package/src/seed-graph-plan.ts +81 -0
  81. package/src/seed-graph-row.ts +134 -0
  82. package/src/seed-graph-shape.ts +104 -0
  83. package/src/seed-graph-values.ts +135 -0
  84. package/src/seed-graph.ts +250 -0
  85. package/src/seed-writer.ts +180 -0
  86. package/src/subject-proof-error.ts +23 -0
  87. package/src/subject-proof.ts +180 -0
  88. package/src/template-db-roles.ts +51 -0
  89. package/src/template-db.ts +8 -2
  90. package/src/tenant-canary.ts +120 -0
  91. package/src/tenant-proof-report.ts +62 -0
  92. package/src/tenant-proof.ts +177 -0
  93. package/src/test-types.ts +12 -3
package/package.json CHANGED
@@ -1,7 +1,17 @@
1
1
  {
2
2
  "name": "@ultimat3/testing",
3
- "version": "29.3.2",
3
+ "version": "30.0.0",
4
4
  "description": "Test harness: cloned template DBs per worker, frozen clock, sealed network, 6 test types",
5
+ "keywords": [
6
+ "ultimate",
7
+ "bun",
8
+ "framework",
9
+ "testing",
10
+ "test",
11
+ "fixtures",
12
+ "harness"
13
+ ],
14
+ "homepage": "https://github.com/developerz-ai/ultimate/wiki/Testing",
5
15
  "license": "MIT",
6
16
  "type": "module",
7
17
  "repository": {
@@ -36,20 +46,21 @@
36
46
  "test": "bun test"
37
47
  },
38
48
  "dependencies": {
39
- "@ultimat3/action": "29.3.2",
40
- "@ultimat3/cache": "29.3.2",
41
- "@ultimat3/core": "29.3.2",
42
- "@ultimat3/db": "29.3.2",
43
- "@ultimat3/entity": "29.3.2",
44
- "@ultimat3/http": "29.3.2",
45
- "@ultimat3/i18n": "29.3.2",
46
- "@ultimat3/jobs": "29.3.2",
47
- "@ultimat3/mail": "29.3.2",
48
- "@ultimat3/policy": "29.3.2",
49
- "@ultimat3/pwa": "29.3.2",
50
- "@ultimat3/query": "29.3.2",
51
- "@ultimat3/realtime": "29.3.2",
52
- "@ultimat3/render": "29.3.2",
53
- "@ultimat3/time": "29.3.2"
49
+ "@ultimat3/action": "30.0.0",
50
+ "@ultimat3/cache": "30.0.0",
51
+ "@ultimat3/core": "30.0.0",
52
+ "@ultimat3/db": "30.0.0",
53
+ "@ultimat3/entity": "30.0.0",
54
+ "@ultimat3/http": "30.0.0",
55
+ "@ultimat3/i18n": "30.0.0",
56
+ "@ultimat3/jobs": "30.0.0",
57
+ "@ultimat3/mail": "30.0.0",
58
+ "@ultimat3/policy": "30.0.0",
59
+ "@ultimat3/pwa": "30.0.0",
60
+ "@ultimat3/query": "30.0.0",
61
+ "@ultimat3/realtime": "30.0.0",
62
+ "@ultimat3/render": "30.0.0",
63
+ "@ultimat3/schema": "30.0.0",
64
+ "@ultimat3/time": "30.0.0"
54
65
  }
55
66
  }
package/src/ax-node.ts ADDED
@@ -0,0 +1,96 @@
1
+ // The one reader of the browser's accessibility nodes: what the BROWSER computed for a screen
2
+ // reader, after ARIA, label association and `aria-hidden` pruning. `x shot` reads one selector's
3
+ // node through it (`cli/src/cdp-shot-a11y.ts`), the e2e audit a whole document (`e2e-a11y.ts`).
4
+ // Every answer is somebody else's JSON, read and never cast.
5
+
6
+ /** What a screen reader is told about one element — the browser's computed role and name. */
7
+ export interface AxNode {
8
+ readonly role: string;
9
+ readonly name: string;
10
+ readonly description?: string | undefined;
11
+ readonly value?: string | undefined;
12
+ readonly focused?: boolean | undefined;
13
+ readonly disabled?: boolean | undefined;
14
+ readonly ignored: boolean;
15
+ }
16
+
17
+ /**
18
+ * One node of a full tree, with the two facts the audit keys on that `AxNode` drops: whether the
19
+ * role is an ARIA `role` or Chrome's `internalRole` (so the document's `RootWebArea` is never
20
+ * confused with an author's string), and the DOM node it describes. `html` and `matches` are
21
+ * filled only for a node the caller asked to have described — one round trip each, never all.
22
+ */
23
+ export interface AxTreeNode extends AxNode {
24
+ readonly roleType: string;
25
+ readonly backendNodeId?: number | undefined;
26
+ /** The element's `outerHTML`, bounded by the describer. */
27
+ readonly html?: string | undefined;
28
+ /** Which of the selectors the describer was handed this element matches. */
29
+ readonly matches?: readonly string[] | undefined;
30
+ }
31
+
32
+ export const axRecord = (value: unknown): Readonly<Record<string, unknown>> | undefined =>
33
+ typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : undefined;
34
+
35
+ /** CDP's `AXValue.value` is any scalar; a reader is told text. */
36
+ const textOf = (wrapped: unknown): string | undefined => {
37
+ const value = axRecord(wrapped)?.['value'];
38
+ return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
39
+ ? String(value)
40
+ : undefined;
41
+ };
42
+
43
+ const flagOf = (node: Readonly<Record<string, unknown>>, name: string): boolean | undefined => {
44
+ const properties = node['properties'];
45
+ if (!Array.isArray(properties)) return undefined;
46
+ const found = properties.map(axRecord).find((property) => property?.['name'] === name);
47
+ return found === undefined ? undefined : axRecord(found['value'])?.['value'] === true;
48
+ };
49
+
50
+ const readNode = (node: Readonly<Record<string, unknown>>): AxNode => {
51
+ const description = textOf(node['description']);
52
+ const value = textOf(node['value']);
53
+ const focused = flagOf(node, 'focused');
54
+ const disabled = flagOf(node, 'disabled');
55
+ return {
56
+ role: textOf(node['role']) ?? '',
57
+ name: textOf(node['name']) ?? '',
58
+ ...(description === undefined ? {} : { description }),
59
+ ...(value === undefined ? {} : { value }),
60
+ ...(focused === undefined ? {} : { focused }),
61
+ ...(disabled === undefined ? {} : { disabled }),
62
+ ignored: node['ignored'] === true,
63
+ };
64
+ };
65
+
66
+ /**
67
+ * `nodes[0]` is the element's own node when `fetchRelatives` is false. An EMPTY list is a real
68
+ * answer — an element that never entered the tree — and is kept as ignored, so the count the
69
+ * caller gets is the count of matches.
70
+ */
71
+ export const toAxNode = (answer: Readonly<Record<string, unknown>> | undefined): AxNode => {
72
+ const nodes = answer?.['nodes'];
73
+ const node = Array.isArray(nodes) ? axRecord(nodes[0]) : undefined;
74
+ return node === undefined ? { role: '', name: '', ignored: true } : readNode(node);
75
+ };
76
+
77
+ /** Every node of a full-tree answer, in the browser's order. A non-object entry is skipped. */
78
+ export const toAxTreeNodes = (
79
+ answer: Readonly<Record<string, unknown>> | undefined,
80
+ ): readonly AxTreeNode[] => {
81
+ const nodes = answer?.['nodes'];
82
+ if (!Array.isArray(nodes)) return [];
83
+ const out: AxTreeNode[] = [];
84
+ for (const raw of nodes) {
85
+ const node = axRecord(raw);
86
+ if (node === undefined) continue;
87
+ const roleType = axRecord(node['role'])?.['type'];
88
+ const backendNodeId = node['backendDOMNodeId'];
89
+ out.push({
90
+ ...readNode(node),
91
+ roleType: typeof roleType === 'string' ? roleType : '',
92
+ ...(typeof backendNodeId === 'number' ? { backendNodeId } : {}),
93
+ });
94
+ }
95
+ return out;
96
+ };
@@ -0,0 +1,91 @@
1
+ // The four refusals a cassette raises: nothing to replay, a recording the code no longer drives,
2
+ // a secret about to reach disk, and a recording that must not run. Codes and titles are
3
+ // registered in ./errors; the throw sites are ./cassette and ./cassette-record.
4
+
5
+ import { renderFixLiteral, renderFixShellArg, UltimateError } from '@ultimat3/core';
6
+
7
+ /** The command that (re)records every cassette under a test directory. */
8
+ const recordCommand = (dir: string): string =>
9
+ `ULTIMATE_CASSETTE=record bun test ${renderFixShellArg(dir, '<test dir>')}`;
10
+
11
+ export class CassetteMissingError extends UltimateError {
12
+ constructor(input: { readonly file: string; readonly dir: string; readonly name: string }) {
13
+ super({
14
+ code: 'X_CASSETTE_MISSING',
15
+ cause: `cassette ${JSON.stringify(input.name)} has no recording at ${input.file}, so there is nothing to replay`,
16
+ fix: `${recordCommand(input.dir)} # with cassette(import.meta.dir, ${renderFixLiteral(input.name, "'<name>'")}, { record: { hosts: ['<vendor host>'] } }), CI unset`,
17
+ meta: { file: input.file },
18
+ });
19
+ }
20
+ }
21
+
22
+ export interface UnusedEntry {
23
+ readonly method: string;
24
+ readonly url: string;
25
+ /** The entry's position among the recording's entries for this method and URL. */
26
+ readonly index: number;
27
+ }
28
+
29
+ export class CassetteUnusedError extends UltimateError {
30
+ constructor(input: {
31
+ readonly file: string;
32
+ readonly dir: string;
33
+ readonly unused: readonly UnusedEntry[];
34
+ }) {
35
+ const first = input.unused[0];
36
+ const named = first === undefined ? '' : `, first ${first.method} ${first.url}`;
37
+ super({
38
+ code: 'X_CASSETTE_UNUSED',
39
+ cause: `${input.unused.length} recorded answer(s) in ${input.file} were never asked for${named}: the recording describes a run the code no longer makes`,
40
+ fix: `${recordCommand(input.dir)} # re-record — or delete the entries meta.unused names from ${input.file}`,
41
+ meta: { file: input.file, unused: input.unused },
42
+ });
43
+ }
44
+ }
45
+
46
+ export class CassetteSecretError extends UltimateError {
47
+ constructor(input: {
48
+ readonly file: string;
49
+ readonly dir: string;
50
+ /** An env key, or `secrets[<i>]` for a value the cassette was handed. Never the value. */
51
+ readonly key: string;
52
+ readonly offset: number;
53
+ readonly encoding: string;
54
+ }) {
55
+ const source = input.key.startsWith('secrets[')
56
+ ? 'the value you passed in secrets'
57
+ : `Bun.env[${renderFixLiteral(input.key, "'<KEY>'")}] ?? ''`;
58
+ super({
59
+ code: 'X_CASSETTE_SECRET',
60
+ cause: `the value of ${input.key} (${input.encoding}) is at byte ${input.offset} of what ${input.file} would hold; nothing was written`,
61
+ fix: `cassette(import.meta.dir, '<name>', { redact: (text) => text.replaceAll(${source}, '[redacted]') }) # covers ${input.key} at byte ${input.offset}; then ${recordCommand(input.dir)}`,
62
+ meta: { file: input.file, key: input.key, offset: input.offset, encoding: input.encoding },
63
+ });
64
+ }
65
+ }
66
+
67
+ export type RecordRefusal =
68
+ | { readonly reason: 'ci' }
69
+ | { readonly reason: 'no-hosts' }
70
+ | { readonly reason: 'host'; readonly host: string };
71
+
72
+ export class CassetteRecordRefusedError extends UltimateError {
73
+ constructor(input: RecordRefusal & { readonly name: string }) {
74
+ const name = renderFixLiteral(input.name, "'<name>'");
75
+ const host = input.reason === 'host' ? renderFixLiteral(input.host, "'<host>'") : "'<host>'";
76
+ const why =
77
+ input.reason === 'ci'
78
+ ? 'CI is set: a gate never reaches a vendor, so a recording only runs on a developer machine'
79
+ : input.reason === 'no-hosts'
80
+ ? 'ULTIMATE_CASSETTE=record is set but the cassette names no record.hosts to let through'
81
+ : `the run requested ${input.host}, which record.hosts does not name`;
82
+ super({
83
+ code: 'X_CASSETTE_RECORD_REFUSED',
84
+ cause: `cassette ${JSON.stringify(input.name)} refused to record: ${why}`,
85
+ fix:
86
+ input.reason === 'ci'
87
+ ? 'env -u CI ULTIMATE_CASSETTE=record bun test <test file> # on a developer machine; CI only replays'
88
+ : `cassette(import.meta.dir, ${name}, { record: { hosts: [${host}] } }) # add the host`,
89
+ });
90
+ }
91
+ }
@@ -0,0 +1,175 @@
1
+ // What a cassette file holds and how a recorded answer is made safe to hold: response headers on
2
+ // an allow-list, credential-named keys masked in JSON bodies and URL queries, sorted keys, and a
3
+ // non-text body as base64. The request side is never written — a test asserts it via `requestLog`.
4
+
5
+ import { assert, isRedactedKey, REDACTED, renderFixShellArg } from '@ultimat3/core';
6
+
7
+ export const CASSETTE_VERSION = 1;
8
+
9
+ export interface CassetteEntry {
10
+ readonly method: string;
11
+ /** The URL as the call spelled it, credential-named query values masked. */
12
+ readonly url: string;
13
+ readonly status: number;
14
+ readonly headers: Readonly<Record<string, string>>;
15
+ readonly body: string;
16
+ readonly encoding: 'utf8' | 'base64';
17
+ }
18
+
19
+ export interface CassetteFile {
20
+ readonly version: number;
21
+ readonly entries: readonly CassetteEntry[];
22
+ }
23
+
24
+ /**
25
+ * An ALLOW-list: a deny-list misses the vendor's next header, and a header is where a session
26
+ * cookie or a request id tied to an account rides back.
27
+ */
28
+ const KEPT_HEADERS: readonly RegExp[] = [/^content-type$/, /^retry-after$/, /^ratelimit-/];
29
+
30
+ export const keptHeaders = (headers: Headers): Record<string, string> => {
31
+ const kept: Record<string, string> = {};
32
+ headers.forEach((value, name) => {
33
+ const lower = name.toLowerCase();
34
+ if (KEPT_HEADERS.some((rule) => rule.test(lower))) kept[lower] = value;
35
+ });
36
+ return kept;
37
+ };
38
+
39
+ /** The masked value as a query string carries it. */
40
+ const MASKED_QUERY = encodeURIComponent(REDACTED);
41
+
42
+ /** Each `key=value` whose key names a credential gets the mask; every other byte kept verbatim. */
43
+ export function maskUrl(url: string): string {
44
+ const queryAt = url.indexOf('?');
45
+ if (queryAt < 0) return url;
46
+ const hashAt = url.indexOf('#', queryAt);
47
+ const end = hashAt < 0 ? url.length : hashAt;
48
+ const pairs = url.slice(queryAt + 1, end).split('&');
49
+ const masked = pairs.map((pair) => {
50
+ const eq = pair.indexOf('=');
51
+ if (eq < 0) return pair;
52
+ return isRedactedKey(decodeKey(pair.slice(0, eq)))
53
+ ? `${pair.slice(0, eq)}=${MASKED_QUERY}`
54
+ : pair;
55
+ });
56
+ return `${url.slice(0, queryAt + 1)}${masked.join('&')}${url.slice(end)}`;
57
+ }
58
+
59
+ /**
60
+ * The match for a recorded URL: exact where nothing was masked, else a pattern where each mask —
61
+ * a query value, or a `[redacted]` an app's `redact` put in a path — stands for one value, so the
62
+ * replayed request, carrying the real key, still matches.
63
+ */
64
+ export function urlMatch(recorded: string): string | RegExp {
65
+ const masks = [MASKED_QUERY, REDACTED];
66
+ if (!masks.some((mask) => recorded.includes(mask))) return recorded;
67
+ const pattern = recorded
68
+ .split(MASKED_QUERY)
69
+ .map((part) => part.split(REDACTED).map(escapeRegExp).join('[^/?&#]*'))
70
+ .join('[^&#]*');
71
+ return new RegExp(`^${pattern}$`);
72
+ }
73
+
74
+ /** A JSON body with every credential-named key's value masked, at any depth; other text as is. */
75
+ export function redactBody(text: string, contentType: string | undefined): string {
76
+ if (contentType !== undefined && !/json/i.test(contentType)) return text;
77
+ let parsed: unknown;
78
+ try {
79
+ parsed = JSON.parse(text);
80
+ } catch {
81
+ return text; // Not JSON: the app's `redact` and the scan are what stand between it and disk.
82
+ }
83
+ return JSON.stringify(masked(parsed));
84
+ }
85
+
86
+ const masked = (value: unknown): unknown => {
87
+ if (Array.isArray(value)) return value.map(masked);
88
+ if (typeof value !== 'object' || value === null) return value;
89
+ return Object.fromEntries(
90
+ Object.entries(value).map(([key, child]) => [
91
+ key,
92
+ isRedactedKey(key) ? REDACTED : masked(child),
93
+ ]),
94
+ );
95
+ };
96
+
97
+ const TEXT_TYPE = /^text\/|json|xml|javascript|x-www-form-urlencoded|event-stream/i;
98
+
99
+ /** A body as the file holds it: text when it is text, base64 otherwise. */
100
+ export function encodeBody(
101
+ bytes: Uint8Array,
102
+ contentType: string | undefined,
103
+ ): Pick<CassetteEntry, 'body' | 'encoding'> {
104
+ if (contentType === undefined || TEXT_TYPE.test(contentType)) {
105
+ try {
106
+ return { body: new TextDecoder('utf-8', { fatal: true }).decode(bytes), encoding: 'utf8' };
107
+ } catch {
108
+ // Labelled text, or unlabelled, that is not UTF-8: keep the bytes, not a lossy decode.
109
+ }
110
+ }
111
+ return { body: Buffer.from(bytes).toString('base64'), encoding: 'base64' };
112
+ }
113
+
114
+ export const decodeBody = (entry: CassetteEntry): Uint8Array<ArrayBuffer> =>
115
+ entry.encoding === 'base64'
116
+ ? Uint8Array.from(Buffer.from(entry.body, 'base64'))
117
+ : new TextEncoder().encode(entry.body);
118
+
119
+ /** Sorted keys at every depth and a trailing newline: a re-recording diffs by what changed. */
120
+ export function serializeCassette(file: CassetteFile): string {
121
+ return `${JSON.stringify(sorted(file), null, 2)}\n`;
122
+ }
123
+
124
+ const sorted = (value: unknown): unknown => {
125
+ if (Array.isArray(value)) return value.map(sorted);
126
+ if (typeof value !== 'object' || value === null) return value;
127
+ return Object.fromEntries(
128
+ Object.keys(value)
129
+ .sort()
130
+ .map((key) => [key, sorted((value as Record<string, unknown>)[key])]),
131
+ );
132
+ };
133
+
134
+ /** The file read back, its shape checked: a hand-edited cassette that broke is named, not replayed. */
135
+ export function parseCassette(text: string, file: string): CassetteFile {
136
+ let parsed: unknown;
137
+ try {
138
+ parsed = JSON.parse(text);
139
+ } catch {
140
+ parsed = undefined;
141
+ }
142
+ const fix = `rm ${renderFixShellArg(file, '<cassette file>')} && ULTIMATE_CASSETTE=record bun test <test file>`;
143
+ const shaped =
144
+ typeof parsed === 'object' &&
145
+ parsed !== null &&
146
+ (parsed as { version?: unknown }).version === CASSETTE_VERSION &&
147
+ Array.isArray((parsed as { entries?: unknown }).entries) &&
148
+ (parsed as { entries: unknown[] }).entries.every(isEntry);
149
+ assert(shaped, `${file} is not a version ${CASSETTE_VERSION} cassette`, fix);
150
+ return parsed as CassetteFile;
151
+ }
152
+
153
+ const isEntry = (value: unknown): value is CassetteEntry => {
154
+ if (typeof value !== 'object' || value === null) return false;
155
+ const entry = value as Record<string, unknown>;
156
+ return (
157
+ typeof entry['method'] === 'string' &&
158
+ typeof entry['url'] === 'string' &&
159
+ typeof entry['status'] === 'number' &&
160
+ typeof entry['body'] === 'string' &&
161
+ (entry['encoding'] === 'utf8' || entry['encoding'] === 'base64') &&
162
+ typeof entry['headers'] === 'object' &&
163
+ entry['headers'] !== null
164
+ );
165
+ };
166
+
167
+ function decodeKey(key: string): string {
168
+ try {
169
+ return decodeURIComponent(key.replace(/\+/g, ' '));
170
+ } catch {
171
+ return key;
172
+ }
173
+ }
174
+
175
+ const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -0,0 +1,120 @@
1
+ // The recording half of a cassette: a tap OVER the sealed `fetch` that copies each answer from a
2
+ // host `record.hosts` names, and the dispose that redacts, scans and writes them. Nothing here
3
+ // decides what gets through — the seal still does; `record.hosts` reaches it through `allowHost`.
4
+
5
+ import { stringField } from '@ultimat3/core';
6
+ import { CassetteRecordRefusedError, CassetteSecretError } from './cassette-errors';
7
+ import type { CassetteEntry } from './cassette-format';
8
+ import {
9
+ CASSETTE_VERSION,
10
+ encodeBody,
11
+ keptHeaders,
12
+ maskUrl,
13
+ redactBody,
14
+ serializeCassette,
15
+ } from './cassette-format';
16
+ import { findSecret, secretSources } from './cassette-secret-scan';
17
+ import type { FetchLike } from './sealed-network';
18
+
19
+ export interface RecordInput {
20
+ readonly name: string;
21
+ readonly dir: string;
22
+ readonly file: string;
23
+ readonly hosts: ReadonlySet<string>;
24
+ readonly redact: ((text: string) => string) | undefined;
25
+ readonly secrets: readonly string[];
26
+ readonly env: Readonly<Record<string, string | undefined>>;
27
+ /** Writes the checked bytes; ./cassette owns the filesystem. */
28
+ readonly write: (text: string) => void;
29
+ }
30
+
31
+ export interface Recording {
32
+ /** Removes the tap, then writes — or throws, and writes nothing. */
33
+ finish(): Promise<void>;
34
+ }
35
+
36
+ const urlOf = (input: RequestInfo | URL): string =>
37
+ typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
38
+
39
+ const methodOf = (input: RequestInfo | URL, init: RequestInit | undefined): string =>
40
+ (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
41
+
42
+ const hostOf = (url: string): string | undefined =>
43
+ URL.canParse(url) ? new URL(url).host : undefined;
44
+
45
+ export function startRecording(input: RecordInput): Recording {
46
+ const sealed: FetchLike = globalThis.fetch;
47
+ // One slot per call, taken BEFORE the await: the file keeps call order, not answer order.
48
+ const slots: Promise<CassetteEntry>[] = [];
49
+ const tap = (async (request: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
50
+ const url = urlOf(request);
51
+ const method = methodOf(request, init);
52
+ const host = hostOf(url);
53
+ const recorded = host !== undefined && input.hosts.has(host);
54
+ let answer: Promise<Response>;
55
+ try {
56
+ answer = sealed(request, init);
57
+ if (!recorded) return await answer;
58
+ } catch (error) {
59
+ // The seal's own refusal names `allowHost`; while recording, the line to edit is the
60
+ // cassette's own `record.hosts`.
61
+ if (stringField(error, 'code') === 'X_TEST_NETWORK_SEALED' && host !== undefined) {
62
+ throw new CassetteRecordRefusedError({ reason: 'host', host, name: input.name });
63
+ }
64
+ throw error;
65
+ }
66
+ const slot = answer.then(async (response) => {
67
+ const contentType = response.headers.get('content-type') ?? undefined;
68
+ const bytes = new Uint8Array(await response.clone().arrayBuffer());
69
+ return {
70
+ method,
71
+ url: maskUrl(url),
72
+ status: response.status,
73
+ headers: keptHeaders(response.headers),
74
+ ...encodeBody(bytes, contentType),
75
+ } satisfies CassetteEntry;
76
+ });
77
+ slots.push(slot);
78
+ // A failed dial is the test's failure as it would be unrecorded; the slot fails `finish` too.
79
+ slot.catch(() => undefined);
80
+ return answer;
81
+ }) as FetchLike;
82
+ globalThis.fetch = tap;
83
+
84
+ return {
85
+ async finish() {
86
+ // Only our own tap is removed: something that replaced it since owns `fetch` now.
87
+ if (globalThis.fetch === tap) globalThis.fetch = sealed;
88
+ // A body that failed to read rejects here, so a half run is never a recording.
89
+ const entries = (await Promise.all(slots)).map((entry) => redacted(entry, input.redact));
90
+ const text = serializeCassette({ version: CASSETTE_VERSION, entries });
91
+ // LAST, over the exact bytes about to be written — what `redact` returned included.
92
+ const sources = secretSources(input.env, input.secrets);
93
+ // A base64 body hides a value at any of three alignments, so its bytes are scanned too.
94
+ const hit =
95
+ findSecret(text, sources) ??
96
+ entries
97
+ .filter((entry) => entry.encoding === 'base64')
98
+ .map((entry) => findSecret(Buffer.from(entry.body, 'base64').toString('latin1'), sources))
99
+ .find((found) => found !== undefined);
100
+ if (hit !== undefined) {
101
+ throw new CassetteSecretError({ file: input.file, dir: input.dir, ...hit });
102
+ }
103
+ input.write(text);
104
+ },
105
+ };
106
+ }
107
+
108
+ function redacted(
109
+ entry: CassetteEntry,
110
+ redact: ((text: string) => string) | undefined,
111
+ ): CassetteEntry {
112
+ const body =
113
+ entry.encoding === 'utf8' ? redactBody(entry.body, entry.headers['content-type']) : entry.body;
114
+ if (redact === undefined) return { ...entry, body };
115
+ return {
116
+ ...entry,
117
+ url: redact(entry.url),
118
+ body: entry.encoding === 'utf8' ? redact(body) : body,
119
+ };
120
+ }
@@ -0,0 +1,77 @@
1
+ // The last check before a cassette reaches disk: is any secret's value in the exact bytes about
2
+ // to be written, in any of the encodings a vendor echoes one back in? A hit names the KEY and the
3
+ // byte offset — never the value, which would put it in a terminal and a CI log instead.
4
+
5
+ import { isRedactedKey } from '@ultimat3/core';
6
+
7
+ /**
8
+ * Shorter values are skipped, stated rather than hidden: a 4-character "secret" occurs by chance
9
+ * in any base64 body, and a refusal that fires on noise is one somebody switches off.
10
+ */
11
+ export const SECRET_MIN_LENGTH = 8;
12
+
13
+ export interface SecretSource {
14
+ /** An env key, or `secrets[<i>]` for a value handed to the cassette. */
15
+ readonly key: string;
16
+ readonly value: string;
17
+ }
18
+
19
+ export interface SecretHit {
20
+ readonly key: string;
21
+ /** UTF-8 byte offset into the scanned text. */
22
+ readonly offset: number;
23
+ readonly encoding: 'raw' | 'json' | 'percent' | 'base64' | 'base64url';
24
+ }
25
+
26
+ /**
27
+ * What to look for: every env value whose key `isRedactedKey` answers yes to (which includes every
28
+ * key `defineEnv({ secret: true })` registered), plus the values the cassette was handed.
29
+ */
30
+ export function secretSources(
31
+ env: Readonly<Record<string, string | undefined>>,
32
+ extra: readonly string[],
33
+ ): readonly SecretSource[] {
34
+ const sources: SecretSource[] = [];
35
+ for (const [key, value] of Object.entries(env)) {
36
+ if (value !== undefined && value.length >= SECRET_MIN_LENGTH && isRedactedKey(key)) {
37
+ sources.push({ key, value });
38
+ }
39
+ }
40
+ extra.forEach((value, index) => {
41
+ if (value.length >= SECRET_MIN_LENGTH) sources.push({ key: `secrets[${index}]`, value });
42
+ });
43
+ return sources;
44
+ }
45
+
46
+ /** The first secret found in `text`, by position; `undefined` when the bytes are clean. */
47
+ export function findSecret(text: string, sources: readonly SecretSource[]): SecretHit | undefined {
48
+ let first: { hit: SecretHit; index: number } | undefined;
49
+ for (const source of sources) {
50
+ for (const [encoding, needle] of encodings(source.value)) {
51
+ const index = text.indexOf(needle);
52
+ if (index >= 0 && (first === undefined || index < first.index)) {
53
+ first = { index, hit: { key: source.key, offset: byteOffset(text, index), encoding } };
54
+ }
55
+ }
56
+ }
57
+ return first?.hit;
58
+ }
59
+
60
+ function encodings(value: string): ReadonlyArray<readonly [SecretHit['encoding'], string]> {
61
+ const bytes = Buffer.from(value, 'utf8');
62
+ // Padding is dropped: a value base64-encoded inside a longer string carries none of its own.
63
+ const base64 = bytes.toString('base64').replace(/=+$/, '');
64
+ const base64url = bytes.toString('base64url');
65
+ return [
66
+ ['raw', value],
67
+ // As a JSON string literal holds it: a quote or a backslash in the value is escaped there.
68
+ ['json', JSON.stringify(value).slice(1, -1)],
69
+ ['percent', encodeURIComponent(value)],
70
+ ['base64', base64],
71
+ ['base64url', base64url],
72
+ ];
73
+ }
74
+
75
+ function byteOffset(text: string, index: number): number {
76
+ return Buffer.byteLength(text.slice(0, index), 'utf8');
77
+ }