import { createHash } from "node:crypto"; import { inspectAnalysisView } from "../../src/application/analysisView/AnalysisViewService.js"; import { createBrowserScenarioEvidence } from "../../src/application/BrowserScenarioEvidence.js"; import { sanitizeBrowserUrl } from "../../src/domain/browserObservation.js"; import { browserScenarioSchema } from "../../src/domain/browserScenario.js"; import { browserScenarioCaptureSchema, type BrowserScenarioCapture, } from "../../src/domain/browserScenarioCapture.js"; import { DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS } from "../../src/domain/browserScenarioCaptureLimits.js"; import type { Evidence } from "../../src/domain/evidence.js"; import { ALL_TEMPLATE_RULES, type EnvironmentServeFixture, } from "./environmentServe.js"; /** One recorded transaction; omit `status` for a request that never got a response. */ export interface ScenarioExchange { readonly method: string; readonly url: string; readonly resourceType?: string; readonly redactedUrl?: boolean; readonly status?: number; readonly mediaType?: string | null; readonly body?: string | Buffer; readonly requestMediaType?: string; readonly requestBody?: string; } export const SCENARIO_ORIGIN = "http://127.0.0.1:3101"; /** A small app session: a page load, JSON reads, a search, a JSON write, a redacted URL, binary bytes and a failure. */ export const SCENARIO_EXCHANGES: readonly ScenarioExchange[] = [ { method: "GET", url: "/", resourceType: "document", status: 200, mediaType: "text/html", body: "Rota", }, { method: "GET", url: "/api/users/2", status: 200, mediaType: "application/json", body: '{"id":2,"login":"probe"}', }, { method: "GET", url: "/api/users/3", status: 200, mediaType: "application/json", body: '{"id":3,"login":"viewer"}', }, { method: "GET", url: "/api/search?query=lighthouse", status: 200, mediaType: "application/json", body: '[{"title":"Lighthouse Rota"}]', }, { method: "POST", url: "/api/folders", status: 200, mediaType: "application/json", body: '{"uid":"f1","title":"Lighthouse Rota"}', requestMediaType: "application/json", requestBody: '{"title":"Lighthouse Rota"}', }, { method: "GET", url: "/api/sessions/[REDACTED:session]", redactedUrl: true, status: 200, mediaType: "application/json", body: "{}", }, { method: "GET", url: "/api/blobs/1", status: 200, mediaType: "application/json", body: Buffer.from([0xff, 0xfe, 0x00, 0x41]), }, { method: "GET", url: "/api/slow" }, ]; const capturedBody = ( content: string | Buffer, representation: | "browser-exposed-request-bytes" | "playwright-response-body-bytes", mediaType: string | null, ) => { const bytes = typeof content === "string" ? Buffer.from(content, "utf8") : content; return { state: "captured" as const, representation, encoding: "base64" as const, content: bytes.toString("base64"), bytes: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex"), media_type: mediaType, redacted: false, }; }; const exchangeEvents = ( exchange: ScenarioExchange, index: number, first: number, ) => { const transaction = `request-${String(index + 1)}`; const url = sanitizeBrowserUrl(`${SCENARIO_ORIGIN}${exchange.url}`); const network = { step_index: 1, transaction_id: transaction, redirected_from_transaction_id: null, redirected_from_transaction_state: "root" as const, method: exchange.method, url: exchange.redactedUrl === true ? { ...url, redacted: true } : url, resource_type: exchange.resourceType ?? "fetch", header_names: [], }; const content = ( sequence: number, source: number, phase: "request" | "response", body: ReturnType, ) => ({ sequence, step_index: 1, kind: "network-content" as const, transaction_id: transaction, source_event_sequence: source, phase, headers: { state: "not_requested" as const }, body, }); const events: unknown[] = [ { ...network, sequence: first, kind: "request", status: null, failure: null, }, ]; let next = first + 1; if (exchange.requestBody !== undefined) { events.push( content( next, first, "request", capturedBody( exchange.requestBody, "browser-exposed-request-bytes", exchange.requestMediaType ?? null, ), ), ); next += 1; } if (exchange.status === undefined) { events.push({ ...network, sequence: next, kind: "request-failed", status: null, failure: "net::ERR_CONNECTION_RESET", }); return { events, next: next + 1 }; } const response = next; events.push({ ...network, sequence: response, kind: "response", status: exchange.status, failure: null, }); next += 1; if (exchange.body !== undefined) { events.push( content( next, response, "response", capturedBody( exchange.body, "playwright-response-body-bytes", exchange.mediaType ?? null, ), ), ); next += 1; } return { events, next }; }; const step = (index: number) => ({ step_index: index, step_id: index === 0 ? "initial" : "browse", action: index === 0 ? "initial" : "goto", elapsed_ms: 0, before_url: sanitizeBrowserUrl(`${SCENARIO_ORIGIN}/`), after_url: sanitizeBrowserUrl(`${SCENARIO_ORIGIN}/`), event_sequence_start: 1, event_sequence_end: 0, artifacts: { screenshot: { state: "not_requested" }, dom: { state: "not_requested" }, accessibility: { state: "not_requested" }, url: { state: "not_requested" }, history: { state: "not_requested" }, storage: { state: "not_requested" }, }, completeness: { status: "complete", equality_eligible: true, missing_sections: [], truncated_sections: [], }, status: "completed", error: null, }); /** A complete, schema-valid scenario capture of the given transactions. */ export const scenarioCaptureFixture = ( exchanges: readonly ScenarioExchange[] = SCENARIO_EXCHANGES, networkContent = { request_body: true, response_body: true, header_values: false, }, ): BrowserScenarioCapture => { const items: unknown[] = []; let sequence = 1; for (const [index, exchange] of exchanges.entries()) { const built = exchangeEvents(exchange, index, sequence); items.push(...built.events); sequence = built.next; } return browserScenarioCaptureSchema.parse({ browser: { mode: "launch", process_ownership: "provider-owned", cleanup: "terminated-owned-process", product: "Chromium", version: "fixture", }, scenario: { start_origin: SCENARIO_ORIGIN, action_count: 1, secret_references: [], network_content: networkContent, event_families: ["network"], limits: DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS, }, duration_ms: 1, steps: [step(0), step(1)], events: { retained: items.length, dropped: 0, items, omissions: { by_kind: [], network_transactions: [], network_transaction_range: null, }, }, completeness: { status: "complete", equality_eligible: true, missing_sections: [], truncated_sections: [], }, limitations: [], }); }; /** The capture as Evidence, the way capture_browser_scenario records it. */ export const scenarioCaptureEvidence = ( capture: BrowserScenarioCapture = scenarioCaptureFixture(), ): Evidence => createBrowserScenarioEvidence( browserScenarioSchema.parse({ browser: { mode: "launch", executable_path: process.execPath }, start_url: { url: `${SCENARIO_ORIGIN}/`, query: [] }, actions: [ { step_id: "browse", action: "wait_for_timeout", duration_ms: 1 }, ], }), capture, { id: "fixture", name: "fixture", version: "1" }, ); /** Project the scenario capture through the real view service, with every opt-in rule. */ export const scenarioServeFixture = ( capture: Evidence = scenarioCaptureEvidence(), ): EnvironmentServeFixture => { const manifest = inspectAnalysisView({ source: { kind: "inline", evidence: capture }, view: { kind: "environment-manifest", template_rules: [...ALL_TEMPLATE_RULES], }, }); if (!manifest.ok) throw manifest.error; return { manifest: manifest.value, capture }; }; /** A scenario capture that kept header values but no bodies: it yields a manifest but no environment state. */ export const scenarioHeaderOnlyCaptureEvidence = (): Evidence => scenarioCaptureEvidence( scenarioCaptureFixture(undefined, { request_body: false, response_body: false, header_values: true, }), );