Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/runtime-core/src/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 @<path> 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,
Expand Down Expand Up @@ -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 @<path> 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.",
Expand Down
70 changes: 68 additions & 2 deletions packages/runtime-core/src/transport-faults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -34,6 +35,7 @@ export interface TransportRequestMatcher {
}

export interface TransportFaultOutcome {
passthrough?: true
status?: number
headers?: Record<string, string>
body?: string
Expand Down Expand Up @@ -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<TransportRequestMatcher, "headers"> & { headerNames?: string[] }
sequence: Array<Omit<TransportFaultOutcome, "headers" | "body" | "bodyBase64" | "metadata"> & {
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
Expand Down Expand Up @@ -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 }),
Expand All @@ -186,6 +210,7 @@ export class TransportFaultEngine {
}

export function transportFaultModel(input: Omit<TransportFaultModel, "schema"> & { 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<string>()
const rules = input.rules.map((rule) => {
Expand All @@ -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")
Expand Down Expand Up @@ -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.`)
Expand All @@ -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
Expand Down
Loading
Loading