Skip to content

Commit f7be757

Browse files
authored
Merge pull request #2030 from Automattic/feat/2025-adaptive-browser-exploration
Add adaptive browser state graph exploration
2 parents 2dfb11a + d1f6b0f commit f7be757

18 files changed

Lines changed: 1325 additions & 174 deletions
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
{
2+
"schema": "wp-codebox/workspace-recipe/v1",
3+
"runtime": {
4+
"backend": "wordpress"
5+
},
6+
"workflow": {
7+
"steps": [
8+
{
9+
"command": "wordpress.run-php",
10+
"args": ["code=update_option( 'wp_codebox_adaptive_fixture', 0 );"]
11+
}
12+
]
13+
},
14+
"adversarialCampaigns": [
15+
{
16+
"schema": "wp-codebox/adversarial-recipe-campaign/v1",
17+
"id": "adaptive-browser",
18+
"seed": "adaptive-browser-v1",
19+
"corpus": [
20+
{
21+
"id": "adaptive-homepage",
22+
"actions": [
23+
{
24+
"type": "explore-browser"
25+
}
26+
]
27+
}
28+
],
29+
"caseTemplates": [
30+
{
31+
"id": "explore-browser",
32+
"phases": {
33+
"action": [
34+
{
35+
"command": "wordpress.browser-actions",
36+
"args": [
37+
"adaptive-exploration-json={\"schema\":\"wp-codebox/browser-adaptive-exploration/v1\",\"seed\":\"adaptive-browser-v1\",\"startUrl\":\"/\",\"budgets\":{\"maxActions\":24,\"maxStates\":16,\"maxTransitions\":32,\"maxDurationMs\":60000,\"maxArtifactBytes\":2097152,\"maxErrors\":10},\"actionFamilies\":[\"click\",\"fill\",\"select\",\"submit\",\"keyboard\",\"back\",\"reload\",\"repeat\",\"double-submit\"],\"resetPolicy\":{\"mode\":\"start-url\"}}",
38+
"capture=steps,console,errors,network"
39+
]
40+
}
41+
]
42+
}
43+
}
44+
],
45+
"mutators": ["sequence"],
46+
"oracles": [
47+
{
48+
"id": "runtime-status",
49+
"severity": "high"
50+
}
51+
],
52+
"concurrency": 1,
53+
"budgets": {
54+
"maxCases": 4,
55+
"maxActionsPerCase": 4,
56+
"maxInputBytes": 65536,
57+
"maxCaseTimeMs": 90000,
58+
"maxWallTimeMs": 360000,
59+
"maxArtifactBytes": 10485760
60+
},
61+
"resetPolicy": {
62+
"mode": "checkpoint-per-case",
63+
"checkpointName": "adaptive-browser-baseline"
64+
},
65+
"requiredCapabilities": ["adversarial-campaign", "artifact-export", "command:wordpress.browser-actions"],
66+
"novelty": {
67+
"retainSignals": true
68+
},
69+
"shrinking": {
70+
"enabled": true
71+
},
72+
"replay": {
73+
"selection": "findings"
74+
}
75+
}
76+
]
77+
}

