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,79 @@
# codes
## Purpose
`TESTING_E2E_ERROR_CODES` is the frozen registry of every failure code `@suaveplan/testing-e2e` emits. All codes carry the `TESTING_E2E.` prefix so log filters can isolate Playwright-harness failures from application code. The registry is hand-built (not via `defineCodes`) because the package has zero `@suaveplan/*` deps by design.
## Features
- Frozen record of namespaced codes
- `TestingE2eCode` type alias for exhaustive-check support
- Stable — additions append; removals require a deprecation cycle
- Structural match for `defineCodes` output — drop-in replacement if the dep rule later relaxes
## Usage: Basic Example
```ts
import { TESTING_E2E_ERROR_CODES } from "@suaveplan/testing-e2e";
TESTING_E2E_ERROR_CODES.WAIT_TIMEOUT; // "TESTING_E2E.WAIT.TIMEOUT"
```
## Usage: Advanced Example
```ts
import {
TESTING_E2E_ERROR_CODES,
TestingE2eError,
type TestingE2eCode,
} from "@suaveplan/testing-e2e";
function shouldRetry(code: TestingE2eCode): boolean {
switch (code) {
case TESTING_E2E_ERROR_CODES.WAIT_TIMEOUT:
case TESTING_E2E_ERROR_CODES.NAVIGATION_FAILED:
return true;
case TESTING_E2E_ERROR_CODES.ABORTED:
case TESTING_E2E_ERROR_CODES.FIXTURE_FAILED:
case TESTING_E2E_ERROR_CODES.CONFIG_INVALID:
return false;
}
}
```
## API Reference
### `TESTING_E2E_ERROR_CODES`
Frozen object; every value is a dot-separated code string starting with `TESTING_E2E.`.
### `type TestingE2eCode`
Union of every `TESTING_E2E_ERROR_CODES[K]` value.
## Implementation Notes
The registry is built with `Object.freeze({ ... })` rather than `defineCodes` to keep the zero-workspace-dep invariant. Because each code is a plain string literal in the object, TypeScript's control-flow analysis narrows `err.code` correctly inside `instanceof TestingE2eError` branches. New codes append in source order; existing codes are never renumbered or reused.
### Registry Layout
Codes live under the `TESTING.E2E` namespace and are produced by `defineCodes`. Current codes cover clipboard-mock setup failures, geolocation-mock validation failures, page-object navigation timeouts, dialog-dismissal races, and fixture-extension errors. The `TestingE2eCode` union is the canonical parameter type for error-handler signatures.
### Stability
New codes are added append-only; existing values never change. Consumers that type-narrow on `error.code` using the exhaustive `TestingE2eCode` union get a compile-time error when they miss a new variant only if they use a `switch` with a `never` default branch.
### CLOAK domain
The `TESTING_E2E.CLOAK.*` family covers failures originating in the
`cloakbrowser`-backed browser launcher used by the default fixtures:
- `CLOAK_LAUNCH_FAILED` — generic launch failure (default classification).
- `CLOAK_BINARY_MISSING` — stealth Chromium binary not found or
could not be downloaded. Remediation: `npx cloakbrowser install`.
- `CLOAK_PROXY_INVALID` — proxy URL malformed, refused, or
authentication failed.
These codes pair with the `CloakLaunchError` class and the
`classifyCloakError` / `wrapCloakError` helpers exported from
`@suaveplan/testing-e2e`.

View File

@@ -0,0 +1,20 @@
/**
* Stable error-code constants for `@suaveplan/testing-e2e`.
*/
export const TESTING_E2E_ERROR_CODES = {
CONFIG_INVALID: "TESTING_E2E.CONFIG.INVALID",
NAVIGATION_FAILED: "TESTING_E2E.NAVIGATION.FAILED",
WAIT_TIMEOUT: "TESTING_E2E.WAIT.TIMEOUT",
FIXTURE_SETUP_FAILED: "TESTING_E2E.FIXTURE.SETUP_FAILED",
ABORTED: "TESTING_E2E.ABORTED",
CLOAK_LAUNCH_FAILED: "TESTING_E2E.CLOAK.LAUNCH_FAILED",
CLOAK_BINARY_MISSING: "TESTING_E2E.CLOAK.BINARY_MISSING",
CLOAK_PROXY_INVALID: "TESTING_E2E.CLOAK.PROXY_INVALID",
} as const;
export type TestingE2eErrorCode =
(typeof TESTING_E2E_ERROR_CODES)[keyof typeof TESTING_E2E_ERROR_CODES];
export const TESTING_E2E_ERROR_CODE_PATTERN =
/^TESTING_E2E\.[A-Z]+\.[A-Z_]+$|^TESTING_E2E\.ABORTED$/;

