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,
}),
);