packages/cli/src/adversarial-recipe.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { isAbsolute, join, relative } from "node:path"
55
import {
66
ADVERSARIAL_ORACLE_SCHEMA,
77
adversarialCampaign,
8+
adversarialFindingFingerprint,
89
artifactManifestFile,
910
refreshArtifactManifestFileSha256s,
1011
runAdversarialCampaign,
@@ -129,17 +130,31 @@ async function runRecipeAdversarialReplay(
129130
const started = Date.now()
130131
const observation = await execute(plan, controller.signal)
131132
const signals = [...new Set(observation.signals ?? [])].sort()
133+
const replayMismatch = Boolean(replay.expectedStateDigest && observation.stateDigest !== replay.expectedStateDigest)
134+
const replayFingerprint = adversarialFindingFingerprint({
135+
oracleIds: observation.status === "passed" ? [] : ["runtime-status"],
136+
status: observation.status,
137+
diagnosticCodes: (observation.diagnostics ?? []).map((diagnostic) => diagnostic.code).sort(),
138+
stateDigest: observation.stateDigest,
139+
matrix: plan.matrix,
140+
})
141+
const fingerprintMismatch = Boolean(replay.expectedFingerprint && replayFingerprint !== replay.expectedFingerprint)
142+
const reproduced = observation.status === "passed" && !replayMismatch && !fingerprintMismatch
132143
return {
133144
schema: "wp-codebox/adversarial-campaign-result/v1",
134145
campaignId: campaign.id,
135146
seed: replay.seed,
136-
status: observation.status === "passed" ? "passed" : "findings",
137-
summary: { generated: 1, executed: 1, retained: 1, findings: observation.status === "passed" ? 0 : 1, duplicates: 0, timedOut: observation.status === "timed-out" ? 1 : 0 },
147+
status: reproduced ? "passed" : "findings",
148+
summary: { generated: 1, executed: 1, retained: 1, findings: reproduced ? 0 : 1, duplicates: 0, timedOut: observation.status === "timed-out" ? 1 : 0 },
138149
corpus: [{ id: replay.caseId, actions: replay.actions, input: replay.input, signals }],
139150
findings: [],
140151
schedule: replay.schedule,
141152
noveltySignals: signals,
142-
diagnostics: [{ code: "adversarial-replay-completed", message: `Replayed ${replay.caseId} through the recipe fuzz lifecycle.` }],
153+
diagnostics: replayMismatch
154+
? [{ code: "adversarial-replay-state-mismatch", message: `Replay ${replay.caseId} did not reproduce the recorded state digest.` }]
155+
: fingerprintMismatch
156+
? [{ code: "adversarial-replay-fingerprint-mismatch", message: `Replay ${replay.caseId} did not reproduce the recorded finding fingerprint.` }]
157+
: [{ code: "adversarial-replay-completed", message: `Replayed ${replay.caseId} through the recipe fuzz lifecycle.` }],
143158
resourceUsage: { wallTimeMs: Date.now() - started, artifactBytes: (observation.artifacts ?? []).reduce((sum, artifact) => sum + (artifact.bytes ?? 0), 0) },
144159
}
145160
} finally {

packages/runtime-core/src/adversarial-campaign.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ export interface AdversarialReplay {
124124
schedule: AdversarialScheduleEntry[]
125125
provenance?: Record<string, unknown>
126126
command: string
127+
expectedFingerprint?: string
128+
expectedStateDigest?: string
127129
}
128130

129131
export interface AdversarialScheduleEntry {
@@ -239,8 +241,8 @@ export async function runAdversarialCampaign(campaignInput: AdversarialCampaign,
239241
const failedOracles = oracleResults.filter((result) => result.failed)
240242
if (failedOracles.length > 0 || observation.status !== "passed") {
241243
if (options.signal?.aborted) { incomplete = true; diagnostics.push({ code: "campaign-interrupted", message: "Campaign stopped before finding minimization after an interruption request." }); break }
242-
const minimized = options.minimize === false ? normalizeCorpusEntry({ id: plan.caseId, actions: plan.actions, input: plan.input }) : await minimizeAdversarialCase(campaign, plan, failedOracles, options)
243-
const fingerprint = adversarialFindingFingerprint({ oracleIds: failedOracles.map((result) => result.oracleId), status: observation.status, diagnosticCodes: (observation.diagnostics ?? []).map((item) => item.code).sort(), matrix: plan.matrix })
244+
const fingerprint = observationFingerprint(plan, observation, failedOracles)
245+
const minimized = options.minimize === false ? normalizeCorpusEntry({ id: plan.caseId, actions: plan.actions, input: plan.input }) : await minimizeAdversarialCase(campaign, plan, failedOracles, fingerprint, options)
244246
const existing = findings.get(fingerprint)
245247
if (existing) existing.duplicates += 1
246248
else findings.set(fingerprint, createFinding(campaign, plan, minimized, observation, failedOracles, schedule, options, fingerprint))
@@ -335,14 +337,15 @@ function mutateCorpusEntry(campaign: AdversarialCampaign, source: AdversarialCor
335337
return { id: `${source.id}-mutation-${iteration}`, caseId: `${campaign.id}-${iteration}`, corpusId: source.id, iteration, workerId, matrix, actions, input, mutation, metadata: source.metadata }
336338
}
337339

338-
async function minimizeAdversarialCase(campaign: AdversarialCampaign, plan: AdversarialCasePlan, originalOracles: AdversarialOracleResult[], options: AdversarialCampaignRunnerOptions): Promise<AdversarialCorpusEntry> {
340+
async function minimizeAdversarialCase(campaign: AdversarialCampaign, plan: AdversarialCasePlan, originalOracles: AdversarialOracleResult[], expectedFingerprint: string, options: AdversarialCampaignRunnerOptions): Promise<AdversarialCorpusEntry> {
339341
const oracleIds = new Set(originalOracles.filter((item) => item.failed).map((item) => item.oracleId))
340342
const preserves = async (candidate: AdversarialCorpusEntry): Promise<boolean> => {
341343
if (options.signal?.aborted) return false
342344
const candidatePlan = { ...plan, actions: candidate.actions, input: candidate.input }
343345
const observation = await executeBoundedCase(campaign, candidatePlan, options)
344346
const oracleResults = options.evaluate ? await options.evaluate(candidatePlan, observation, campaign.oracles) : defaultOracleResults(observation)
345-
return observation.status !== "passed" || oracleResults.some((item) => item.failed && (oracleIds.size === 0 || oracleIds.has(item.oracleId)))
347+
const preservesOracle = observation.status !== "passed" || oracleResults.some((item) => item.failed && (oracleIds.size === 0 || oracleIds.has(item.oracleId)))
348+
return preservesOracle && observationFingerprint(candidatePlan, observation, oracleResults.filter((item) => item.failed)) === expectedFingerprint
346349
}
347350
let actions = [...plan.actions]
348351
let chunk = Math.max(1, Math.floor(actions.length / 2))
@@ -373,7 +376,7 @@ function createFinding(campaign: AdversarialCampaign, plan: AdversarialCasePlan,
373376
status: observation.status,
374377
minimized,
375378
original: { id: plan.caseId, actions: plan.actions, input: plan.input },
376-
replay: stripUndefined({ schema: ADVERSARIAL_REPLAY_SCHEMA, campaignId: campaign.id, seed: campaign.seed, caseId: plan.caseId, corpusId: plan.corpusId, workerId: plan.workerId, iteration: plan.iteration, matrix: plan.matrix, actions: minimized.actions, input: minimized.input, faultSchedule: campaign.faults, schedule: [...schedule], provenance: campaign.provenance, command }),
379+
replay: stripUndefined({ schema: ADVERSARIAL_REPLAY_SCHEMA, campaignId: campaign.id, seed: campaign.seed, caseId: plan.caseId, corpusId: plan.corpusId, workerId: plan.workerId, iteration: plan.iteration, matrix: plan.matrix, actions: minimized.actions, input: minimized.input, faultSchedule: campaign.faults, schedule: [...schedule], provenance: campaign.provenance, command, expectedFingerprint: fingerprint, expectedStateDigest: observation.stateDigest }),
377380
diagnostics: observation.diagnostics ?? [],
378381
artifactRefs: observation.artifacts ?? [],
379382
secretScan: { status: "passed", redactions: 0 },
@@ -386,6 +389,16 @@ function defaultOracleResults(observation: AdversarialExecutionObservation): Adv
386389
return observation.status === "passed" ? [] : [{ oracleId: "runtime-status", failed: true, code: observation.status, message: observation.diagnostics?.[0]?.message ?? `Runtime status was ${observation.status}.` }]
387390
}
388391

392+
function observationFingerprint(plan: AdversarialCasePlan, observation: AdversarialExecutionObservation, oracles: AdversarialOracleResult[]): string {
393+
return adversarialFindingFingerprint({
394+
oracleIds: oracles.filter((result) => result.failed).map((result) => result.oracleId).sort(),
395+
status: observation.status,
396+
diagnosticCodes: (observation.diagnostics ?? []).map((item) => item.code).sort(),
397+
stateDigest: observation.stateDigest,
398+
matrix: plan.matrix,
399+
})
400+
}
401+
389402
function normalizeBudgets(input: Partial<AdversarialResourceBudget> | undefined): AdversarialResourceBudget {
390403
return {
391404
maxCases: boundedInteger(input?.maxCases, 100, 1, 100_000),

0 commit comments

Comments
 (0)