View File

@@ -0,0 +1,17 @@
export {
TESTING_E2E_ERROR_CODE_PATTERN,
TESTING_E2E_ERROR_CODES,
type TestingE2eErrorCode,
} from "./codes.js";
export {
AbortedError,
CloakLaunchError,
ConfigError,
classifyCloakError,
FixtureError,
NavigationError,
TestingE2eError,
type TestingE2eErrorOptions,
WaitTimeoutError,
wrapCloakError,
} from "./testing-e2e-error.js";

View File

@@ -0,0 +1,94 @@
# testing-e2e-error
## Purpose
`TestingE2eError` is the structured error class thrown by every helper, fixture, and page object in `@suaveplan/testing-e2e`. Its shape mirrors `SuaveplanError` — `{ code, cause?, context? }` — but it does NOT extend it, because testing-infrastructure packages must stay free of any `@suaveplan/*` workspace dependency (rule #8 in `CLAUDE.md`).
## Features
- `code` narrowed to `TestingE2eCode` (the literal union of `TESTING_E2E.*` strings)
- Named subclasses (`ConfigError`, `NavigationError`, `WaitTimeoutError`, `FixtureError`, `AbortedError`) for targeted `instanceof` checks
- Preserves `cause` via ES2022 `Error.cause`
- Optional `context` for structured diagnostic fields
## Usage: Basic Example
```ts
import { WaitTimeoutError, TESTING_E2E_ERROR_CODES } from "@suaveplan/testing-e2e";
throw new WaitTimeoutError(
TESTING_E2E_ERROR_CODES.WAIT_TIMEOUT,
"waitForNetworkIdle: network did not settle within 30000ms"
);
```
## Usage: Advanced Example
```ts
import {
WaitTimeoutError,
NavigationError,
TestingE2eError,
TESTING_E2E_ERROR_CODES,
} from "@suaveplan/testing-e2e";
async function safeNavigate(url: string, page: Page): Promise<void> {
try {
await page.goto(url, { timeout: 10_000 });
} catch (cause) {
throw new NavigationError(
TESTING_E2E_ERROR_CODES.NAVIGATION_FAILED,
`navigation to ${url} failed`,
{ cause, context: { url } }
);
}
}
try {
await safeNavigate("https://example.com", page);
} catch (err) {
if (err instanceof TestingE2eError) {
console.error(err.code, err.context, err.cause);
}
throw err;
}
```
## API Reference
### `class TestingE2eError extends Error`
- `readonly code: TestingE2eCode`
- `readonly context?: Readonly<Record<string, unknown>>`
- `cause?: unknown` (from `Error.cause`)
### Subclasses
- `ConfigError` — config-factory rejections
- `NavigationError` — `page.goto` / routing failures
- `WaitTimeoutError` — helper wait timeouts
- `FixtureError` — fixture setup/teardown failures
- `AbortedError` — signal-driven cancellations
- `CloakLaunchError` — `cloakbrowser` launcher failures (binary
missing, proxy invalid, generic launch failure)
### Cloak helpers
- `classifyCloakError(err) → TestingE2eErrorCode` — pure synchronous
classifier mapping raw errors thrown by `cloakbrowser` to one of the
three `TESTING_E2E.CLOAK.*` codes. Returns `LAUNCH_FAILED` for any
error the classifier does not specifically recognise.
- `wrapCloakError(err, context?) → CloakLaunchError` — convenience
wrapper used by every cloakbrowser-backed fixture. Classifies the
raw error, appends a remediation hint where applicable
(`npx cloakbrowser install` for binary issues, proxy verification
for proxy errors), and preserves the original error via
`Error.cause`.
## Implementation Notes
The subclasses exist so callers can write `catch (err instanceof WaitTimeoutError)` in hot paths without a `switch` on `code`. They all share the same structural shape — the subclass is purely a type-narrowing aid, not a behavioural difference. The base class deliberately does not extend `SuaveplanError` — if the project ever relaxes the testing-infra dep rule, swapping the base is a one-line change and no caller breaks.
### Subclass Index
`TestingE2eError` is the base class. Subclasses cover `ClipboardMockError`, `GeolocationMockError`, `PageObjectTimeoutError`, and `DialogDismissalError`. Every subclass sets its own code from `TESTING_E2E_CODES`, overrides `name`, and inherits wire-serialisation + OTel span-event behaviour from `SuaveplanError` so Playwright trace viewers see a consistent error shape.

View File

@@ -0,0 +1,185 @@
import { describe, expect, it } from "vitest";
import {
TESTING_E2E_ERROR_CODE_PATTERN,
TESTING_E2E_ERROR_CODES,
} from "./codes.js";
import {
AbortedError,
CloakLaunchError,
ConfigError,
classifyCloakError,
FixtureError,
NavigationError,
TestingE2eError,
WaitTimeoutError,
wrapCloakError,
} from "./testing-e2e-error.js";
describe("TestingE2eError base", () => {
it("stores code, message, cause, context", { timeout: 5000 }, () => {
const cause = new Error("u");
const err = new TestingE2eError(
TESTING_E2E_ERROR_CODES.NAVIGATION_FAILED,
"nav fail",
{ cause, context: { path: "/x" } }
);
expect(err.code).toBe("TESTING_E2E.NAVIGATION.FAILED");
expect(err.message).toBe("nav fail");
expect(err.cause).toBe(cause);
expect(err.context).toEqual({ path: "/x" });
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(TestingE2eError);
});
it("omits context/cause when not provided", { timeout: 5000 }, () => {
const err = new TestingE2eError(
TESTING_E2E_ERROR_CODES.WAIT_TIMEOUT,
"x"
);
expect(err.cause).toBeUndefined();
expect(err.context).toBeUndefined();
});
});
describe("Subclasses", () => {
it("each preserves instanceof TestingE2eError", { timeout: 5000 }, () => {
const cases = [
new ConfigError(TESTING_E2E_ERROR_CODES.CONFIG_INVALID, "x"),
new NavigationError(TESTING_E2E_ERROR_CODES.NAVIGATION_FAILED, "x"),
new WaitTimeoutError(TESTING_E2E_ERROR_CODES.WAIT_TIMEOUT, "x"),
new FixtureError(TESTING_E2E_ERROR_CODES.FIXTURE_SETUP_FAILED, "x"),
];
for (const e of cases) expect(e).toBeInstanceOf(TestingE2eError);
});
it("AbortedError hardcodes ABORTED code", { timeout: 5000 }, () => {
expect(new AbortedError("x").code).toBe(
TESTING_E2E_ERROR_CODES.ABORTED
);
});
});
describe("Code pattern", () => {
it("every value matches the pattern", { timeout: 5000 }, () => {
for (const code of Object.values(TESTING_E2E_ERROR_CODES)) {
expect(code).toMatch(TESTING_E2E_ERROR_CODE_PATTERN);
}
});
it("registers CLOAK_* codes", { timeout: 5000 }, () => {
expect(TESTING_E2E_ERROR_CODES.CLOAK_LAUNCH_FAILED).toBe(
"TESTING_E2E.CLOAK.LAUNCH_FAILED"
);
expect(TESTING_E2E_ERROR_CODES.CLOAK_BINARY_MISSING).toBe(
"TESTING_E2E.CLOAK.BINARY_MISSING"
);
expect(TESTING_E2E_ERROR_CODES.CLOAK_PROXY_INVALID).toBe(
"TESTING_E2E.CLOAK.PROXY_INVALID"
);
});
});
describe("CloakLaunchError", () => {
it("is a TestingE2eError subclass with named brand", {
timeout: 5000,
}, () => {
const err = new CloakLaunchError(
TESTING_E2E_ERROR_CODES.CLOAK_LAUNCH_FAILED,
"boom"
);
expect(err).toBeInstanceOf(TestingE2eError);
expect(err.name).toBe("CloakLaunchError");
expect(err.code).toBe("TESTING_E2E.CLOAK.LAUNCH_FAILED");
});
it("CloakLaunchError exposes the LAUNCH_FAILED code by default", {
timeout: 5000,
}, () => {
const wrapped = wrapCloakError(new Error("kaboom"));
expect(wrapped).toBeInstanceOf(CloakLaunchError);
expect(wrapped.code).toBe("TESTING_E2E.CLOAK.LAUNCH_FAILED");
expect(wrapped.message).toContain("cloakbrowser launch failed");
expect(wrapped.message).toContain("kaboom");
});
});
describe("classifyCloakError", () => {
it("classifyCloakError maps ENOENT to BINARY_MISSING", {
timeout: 5000,
}, () => {
expect(classifyCloakError(new Error("ENOENT: missing"))).toBe(
"TESTING_E2E.CLOAK.BINARY_MISSING"
);
expect(
classifyCloakError(new Error("Failed to download chromium"))
).toBe("TESTING_E2E.CLOAK.BINARY_MISSING");
expect(classifyCloakError(new Error("binary not found in cache"))).toBe(
"TESTING_E2E.CLOAK.BINARY_MISSING"
);
expect(classifyCloakError("no such file or directory")).toBe(
"TESTING_E2E.CLOAK.BINARY_MISSING"
);
expect(classifyCloakError(new Error("cloakbrowser/bin x"))).toBe(
"TESTING_E2E.CLOAK.BINARY_MISSING"
);
});
it("maps proxy hints to PROXY_INVALID", { timeout: 5000 }, () => {
for (const m of [
"ECONNREFUSED 127.0.0.1:1080",
"proxy authentication required",
"Invalid proxy: malformed url",
"ERR_TUNNEL_CONNECTION_FAILED",
"ERR_PROXY_CONNECTION_FAILED at hop",
]) {
expect(classifyCloakError(new Error(m))).toBe(
"TESTING_E2E.CLOAK.PROXY_INVALID"
);
}
});
it("defaults to LAUNCH_FAILED for unknown errors", {
timeout: 5000,
}, () => {
expect(classifyCloakError(new Error("kaboom"))).toBe(
"TESTING_E2E.CLOAK.LAUNCH_FAILED"
);
expect(classifyCloakError(42)).toBe("TESTING_E2E.CLOAK.LAUNCH_FAILED");
expect(classifyCloakError(undefined)).toBe(
"TESTING_E2E.CLOAK.LAUNCH_FAILED"
);
});
it("wrapCloakError includes remediation hint for BINARY_MISSING", {
timeout: 5000,
}, () => {
const e = wrapCloakError(new Error("ENOENT"));
expect(e.code).toBe("TESTING_E2E.CLOAK.BINARY_MISSING");
expect(e.message).toContain("npx cloakbrowser install");
});
it("wrapCloakError includes remediation hint for PROXY_INVALID", {
timeout: 5000,
}, () => {
const e = wrapCloakError(new Error("ECONNREFUSED"));
expect(e.code).toBe("TESTING_E2E.CLOAK.PROXY_INVALID");
expect(e.message).toContain("verify proxy URL credentials");
});
it("wrapCloakError accepts a string error and preserves cause", {
timeout: 5000,
}, () => {
const e = wrapCloakError("plain string error", { hint: true });
expect(e.cause).toBe("plain string error");
expect(e.context).toEqual({ hint: true });
expect(e.message).toContain("plain string error");
});
it("wrapCloakError handles non-string non-Error causes", {
timeout: 5000,
}, () => {
const e = wrapCloakError(42);
expect(e.cause).toBe(42);
expect(e.message).toContain("unknown error");
});
});

View File

@@ -0,0 +1,131 @@
/**
* Structured error hierarchy for `@suaveplan/testing-e2e`.
*
* Mirrors the shape of `@suaveplan/testing`'s `TestingError` with
* `TESTING_E2E.<DOMAIN>.<REASON>` codes. Local because Layer 0
* testing-infrastructure packages forbid `@suaveplan/*` workspace deps.
*/
import type { TestingE2eErrorCode } from "./codes.js";
export interface TestingE2eErrorOptions {
readonly cause?: unknown;
readonly context?: Record<string, unknown>;
}
export class TestingE2eError extends Error {
public override readonly name: string = "TestingE2eError";
public readonly code: TestingE2eErrorCode;
public override readonly cause: unknown;
public readonly context: Record<string, unknown> | undefined;
constructor(
code: TestingE2eErrorCode,
message: string,
options: TestingE2eErrorOptions = {}
) {
super(message);
this.code = code;
this.cause = options.cause;
this.context = options.context;
Object.setPrototypeOf(this, new.target.prototype);
}
}
export class ConfigError extends TestingE2eError {
public override readonly name = "ConfigError";
}
export class NavigationError extends TestingE2eError {
public override readonly name = "NavigationError";
}
export class WaitTimeoutError extends TestingE2eError {
public override readonly name = "WaitTimeoutError";
}
export class FixtureError extends TestingE2eError {
public override readonly name = "FixtureError";
}
export class AbortedError extends TestingE2eError {
public override readonly name = "AbortedError";
constructor(message: string, options?: TestingE2eErrorOptions) {
super("TESTING_E2E.ABORTED", message, options);
}
}
/**
* Raised when a `cloakbrowser` launch (any flavour: `launch`,
* `launchContext`, `launchPersistentContext`) fails. `code` carries the
* specific reason classified by {@link classifyCloakError}; `cause`
* holds the original error thrown by cloakbrowser or its underlying
* playwright-core driver.
*/
export class CloakLaunchError extends TestingE2eError {
public override readonly name = "CloakLaunchError";
}
const BINARY_HINTS = [
"binary not found",
"ENOENT",
"Failed to download",
"cloakbrowser/bin",
"no such file",
] as const;
const PROXY_HINTS = [
"ECONNREFUSED",
"proxy authentication required",
"Invalid proxy",
"ERR_TUNNEL_CONNECTION_FAILED",
"ERR_PROXY_CONNECTION_FAILED",
] as const;
/**
* Map a raw error thrown by cloakbrowser to one of the three CLOAK
* codes. Returns `LAUNCH_FAILED` as the default when no classifier
* matches. Pure and synchronous — caller is responsible for wrapping
* the result into a {@link CloakLaunchError}.
*/
export function classifyCloakError(err: unknown): TestingE2eErrorCode {
const message =
err instanceof Error ? err.message : typeof err === "string" ? err : "";
if (BINARY_HINTS.some((h) => message.includes(h))) {
return "TESTING_E2E.CLOAK.BINARY_MISSING";
}
if (PROXY_HINTS.some((h) => message.includes(h))) {
return "TESTING_E2E.CLOAK.PROXY_INVALID";
}
return "TESTING_E2E.CLOAK.LAUNCH_FAILED";
}
/**
* Convenience helper: wrap an arbitrary error into a
* {@link CloakLaunchError} with a classified code. Used by the
* cloakbrowser-backed fixtures so every launch failure surfaces as a
* structured `TestingE2eError`.
*/
export function wrapCloakError(
err: unknown,
context?: Record<string, unknown>
): CloakLaunchError {
const code = classifyCloakError(err);
const original =
err instanceof Error
? err.message
: typeof err === "string"
? err
: "unknown error";
const remediation =
code === "TESTING_E2E.CLOAK.BINARY_MISSING"
? " (run `npx cloakbrowser install` to fetch the stealth Chromium binary)"
: code === "TESTING_E2E.CLOAK.PROXY_INVALID"
? " (verify proxy URL credentials and reachability)"
: "";
return new CloakLaunchError(
code,
`cloakbrowser launch failed: ${original}${remediation}`,
context === undefined ? { cause: err } : { cause: err, context }
);
}