Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
export { attachNetworkMonitor } from "./network-monitor.js";
export type {
NetworkFailure,
NetworkMonitor,
} from "./network-monitor.types.js";

View File

@@ -0,0 +1,104 @@
# network-monitor
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`attachNetworkMonitor` intercepts all network activity on a Playwright `Page` and records every HTTP 4xx/5xx response and every network-level request failure (connection refused, DNS failure, aborted). Its `assertNoFailures()` method fails the test if any failures were captured. Used as a standard fixture in every E2E spec to catch unexpected API errors and missing assets automatically, without requiring individual assertions for each network call. This creates a zero-escape-hatch guarantee: tests that make unintended requests to broken endpoints are caught immediately rather than producing silent wrong-state failures.
The monitor is intentionally passive — it observes without intercepting or blocking requests. Unlike Playwright route mocking, it lets the full application network stack run unmodified.
## Features
- Registers `page.on("response")` and `page.on("requestfailed")` listeners
- Records any response with HTTP status >= 400 as a `"status"` failure including the URL and status code
- Records `requestfailed` events (connection errors, aborts, DNS failures) as `"failed"` failures including the error text from `request.failure()`
- Passes 2xx and 3xx responses through without recording
- Handles `request.failure()` returning `null` gracefully — records the failure with `errorText: undefined`
- `assertNoFailures()` throws with a formatted multi-line list of all recorded failures
- Exposes the raw `failures` array for test-specific inspection
## Usage: Basic Example
```typescript
import { attachNetworkMonitor } from "@suaveplan/testing-e2e/network-monitor";
import { test } from "@playwright/test";
test("login page makes no failed requests", async ({ page }) => {
const monitor = attachNetworkMonitor(page);
await page.goto("http://localhost:3200/login");
await page.waitForLoadState("networkidle");
monitor.assertNoFailures();
});
```
## Usage: Advanced Example
```typescript
import { attachNetworkMonitor } from "@suaveplan/testing-e2e/network-monitor";
import { attachConsoleMonitor } from "@suaveplan/testing-e2e/console-monitor";
import { test } from "@playwright/test";
test("dashboard loads all assets and API calls succeed", async ({ page }) => {
const network = attachNetworkMonitor(page);
const console = attachConsoleMonitor(page, { failOn: ["error"] });
await page.goto("http://localhost:3200/dashboard");
await page.waitForSelector("[data-testid=dashboard-ready]");
// Inspect specific failures before blanket assertion
const apiFailures = network.failures.filter((f) =>
f.url.includes("/api/")
);
if (apiFailures.length > 0) {
console.log("API failures:", apiFailures);
}
network.assertNoFailures();
console.assertClean();
});
```
## API Reference
### `attachNetworkMonitor(page: Page): NetworkMonitor`
Attaches response and request-failed listeners to the given Playwright page.
**Parameters:**
- `page` — Playwright `Page` instance to monitor
**Returns:** `NetworkMonitor`
- `.failures` — read-only array of `NetworkFailure` objects collected since the monitor was attached
- `.assertNoFailures()` — throws `Error` listing all recorded failures if the array is non-empty
### `NetworkFailure` interface
```typescript
interface NetworkFailure {
url: string;
type: "status" | "failed";
status?: number; // set for type === "status"
errorText?: string; // set for type === "failed" when failure() is non-null
}
```
### `NetworkMonitor` interface
```typescript
interface NetworkMonitor {
readonly failures: NetworkFailure[];
assertNoFailures(): void;
}
```
## Implementation Notes
The status threshold of 400 is inclusive: 400 Bad Request through all 5xx server errors are recorded. 301/302 redirects and 304 Not Modified responses are not recorded — these are normal browser behaviour during page load. 401 and 403 are deliberately included because they almost always indicate a test environment misconfiguration (wrong auth token, missing seed data).
`request.failure()` returns `null` for requests that were cancelled by the browser's own navigation logic rather than failing at the network layer. The monitor records these as failures with `errorText: undefined` to preserve the URL for debugging, since they may indicate leaked requests from prior test steps.
The `failures` getter returns the live internal array. Like `console-monitor`, this avoids copy overhead and the live reference is safe because test assertions run synchronously after the monitored page actions complete.

