diff --git a/docs/recipe-contract.md b/docs/recipe-contract.md index 923989a4a..b9ceabdc2 100644 --- a/docs/recipe-contract.md +++ b/docs/recipe-contract.md @@ -100,11 +100,13 @@ top-level fields: - `dependency_overlays` - `runtimeEnv` - `secretEnv` +- `services` - `externalServices` - `pluginRuntime` - `fixtureDatabases` - `fixtureUsers` - `userSessions` +- `browserActors` - `siteSeeds` - `stagedFiles` - `sourcePackages` @@ -561,6 +563,47 @@ caller-owned external checks: } ``` +### Coordinated Browser Actors + +`inputs.browserActors` names the authenticated actors available to coordinated +browser scenarios. Every actor must explicitly reference an existing +`inputs.userSessions` entry. This keeps browser identity declarative and avoids +ambient session selection. + +```json +{ + "inputs": { + "fixtureUsers": [ + { "name": "author", "username": "fixture-author", "role": "editor" }, + { "name": "reviewer", "username": "fixture-reviewer", "role": "editor" } + ], + "userSessions": [ + { "name": "author-session", "user": "author" }, + { "name": "reviewer-session", "user": "reviewer" } + ], + "browserActors": [ + { "name": "author", "userSession": "author-session" }, + { "name": "reviewer", "userSession": "reviewer-session" } + ] + } +} +``` + +Pass the public `wp-codebox/browser-multi-actor-scenario/v1` object as +`wordpress.browser-scenario` `scenario-json`. It uses actor names, a seed, action +ids, optional named barriers, and bounded request gates. Each actor receives an +isolated authenticated Playwright context and page. The scenario summary records +the complete input and deterministic schedule; actor-scoped screenshots, traces, +console, network, step, and error files are written before deterministic teardown, +including when a barrier or gate times out. + +Actions are launched in the seed-derived schedule order. A barrier holds its +declared actors until every participant arrives. A request gate only holds the +declared actor's matching URL; `occurrence` selects which matching request to +hold (default `1`), and an action releases named gates in its declared order. +Timeouts abort held requests, release all waits, and retain partial replay and +actor evidence. Scenarios without `actors` keep the normal single-browser path. + `tool` is the exact caller-provided host tool command name and must be allowed by runtime policy using that same command name. `input` must be JSON-serializable. WP Codebox treats this as transport and evidence only; callers own the tool and diff --git a/examples/recipes/cookbook/multi-actor-browser-scenario.json b/examples/recipes/cookbook/multi-actor-browser-scenario.json new file mode 100644 index 000000000..dcd7b7c88 --- /dev/null +++ b/examples/recipes/cookbook/multi-actor-browser-scenario.json @@ -0,0 +1,27 @@ +{ + "schema": "wp-codebox/workspace-recipe/v1", + "inputs": { + "fixtureUsers": [ + { "name": "author", "username": "fixture-author", "role": "editor" }, + { "name": "reviewer", "username": "fixture-reviewer", "role": "editor" } + ], + "userSessions": [ + { "name": "author-session", "user": "author" }, + { "name": "reviewer-session", "user": "reviewer" } + ], + "browserActors": [ + { "name": "author", "userSession": "author-session" }, + { "name": "reviewer", "userSession": "reviewer-session" } + ] + }, + "workflow": { + "steps": [ + { + "command": "wordpress.browser-scenario", + "args": [ + "scenario-json={\"schema\":\"wp-codebox/browser-multi-actor-scenario/v1\",\"url\":\"/\",\"seed\":\"two-editors\",\"actors\":[{\"name\":\"author\",\"userSession\":\"author-session\"},{\"name\":\"reviewer\",\"userSession\":\"reviewer-session\"}],\"actions\":[{\"id\":\"author-ready\",\"actor\":\"author\",\"step\":{\"kind\":\"waitFor\",\"waitFor\":\"duration\",\"duration\":\"1ms\"},\"barrier\":\"both-ready\"},{\"id\":\"reviewer-ready\",\"actor\":\"reviewer\",\"step\":{\"kind\":\"waitFor\",\"waitFor\":\"duration\",\"duration\":\"1ms\"},\"barrier\":\"both-ready\"}],\"barriers\":[{\"name\":\"both-ready\",\"actors\":[\"author\",\"reviewer\"],\"timeoutMs\":5000}]}" + ] + } + ] + } +} diff --git a/package.json b/package.json index 500e0912a..bc22656e8 100644 --- a/package.json +++ b/package.json @@ -156,6 +156,7 @@ "test:browser-provider-bridge-inheritance": "tsx tests/browser-provider-bridge-inheritance.test.ts", "test:fixture-auth-storage-state": "tsx tests/fixture-auth-storage-state.test.ts", "test:recipe-user-session": "tsx tests/recipe-user-session.test.ts", + "test:browser-scenarios": "tsx tests/browser-multi-actor-scenario.test.ts", "test:reviewer-access": "tsx tests/reviewer-access.test.ts", "test:command-router": "tsx tests/command-router.test.ts", "test:command-agent-run": "tsx tests/command-agent-run.test.ts", diff --git a/packages/cli/src/commands/recipe-run.ts b/packages/cli/src/commands/recipe-run.ts index 71ec9a8e6..ef1412b08 100644 --- a/packages/cli/src/commands/recipe-run.ts +++ b/packages/cli/src/commands/recipe-run.ts @@ -1457,6 +1457,10 @@ function recipeRunMetadata(recipe: WorkspaceRecipe, recipePath: string, workspac dependency_overlays: recipe.inputs?.dependency_overlays ?? [], pluginRuntime: recipe.inputs?.pluginRuntime ?? {}, fixtureDatabases: recipe.inputs?.fixtureDatabases ?? [], + // Browser and command session resolution needs identities, never fixture passwords. + fixtureUsers: recipeRuntimeFixtureUsers(recipe), + userSessions: recipe.inputs?.userSessions ?? [], + browserActors: recipe.inputs?.browserActors ?? [], siteSeeds: recipe.inputs?.siteSeeds ?? [], siteSeedProvenance, stagedFiles: recipe.inputs?.stagedFiles ?? [], @@ -1488,6 +1492,9 @@ function recipeRunMetadata(recipe: WorkspaceRecipe, recipePath: string, workspac dependency_overlays: recipe.inputs?.dependency_overlays ?? [], pluginRuntime: recipe.inputs?.pluginRuntime ?? {}, fixtureDatabases: recipe.inputs?.fixtureDatabases ?? [], + fixtureUsers: recipeRuntimeFixtureUsers(recipe), + userSessions: recipe.inputs?.userSessions ?? [], + browserActors: recipe.inputs?.browserActors ?? [], siteSeeds: recipe.inputs?.siteSeeds ?? [], siteSeedProvenance, stagedFiles: recipe.inputs?.stagedFiles ?? [], @@ -1525,6 +1532,10 @@ function recipeRunMetadata(recipe: WorkspaceRecipe, recipePath: string, workspac } } +function recipeRuntimeFixtureUsers(recipe: WorkspaceRecipe): Array["fixtureUsers"]>[number], "password">> { + return (recipe.inputs?.fixtureUsers ?? []).map(({ password: _password, ...user }) => user) +} + function recipeComponentManifest(extraPlugins: PreparedExtraPlugin[], fallback: WorkspaceRecipeComponentManifest | undefined): Record | undefined { if (extraPlugins.length === 0) { return fallback as Record | undefined diff --git a/packages/cli/src/recipe-validation.ts b/packages/cli/src/recipe-validation.ts index 6e2f2ae0e..9aa7f910f 100644 --- a/packages/cli/src/recipe-validation.ts +++ b/packages/cli/src/recipe-validation.ts @@ -80,6 +80,14 @@ export function validateWorkspaceRecipeShape(recipe: WorkspaceRecipe, recipePath throw new Error(`Recipe must include at least one workflow step: ${recipePath}`) } + const sessions = new Set((recipe.inputs?.userSessions ?? []).map((session) => session.name)) + const actors = new Set() + for (const [index, actor] of (recipe.inputs?.browserActors ?? []).entries()) { + if (actors.has(actor.name)) throw new Error(`Recipe browser actor names must be unique: ${actor.name}`) + if (!sessions.has(actor.userSession)) throw new Error(`Recipe browser actor ${actor.name} references unknown user session ${actor.userSession} at inputs.browserActors[${index}]`) + actors.add(actor.name) + } + for (const phase of ["before", "after"] as const) { if (recipe.workflow[phase] !== undefined && !Array.isArray(recipe.workflow[phase])) { throw new Error(`Recipe workflow ${phase} must be an array: ${recipePath}`) diff --git a/packages/runtime-core/src/browser-multi-actor-scenario-contracts.ts b/packages/runtime-core/src/browser-multi-actor-scenario-contracts.ts new file mode 100644 index 000000000..b19bffbde --- /dev/null +++ b/packages/runtime-core/src/browser-multi-actor-scenario-contracts.ts @@ -0,0 +1,43 @@ +import type { BrowserInteractionStep } from "./browser-interaction.js" +import type { WorkspaceRecipeBrowserActor } from "./runtime-contracts.js" + +export const BROWSER_MULTI_ACTOR_SCENARIO_SCHEMA = "wp-codebox/browser-multi-actor-scenario/v1" as const + +export interface BrowserMultiActorAction { + id: string + actor: string + step: BrowserInteractionStep + barrier?: string + releaseGates?: string[] +} + +export interface BrowserMultiActorBarrier { + name: string + actors: string[] + timeoutMs?: number +} + +export interface BrowserMultiActorRequestGate { + name: string + actor: string + url: string + occurrence?: number + timeoutMs?: number +} + +export interface BrowserMultiActorScenario { + schema: typeof BROWSER_MULTI_ACTOR_SCENARIO_SCHEMA + seed: string + actors: WorkspaceRecipeBrowserActor[] + actions: BrowserMultiActorAction[] + barriers?: BrowserMultiActorBarrier[] + requestGates?: BrowserMultiActorRequestGate[] + timeoutMs?: number +} + +export interface BrowserMultiActorReplayArtifact { + schema: "wp-codebox/browser-multi-actor-replay/v1" + seed: string + scenario: BrowserMultiActorScenario + schedule: string[] +} diff --git a/packages/runtime-core/src/contracts.ts b/packages/runtime-core/src/contracts.ts index 72274d3a8..067c27cca 100644 --- a/packages/runtime-core/src/contracts.ts +++ b/packages/runtime-core/src/contracts.ts @@ -1,5 +1,6 @@ /** Inspectable Codebox contract metadata for CLI and orchestrator consumers. */ export * from "./browser-probe-contract.js" +export * from "./browser-multi-actor-scenario-contracts.js" export * from "./command-registry.js" export * from "./fuzz-fixture-plan-contracts.js" export * from "./fuzz-coverage-plan-contracts.js" diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 96170a6e3..f4ef9cf63 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -48,6 +48,7 @@ export * from "./command-diagnostics.js" export * from "./command-agent-run.js" export * from "./php-worker-runner.js" export * from "./browser-interaction.js" +export * from "./browser-multi-actor-scenario-contracts.js" export * from "./browser-probe-contract.js" export * from "./browser-playground-session-run.js" export * from "./browser-review-bridge.js" diff --git a/packages/runtime-core/src/public.ts b/packages/runtime-core/src/public.ts index ef54a5046..2705bb6c9 100644 --- a/packages/runtime-core/src/public.ts +++ b/packages/runtime-core/src/public.ts @@ -24,6 +24,7 @@ export * from "./artifact-test-results.js" export * from "./browser-artifact-lifecycle.js" export * from "./browser-callback-contracts.js" export * from "./browser-interaction.js" +export * from "./browser-multi-actor-scenario-contracts.js" export * from "./browser-probe-contract.js" export * from "./browser-playground-session-run.js" export * from "./browser-result-shapes.js" diff --git a/packages/runtime-core/src/recipe-schema.ts b/packages/runtime-core/src/recipe-schema.ts index 33540979b..083b7d3a4 100644 --- a/packages/runtime-core/src/recipe-schema.ts +++ b/packages/runtime-core/src/recipe-schema.ts @@ -202,6 +202,11 @@ export function createWorkspaceRecipeJsonSchema(options: WorkspaceRecipeJsonSche description: "Named command execution sessions that reference fixture users. Cookie/token/storage-state artifacts remain redaction-required metadata.", items: { $ref: "#/$defs/userSession" }, }, + browserActors: { + type: "array", + description: "Named browser actors explicitly bound to declared userSessions for coordinated browser scenarios.", + items: { $ref: "#/$defs/browserActor" }, + }, siteSeeds: { type: "array", description: "Explicit site/content seed declarations. Local JSON fixture seeds are imported into the sandbox before workflow steps. Parent-site declarations remain bounded, auditable metadata until export support lands.", @@ -966,6 +971,16 @@ export function createWorkspaceRecipeJsonSchema(options: WorkspaceRecipeJsonSche metadata: { $ref: "#/$defs/metadata" }, }, }, + browserActor: { + type: "object", + additionalProperties: false, + required: ["name", "userSession"], + properties: { + name: { type: "string", minLength: 1, pattern: "^[A-Za-z0-9._-]+$" }, + userSession: { type: "string", minLength: 1 }, + metadata: { $ref: "#/$defs/metadata" }, + }, + }, recipeProbe: { type: "object", additionalProperties: false, diff --git a/packages/runtime-core/src/runtime-contracts.ts b/packages/runtime-core/src/runtime-contracts.ts index 8b9a174be..935862263 100644 --- a/packages/runtime-core/src/runtime-contracts.ts +++ b/packages/runtime-core/src/runtime-contracts.ts @@ -356,6 +356,12 @@ export interface WorkspaceRecipeUserSession { metadata?: Record } +export interface WorkspaceRecipeBrowserActor { + name: string + userSession: string + metadata?: Record +} + export interface WorkspaceRecipeProbe { name: string step: WorkspaceRecipeStep @@ -608,6 +614,7 @@ export interface WorkspaceRecipe { fixtureDatabases?: WorkspaceRecipeFixtureDatabase[] fixtureUsers?: WorkspaceRecipeFixtureUser[] userSessions?: WorkspaceRecipeUserSession[] + browserActors?: WorkspaceRecipeBrowserActor[] siteSeeds?: WorkspaceRecipeSiteSeed[] stagedFiles?: WorkspaceRecipeStagedFile[] sourcePackages?: WorkspaceRecipeSourcePackage[] diff --git a/packages/runtime-playground/src/browser-actions-runner.ts b/packages/runtime-playground/src/browser-actions-runner.ts index 5e21ae9de..9f62d175c 100644 --- a/packages/runtime-playground/src/browser-actions-runner.ts +++ b/packages/runtime-playground/src/browser-actions-runner.ts @@ -1,9 +1,10 @@ import { readFile } from "node:fs/promises" -import { BROWSER_ACTION_CORPUS_SCHEMA, BROWSER_TOOL_VERIFIER_RESULT_SCHEMA, HostToolRegistry, assertRuntimeCommandAllowed, browserActionCorpusArtifact, browserActionCorpusContract, browserInteractionScriptUsesEvaluate, browserToolVerifierInputSummary, createHostToolRegistry, executeHostTool, resolveCommandPath, validateBrowserInteractionScript, type BrowserActionCorpusArtifact, type BrowserActionCorpusContract, type BrowserActionCorpusDescriptor, type BrowserInteractionStep, type BrowserToolVerifierResult, type ExecutionSpec, type HostToolDefinition, type JsonValue, type RuntimeCreateSpec } from "@automattic/wp-codebox-core" +import { BROWSER_ACTION_CORPUS_SCHEMA, BROWSER_MULTI_ACTOR_SCENARIO_SCHEMA, BROWSER_TOOL_VERIFIER_RESULT_SCHEMA, HostToolRegistry, assertRuntimeCommandAllowed, browserActionCorpusArtifact, browserActionCorpusContract, browserInteractionScriptUsesEvaluate, browserToolVerifierInputSummary, createHostToolRegistry, executeHostTool, resolveCommandPath, validateBrowserInteractionScript, type BrowserActionCorpusArtifact, type BrowserActionCorpusContract, type BrowserActionCorpusDescriptor, type BrowserInteractionStep, type BrowserMultiActorScenario, type BrowserToolVerifierResult, type ExecutionSpec, type HostToolDefinition, type JsonValue, type RuntimeCreateSpec } 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" import { BrowserCommandArtifactError, isBrowserCommandArtifactError } from "./browser-command-artifact-error.js" +import { runBrowserMultiActorScenarioCommand } from "./browser-multi-actor-scenario-runner.js" import type { BrowserArtifact, BrowserProbeAuthSummary, BrowserProbeErrorRecord, BrowserProbeNetworkRecord, BrowserProbeViewport, BrowserProbeWebSocketRecord, BrowserStepRecord } from "./browser-artifacts.js" import { attachBrowserCaptureListeners, launchChromiumBrowser, settleBrowserNetworkTasks } from "./browser-capture-session.js" import { captureBrowserDomSnapshot, type BrowserDomSnapshotArtifact } from "./browser-dom-snapshot.js" @@ -735,6 +736,12 @@ interface BrowserScenarioInput { duration?: string stepTimeout?: string timeout?: string + schema?: string + seed?: string + actors?: BrowserMultiActorScenario["actors"] + actions?: BrowserMultiActorScenario["actions"] + barriers?: BrowserMultiActorScenario["barriers"] + requestGates?: BrowserMultiActorScenario["requestGates"] } export async function runBrowserScenarioCommand({ @@ -752,6 +759,10 @@ export async function runBrowserScenarioCommand({ }): Promise<{ artifact: BrowserArtifact; output: string }> { const args = spec.args ?? [] const scenario = await browserScenarioFromArgs(args) + const multiActorScenario = browserMultiActorScenario(scenario, args) + if (multiActorScenario) { + return runBrowserMultiActorScenarioCommand({ artifactRoot, scenario: multiActorScenario, runtimeSpec, runPlaygroundCommand, server }) + } const url = scenario.url?.trim() || argValue(args, "url")?.trim() if (!url) { throw new Error("wordpress.browser-scenario requires url= or scenario-json.url") @@ -870,6 +881,19 @@ async function browserScenarioFromArgs(args: string[]): Promise") + if (scenario.schema !== BROWSER_MULTI_ACTOR_SCENARIO_SCHEMA || !scenario.seed || !scenario.actions) throw new Error(`Multi-actor wordpress.browser-scenario requires schema=${BROWSER_MULTI_ACTOR_SCENARIO_SCHEMA}, seed, actors, and actions`) + if (!Array.isArray(scenario.actors) || !Array.isArray(scenario.actions) || !scenario.actors.every((actor) => actor && typeof actor.name === "string" && typeof actor.userSession === "string") || !scenario.actions.every((action) => action && typeof action.id === "string" && typeof action.actor === "string" && validateBrowserInteractionScript([action.step]).valid)) { + throw new Error("Multi-actor wordpress.browser-scenario requires named actors and valid browser interaction steps") + } + if (scenario.barriers && (!Array.isArray(scenario.barriers) || !scenario.barriers.every((barrier) => barrier && typeof barrier.name === "string" && Array.isArray(barrier.actors)))) throw new Error("Multi-actor browser scenario barriers must have a name and actor list") + if (scenario.requestGates && (!Array.isArray(scenario.requestGates) || !scenario.requestGates.every((gate) => gate && typeof gate.name === "string" && typeof gate.actor === "string" && typeof gate.url === "string" && (gate.occurrence === undefined || (Number.isInteger(gate.occurrence) && gate.occurrence > 0))))) throw new Error("Multi-actor browser scenario request gates must have a name, actor, URL, and positive occurrence") + return { schema: scenario.schema, seed: scenario.seed, actors: scenario.actors, actions: scenario.actions, ...(scenario.barriers ? { barriers: scenario.barriers } : {}), ...(scenario.requestGates ? { requestGates: scenario.requestGates } : {}), ...(typeof scenario.timeout === "string" ? { timeoutMs: durationStringMs(scenario.timeout) } : {}), url, captures: browserScenarioCaptures(scenario, args), stepTimeoutMs: durationStringMs(scenario.stepTimeout ?? argValue(args, "step-timeout")) || BROWSER_STEP_DEFAULT_TIMEOUT_MS } +} + function browserScenarioCaptures(scenario: BrowserScenarioInput, args: string[]): string[] { const raw = scenario.captures ?? commaListArg(args, "capture") const captures = Array.isArray(raw) ? raw.map(String).filter(Boolean) : [] diff --git a/packages/runtime-playground/src/browser-artifacts.ts b/packages/runtime-playground/src/browser-artifacts.ts index af70fa71f..a5136096e 100644 --- a/packages/runtime-playground/src/browser-artifacts.ts +++ b/packages/runtime-playground/src/browser-artifacts.ts @@ -80,6 +80,7 @@ export interface BrowserArtifactFiles { performance?: string review?: string screenshot?: string + traces?: string[] domSnapshots?: string[] verifierResults?: string[] actionCorpus?: string @@ -186,6 +187,12 @@ export interface BrowserArtifactSummary { wordpressDiagnostics?: BrowserWordPressDiagnosticsSummary context?: BrowserProbeContextDetails auth?: BrowserProbeAuthSummary + multiActor?: { + seed: string + finalState: "completed" | "failed" + actors: string[] + replay: string + } capabilities?: BrowserProbeCapabilityDiagnostics replayability: BrowserProbeReplayability screenshot: boolean @@ -1096,6 +1103,7 @@ const BROWSER_ARTIFACT_FILE_MANIFEST: Record Promise + server: PlaygroundCliServer +}): Promise<{ artifact: BrowserArtifact; output: string }> { + const { artifactRoot, scenario, runtimeSpec, runPlaygroundCommand, server } = input + // Traces are always retained for replay, even when callers narrow display captures. + const captures = new Set([...(scenario.captures ?? ["steps", "console", "errors", "network", "screenshot"]), "trace"]) + const artifacts = new BrowserArtifactSession(artifactRoot, "files/browser", { source: "wordpress.browser-scenario", operation: "browser-multi-actor-scenario" }) + const topology = browserPreviewTopology([], runtimeSpec, server.serverUrl) + const browser = await launchChromiumBrowser() + const evidence: Record = {} + let result: BrowserMultiActorScenarioResult | undefined + let failure: Error | undefined + + try { + const clientEntries: Array<[string, BrowserMultiActorClient]> = [] + // Playground PHP commands share one runtime endpoint, so provision identities + // and install cookies serially before actions begin concurrently. + for (const actor of scenario.actors) { + const session = wordpressUserSessionFromCommandArgs([`session=${actor.userSession}`], runtimeSpec) + if (!session) throw new Error(`Actor ${actor.name} requires user session ${actor.userSession}`) + const userId = await actorUserId(actor.name, session.user.userId, session.user, runtimeSpec, runPlaygroundCommand, server) + const context = await browser.newContext() + await routeBrowserPreviewContextNetwork(context, topology.networkPolicy, topology.preview.effectiveOrigin) + const page = await context.newPage() + await context.tracing.start({ screenshots: true, snapshots: true }) + await installWordPressAdminAuthCookies({ command: "wordpress.browser-scenario", cookieUrls: topology.authCookieUrls([topology.resolveUrl(scenario.url)]), page, runPlaygroundCommand, runtimeSpec, server, userId }) + const actorEvidence = evidence[actor.name] = { console: [], errors: [], network: [], steps: [], files: {} } + const networkTasks: Array> = [] + attachBrowserCaptureListeners({ captureConsole: captures.has("console"), captureErrors: captures.has("errors"), captureNetwork: true, consoleMessages: actorEvidence.console, errors: actorEvidence.errors, network: actorEvidence.network, networkTasks, page }) + clientEntries.push([actor.name, actorClient({ actor: actor.name, artifacts, captures, context, evidence: actorEvidence, networkTasks, page, scenario, previewOrigin: topology.preview.effectiveOrigin })]) + } + const clients = Object.fromEntries(clientEntries) + result = await runBrowserMultiActorScenario(scenario, clients) + } catch (error) { + failure = error instanceof Error ? error : new Error(String(error)) + if (error instanceof BrowserMultiActorScenarioError) result = error.result + } finally { + await browser.close() + } + + const replay = result?.replay ?? { schema: "wp-codebox/browser-multi-actor-replay/v1", seed: scenario.seed, scenario, schedule: [] } + const eventRecords = result?.events ?? [] + const network = Object.entries(evidence).flatMap(([actor, actorEvidence]) => actorEvidence.network.map((record) => ({ actor, ...record }))) + const requestCoverage = network.map((record) => ({ actor: record.actor, url: record.url, method: record.method, status: record.status })) + const summary = { schema: "wp-codebox/browser-multi-actor-scenario-result/v1", capturedAt: now(), normalizedReplayInput: replay, scenario: result, actors: evidence, ...(failure ? { error: failure.message } : {}) } + await artifacts.writeJson("summary", "multi-actor-scenario-summary.json", summary) + await artifacts.writeJson("steps", "multi-actor-events.json", eventRecords) + await artifacts.writeJson("steps", "multi-actor-replay.json", replay) + await artifacts.writeJson("network", "multi-actor-network.json", network) + await artifacts.writeJson("requestCoverage", "multi-actor-request-coverage.json", requestCoverage) + await artifacts.writeJson("waterfall", "multi-actor-waterfall.json", network) + const target = topology.resolveUrl(scenario.url) + const traces = Object.values(evidence).map((actor) => actor.files.trace).filter((path): path is string => Boolean(path)) + const artifact = { artifactType: "scenario" as const, requestedUrl: target, url: target, preview: topology.preview, ...topology.origins, files: { summary: "files/browser/multi-actor-scenario-summary.json", steps: "files/browser/multi-actor-events.json", network: "files/browser/multi-actor-network.json", requestCoverage: "files/browser/multi-actor-request-coverage.json", waterfall: "files/browser/multi-actor-waterfall.json", ...(traces.length > 0 ? { traces } : {}) }, summary: { actions: scenario.actions.length, steps: scenario.actions.length, consoleMessages: Object.values(evidence).reduce((total, actor) => total + actor.console.length, 0), errors: Object.values(evidence).reduce((total, actor) => total + actor.errors.length, 0), finalUrl: target, htmlSnapshot: false, networkEvents: network.length, replayability: browserProbeReplayability(captures), screenshot: captures.has("screenshot"), viewport: null, multiActor: { seed: scenario.seed, finalState: result?.finalState ?? "failed", actors: Object.keys(evidence), replay: "files/browser/multi-actor-replay.json" } } } satisfies BrowserArtifact + if (failure) throw new BrowserCommandArtifactError(`wordpress.browser-scenario failed: ${failure.message}`, artifact) + return { artifact, output: `${JSON.stringify({ command: "wordpress.browser-scenario", files: artifact.files, summary: artifact.summary, scenario: summary }, null, 2)}\n` } +} + +interface ActorEvidence { + console: Record[] + errors: BrowserProbeErrorRecord[] + network: BrowserProbeNetworkRecord[] + steps: Array> + files: Record +} + +function actorClient(input: { actor: string; artifacts: BrowserArtifactSession; captures: Set; context: import("playwright").BrowserContext; evidence: ActorEvidence; networkTasks: Array>; page: import("playwright").Page; scenario: BrowserMultiActorScenario & { stepTimeoutMs?: number }; previewOrigin: string }): BrowserMultiActorClient { + const { actor, artifacts, captures, context, evidence, networkTasks, page, scenario, previewOrigin } = input + return { + async execute(action) { + const startedAt = now() + try { + await executeBrowserInteractionStep(page, action.step, previewOrigin, scenario.stepTimeoutMs ?? DEFAULT_STEP_TIMEOUT_MS, async (name, write) => { + const fileName = `${actor}-${name}` + await artifacts.writeGenerated("screenshot", fileName, write) + return { path: artifacts.path(fileName), isDefault: false } + }) + evidence.steps.push({ action: action.id, status: "ok", startedAt, finalUrl: page.url() }) + } catch (error) { + evidence.steps.push({ action: action.id, status: "failed", startedAt, finalUrl: page.url(), error: error instanceof Error ? error.message : String(error) }) + throw error + } + }, + async onRequest(listener) { + for (const gate of scenario.requestGates?.filter((candidate) => candidate.actor === actor) ?? []) { + await page.route(gate.url, (route) => listener(route.request().url(), () => route.continue(), (reason) => route.abort(reason))) + } + }, + async close() { + try { + await settleBrowserNetworkTasks(networkTasks) + if (captures.has("screenshot")) { + const name = `${actor}-screenshot.png` + await artifacts.writeGenerated("screenshot", name, (path) => page.screenshot({ path, fullPage: true }).then(() => undefined)).catch(() => undefined) + evidence.files.screenshot = artifacts.path(name) + } + if (captures.has("trace")) { + const name = `${actor}-trace.zip` + await artifacts.writeGenerated("traces", name, (path) => context.tracing.stop({ path })).catch(() => undefined) + evidence.files.trace = artifacts.path(name) + } + for (const [key, records] of Object.entries({ console: evidence.console, errors: evidence.errors, network: evidence.network, steps: evidence.steps })) { + if (key === "steps" || captures.has(key)) { + const name = `${actor}-${key}.jsonl` + await artifacts.writeJsonLines(key as "console" | "errors" | "network" | "steps", name, records) + evidence.files[key] = artifacts.path(name) + } + } + } finally { + await context.close() + } + }, + } +} + +async function actorUserId(actor: string, knownUserId: number | undefined, user: WordPressFixtureUserSpec, runtimeSpec: RuntimeCreateSpec, runPlaygroundCommand: ((command: string, server: PlaygroundCliServer, options: { code: string } | { scriptPath: string }) => Promise) | undefined, server: PlaygroundCliServer): Promise { + if (knownUserId) return knownUserId + if (!runPlaygroundCommand) throw new Error(`Actor ${actor} requires a fixture user ID and Playground PHP command support`) + const response = await runPlaygroundCommand("wordpress.browser-scenario.actor-auth", server, { code: bootstrapPhpCode(runtimeSpec, `${wordpressFixtureUserPhpCode(user)} echo (string) get_current_user_id();`, []) }) + assertPlaygroundResponseOk("wordpress.browser-scenario.actor-auth", response) + const match = response.text.match(/(\d+)\s*$/) + if (!match) throw new Error(`Actor ${actor} fixture user ID was not returned`) + return Number(match[1]) +} diff --git a/packages/runtime-playground/src/browser-multi-actor-scenario.ts b/packages/runtime-playground/src/browser-multi-actor-scenario.ts new file mode 100644 index 000000000..260477552 --- /dev/null +++ b/packages/runtime-playground/src/browser-multi-actor-scenario.ts @@ -0,0 +1,182 @@ +import { now } from "@automattic/wp-codebox-core/internals" +import { BROWSER_MULTI_ACTOR_SCENARIO_SCHEMA, type BrowserMultiActorReplayArtifact, type BrowserMultiActorScenario } from "@automattic/wp-codebox-core" + +export interface BrowserMultiActorEvent { + type: "action" | "barrier" | "request-gate" | "failure" | "teardown" + name: string + actor?: string + status: "started" | "waiting" | "released" | "completed" | "failed" | "closed" + timestamp: string + details?: Record +} + +export interface BrowserMultiActorClient { + close(): Promise + execute(action: BrowserMultiActorScenario["actions"][number]): Promise + onRequest?(listener: (url: string, release: () => Promise, fail: (reason: string) => Promise) => Promise): Promise +} + +export interface BrowserMultiActorScenarioResult { + schema: "wp-codebox/browser-multi-actor-result/v1" + seed: string + schedule: string[] + events: BrowserMultiActorEvent[] + replay: BrowserMultiActorReplayArtifact + finalState: "completed" | "failed" +} + +/** Carries replayable partial evidence when coordinated execution cannot finish. */ +export class BrowserMultiActorScenarioError extends Error { + constructor(message: string, readonly result: BrowserMultiActorScenarioResult) { + super(message) + } +} + +const DEFAULT_TIMEOUT_MS = 15_000 + +export async function runBrowserMultiActorScenario(scenario: BrowserMultiActorScenario, clients: Record): Promise { + validateScenario(scenario, clients) + const events: BrowserMultiActorEvent[] = [] + const schedule = seededSchedule(scenario.actions.map((action) => action.id), scenario.seed) + const actions = new Map(scenario.actions.map((action) => [action.id, action])) + const gateReleases = new Map void>() + const gateFails = new Map void>() + const gates = new Map(scenario.requestGates?.map((gate) => [gate.name, gate]) ?? []) + const barriers = new Map(scenario.barriers?.map((barrier) => [barrier.name, barrier]) ?? []) + const arrived = new Map>() + const waiters = new Map void>>() + const waiterFails = new Map void>>() + const gateOccurrences = new Map() + let cancel: (reason: Error) => void = () => undefined + const cancelled = new Promise((_resolve, reject) => { cancel = reject }) + let failure: Error | undefined + + for (const gate of gates.values()) { + await clients[gate.actor]!.onRequest?.(async (url, release, fail) => { + if (url !== gate.url || gateReleases.has(gate.name)) return release() + const occurrence = (gateOccurrences.get(gate.name) ?? 0) + 1 + gateOccurrences.set(gate.name, occurrence) + if (occurrence !== (gate.occurrence ?? 1)) return release() + events.push(event("request-gate", gate.name, gate.actor, "waiting", { url, occurrence })) + const timer = setTimeout(() => { + gateReleases.delete(gate.name) + gateFails.delete(gate.name) + const reason = `Request gate ${gate.name} timed out after ${gate.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms` + events.push(event("request-gate", gate.name, gate.actor, "failed", { reason: "timeout", url, occurrence })) + void fail(reason) + cancel(new Error(reason)) + }, gate.timeoutMs ?? DEFAULT_TIMEOUT_MS) + gateReleases.set(gate.name, () => { + clearTimeout(timer) + gateReleases.delete(gate.name) + gateFails.delete(gate.name) + events.push(event("request-gate", gate.name, gate.actor, "released", { url, occurrence })) + void release() + }) + gateFails.set(gate.name, (reason) => { + clearTimeout(timer) + gateReleases.delete(gate.name) + gateFails.delete(gate.name) + events.push(event("request-gate", gate.name, gate.actor, "failed", { reason, url, occurrence })) + void fail(reason) + }) + }) + } + + try { + const tasks: Promise[] = [] + for (const id of schedule) { + const action = actions.get(id)! + tasks.push((async () => { + events.push(event("action", action.id, action.actor, "started")) + try { + await clients[action.actor]!.execute(action) + } catch (error) { + throw new Error(`Actor ${action.actor} action ${action.id} failed: ${error instanceof Error ? error.message : String(error)}`) + } + events.push(event("action", action.id, action.actor, "completed")) + for (const gate of action.releaseGates ?? []) { + const release = gateReleases.get(gate) + if (!release) throw new Error(`Action ${action.id} released request gate ${gate} before it was held`) + release() + } + if (action.barrier) { + const barrier = barriers.get(action.barrier) + if (!barrier) throw new Error(`Action ${action.id} references unknown barrier ${action.barrier}`) + const participants = arrived.get(barrier.name) ?? new Set() + participants.add(action.actor) + arrived.set(barrier.name, participants) + events.push(event("barrier", barrier.name, action.actor, participants.size === barrier.actors.length ? "released" : "waiting", { participants: [...participants].sort() })) + if (participants.size === barrier.actors.length) { + for (const resolve of waiters.get(barrier.name) ?? []) resolve() + waiters.delete(barrier.name) + } else { + await withTimeout(new Promise((resolve, reject) => { + waiters.set(barrier.name, [...(waiters.get(barrier.name) ?? []), resolve]) + waiterFails.set(barrier.name, [...(waiterFails.get(barrier.name) ?? []), reject]) + }), barrier.timeoutMs ?? scenario.timeoutMs ?? DEFAULT_TIMEOUT_MS, `Barrier ${barrier.name} timed out; waiting actors: ${barrier.actors.filter((actor) => !participants.has(actor)).join(", ")}`) + } + } + })()) + } + await Promise.race([Promise.all(tasks), cancelled]) + } catch (error) { + failure = error instanceof Error ? error : new Error(String(error)) + events.push(event("failure", "scenario", undefined, "failed", { message: failure.message })) + } finally { + const reason = failure?.message ?? "Scenario cancelled" + for (const rejects of waiterFails.values()) for (const reject of rejects) reject(new Error(reason)) + for (const fail of gateFails.values()) fail(reason) + await Promise.allSettled(Object.entries(clients).map(async ([actor, client]) => { + await client.close() + events.push(event("teardown", actor, actor, "closed")) + })) + } + if (failure) throw new BrowserMultiActorScenarioError(failure.message, result("failed", scenario, schedule, events)) + return result("completed", scenario, schedule, events) +} + +function result(finalState: BrowserMultiActorScenarioResult["finalState"], scenario: BrowserMultiActorScenario, schedule: string[], events: BrowserMultiActorEvent[]): BrowserMultiActorScenarioResult { + return { schema: "wp-codebox/browser-multi-actor-result/v1", seed: scenario.seed, schedule, events, replay: { schema: "wp-codebox/browser-multi-actor-replay/v1", seed: scenario.seed, scenario, schedule }, finalState } +} + +function validateScenario(scenario: BrowserMultiActorScenario, clients: Record): void { + if (scenario.schema !== BROWSER_MULTI_ACTOR_SCENARIO_SCHEMA) throw new Error(`Unsupported multi-actor scenario schema: ${scenario.schema}`) + const actorNames = new Set(scenario.actors.map((actor) => actor.name)) + if (actorNames.size !== scenario.actors.length) throw new Error("Multi-actor scenario actor names must be unique") + for (const actor of scenario.actors) { + if (!actor.userSession) throw new Error(`Actor ${actor.name} must bind userSession`) + if (!clients[actor.name]) throw new Error(`Missing browser client for actor ${actor.name}`) + } + for (const action of scenario.actions) if (!actorNames.has(action.actor)) throw new Error(`Action ${action.id} references unknown actor ${action.actor}`) + if (new Set(scenario.actions.map((action) => action.id)).size !== scenario.actions.length) throw new Error("Multi-actor scenario action IDs must be unique") + for (const barrier of scenario.barriers ?? []) for (const actor of barrier.actors) if (!actorNames.has(actor)) throw new Error(`Barrier ${barrier.name} references unknown actor ${actor}`) + for (const gate of scenario.requestGates ?? []) { + if (!actorNames.has(gate.actor)) throw new Error(`Request gate ${gate.name} references unknown actor ${gate.actor}`) + if (!gate.url || (gate.occurrence !== undefined && (!Number.isInteger(gate.occurrence) || gate.occurrence < 1))) throw new Error(`Request gate ${gate.name} requires a URL and positive occurrence`) + } +} + +function seededSchedule(ids: string[], seed: string): string[] { + let state = [...seed].reduce((value, character) => ((value * 31) + character.charCodeAt(0)) >>> 0, 2166136261) + const output = [...ids] + for (let index = output.length - 1; index > 0; index--) { + state = (state * 1664525 + 1013904223) >>> 0 + const swap = state % (index + 1) + ;[output[index], output[swap]] = [output[swap]!, output[index]!] + } + return output +} + +function event(type: BrowserMultiActorEvent["type"], name: string, actor: string | undefined, status: BrowserMultiActorEvent["status"], details?: Record): BrowserMultiActorEvent { + return { type, name, ...(actor ? { actor } : {}), status, timestamp: now(), ...(details ? { details } : {}) } +} + +async function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([promise, new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error(message)), timeoutMs) })]) + } finally { + if (timer) clearTimeout(timer) + } +} diff --git a/packages/runtime-playground/src/index.ts b/packages/runtime-playground/src/index.ts index 6e721da85..bf9b89953 100644 --- a/packages/runtime-playground/src/index.ts +++ b/packages/runtime-playground/src/index.ts @@ -6,6 +6,8 @@ export { playgroundRuntimeCommandIds } from "./command-router.js" export { ArtifactBundleWriter, ManifestedArtifactSet, type ManifestedArtifactFileInput } from "./artifact-bundle-writer.js" export { buildArtifactDiagnostics } from "./artifacts.js" export { browserArtifactMetrics, type BrowserArtifactMetricsResult } from "./browser-metrics.js" +export { BrowserMultiActorScenarioError, runBrowserMultiActorScenario, type BrowserMultiActorClient, type BrowserMultiActorEvent, type BrowserMultiActorScenarioResult } from "./browser-multi-actor-scenario.js" +export { runBrowserMultiActorScenarioCommand } from "./browser-multi-actor-scenario-runner.js" export { browserStorageStateCookieHostSummary, browserStorageStateFromWordPressAuthCookies, normalizeBrowserStorageStatePayload, wordpressFixtureUserStorageStatePhpCode, type BrowserAuthStorageState, type BrowserStorageStateCookie, type BrowserStorageStateImportResult, type BrowserStorageStateImportSummary, type WordPressFixtureUserSpec, type WordPressFixtureUserStorageStateEnvelope } from "./browser-auth-storage-state.js" export { createHostCommandTool, type HostCommandToolConfig } from "./host-command-tool.js" export { PlaygroundRuntimeBackend, createPlaygroundRuntimeBackend, playgroundRuntimeBackendProvider } from "./playground-runtime.js" diff --git a/tests/browser-multi-actor-scenario.test.ts b/tests/browser-multi-actor-scenario.test.ts new file mode 100644 index 000000000..2a3836a74 --- /dev/null +++ b/tests/browser-multi-actor-scenario.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict" +import { BROWSER_MULTI_ACTOR_SCENARIO_SCHEMA, type BrowserMultiActorScenario } from "../packages/runtime-core/src/browser-multi-actor-scenario-contracts.js" +import { BrowserMultiActorScenarioError, runBrowserMultiActorScenario, type BrowserMultiActorClient } from "../packages/runtime-playground/src/browser-multi-actor-scenario.js" + +const closed: string[] = [] +const actions: string[] = [] +let requestListener: ((url: string, release: () => Promise, fail: (reason: string) => Promise) => void) | undefined +let releaseRequest!: () => void +const requestHeld = new Promise((resolve) => { releaseRequest = resolve }) + +const clients: Record = { + author: { + async execute(action) { + actions.push(action.id) + if (action.id === "author-request") { + const released = new Promise((resolve, reject) => requestListener?.("https://example.test/save", async () => resolve(), async (reason) => reject(new Error(reason)))) + await released + releaseRequest() + } + }, + async close() { closed.push("author") }, + async onRequest(listener) { requestListener = listener }, + }, + reviewer: { + async execute(action) { actions.push(action.id) }, + async close() { closed.push("reviewer") }, + }, +} + +const scenario: BrowserMultiActorScenario = { + schema: BROWSER_MULTI_ACTOR_SCENARIO_SCHEMA, + seed: "proof-1899", + actors: [{ name: "author", userSession: "author-session" }, { name: "reviewer", userSession: "reviewer-session" }], + actions: [ + { id: "author-request", actor: "author", step: { kind: "waitFor", waitFor: "duration", duration: "1ms" }, barrier: "both-ready" }, + { id: "reviewer-ready", actor: "reviewer", step: { kind: "waitFor", waitFor: "duration", duration: "1ms" }, barrier: "both-ready", releaseGates: ["author-save"] }, + ], + barriers: [{ name: "both-ready", actors: ["author", "reviewer"], timeoutMs: 200 }], + requestGates: [{ name: "author-save", actor: "author", url: "https://example.test/save", timeoutMs: 200 }], +} + +const result = await runBrowserMultiActorScenario(scenario, clients) +await requestHeld +assert.equal(result.finalState, "completed") +assert.equal(result.replay.seed, "proof-1899") +assert.deepEqual(result.replay.schedule, result.schedule) +assert.ok(result.events.some((event) => event.type === "barrier" && event.status === "waiting")) +assert.ok(result.events.some((event) => event.type === "barrier" && event.status === "released")) +assert.ok(result.events.some((event) => event.type === "request-gate" && event.status === "waiting")) +assert.ok(result.events.some((event) => event.type === "request-gate" && event.status === "released")) +assert.ok(actions.indexOf("reviewer-ready") < actions.indexOf("author-request") || result.events.findIndex((event) => event.name === "reviewer-ready" && event.status === "completed") < result.events.findIndex((event) => event.name === "author-save" && event.status === "released")) +assert.deepEqual(closed.sort(), ["author", "reviewer"]) + +const timeoutClients: Record = { + author: { async execute() {}, async close() { closed.push("timeout-author") } }, + reviewer: { async execute() {}, async close() { closed.push("timeout-reviewer") } }, +} +let timeoutError: unknown +try { + await runBrowserMultiActorScenario({ ...scenario, actions: [scenario.actions[0]!], requestGates: [] }, timeoutClients) +} catch (error) { + timeoutError = error +} +assert.ok(timeoutError instanceof BrowserMultiActorScenarioError) +assert.match(timeoutError.message, /Barrier both-ready timed out; waiting actors: reviewer/) +assert.equal(timeoutError.result.finalState, "failed") +assert.ok(timeoutError.result.events.some((event) => event.type === "failure" && event.status === "failed")) +assert.deepEqual(timeoutError.result.replay.scenario.actions, [scenario.actions[0]!]) +assert.ok(closed.includes("timeout-author") && closed.includes("timeout-reviewer")) + +await assert.rejects( + runBrowserMultiActorScenario({ ...scenario, requestGates: [{ ...scenario.requestGates![0]!, occurrence: 0 }] }, clients), + /positive occurrence/, +) +console.log("multi-actor browser scenarios ok") diff --git a/tests/public-api-contract.test.ts b/tests/public-api-contract.test.ts index 80fa751ed..a0ca485f0 100644 --- a/tests/public-api-contract.test.ts +++ b/tests/public-api-contract.test.ts @@ -99,6 +99,7 @@ assert.deepEqual(exportKeys(rootPackage), [ "./core/php-snippets", "./recipe-builders", "./run-results", + "./runtime-command-result", "./agent-task-recipe", "./runtime-presets", "./playground", @@ -113,6 +114,7 @@ assert.deepEqual(exportKeys(corePackage), [ "./contracts", "./artifacts", "./run-results", + "./runtime-command-result", "./php-snippets", "./internals", "./recipe-builders", @@ -156,6 +158,7 @@ assert.deepEqual(barrelExportModules(publicBarrel), [ "./browser-artifact-lifecycle.js", "./browser-callback-contracts.js", "./browser-interaction.js", + "./browser-multi-actor-scenario-contracts.js", "./browser-probe-contract.js", "./browser-playground-session-run.js", "./browser-result-shapes.js", @@ -198,6 +201,7 @@ assert.deepEqual(barrelExportModules(publicBarrel), [ "./runtime-command-result.js", "./runtime-contracts.js", "./runtime-episode.js", + "./runtime-action-adapter.js", "./runtime-neutral-contracts.js", "./runtime-overlay-bundle.js", "./runtime-overlay-descriptors.js", @@ -213,6 +217,7 @@ assert.deepEqual(barrelExportModules(publicBarrel), [ "./tool-call-artifacts.js", "./transfer-proof.js", "./workspace-policy.js", + "./workspace-delta.js", "./workspace-preload-artifacts.js", "./wordpress-crud-contracts.js", "./wordpress-block-exercise-contracts.js", @@ -230,6 +235,7 @@ assert.deepEqual(barrelExportModules(publicBarrel), [ assert.deepEqual(barrelExportModules(contractsBarrel), [ "./browser-probe-contract.js", + "./browser-multi-actor-scenario-contracts.js", "./command-registry.js", "./fuzz-fixture-plan-contracts.js", "./fuzz-coverage-plan-contracts.js", @@ -322,7 +328,6 @@ for (const internalModule of [ "./object-utils.js", "./prepared-source-staging.js", "./provider-runtime-contracts.js", - "./runtime-action-adapter.js", "./wordpress-workload-primitives.js", ]) { assert.ok(!publicBarrel.includes(`export * from "${internalModule}"`), `public barrel must not export ${internalModule}`)