@jterrazz/test 7.1.0 → 9.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.
- package/README.md +209 -230
- package/dist/checker.d.ts +1 -0
- package/dist/checker.js +741 -0
- package/dist/index.d.ts +1296 -529
- package/dist/index.js +3303 -1261
- package/dist/intercept.js +129 -290
- package/dist/match.js +153 -0
- package/dist/oxlint.cjs +2931 -0
- package/dist/oxlint.d.cts +150 -0
- package/dist/oxlint.d.ts +150 -0
- package/dist/oxlint.js +2925 -0
- package/package.json +47 -41
- package/dist/chunk.cjs +0 -28
- package/dist/index.cjs +0 -1814
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts +0 -734
- package/dist/index.js.map +0 -1
- package/dist/intercept.cjs +0 -313
- package/dist/intercept.cjs.map +0 -1
- package/dist/intercept.d.cts +0 -105
- package/dist/intercept.d.ts +0 -105
- package/dist/intercept.js.map +0 -1
- package/dist/intercept2.cjs +0 -81
- package/dist/intercept2.cjs.map +0 -1
- package/dist/intercept2.js +0 -81
- package/dist/intercept2.js.map +0 -1
- package/dist/mock-of.cjs +0 -32
- package/dist/mock-of.cjs.map +0 -1
- package/dist/mock-of.d.cts +0 -27
- package/dist/mock-of.d.ts +0 -27
- package/dist/mock-of.js +0 -19
- package/dist/mock-of.js.map +0 -1
- package/dist/mock.cjs +0 -4
- package/dist/mock.d.cts +0 -2
- package/dist/mock.d.ts +0 -2
- package/dist/mock.js +0 -2
- package/dist/services.cjs +0 -5
- package/dist/services.d.cts +0 -2
- package/dist/services.d.ts +0 -2
- package/dist/services.js +0 -2
- package/dist/sqlite.adapter.cjs +0 -350
- package/dist/sqlite.adapter.cjs.map +0 -1
- package/dist/sqlite.adapter.d.cts +0 -209
- package/dist/sqlite.adapter.d.ts +0 -209
- package/dist/sqlite.adapter.js +0 -331
- package/dist/sqlite.adapter.js.map +0 -1
- package/dist/types.d.cts +0 -42
- package/dist/types.d.ts +0 -42
package/dist/intercept.js
CHANGED
|
@@ -1,310 +1,149 @@
|
|
|
1
|
-
//#region src/
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
//#region src/integrations/msw/intercept.ts
|
|
2
|
+
let mswModule = null;
|
|
3
|
+
let mswHttp = null;
|
|
4
|
+
async function loadMsw() {
|
|
5
|
+
if (!mswModule) {
|
|
6
|
+
mswModule = await import("msw/node");
|
|
7
|
+
mswHttp = await import("msw");
|
|
8
8
|
}
|
|
9
|
-
if (filter.system) {
|
|
10
|
-
const system = typeof body?.system === "string" ? body.system : "";
|
|
11
|
-
if (typeof filter.system === "string" && !system.includes(filter.system)) return false;
|
|
12
|
-
if (filter.system instanceof RegExp && !filter.system.test(system)) return false;
|
|
13
|
-
}
|
|
14
|
-
if (filter.user) {
|
|
15
|
-
const userMsg = body?.messages?.find((m) => m.role === "user")?.content ?? "";
|
|
16
|
-
const text = typeof userMsg === "string" ? userMsg : JSON.stringify(userMsg);
|
|
17
|
-
if (typeof filter.user === "string" && !text.includes(filter.user)) return false;
|
|
18
|
-
if (filter.user instanceof RegExp && !filter.user.test(text)) return false;
|
|
19
|
-
}
|
|
20
|
-
if (filter.tools) {
|
|
21
|
-
const names = body?.tools?.map((t) => t.name).filter(Boolean) ?? [];
|
|
22
|
-
if (!filter.tools.every((t) => names.includes(t))) return false;
|
|
23
|
-
}
|
|
24
|
-
return true;
|
|
25
|
-
}
|
|
26
|
-
function buildReply(data) {
|
|
27
9
|
return {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
id: "msg-test",
|
|
31
|
-
type: "message",
|
|
32
|
-
role: "assistant",
|
|
33
|
-
content: [{
|
|
34
|
-
type: "text",
|
|
35
|
-
text: typeof data === "string" ? data : JSON.stringify(data)
|
|
36
|
-
}],
|
|
37
|
-
model: "claude-sonnet-4-20250514",
|
|
38
|
-
stop_reason: "end_turn",
|
|
39
|
-
usage: {
|
|
40
|
-
input_tokens: 10,
|
|
41
|
-
output_tokens: 10
|
|
42
|
-
}
|
|
43
|
-
}
|
|
10
|
+
node: mswModule,
|
|
11
|
+
msw: mswHttp
|
|
44
12
|
};
|
|
45
13
|
}
|
|
14
|
+
let serverInstance = null;
|
|
46
15
|
/**
|
|
47
|
-
*
|
|
16
|
+
* Start the MSW server (once per process).
|
|
48
17
|
*/
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
};
|
|
58
|
-
},
|
|
59
|
-
reply: buildReply,
|
|
60
|
-
error(status, message) {
|
|
61
|
-
return {
|
|
62
|
-
status,
|
|
63
|
-
body: {
|
|
64
|
-
type: "error",
|
|
65
|
-
error: {
|
|
66
|
-
type: status === 429 ? "rate_limit_error" : "api_error",
|
|
67
|
-
message: message ?? `Anthropic error (${status})`
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
};
|
|
71
|
-
},
|
|
72
|
-
timeout() {
|
|
73
|
-
return {
|
|
74
|
-
status: 200,
|
|
75
|
-
body: {},
|
|
76
|
-
delay: 3e4
|
|
77
|
-
};
|
|
78
|
-
},
|
|
79
|
-
messages(filter) {
|
|
80
|
-
return anthropic.request(filter);
|
|
81
|
-
},
|
|
82
|
-
response: buildReply
|
|
83
|
-
};
|
|
84
|
-
//#endregion
|
|
85
|
-
//#region src/builder/common/intercept/adapters/http.ts
|
|
86
|
-
function wrapJson(data) {
|
|
87
|
-
return {
|
|
88
|
-
status: 200,
|
|
89
|
-
body: data
|
|
90
|
-
};
|
|
18
|
+
async function ensureInterceptServer() {
|
|
19
|
+
if (serverInstance) return;
|
|
20
|
+
const { node } = await loadMsw();
|
|
21
|
+
serverInstance = node.setupServer();
|
|
22
|
+
serverInstance.listen({ onUnhandledRequest: "bypass" });
|
|
23
|
+
}
|
|
24
|
+
function describeTrigger(entry) {
|
|
25
|
+
return `${entry.trigger.method === "*" ? "ANY" : entry.trigger.method} ${String(entry.trigger.url)}`;
|
|
91
26
|
}
|
|
92
27
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* .intercept(http.get('https://api.example.com/data'), 'http/response.json')
|
|
28
|
+
* Pure FIFO queue over the chain's intercept entries. Each observed request
|
|
29
|
+
* consumes the first unconsumed entry whose URL, method, and optional body
|
|
30
|
+
* matcher accept it. Exported for unit tests — MSW never reaches this class.
|
|
97
31
|
*/
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
wrap: wrapJson
|
|
105
|
-
};
|
|
106
|
-
},
|
|
107
|
-
post(url) {
|
|
108
|
-
return {
|
|
109
|
-
adapter: "http",
|
|
110
|
-
method: "POST",
|
|
111
|
-
url,
|
|
112
|
-
wrap: wrapJson
|
|
113
|
-
};
|
|
114
|
-
},
|
|
115
|
-
put(url) {
|
|
116
|
-
return {
|
|
117
|
-
adapter: "http",
|
|
118
|
-
method: "PUT",
|
|
119
|
-
url,
|
|
120
|
-
wrap: wrapJson
|
|
121
|
-
};
|
|
122
|
-
},
|
|
123
|
-
delete(url) {
|
|
124
|
-
return {
|
|
125
|
-
adapter: "http",
|
|
126
|
-
method: "DELETE",
|
|
127
|
-
url,
|
|
128
|
-
wrap: wrapJson
|
|
129
|
-
};
|
|
130
|
-
},
|
|
131
|
-
any(url) {
|
|
132
|
-
return {
|
|
133
|
-
adapter: "http",
|
|
134
|
-
method: "*",
|
|
135
|
-
url,
|
|
136
|
-
wrap: wrapJson
|
|
137
|
-
};
|
|
138
|
-
},
|
|
139
|
-
json(data, status = 200) {
|
|
140
|
-
return {
|
|
141
|
-
status,
|
|
142
|
-
body: data
|
|
143
|
-
};
|
|
144
|
-
},
|
|
145
|
-
error(status, message) {
|
|
146
|
-
return {
|
|
147
|
-
status,
|
|
148
|
-
body: { error: message ?? `HTTP ${status}` }
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
};
|
|
152
|
-
//#endregion
|
|
153
|
-
//#region src/builder/common/intercept/adapters/openai.ts
|
|
154
|
-
const OPENAI_CHAT_URL = "https://api.openai.com/v1/chat/completions";
|
|
155
|
-
const OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses";
|
|
156
|
-
function matchesChatFilter(body, filter) {
|
|
157
|
-
if (filter.model) {
|
|
158
|
-
const model = body?.model;
|
|
159
|
-
if (typeof filter.model === "string" && model !== filter.model) return false;
|
|
160
|
-
if (filter.model instanceof RegExp && !filter.model.test(model ?? "")) return false;
|
|
161
|
-
}
|
|
162
|
-
if (filter.system) {
|
|
163
|
-
const msg = body?.messages?.find((m) => m.role === "system")?.content ?? "";
|
|
164
|
-
if (typeof filter.system === "string" && !msg.includes(filter.system)) return false;
|
|
165
|
-
if (filter.system instanceof RegExp && !filter.system.test(msg)) return false;
|
|
166
|
-
}
|
|
167
|
-
if (filter.user) {
|
|
168
|
-
const msg = body?.messages?.find((m) => m.role === "user")?.content ?? "";
|
|
169
|
-
if (typeof filter.user === "string" && !msg.includes(filter.user)) return false;
|
|
170
|
-
if (filter.user instanceof RegExp && !filter.user.test(msg)) return false;
|
|
171
|
-
}
|
|
172
|
-
if (filter.tools) {
|
|
173
|
-
const names = body?.tools?.map((t) => t.function?.name).filter(Boolean) ?? [];
|
|
174
|
-
if (!filter.tools.every((t) => names.includes(t))) return false;
|
|
32
|
+
var InterceptQueue = class {
|
|
33
|
+
consumed;
|
|
34
|
+
entries;
|
|
35
|
+
constructor(entries) {
|
|
36
|
+
this.entries = entries;
|
|
37
|
+
this.consumed = entries.map(() => false);
|
|
175
38
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Unique trigger URLs, preserving RegExp/string identity for MSW routing.
|
|
41
|
+
* Deduped by string form — two RegExp objects with the same source are
|
|
42
|
+
* one trigger (and get one handler).
|
|
43
|
+
*/
|
|
44
|
+
get urls() {
|
|
45
|
+
const urls = /* @__PURE__ */ new Map();
|
|
46
|
+
for (const entry of this.entries) {
|
|
47
|
+
const key = String(entry.trigger.url);
|
|
48
|
+
if (!urls.has(key)) urls.set(key, entry.trigger.url);
|
|
49
|
+
}
|
|
50
|
+
return [...urls.values()];
|
|
184
51
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
if (filter.system instanceof RegExp && !filter.system.test(text)) return false;
|
|
52
|
+
/** Methods declared for a given trigger URL. */
|
|
53
|
+
methodsFor(url) {
|
|
54
|
+
const methods = /* @__PURE__ */ new Set();
|
|
55
|
+
for (const entry of this.entries) if (sameUrl(entry.trigger.url, url)) methods.add(entry.trigger.method);
|
|
56
|
+
return [...methods];
|
|
191
57
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Consume and return the first unconsumed entry registered for
|
|
60
|
+
* `handlerUrl`/`method` that accepts the request, or null when the queue
|
|
61
|
+
* for that trigger is exhausted (or no matcher accepts).
|
|
62
|
+
*/
|
|
63
|
+
take(handlerUrl, method, request) {
|
|
64
|
+
for (let i = 0; i < this.entries.length; i++) {
|
|
65
|
+
if (this.consumed[i]) continue;
|
|
66
|
+
const entry = this.entries[i];
|
|
67
|
+
if (!sameUrl(entry.trigger.url, handlerUrl)) continue;
|
|
68
|
+
if (entry.trigger.method !== method && entry.trigger.method !== "*") continue;
|
|
69
|
+
if (entry.trigger.match && !entry.trigger.match(request)) continue;
|
|
70
|
+
this.consumed[i] = true;
|
|
71
|
+
return entry;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
196
74
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Build the strict-intercept failure for a request that matched no
|
|
77
|
+
* registered intercept (CONVENTIONS D7): method + URL of the offending
|
|
78
|
+
* request, plus every registered trigger and its consumption state.
|
|
79
|
+
*/
|
|
80
|
+
unmatchedError(method, url) {
|
|
81
|
+
const triggers = this.entries.length === 0 ? " (no intercepts registered)" : this.entries.map((entry, i) => ` - ${describeTrigger(entry)}${this.consumed[i] ? " (already consumed)" : ""}`).join("\n");
|
|
82
|
+
return /* @__PURE__ */ new Error(`Unmatched outgoing HTTP request during spec: ${method} ${url}\nRegistered intercepts:\n${triggers}\nEvery outgoing request of a chain that declares intercepts must match one — add an .intercept() for it (or one more if its queue is exhausted).`);
|
|
200
83
|
}
|
|
201
|
-
|
|
84
|
+
};
|
|
85
|
+
function sameUrl(a, b) {
|
|
86
|
+
return a === b || String(a) === String(b);
|
|
202
87
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
index: 0,
|
|
214
|
-
message: {
|
|
215
|
-
role: "assistant",
|
|
216
|
-
content
|
|
217
|
-
},
|
|
218
|
-
finish_reason: "stop"
|
|
219
|
-
}],
|
|
220
|
-
usage: {
|
|
221
|
-
prompt_tokens: 10,
|
|
222
|
-
completion_tokens: 10,
|
|
223
|
-
total_tokens: 20
|
|
224
|
-
}
|
|
225
|
-
}
|
|
88
|
+
/**
|
|
89
|
+
* Register intercept entries as MSW handlers for one chain. A trailing
|
|
90
|
+
* catch-all handler records any request that no entry accepted — the
|
|
91
|
+
* builder rethrows it as the spec failure (rejecting the action promise,
|
|
92
|
+
* never an unhandled rejection).
|
|
93
|
+
*/
|
|
94
|
+
async function registerIntercepts(entries) {
|
|
95
|
+
if (entries.length === 0) return {
|
|
96
|
+
cleanup: () => {},
|
|
97
|
+
violation: () => null
|
|
226
98
|
};
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}],
|
|
247
|
-
usage: {
|
|
248
|
-
input_tokens: 10,
|
|
249
|
-
output_tokens: 10,
|
|
250
|
-
total_tokens: 20
|
|
99
|
+
await ensureInterceptServer();
|
|
100
|
+
const { msw } = await loadMsw();
|
|
101
|
+
const queue = new InterceptQueue(entries);
|
|
102
|
+
let violation = null;
|
|
103
|
+
const recordViolation = (method, url) => {
|
|
104
|
+
violation ??= queue.unmatchedError(method, url);
|
|
105
|
+
return msw.HttpResponse.json({ error: `@jterrazz/test strict intercepts: unmatched request ${method} ${url}` }, { status: 501 });
|
|
106
|
+
};
|
|
107
|
+
const handlers = [];
|
|
108
|
+
for (const url of queue.urls) for (const method of queue.methodsFor(url)) {
|
|
109
|
+
const handlerFn = method === "*" ? msw.http.all : msw.http[method.toLowerCase()];
|
|
110
|
+
if (!handlerFn) continue;
|
|
111
|
+
const handler = handlerFn(url, async ({ request }) => {
|
|
112
|
+
let body = null;
|
|
113
|
+
const rawText = await request.clone().text();
|
|
114
|
+
if (rawText) try {
|
|
115
|
+
body = JSON.parse(rawText);
|
|
116
|
+
} catch {
|
|
117
|
+
body = rawText;
|
|
251
118
|
}
|
|
252
|
-
|
|
119
|
+
const headers = {};
|
|
120
|
+
request.headers.forEach((value, key) => {
|
|
121
|
+
headers[key.toLowerCase()] = value;
|
|
122
|
+
});
|
|
123
|
+
const observed = {
|
|
124
|
+
body,
|
|
125
|
+
headers,
|
|
126
|
+
url: request.url
|
|
127
|
+
};
|
|
128
|
+
const entry = queue.take(url, request.method, observed);
|
|
129
|
+
if (!entry) return recordViolation(request.method, request.url);
|
|
130
|
+
const response = typeof entry.response === "function" ? entry.response(observed) : entry.response;
|
|
131
|
+
if (response.delay) await new Promise((r) => setTimeout(r, response.delay));
|
|
132
|
+
return msw.HttpResponse.json(response.body, {
|
|
133
|
+
status: response.status ?? 200,
|
|
134
|
+
headers: response.headers
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
handlers.push(handler);
|
|
138
|
+
}
|
|
139
|
+
handlers.push(msw.http.all("*", ({ request }) => recordViolation(request.method, request.url)));
|
|
140
|
+
serverInstance.use(...handlers);
|
|
141
|
+
return {
|
|
142
|
+
cleanup: () => {
|
|
143
|
+
serverInstance.resetHandlers();
|
|
144
|
+
},
|
|
145
|
+
violation: () => violation
|
|
253
146
|
};
|
|
254
147
|
}
|
|
255
|
-
/**
|
|
256
|
-
* OpenAI API intercept helpers.
|
|
257
|
-
*/
|
|
258
|
-
const openai = {
|
|
259
|
-
request(filter) {
|
|
260
|
-
return {
|
|
261
|
-
adapter: "openai",
|
|
262
|
-
method: "POST",
|
|
263
|
-
url: OPENAI_CHAT_URL,
|
|
264
|
-
match: filter ? (body) => matchesChatFilter(body, filter) : void 0,
|
|
265
|
-
wrap: buildChatReply
|
|
266
|
-
};
|
|
267
|
-
},
|
|
268
|
-
agent(filter, url) {
|
|
269
|
-
return {
|
|
270
|
-
adapter: "openai",
|
|
271
|
-
method: "POST",
|
|
272
|
-
url: url ?? OPENAI_RESPONSES_URL,
|
|
273
|
-
match: filter ? (body) => matchesResponsesFilter(body, filter) : void 0,
|
|
274
|
-
wrap: buildResponsesReply
|
|
275
|
-
};
|
|
276
|
-
},
|
|
277
|
-
reply: buildChatReply,
|
|
278
|
-
error(status, message) {
|
|
279
|
-
return {
|
|
280
|
-
status,
|
|
281
|
-
body: { error: {
|
|
282
|
-
message: message ?? `OpenAI error (${status})`,
|
|
283
|
-
type: status === 429 ? "rate_limit_error" : "api_error",
|
|
284
|
-
code: status === 429 ? "rate_limit_exceeded" : null
|
|
285
|
-
} }
|
|
286
|
-
};
|
|
287
|
-
},
|
|
288
|
-
malformed(content) {
|
|
289
|
-
return buildChatReply(content);
|
|
290
|
-
},
|
|
291
|
-
timeout() {
|
|
292
|
-
return {
|
|
293
|
-
status: 200,
|
|
294
|
-
body: {},
|
|
295
|
-
delay: 3e4
|
|
296
|
-
};
|
|
297
|
-
},
|
|
298
|
-
chat(filter) {
|
|
299
|
-
return openai.request(filter);
|
|
300
|
-
},
|
|
301
|
-
response: buildChatReply,
|
|
302
|
-
responses(filter, url) {
|
|
303
|
-
return openai.agent(filter, url);
|
|
304
|
-
},
|
|
305
|
-
responsesResponse: buildResponsesReply
|
|
306
|
-
};
|
|
307
148
|
//#endregion
|
|
308
|
-
export {
|
|
309
|
-
|
|
310
|
-
//# sourceMappingURL=intercept.js.map
|
|
149
|
+
export { registerIntercepts };
|
package/dist/match.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
//#region src/core/matching/match.ts
|
|
2
|
+
/** Every kind usable as a `{{token}}` in fixture files (all but ref/regex). */
|
|
3
|
+
const TOKEN_KINDS = [
|
|
4
|
+
"any",
|
|
5
|
+
"base64",
|
|
6
|
+
"date",
|
|
7
|
+
"duration",
|
|
8
|
+
"email",
|
|
9
|
+
"float",
|
|
10
|
+
"hex",
|
|
11
|
+
"int",
|
|
12
|
+
"ip",
|
|
13
|
+
"iso8601",
|
|
14
|
+
"number",
|
|
15
|
+
"path",
|
|
16
|
+
"port",
|
|
17
|
+
"semver",
|
|
18
|
+
"sha",
|
|
19
|
+
"string",
|
|
20
|
+
"time",
|
|
21
|
+
"ulid",
|
|
22
|
+
"url",
|
|
23
|
+
"uuid",
|
|
24
|
+
"workdir"
|
|
25
|
+
];
|
|
26
|
+
/**
|
|
27
|
+
* A dynamic-value matcher. Created via the {@link match} factories — never
|
|
28
|
+
* constructed directly by user code.
|
|
29
|
+
*/
|
|
30
|
+
var Matcher = class {
|
|
31
|
+
kind;
|
|
32
|
+
/** For kind 'ref': the capture to assert inequality against. */
|
|
33
|
+
notRef;
|
|
34
|
+
/** For kind 'ref': the capture name. */
|
|
35
|
+
refName;
|
|
36
|
+
/** For kind 'regex': the pattern the actual value must match. */
|
|
37
|
+
regex;
|
|
38
|
+
constructor(kind, options = {}) {
|
|
39
|
+
this.kind = kind;
|
|
40
|
+
this.notRef = options.notRef;
|
|
41
|
+
this.refName = options.refName;
|
|
42
|
+
this.regex = options.regex;
|
|
43
|
+
}
|
|
44
|
+
/** Placeholder-style rendering used in failure diffs and serialization. */
|
|
45
|
+
toString() {
|
|
46
|
+
switch (this.kind) {
|
|
47
|
+
case "ref": return this.notRef ? `{{ref#${this.refName}!${this.notRef}}}` : `{{ref#${this.refName}}}`;
|
|
48
|
+
case "regex": return `{{regex:${this.regex?.source}}}`;
|
|
49
|
+
default: return this.refName ? `{{${this.kind}#${this.refName}}}` : `{{${this.kind}}}`;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
toJSON() {
|
|
53
|
+
return this.toString();
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const typed = (kind) => () => new Matcher(kind);
|
|
57
|
+
/**
|
|
58
|
+
* Dynamic-value matchers for structural comparisons — the code-side mirror of
|
|
59
|
+
* the `{{token}}` fixture grammar (CONVENTIONS D4).
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* await expect(result.table('users', { database: 'db' })).toMatchRows({
|
|
63
|
+
* columns: ['id', 'name'],
|
|
64
|
+
* rows: [[match.uuid(), 'Alice']],
|
|
65
|
+
* });
|
|
66
|
+
*/
|
|
67
|
+
const match = {
|
|
68
|
+
/** Matches anything. */
|
|
69
|
+
any: typed("any"),
|
|
70
|
+
/** Matches a base64 string. */
|
|
71
|
+
base64: typed("base64"),
|
|
72
|
+
/** Matches a calendar date (`YYYY-MM-DD`). */
|
|
73
|
+
date: typed("date"),
|
|
74
|
+
/** Matches a human duration (`12ms`, `1.5s`, `2m`, `3h`). */
|
|
75
|
+
duration: typed("duration"),
|
|
76
|
+
/** Matches an email address. */
|
|
77
|
+
email: typed("email"),
|
|
78
|
+
/**
|
|
79
|
+
* Matches a float. In JSON contexts any finite number passes (JSON does
|
|
80
|
+
* not distinguish `42` from `42.0`); in text contexts the decimal part
|
|
81
|
+
* is required (`4.2`, never `42`).
|
|
82
|
+
*/
|
|
83
|
+
float: typed("float"),
|
|
84
|
+
/** Matches a hexadecimal string. */
|
|
85
|
+
hex: typed("hex"),
|
|
86
|
+
/** Matches an integer (or an integer string in text contexts). */
|
|
87
|
+
int: typed("int"),
|
|
88
|
+
/** Matches an IPv4 address. */
|
|
89
|
+
ip: typed("ip"),
|
|
90
|
+
/** Matches an ISO-8601 timestamp string. */
|
|
91
|
+
iso8601: typed("iso8601"),
|
|
92
|
+
/** Matches a number (or a numeric string in text contexts). */
|
|
93
|
+
number: typed("number"),
|
|
94
|
+
/** Matches a filesystem path (`/...` or `./...`). */
|
|
95
|
+
path: typed("path"),
|
|
96
|
+
/** Matches a TCP/UDP port number (0-65535). */
|
|
97
|
+
port: typed("port"),
|
|
98
|
+
/**
|
|
99
|
+
* Capture-and-compare. The first occurrence of `ref(name)` captures the
|
|
100
|
+
* actual value; every later occurrence must strictly equal the capture.
|
|
101
|
+
* `{ not: other }` additionally asserts inequality with the capture named
|
|
102
|
+
* `other`. Scope: the current spec execution (reset per chain).
|
|
103
|
+
*/
|
|
104
|
+
ref: (name, options) => new Matcher("ref", {
|
|
105
|
+
notRef: options?.not,
|
|
106
|
+
refName: name
|
|
107
|
+
}),
|
|
108
|
+
/** Matches a string against the given regular expression. */
|
|
109
|
+
regex: (regex) => new Matcher("regex", { regex }),
|
|
110
|
+
/** Matches a semantic version (`1.2.3`, `2.0.0-rc.1`). */
|
|
111
|
+
semver: typed("semver"),
|
|
112
|
+
/** Matches a git SHA (7-64 hex chars). */
|
|
113
|
+
sha: typed("sha"),
|
|
114
|
+
/** Matches any string. */
|
|
115
|
+
string: typed("string"),
|
|
116
|
+
/** Matches a wall-clock time (`HH:MM` or `HH:MM:SS`). */
|
|
117
|
+
time: typed("time"),
|
|
118
|
+
/** Matches a ULID. */
|
|
119
|
+
ulid: typed("ulid"),
|
|
120
|
+
/** Matches an http(s) URL. */
|
|
121
|
+
url: typed("url"),
|
|
122
|
+
/** Matches a UUID string. */
|
|
123
|
+
uuid: typed("uuid"),
|
|
124
|
+
/** Matches the exact working directory of the current spec. */
|
|
125
|
+
workdir: typed("workdir")
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* Named captures recorded by `match.ref()` / `{{type#ref}}` placeholders.
|
|
129
|
+
* One scope lives on each spec result — every assertion chained off the same
|
|
130
|
+
* result shares it, and a new chain starts fresh.
|
|
131
|
+
*/
|
|
132
|
+
var CaptureScope = class {
|
|
133
|
+
values = /* @__PURE__ */ new Map();
|
|
134
|
+
/**
|
|
135
|
+
* The exact working directory of the current spec, when the framework
|
|
136
|
+
* knows it (cli mode). Drives the `{{workdir}}` token / `match.workdir()`.
|
|
137
|
+
*/
|
|
138
|
+
workdir;
|
|
139
|
+
constructor(workdir) {
|
|
140
|
+
this.workdir = workdir;
|
|
141
|
+
}
|
|
142
|
+
get(name) {
|
|
143
|
+
return this.values.get(name);
|
|
144
|
+
}
|
|
145
|
+
has(name) {
|
|
146
|
+
return this.values.has(name);
|
|
147
|
+
}
|
|
148
|
+
set(name, value) {
|
|
149
|
+
this.values.set(name, value);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
//#endregion
|
|
153
|
+
export { match as i, Matcher as n, TOKEN_KINDS as r, CaptureScope as t };
|