View File

@@ -0,0 +1,179 @@
import type { Page, Request, Response } from "@playwright/test";
import { describe, expect, it, vi } from "vitest";
type EventCallback = (arg: Request | Response) => void;
function makeFakePage() {
const listeners = new Map<string, EventCallback[]>();
const page = {
on: vi.fn((event: string, cb: EventCallback) => {
if (!listeners.has(event)) listeners.set(event, []);
listeners.get(event)!.push(cb);
}),
} as unknown as Page;
function emit(event: string, arg: Request | Response) {
for (const cb of listeners.get(event) ?? []) cb(arg);
}
return { page, emit };
}
function fakeResponse(url: string, status: number): Response {
return {
url: () => url,
status: () => status,
request: () => ({ url: () => url }) as unknown as Request,
} as unknown as Response;
}
function fakeFailedRequest(url: string, errorText: string): Request {
return {
url: () => url,
failure: () => ({ errorText }),
} as unknown as Request;
}
describe("attachNetworkMonitor", () => {
it("registers response and requestfailed listeners", {
timeout: 5000,
}, async () => {
const { attachNetworkMonitor } = await import("./network-monitor.js");
const { page } = makeFakePage();
attachNetworkMonitor(page);
const onCalls = (page.on as ReturnType<typeof vi.fn>).mock.calls.map(
(c: unknown[]) => c[0]
);
expect(onCalls).toContain("response");
expect(onCalls).toContain("requestfailed");
});
it("records 4xx responses as failures", { timeout: 5000 }, async () => {
const { attachNetworkMonitor } = await import("./network-monitor.js");
const { page, emit } = makeFakePage();
const monitor = attachNetworkMonitor(page);
emit(
"response",
fakeResponse("http://localhost:3200/api/missing", 404)
);
expect(monitor.failures).toHaveLength(1);
expect(monitor.failures[0]!.status).toBe(404);
expect(monitor.failures[0]!.type).toBe("status");
});
it("records 5xx responses as failures", { timeout: 5000 }, async () => {
const { attachNetworkMonitor } = await import("./network-monitor.js");
const { page, emit } = makeFakePage();
const monitor = attachNetworkMonitor(page);
emit("response", fakeResponse("http://localhost:3200/api/boom", 500));
expect(monitor.failures).toHaveLength(1);
expect(monitor.failures[0]!.status).toBe(500);
});
it("does not record 2xx responses as failures", {
timeout: 5000,
}, async () => {
const { attachNetworkMonitor } = await import("./network-monitor.js");
const { page, emit } = makeFakePage();
const monitor = attachNetworkMonitor(page);
emit("response", fakeResponse("http://localhost:3200/api/ok", 200));
expect(monitor.failures).toHaveLength(0);
});
it("does not record 3xx responses as failures", {
timeout: 5000,
}, async () => {
const { attachNetworkMonitor } = await import("./network-monitor.js");
const { page, emit } = makeFakePage();
const monitor = attachNetworkMonitor(page);
emit("response", fakeResponse("http://localhost:3200/redirect", 302));
expect(monitor.failures).toHaveLength(0);
});
it("records requestfailed events as failures", {
timeout: 5000,
}, async () => {
const { attachNetworkMonitor } = await import("./network-monitor.js");
const { page, emit } = makeFakePage();
const monitor = attachNetworkMonitor(page);
emit(
"requestfailed",
fakeFailedRequest(
"http://localhost:3200/api/x",
"net::ERR_CONNECTION_REFUSED"
)
);
expect(monitor.failures).toHaveLength(1);
expect(monitor.failures[0]!.type).toBe("failed");
expect(monitor.failures[0]!.errorText).toBe(
"net::ERR_CONNECTION_REFUSED"
);
});
it("assertNoFailures does not throw when no failures", {
timeout: 5000,
}, async () => {
const { attachNetworkMonitor } = await import("./network-monitor.js");
const { page } = makeFakePage();
const monitor = attachNetworkMonitor(page);
expect(() => monitor.assertNoFailures()).not.toThrow();
});
it("assertNoFailures throws when failures exist", {
timeout: 5000,
}, async () => {
const { attachNetworkMonitor } = await import("./network-monitor.js");
const { page, emit } = makeFakePage();
const monitor = attachNetworkMonitor(page);
emit(
"response",
fakeResponse("http://localhost:3200/api/missing", 404)
);
expect(() => monitor.assertNoFailures()).toThrow("404");
});
it("records requestfailed with no failure() object gracefully", {
timeout: 5000,
}, async () => {
const { attachNetworkMonitor } = await import("./network-monitor.js");
const { page, emit } = makeFakePage();
const monitor = attachNetworkMonitor(page);
const req = {
url: () => "http://localhost:3200/img.png",
failure: () => null,
} as unknown as Request;
emit("requestfailed", req);
expect(monitor.failures).toHaveLength(1);
expect(monitor.failures[0]!.errorText).toBeUndefined();
});
it("assertNoFailures includes errorText in message for failed requests", {
timeout: 5000,
}, async () => {
const { attachNetworkMonitor } = await import("./network-monitor.js");
const { page, emit } = makeFakePage();
const monitor = attachNetworkMonitor(page);
emit(
"requestfailed",
fakeFailedRequest(
"http://localhost:3200/api/x",
"net::ERR_CONNECTION_REFUSED"
)
);
expect(() => monitor.assertNoFailures()).toThrow(
"net::ERR_CONNECTION_REFUSED"
);
});
it("assertNoFailures includes FAILED label without errorText when failure is null", {
timeout: 5000,
}, async () => {
const { attachNetworkMonitor } = await import("./network-monitor.js");
const { page, emit } = makeFakePage();
const monitor = attachNetworkMonitor(page);
const req = {
url: () => "http://localhost:3200/img.png",
failure: () => null,
} as unknown as Request;
emit("requestfailed", req);
expect(() => monitor.assertNoFailures()).toThrow("[FAILED]");
});
});

