diff --git a/package.json b/package.json index 2ae4c8ec..4c70e7f1 100644 --- a/package.json +++ b/package.json @@ -197,6 +197,7 @@ "test:browser-artifact-session": "tsx tests/browser-artifact-session.test.ts", "test:browser-environment-matrix": "tsx --test tests/browser-environment-matrix.test.ts tests/browser-environment-matrix.browser.test.ts", "test:browser-actions-environment": "tsx --test tests/browser-actions-environment.browser.test.ts", + "test:browser-transport-faults": "tsx --test tests/browser-transport-fault-scenarios.browser.test.ts", "test:browser-recipe-file-payloads-integration": "tsx tests/browser-recipe-file-payloads.integration.test.ts", "test:browser-diagnostic-providers": "tsx tests/browser-diagnostic-providers.test.ts", "test:browser-redirect-diagnostics": "tsx tests/browser-redirect-diagnostics.test.ts", diff --git a/packages/runtime-core/src/command-registry.ts b/packages/runtime-core/src/command-registry.ts index fcb824fe..d0859334 100644 --- a/packages/runtime-core/src/command-registry.ts +++ b/packages/runtime-core/src/command-registry.ts @@ -1213,6 +1213,7 @@ export const commandRegistry = [ { name: "auth", description: "Optional in-memory browser authentication mode. Use wordpress-admin to bootstrap WordPress admin cookies from PHP without writing token-bearing storage-state artifacts.", format: "wordpress-admin" }, { name: "auth-user-id", description: "WordPress user ID used with auth=wordpress-admin; defaults to 1.", format: "positive integer" }, { name: "storage-state", description: "Optional Playwright storageState JSON, or @ to JSON, imported into a fresh browser context. Summaries redact cookie/localStorage values and report only schema/kind, counts, hosts, and diagnostics.", format: "JSON object or @path" }, + { name: "transport-faults-json", description: "Optional canonical wp-codebox/transport-fault-model/v1 schedule applied only to HTTP(S) requests emitted by this isolated browser context. Server-side WordPress HTTP API requests are not intercepted.", format: "JSON object or @path" }, { name: "capture", description: "Comma-separated artifacts to capture after interactions.", format: "steps,console,errors,html,network,screenshot,dom-snapshot" }, { name: "max-dom-snapshot-elements", description: "Maximum visible elements captured in each screenshot sidecar DOM/style snapshot; defaults to 160.", format: "positive integer" }, ...browserActionEnvironmentAcceptedArgs, @@ -1240,6 +1241,7 @@ export const commandRegistry = [ { name: "step-timeout", description: "Per-step timeout applied to action steps.", format: "duration, e.g. 5s or 500ms" }, { name: "timeout", description: "Total action timeout bounding the interaction run.", format: "duration, e.g. 30s or 1500ms" }, { name: "storage-state", description: "Optional Playwright storageState JSON, or @ to JSON, shared by scenario probe collection and authored actions.", format: "JSON object or @path" }, + { name: "transport-faults-json", description: "Optional canonical wp-codebox/transport-fault-model/v1 schedule applied before navigation to HTTP(S) requests emitted by this isolated browser context. The same model may be supplied as scenario-json.transportFaults.", format: "JSON object or @path" }, ...browserActionEnvironmentAcceptedArgs.filter((arg) => !["device", "viewport", "locale"].includes(arg.name)), ], outputShape: "JSON scenario summary with requested, provider-resolved, and browser-observed environment metadata plus files/browser/scenario-summary.json, preserving lower-level browser-probe and browser-actions summaries when used.", diff --git a/packages/runtime-core/src/transport-faults.ts b/packages/runtime-core/src/transport-faults.ts index 99d104c3..268a6984 100644 --- a/packages/runtime-core/src/transport-faults.ts +++ b/packages/runtime-core/src/transport-faults.ts @@ -5,6 +5,7 @@ import { stableJson, stripUndefined } from "./object-utils.js" export const TRANSPORT_FAULT_MODEL_SCHEMA = "wp-codebox/transport-fault-model/v1" as const export const TRANSPORT_FAULT_CAPABILITIES_SCHEMA = "wp-codebox/transport-fault-capabilities/v1" as const export const TRANSPORT_FAULT_EVIDENCE_SCHEMA = "wp-codebox/transport-fault-evidence/v1" as const +export const TRANSPORT_FAULT_SAFE_SCHEDULE_SCHEMA = "wp-codebox/transport-fault-safe-schedule/v1" as const export type TransportFaultSemantic = | "response-substitution" @@ -34,6 +35,7 @@ export interface TransportRequestMatcher { } export interface TransportFaultOutcome { + passthrough?: true status?: number headers?: Record body?: string @@ -130,6 +132,25 @@ export interface TransportFaultEvidence { } } +export interface TransportFaultSafeSchedule { + schema: typeof TRANSPORT_FAULT_SAFE_SCHEDULE_SCHEMA + seed: string + structuralFingerprint: string + rules: Array<{ + id: string + match: Omit & { headerNames?: string[] } + sequence: Array & { + headerNames?: string[] + body?: { encoding: "utf8" | "base64"; bytes: number; redacted: true } + metadataRedacted?: true + }> + repeat?: TransportFaultRule["repeat"] + metadataRedacted?: true + }> + redactHeaders?: string[] + metadataRedacted?: true +} + export class TransportFaultEngine { readonly model: TransportFaultModel readonly capabilities: TransportFaultCapabilities @@ -164,8 +185,11 @@ export class TransportFaultEngine { const fidelity = decision ? decision.semantics.map((semantic) => this.capabilities.capabilities.find((item) => item.semantic === semantic) ?? { semantic, fidelity: "unsupported" as const, reason: "Adapter did not declare this semantic." }) : [] - const requestHeaders = redactTransportHeaders(request.headers ?? {}, this.model.redactHeaders) - const responseHeaders = response?.headers ? redactTransportHeaders(response.headers, this.model.redactHeaders) : undefined + const rule = decision ? this.model.rules.find(({ id }) => id === decision.ruleId) : undefined + const declaredHeaders = [...Object.keys(rule?.match.headers ?? {}), ...Object.keys(decision?.outcome.headers ?? {})] + const redactedHeaders = [...(this.model.redactHeaders ?? []), ...declaredHeaders] + const requestHeaders = redactTransportHeaders(request.headers ?? {}, redactedHeaders) + const responseHeaders = response?.headers ? redactTransportHeaders(response.headers, redactedHeaders) : undefined const evidence = stripUndefined({ schema: TRANSPORT_FAULT_EVIDENCE_SCHEMA, fingerprint: transportFaultFingerprint({ adapter: this.capabilities.adapter, request: { url: redactTransportUrl(request.url), method: request.method.toUpperCase() }, fault: decision ? { ruleId: decision.ruleId, sequenceIndex: decision.sequenceIndex, semantics: decision.semantics } : undefined, response: response ? { status: response.status, connection: response.connection } : undefined }), @@ -186,6 +210,7 @@ export class TransportFaultEngine { } export function transportFaultModel(input: Omit & { schema?: string }): TransportFaultModel { + if (input.schema !== undefined && input.schema !== TRANSPORT_FAULT_MODEL_SCHEMA) throw new Error(`Transport fault model schema must be ${TRANSPORT_FAULT_MODEL_SCHEMA}.`) if (!input.seed) throw new Error("Transport fault model requires a non-empty seed.") const ids = new Set() const rules = input.rules.map((rule) => { @@ -204,6 +229,30 @@ export function transportFaultCapabilities(adapter: string, capabilities: readon return { schema: TRANSPORT_FAULT_CAPABILITIES_SCHEMA, adapter, capabilities: [...bySemantic.values()].sort((left, right) => left.semantic.localeCompare(right.semantic)) } } +export function transportFaultSafeSchedule(modelInput: TransportFaultModel): TransportFaultSafeSchedule { + const model = transportFaultModel(modelInput) + const safe = stripUndefined({ + schema: TRANSPORT_FAULT_SAFE_SCHEDULE_SCHEMA, + seed: model.seed, + rules: model.rules.map((rule) => stripUndefined({ + id: rule.id, + match: stripUndefined({ + host: rule.match.host, + method: rule.match.method, + path: rule.match.path, + pathPattern: rule.match.pathPattern, + headerNames: rule.match.headers ? Object.keys(rule.match.headers).map((name) => name.toLowerCase()).sort() : undefined, + }), + sequence: rule.sequence.map((outcome) => safeTransportFaultOutcome(outcome)), + repeat: rule.repeat, + metadataRedacted: rule.metadata ? true as const : undefined, + })), + redactHeaders: model.redactHeaders ? [...model.redactHeaders].map((name) => name.toLowerCase()).sort() : undefined, + metadataRedacted: model.metadata ? true as const : undefined, + }) + return { ...safe, structuralFingerprint: transportFaultFingerprint(safe) } +} + export function negotiateTransportFaults(model: TransportFaultModel, capabilities: TransportFaultCapabilities): TransportFaultNegotiation { const required = [...new Set(model.rules.flatMap((rule) => rule.sequence.flatMap(transportFaultOutcomeSemantics)))].sort() const unsupported = required.map((semantic) => capabilities.capabilities.find((item) => item.semantic === semantic) ?? { semantic, fidelity: "unsupported" as const, reason: "Adapter did not declare this semantic." }).filter((item) => item.fidelity === "unsupported") @@ -265,6 +314,8 @@ export function redactTransportUrl(raw: string): string { } function normalizeTransportFaultOutcome(outcome: TransportFaultOutcome): TransportFaultOutcome { + if (outcome.passthrough !== undefined && outcome.passthrough !== true) throw new Error("Transport fault outcome passthrough must be true when declared.") + if (outcome.passthrough && Object.entries(outcome).some(([name, value]) => value !== undefined && name !== "passthrough" && name !== "metadata")) throw new Error("Transport fault passthrough outcomes cannot declare another fault effect.") for (const [name, value] of Object.entries(outcome)) { if ((name.endsWith("Ms") || name.endsWith("Bytes") || name === "bandwidthBytesPerSecond") && typeof value === "number" && (!Number.isFinite(value) || value < 0)) { throw new Error(`Transport fault outcome ${name} must be a finite non-negative number.`) @@ -274,6 +325,21 @@ function normalizeTransportFaultOutcome(outcome: TransportFaultOutcome): Transpo return { ...outcome, headers: outcome.headers ? normalizeHeaders(outcome.headers) : undefined } } +function safeTransportFaultOutcome(outcome: TransportFaultOutcome): TransportFaultSafeSchedule["rules"][number]["sequence"][number] { + const { headers, body, bodyBase64, metadata, remapHost, ...safe } = outcome + return stripUndefined({ + ...safe, + remapHost: remapHost ? redactTransportUrl(remapHost) : undefined, + headerNames: headers ? Object.keys(headers).map((name) => name.toLowerCase()).sort() : undefined, + body: body !== undefined + ? { encoding: "utf8" as const, bytes: Buffer.byteLength(body), redacted: true as const } + : bodyBase64 !== undefined + ? { encoding: "base64" as const, bytes: Buffer.from(bodyBase64, "base64").byteLength, redacted: true as const } + : undefined, + metadataRedacted: metadata ? true as const : undefined, + }) +} + function faultSequenceIndex(rule: TransportFaultRule, invocation: number): number | undefined { if (invocation < rule.sequence.length) return invocation if ((rule.repeat ?? "last") === "cycle") return invocation % rule.sequence.length diff --git a/packages/runtime-playground/src/browser-actions-runner.ts b/packages/runtime-playground/src/browser-actions-runner.ts index 5b62f1f0..f9dea3d1 100644 --- a/packages/runtime-playground/src/browser-actions-runner.ts +++ b/packages/runtime-playground/src/browser-actions-runner.ts @@ -1,5 +1,5 @@ import { readFile, writeFile } from "node:fs/promises" -import { BROWSER_ACTION_CORPUS_SCHEMA, BROWSER_ADAPTIVE_EXPLORATION_SCHEMA, BROWSER_MULTI_ACTOR_SCENARIO_SCHEMA, BROWSER_PROBE_PROFILES, BROWSER_TOOL_VERIFIER_RESULT_SCHEMA, HostToolRegistry, assertRuntimeCommandAllowed, browserActionCorpusArtifact, browserActionCorpusContract, browserAdaptiveExplorationContract, browserEnvironment, browserEnvironmentDigest, browserGeolocation, browserInteractionScriptUsesEvaluate, browserToolVerifierInputSummary, createHostToolRegistry, executeHostTool, resolveCommandPath, validateBrowserInteractionScript, type BrowserActionCorpusArtifact, type BrowserActionCorpusContract, type BrowserAdaptiveExplorationArtifact, type BrowserAdaptiveExplorationContract, type BrowserEnvironment, type BrowserGeolocationPermissionState, type BrowserInteractionStep, type BrowserMultiActorScenario, type BrowserToolVerifierResult, type ExecutionSpec, type HostToolDefinition, type JsonValue, type RuntimeCreateSpec } from "@automattic/wp-codebox-core" +import { BROWSER_ACTION_CORPUS_SCHEMA, BROWSER_ADAPTIVE_EXPLORATION_SCHEMA, BROWSER_MULTI_ACTOR_SCENARIO_SCHEMA, BROWSER_PROBE_PROFILES, BROWSER_TOOL_VERIFIER_RESULT_SCHEMA, HostToolRegistry, assertRuntimeCommandAllowed, browserActionCorpusArtifact, browserActionCorpusContract, browserAdaptiveExplorationContract, browserEnvironment, browserEnvironmentDigest, browserGeolocation, browserInteractionScriptUsesEvaluate, browserToolVerifierInputSummary, createHostToolRegistry, executeHostTool, resolveCommandPath, transportFaultModel, validateBrowserInteractionScript, type BrowserActionCorpusArtifact, type BrowserActionCorpusContract, type BrowserAdaptiveExplorationArtifact, type BrowserAdaptiveExplorationContract, type BrowserEnvironment, type BrowserGeolocationPermissionState, type BrowserInteractionStep, type BrowserMultiActorScenario, type BrowserToolVerifierResult, type ExecutionSpec, type HostToolDefinition, type JsonValue, type RuntimeCreateSpec, type TransportFaultModel } from "@automattic/wp-codebox-core" import { now, sha256 } from "@automattic/wp-codebox-core/internals" import { browserInteractionStepsFromArgs, browserStepTimeoutMs, durationStringMs, sanitizeScreenshotName } from "./browser-actions.js" import { BrowserArtifactSession } from "./browser-artifact-session.js" @@ -12,7 +12,7 @@ import { browserAssertionsSummary, browserStepRecord, executeBrowserInteractionS import { browserCommandLivenessPolicy, isBrowserCommandLivenessError, withBrowserCommandLiveness } from "./browser-liveness.js" import { serializeBrowserError } from "./browser-metrics.js" import { executeBrowserObservationAssertion } from "./browser-observation-assertions.js" -import { browserPreviewCleanupErrorIsFatal, browserPreviewNetworkPolicyIsActive, browserPreviewNetworkPolicySummary, browserPreviewNeedsContextRouting, browserPreviewReadinessError, browserPreviewTopology, closeBrowserAndDrainPreviewRoutes, createBrowserPreviewRouteTracker, resolveBrowserPreviewUrl, routeBrowserPreviewContextNetwork } from "./browser-preview-routing.js" +import { browserPreviewCleanupErrorIsFatal, browserPreviewNetworkPolicyIsActive, browserPreviewNetworkPolicySummary, browserPreviewNeedsContextRouting, browserPreviewReadinessError, browserPreviewTopology, browserPreviewTransportFaultPolicy, closeBrowserAndDrainPreviewRoutes, createBrowserPreviewRouteTracker, resolveBrowserPreviewUrl, routeBrowserPreviewContextNetwork } from "./browser-preview-routing.js" import { browserCommandResult } from "./browser-result-sanitization.js" import { BROWSER_PROBE_STATE_INIT_SCRIPT, browserProbeReplayability, browserProbeViewport } from "./browser-probe.js" import { runBrowserProbeCommand, type BrowserProbeRunPlan } from "./browser-probe-runner.js" @@ -26,6 +26,7 @@ import { discoverBrowserActionCorpusDescriptors } from "./browser-action-discove import { exploreAdaptiveBrowserStateMachine } from "./browser-adaptive-explorer.js" import { createBrowserAccessibilityCollector } from "./browser-accessibility-collector.js" import { browserEnvironmentCell, createPlaywrightBrowserEnvironmentContext, observePlaywrightBrowserEnvironment, resolvePlaywrightBrowserEnvironment, type PlaywrightBrowserEnvironmentSession } from "./browser-environment-matrix.js" +import { installBrowserTransportFaults, type BrowserTransportFaultReport, type InstalledBrowserTransportFaults } from "./browser-transport-faults.js" export { discoverBrowserActionCorpusDescriptors } from "./browser-action-discovery.js" @@ -50,6 +51,7 @@ export interface BrowserActionsRunPlan { maxDomSnapshotElements: number actionCorpus?: BrowserActionCorpusContract adaptiveExploration?: BrowserAdaptiveExplorationContract + transportFaults?: TransportFaultModel reusePage?: boolean } @@ -154,6 +156,8 @@ export async function runBrowserActionsCommand({ let environmentEvidence: BrowserArtifact["summary"]["environment"] | undefined let resolvedEnvironment: Awaited> | undefined let activePage: Page | undefined + let installedTransportFaults: InstalledBrowserTransportFaults | undefined + let transportFaultReport: BrowserTransportFaultReport | undefined try { const previewReadinessError = browserPreviewReadinessError(preview) @@ -173,17 +177,19 @@ export async function runBrowserActionsCommand({ if (unsupportedEnvironment.length > 0) { throw new Error(`wordpress.browser-actions browser environment is unsupported: ${unsupportedEnvironment.join(", ")}`) } - const needsEnvironmentContext = Object.keys(requestedEnvironment).length > 0 || browserPreviewNeedsContextRouting(networkPolicy) || !!storageStateImport + const needsEnvironmentContext = Object.keys(requestedEnvironment).length > 0 || browserPreviewNeedsContextRouting(networkPolicy) || !!storageStateImport || !!runPlan.transportFaults environmentRuntime = session?.runtime ?? (needsEnvironmentContext ? await createPlaywrightBrowserEnvironmentContext(browser, resolvedEnvironment, { contextOptions: { ...topology.contextOptions(), ...(storageStateImport ? { storageState: storageStateImport.storageState } : {}), + ...(runPlan.transportFaults ? { serviceWorkers: "block" as const } : {}), }, }) : undefined) const context = environmentRuntime?.context ?? null if (context && !session) { await routeBrowserPreviewContextNetwork(context, networkPolicy, topology.origins.localProxyOrigin, routeTracker) } + if (context && runPlan.transportFaults) installedTransportFaults = await installBrowserTransportFaults(context, runPlan.transportFaults, { policy: browserPreviewTransportFaultPolicy(networkPolicy, topology.origins.localProxyOrigin), serviceWorkersBlocked: true }) const page = activePage = environmentRuntime?.page ?? await browser.newPage() if (onProgress) { await page.exposeFunction("__wpCodeboxProbeCheckpointEvent", (checkpoint: unknown) => { @@ -447,6 +453,13 @@ export async function runBrowserActionsCommand({ errors.push(serializeBrowserError("probe-error", error)) } finally { await settleBrowserNetworkTasks(networkTasks, livenessPolicy.networkSettleTimeoutMs).catch((error) => errors.push(serializeBrowserError("probe-error", error))) + if (installedTransportFaults) { + await installedTransportFaults.dispose().then((report) => { transportFaultReport = report }).catch((error) => { + const cleanupError = error instanceof Error ? error : new Error(String(error)) + errors.push(serializeBrowserError("probe-error", cleanupError)) + pendingError ??= cleanupError + }) + } if (activePage && resolvedEnvironment) environmentEvidence = await observePlaywrightBrowserEnvironment(activePage, requestedEnvironment, resolvedEnvironment).catch(() => environmentEvidence) const cleanupBrowser = session ? { close: async () => {} } : { close: async () => { await environmentRuntime?.close(); await browser.close() } } for (const routeError of await closeBrowserAndDrainPreviewRoutes(cleanupBrowser, routeTracker)) { @@ -470,6 +483,7 @@ export async function runBrowserActionsCommand({ if (capture.has("websocket")) { await artifactSession.writeJson("websocket", "websocket.json", browserProbeWebSocketArtifact(webSockets, startedAt)) } + if (transportFaultReport) await artifactSession.writeJson("transportFaults", "transport-faults.json", transportFaultReport) const redirectDiagnostics = browserRedirectDiagnosticsArtifact({ artifactPath: "files/browser/redirect-diagnostics.json", @@ -519,6 +533,7 @@ export async function runBrowserActionsCommand({ ...(actionCorpusArtifact ? { actionCorpus: "files/browser/action-corpus.json" } : {}), ...(adaptiveExplorationArtifact ? { adaptiveExploration: "files/browser/adaptive-exploration.json" } : {}), ...(wordpressDiagnostics ? { wordpressDiagnostics: "files/browser/wordpress-diagnostics.json" } : {}), + ...(transportFaultReport ? { transportFaults: "files/browser/transport-faults.json" } : {}), summary: "files/browser/action-summary.json", }, summary: { @@ -540,6 +555,7 @@ export async function runBrowserActionsCommand({ ...(capture.has("websocket") ? { webSockets: browserProbeWebSocketSummary(webSockets) } : {}), ...(redirectDiagnosticsSummary ? { redirectDiagnostics: redirectDiagnosticsSummary } : {}), ...(wordpressDiagnosticsSummary ? { wordpressDiagnostics: wordpressDiagnosticsSummary } : {}), + ...(transportFaultReport ? { transportFaults: browserTransportFaultSummary(transportFaultReport) } : {}), replayability: browserProbeReplayability(capture), screenshot: capture.has("screenshot"), auth: authSummary, @@ -571,6 +587,7 @@ export async function runBrowserActionsCommand({ }, ...(redirectDiagnosticsSummary ? { redirectDiagnostics: redirectDiagnosticsSummary } : {}), ...(wordpressDiagnosticsSummary ? { wordpressDiagnostics: wordpressDiagnosticsSummary } : {}), + ...(transportFaultReport ? { transportFaults: browserTransportFaultSummary(transportFaultReport) } : {}), ...(verifierResults.length > 0 ? { verifierResults } : {}), ...(actionCorpusArtifact ? { actionCorpus: actionCorpusArtifact.plan } : {}), ...(adaptiveExplorationArtifact ? { adaptiveExploration: adaptiveExplorationArtifact.result } : {}), @@ -722,6 +739,7 @@ async function browserActionsRunPlanFromArgs(args: string[], artifactRoot: strin maxDomSnapshotElements: positiveIntegerArg(args, "max-dom-snapshot-elements", 160), actionCorpus: browserActionCorpusFromArgs(args), adaptiveExploration: adaptiveExplorationPlan.contract, + transportFaults: await transportFaultModelFromArg(args), } } @@ -938,6 +956,7 @@ interface BrowserScenarioInput { actions?: BrowserMultiActorScenario["actions"] barriers?: BrowserMultiActorScenario["barriers"] requestGates?: BrowserMultiActorScenario["requestGates"] + transportFaults?: TransportFaultModel } export async function runBrowserScenarioCommand({ @@ -955,9 +974,11 @@ export async function runBrowserScenarioCommand({ }): Promise<{ artifact: BrowserArtifact; output: string }> { const args = spec.args ?? [] const scenario = await browserScenarioFromArgs(args) + const scenarioTransportFaults = await browserScenarioTransportFaults(scenario, args) const requestedEnvironment = await browserScenarioEnvironment(scenario, args) const multiActorScenario = browserMultiActorScenario(scenario, args, requestedEnvironment) if (multiActorScenario) { + if (scenarioTransportFaults) throw new Error("Multi-actor wordpress.browser-scenario does not support transport faults; browser fault schedules require one isolated browser context per scenario.") return runBrowserMultiActorScenarioCommand({ artifactRoot, scenario: multiActorScenario, runtimeSpec, runPlaygroundCommand, server }) } const url = scenario.url?.trim() || argValue(args, "url")?.trim() @@ -975,9 +996,11 @@ export async function runBrowserScenarioCommand({ let scenarioSession: PlaywrightBrowserEnvironmentSession | undefined let scenarioBrowser: Awaited> | undefined let scenarioRouteTracker: ReturnType | undefined + let installedTransportFaults: InstalledBrowserTransportFaults | undefined + let transportFaultReport: BrowserTransportFaultReport | undefined try { - if (runPlan.probe && runPlan.actions) { + if ((runPlan.probe && runPlan.actions) || scenarioTransportFaults) { const browser = scenarioBrowser = await launchChromiumBrowser() const resolved = await resolvePlaywrightBrowserEnvironment(browserEnvironmentCell(requestedEnvironment), browser) const unsupported = resolved.capabilities.filter(({ fidelity }) => fidelity === "unsupported").map(({ id }) => id) @@ -988,11 +1011,13 @@ export async function runBrowserScenarioCommand({ const runtime = await createPlaywrightBrowserEnvironmentContext(browser, resolved, { contextOptions: { ...topology.contextOptions(), - ...(runPlan.actions.storageStateImport ? { storageState: runPlan.actions.storageStateImport.storageState } : {}), + ...((runPlan.actions?.storageStateImport ?? runPlan.probe?.storageStateImport) ? { storageState: (runPlan.actions?.storageStateImport ?? runPlan.probe?.storageStateImport)?.storageState } : {}), + ...(scenarioTransportFaults ? { serviceWorkers: "block" as const } : {}), }, }) const routeTracker = scenarioRouteTracker = createBrowserPreviewRouteTracker() if (browserPreviewNeedsContextRouting(topology.networkPolicy)) await routeBrowserPreviewContextNetwork(runtime.context, topology.networkPolicy, topology.origins.localProxyOrigin, routeTracker) + if (scenarioTransportFaults) installedTransportFaults = await installBrowserTransportFaults(runtime.context, scenarioTransportFaults, { policy: browserPreviewTransportFaultPolicy(topology.networkPolicy, topology.origins.localProxyOrigin), serviceWorkersBlocked: true }) scenarioSession = { browser, requested: requestedEnvironment, resolved, routeTracker, runtime } } @@ -1009,7 +1034,7 @@ export async function runBrowserScenarioCommand({ if (!pendingError && runPlan.actions) { try { - actionsResult = await runBrowserActionsCommand({ artifactRoot, plan: { ...runPlan.actions, reusePage: Boolean(scenarioSession) }, runtimeSpec, runPlaygroundCommand, server, spec: { ...spec, command: "wordpress.browser-actions", args }, session: scenarioSession }) + actionsResult = await runBrowserActionsCommand({ artifactRoot, plan: { ...runPlan.actions, reusePage: Boolean(scenarioSession && runPlan.probe) }, runtimeSpec, runPlaygroundCommand, server, spec: { ...spec, command: "wordpress.browser-actions", args }, session: scenarioSession }) } catch (error) { if (isBrowserCommandArtifactError(error) && error.artifact.artifactType === "actions") { actionsResult = { artifact: error.artifact, output: "" } @@ -1018,6 +1043,9 @@ export async function runBrowserScenarioCommand({ } } } finally { + if (installedTransportFaults) { + await installedTransportFaults.dispose().then((report) => { transportFaultReport = report }).catch((error) => { pendingError ??= error instanceof Error ? error : new Error(String(error)) }) + } if (scenarioSession && scenarioBrowser && scenarioRouteTracker) { const activeSession = scenarioSession const activeBrowser = scenarioBrowser @@ -1049,6 +1077,7 @@ export async function runBrowserScenarioCommand({ finishedAt: now(), context: primaryArtifact.summary.context, auth: primaryArtifact.summary.auth, + ...(transportFaultReport ? { transportFaults: browserTransportFaultSummary(transportFaultReport) } : {}), viewport: primaryArtifact.summary.viewport, files: { ...(probeResult ? { probeSummary: probeResult.artifact.files.summary } : {}), @@ -1062,6 +1091,7 @@ export async function runBrowserScenarioCommand({ auth: primaryArtifact.summary.auth, }, } + if (transportFaultReport) await artifactSession.writeJson("transportFaults", "transport-faults.json", transportFaultReport) await artifactSession.writeJson("summary", "scenario-summary.json", scenarioSummary) const artifact: BrowserArtifact = { @@ -1069,6 +1099,7 @@ export async function runBrowserScenarioCommand({ artifactType: "scenario", files: { ...primaryArtifact.files, + ...(transportFaultReport ? { transportFaults: "files/browser/transport-faults.json" } : {}), summary: "files/browser/scenario-summary.json", }, summary: { @@ -1079,6 +1110,7 @@ export async function runBrowserScenarioCommand({ context: primaryArtifact.summary.context, auth: primaryArtifact.summary.auth, finalUrl, + ...(transportFaultReport ? { transportFaults: browserTransportFaultSummary(transportFaultReport) } : {}), }, } @@ -1096,6 +1128,32 @@ export async function runBrowserScenarioCommand({ }) } +async function browserScenarioTransportFaults(scenario: BrowserScenarioInput, args: string[]): Promise { + if (scenario.transportFaults && argValue(args, "transport-faults-json")) throw new Error("wordpress.browser-scenario transport faults must be declared once, in scenario-json.transportFaults or transport-faults-json.") + return scenario.transportFaults ? transportFaultModel(scenario.transportFaults) : transportFaultModelFromArg(args) +} + +async function transportFaultModelFromArg(args: string[]): Promise { + const raw = argValue(args, "transport-faults-json") + if (!raw) return undefined + const text = raw.startsWith("@") ? await readFile(resolveCommandPath(raw.slice(1)), "utf8") : raw + const parsed = JSON.parse(text) as TransportFaultModel + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("transport-faults-json must be a JSON object") + return transportFaultModel(parsed) +} + +function browserTransportFaultSummary(report: BrowserTransportFaultReport): NonNullable { + return { + seed: report.seed, + adapter: report.adapter, + fidelity: report.fidelity, + matchedRequests: report.matchedRequests.length, + consumedSequenceEntries: report.consumedSequenceEntries.length, + unmatchedRules: report.unmatchedRules, + artifact: "files/browser/transport-faults.json", + } +} + async function browserScenarioFromArgs(args: string[]): Promise { const raw = argValue(args, "scenario-json") let scenario: BrowserScenarioInput = {} diff --git a/packages/runtime-playground/src/browser-artifacts.ts b/packages/runtime-playground/src/browser-artifacts.ts index 24b5a3d6..f8836ed4 100644 --- a/packages/runtime-playground/src/browser-artifacts.ts +++ b/packages/runtime-playground/src/browser-artifacts.ts @@ -1,5 +1,5 @@ import { join } from "node:path" -import { artifactManifestFile, type ArtifactManifestFile, type ArtifactManifestFileOptions, type ArtifactReviewBrowserSummary, type BrowserEnvironment, type BrowserEnvironmentCapabilityResult, type BrowserGeolocation, type ResolvedBrowserEnvironment } from "@automattic/wp-codebox-core" +import { artifactManifestFile, type ArtifactManifestFile, type ArtifactManifestFileOptions, type ArtifactReviewBrowserSummary, type BrowserEnvironment, type BrowserEnvironmentCapabilityResult, type BrowserGeolocation, type ResolvedBrowserEnvironment, type TransportFaultCapability } from "@automattic/wp-codebox-core" import type { PlaygroundPreviewProxyDiagnostics } from "./preview-server.js" import type { Request } from "playwright" @@ -98,6 +98,7 @@ export interface BrowserArtifactFiles { blocksEngineVisualParity?: string | string[] redirectDiagnostics?: string wordpressDiagnostics?: string + transportFaults?: string summary: string } @@ -198,6 +199,15 @@ export interface BrowserArtifactSummary { review?: BrowserProbeReviewSummary redirectDiagnostics?: BrowserRedirectDiagnosticsSummary wordpressDiagnostics?: BrowserWordPressDiagnosticsSummary + transportFaults?: { + seed: string + adapter: string + fidelity: TransportFaultCapability[] + matchedRequests: number + consumedSequenceEntries: number + unmatchedRules: string[] + artifact: string + } context?: BrowserProbeContextDetails auth?: BrowserProbeAuthSummary multiActor?: { @@ -1173,6 +1183,7 @@ const BROWSER_ARTIFACT_FILE_MANIFEST: Record + fetch(route: Route, overrides: { url?: string; postData?: Buffer }): Promise> | undefined> + recordHandled(route: Route): void +} + export function createBrowserPreviewRouteTracker(): BrowserPreviewRouteTracker { return { pending: new Set(), errors: [], registrations: 0 } } @@ -331,6 +337,40 @@ export async function routeBrowserPreviewContextNetwork(context: import("playwri await routeBrowserPreviewNetwork(context.route.bind(context), policy, previewOrigin, tracker) } +export function browserPreviewTransportFaultPolicy(policy: BrowserPreviewNetworkPolicy, previewOrigin: string): BrowserPreviewTransportFaultPolicy { + const origin = new URL(previewOrigin) + return { + async preflight(route) { + const requestUrl = browserPreviewRouteUrl(route) + if (!requestUrl) return true + if (!browserPreviewRouteIsBlocked(requestUrl, route.request().resourceType(), policy)) return true + recordBrowserPreviewPolicyRequest(policy, requestUrl, "blocked") + await route.abort("blockedbyclient") + return false + }, + async fetch(route, overrides) { + const requestUrl = browserPreviewRouteUrl(route, overrides.url) + if (!requestUrl) return route.fetch(overrides) + if (browserPreviewRouteIsBlocked(requestUrl, route.request().resourceType(), policy)) { + recordBrowserPreviewPolicyRequest(policy, requestUrl, "blocked") + await route.abort("blockedbyclient") + return undefined + } + const routed = policy.routeOrigins.has(requestUrl.origin) || policy.routeHosts.has(normalizeBrowserPreviewHost(requestUrl.hostname)) + recordBrowserPreviewPolicyRequest(policy, requestUrl, routed ? "routed" : "handled") + return routed + ? fetchBrowserPreviewRoutedHost(route, requestUrl, policy, origin, !policy.routeOrigins.has(requestUrl.origin), overrides) + : route.fetch(overrides) + }, + recordHandled(route) { + const requestUrl = browserPreviewRouteUrl(route) + if (!requestUrl) return + const routed = policy.routeOrigins.has(requestUrl.origin) || policy.routeHosts.has(normalizeBrowserPreviewHost(requestUrl.hostname)) + recordBrowserPreviewPolicyRequest(policy, requestUrl, routed ? "routed" : "handled") + }, + } +} + export async function drainBrowserPreviewRouteTracker(tracker: BrowserPreviewRouteTracker, timeoutMs = BROWSER_PREVIEW_ROUTE_DRAIN_TIMEOUT_MS): Promise { const deadline = Date.now() + timeoutMs let observedRegistrations = -1 @@ -516,6 +556,31 @@ function browserPreviewNetworkPolicyHostStat(policy: BrowserPreviewNetworkPolicy return stat } +function browserPreviewRouteUrl(route: Route, override?: string): URL | undefined { + try { + return new URL(override ?? route.request().url()) + } catch { + return undefined + } +} + +function browserPreviewRouteIsBlocked(requestUrl: URL, resourceType: string, policy: BrowserPreviewNetworkPolicy): boolean { + const host = normalizeBrowserPreviewHost(requestUrl.hostname) + if (policy.blockHosts.has(host)) return true + const routed = policy.routeOrigins.has(requestUrl.origin) || policy.routeHosts.has(host) + const external = !policy.firstPartyHosts.has(host) + return !routed && (policy.preserveRoutedOrigin || (policy.mode === "block" && external && !policy.allowHosts.has(host)) || (resourceType === "document" && external)) +} + +function recordBrowserPreviewPolicyRequest(policy: BrowserPreviewNetworkPolicy, requestUrl: URL, outcome: "blocked" | "routed" | "handled"): void { + const host = normalizeBrowserPreviewHost(requestUrl.hostname) + const stat = browserPreviewNetworkPolicyHostStat(policy, host) + stat.requests += 1 + stat.external = !policy.firstPartyHosts.has(host) + if (outcome === "blocked") stat.blocked += 1 + if (outcome === "routed") stat.routed += 1 +} + function browserPreviewUrlHostname(url: string): string | undefined { try { return normalizeBrowserPreviewHost(new URL(url).hostname) @@ -564,7 +629,7 @@ function wait(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } -async function fetchBrowserPreviewRoutedHost(route: Route, requestUrl: URL, policy: BrowserPreviewNetworkPolicy, origin: URL, preserveRequestedAuthority: boolean): Promise> | undefined> { +async function fetchBrowserPreviewRoutedHost(route: Route, requestUrl: URL, policy: BrowserPreviewNetworkPolicy, origin: URL, preserveRequestedAuthority: boolean, overrides: { postData?: Buffer } = {}): Promise> | undefined> { let currentUrl = requestUrl for (let redirectCount = 0; redirectCount < 10; redirectCount++) { const routedUrl = new URL(currentUrl.toString()) @@ -581,6 +646,7 @@ async function fetchBrowserPreviewRoutedHost(route: Route, requestUrl: URL, poli try { response = await route.fetch({ url: routedUrl.toString(), + ...(overrides.postData ? { postData: overrides.postData } : {}), headers: { ...route.request().headers(), host: preserveRequestedAuthority ? currentUrl.host : origin.host, diff --git a/packages/runtime-playground/src/browser-transport-faults.ts b/packages/runtime-playground/src/browser-transport-faults.ts index 37a3f004..afe3f8dd 100644 --- a/packages/runtime-playground/src/browser-transport-faults.ts +++ b/packages/runtime-playground/src/browser-transport-faults.ts @@ -1,5 +1,8 @@ -import { TransportFaultEngine, negotiateTransportFaults, transportFaultCapabilities, type TransportFaultCapability, type TransportFaultDecision, type TransportFaultEvidence, type TransportFaultModel } from "@automattic/wp-codebox-core" -import type { Route } from "playwright" +import { TransportFaultEngine, negotiateTransportFaults, transportFaultCapabilities, transportFaultSafeSchedule, transportRequestMatches, type TransportFaultCapability, type TransportFaultDecision, type TransportFaultEvidence, type TransportFaultFidelity, type TransportFaultModel, type TransportFaultSafeSchedule } from "@automattic/wp-codebox-core" +import type { BrowserContext, Route } from "playwright" +import type { BrowserPreviewTransportFaultPolicy } from "./browser-preview-routing.js" + +const BROWSER_TRANSPORT_FAULT_DRAIN_TIMEOUT_MS = 5_000 const browserCapabilities: TransportFaultCapability[] = [ { semantic: "response-substitution", fidelity: "exact" }, @@ -24,22 +27,176 @@ export const BROWSER_TRANSPORT_FAULT_CAPABILITIES = transportFaultCapabilities(" export interface BrowserTransportFaultAdapter { engine: TransportFaultEngine negotiation: ReturnType - handle(route: Route): Promise + matches(route: Route): boolean + handle(route: Route, options?: BrowserTransportFaultApplyOptions): Promise evidence(): TransportFaultEvidence[] } +export interface BrowserTransportFaultReport { + schema: "wp-codebox/browser-transport-fault-report/v1" + interception: { + browserHttp: { fidelity: TransportFaultFidelity; scope: string; serviceWorkers: "blocked" | "not-enforced"; reason?: string } + wordpressHttp: { fidelity: "unsupported"; reason: string } + } + seed: string + schedule: TransportFaultSafeSchedule + adapter: string + fidelity: TransportFaultCapability[] + matchedRequests: TransportFaultEvidence[] + consumedSequenceEntries: Array<{ ruleId: string; sequenceIndex: number; invocation: number }> + unmatchedRules: string[] + teardown?: BrowserTransportFaultTeardown + replay: { schema: "wp-codebox/browser-transport-fault-replay/v1"; seed: string; structuralScheduleFingerprint: string; schedule: TransportFaultSafeSchedule; fidelity: "identity-only-redacted" } +} + +export interface BrowserTransportFaultTeardown { + status: "drained" | "timed-out" + timeoutMs: number + pendingHandlers: number + pendingRouteFetches: number + routeFetchCancellation: { + fidelity: "emulated" + reason: string + } +} + +export interface InstalledBrowserTransportFaults { + adapter: BrowserTransportFaultAdapter + inFlight(): number + report(): BrowserTransportFaultReport + dispose(): Promise +} + +export interface BrowserTransportFaultApplyOptions { + signal?: AbortSignal + fetch?: BrowserPreviewTransportFaultPolicy["fetch"] + recordHandled?: BrowserPreviewTransportFaultPolicy["recordHandled"] +} + +export interface BrowserTransportFaultInstallOptions { + policy?: BrowserPreviewTransportFaultPolicy + serviceWorkersBlocked?: boolean + drainTimeoutMs?: number +} + export function createBrowserTransportFaultAdapter(model: TransportFaultModel): BrowserTransportFaultAdapter { const engine = new TransportFaultEngine(model, BROWSER_TRANSPORT_FAULT_CAPABILITIES) const negotiation = negotiateTransportFaults(model, BROWSER_TRANSPORT_FAULT_CAPABILITIES) return { engine, negotiation, - async handle(route) { return await applyBrowserTransportFault(route, engine) }, + matches(route) { + const request = route.request() + const transportRequest = { url: request.url(), method: request.method(), headers: request.headers(), body: request.postDataBuffer() ?? undefined } + return engine.model.rules.some(({ match }) => transportRequestMatches(match, transportRequest)) + }, + async handle(route, options) { return await applyBrowserTransportFault(route, engine, options) }, evidence() { return [...engine.evidence] }, } } -export async function applyBrowserTransportFault(route: Route, engine: TransportFaultEngine): Promise { +/** + * Installs a context-local schedule. Teardown aborts handler wrappers and waits + * for underlying route.fetch promises up to drainTimeoutMs; Playwright exposes + * no direct cancellation signal for route.fetch, so timeout state is evidence. + */ +export async function installBrowserTransportFaults(context: BrowserContext, model: TransportFaultModel, options: BrowserTransportFaultInstallOptions = {}): Promise { + const adapter = createBrowserTransportFaultAdapter(model) + if (!adapter.negotiation.supported) { + throw new Error(`Browser transport fault schedule requires unsupported semantics: ${adapter.negotiation.unsupported.map(({ semantic }) => semantic).join(", ")}`) + } + const controller = new AbortController() + const pending = new Set>() + const pendingRouteFetches = new Set>() + const drainTimeoutMs = options.drainTimeoutMs ?? BROWSER_TRANSPORT_FAULT_DRAIN_TIMEOUT_MS + const trackedFetch = (route: Route, overrides: { url?: string; postData?: Buffer }) => { + const operation = options.policy ? options.policy.fetch(route, overrides) : route.fetch(overrides) + let tracked: Promise + tracked = operation.then(() => undefined, () => undefined).finally(() => pendingRouteFetches.delete(tracked)) + pendingRouteFetches.add(tracked) + return operation + } + let finalReport: BrowserTransportFaultReport | undefined + const handler = async (route: Route) => { + if (disposed) { + await route.abort("aborted").catch(() => undefined) + return + } + const operation = (async () => { + if (!adapter.matches(route)) { + await route.fallback() + return + } + if (options.policy && !await options.policy.preflight(route)) return + if (!await adapter.handle(route, { signal: controller.signal, fetch: trackedFetch, recordHandled: options.policy?.recordHandled })) await route.fallback() + })() + let tracked: Promise + tracked = operation.catch(async (error) => { + if (!isTransportFaultAbort(error)) throw error + await route.abort("aborted").catch(() => undefined) + }).finally(() => pending.delete(tracked)) + pending.add(tracked) + await tracked + } + await context.route("**/*", handler) + let disposed = false + return { + adapter, + inFlight: () => pending.size, + report: () => { + if (!finalReport) throw new Error("Browser transport fault evidence is unavailable until routing is disposed and drained.") + return finalReport + }, + async dispose() { + if (disposed) return finalReport ?? browserTransportFaultReport(adapter, options.serviceWorkersBlocked ?? false) + disposed = true + controller.abort() + const deadline = Date.now() + drainTimeoutMs + const handlersDrained = await drainTransportFaultTasks(pending, deadline) + await context.unroute("**/*", handler) + const fetchesDrained = await drainTransportFaultTasks(pendingRouteFetches, deadline) + const teardown: BrowserTransportFaultTeardown = { + status: handlersDrained && fetchesDrained ? "drained" : "timed-out", + timeoutMs: drainTimeoutMs, + pendingHandlers: pending.size, + pendingRouteFetches: pendingRouteFetches.size, + routeFetchCancellation: { + fidelity: "emulated", + reason: "Playwright route.fetch has no AbortSignal; teardown aborts the owning route wrapper and waits boundedly for the underlying fetch promise.", + }, + } + finalReport = browserTransportFaultReport(adapter, options.serviceWorkersBlocked ?? false, teardown) + return finalReport + }, + } +} + +export function browserTransportFaultReport(adapter: BrowserTransportFaultAdapter, serviceWorkersBlocked = false, teardown?: BrowserTransportFaultTeardown): BrowserTransportFaultReport { + const matchedRequests = adapter.evidence() + const matchedRuleIds = new Set(matchedRequests.flatMap((item) => item.fault ? [item.fault.ruleId] : [])) + const required = new Set(adapter.negotiation.required) + const schedule = transportFaultSafeSchedule(adapter.engine.model) + return { + schema: "wp-codebox/browser-transport-fault-report/v1", + interception: { + browserHttp: serviceWorkersBlocked + ? { fidelity: "exact", scope: "HTTP(S) requests emitted by this isolated browser context, including navigation, fetch, XHR, and subresources.", serviceWorkers: "blocked" } + : { fidelity: "emulated", scope: "HTTP(S) requests observed by Playwright routing.", serviceWorkers: "not-enforced", reason: "Service-worker-owned requests can bypass Playwright routing unless the browser context blocks service workers." }, + wordpressHttp: { fidelity: "unsupported", reason: "Playwright routing cannot observe server-side WordPress HTTP API requests; use the WordPress HTTP fault adapter for that traffic." }, + }, + seed: adapter.engine.model.seed, + schedule, + adapter: adapter.engine.capabilities.adapter, + fidelity: adapter.engine.capabilities.capabilities.filter(({ semantic }) => required.has(semantic)), + matchedRequests, + consumedSequenceEntries: matchedRequests.flatMap((item) => item.fault ? [{ ruleId: item.fault.ruleId, sequenceIndex: item.fault.sequenceIndex, invocation: item.fault.invocation }] : []), + unmatchedRules: adapter.engine.model.rules.map(({ id }) => id).filter((id) => !matchedRuleIds.has(id)), + ...(teardown ? { teardown } : {}), + replay: { schema: "wp-codebox/browser-transport-fault-replay/v1", seed: adapter.engine.model.seed, structuralScheduleFingerprint: schedule.structuralFingerprint, schedule, fidelity: "identity-only-redacted" }, + } +} + +export async function applyBrowserTransportFault(route: Route, engine: TransportFaultEngine, options: BrowserTransportFaultApplyOptions = {}): Promise { const request = route.request() const transportRequest = { url: request.url(), method: request.method(), headers: request.headers(), body: request.postDataBuffer() ?? undefined } const decision = engine.decide(transportRequest) @@ -50,15 +207,17 @@ export async function applyBrowserTransportFault(route: Route, engine: Transport throw new Error(`Browser transport fault semantics are unsupported: ${unsupported.map((item) => item?.semantic).join(", ")}`) } - if (decision.delayMs > 0) await delay(decision.delayMs) + if (decision.delayMs > 0) await abortableDelay(decision.delayMs, options.signal) if (decision.outcome.timeoutMs !== undefined) { - await delay(decision.outcome.timeoutMs) + await abortableDelay(decision.outcome.timeoutMs, options.signal) + options.recordHandled?.(route) await route.abort("timedout") engine.record(transportRequest, decision, { connection: "timedout" }) return true } if (decision.outcome.connection) { const code = decision.outcome.connection === "refuse" ? "connectionrefused" : decision.outcome.connection === "reset" ? "connectionreset" : "failed" + options.recordHandled?.(route) await route.abort(code) engine.record(transportRequest, decision, { connection: decision.outcome.connection }) return true @@ -67,20 +226,23 @@ export async function applyBrowserTransportFault(route: Route, engine: Transport const continuation = browserFaultContinuation(decision, request.url(), request.postDataBuffer() ?? undefined) const needsResponse = browserFaultNeedsResponse(decision) if (!needsResponse) { - await route.continue(continuation) + await route.fallback(continuation) engine.record(transportRequest, decision) return true } - const upstream = decision.outcome.status === undefined || decision.outcome.body === undefined && decision.outcome.bodyBase64 === undefined || decision.outcome.responseCorruption || decision.outcome.truncateAfterBytes !== undefined - ? await route.fetch(continuation) + const needsUpstream = decision.outcome.status === undefined || decision.outcome.body === undefined && decision.outcome.bodyBase64 === undefined || decision.outcome.responseCorruption || decision.outcome.truncateAfterBytes !== undefined + const upstream = needsUpstream + ? await abortableOperation(options.fetch ? options.fetch(route, continuation) : route.fetch(continuation), options.signal) : undefined + if (needsUpstream && upstream === undefined && options.fetch) return true const originalBody = upstream ? await upstream.body() : Buffer.alloc(0) let body = decision.outcome.bodyBase64 !== undefined ? Buffer.from(decision.outcome.bodyBase64, "base64") : decision.outcome.body !== undefined ? Buffer.from(decision.outcome.body) : originalBody body = mutateResponseBody(body, decision) - if (decision.outcome.bandwidthBytesPerSecond && body.length > 0) await delay(Math.ceil(body.length / decision.outcome.bandwidthBytesPerSecond * 1000)) + if (decision.outcome.bandwidthBytesPerSecond && body.length > 0) await abortableDelay(Math.ceil(body.length / decision.outcome.bandwidthBytesPerSecond * 1000), options.signal) const status = decision.outcome.status ?? upstream?.status() ?? 200 const headers = { ...(upstream?.headers() ?? {}), ...(decision.outcome.headers ?? {}) } + if (!(needsUpstream && options.fetch)) options.recordHandled?.(route) await route.fulfill({ status, headers, body }) engine.record(transportRequest, decision, { status, headers, bodyBytes: body.length }) return true @@ -122,6 +284,57 @@ function corruptBytes(input: Buffer, mode: "truncate" | "flip-byte" | "invalid-e return output } -function delay(milliseconds: number): Promise { - return new Promise((resolve) => setTimeout(resolve, milliseconds)) +function abortableDelay(milliseconds: number, signal?: AbortSignal): Promise { + if (!signal) return new Promise((resolve) => setTimeout(resolve, milliseconds)) + if (signal.aborted) return Promise.reject(transportFaultAbortError()) + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal.removeEventListener("abort", abort) + resolve() + }, milliseconds) + const abort = () => { + clearTimeout(timer) + reject(transportFaultAbortError()) + } + signal.addEventListener("abort", abort, { once: true }) + }) +} + +function abortableOperation(operation: Promise, signal?: AbortSignal): Promise { + if (!signal) return operation + if (signal.aborted) return Promise.reject(transportFaultAbortError()) + return new Promise((resolve, reject) => { + const abort = () => reject(transportFaultAbortError()) + signal.addEventListener("abort", abort, { once: true }) + operation.then( + (value) => { signal.removeEventListener("abort", abort); resolve(value) }, + (error) => { signal.removeEventListener("abort", abort); reject(error) }, + ) + }) +} + +async function drainTransportFaultTasks(pending: Set>, deadline: number): Promise { + if (pending.size === 0) return true + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) return false + let timer: ReturnType | undefined + try { + const result = await Promise.race([ + Promise.allSettled([...pending]).then(() => "drained" as const), + new Promise<"timeout">((resolve) => { timer = setTimeout(() => resolve("timeout"), remainingMs) }), + ]) + return result === "drained" + } finally { + if (timer) clearTimeout(timer) + } +} + +function transportFaultAbortError(): Error { + const error = new Error("Browser transport fault operation aborted during teardown.") + error.name = "AbortError" + return error +} + +function isTransportFaultAbort(error: unknown): boolean { + return error instanceof Error && error.name === "AbortError" } diff --git a/packages/runtime-playground/src/index.ts b/packages/runtime-playground/src/index.ts index 1828dd2a..f5822d25 100644 --- a/packages/runtime-playground/src/index.ts +++ b/packages/runtime-playground/src/index.ts @@ -16,7 +16,7 @@ export { collectBrowserArtifactMetrics, collectWordPressEpisodeArtifacts, collec export { preflightPhpWasmRuntimeAssets, PhpWasmRuntimeAssetIntegrityError, type PhpWasmRuntimeAssetPreflight, type PhpWasmRuntimeAssetPreflightOptions } from "./php-wasm-preflight.js" export { assertPlaywrightBrowserReady, playwrightBrowserProvenance, type PlaywrightBrowserProvenance } from "./playwright-browser-provenance.js" export { browserPreviewAuthCookieUrls, browserPreviewNetworkPolicySummary, browserPreviewReadinessError, browserPreviewRouting, browserPreviewSecureContextError, browserPreviewTopology, browserPreviewOrigins, resolveBrowserPreviewUrl, type BrowserPreviewNetworkPolicy, type BrowserPreviewTopology } from "./browser-preview-routing.js" -export { BROWSER_TRANSPORT_FAULT_CAPABILITIES, applyBrowserTransportFault, createBrowserTransportFaultAdapter, type BrowserTransportFaultAdapter } from "./browser-transport-faults.js" +export { BROWSER_TRANSPORT_FAULT_CAPABILITIES, applyBrowserTransportFault, browserTransportFaultReport, createBrowserTransportFaultAdapter, installBrowserTransportFaults, type BrowserTransportFaultAdapter, type BrowserTransportFaultInstallOptions, type BrowserTransportFaultReport, type BrowserTransportFaultTeardown, type InstalledBrowserTransportFaults } from "./browser-transport-faults.js" export { PLAYWRIGHT_CLOCK_CONTROL_CAPABILITIES, createBrowserClockController, type BrowserClockController } from "./browser-clock-control.js" export { PLAYWRIGHT_BROWSER_ENVIRONMENT_CAPABILITIES, applyPlaywrightPageEnvironment, browserEnvironmentCell, createPlaywrightBrowserEnvironmentContext, observePlaywrightBrowserEnvironment, resolvePlaywrightBrowserEnvironment, runPlaywrightBrowserEnvironmentMatrix, type BrowserEnvironmentCpuProfile, type BrowserEnvironmentNetworkProfile, type PlaywrightBrowserEnvironmentExecutionInput, type PlaywrightBrowserEnvironmentOptions, type PlaywrightBrowserEnvironmentRuntime, type PlaywrightBrowserEnvironmentSession } from "./browser-environment-matrix.js" export { WORDPRESS_ADVERSARIAL_ADAPTER_SCHEMA, WORDPRESS_ADVERSARIAL_CAPABILITIES, WORDPRESS_ADVERSARIAL_ORACLES, WORDPRESS_CLOCK_CONTROL_CAPABILITIES, WORDPRESS_HTTP_TRANSPORT_FAULT_CAPABILITIES, createWordPressAdversarialAdapter, evaluateWordPressAdversarialOracles, negotiateWordPressHttpTransportFaults, wordpressAdversarialActionSpec, wordpressHttpFaultConfigurationAction, wordpressNoveltySignals, wordpressSchedulerClockAction, type WordPressAdapterFidelity, type WordPressAdversarialAction, type WordPressAdversarialAdapter, type WordPressAdversarialCapability, type WordPressAdversarialSurface } from "./wordpress-adversarial-adapter.js" diff --git a/tests/browser-transport-fault-scenarios.browser.test.ts b/tests/browser-transport-fault-scenarios.browser.test.ts new file mode 100644 index 00000000..ea56453e --- /dev/null +++ b/tests/browser-transport-fault-scenarios.browser.test.ts @@ -0,0 +1,226 @@ +import assert from "node:assert/strict" +import { mkdtemp, readFile, rm } from "node:fs/promises" +import { createServer } from "node:http" +import { tmpdir } from "node:os" +import { join } from "node:path" +import test from "node:test" +import { chromium } from "playwright" + +import type { RuntimeCreateSpec, TransportFaultModel } from "../packages/runtime-core/src/index.js" +import { runBrowserActionsCommand, runBrowserScenarioCommand } from "../packages/runtime-playground/src/browser-actions-runner.js" +import { isBrowserCommandArtifactError } from "../packages/runtime-playground/src/browser-command-artifact-error.js" +import { installBrowserTransportFaults } from "../packages/runtime-playground/src/browser-transport-faults.js" +import type { PlaygroundCliServer } from "../packages/runtime-playground/src/preview-server.js" + +const runtimeSpec: RuntimeCreateSpec = { + backend: "wordpress-playground", + environment: {}, + policy: { network: "deny", filesystem: "sandbox", commands: ["wordpress.browser-actions", "wordpress.browser-actions.evaluate", "wordpress.browser-scenario"], secrets: "none", approvals: "never" }, +} + +test("real browser faults cover delay, timeout, refusal, reset, malformed, truncation, recovery, and unmatched rules", async () => { + const fixture = await browserFixture() + const artifactRoot = await mkdtemp(join(tmpdir(), "wp-codebox-browser-fault-semantics-")) + const schedule: TransportFaultModel = { + schema: "wp-codebox/transport-fault-model/v1", + seed: "browser-semantics", + rules: [ + { id: "delay", match: { path: "/delay" }, sequence: [{ delayMs: 120 }] }, + { id: "timeout", match: { path: "/timeout" }, sequence: [{ timeoutMs: 10 }] }, + { id: "refuse", match: { path: "/refuse" }, sequence: [{ connection: "refuse" }] }, + { id: "reset", match: { path: "/reset" }, sequence: [{ connection: "reset" }] }, + { id: "malformed", match: { path: "/malformed" }, sequence: [{ malformed: true }] }, + { id: "truncated", match: { path: "/truncated" }, sequence: [{ truncateAfterBytes: 4 }] }, + { id: "recovery", match: { method: "POST", path: "/api" }, sequence: [{ status: 503, body: "fault" }, { passthrough: true }], repeat: "none" }, + { id: "unmatched", match: { path: "/never" }, sequence: [{ status: 500 }] }, + ], + } + try { + const steps = [ + { kind: "evaluate", expression: "await Promise.all([Date.now(), fetch('/delay')]).then(([started, response]) => ({ status: response.status, delayed: Date.now() - started >= 80 }))", assert: { status: 200, delayed: true } }, + ...["timeout", "refuse", "reset"].map((name) => ({ kind: "evaluate", expression: `await fetch('/${name}').then(() => false, () => true)`, assert: true })), + { kind: "evaluate", expression: "[...new Uint8Array(await fetch('/malformed').then(r => r.arrayBuffer()))].slice(0, 3)", assert: [255, 254, 0] }, + { kind: "evaluate", expression: "await fetch('/truncated').then(r => r.text())", assert: "runt" }, + { kind: "evaluate", expression: "[await fetch('/api', { method: 'POST' }).then(r => r.status), await fetch('/api', { method: 'POST' }).then(r => r.status)]", assert: [503, 200] }, + { kind: "evaluate", expression: "await navigator.serviceWorker.register('/sw.js').then(registration => registration ? Promise.race([new Promise(resolve => { const worker = registration.installing ?? registration.waiting ?? registration.active; if (worker?.state === 'activated') resolve(true); else worker?.addEventListener('statechange', () => worker.state === 'activated' && resolve(true)); }), new Promise(resolve => setTimeout(() => resolve(false), 500))]).then(activated => ({ registered: true, activated })) : ({ registered: false, activated: false }), () => ({ registered: false, activated: false }))", assert: { registered: false, activated: false } }, + ] + const result = await runBrowserScenarioCommand({ + artifactRoot, + runtimeSpec, + server: fixture.server, + spec: { command: "wordpress.browser-scenario", args: [`scenario-json=${JSON.stringify({ url: "/", transportFaults: schedule, captures: ["steps", "network"], steps })}`] }, + }) + assert.equal(result.artifact.summary.transportFaults?.matchedRequests, 8) + assert.deepEqual(result.artifact.summary.transportFaults?.unmatchedRules, ["unmatched"]) + const report = JSON.parse(await readFile(join(artifactRoot, "files/browser/transport-faults.json"), "utf8")) + assert.deepEqual(report.consumedSequenceEntries.filter(({ ruleId }: { ruleId: string }) => ruleId === "recovery").map(({ sequenceIndex }: { sequenceIndex: number }) => sequenceIndex), [0, 1]) + assert.equal(report.interception.browserHttp.fidelity, "exact") + assert.equal(report.interception.browserHttp.serviceWorkers, "blocked") + assert.equal(report.interception.wordpressHttp.fidelity, "unsupported") + assert.equal(report.replay.fidelity, "identity-only-redacted") + assert.equal(report.replay.structuralScheduleFingerprint, report.schedule.structuralFingerprint) + assert.equal(fixture.requests.filter(({ url }) => url === "/sw.js").length, 0) + } finally { + await fixture.close() + await rm(artifactRoot, { recursive: true, force: true }) + } +}) + +test("response transforms preserve blocked-host enforcement and routed preview remapping", async () => { + const fixture = await browserFixture() + const blocked = await browserFixture() + const artifactRoot = await mkdtemp(join(tmpdir(), "wp-codebox-browser-fault-routing-")) + const routedHost = "routed-fault.test" + const blockedUrl = `http://localhost:${blocked.port}/truncated` + const routedUrl = `http://${routedHost}:${fixture.port}/truncated` + const schedule: TransportFaultModel = { + schema: "wp-codebox/transport-fault-model/v1", + seed: "routing-policy", + rules: [ + { id: "blocked-transform", match: { host: `localhost:${blocked.port}`, path: "/truncated" }, sequence: [{ truncateAfterBytes: 4 }] }, + { id: "routed-transform", match: { host: `${routedHost}:${fixture.port}`, method: "POST", path: "/truncated" }, sequence: [{ requestCorruption: "truncate", truncateAfterBytes: 4 }] }, + ], + } + try { + const result = await runBrowserActionsCommand({ + artifactRoot, + runtimeSpec, + server: fixture.server, + spec: { command: "wordpress.browser-actions", args: [ + "url=/", + `route-host=${routedHost}`, + "block-host=localhost", + `transport-faults-json=${JSON.stringify(schedule)}`, + `steps-json=${JSON.stringify([{ kind: "evaluate", expression: `({ blocked: await fetch(${JSON.stringify(blockedUrl)}).then(() => false, () => true), routed: await fetch(${JSON.stringify(routedUrl)}, { method: 'POST', body: 'abcdefgh' }).then(r => r.text()) })`, assert: { blocked: true, routed: "runt" } }])}`, + "capture=steps,network", + ] }, + }) + assert.equal(blocked.requests.filter(({ url }) => url === "/truncated").length, 0) + assert(fixture.requests.some(({ host, url }) => host === `${routedHost}:${fixture.port}` && url === "/truncated"), JSON.stringify(fixture.requests)) + assert.equal(fixture.requests.find(({ host, url }) => host === `${routedHost}:${fixture.port}` && url === "/truncated")?.body, "abcd") + assert.equal(result.artifact.summary.networkPolicy?.blockedRequests, 1) + assert.equal(result.artifact.summary.transportFaults?.matchedRequests, 1) + assert.deepEqual(result.artifact.summary.transportFaults?.unmatchedRules, ["blocked-transform"]) + } finally { + await Promise.all([fixture.close(), blocked.close()]) + await rm(artifactRoot, { recursive: true, force: true }) + } +}) + +test("fault disposal cancels delayed background requests and freezes sequence evidence", async () => { + const fixture = await browserFixture() + const browser = await chromium.launch({ headless: true }) + const context = await browser.newContext({ serviceWorkers: "block" }) + const page = await context.newPage() + const installed = await installBrowserTransportFaults(context, { + schema: "wp-codebox/transport-fault-model/v1", + seed: "dispose-delayed", + rules: [{ id: "slow", match: { path: "/slow" }, sequence: [{ delayMs: 10_000 }], repeat: "cycle" }], + }, { serviceWorkersBlocked: true }) + try { + await page.goto(fixture.server.serverUrl) + await page.evaluate(() => { void fetch("/slow").catch(() => undefined) }) + await waitFor(() => installed.inFlight() === 1) + const report = await installed.dispose() + const evidenceCount = installed.adapter.evidence().length + await page.evaluate(() => fetch("/slow").then((response) => response.text())) + await new Promise((resolve) => setTimeout(resolve, 50)) + assert.equal(report.matchedRequests.length, evidenceCount) + assert.equal(installed.adapter.evidence().length, evidenceCount) + assert.deepEqual(installed.report(), report) + assert.equal(fixture.requests.filter(({ url }) => url === "/slow").length, 1) + } finally { + await context.close() + await browser.close() + await fixture.close() + } +}) + +test("failed and sequential browser runs tear down fault routes", async () => { + const fixture = await browserFixture() + const failedRoot = await mkdtemp(join(tmpdir(), "wp-codebox-browser-fault-failed-")) + const cleanRoot = await mkdtemp(join(tmpdir(), "wp-codebox-browser-fault-clean-")) + try { + await assert.rejects(runBrowserActionsCommand({ + artifactRoot: failedRoot, + runtimeSpec, + server: fixture.server, + spec: { command: "wordpress.browser-actions", args: ["url=/", `transport-faults-json=${JSON.stringify(faultModel("failed-run", 599))}`, `steps-json=${JSON.stringify([{ kind: "evaluate", expression: "await fetch('/api', { method: 'POST' }).then(r => r.status)", assert: 599 }, { kind: "expect", selector: "#missing", state: "visible", timeout: "50ms" }])}`, "capture=steps"] }, + }), (error: unknown) => isBrowserCommandArtifactError(error)) + const clean = await runBrowserActionsCommand({ + artifactRoot: cleanRoot, + runtimeSpec, + server: fixture.server, + spec: { command: "wordpress.browser-actions", args: ["url=/", `steps-json=${JSON.stringify([{ kind: "evaluate", expression: "await fetch('/api', { method: 'POST' }).then(r => r.status)", assert: 200 }])}`, "capture=steps"] }, + }) + assert.equal(clean.artifact.summary.transportFaults, undefined) + } finally { + await fixture.close() + await Promise.all([rm(failedRoot, { recursive: true, force: true }), rm(cleanRoot, { recursive: true, force: true })]) + } +}) + +test("parallel browser fault schedules remain context-local", async () => { + const fixture = await browserFixture() + const roots = await Promise.all([451, 452].map(() => mkdtemp(join(tmpdir(), "wp-codebox-browser-fault-parallel-")))) + try { + const results = await Promise.all([451, 452].map((status, index) => runBrowserActionsCommand({ + artifactRoot: roots[index]!, + runtimeSpec, + server: fixture.server, + spec: { command: "wordpress.browser-actions", args: ["url=/", `transport-faults-json=${JSON.stringify(faultModel(`parallel-${status}`, status))}`, `steps-json=${JSON.stringify([{ kind: "evaluate", expression: "await fetch('/api', { method: 'POST' }).then(r => r.status)", assert: status }])}`, "capture=steps"] }, + }))) + assert.deepEqual(results.map(({ artifact }) => artifact.summary.transportFaults?.seed), ["parallel-451", "parallel-452"]) + assert.deepEqual(results.map(({ artifact }) => artifact.summary.transportFaults?.matchedRequests), [1, 1]) + } finally { + await fixture.close() + await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))) + } +}) + +function faultModel(seed: string, status: number): TransportFaultModel { + return { schema: "wp-codebox/transport-fault-model/v1", seed, rules: [{ id: "api-recovery", match: { method: "POST", path: "/api" }, sequence: [{ status, body: "fault" }, { passthrough: true }], repeat: "none" }] } +} + +async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() >= deadline) throw new Error("Timed out waiting for browser transport fault lifecycle state.") + await new Promise((resolve) => setTimeout(resolve, 10)) + } +} + +async function browserFixture(): Promise<{ port: number; requests: Array<{ host?: string; url?: string; body: string }>; server: PlaygroundCliServer; close(): Promise }> { + const requests: Array<{ host?: string; url?: string; body: string }> = [] + const httpServer = createServer(async (request, response) => { + const body = await requestBody(request) + requests.push({ host: request.headers.host, url: request.url, body }) + if (request.url === "/sw.js") { + response.setHeader("content-type", "application/javascript") + response.end("self.addEventListener('install', () => self.skipWaiting()); self.addEventListener('activate', event => event.waitUntil(self.clients.claim())); self.addEventListener('fetch', event => event.respondWith(new Response('service-worker')))" ) + return + } + if (["/api", "/delay", "/timeout", "/refuse", "/reset", "/malformed", "/truncated", "/slow"].includes(request.url ?? "")) { + response.statusCode = 200 + response.end("runtime-response") + return + } + response.setHeader("content-type", "text/html") + response.end("Transport faults
ready
") + }) + await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)) + const address = httpServer.address() + assert(address && typeof address === "object") + return { + port: address.port, + requests, + server: { serverUrl: `http://127.0.0.1:${address.port}`, playground: { async run() { return { text: "", exitCode: 0 } } }, async [Symbol.asyncDispose]() {} }, + close: () => new Promise((resolve, reject) => httpServer.close((error) => error ? reject(error) : resolve())), + } +} + +async function requestBody(request: import("node:http").IncomingMessage): Promise { + const chunks: Buffer[] = [] + for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + return Buffer.concat(chunks).toString("utf8") +} diff --git a/tests/transport-faults.test.ts b/tests/transport-faults.test.ts index eba19cae..0faa1278 100644 --- a/tests/transport-faults.test.ts +++ b/tests/transport-faults.test.ts @@ -2,7 +2,8 @@ import assert from "node:assert/strict" import test from "node:test" import { TransportFaultEngine, negotiateTransportFaults, redactTransportHeaders, transportFaultCapabilities, transportFaultModel } from "../packages/runtime-core/src/transport-faults.js" -import { applyBrowserTransportFault, createBrowserTransportFaultAdapter } from "../packages/runtime-playground/src/browser-transport-faults.js" +import { getCommandDefinition } from "../packages/runtime-core/src/command-registry.js" +import { applyBrowserTransportFault, browserTransportFaultReport, createBrowserTransportFaultAdapter, installBrowserTransportFaults } from "../packages/runtime-playground/src/browser-transport-faults.js" const model = transportFaultModel({ seed: "fault-seed", @@ -66,3 +67,138 @@ test("browser adapter applies response faults and rejects socket semantics it ca assert.equal(unsupported.negotiation.supported, false) await assert.rejects(applyBrowserTransportFault(route, unsupported.engine), /half-close/) }) + +test("public browser commands expose the canonical transport fault model", () => { + for (const command of ["wordpress.browser-actions", "wordpress.browser-scenario"]) { + const argument = getCommandDefinition(command)?.acceptedArgs.find(({ name }) => name === "transport-faults-json") + assert.equal(argument?.format, "JSON object or @path") + assert.match(argument?.description ?? "", /wp-codebox\/transport-fault-model\/v1/) + } + assert.throws(() => transportFaultModel({ schema: "parallel/fault-model/v1", seed: "wrong", rules: [] }), /schema must be wp-codebox\/transport-fault-model\/v1/) + assert.throws(() => transportFaultModel({ seed: "mixed", rules: [{ id: "mixed", match: {}, sequence: [{ passthrough: true, status: 200 }] }] }), /passthrough outcomes cannot declare another fault effect/) +}) + +test("browser adapter supports deterministic transient recovery and browser-emulated failures", async () => { + const adapter = createBrowserTransportFaultAdapter(transportFaultModel({ + seed: "browser-sequences", + rules: [ + { id: "recovery", match: { path: "/recover" }, sequence: [{ status: 503, body: "offline" }, { passthrough: true }], repeat: "none" }, + { id: "timeout", match: { path: "/timeout" }, sequence: [{ timeoutMs: 0 }] }, + { id: "refuse", match: { path: "/refuse" }, sequence: [{ connection: "refuse" }] }, + { id: "reset", match: { path: "/reset" }, sequence: [{ connection: "reset" }] }, + { id: "body", match: { path: "/body" }, sequence: [{ delayMs: 1, malformed: true, truncateAfterBytes: 3 }] }, + { id: "never", match: { path: "/never" }, sequence: [{ status: 500 }] }, + ], + })) + const calls: Array<{ method: string; input?: unknown }> = [] + const route = (path: string) => ({ + request: () => ({ url: () => `https://fixture.test${path}`, method: () => "GET", headers: () => ({}), postDataBuffer: () => null }), + async fulfill(input: unknown) { calls.push({ method: "fulfill", input }) }, + async fallback(input: unknown) { calls.push({ method: "fallback", input }) }, + async abort(input: unknown) { calls.push({ method: "abort", input }) }, + async fetch() { return { body: async () => Buffer.from("abcdef"), status: () => 200, headers: () => ({ "content-type": "text/plain" }) } }, + }) as never + + await applyBrowserTransportFault(route("/recover"), adapter.engine) + await applyBrowserTransportFault(route("/recover"), adapter.engine) + await applyBrowserTransportFault(route("/timeout"), adapter.engine) + await applyBrowserTransportFault(route("/refuse"), adapter.engine) + await applyBrowserTransportFault(route("/reset"), adapter.engine) + await applyBrowserTransportFault(route("/body"), adapter.engine) + + assert.deepEqual(calls.map(({ method }) => method), ["fulfill", "fallback", "abort", "abort", "abort", "fulfill"]) + assert.deepEqual(calls.filter(({ method }) => method === "abort").map(({ input }) => input), ["timedout", "connectionrefused", "connectionreset"]) + assert.deepEqual((calls.at(-1)?.input as { body: Buffer }).body, Buffer.from([0xff, 0xfe, 0x00])) + const report = browserTransportFaultReport(adapter) + assert.deepEqual(report.consumedSequenceEntries.slice(0, 2), [ + { ruleId: "recovery", sequenceIndex: 0, invocation: 0 }, + { ruleId: "recovery", sequenceIndex: 1, invocation: 1 }, + ]) + assert.deepEqual(report.unmatchedRules, ["never"]) + assert.equal(report.replay.seed, "browser-sequences") + assert.equal(report.matchedRequests.at(-1)?.fault?.delayMs, 1) + assert.equal(report.interception.wordpressHttp.fidelity, "unsupported") +}) + +test("browser transport reports persist only a canonical secret-safe schedule", async () => { + const adapter = createBrowserTransportFaultAdapter(transportFaultModel({ + seed: "safe-replay", + metadata: { opaqueSecret: "model-secret" }, + rules: [{ + id: "secret-rule", + match: { path: "/secret", headers: { Authorization: "Bearer matcher-secret", Cookie: "session=matcher-cookie", "X-Custom-Secret": "custom-value" } }, + sequence: [{ status: 503, headers: { Authorization: "response-authorization", "Set-Cookie": "response-cookie", "X-Private": "response-private" }, body: "sensitive-response-body", metadata: { token: "outcome-secret" } }], + metadata: { credential: "rule-secret" }, + }], + })) + const route = { + request: () => ({ url: () => "https://fixture.test/secret", method: () => "GET", headers: () => ({ Authorization: "Bearer matcher-secret", Cookie: "session=matcher-cookie", "X-Custom-Secret": "custom-value" }), postDataBuffer: () => Buffer.from("sensitive-request-body") }), + async fulfill() {}, + } as never + await applyBrowserTransportFault(route, adapter.engine) + + const report = browserTransportFaultReport(adapter) + const serialized = JSON.stringify(report) + assert.doesNotMatch(serialized, /matcher-secret|matcher-cookie|custom-value|response-authorization|response-cookie|response-private|sensitive-response-body|sensitive-request-body|model-secret|outcome-secret|rule-secret/) + assert.deepEqual(report.schedule.rules[0]?.match.headerNames, ["authorization", "cookie", "x-custom-secret"]) + assert.deepEqual(report.schedule.rules[0]?.sequence[0]?.headerNames, ["authorization", "set-cookie", "x-private"]) + assert.deepEqual(report.schedule.rules[0]?.sequence[0]?.body, { encoding: "utf8", bytes: 23, redacted: true }) + assert.equal(report.schedule.rules[0]?.metadataRedacted, true) + assert.equal(report.schedule.rules[0]?.sequence[0]?.metadataRedacted, true) + assert.equal(report.matchedRequests[0]?.request.headers["X-Custom-Secret"], "[redacted]") + assert.equal(report.replay.structuralScheduleFingerprint, report.schedule.structuralFingerprint) + assert.equal(report.replay.fidelity, "identity-only-redacted") +}) + +test("direct installer reports bounded underlying route fetch drain timeouts honestly", async () => { + let handler: ((route: never) => Promise) | undefined + const context = { + async route(_pattern: string, callback: (route: never) => Promise) { handler = callback }, + async unroute() {}, + } as never + const installed = await installBrowserTransportFaults(context, transportFaultModel({ + seed: "uncancellable-fetch", + rules: [{ id: "transform", match: { path: "/transform" }, sequence: [{ truncateAfterBytes: 2 }] }], + }), { drainTimeoutMs: 20 }) + const route = { + request: () => ({ url: () => "https://fixture.test/transform", method: () => "GET", headers: () => ({}), postDataBuffer: () => null }), + async fetch() { return await new Promise(() => undefined) }, + async abort() {}, + async fallback() {}, + async fulfill() {}, + } as never + void handler!(route) + await new Promise((resolve) => setImmediate(resolve)) + const report = await installed.dispose() + assert.equal(report.teardown?.status, "timed-out") + assert.equal(report.teardown?.timeoutMs, 20) + assert.equal(report.teardown?.pendingHandlers, 0) + assert.equal(report.teardown?.pendingRouteFetches, 1) + assert.equal(report.teardown?.routeFetchCancellation.fidelity, "emulated") + assert.match(report.teardown?.routeFetchCancellation.reason ?? "", /no AbortSignal/) +}) + +test("direct installer reports settled upstream route fetches as drained", async () => { + let handler: ((route: never) => Promise) | undefined + const context = { + async route(_pattern: string, callback: (route: never) => Promise) { handler = callback }, + async unroute() {}, + } as never + const installed = await installBrowserTransportFaults(context, transportFaultModel({ + seed: "settled-fetch", + rules: [{ id: "transform", match: { path: "/transform" }, sequence: [{ truncateAfterBytes: 2 }] }], + }), { drainTimeoutMs: 20 }) + const route = { + request: () => ({ url: () => "https://fixture.test/transform", method: () => "GET", headers: () => ({}), postDataBuffer: () => null }), + async fetch() { return { body: async () => Buffer.from("response"), status: () => 200, headers: () => ({ "content-type": "text/plain" }) } }, + async abort() {}, + async fallback() {}, + async fulfill() {}, + } as never + await handler!(route) + const report = await installed.dispose() + assert.equal(report.teardown?.status, "drained") + assert.equal(report.teardown?.pendingHandlers, 0) + assert.equal(report.teardown?.pendingRouteFetches, 0) + assert.equal(report.matchedRequests.length, 1) +})