Expand unit tests for network intercept and error monitor
Cover NetworkErrorMonitor (record/dedupe/exclude/minStatus/assert/stop) and interceptNetworkCall (spy, fulfill, custom handler, method continue, non-JSON bodies). Export globToRegExp + responseMatchesFilter for direct matcher coverage.
This commit is contained in:
parent
6e3763f44f
commit
9eeea87c16
@ -2,6 +2,7 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Tests: expand coverage for network helpers — `NetworkErrorMonitor` (record/dedupe/exclude/minStatus/assert), `interceptNetworkCall` (spy/fulfill/handler/method continue/non-JSON), plus `globToRegExp` / `responseMatchesFilter`
|
||||
- **Network interception / error monitor** — `interceptNetworkCall()`, `startNetworkErrorMonitor()` (see `docs/NETWORK.md`)
|
||||
- Docs: `docs/OUTLINE.md` checklist (update Outline QA & Dev Playkit page on each release); `docs/IDEAS.md` for OSS-borrowed backlog; adoption pause (no new consumer repos until soak)
|
||||
- CI: add tag-triggered `release` job (`.gitea/workflows/ci.yml`) — re-runs build/test, verifies tag matches `package.json` version and `CHANGELOG.md` documents it, creates a Gitea release with an `npm pack` tarball attached. Requires a one-time `GITEA_TOKEN` Actions secret.
|
||||
|
||||
@ -19,6 +19,8 @@ export {
|
||||
NetworkErrorMonitor,
|
||||
matchesExcludePattern,
|
||||
dedupeNetworkErrors,
|
||||
globToRegExp,
|
||||
responseMatchesFilter,
|
||||
type FulfillResponse,
|
||||
type InterceptNetworkCallOptions,
|
||||
type InterceptedNetworkCall,
|
||||
|
||||
@ -1,10 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Page, Request, Response, Route } from '@playwright/test';
|
||||
import {
|
||||
dedupeNetworkErrors,
|
||||
globToRegExp,
|
||||
interceptNetworkCall,
|
||||
matchesExcludePattern,
|
||||
responseMatchesFilter,
|
||||
startNetworkErrorMonitor,
|
||||
type NetworkError,
|
||||
} from './network.js';
|
||||
|
||||
function mockRequest(overrides: {
|
||||
method?: string;
|
||||
url?: string;
|
||||
postData?: string | null;
|
||||
} = {}): Request {
|
||||
return {
|
||||
method: () => overrides.method ?? 'GET',
|
||||
url: () => overrides.url ?? 'https://example.com/api',
|
||||
postData: () => overrides.postData ?? null,
|
||||
} as unknown as Request;
|
||||
}
|
||||
|
||||
function mockResponse(overrides: {
|
||||
status?: number;
|
||||
url?: string;
|
||||
method?: string;
|
||||
json?: unknown;
|
||||
postData?: string | null;
|
||||
jsonThrows?: boolean;
|
||||
}): Response {
|
||||
const request = mockRequest({
|
||||
method: overrides.method,
|
||||
url: overrides.url,
|
||||
postData: overrides.postData,
|
||||
});
|
||||
return {
|
||||
status: () => overrides.status ?? 200,
|
||||
url: () => overrides.url ?? 'https://example.com/api',
|
||||
request: () => request,
|
||||
json: async () => {
|
||||
if (overrides.jsonThrows) throw new Error('not json');
|
||||
return overrides.json ?? { ok: true };
|
||||
},
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
type ResponseListener = (response: Response) => void;
|
||||
|
||||
function createMockPage() {
|
||||
const listeners = new Map<string, ResponseListener>();
|
||||
const page = {
|
||||
on: vi.fn((event: string, fn: ResponseListener) => {
|
||||
listeners.set(event, fn);
|
||||
}),
|
||||
off: vi.fn((event: string, fn: ResponseListener) => {
|
||||
if (listeners.get(event) === fn) listeners.delete(event);
|
||||
}),
|
||||
waitForResponse: vi.fn(),
|
||||
route: vi.fn(async () => undefined),
|
||||
unroute: vi.fn(async () => undefined),
|
||||
emitResponse(response: Response) {
|
||||
listeners.get('response')?.(response);
|
||||
},
|
||||
hasResponseListener() {
|
||||
return listeners.has('response');
|
||||
},
|
||||
};
|
||||
return page as typeof page & Page;
|
||||
}
|
||||
|
||||
describe('matchesExcludePattern', () => {
|
||||
it('matches substring and regex', () => {
|
||||
expect(matchesExcludePattern('https://cdn.example.com/x.js', ['cdn.example.com'])).toBe(
|
||||
@ -15,6 +80,10 @@ describe('matchesExcludePattern', () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false for empty patterns', () => {
|
||||
expect(matchesExcludePattern('https://example.com', [])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dedupeNetworkErrors', () => {
|
||||
@ -45,3 +114,211 @@ describe('dedupeNetworkErrors', () => {
|
||||
expect(out[1]?.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('globToRegExp / responseMatchesFilter', () => {
|
||||
it('matches Playwright-style globs', () => {
|
||||
const re = globToRegExp('**/api/users');
|
||||
expect('https://punimtagdev.levkin.ca/api/users').toMatch(re);
|
||||
expect('https://punimtagdev.levkin.ca/api/posts').not.toMatch(re);
|
||||
expect(globToRegExp('**/api/users/*').test('https://app/api/users/1')).toBe(true);
|
||||
});
|
||||
|
||||
it('filters by url string glob and method', () => {
|
||||
const getUsers = mockResponse({
|
||||
url: 'https://app.example.com/api/users',
|
||||
method: 'GET',
|
||||
status: 200,
|
||||
});
|
||||
const postUsers = mockResponse({
|
||||
url: 'https://app.example.com/api/users',
|
||||
method: 'POST',
|
||||
status: 201,
|
||||
});
|
||||
expect(responseMatchesFilter(getUsers, '**/api/users', 'GET')).toBe(true);
|
||||
expect(responseMatchesFilter(postUsers, '**/api/users', 'GET')).toBe(false);
|
||||
expect(responseMatchesFilter(postUsers, /\/api\/users$/, 'POST')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NetworkErrorMonitor', () => {
|
||||
it('records 4xx/5xx, skips 2xx, and assertNoErrors throws', () => {
|
||||
const page = createMockPage();
|
||||
const net = startNetworkErrorMonitor(page);
|
||||
|
||||
page.emitResponse(mockResponse({ status: 200, url: 'https://app/ok' }));
|
||||
page.emitResponse(mockResponse({ status: 500, url: 'https://app/boom', method: 'GET' }));
|
||||
page.emitResponse(mockResponse({ status: 500, url: 'https://app/boom', method: 'GET' })); // dedupe
|
||||
page.emitResponse(mockResponse({ status: 404, url: 'https://app/missing', method: 'GET' }));
|
||||
|
||||
expect(net.getErrors()).toHaveLength(2);
|
||||
expect(() => net.assertNoErrors()).toThrow(/Network errors detected: 2/);
|
||||
expect(page.hasResponseListener()).toBe(false); // stopped
|
||||
});
|
||||
|
||||
it('assertNoErrors is a no-op when clean', () => {
|
||||
const page = createMockPage();
|
||||
const net = startNetworkErrorMonitor(page);
|
||||
page.emitResponse(mockResponse({ status: 204, url: 'https://app/ok' }));
|
||||
expect(() => net.assertNoErrors()).not.toThrow();
|
||||
});
|
||||
|
||||
it('honors excludePatterns and minStatus', () => {
|
||||
const page = createMockPage();
|
||||
const net = startNetworkErrorMonitor(page, {
|
||||
excludePatterns: ['sentry.io', /analytics\./],
|
||||
minStatus: 500,
|
||||
});
|
||||
|
||||
page.emitResponse(mockResponse({ status: 404, url: 'https://app/missing' })); // below min
|
||||
page.emitResponse(mockResponse({ status: 500, url: 'https://sentry.io/api/1' })); // excluded
|
||||
page.emitResponse(mockResponse({ status: 503, url: 'https://analytics.example.com/x' })); // excluded
|
||||
page.emitResponse(mockResponse({ status: 502, url: 'https://app/fail' }));
|
||||
|
||||
expect(net.getErrors()).toEqual([
|
||||
expect.objectContaining({ status: 502, url: 'https://app/fail' }),
|
||||
]);
|
||||
net.stop();
|
||||
});
|
||||
|
||||
it('stop() ignores further responses', () => {
|
||||
const page = createMockPage();
|
||||
const net = startNetworkErrorMonitor(page);
|
||||
net.stop();
|
||||
page.emitResponse(mockResponse({ status: 500, url: 'https://app/late' }));
|
||||
expect(net.getErrors()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('interceptNetworkCall', () => {
|
||||
it('spies on a matching response without installing a route', async () => {
|
||||
const page = createMockPage();
|
||||
const response = mockResponse({
|
||||
status: 200,
|
||||
url: 'https://app.example.com/api/users',
|
||||
method: 'GET',
|
||||
json: [{ id: 1 }],
|
||||
postData: null,
|
||||
});
|
||||
page.waitForResponse.mockImplementation(async (pred: (r: Response) => boolean) => {
|
||||
expect(pred(response)).toBe(true);
|
||||
return response;
|
||||
});
|
||||
|
||||
const result = await interceptNetworkCall({
|
||||
page,
|
||||
url: '**/api/users',
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(page.route).not.toHaveBeenCalled();
|
||||
expect(page.unroute).not.toHaveBeenCalled();
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.responseJson).toEqual([{ id: 1 }]);
|
||||
expect(result.requestJson).toBeUndefined();
|
||||
});
|
||||
|
||||
it('installs fulfill route and unroutes in finally', async () => {
|
||||
const page = createMockPage();
|
||||
const response = mockResponse({
|
||||
status: 200,
|
||||
url: 'https://app.example.com/api/users',
|
||||
method: 'GET',
|
||||
json: [{ id: 9, name: 'stub' }],
|
||||
});
|
||||
page.waitForResponse.mockResolvedValue(response);
|
||||
page.route.mockImplementation(async (_url: string | RegExp, handler: (route: Route, request: Request) => Promise<void>) => {
|
||||
const route = {
|
||||
continue: vi.fn(async () => undefined),
|
||||
fulfill: vi.fn(async () => undefined),
|
||||
} as unknown as Route;
|
||||
await handler(route, mockRequest({ method: 'GET', url: response.url() }));
|
||||
expect(route.fulfill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 200,
|
||||
body: JSON.stringify([{ id: 9, name: 'stub' }]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const result = await interceptNetworkCall({
|
||||
page,
|
||||
url: '**/api/users',
|
||||
fulfillResponse: { status: 200, body: [{ id: 9, name: 'stub' }] },
|
||||
});
|
||||
|
||||
expect(page.route).toHaveBeenCalledOnce();
|
||||
expect(page.unroute).toHaveBeenCalledOnce();
|
||||
expect(result.responseJson).toEqual([{ id: 9, name: 'stub' }]);
|
||||
});
|
||||
|
||||
it('uses custom handler and parses request JSON', async () => {
|
||||
const page = createMockPage();
|
||||
const response = mockResponse({
|
||||
status: 401,
|
||||
url: 'https://app.example.com/api/login',
|
||||
method: 'POST',
|
||||
json: { error: 'nope' },
|
||||
postData: JSON.stringify({ user: 'e2e' }),
|
||||
});
|
||||
page.waitForResponse.mockResolvedValue(response);
|
||||
const handler = vi.fn(async (route: Route) => {
|
||||
await route.fulfill({ status: 401, body: '{"error":"nope"}' });
|
||||
});
|
||||
page.route.mockImplementation(async (_url, routeHandler) => {
|
||||
const route = {
|
||||
continue: vi.fn(),
|
||||
fulfill: vi.fn(async () => undefined),
|
||||
} as unknown as Route;
|
||||
await routeHandler(route, mockRequest({ method: 'POST', postData: '{"user":"e2e"}' }));
|
||||
});
|
||||
|
||||
const result = await interceptNetworkCall({
|
||||
page,
|
||||
url: /\/api\/login$/,
|
||||
method: 'POST',
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
expect(result.status).toBe(401);
|
||||
expect(result.requestJson).toEqual({ user: 'e2e' });
|
||||
expect(result.responseJson).toEqual({ error: 'nope' });
|
||||
});
|
||||
|
||||
it('tolerates non-JSON response bodies', async () => {
|
||||
const page = createMockPage();
|
||||
const response = mockResponse({
|
||||
status: 200,
|
||||
url: 'https://app.example.com/health',
|
||||
jsonThrows: true,
|
||||
});
|
||||
page.waitForResponse.mockResolvedValue(response);
|
||||
|
||||
const result = await interceptNetworkCall({ page, url: '**/health' });
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.responseJson).toBeUndefined();
|
||||
});
|
||||
|
||||
it('continues non-matching method on fulfill route', async () => {
|
||||
const page = createMockPage();
|
||||
const response = mockResponse({
|
||||
status: 200,
|
||||
url: 'https://app.example.com/api/users',
|
||||
method: 'GET',
|
||||
});
|
||||
page.waitForResponse.mockResolvedValue(response);
|
||||
const continueFn = vi.fn(async () => undefined);
|
||||
page.route.mockImplementation(async (_url, routeHandler) => {
|
||||
const route = { continue: continueFn, fulfill: vi.fn() } as unknown as Route;
|
||||
await routeHandler(route, mockRequest({ method: 'OPTIONS' }));
|
||||
});
|
||||
|
||||
await interceptNetworkCall({
|
||||
page,
|
||||
url: '**/api/users',
|
||||
method: 'GET',
|
||||
fulfillResponse: { body: [] },
|
||||
});
|
||||
expect(continueFn).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@ -50,21 +50,25 @@ async function parseJsonSafe(raw: string | null): Promise<unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
function responsePredicate(
|
||||
/** Convert a Playwright-style glob to a RegExp (exported for unit tests). */
|
||||
export function globToRegExp(pattern: string): RegExp {
|
||||
const escaped = pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\*\*/g, '.*')
|
||||
.replace(/\*/g, '[^/?]*');
|
||||
return new RegExp(escaped);
|
||||
}
|
||||
|
||||
/** True when a response matches URL + optional method filters (exported for unit tests). */
|
||||
export function responseMatchesFilter(
|
||||
response: Response,
|
||||
url: string | RegExp,
|
||||
method?: string,
|
||||
): boolean {
|
||||
if (!methodMatches(response.request(), method)) return false;
|
||||
if (url instanceof RegExp) return url.test(response.url());
|
||||
// Prefer Playwright's built-in glob matching when possible via URL string compare.
|
||||
try {
|
||||
return response.url().match(new RegExp(
|
||||
url
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\*\*/g, '.*')
|
||||
.replace(/\*/g, '[^/?]*'),
|
||||
)) !== null;
|
||||
return response.url().match(globToRegExp(url)) !== null;
|
||||
} catch {
|
||||
return response.url().includes(String(url));
|
||||
}
|
||||
@ -89,7 +93,7 @@ export function interceptNetworkCall(
|
||||
|
||||
// Register the response waiter first so we never miss a fast request.
|
||||
const responsePromise = page.waitForResponse(
|
||||
(res) => responsePredicate(res, url, method),
|
||||
(res) => responseMatchesFilter(res, url, method),
|
||||
{ timeout },
|
||||
);
|
||||
|
||||
|
||||
@ -27,6 +27,8 @@ export {
|
||||
NetworkErrorMonitor,
|
||||
matchesExcludePattern,
|
||||
dedupeNetworkErrors,
|
||||
globToRegExp,
|
||||
responseMatchesFilter,
|
||||
type ClickOptions,
|
||||
type FillOptions,
|
||||
type GotoOptions,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user