diff --git a/CHANGELOG.md b/CHANGELOG.md index bce07f4..3fcc4bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/browser/index.ts b/src/browser/index.ts index 4747b94..99ad24a 100644 --- a/src/browser/index.ts +++ b/src/browser/index.ts @@ -19,6 +19,8 @@ export { NetworkErrorMonitor, matchesExcludePattern, dedupeNetworkErrors, + globToRegExp, + responseMatchesFilter, type FulfillResponse, type InterceptNetworkCallOptions, type InterceptedNetworkCall, diff --git a/src/browser/network.test.ts b/src/browser/network.test.ts index a162829..2cd8080 100644 --- a/src/browser/network.test.ts +++ b/src/browser/network.test.ts @@ -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(); + 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) => { + 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(); + }); +}); diff --git a/src/browser/network.ts b/src/browser/network.ts index 54f4fbc..5cd22fe 100644 --- a/src/browser/network.ts +++ b/src/browser/network.ts @@ -50,21 +50,25 @@ async function parseJsonSafe(raw: string | null): Promise { } } -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 }, ); diff --git a/src/index.ts b/src/index.ts index 09b2945..4786a11 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,8 @@ export { NetworkErrorMonitor, matchesExcludePattern, dedupeNetworkErrors, + globToRegExp, + responseMatchesFilter, type ClickOptions, type FillOptions, type GotoOptions,