View File

@@ -0,0 +1,44 @@
import type { Page, Request, Response } from "@playwright/test";
import type {
NetworkFailure,
NetworkMonitor,
} from "./network-monitor.types.js";
export function attachNetworkMonitor(page: Page): NetworkMonitor {
const failures: NetworkFailure[] = [];
page.on("response", (response: Response) => {
const status = response.status();
if (status >= 400) {
failures.push({ url: response.url(), type: "status", status });
}
});
page.on("requestfailed", (request: Request) => {
const failure = request.failure();
const entry: NetworkFailure = { url: request.url(), type: "failed" };
if (failure?.errorText !== undefined) {
entry.errorText = failure.errorText;
}
failures.push(entry);
});
return {
get failures() {
return failures;
},
assertNoFailures() {
if (failures.length === 0) return;
const lines = failures
.map((f) =>
f.type === "status"
? ` [${f.status}] ${f.url}`
: ` [FAILED] ${f.url}${f.errorText ? ` -- ${f.errorText}` : ""}`
)
.join("\n");
throw new Error(
`NetworkMonitor: unexpected network failures:\n${lines}`
);
},
};
}

View File

@@ -0,0 +1,11 @@
export interface NetworkFailure {
url: string;
type: "status" | "failed";
status?: number;
errorText?: string;
}
export interface NetworkMonitor {
readonly failures: NetworkFailure[];
assertNoFailures(): void;
}