@testspectra/matchers 1.0.70 → 1.1.0-rc.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 (51) hide show
  1. package/LICENSE.md +48 -0
  2. package/dist/__tests__/intercept.test.d.ts +1 -0
  3. package/dist/__tests__/intercept.test.js +183 -0
  4. package/dist/__tests__/matcher-contracts.test.d.ts +1 -0
  5. package/dist/__tests__/matcher-contracts.test.js +498 -0
  6. package/dist/__tests__/matchers.test.d.ts +1 -0
  7. package/dist/__tests__/matchers.test.js +161 -0
  8. package/dist/contract.d.ts +141 -0
  9. package/dist/contract.js +10 -0
  10. package/dist/index.d.ts +4 -21
  11. package/dist/index.js +4 -21
  12. package/dist/intercept/cdp-handler.d.ts +22 -0
  13. package/dist/intercept/cdp-handler.js +123 -0
  14. package/dist/intercept/index.d.ts +4 -0
  15. package/dist/intercept/index.js +4 -0
  16. package/dist/intercept/mock-handle.d.ts +49 -0
  17. package/dist/intercept/mock-handle.js +124 -0
  18. package/dist/intercept/mock-registry.d.ts +18 -0
  19. package/dist/intercept/mock-registry.js +97 -0
  20. package/dist/intercept/types.d.ts +43 -0
  21. package/dist/intercept/types.js +1 -0
  22. package/dist/matchers.d.ts +15 -4
  23. package/dist/matchers.js +609 -81
  24. package/dist/proto.d.ts +18 -283
  25. package/dist/proto.js +1 -114
  26. package/dist/reporter.d.ts +30 -0
  27. package/dist/reporter.js +97 -0
  28. package/dist/runner/collection.d.ts +33 -20
  29. package/dist/runner/collection.js +104 -26
  30. package/dist/runner/single.d.ts +125 -34
  31. package/dist/runner/single.js +290 -55
  32. package/dist/semantic.d.ts +11 -0
  33. package/dist/semantic.js +141 -0
  34. package/dist/spectra.d.ts +32 -8
  35. package/dist/spectra.js +157 -40
  36. package/dist/types.d.ts +1226 -28
  37. package/package.json +13 -8
  38. package/src/contract.ts +182 -0
  39. package/src/index.ts +4 -22
  40. package/src/proto.ts +22 -433
  41. package/src/runtime/assertions.ts +450 -0
  42. package/src/runtime/element_actions.ts +169 -0
  43. package/src/runtime/element_proxy.ts +159 -0
  44. package/src/runtime/element_state.ts +91 -0
  45. package/src/runtime/spectra.ts +109 -0
  46. package/src/types.ts +1424 -96
  47. package/tsconfig.json +2 -2
  48. package/src/matchers.ts +0 -146
  49. package/src/runner/collection.ts +0 -153
  50. package/src/runner/single.ts +0 -301
  51. package/src/spectra.ts +0 -376
