381 lines
12 KiB
TypeScript
381 lines
12 KiB
TypeScript
import type { BrowserScenarioCapturePort } from "../application/BrowserScenarioCapturePort.js";
|
|
import type {
|
|
ExecutionOptions,
|
|
ProviderIdentity,
|
|
} from "../application/AnalysisProvider.js";
|
|
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
|
|
import type {
|
|
BrowserScenario,
|
|
BrowserScenarioAction,
|
|
} from "../domain/browserScenario.js";
|
|
import {
|
|
browserScenarioCaptureSchema,
|
|
browserScenarioCompletenessSchema,
|
|
browserScenarioStepSchema,
|
|
browserStepArtifactsSchema,
|
|
type BrowserScenarioCapture,
|
|
type BrowserScenarioCompleteness,
|
|
type BrowserScenarioStep,
|
|
type BrowserStepArtifacts,
|
|
} from "../domain/browserScenarioCapture.js";
|
|
import {
|
|
AnalysisError,
|
|
BrowserObservationError,
|
|
ProviderAdapterError,
|
|
} from "../domain/errors.js";
|
|
import { err, ok, type Result } from "../domain/result.js";
|
|
import type {
|
|
BrowserScenarioSessionFactory,
|
|
BrowserScenarioSessionPort,
|
|
} from "./BrowserScenarioSessionPort.js";
|
|
import { BrowserScenarioCaptureBudget } from "./PlaywrightScenarioArtifacts.js";
|
|
import { PlaywrightScenarioSessionFactory } from "./PlaywrightScenarioSession.js";
|
|
|
|
const OPERATION = "capture_browser_scenario" as const;
|
|
|
|
export const PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY: ProviderIdentity =
|
|
Object.freeze({
|
|
id: "rea-playwright-browser-scenario",
|
|
name: "REA Playwright browser scenario capture provider",
|
|
version: "1",
|
|
});
|
|
|
|
type SnapshotKind = BrowserScenario["capture"]["after_each_step"][number];
|
|
|
|
const requestedForStep = (
|
|
scenario: BrowserScenario,
|
|
stepIndex: number,
|
|
): ReadonlySet<SnapshotKind> =>
|
|
new Set([
|
|
...scenario.capture.after_each_step,
|
|
...(stepIndex === scenario.actions.length ? scenario.capture.at_end : []),
|
|
]);
|
|
|
|
const unavailableArtifacts = (
|
|
requested: ReadonlySet<SnapshotKind>,
|
|
reason: string,
|
|
): BrowserStepArtifacts => {
|
|
const state = (kind: SnapshotKind) =>
|
|
requested.has(kind)
|
|
? { state: "missing" as const, reason }
|
|
: { state: "not_requested" as const };
|
|
return browserStepArtifactsSchema.parse({
|
|
screenshot: state("screenshot"),
|
|
dom: state("dom"),
|
|
accessibility: state("accessibility"),
|
|
url: state("url"),
|
|
history: state("history"),
|
|
storage: state("storage"),
|
|
});
|
|
};
|
|
|
|
const stepCompleteness = (
|
|
artifacts: BrowserStepArtifacts,
|
|
status: BrowserScenarioStep["status"],
|
|
): BrowserScenarioCompleteness => {
|
|
const missing = Object.entries(artifacts)
|
|
.filter(([, state]) => state.state === "missing")
|
|
.map(([section]) => section);
|
|
const truncated = Object.entries(artifacts)
|
|
.filter(([, state]) => state.state === "truncated")
|
|
.map(([section]) => section);
|
|
if (status !== "completed") missing.push("action");
|
|
const condition =
|
|
truncated.length > 0
|
|
? "truncated"
|
|
: missing.length > 0
|
|
? "incomplete"
|
|
: "complete";
|
|
return browserScenarioCompletenessSchema.parse({
|
|
status: condition,
|
|
equality_eligible: condition === "complete",
|
|
missing_sections: [...new Set(missing)].sort(),
|
|
truncated_sections: [...new Set(truncated)].sort(),
|
|
});
|
|
};
|
|
|
|
const createStep = (input: {
|
|
readonly stepIndex: number;
|
|
readonly stepId: string;
|
|
readonly action: string;
|
|
readonly status: BrowserScenarioStep["status"];
|
|
readonly elapsedMs: number;
|
|
readonly beforeUrl: string;
|
|
readonly afterUrl: string;
|
|
readonly error: string | null;
|
|
readonly eventStart: number;
|
|
readonly eventEnd: number;
|
|
readonly artifacts: BrowserStepArtifacts;
|
|
}): BrowserScenarioStep =>
|
|
browserScenarioStepSchema.parse({
|
|
step_index: input.stepIndex,
|
|
step_id: input.stepId,
|
|
action: input.action,
|
|
status: input.status,
|
|
elapsed_ms: input.elapsedMs,
|
|
before_url: sanitizeBrowserUrl(input.beforeUrl),
|
|
after_url: sanitizeBrowserUrl(input.afterUrl),
|
|
error: input.error,
|
|
event_sequence_start: input.eventStart,
|
|
event_sequence_end: input.eventEnd,
|
|
artifacts: input.artifacts,
|
|
completeness: stepCompleteness(input.artifacts, input.status),
|
|
});
|
|
|
|
const initialStep = async (input: {
|
|
readonly session: BrowserScenarioSessionPort;
|
|
readonly scenario: BrowserScenario;
|
|
readonly budget: BrowserScenarioCaptureBudget;
|
|
readonly elapsedMs: number;
|
|
readonly maximumTimeoutMs: number;
|
|
readonly signal: AbortSignal | undefined;
|
|
}): Promise<BrowserScenarioStep> => {
|
|
const { session, scenario, budget, elapsedMs, maximumTimeoutMs, signal } =
|
|
input;
|
|
const artifacts = await session.capture(
|
|
requestedForStep(scenario, 0),
|
|
budget,
|
|
maximumTimeoutMs,
|
|
signal,
|
|
);
|
|
return createStep({
|
|
stepIndex: 0,
|
|
stepId: "scenario_start",
|
|
action: "goto_start",
|
|
status: "completed",
|
|
elapsedMs,
|
|
beforeUrl: session.initialUrl,
|
|
afterUrl: session.currentUrl(),
|
|
error: null,
|
|
eventStart: 1,
|
|
eventEnd: session.lastEventSequence(),
|
|
artifacts,
|
|
});
|
|
};
|
|
|
|
const executeStep = async (input: {
|
|
readonly session: BrowserScenarioSessionPort;
|
|
readonly scenario: BrowserScenario;
|
|
readonly budget: BrowserScenarioCaptureBudget;
|
|
readonly action: BrowserScenarioAction;
|
|
readonly stepIndex: number;
|
|
readonly startedAt: number;
|
|
readonly priorFailure: boolean;
|
|
readonly signal: AbortSignal | undefined;
|
|
}): Promise<BrowserScenarioStep> => {
|
|
const {
|
|
session,
|
|
scenario,
|
|
budget,
|
|
action,
|
|
stepIndex,
|
|
startedAt,
|
|
priorFailure,
|
|
signal,
|
|
} = input;
|
|
const beforeUrl = session.currentUrl();
|
|
const eventStart = session.nextEventSequence();
|
|
const requested = requestedForStep(scenario, stepIndex);
|
|
if (priorFailure)
|
|
return createStep({
|
|
stepIndex,
|
|
stepId: action.step_id,
|
|
action: action.action,
|
|
status: "cancelled",
|
|
elapsedMs: 0,
|
|
beforeUrl,
|
|
afterUrl: beforeUrl,
|
|
error: "not executed after an earlier action failure",
|
|
eventStart,
|
|
eventEnd: session.lastEventSequence(),
|
|
artifacts: unavailableArtifacts(
|
|
requested,
|
|
"action was not executed after an earlier failure",
|
|
),
|
|
});
|
|
|
|
const actionStartedAt = Date.now();
|
|
session.setStep(stepIndex);
|
|
const requestCancelled = (): boolean => signal?.aborted === true;
|
|
let status: BrowserScenarioStep["status"] = "completed";
|
|
let error: string | null = null;
|
|
if (requestCancelled()) {
|
|
status = "cancelled";
|
|
error = "scenario request cancelled";
|
|
} else {
|
|
try {
|
|
const elapsed = Date.now() - startedAt;
|
|
const remaining = scenario.limits.max_duration_ms - elapsed;
|
|
if (remaining <= 0) throw new Error("Scenario duration limit reached");
|
|
await session.perform(action, remaining, signal);
|
|
} catch (cause) {
|
|
status = requestCancelled() ? "cancelled" : "failed";
|
|
error = session.redactError(cause);
|
|
}
|
|
}
|
|
const remaining = scenario.limits.max_duration_ms - (Date.now() - startedAt);
|
|
const artifacts =
|
|
status === "cancelled" || remaining <= 0
|
|
? unavailableArtifacts(
|
|
requested,
|
|
status === "cancelled"
|
|
? "scenario request cancelled"
|
|
: "scenario duration limit reached",
|
|
)
|
|
: await session.capture(requested, budget, remaining, signal);
|
|
return createStep({
|
|
stepIndex,
|
|
stepId: action.step_id,
|
|
action: action.action,
|
|
status,
|
|
elapsedMs: Date.now() - actionStartedAt,
|
|
beforeUrl,
|
|
afterUrl: session.currentUrl(),
|
|
error,
|
|
eventStart,
|
|
eventEnd: session.lastEventSequence(),
|
|
artifacts,
|
|
});
|
|
};
|
|
|
|
const globalCompleteness = (
|
|
scenario: BrowserScenario,
|
|
session: BrowserScenarioSessionPort,
|
|
steps: readonly BrowserScenarioStep[],
|
|
): BrowserScenarioCompleteness => {
|
|
const missing = steps.flatMap(
|
|
({ completeness }) => completeness.missing_sections,
|
|
);
|
|
const truncated = steps.flatMap(
|
|
({ completeness }) => completeness.truncated_sections,
|
|
);
|
|
truncated.push(...session.eventTruncationSections());
|
|
if (session.mode === "connect") missing.push("events");
|
|
const status =
|
|
truncated.length > 0
|
|
? "truncated"
|
|
: missing.length > 0
|
|
? "incomplete"
|
|
: "complete";
|
|
return browserScenarioCompletenessSchema.parse({
|
|
status,
|
|
equality_eligible: status === "complete",
|
|
missing_sections: [...new Set(missing)].sort(),
|
|
truncated_sections: [...new Set(truncated)].sort(),
|
|
});
|
|
};
|
|
|
|
const runScenario = async (
|
|
factory: BrowserScenarioSessionFactory,
|
|
scenario: BrowserScenario,
|
|
options: ExecutionOptions,
|
|
): Promise<BrowserScenarioCapture> => {
|
|
if (options.signal?.aborted === true)
|
|
throw new BrowserObservationError(OPERATION, "cancelled");
|
|
const startedAt = Date.now();
|
|
const budget = new BrowserScenarioCaptureBudget(
|
|
scenario.limits.max_screenshots,
|
|
scenario.limits.max_total_metadata_bytes,
|
|
);
|
|
const session = await factory.open(scenario, {
|
|
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
budget,
|
|
deadlineAt: startedAt + scenario.limits.max_duration_ms,
|
|
});
|
|
const steps: BrowserScenarioStep[] = [];
|
|
let cleanup: "terminated-owned-process" | "disconnected-external" | undefined;
|
|
try {
|
|
steps.push(
|
|
await initialStep({
|
|
session,
|
|
scenario,
|
|
budget,
|
|
elapsedMs: Date.now() - startedAt,
|
|
maximumTimeoutMs:
|
|
scenario.limits.max_duration_ms - (Date.now() - startedAt),
|
|
signal: options.signal,
|
|
}),
|
|
);
|
|
let failed = false;
|
|
for (const [offset, action] of scenario.actions.entries()) {
|
|
const step = await executeStep({
|
|
session,
|
|
scenario,
|
|
budget,
|
|
action,
|
|
stepIndex: offset + 1,
|
|
startedAt,
|
|
priorFailure: failed,
|
|
signal: options.signal,
|
|
});
|
|
steps.push(step);
|
|
failed ||= step.status !== "completed";
|
|
}
|
|
} finally {
|
|
cleanup = await session.close();
|
|
}
|
|
const events = session.events();
|
|
const completeness = globalCompleteness(scenario, session, steps);
|
|
return browserScenarioCaptureSchema.parse({
|
|
schema_version: 1,
|
|
browser: {
|
|
mode: session.mode,
|
|
process_ownership: session.processOwnership,
|
|
cleanup,
|
|
product: session.product,
|
|
version: session.version,
|
|
},
|
|
scenario: {
|
|
start_origin: new URL(scenario.start_url.url).origin,
|
|
allowed_origins: scenario.allowed_origins,
|
|
action_count: scenario.actions.length,
|
|
secret_references: scenario.secrets.map(({ secret_id: id }) => id).sort(),
|
|
},
|
|
duration_ms: Date.now() - startedAt,
|
|
steps,
|
|
events,
|
|
completeness,
|
|
limitations: [
|
|
"Event sequence records provider receipt order; simultaneous browser causality is not inferred.",
|
|
"Response bodies are not retained.",
|
|
"Storage values are hashed only after declared-secret redaction.",
|
|
...(session.mode === "connect"
|
|
? [
|
|
"CDP attachment cannot recover pre-attach events or guarantee launch-time context options.",
|
|
]
|
|
: []),
|
|
],
|
|
});
|
|
};
|
|
|
|
/** Controlled Playwright/CDP scenario driver with exact process ownership. */
|
|
export class PlaywrightBrowserScenarioProvider
|
|
implements BrowserScenarioCapturePort
|
|
{
|
|
constructor(
|
|
private readonly factory: BrowserScenarioSessionFactory = new PlaywrightScenarioSessionFactory(),
|
|
) {}
|
|
|
|
identity(): ProviderIdentity {
|
|
return PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY;
|
|
}
|
|
|
|
async captureScenario(
|
|
scenario: BrowserScenario,
|
|
options: ExecutionOptions = {},
|
|
): Promise<Result<BrowserScenarioCapture, AnalysisError>> {
|
|
try {
|
|
return ok(await runScenario(this.factory, scenario, options));
|
|
} catch (cause) {
|
|
if (cause instanceof AnalysisError) return err(cause);
|
|
return err(
|
|
new ProviderAdapterError(
|
|
PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY.id,
|
|
OPERATION,
|
|
{ cause },
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|