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
43 changes: 43 additions & 0 deletions docs/recipe-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,13 @@ top-level fields:
- `dependency_overlays`
- `runtimeEnv`
- `secretEnv`
- `services`
- `externalServices`
- `pluginRuntime`
- `fixtureDatabases`
- `fixtureUsers`
- `userSessions`
- `browserActors`
- `siteSeeds`
- `stagedFiles`
- `sourcePackages`
Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions examples/recipes/cookbook/multi-actor-browser-scenario.json
Original file line number Diff line number Diff line change
@@ -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}]}"
]
}
]
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/commands/recipe-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? [],
Expand Down Expand Up @@ -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 ?? [],
Expand Down Expand Up @@ -1525,6 +1532,10 @@ function recipeRunMetadata(recipe: WorkspaceRecipe, recipePath: string, workspac
}
}

function recipeRuntimeFixtureUsers(recipe: WorkspaceRecipe): Array<Omit<NonNullable<NonNullable<WorkspaceRecipe["inputs"]>["fixtureUsers"]>[number], "password">> {
return (recipe.inputs?.fixtureUsers ?? []).map(({ password: _password, ...user }) => user)
}

function recipeComponentManifest(extraPlugins: PreparedExtraPlugin[], fallback: WorkspaceRecipeComponentManifest | undefined): Record<string, unknown> | undefined {
if (extraPlugins.length === 0) {
return fallback as Record<string, unknown> | undefined
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/recipe-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
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}`)
Expand Down
Original file line number Diff line number Diff line change
@@ -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[]
}
1 change: 1 addition & 0 deletions packages/runtime-core/src/contracts.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
1 change: 1 addition & 0 deletions packages/runtime-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions packages/runtime-core/src/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
15 changes: 15 additions & 0 deletions packages/runtime-core/src/recipe-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions packages/runtime-core/src/runtime-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,12 @@ export interface WorkspaceRecipeUserSession {
metadata?: Record<string, unknown>
}

export interface WorkspaceRecipeBrowserActor {
name: string
userSession: string
metadata?: Record<string, unknown>
}

export interface WorkspaceRecipeProbe {
name: string
step: WorkspaceRecipeStep
Expand Down Expand Up @@ -608,6 +614,7 @@ export interface WorkspaceRecipe {
fixtureDatabases?: WorkspaceRecipeFixtureDatabase[]
fixtureUsers?: WorkspaceRecipeFixtureUser[]
userSessions?: WorkspaceRecipeUserSession[]
browserActors?: WorkspaceRecipeBrowserActor[]
siteSeeds?: WorkspaceRecipeSiteSeed[]
stagedFiles?: WorkspaceRecipeStagedFile[]
sourcePackages?: WorkspaceRecipeSourcePackage[]
Expand Down
26 changes: 25 additions & 1 deletion packages/runtime-playground/src/browser-actions-runner.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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({
Expand All @@ -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=<path-or-url> or scenario-json.url")
Expand Down Expand Up @@ -870,6 +881,19 @@ async function browserScenarioFromArgs(args: string[]): Promise<BrowserScenarioI
return parsed as BrowserScenarioInput
}

function browserMultiActorScenario(scenario: BrowserScenarioInput, args: string[]): (BrowserMultiActorScenario & { url: string; captures?: string[]; stepTimeoutMs?: number }) | undefined {
if (!scenario.actors) return undefined
const url = scenario.url?.trim() || argValue(args, "url")?.trim()
if (!url) throw new Error("Multi-actor wordpress.browser-scenario requires scenario-json.url or url=<path-or-url>")
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) : []
Expand Down
8 changes: 8 additions & 0 deletions packages/runtime-playground/src/browser-artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export interface BrowserArtifactFiles {
performance?: string
review?: string
screenshot?: string
traces?: string[]
domSnapshots?: string[]
verifierResults?: string[]
actionCorpus?: string
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1096,6 +1103,7 @@ const BROWSER_ARTIFACT_FILE_MANIFEST: Record<keyof BrowserArtifactFiles, Browser
performance: { kind: "browser-performance", contentType: "application/json", redact: true },
review: { kind: "browser-review", contentType: "application/json", redact: true },
screenshot: { kind: "browser-screenshot", contentType: "image/png", redact: false },
traces: { kind: "browser-trace", contentType: "application/zip", redact: true },
domSnapshots: { kind: "browser-dom-snapshot", contentType: "application/json", redact: true },
verifierResults: { kind: "browser-verifier-result", contentType: "application/json", redact: true },
actionCorpus: { kind: "browser-action-corpus", contentType: "application/json", redact: true },
Expand Down
Loading
Loading