@@ -0,0 +1,141 @@
1
+ /**
2
+ * @packageDocumentation
3
+ * # PlatformDriverBridge Contract
4
+ *
5
+ * The unified abstract driver contract consumed by `@testspectra/matchers` element proxies,
6
+ * fluent matchers, and the `Spectra` automation object. Every platform-specific driver
7
+ * (`WebCdpDriverBridge`, `AndroidTcpDriverBridge`) implements this interface so that the
8
+ * DSL layer contains zero platform branching.
9
+ */
10
+ import type { CDPNetworkEntry, MockInterceptHandle, MockRule, ScrollOptions, SpectraBrowserBridge, SwipeOptions } from './types.js';
11
+ /**
12
+ * Runtime configuration injected by the Rust orchestrator via `globalThis.__TESTSPECTRA_CONFIG__`.
13
+ */
14
+ export interface RuntimeWorkerConfig {
15
+ workerId?: number;
16
+ rootDir: string;
17
+ testFilePath?: string;
18
+ testFiles?: Array<{
19
+ id: string;
20
+ title: string;
21
+ filePath: string;
22
+ suite: string;
23
+ }>;
24
+ activePlatform: string;
25
+ configuredBaseUrl: string;
26
+ cdpWsUrl: string;
27
+ driverServerUrl: string;
28
+ manifest?: Record<string, any>;
29
+ /** Target app package (Android). Paired with androidSerial/adbPath for on-demand adb actions. */
30
+ appPackage?: string;
31
+ /** Target adb device serial for this worker (Android only). */
32
+ androidSerial?: string;
33
+ /** Resolved `adb` binary path (Android only), paired with androidSerial above. */
34
+ adbPath?: string;
35
+ /** Host port this worker's own MobileMockProxyServer should listen on (Android only) — derived
36
+ * per-device the same way androidSerial/the driver-server port already are, so each device in
37
+ * a multi-device run gets its own proxy instead of every worker racing for one shared port. */
38
+ mockProxyPort?: number;
39
+ implicitWaitMs?: number;
40
+ timeoutMs?: number;
41
+ stepDelayMs?: number;
42
+ }
43
+ /**
44
+ * Cross-cutting shared runtime state injected into the assembled worker script.
45
+ *
46
+ * The worker runtime is emitted as a single self-contained TypeScript module (concatenated
47
+ * by the Rust orchestrator). This object is created once in the harness prelude and mutated
48
+ * in place (`activeDriver` is bound after the platform bridge is instantiated), so fragments
49
+ * access it lazily at call-time through `globalThis.__TS_RUNTIME__`.
50
+ */
51
+ export interface RuntimeContext {
52
+ config: RuntimeWorkerConfig;
53
+ activeDriver: PlatformDriverBridge | null;
54
+ mobileMockRules: MockRule[];
55
+ lastFocusedSelector: string | null;
56
+ currentTargetItem?: any;
57
+ emitEvent(tag: string, payload: string): void;
58
+ trackStep<T>(category: 'action' | 'assertion' | 'browser', payloadObj: Record<string, any>, fn: () => Promise<T>): Promise<T>;
59
+ pollCondition(fn: () => Promise<boolean> | boolean, timeoutMs?: number, intervalMs?: number): Promise<boolean>;
60
+ getImplicitWaitMs(): number;
61
+ getAssertionTimeoutMs(): number;
62
+ matchesUrlPattern(pattern: string, url: string): boolean;
63
+ createMockHandle(rule: MockRule): MockInterceptHandle;
64
+ parseMockRule(patternOrOptions?: any, maybeMethodOrHandler?: any, maybeFixture?: any, maybeOptions?: any): MockRule;
65
+ recordDuration?: (label: string, category: string, durationMs: number, extra?: Record<string, unknown>) => void;
66
+ withDuration?: <T>(label: string, category: string, fn: () => Promise<T>, extra?: Record<string, unknown>) => Promise<T>;
67
+ durationLogPath?: string | null;
68
+ createElementActions?: (selector: string, index: number | null) => Record<string, any>;
69
+ createElementState?: (selector: string, index: number | null) => Record<string, any>;
70
+ createElementAssertions?: (selector: string, index: number | null, state: Record<string, any>) => Record<string, any>;
71
+ createElementProxy?: (selector: string, index?: number | null) => any;
72
+ createCollectionProxy?: (selector: string) => any;
73
+ resolveTargetProxy?: (target: any) => any;
74
+ buildSpectra?: () => any;
75
+ cdpBridgeClass?: any;
76
+ androidBridgeClass?: any;
77
+ mobileMockProxyClass?: any;
78
+ mobileMockProxy?: any;
79
+ beforeHook?: (() => Promise<void> | void) | null;
80
+ afterHook?: (() => Promise<void> | void) | null;
81
+ beforeEachHook?: (() => Promise<void> | void) | null;
82
+ afterEachHook?: (() => Promise<void> | void) | null;
83
+ bootstrapEnvironment?: () => Promise<void>;
84
+ runSuite?: () => Promise<void>;
85
+ }
86
+ /**
87
+ * The unified platform driver contract. It extends the browser surface documented in
88
+ * `SpectraBrowserBridge` (navigation, script execution, network interception, browser-level
89
+ * assertions) with the element interaction and state-inspection methods needed by the DSL,
90
+ * so the `tools/matchers` layer never branches on `activePlatform`.
91
+ */
92
+ export interface PlatformDriverBridge extends SpectraBrowserBridge {
93
+ /** Establishes connection to the underlying runtime endpoint (WebSocket / TCP Socket). */
94
+ connect(): Promise<void>;
95
+ /** Cleans up open connections, servers, and listeners. */
96
+ close(): Promise<void>;
97
+ /** Navigates to target URL (Web) or Activity / Deep Link (Android). */
98
+ navigate(targetUrl: string): Promise<void>;
99
+ click(selector: string, index?: number | null): Promise<void>;
100
+ doubleClick(selector: string, index?: number | null): Promise<void>;
101
+ rightClick(selector: string, index?: number | null): Promise<void>;
102
+ setValue(selector: string, value: unknown, index?: number | null): Promise<void>;
103
+ clearValue(selector: string, index?: number | null): Promise<void>;
104
+ select(selector: string, option: string, index?: number | null): Promise<void>;
105
+ hover(selector: string, index?: number | null): Promise<void>;
106
+ focus(selector: string, index?: number | null): Promise<void>;
107
+ dragDrop(sourceSelector: string, targetSelector: string, sourceIndex?: number | null): Promise<void>;
108
+ longPress(selector: string, duration: number, index?: number | null): Promise<void>;
109
+ scrollIntoView(selector: string, index?: number | null): Promise<void>;
110
+ getText(selector: string, index?: number | null): Promise<string>;
111
+ getValue(selector: string, index?: number | null): Promise<string>;
112
+ isDisplayed(selector: string, index?: number | null): Promise<boolean>;
113
+ isExisting(selector: string, index?: number | null): Promise<boolean>;
114
+ isEnabled(selector: string, index?: number | null): Promise<boolean>;
115
+ isSelected(selector: string, index?: number | null): Promise<boolean>;
116
+ isFocused(selector: string, index?: number | null): Promise<boolean>;
117
+ isClickable(selector: string, index?: number | null): Promise<boolean>;
118
+ hasClass(selector: string, className: string, index?: number | null): Promise<boolean>;
119
+ getAttribute(selector: string, name: string, index?: number | null): Promise<string | null>;
120
+ getCSSProperty(selector: string, name: string, index?: number | null): Promise<{
121
+ value: string;
122
+ }>;
123
+ count(selector: string): Promise<number>;
124
+ pressKey(key: string | number): Promise<void>;
125
+ scroll(options?: ScrollOptions): Promise<void>;
126
+ swipe(options: SwipeOptions): Promise<void>;
127
+ /**
128
+ * Grants an Android runtime permission on demand (e.g. right after confirming an in-app
129
+ * rationale dialog), via `adb shell pm grant` on the host. No-op on platforms without an
130
+ * OS-level runtime permission model (e.g. web).
131
+ */
132
+ grantPermission(name: string): Promise<void>;
133
+ /** Clears all active network interception rules. */
134
+ clearMocks(): void;
135
+ mockRules: MockRule[];
136
+ consoleErrors: string[];
137
+ recordedNetwork: CDPNetworkEntry[];
138
+ }
139
+ declare global {
140
+ var __TS_RUNTIME__: RuntimeContext;
141
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @packageDocumentation
3
+ * # PlatformDriverBridge Contract
4
+ *
5
+ * The unified abstract driver contract consumed by `@testspectra/matchers` element proxies,
6
+ * fluent matchers, and the `Spectra` automation object. Every platform-specific driver
7
+ * (`WebCdpDriverBridge`, `AndroidTcpDriverBridge`) implements this interface so that the
8
+ * DSL layer contains zero platform branching.
9
+ */
10
+ export {};
package/dist/index.d.ts CHANGED
@@ -2,25 +2,8 @@
2
2
  * @packageDocumentation
3
3
  * # @testspectra/matchers
4
4
  *
5
- * Cross-platform action commands, fluent runners, and callable assertion matchers for TestSpectra.
6
- *
7
- * ## Features:
8
- * - **`Spectra`**: Unified automation API (`Spectra.get()`, `Spectra.getAll()`, `Spectra.click()`, `Spectra.type()`, etc.)
9
- * - **`SingleElementRunner`**: Fluent interaction & assertion runner for single element targets
10
- * - **`MultiElementRunner`**: Fluent assertion & traversal runner for multi-element collections
11
- * - **Callable Matchers**: Ambient prototypes for WebdriverIO Element & Browser (`.shouldBeVisible()`, `.shouldHaveText()`, etc.)
12
- *
13
- * @example
14
- * ```ts
15
- * import { Spectra } from "@testspectra/matchers";
16
- *
17
- * await Spectra.get("#login-btn").click().should("not.be.visible");
18
- * await Spectra.getAll(".list-item").should("have.length.greaterThan", 0);
19
- * ```
5
+ * Canonical TypeScript contracts, action/assertion types, and ambient type definitions for TestSpectra.
20
6
  */
21
- export * from "./types.js";
22
- export * from "./matchers.js";
23
- export * from "./runner/single.js";
24
- export * from "./runner/collection.js";
25
- export * from "./spectra.js";
26
- export * from "./proto.js";
7
+ export * from './types.js';
8
+ export * from './contract.js';
9
+ export * from './proto.js';
package/dist/index.js CHANGED
@@ -2,25 +2,8 @@
2
2
  * @packageDocumentation
3
3
  * # @testspectra/matchers
4
4
  *
5
- * Cross-platform action commands, fluent runners, and callable assertion matchers for TestSpectra.
6
- *
7
- * ## Features:
8
- * - **`Spectra`**: Unified automation API (`Spectra.get()`, `Spectra.getAll()`, `Spectra.click()`, `Spectra.type()`, etc.)
9
- * - **`SingleElementRunner`**: Fluent interaction & assertion runner for single element targets
10
- * - **`MultiElementRunner`**: Fluent assertion & traversal runner for multi-element collections
11
- * - **Callable Matchers**: Ambient prototypes for WebdriverIO Element & Browser (`.shouldBeVisible()`, `.shouldHaveText()`, etc.)
12
- *
13
- * @example
14
- * ```ts
15
- * import { Spectra } from "@testspectra/matchers";
16
- *
17
- * await Spectra.get("#login-btn").click().should("not.be.visible");
18
- * await Spectra.getAll(".list-item").should("have.length.greaterThan", 0);
19
- * ```
5
+ * Canonical TypeScript contracts, action/assertion types, and ambient type definitions for TestSpectra.
20
6
  */
21
- export * from "./types.js";
22
- export * from "./matchers.js";
23
- export * from "./runner/single.js";
24
- export * from "./runner/collection.js";
25
- export * from "./spectra.js";
26
- export * from "./proto.js";
7
+ export * from './types.js';
8
+ export * from './contract.js';
9
+ export * from './proto.js';
@@ -0,0 +1,22 @@
1
+ import { MockHandle } from './mock-handle.js';
2
+ import { InterceptedRequest, InterceptRule } from './types.js';
3
+ export declare class CdpHandler {
4
+ private static cdpEnabled;
5
+ private static cdpPromise;
6
+ /**
7
+ * Ensures Chrome DevTools Protocol Fetch domain is enabled and listening.
8
+ * Awaits confirmation from CDP Fetch.enable before resolving.
9
+ */
10
+ static enableFetchDomain(): Promise<void>;
11
+ /**
12
+ * Atomically registers rule and awaits CDP Fetch.enable before returning the handle.
13
+ */
14
+ static registerRule(rule: InterceptRule): Promise<MockHandle>;
15
+ /**
16
+ * Dispatches an intercepted request through the MockRegistry, records metrics, and returns fulfillment.
17
+ */
18
+ static dispatchIncomingRequest(req: InterceptedRequest): Promise<{
19
+ aborted?: string;
20
+ response?: any;
21
+ }>;
22
+ }
@@ -0,0 +1,123 @@
1
+ import { MockRegistry } from './mock-registry.js';
2
+ export class CdpHandler {
3
+ static cdpEnabled = false;
4
+ static cdpPromise = null;
5
+ /**
6
+ * Ensures Chrome DevTools Protocol Fetch domain is enabled and listening.
7
+ * Awaits confirmation from CDP Fetch.enable before resolving.
8
+ */
9
+ static async enableFetchDomain() {
10
+ if (this.cdpEnabled)
11
+ return;
12
+ if (this.cdpPromise)
13
+ return this.cdpPromise;
14
+ this.cdpPromise = (async () => {
15
+ // Check if running inside real WebdriverIO browser with CDP
16
+ const b = globalThis.browser;
17
+ if (b && typeof b.getPuppeteer === 'function') {
18
+ try {
19
+ const puppeteer = await b.getPuppeteer();
20
+ const pages = await puppeteer.pages();
21
+ const page = pages[0];
22
+ if (page) {
23
+ const client = await page.target().createCDPSession();
24
+ await client.send('Fetch.enable', {
25
+ patterns: [{ urlPattern: '*' }],
26
+ });
27
+ client.on('Fetch.requestPaused', async (event) => {
28
+ const req = {
29
+ id: event.requestId,
30
+ url: event.request.url,
31
+ method: event.request.method,
32
+ headers: event.request.headers,
33
+ body: event.request.postData,
34
+ timestamp: Date.now(),
35
+ };
36
+ const fulfill = await CdpHandler.dispatchIncomingRequest(req);
37
+ if (fulfill.aborted) {
38
+ await client.send('Fetch.failRequest', {
39
+ requestId: event.requestId,
40
+ errorReason: fulfill.aborted,
41
+ });
42
+ }
43
+ else if (fulfill.response) {
44
+ const bodyStr = typeof fulfill.response.body === 'object'
45
+ ? JSON.stringify(fulfill.response.body)
46
+ : String(fulfill.response.body || '');
47
+ const base64Body = Buffer.from(bodyStr).toString('base64');
48
+ const responseHeaders = Object.entries(fulfill.response.headers || {}).map(([name, value]) => ({
49
+ name,
50
+ value,
51
+ }));
52
+ await client.send('Fetch.fulfillRequest', {
53
+ requestId: event.requestId,
54
+ responseCode: fulfill.response.status || 200,
55
+ responseHeaders,
56
+ body: base64Body,
57
+ });
58
+ }
59
+ else {
60
+ await client.send('Fetch.continueRequest', {
61
+ requestId: event.requestId,
62
+ });
63
+ }
64
+ });
65
+ }
66
+ }
67
+ catch {
68
+ // Graceful fallback in non-CDP environments (e.g. mobile Appium or test runners)
69
+ }
70
+ }
71
+ this.cdpEnabled = true;
72
+ })();
73
+ return this.cdpPromise;
74
+ }
75
+ /**
76
+ * Atomically registers rule and awaits CDP Fetch.enable before returning the handle.
77
+ */
78
+ static async registerRule(rule) {
79
+ await this.enableFetchDomain();
80
+ const registry = MockRegistry.getInstance();
81
+ return registry.register(rule);
82
+ }
83
+ /**
84
+ * Dispatches an intercepted request through the MockRegistry, records metrics, and returns fulfillment.
85
+ */
86
+ static async dispatchIncomingRequest(req) {
87
+ const startTime = Date.now();
88
+ const registry = MockRegistry.getInstance();
89
+ const handle = registry.findMatchingHandle(req.method, req.url);
90
+ let result = {};
91
+ if (handle) {
92
+ const fulfill = await handle.matchAndFulfill(req);
93
+ result = fulfill;
94
+ }
95
+ // Record network traffic
96
+ const duration = Date.now() - startTime;
97
+ const status = result.aborted ? 0 : result.response?.status || 200;
98
+ const statusText = result.aborted ? result.aborted : status === 200 ? 'OK' : 'Mocked';
99
+ const recorded = {
100
+ id: req.id,
101
+ url: req.url,
102
+ method: req.method,
103
+ status,
104
+ statusText,
105
+ resourceType: 'fetch',
106
+ requestHeaders: req.headers,
107
+ responseHeaders: result.response?.headers,
108
+ requestBody: req.body,
109
+ responseBody: result.response?.body,
110
+ timing: {
111
+ dns: 1.0,
112
+ tcp: 1.5,
113
+ ssl: 2.0,
114
+ ttfb: duration,
115
+ download: 0.5,
116
+ total: duration + 5.0,
117
+ },
118
+ transferSize: result.response?.body ? JSON.stringify(result.response.body).length : 0,
119
+ };
120
+ registry.recordResource(recorded);
121
+ return result;
122
+ }
123
+ }
@@ -0,0 +1,4 @@
1
+ export * from './types.js';
2
+ export * from './mock-handle.js';
3
+ export * from './mock-registry.js';
4
+ export * from './cdp-handler.js';
@@ -0,0 +1,4 @@
1
+ export * from './types.js';
2
+ export * from './mock-handle.js';
3
+ export * from './mock-registry.js';
4
+ export * from './cdp-handler.js';
@@ -0,0 +1,49 @@
1
+ import { InterceptedRequest, InterceptRule, MockResponse, NetworkAbortReason } from './types.js';
2
+ export interface FulfillResult {
3
+ aborted?: NetworkAbortReason;
4
+ response?: MockResponse;
5
+ }
6
+ export declare class MockHandle {
7
+ readonly rule: InterceptRule;
8
+ private onceQueue;
9
+ private defaultResponse;
10
+ private abortReason;
11
+ private interceptedCalls;
12
+ private waitingCallers;
13
+ constructor(rule: InterceptRule);
14
+ /**
15
+ * Updates the default mock response for all subsequent matching requests.
16
+ */
17
+ respondWith(response: MockResponse): this;
18
+ /**
19
+ * Queues a one-time mock response for the next matching request (FIFO queue for polling/retries).
20
+ */
21
+ respondOnce(response: MockResponse): this;
22
+ /**
23
+ * Simulates a network failure or connection abort.
24
+ */
25
+ abort(errorCode?: NetworkAbortReason): this;
26
+ /**
27
+ * Total matching requests intercepted so far.
28
+ */
29
+ get callCount(): number;
30
+ /**
31
+ * Historical array of intercepted requests.
32
+ */
33
+ get calls(): InterceptedRequest[];
34
+ /**
35
+ * Awaits until a matching request arrives or times out.
36
+ */
37
+ waitForCall(options?: {
38
+ timeout?: number;
39
+ count?: number;
40
+ }): Promise<InterceptedRequest>;
41
+ /**
42
+ * Internal fulfillment method called when a request matches this rule.
43
+ */
44
+ matchAndFulfill(req: InterceptedRequest): Promise<FulfillResult>;
45
+ /**
46
+ * Resets local state and queued responses.
47
+ */
48
+ clear(): void;
49
+ }
@@ -0,0 +1,124 @@
1
+ export class MockHandle {
2
+ rule;
3
+ onceQueue = [];
4
+ defaultResponse = null;
5
+ abortReason = null;
6
+ interceptedCalls = [];
7
+ waitingCallers = [];
8
+ constructor(rule) {
9
+ this.rule = rule;
10
+ if (rule.response) {
11
+ this.defaultResponse = rule.response;
12
+ }
13
+ }
14
+ /**
15
+ * Updates the default mock response for all subsequent matching requests.
16
+ */
17
+ respondWith(response) {
18
+ this.defaultResponse = response;
19
+ this.abortReason = null;
20
+ return this;
21
+ }
22
+ /**
23
+ * Queues a one-time mock response for the next matching request (FIFO queue for polling/retries).
24
+ */
25
+ respondOnce(response) {
26
+ this.onceQueue.push(response);
27
+ return this;
28
+ }
29
+ /**
30
+ * Simulates a network failure or connection abort.
31
+ */
32
+ abort(errorCode = 'Aborted') {
33
+ this.abortReason = errorCode;
34
+ return this;
35
+ }
36
+ /**
37
+ * Total matching requests intercepted so far.
38
+ */
39
+ get callCount() {
40
+ return this.interceptedCalls.length;
41
+ }
42
+ /**
43
+ * Historical array of intercepted requests.
44
+ */
45
+ get calls() {
46
+ return [...this.interceptedCalls];
47
+ }
48
+ /**
49
+ * Awaits until a matching request arrives or times out.
50
+ */
51
+ async waitForCall(options = {}) {
52
+ const timeout = options.timeout ?? 5000;
53
+ const targetCount = options.count ?? 1;
54
+ if (this.interceptedCalls.length >= targetCount) {
55
+ return this.interceptedCalls[targetCount - 1];
56
+ }
57
+ return new Promise((resolve, reject) => {
58
+ const timer = setTimeout(() => {
59
+ const idx = this.waitingCallers.findIndex((w) => w.timer === timer);
60
+ if (idx !== -1)
61
+ this.waitingCallers.splice(idx, 1);
62
+ reject(new Error(`waitForCall timed out after ${timeout}ms. Expected ${targetCount} call(s), but received ${this.interceptedCalls.length}.`));
63
+ }, timeout);
64
+ this.waitingCallers.push({
65
+ targetCount,
66
+ resolve,
67
+ reject,
68
+ timer,
69
+ });
70
+ });
71
+ }
72
+ /**
73
+ * Internal fulfillment method called when a request matches this rule.
74
+ */
75
+ async matchAndFulfill(req) {
76
+ this.interceptedCalls.push(req);
77
+ // Notify any waiting callers whose count requirement is met
78
+ for (let i = this.waitingCallers.length - 1; i >= 0; i--) {
79
+ const waiter = this.waitingCallers[i];
80
+ if (this.interceptedCalls.length >= waiter.targetCount) {
81
+ clearTimeout(waiter.timer);
82
+ waiter.resolve(req);
83
+ this.waitingCallers.splice(i, 1);
84
+ }
85
+ }
86
+ if (this.abortReason) {
87
+ return { aborted: this.abortReason };
88
+ }
89
+ // 1. Shift from FIFO onceQueue if available
90
+ if (this.onceQueue.length > 0) {
91
+ const nextOnce = this.onceQueue.shift();
92
+ return { response: nextOnce };
93
+ }
94
+ // 2. Fall back to default response
95
+ if (typeof this.defaultResponse === 'function') {
96
+ const res = await this.defaultResponse(req);
97
+ return { response: res };
98
+ }
99
+ if (this.defaultResponse) {
100
+ return { response: this.defaultResponse };
101
+ }
102
+ // Default 200 OK
103
+ return {
104
+ response: {
105
+ status: 200,
106
+ headers: { 'content-type': 'application/json' },
107
+ body: {},
108
+ },
109
+ };
110
+ }
111
+ /**
112
+ * Resets local state and queued responses.
113
+ */
114
+ clear() {
115
+ this.onceQueue = [];
116
+ this.interceptedCalls = [];
117
+ this.abortReason = null;
118
+ for (const waiter of this.waitingCallers) {
119
+ clearTimeout(waiter.timer);
120
+ waiter.reject(new Error('Mock rule cleared while waiting for call'));
121
+ }
122
+ this.waitingCallers = [];
123
+ }
124
+ }
@@ -0,0 +1,18 @@
1
+ import { MockHandle } from './mock-handle.js';
2
+ import { InterceptRule, RecordedNetworkResource } from './types.js';
3
+ export declare function globToRegExp(glob: string): RegExp;
4
+ export declare function matchesUrl(pattern: string | RegExp, url: string): boolean;
5
+ export declare function matchesMethod(ruleMethod?: string, reqMethod?: string): boolean;
6
+ export declare class MockRegistry {
7
+ private static instance;
8
+ private handles;
9
+ private recordedResources;
10
+ static getInstance(): MockRegistry;
11
+ register(rule: InterceptRule): MockHandle;
12
+ findMatchingHandle(method: string, url: string): MockHandle | undefined;
13
+ clear(): void;
14
+ recordResource(resource: RecordedNetworkResource): void;
15
+ getRecordedResources(): RecordedNetworkResource[];
16
+ clearRecordedResources(): void;
17
+ exportNetworkResources(filePath: string): void;
18
+ }
@@ -0,0 +1,97 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { MockHandle } from './mock-handle.js';
4
+ export function globToRegExp(glob) {
5
+ let regexStr = '';
6
+ let i = 0;
7
+ while (i < glob.length) {
8
+ const c = glob[i];
9
+ if (c === '*' && glob[i + 1] === '*') {
10
+ regexStr += '.*';
11
+ i += 2;
12
+ }
13
+ else if (c === '*') {
14
+ regexStr += '[^/]*';
15
+ i += 1;
16
+ }
17
+ else if (['.', '?', '+', '^', '$', '[', ']', '(', ')', '{', '}', '|', '\\'].includes(c)) {
18
+ regexStr += `\\${c}`;
19
+ i += 1;
20
+ }
21
+ else {
22
+ regexStr += c;
23
+ i += 1;
24
+ }
25
+ }
26
+ // If glob does not start with protocol and does not start with wildcard, allow leading domain
27
+ if (!glob.startsWith('http://') && !glob.startsWith('https://') && !glob.startsWith('*')) {
28
+ return new RegExp(`(?:https?://[^/]+)?${regexStr}(?:\\?.*)?$`);
29
+ }
30
+ return new RegExp(`^${regexStr}(?:\\?.*)?$`);
31
+ }
32
+ export function matchesUrl(pattern, url) {
33
+ if (pattern instanceof RegExp) {
34
+ return pattern.test(url);
35
+ }
36
+ if (pattern === url) {
37
+ return true;
38
+ }
39
+ try {
40
+ const parsed = new URL(url, 'http://localhost');
41
+ if (pattern === parsed.pathname || pattern === `${parsed.pathname}${parsed.search}`) {
42
+ return true;
43
+ }
44
+ }
45
+ catch { }
46
+ const reg = globToRegExp(pattern);
47
+ return reg.test(url);
48
+ }
49
+ export function matchesMethod(ruleMethod, reqMethod = 'GET') {
50
+ if (!ruleMethod || ruleMethod.toUpperCase() === 'ALL') {
51
+ return true;
52
+ }
53
+ return ruleMethod.toUpperCase() === reqMethod.toUpperCase();
54
+ }
55
+ export class MockRegistry {
56
+ static instance;
57
+ handles = [];
58
+ recordedResources = [];
59
+ static getInstance() {
60
+ if (!MockRegistry.instance) {
61
+ MockRegistry.instance = new MockRegistry();
62
+ }
63
+ return MockRegistry.instance;
64
+ }
65
+ register(rule) {
66
+ const handle = new MockHandle(rule);
67
+ this.handles.unshift(handle);
68
+ return handle;
69
+ }
70
+ findMatchingHandle(method, url) {
71
+ return this.handles.find((h) => {
72
+ return matchesMethod(h.rule.method, method) && matchesUrl(h.rule.url, url);
73
+ });
74
+ }
75
+ clear() {
76
+ for (const h of this.handles) {
77
+ h.clear();
78
+ }
79
+ this.handles = [];
80
+ }
81
+ recordResource(resource) {
82
+ this.recordedResources.push(resource);
83
+ }
84
+ getRecordedResources() {
85
+ return [...this.recordedResources];
86
+ }
87
+ clearRecordedResources() {
88
+ this.recordedResources = [];
89
+ }
90
+ exportNetworkResources(filePath) {
91
+ const dir = path.dirname(filePath);
92
+ if (!fs.existsSync(dir)) {
93
+ fs.mkdirSync(dir, { recursive: true });
94
+ }
95
+ fs.writeFileSync(filePath, JSON.stringify(this.recordedResources, null, 2), 'utf-8');
96
+ }
97
+ }