Expand unit tests for network intercept and error monitor
All checks were successful
CI / release (pull_request) Has been skipped
CI / skip-ci-check (pull_request) Successful in 4s
CI / secret-scan (pull_request) Successful in 3s
CI / build-and-test (pull_request) Successful in 16s

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:
ilia 2026-07-14 20:58:41 -04:00
parent 6e3763f44f
commit 9eeea87c16
5 changed files with 296 additions and 10 deletions

View File

@ -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.

View File

@ -19,6 +19,8 @@ export {
NetworkErrorMonitor,
matchesExcludePattern,
dedupeNetworkErrors,
globToRegExp,
responseMatchesFilter,
type FulfillResponse,
type InterceptNetworkCallOptions,
type InterceptedNetworkCall,

View File

@ -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();
});
});

View File

@ -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 },
);

View File

@ -27,6 +27,8 @@ export {
NetworkErrorMonitor,
matchesExcludePattern,
dedupeNetworkErrors,
globToRegExp,
responseMatchesFilter,
type ClickOptions,
type FillOptions,
type GotoOptions,