ODW Testing
Use this skill to test ODW workflow scripts: plain JavaScript files in Claude
Code's workflow dialect, executed by the odw CLI. A workflow under test has
three kinds of surface, each needing a different technique:
- Deterministic JavaScript — parsers, guards, classifiers, schema constants, selection logic. Unit-test directly; this is where most defects live and where tests are cheapest.
- Orchestration wiring — which agents run, in what order, behind which locks, and how failures route. Test by simulation with scripted primitives or by source-invariant assertions.
- Agent behaviour — what real CLIs do with the prompts. Not unit-testable; bound it with mock adapters end-to-end, and with bounded live smoke runs only when the user asks.
Never "test" a workflow by running it against real adapters as a first resort. Real runs are expensive, non-deterministic, and can mutate repositories.
The Contract Under Test
ODW's loader (see src/loader.ts in the ODW source) defines what a valid
script is, and tests must respect the same transform:
- The file must
export const meta = { ... }with a pure literal object (nameanddescriptionrequired). The loader evaluates the literal withnew Function('return (…)'). - No other top-level
import/exportis allowed anywhere outside strings and comments. - The body is wrapped in an
AsyncFunctionwhose parameters are the injected globals, in this exact order:agent,parallel,pipeline,phase,log,args,budget,workflow, plus ODW'svalidatewhen the body does not bind that name itself. Top-levelawaitandreturnare legal because of this wrap. Date.now(),Math.random(), and arg-lessnew Date()run under ODW but are banned in Claude Code's Workflow tool; ODW'sscanDualCompatreports them as warnings. Treat them as test failures in dual-target workflows.
Primitive semantics that stubs must reproduce faithfully:
agent(prompt, opts)returns a schema-validated object whenopts.schemais set, otherwise the reply string. On adapter failure it throws; it does not returnnullby itself.parallel(thunks)is a barrier; a thunk that throws resolves tonullin the result array rather than rejecting the batch.pipeline(items, ...stages)has no barrier; each stage receives(previous, item, index), and a throwing chain yields anullslot.budgetexposestotal(ornull),spent(), andremaining()(Infinitywhen no total). Reductions must be order-independent because completion order is not deterministic.
Test Layers
Pick the smallest layer that answers the question; compose layers in one suite.
| Layer | Verifies | Cost |
|---|---|---|
| 0. Parse gate | source compiles as a workflow | milliseconds |
| 1. Helper surface | pure helpers and schema constants | milliseconds |
| 2. Fixture repos | git/filesystem evidence collectors | fast |
| 3. Source invariants | wiring that only exists in prompts/flow | milliseconds |
| 4. Simulation | control loop with scripted primitives | fast |
| 5. Mock-adapter e2e | whole run through the real odw runtime |
seconds |
| 6. Live smoke | real adapters, bounded scope | expensive; only on request |
Layer 0: Parse Gate
Wire a deterministic compile check into the repository gates (make typecheck/
lint) so a workflow that no longer parses fails CI before any agent spends a
token. Mirror the loader's transform (run this from a workflow-parse make
target over every workflow file):
node -e "const fs=require('fs');
for (const path of process.argv.slice(1)) {
let source=fs.readFileSync(path,'utf8')
.replace(/^export const meta\s*=/,'const meta =');
new Function('return (async function __wrapped__() {\n' + source + '\n})');
console.log(path + ': wrapped JavaScript parses');
}" workflows/*.js
For workflow source generated inside a workflow, use the injected
validate(source) primitive instead — it returns
{ ok, meta, errors, warnings } including the dual-compat warnings. Add a
grep-level check (or a test) that the source contains no Date.now(),
Math.random(), or arg-less new Date() outside strings and comments.
Layer 1: Helper-Surface Unit Tests
This is the workhorse layer. It requires a structural convention in the
workflow itself — adopt it when authoring (see odw-authoring):
- Define every pure helper, schema constant, and prompt builder above the control loop.
- Mark the boundary with a stable marker comment, for example
// --- Worker-pool control loop.
Tests then slice the helper region, compile it with injected stubs, and return the helpers worth testing:
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
const WORKFLOW_PATH = new URL('../workflows/my-flow.js', import.meta.url)
const CONTROL_LOOP_MARKER = '// --- Worker-pool control loop'
async function loadSurface() {
let source = await readFile(WORKFLOW_PATH, 'utf8')
source = source.replace(/^export const meta\s*=/, 'const meta =')
const markerIndex = source.indexOf(CONTROL_LOOP_MARKER)
assert.notEqual(markerIndex, -1, 'control-loop marker should exist')
const helperSource = source.slice(0, markerIndex)
const factory = new Function(
'args', 'phase', 'log', 'agent', 'parallel', 'budget',
`${helperSource}
return { MY_SCHEMA, classifyFailure, selectNextTask, shouldAssess }
`,
)
return factory(
{}, // args: default configuration path
() => {}, // phase: no-op
() => {}, // log: no-op
async () => null, // agent: never called by helpers
async (thunks) => Promise.all(thunks.map((t) => t())),
{ total: null, remaining: () => Infinity, spent: () => 0 },
)
}
Notes on this pattern:
- Because the test compiles the slice with its own parameter list, the loader's parameter order does not apply here; it only matters when executing the whole body (Layer 4).
- Guard the marker with
assert.notEqual(markerIndex, -1, …)so a refactor that drops it fails loudly instead of silently compiling the whole file. - Passing
{}asargsexercises every configuration default. Add a second factory call with overrides when defaults are load-bearing (adapter routing, parallelism caps, feature flags). - Helpers using
process.getBuiltinModule('node:fs')etc. work unchanged undernode --test(Node 22+); no mocking needed. - The slice must not execute side effects at top level. If the helper region
contains an eager
process.chdiror filesystem probe, run the tests from a directory where those succeed, or gate the side effect on configuration.
Prime candidates for this layer:
- Classifiers and guards — failure-detail matchers (auth vs provider vs product fault), retry/defer predicates, "should this branch be assessed" gates. Table-drive them: one assertion per representative detail string, including the negative cases that must NOT match.
- Deterministic selection — roadmap/queue parsing, dependency resolution, dedupe keys. Feed crafted text fixtures; assert both the selection and the blocked/remainder reporting.
- Result shapers — functions that turn an error or agent reply into a
typed result object; assert the exact object with
assert.deepEqual.
Layer 2: Schema Contract Tests
Schemas are cross-agent contracts, so test them as data:
test('assessment schema exposes only the ADR classifications', async () => {
const s = await loadSurface()
assert.deepEqual(s.ASSESSMENT_SCHEMA.properties.classification.enum,
s.ASSESSMENT_CLASSIFICATIONS)
assert.equal(s.ASSESSMENT_SCHEMA.additionalProperties, false)
assert.deepEqual(s.ASSESSMENT_SCHEMA.required, [/* every property */])
})
Assert three things: the enum matches the documented contract (an ADR or design
doc where one exists), additionalProperties is false wherever the consumer
iterates keys, and required lists exactly what downstream JavaScript
dereferences without optional chaining. When a downstream branch reads
result.foo, a test must fail if foo leaves required.
Also check mock-satisfiability: ODW's schema-satisfying mock agent (Layer 5)
generates enum[0], false for booleans, 1/0.5 for numbers, and
max(minItems, 2) array entries. A schema whose only "healthy" value is not
the mock default (e.g. a workflow that loops until ok === true while the mock
always returns false) will hang or exhaust rounds in e2e tests — either bound
the loop or script a bespoke adapter for that call.
Layer 3: Git and Filesystem Fixtures
Helpers that collect evidence from a repository get real throwaway repos, not mocks:
function git(cwd, ...args) {
return execFileSync('git', args, {
cwd, encoding: 'utf8',
env: {
...process.env,
GIT_AUTHOR_NAME: 'test', GIT_AUTHOR_EMAIL: 'test@example.invalid',
GIT_COMMITTER_NAME: 'test', GIT_COMMITTER_EMAIL: 'test@example.invalid',
},
}).trim()
}
function makeRepo() {
const dir = mkdtempSync(path.join(tmpdir(), 'wf-fixture-'))
git(dir, 'init', '-b', 'main')
writeFileSync(path.join(dir, 'README.md'), '# Fixture\n')
git(dir, 'add', 'README.md')
git(dir, 'commit', '-m', 'Initial fixture')
return { dir, baseSha: git(dir, 'rev-parse', 'HEAD') }
}
Rules:
- Pin author/committer identity through
envso CI hosts without a git identity still pass. - Build the exact states the helper distinguishes: committed-only, staged,
dirty/untracked, and the empty branch (no commits after base). The empty case
is where
git log base..HEADanddiff base...HEADparsers usually break. - Assert structured output (
assert.deepEqualon parsed entries), plus the error-accumulator path: point the helper at a missing directory or a bogus base SHA and assert it reports collection errors instead of throwing.
Layer 4: Source-Invariant Assertions
Some wiring exists only in prompt text or call ordering: an auth check gated before integration, a preflight command per adapter, a lock wrapping a merge. When simulation would cost more than it proves, pin the invariant with an anchored regex over the raw source:
test('implementations gate auth before integration', async () => {
const source = await readFile(WORKFLOW_PATH, 'utf8')
assert.match(
source,
new RegExp(
String.raw`const impl = await buildLock\(\(\) => agent\(implementPrompt` +
String.raw`[\s\S]*?implementationAuthFailureDetail\(impl\)` +
String.raw`[\s\S]*?status: 'fatal-auth'`,
),
)
})
Use these sparingly and only for load-bearing sequences. Anchor on structural
tokens (function names, status literals, option keys), never on prose wording —
prompt copy-editing must not break the suite. If a regex needs [\s\S]*? more
than twice, the invariant probably deserves a Layer 4/5 test or a helper
extraction instead.
Layer 5: Control-Loop Simulation
To test routing decisions (which statuses halt the pool, what gets retried, what a terminal summary contains), execute the whole body with scripted primitives, mirroring the loader exactly:
async function runWorkflow({ args, script }) {
let source = await readFile(WORKFLOW_PATH, 'utf8')
source = source.replace(/^export const meta\s*=/, 'const meta =')
const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor
const body = new AsyncFunction(
'agent', 'parallel', 'pipeline', 'phase', 'log',
'args', 'budget', 'workflow', 'validate',
source,
)
const calls = []
const agent = async (prompt, opts = {}) => {
calls.push({ label: opts.label, adapter: opts.adapter, phase: opts.phase })
return script(prompt, opts) // scripted reply per call
}
const parallel = (thunks) => Promise.all(
thunks.map((t) => Promise.resolve().then(t).catch(() => null)),
)
const pipeline = async (items, ...stages) => Promise.all(
items.map(async (item, index) => {
try {
let value = item
for (const stage of stages) value = await stage(value, item, index)
return value
} catch { return null }
}),
)
const phases = []
const result = await body(
agent, parallel, pipeline,
(t) => phases.push(t), () => {},
args, { total: null, spent: () => 0, remaining: () => Infinity },
async () => { throw new Error('nested workflow not scripted') },
() => ({ ok: true, errors: [], warnings: [] }),
)
return { result, calls, phases }
}
The script function is the test's steering wheel. Key it on opts.label or
opts.adapter (stable identifiers), not on prompt substrings, and make it
schema-aware: when opts.schema is present, return an object that satisfies
it. Scenarios worth scripting:
- the happy path (assert the terminal result shape and processed set);
- one stage returning a failing verdict (assert the fix/retry loop runs and is bounded by its cap);
agentthrowing an auth-shaped and a provider-shaped error (assert the run halts with the right status and no further work is opened);- an agent returning
null/nothing (assert the control loop converts it to a failure result rather than crashing).
Beware workflows whose helpers shell out (git fetch origin, auth-status
CLIs): simulation executes those for real. Point args at a fixture repo
(Layer 3) with a local origin remote, or make the command paths injectable via
args. If the top of the body does process.chdir, run the simulation in a
subprocess or pass a safe projectRoot.
Layer 6: End-to-End with Mock Adapters
The highest-fidelity test short of spending tokens: run the real odw runtime
with adapters that are deterministic local scripts. ODW ships the canonical
fixture — tests/fixtures/mock-agent.mjs in the ODW source (upstream:
github.com/xz1220/open-dynamic-workflows) — which reads the prompt from
stdin, extracts the JSON Schema the bridge appends after the JSON Schema:
marker, and prints a minimal valid instance. Any schema-driven workflow runs
end to end against it with no model calls.
Hermetic test config (write into a mkdtemp root):
{
"defaultAdapter": "mock",
"workspaceMode": "inplace",
"schemaRetries": 1,
"concurrency": 8,
"runsRoot": "<tmp>/runs",
"adapters": {
"mock": { "command": ["node", "<path>/mock-agent.mjs"], "stdin": "{prompt}" }
}
}
Then:
odw run workflow.js --config <tmp>/odw.config.json --source <tmp>/fixture \
--wait --timeout 300 --args '{"maxTasks":1}'
For role-specific behaviour (an implementer that improves per round, a reviewer
that fails round 1 and passes round 2), define one adapter per role as an inline
node -e script and reference them from the workflow's adapter-routing args —
this is how ODW's own multi-adapter e2e test drives a converging duel
deterministically.
Assert on durable run artefacts under runsRoot, not on stdout alone:
result.json— the final return value (wrapped as{ "value": … }); assert its shape and the terminal summary.events.jsonl— the ordered event stream; assert thephase_startedsequence matches the declared flow, and countagent_finishedevents per adapter to prove each role really ran the expected number of times.error.json— must be absent on success; on failure tests, assert the message routes correctly.
Every adapter named by the workflow's routing configuration must exist in the test config, or the run fails on dispatch — that failure is itself a useful test that routing args and config stay in sync.
Live Smoke Runs
Only when the user asks, and always bounded:
- use the workflow's own scope limits (
dryRun, a singletaskId,maxTasks: 1) so planning/review paths run without implementation or merges; - point
--sourceat a throwaway clone, never the canonical checkout, unless real-tree edits are the explicit goal; - set
--timeoutwith--wait, and supervise withodw status/odw logs(seeodw-supervision); - inspect
result.jsonbefore treating the run as evidence.
Suite Hygiene
- Use
node --testwith plainnode:assert/strict; name filestests/<workflow>-<facet>.test.mjsand wire them into the repository's standard test gate somake all-style commit gates run them. - One behavioural claim per test; table-drive classifier matrices.
- Keep tests independent of agent prose: key on labels, adapters, schema fields, status literals, and marker comments — the stable contract — so prompt tuning never breaks the suite.
- When a test needs a helper the factory does not yet export, add it to the factory's return list rather than duplicating logic in the test.
- Clean up
mkdtemproots infinally; a leaked fixture repo with a staleorigincan poison later git tests.
Anti-Patterns
- Prompt-wording assertions. Testing that a prompt contains a sentence couples the suite to copy-editing. Test the data the prompt is built from, or a structural regex on function/option names.
- Whole-body eval in a unit test. Executing the full script to reach one helper runs the control loop, shells out, and can chdir. Slice above the marker instead.
- Stubs with the wrong failure semantics. A
parallelstub that rejects on the first error, or anagentstub that returnsnullon failure instead of throwing, validates code paths the real runtime never takes. - Order-dependent assertions on concurrent work. Completion order is scheduler-dependent; sort or set-compare results.
- Unbounded e2e loops against the mock agent. The mock returns fixed
defaults (
false,enum[0]), so a loop-until-true workflow never converges; bound rounds via args or script a bespoke adapter. - Testing against real adapters in CI. Non-deterministic, slow, billed, and capable of mutating repositories. Reserve real adapters for explicit, supervised smoke runs.
Checklist
- Parse gate wired into CI for every workflow file.
- Helper region above a marker comment; factory-based unit tests for every classifier, guard, parser, and result shaper — negatives included.
- Schema tests: enum ↔ contract,
required↔ downstream access,additionalProperties: false, mock-satisfiable. - Fixture-repo tests for every git/filesystem evidence helper, including empty and error paths.
- A few anchored source invariants for load-bearing wiring.
- Simulation scenarios for happy path, bounded retry, fatal-auth, provider-fault, and null-return routing.
- Mock-adapter e2e proving the flow end to end, asserting
result.jsonandevents.jsonl. - Live smoke only on request, bounded, on a throwaway source.