@jterrazz/test 6.2.0 → 6.3.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/dist/index.cjs +34 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +18 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +34 -3
- package/dist/index.js.map +1 -1
- package/dist/intercept.cjs +240 -0
- package/dist/intercept.cjs.map +1 -0
- package/dist/intercept.d.cts +89 -0
- package/dist/intercept.d.ts +89 -0
- package/dist/intercept.js +237 -0
- package/dist/intercept.js.map +1 -0
- package/dist/server.cjs +79 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.js +79 -0
- package/dist/server.js.map +1 -0
- package/dist/types.d.cts +35 -0
- package/dist/types.d.ts +35 -0
- package/package.json +6 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"intercept.js","names":["matchesFilter"],"sources":["../src/intercept/anthropic.ts","../src/intercept/http.ts","../src/intercept/openai.ts"],"sourcesContent":["import type { InterceptResponse, InterceptTrigger } from './types.js';\n\nconst ANTHROPIC_MESSAGES_URL = 'https://api.anthropic.com/v1/messages';\n\nexport interface AnthropicMessagesFilter {\n /** Match by model name. */\n model?: RegExp | string;\n /** Match by system message content. */\n system?: RegExp | string;\n /** Match by user message content. */\n user?: RegExp | string;\n /** Match by tool names. */\n tools?: string[];\n}\n\nfunction matchesFilter(body: any, filter: AnthropicMessagesFilter): boolean {\n if (filter.model) {\n const model = body?.model;\n if (typeof filter.model === 'string' && model !== filter.model) {\n return false;\n }\n if (filter.model instanceof RegExp && !filter.model.test(model ?? '')) {\n return false;\n }\n }\n\n if (filter.system) {\n const system = typeof body?.system === 'string' ? body.system : '';\n if (typeof filter.system === 'string' && !system.includes(filter.system)) {\n return false;\n }\n if (filter.system instanceof RegExp && !filter.system.test(system)) {\n return false;\n }\n }\n\n if (filter.user) {\n const userMsg = body?.messages?.find((m: any) => m.role === 'user')?.content ?? '';\n const text = typeof userMsg === 'string' ? userMsg : JSON.stringify(userMsg);\n if (typeof filter.user === 'string' && !text.includes(filter.user)) {\n return false;\n }\n if (filter.user instanceof RegExp && !filter.user.test(text)) {\n return false;\n }\n }\n\n if (filter.tools) {\n const requestTools = body?.tools?.map((t: any) => t.name).filter(Boolean) ?? [];\n if (!filter.tools.every((t) => requestTools.includes(t))) {\n return false;\n }\n }\n\n return true;\n}\n\n/**\n * Anthropic API intercept helpers.\n */\nexport const anthropic = {\n /**\n * Trigger: match Anthropic messages API requests.\n */\n messages(filter?: AnthropicMessagesFilter): InterceptTrigger {\n return {\n method: 'POST',\n url: ANTHROPIC_MESSAGES_URL,\n match: filter ? (body: unknown) => matchesFilter(body, filter) : undefined,\n };\n },\n\n /** Response: wrap data in Anthropic messages format. */\n response(data: unknown): InterceptResponse {\n const content = typeof data === 'string' ? data : JSON.stringify(data);\n return {\n status: 200,\n body: {\n id: 'msg-test',\n type: 'message',\n role: 'assistant',\n content: [{ type: 'text', text: content }],\n model: 'claude-sonnet-4-20250514',\n stop_reason: 'end_turn',\n usage: { input_tokens: 10, output_tokens: 10 },\n },\n };\n },\n\n /** Response: return an Anthropic error. */\n error(status: number, message?: string): InterceptResponse {\n return {\n status,\n body: {\n type: 'error',\n error: {\n type: status === 429 ? 'rate_limit_error' : 'api_error',\n message: message ?? `Anthropic error (${status})`,\n },\n },\n };\n },\n\n /** Response: simulate a timeout. */\n timeout(): InterceptResponse {\n return { status: 200, body: {}, delay: 30_000 };\n },\n};\n","import type { InterceptResponse, InterceptTrigger } from './types.js';\n\n/**\n * Generic HTTP intercept helpers for any URL.\n *\n * @example\n * .intercept(http.get('https://api.example.com/data'), { body: [...] })\n * .intercept(http.post('https://api.stripe.com/v1/charges'), { body: { id: 'ch_...' } })\n */\nexport const http = {\n /** Trigger: match GET requests to a URL pattern. */\n get(url: RegExp | string): InterceptTrigger {\n return { method: 'GET', url };\n },\n\n /** Trigger: match POST requests to a URL pattern. */\n post(url: RegExp | string): InterceptTrigger {\n return { method: 'POST', url };\n },\n\n /** Trigger: match PUT requests to a URL pattern. */\n put(url: RegExp | string): InterceptTrigger {\n return { method: 'PUT', url };\n },\n\n /** Trigger: match DELETE requests to a URL pattern. */\n delete(url: RegExp | string): InterceptTrigger {\n return { method: 'DELETE', url };\n },\n\n /** Trigger: match any method to a URL pattern. */\n any(url: RegExp | string): InterceptTrigger {\n return { method: '*', url };\n },\n\n /** Response: simple JSON success. */\n json(data: unknown, status = 200): InterceptResponse {\n return { status, body: data };\n },\n\n /** Response: error with message. */\n error(status: number, message?: string): InterceptResponse {\n return { status, body: { error: message ?? `HTTP ${status}` } };\n },\n};\n","import type { InterceptResponse, InterceptTrigger } from './types.js';\n\nconst OPENAI_CHAT_URL = 'https://api.openai.com/v1/chat/completions';\n\nexport interface OpenAIChatFilter {\n /** Match by model name (exact string or regex). */\n model?: RegExp | string;\n /** Match by system message content (regex or substring). */\n system?: RegExp | string;\n /** Match by user message content (regex or substring). */\n user?: RegExp | string;\n /** Match by tool/function names present in the request. */\n tools?: string[];\n /** Match by temperature value. */\n temperature?: number;\n}\n\nfunction matchesFilter(body: any, filter: OpenAIChatFilter): boolean {\n if (filter.model) {\n const model = body?.model;\n if (typeof filter.model === 'string' && model !== filter.model) {\n return false;\n }\n if (filter.model instanceof RegExp && !filter.model.test(model ?? '')) {\n return false;\n }\n }\n\n if (filter.system) {\n const systemMsg = body?.messages?.find((m: any) => m.role === 'system')?.content ?? '';\n if (typeof filter.system === 'string' && !systemMsg.includes(filter.system)) {\n return false;\n }\n if (filter.system instanceof RegExp && !filter.system.test(systemMsg)) {\n return false;\n }\n }\n\n if (filter.user) {\n const userMsg = body?.messages?.find((m: any) => m.role === 'user')?.content ?? '';\n if (typeof filter.user === 'string' && !userMsg.includes(filter.user)) {\n return false;\n }\n if (filter.user instanceof RegExp && !filter.user.test(userMsg)) {\n return false;\n }\n }\n\n if (filter.tools) {\n const requestTools = body?.tools?.map((t: any) => t.function?.name).filter(Boolean) ?? [];\n if (!filter.tools.every((t) => requestTools.includes(t))) {\n return false;\n }\n }\n\n if (filter.temperature !== undefined && body?.temperature !== filter.temperature) {\n return false;\n }\n\n return true;\n}\n\n/**\n * OpenAI API intercept helpers.\n */\nexport const openai = {\n /**\n * Trigger: match OpenAI chat completion requests.\n *\n * @param filter - Optional filters to narrow which requests match.\n *\n * @example\n * openai.chat() // any chat call\n * openai.chat({ model: 'gpt-4o' }) // specific model\n * openai.chat({ system: /classify/ }) // system prompt contains \"classify\"\n * openai.chat({ tools: ['extract_facts'] }) // function calling\n */\n chat(filter?: OpenAIChatFilter): InterceptTrigger {\n return {\n method: 'POST',\n url: OPENAI_CHAT_URL,\n match: filter ? (body: unknown) => matchesFilter(body, filter) : undefined,\n };\n },\n\n /**\n * Response: wrap data in OpenAI chat completion format.\n *\n * @param data - The content to return (will be JSON.stringified if not a string).\n *\n * @example\n * openai.response({ categories: ['TECH'] })\n */\n response(data: unknown): InterceptResponse {\n const content = typeof data === 'string' ? data : JSON.stringify(data);\n return {\n status: 200,\n body: {\n id: 'chatcmpl-test',\n object: 'chat.completion',\n created: Math.floor(Date.now() / 1000),\n model: 'gpt-4o-test',\n choices: [\n {\n index: 0,\n message: { role: 'assistant', content },\n finish_reason: 'stop',\n },\n ],\n usage: { prompt_tokens: 10, completion_tokens: 10, total_tokens: 20 },\n },\n };\n },\n\n /** Response: return an OpenAI error. */\n error(status: number, message?: string): InterceptResponse {\n return {\n status,\n body: {\n error: {\n message: message ?? `OpenAI error (${status})`,\n type: status === 429 ? 'rate_limit_error' : 'api_error',\n code: status === 429 ? 'rate_limit_exceeded' : null,\n },\n },\n };\n },\n\n /** Response: return malformed (non-JSON) content. */\n malformed(content: string): InterceptResponse {\n return {\n status: 200,\n body: {\n id: 'chatcmpl-test',\n object: 'chat.completion',\n created: Math.floor(Date.now() / 1000),\n model: 'gpt-4o-test',\n choices: [\n {\n index: 0,\n message: { role: 'assistant', content },\n finish_reason: 'stop',\n },\n ],\n usage: { prompt_tokens: 10, completion_tokens: 10, total_tokens: 20 },\n },\n };\n },\n\n /** Response: simulate a timeout (30s delay). */\n timeout(): InterceptResponse {\n return {\n status: 200,\n body: {},\n delay: 30_000,\n };\n },\n};\n"],"mappings":"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"}
|
package/dist/server.cjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
//#region src/intercept/server.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
|
+
}
|
|
9
|
+
return {
|
|
10
|
+
node: mswModule,
|
|
11
|
+
msw: mswHttp
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
let serverInstance = null;
|
|
15
|
+
/**
|
|
16
|
+
* Start the MSW server (once per process).
|
|
17
|
+
*/
|
|
18
|
+
async function ensureInterceptServer() {
|
|
19
|
+
if (serverInstance) return;
|
|
20
|
+
const { node } = await loadMsw();
|
|
21
|
+
serverInstance = node.setupServer();
|
|
22
|
+
serverInstance.listen({ onUnhandledRequest: "bypass" });
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Register intercept entries as MSW handlers. Returns a cleanup function.
|
|
26
|
+
* Handlers are consumed in queue order — each trigger match pops one response.
|
|
27
|
+
*/
|
|
28
|
+
async function registerIntercepts(entries) {
|
|
29
|
+
if (entries.length === 0) return () => {};
|
|
30
|
+
await ensureInterceptServer();
|
|
31
|
+
const { msw } = await loadMsw();
|
|
32
|
+
const queues = /* @__PURE__ */ new Map();
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
const key = `${entry.trigger.method}:${entry.trigger.url}`;
|
|
35
|
+
const existing = queues.get(key) ?? [];
|
|
36
|
+
existing.push(entry);
|
|
37
|
+
queues.set(key, existing);
|
|
38
|
+
}
|
|
39
|
+
const handlers = [];
|
|
40
|
+
for (const [, queue] of queues) {
|
|
41
|
+
let index = 0;
|
|
42
|
+
const trigger = queue[0].trigger;
|
|
43
|
+
const handler = trigger.method === "*" ? msw.http.all(trigger.url instanceof RegExp ? trigger.url : trigger.url, createResolver(queue, () => index, () => {
|
|
44
|
+
index++;
|
|
45
|
+
})) : msw.http[trigger.method.toLowerCase()](trigger.url instanceof RegExp ? trigger.url : trigger.url, createResolver(queue, () => index, () => {
|
|
46
|
+
index++;
|
|
47
|
+
}));
|
|
48
|
+
handlers.push(handler);
|
|
49
|
+
}
|
|
50
|
+
serverInstance.use(...handlers);
|
|
51
|
+
return () => {
|
|
52
|
+
serverInstance.resetHandlers();
|
|
53
|
+
};
|
|
54
|
+
function createResolver(queue, getIndex, advance) {
|
|
55
|
+
return async ({ request }) => {
|
|
56
|
+
const idx = getIndex();
|
|
57
|
+
if (idx >= queue.length) return;
|
|
58
|
+
const entry = queue[idx];
|
|
59
|
+
if (entry.trigger.match) {
|
|
60
|
+
let body = null;
|
|
61
|
+
try {
|
|
62
|
+
body = await request.clone().json();
|
|
63
|
+
} catch {}
|
|
64
|
+
if (!entry.trigger.match(body)) return;
|
|
65
|
+
}
|
|
66
|
+
advance();
|
|
67
|
+
if (entry.response.delay) await new Promise((r) => setTimeout(r, entry.response.delay));
|
|
68
|
+
const { HttpResponse } = await loadMsw().then((m) => m.msw);
|
|
69
|
+
return HttpResponse.json(entry.response.body, {
|
|
70
|
+
status: entry.response.status ?? 200,
|
|
71
|
+
headers: entry.response.headers
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
//#endregion
|
|
77
|
+
exports.registerIntercepts = registerIntercepts;
|
|
78
|
+
|
|
79
|
+
//# sourceMappingURL=server.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.cjs","names":[],"sources":["../src/intercept/server.ts"],"sourcesContent":["/**\n * MSW-based intercept server. Manages HTTP interception for spec.run().\n *\n * Intercepts are queued per trigger — the first matching handler is consumed,\n * then the next one in the queue is used for subsequent matching requests.\n */\nimport type { InterceptEntry } from './types.js';\n\nlet mswModule: any = null;\nlet mswHttp: any = null;\n\nasync function loadMsw() {\n if (!mswModule) {\n mswModule = await import('msw/node');\n mswHttp = await import('msw');\n }\n return { node: mswModule, msw: mswHttp! };\n}\n\nlet serverInstance: any = null;\n\n/**\n * Start the MSW server (once per process).\n */\nexport async function ensureInterceptServer(): Promise<void> {\n if (serverInstance) {\n return;\n }\n const { node } = await loadMsw();\n serverInstance = node.setupServer();\n serverInstance.listen({ onUnhandledRequest: 'bypass' });\n}\n\n/**\n * Register intercept entries as MSW handlers. Returns a cleanup function.\n * Handlers are consumed in queue order — each trigger match pops one response.\n */\nexport async function registerIntercepts(entries: InterceptEntry[]): Promise<() => void> {\n if (entries.length === 0) {\n return () => {};\n }\n\n await ensureInterceptServer();\n const { msw } = await loadMsw();\n\n // Build a queue per unique trigger (same method+url+match combo)\n const queues = new Map<string, InterceptEntry[]>();\n for (const entry of entries) {\n const key = `${entry.trigger.method}:${entry.trigger.url}`;\n const existing = queues.get(key) ?? [];\n existing.push(entry);\n queues.set(key, existing);\n }\n\n const handlers: any[] = [];\n\n for (const [, queue] of queues) {\n let index = 0;\n const trigger = queue[0].trigger;\n\n const handler =\n trigger.method === '*'\n ? msw.http.all(\n trigger.url instanceof RegExp ? trigger.url : trigger.url,\n createResolver(\n queue,\n () => index,\n () => {\n index++;\n },\n ),\n )\n : (msw.http as any)[trigger.method.toLowerCase()](\n trigger.url instanceof RegExp ? trigger.url : trigger.url,\n createResolver(\n queue,\n () => index,\n () => {\n index++;\n },\n ),\n );\n\n handlers.push(handler);\n }\n\n serverInstance.use(...handlers);\n\n return () => {\n serverInstance.resetHandlers();\n };\n\n function createResolver(queue: InterceptEntry[], getIndex: () => number, advance: () => void) {\n return async ({ request }: { request: Request }) => {\n const idx = getIndex();\n if (idx >= queue.length) {\n // Queue exhausted — passthrough\n return undefined;\n }\n\n const entry = queue[idx];\n\n // Check body matcher if present\n if (entry.trigger.match) {\n let body: unknown = null;\n try {\n body = await request.clone().json();\n } catch {\n // Not JSON — skip match\n }\n if (!entry.trigger.match(body)) {\n return undefined;\n }\n }\n\n advance();\n\n // Apply delay if specified\n if (entry.response.delay) {\n await new Promise((r) => setTimeout(r, entry.response.delay));\n }\n\n const { HttpResponse } = await loadMsw().then((m) => m.msw);\n return HttpResponse.json(entry.response.body, {\n status: entry.response.status ?? 200,\n headers: entry.response.headers,\n });\n };\n }\n}\n\n/**\n * Stop the MSW server (call in afterAll).\n */\nexport async function stopInterceptServer(): Promise<void> {\n if (serverInstance) {\n serverInstance.close();\n serverInstance = null;\n }\n}\n"],"mappings":";AAQA,IAAI,YAAiB;AACrB,IAAI,UAAe;AAEnB,eAAe,UAAU;AACrB,KAAI,CAAC,WAAW;AACZ,cAAY,MAAM,OAAO;AACzB,YAAU,MAAM,OAAO;;AAE3B,QAAO;EAAE,MAAM;EAAW,KAAK;EAAU;;AAG7C,IAAI,iBAAsB;;;;AAK1B,eAAsB,wBAAuC;AACzD,KAAI,eACA;CAEJ,MAAM,EAAE,SAAS,MAAM,SAAS;AAChC,kBAAiB,KAAK,aAAa;AACnC,gBAAe,OAAO,EAAE,oBAAoB,UAAU,CAAC;;;;;;AAO3D,eAAsB,mBAAmB,SAAgD;AACrF,KAAI,QAAQ,WAAW,EACnB,cAAa;AAGjB,OAAM,uBAAuB;CAC7B,MAAM,EAAE,QAAQ,MAAM,SAAS;CAG/B,MAAM,yBAAS,IAAI,KAA+B;AAClD,MAAK,MAAM,SAAS,SAAS;EACzB,MAAM,MAAM,GAAG,MAAM,QAAQ,OAAO,GAAG,MAAM,QAAQ;EACrD,MAAM,WAAW,OAAO,IAAI,IAAI,IAAI,EAAE;AACtC,WAAS,KAAK,MAAM;AACpB,SAAO,IAAI,KAAK,SAAS;;CAG7B,MAAM,WAAkB,EAAE;AAE1B,MAAK,MAAM,GAAG,UAAU,QAAQ;EAC5B,IAAI,QAAQ;EACZ,MAAM,UAAU,MAAM,GAAG;EAEzB,MAAM,UACF,QAAQ,WAAW,MACb,IAAI,KAAK,IACL,QAAQ,eAAe,SAAS,QAAQ,MAAM,QAAQ,KACtD,eACI,aACM,aACA;AACF;IAEP,CACJ,GACA,IAAI,KAAa,QAAQ,OAAO,aAAa,EAC1C,QAAQ,eAAe,SAAS,QAAQ,MAAM,QAAQ,KACtD,eACI,aACM,aACA;AACF;IAEP,CACJ;AAEX,WAAS,KAAK,QAAQ;;AAG1B,gBAAe,IAAI,GAAG,SAAS;AAE/B,cAAa;AACT,iBAAe,eAAe;;CAGlC,SAAS,eAAe,OAAyB,UAAwB,SAAqB;AAC1F,SAAO,OAAO,EAAE,cAAoC;GAChD,MAAM,MAAM,UAAU;AACtB,OAAI,OAAO,MAAM,OAEb;GAGJ,MAAM,QAAQ,MAAM;AAGpB,OAAI,MAAM,QAAQ,OAAO;IACrB,IAAI,OAAgB;AACpB,QAAI;AACA,YAAO,MAAM,QAAQ,OAAO,CAAC,MAAM;YAC/B;AAGR,QAAI,CAAC,MAAM,QAAQ,MAAM,KAAK,CAC1B;;AAIR,YAAS;AAGT,OAAI,MAAM,SAAS,MACf,OAAM,IAAI,SAAS,MAAM,WAAW,GAAG,MAAM,SAAS,MAAM,CAAC;GAGjE,MAAM,EAAE,iBAAiB,MAAM,SAAS,CAAC,MAAM,MAAM,EAAE,IAAI;AAC3D,UAAO,aAAa,KAAK,MAAM,SAAS,MAAM;IAC1C,QAAQ,MAAM,SAAS,UAAU;IACjC,SAAS,MAAM,SAAS;IAC3B,CAAC"}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
//#region src/intercept/server.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
|
+
}
|
|
9
|
+
return {
|
|
10
|
+
node: mswModule,
|
|
11
|
+
msw: mswHttp
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
let serverInstance = null;
|
|
15
|
+
/**
|
|
16
|
+
* Start the MSW server (once per process).
|
|
17
|
+
*/
|
|
18
|
+
async function ensureInterceptServer() {
|
|
19
|
+
if (serverInstance) return;
|
|
20
|
+
const { node } = await loadMsw();
|
|
21
|
+
serverInstance = node.setupServer();
|
|
22
|
+
serverInstance.listen({ onUnhandledRequest: "bypass" });
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Register intercept entries as MSW handlers. Returns a cleanup function.
|
|
26
|
+
* Handlers are consumed in queue order — each trigger match pops one response.
|
|
27
|
+
*/
|
|
28
|
+
async function registerIntercepts(entries) {
|
|
29
|
+
if (entries.length === 0) return () => {};
|
|
30
|
+
await ensureInterceptServer();
|
|
31
|
+
const { msw } = await loadMsw();
|
|
32
|
+
const queues = /* @__PURE__ */ new Map();
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
const key = `${entry.trigger.method}:${entry.trigger.url}`;
|
|
35
|
+
const existing = queues.get(key) ?? [];
|
|
36
|
+
existing.push(entry);
|
|
37
|
+
queues.set(key, existing);
|
|
38
|
+
}
|
|
39
|
+
const handlers = [];
|
|
40
|
+
for (const [, queue] of queues) {
|
|
41
|
+
let index = 0;
|
|
42
|
+
const trigger = queue[0].trigger;
|
|
43
|
+
const handler = trigger.method === "*" ? msw.http.all(trigger.url instanceof RegExp ? trigger.url : trigger.url, createResolver(queue, () => index, () => {
|
|
44
|
+
index++;
|
|
45
|
+
})) : msw.http[trigger.method.toLowerCase()](trigger.url instanceof RegExp ? trigger.url : trigger.url, createResolver(queue, () => index, () => {
|
|
46
|
+
index++;
|
|
47
|
+
}));
|
|
48
|
+
handlers.push(handler);
|
|
49
|
+
}
|
|
50
|
+
serverInstance.use(...handlers);
|
|
51
|
+
return () => {
|
|
52
|
+
serverInstance.resetHandlers();
|
|
53
|
+
};
|
|
54
|
+
function createResolver(queue, getIndex, advance) {
|
|
55
|
+
return async ({ request }) => {
|
|
56
|
+
const idx = getIndex();
|
|
57
|
+
if (idx >= queue.length) return;
|
|
58
|
+
const entry = queue[idx];
|
|
59
|
+
if (entry.trigger.match) {
|
|
60
|
+
let body = null;
|
|
61
|
+
try {
|
|
62
|
+
body = await request.clone().json();
|
|
63
|
+
} catch {}
|
|
64
|
+
if (!entry.trigger.match(body)) return;
|
|
65
|
+
}
|
|
66
|
+
advance();
|
|
67
|
+
if (entry.response.delay) await new Promise((r) => setTimeout(r, entry.response.delay));
|
|
68
|
+
const { HttpResponse } = await loadMsw().then((m) => m.msw);
|
|
69
|
+
return HttpResponse.json(entry.response.body, {
|
|
70
|
+
status: entry.response.status ?? 200,
|
|
71
|
+
headers: entry.response.headers
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
//#endregion
|
|
77
|
+
export { registerIntercepts };
|
|
78
|
+
|
|
79
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.js","names":[],"sources":["../src/intercept/server.ts"],"sourcesContent":["/**\n * MSW-based intercept server. Manages HTTP interception for spec.run().\n *\n * Intercepts are queued per trigger — the first matching handler is consumed,\n * then the next one in the queue is used for subsequent matching requests.\n */\nimport type { InterceptEntry } from './types.js';\n\nlet mswModule: any = null;\nlet mswHttp: any = null;\n\nasync function loadMsw() {\n if (!mswModule) {\n mswModule = await import('msw/node');\n mswHttp = await import('msw');\n }\n return { node: mswModule, msw: mswHttp! };\n}\n\nlet serverInstance: any = null;\n\n/**\n * Start the MSW server (once per process).\n */\nexport async function ensureInterceptServer(): Promise<void> {\n if (serverInstance) {\n return;\n }\n const { node } = await loadMsw();\n serverInstance = node.setupServer();\n serverInstance.listen({ onUnhandledRequest: 'bypass' });\n}\n\n/**\n * Register intercept entries as MSW handlers. Returns a cleanup function.\n * Handlers are consumed in queue order — each trigger match pops one response.\n */\nexport async function registerIntercepts(entries: InterceptEntry[]): Promise<() => void> {\n if (entries.length === 0) {\n return () => {};\n }\n\n await ensureInterceptServer();\n const { msw } = await loadMsw();\n\n // Build a queue per unique trigger (same method+url+match combo)\n const queues = new Map<string, InterceptEntry[]>();\n for (const entry of entries) {\n const key = `${entry.trigger.method}:${entry.trigger.url}`;\n const existing = queues.get(key) ?? [];\n existing.push(entry);\n queues.set(key, existing);\n }\n\n const handlers: any[] = [];\n\n for (const [, queue] of queues) {\n let index = 0;\n const trigger = queue[0].trigger;\n\n const handler =\n trigger.method === '*'\n ? msw.http.all(\n trigger.url instanceof RegExp ? trigger.url : trigger.url,\n createResolver(\n queue,\n () => index,\n () => {\n index++;\n },\n ),\n )\n : (msw.http as any)[trigger.method.toLowerCase()](\n trigger.url instanceof RegExp ? trigger.url : trigger.url,\n createResolver(\n queue,\n () => index,\n () => {\n index++;\n },\n ),\n );\n\n handlers.push(handler);\n }\n\n serverInstance.use(...handlers);\n\n return () => {\n serverInstance.resetHandlers();\n };\n\n function createResolver(queue: InterceptEntry[], getIndex: () => number, advance: () => void) {\n return async ({ request }: { request: Request }) => {\n const idx = getIndex();\n if (idx >= queue.length) {\n // Queue exhausted — passthrough\n return undefined;\n }\n\n const entry = queue[idx];\n\n // Check body matcher if present\n if (entry.trigger.match) {\n let body: unknown = null;\n try {\n body = await request.clone().json();\n } catch {\n // Not JSON — skip match\n }\n if (!entry.trigger.match(body)) {\n return undefined;\n }\n }\n\n advance();\n\n // Apply delay if specified\n if (entry.response.delay) {\n await new Promise((r) => setTimeout(r, entry.response.delay));\n }\n\n const { HttpResponse } = await loadMsw().then((m) => m.msw);\n return HttpResponse.json(entry.response.body, {\n status: entry.response.status ?? 200,\n headers: entry.response.headers,\n });\n };\n }\n}\n\n/**\n * Stop the MSW server (call in afterAll).\n */\nexport async function stopInterceptServer(): Promise<void> {\n if (serverInstance) {\n serverInstance.close();\n serverInstance = null;\n }\n}\n"],"mappings":";AAQA,IAAI,YAAiB;AACrB,IAAI,UAAe;AAEnB,eAAe,UAAU;AACrB,KAAI,CAAC,WAAW;AACZ,cAAY,MAAM,OAAO;AACzB,YAAU,MAAM,OAAO;;AAE3B,QAAO;EAAE,MAAM;EAAW,KAAK;EAAU;;AAG7C,IAAI,iBAAsB;;;;AAK1B,eAAsB,wBAAuC;AACzD,KAAI,eACA;CAEJ,MAAM,EAAE,SAAS,MAAM,SAAS;AAChC,kBAAiB,KAAK,aAAa;AACnC,gBAAe,OAAO,EAAE,oBAAoB,UAAU,CAAC;;;;;;AAO3D,eAAsB,mBAAmB,SAAgD;AACrF,KAAI,QAAQ,WAAW,EACnB,cAAa;AAGjB,OAAM,uBAAuB;CAC7B,MAAM,EAAE,QAAQ,MAAM,SAAS;CAG/B,MAAM,yBAAS,IAAI,KAA+B;AAClD,MAAK,MAAM,SAAS,SAAS;EACzB,MAAM,MAAM,GAAG,MAAM,QAAQ,OAAO,GAAG,MAAM,QAAQ;EACrD,MAAM,WAAW,OAAO,IAAI,IAAI,IAAI,EAAE;AACtC,WAAS,KAAK,MAAM;AACpB,SAAO,IAAI,KAAK,SAAS;;CAG7B,MAAM,WAAkB,EAAE;AAE1B,MAAK,MAAM,GAAG,UAAU,QAAQ;EAC5B,IAAI,QAAQ;EACZ,MAAM,UAAU,MAAM,GAAG;EAEzB,MAAM,UACF,QAAQ,WAAW,MACb,IAAI,KAAK,IACL,QAAQ,eAAe,SAAS,QAAQ,MAAM,QAAQ,KACtD,eACI,aACM,aACA;AACF;IAEP,CACJ,GACA,IAAI,KAAa,QAAQ,OAAO,aAAa,EAC1C,QAAQ,eAAe,SAAS,QAAQ,MAAM,QAAQ,KACtD,eACI,aACM,aACA;AACF;IAEP,CACJ;AAEX,WAAS,KAAK,QAAQ;;AAG1B,gBAAe,IAAI,GAAG,SAAS;AAE/B,cAAa;AACT,iBAAe,eAAe;;CAGlC,SAAS,eAAe,OAAyB,UAAwB,SAAqB;AAC1F,SAAO,OAAO,EAAE,cAAoC;GAChD,MAAM,MAAM,UAAU;AACtB,OAAI,OAAO,MAAM,OAEb;GAGJ,MAAM,QAAQ,MAAM;AAGpB,OAAI,MAAM,QAAQ,OAAO;IACrB,IAAI,OAAgB;AACpB,QAAI;AACA,YAAO,MAAM,QAAQ,OAAO,CAAC,MAAM;YAC/B;AAGR,QAAI,CAAC,MAAM,QAAQ,MAAM,KAAK,CAC1B;;AAIR,YAAS;AAGT,OAAI,MAAM,SAAS,MACf,OAAM,IAAI,SAAS,MAAM,WAAW,GAAG,MAAM,SAAS,MAAM,CAAC;GAGjE,MAAM,EAAE,iBAAiB,MAAM,SAAS,CAAC,MAAM,MAAM,EAAE,IAAI;AAC3D,UAAO,aAAa,KAAK,MAAM,SAAS,MAAM;IAC1C,QAAQ,MAAM,SAAS,UAAU;IACjC,SAAS,MAAM,SAAS;IAC3B,CAAC"}
|
package/dist/types.d.cts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//#region src/intercept/types.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* An intercept trigger describes which HTTP request to match.
|
|
4
|
+
*/
|
|
5
|
+
interface InterceptTrigger {
|
|
6
|
+
/** HTTP method to match. */
|
|
7
|
+
method: string;
|
|
8
|
+
/** URL pattern to match (string for exact prefix, RegExp for pattern). */
|
|
9
|
+
url: RegExp | string;
|
|
10
|
+
/** Optional request body matcher — the handler only fires if this returns true. */
|
|
11
|
+
match?: (body: unknown) => boolean;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* An intercept response describes what to return when the trigger matches.
|
|
15
|
+
*/
|
|
16
|
+
interface InterceptResponse {
|
|
17
|
+
/** HTTP status code (default: 200). */
|
|
18
|
+
status?: number;
|
|
19
|
+
/** Response body (will be JSON.stringified). */
|
|
20
|
+
body: unknown;
|
|
21
|
+
/** Response headers. */
|
|
22
|
+
headers?: Record<string, string>;
|
|
23
|
+
/** Delay in ms before responding (for timeout testing). */
|
|
24
|
+
delay?: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* A fully resolved intercept entry ready to be registered with MSW.
|
|
28
|
+
*/
|
|
29
|
+
interface InterceptEntry {
|
|
30
|
+
trigger: InterceptTrigger;
|
|
31
|
+
response: InterceptResponse;
|
|
32
|
+
}
|
|
33
|
+
//#endregion
|
|
34
|
+
export { InterceptResponse as n, InterceptTrigger as r, InterceptEntry as t };
|
|
35
|
+
//# sourceMappingURL=types.d.cts.map
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//#region src/intercept/types.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* An intercept trigger describes which HTTP request to match.
|
|
4
|
+
*/
|
|
5
|
+
interface InterceptTrigger {
|
|
6
|
+
/** HTTP method to match. */
|
|
7
|
+
method: string;
|
|
8
|
+
/** URL pattern to match (string for exact prefix, RegExp for pattern). */
|
|
9
|
+
url: RegExp | string;
|
|
10
|
+
/** Optional request body matcher — the handler only fires if this returns true. */
|
|
11
|
+
match?: (body: unknown) => boolean;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* An intercept response describes what to return when the trigger matches.
|
|
15
|
+
*/
|
|
16
|
+
interface InterceptResponse {
|
|
17
|
+
/** HTTP status code (default: 200). */
|
|
18
|
+
status?: number;
|
|
19
|
+
/** Response body (will be JSON.stringified). */
|
|
20
|
+
body: unknown;
|
|
21
|
+
/** Response headers. */
|
|
22
|
+
headers?: Record<string, string>;
|
|
23
|
+
/** Delay in ms before responding (for timeout testing). */
|
|
24
|
+
delay?: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* A fully resolved intercept entry ready to be registered with MSW.
|
|
28
|
+
*/
|
|
29
|
+
interface InterceptEntry {
|
|
30
|
+
trigger: InterceptTrigger;
|
|
31
|
+
response: InterceptResponse;
|
|
32
|
+
}
|
|
33
|
+
//#endregion
|
|
34
|
+
export { InterceptResponse as n, InterceptTrigger as r, InterceptEntry as t };
|
|
35
|
+
//# sourceMappingURL=types.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jterrazz/test",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.3.0",
|
|
4
4
|
"author": "Jean-Baptiste Terrazzoni <contact@jterrazz.com>",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
"./mock": {
|
|
23
23
|
"require": "./dist/mock.cjs",
|
|
24
24
|
"import": "./dist/mock.js"
|
|
25
|
+
},
|
|
26
|
+
"./intercept": {
|
|
27
|
+
"require": "./dist/intercept.cjs",
|
|
28
|
+
"import": "./dist/intercept.js"
|
|
25
29
|
}
|
|
26
30
|
},
|
|
27
31
|
"publishConfig": {
|
|
@@ -51,6 +55,7 @@
|
|
|
51
55
|
"@types/node": "^25.5.0",
|
|
52
56
|
"@types/pg": "^8.20.0",
|
|
53
57
|
"hono": "^4.12.9",
|
|
58
|
+
"msw": "^2.13.3",
|
|
54
59
|
"tsdown": "^0.21.8",
|
|
55
60
|
"vitest": "^4.1.2"
|
|
56
61
|
},
|