@farmslot/recipe-harness 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.md +207 -0
- package/bin/farmslot-recipe.mjs +10 -0
- package/dist/adapters/core.d.ts +5 -0
- package/dist/adapters/core.d.ts.map +1 -0
- package/dist/adapters/core.js +438 -0
- package/dist/adapters/core.js.map +1 -0
- package/dist/adapters/ui.d.ts +23 -0
- package/dist/adapters/ui.d.ts.map +1 -0
- package/dist/adapters/ui.js +41 -0
- package/dist/adapters/ui.js.map +1 -0
- package/dist/cli/run-command.d.ts +3 -0
- package/dist/cli/run-command.d.ts.map +1 -0
- package/dist/cli/run-command.js +41 -0
- package/dist/cli/run-command.js.map +1 -0
- package/dist/cli/validate-command.d.ts +3 -0
- package/dist/cli/validate-command.d.ts.map +1 -0
- package/dist/cli/validate-command.js +31 -0
- package/dist/cli/validate-command.js.map +1 -0
- package/dist/cli-support.d.ts +14 -0
- package/dist/cli-support.d.ts.map +1 -0
- package/dist/cli-support.js +94 -0
- package/dist/cli-support.js.map +1 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +29 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/json.d.ts +9 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +74 -0
- package/dist/json.js.map +1 -0
- package/dist/runner.d.ts +4 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +1172 -0
- package/dist/runner.js.map +1 -0
- package/dist/runtime/browser-extension.d.ts +17 -0
- package/dist/runtime/browser-extension.d.ts.map +1 -0
- package/dist/runtime/browser-extension.js +42 -0
- package/dist/runtime/browser-extension.js.map +1 -0
- package/dist/runtime/cdp.d.ts +86 -0
- package/dist/runtime/cdp.d.ts.map +1 -0
- package/dist/runtime/cdp.js +512 -0
- package/dist/runtime/cdp.js.map +1 -0
- package/dist/runtime/react-native-bridge.d.ts +17 -0
- package/dist/runtime/react-native-bridge.d.ts.map +1 -0
- package/dist/runtime/react-native-bridge.js +24 -0
- package/dist/runtime/react-native-bridge.js.map +1 -0
- package/dist/types.d.ts +143 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/writers.d.ts +23 -0
- package/dist/writers.d.ts.map +1 -0
- package/dist/writers.js +72 -0
- package/dist/writers.js.map +1 -0
- package/package.json +79 -0
package/CHANGELOG.md
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# @farmslot/recipe-harness
|
|
2
|
+
|
|
3
|
+
Reusable Recipe Protocol v1 runner for Farmslot.
|
|
4
|
+
|
|
5
|
+
This package executes portable recipe graphs, validates them against an action
|
|
6
|
+
manifest from `@farmslot/protocol`, invokes registered adapters, and writes the
|
|
7
|
+
standard artifact package:
|
|
8
|
+
|
|
9
|
+
- `recipe.json`
|
|
10
|
+
- `summary.json`
|
|
11
|
+
- `trace.json`
|
|
12
|
+
- `artifact-manifest.json`
|
|
13
|
+
|
|
14
|
+
It is the base runner for backend/CLI projects and the extension point for UI,
|
|
15
|
+
React Native, browser extension, native app, and project-domain runners.
|
|
16
|
+
|
|
17
|
+
## Canonical documents
|
|
18
|
+
|
|
19
|
+
- [Recipe Protocol v1](https://farmslot.io/docs/reference/recipe-protocol-v1) — source of truth for recipe schema and official actions.
|
|
20
|
+
- [Recipe Runner Protocol](https://farmslot.io/docs/reference/recipe-runner-protocol) — runner manifest, adapter, and artifact guidance.
|
|
21
|
+
- [Recipe Harness Architecture](https://farmslot.io/docs/architecture/recipe-harness) — package boundary and runtime model.
|
|
22
|
+
- [Recipe Composition Quality](https://farmslot.io/docs/reference/recipe-composition-quality) — flow design and proof quality guidance.
|
|
23
|
+
|
|
24
|
+
If this README conflicts with the public Recipe Protocol v1 reference, the protocol reference wins.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
yarn add @farmslot/recipe-harness @farmslot/protocol
|
|
30
|
+
# or
|
|
31
|
+
npm install @farmslot/recipe-harness @farmslot/protocol
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Source layout
|
|
35
|
+
|
|
36
|
+
| Path | Owns |
|
|
37
|
+
| --------------------------- | ------------------------------------------------------------------------------------ |
|
|
38
|
+
| `bin/` | Published `farmslot-recipe` executable shim. |
|
|
39
|
+
| `src/index.ts` | Public package export surface. |
|
|
40
|
+
| `src/runner.ts` | Manifest-aware recipe graph execution engine. |
|
|
41
|
+
| `src/adapters/` | Standard core and UI adapter factories. |
|
|
42
|
+
| `src/runtime/` | Runtime transports for CDP, browser extensions, and React Native bridge connections. |
|
|
43
|
+
| `src/writers.ts` | Portable artifact package writers. |
|
|
44
|
+
| `src/cli.ts` and `src/cli/` | CLI entrypoint and command implementations. |
|
|
45
|
+
| `src/cli-support.ts` | Shared CLI parsing and execution helpers. |
|
|
46
|
+
| `src/types.ts` | Harness-owned runtime types that are not cross-process protocol contracts. |
|
|
47
|
+
|
|
48
|
+
## What belongs here
|
|
49
|
+
|
|
50
|
+
The harness owns generic execution mechanics:
|
|
51
|
+
|
|
52
|
+
- manifest-aware runner construction;
|
|
53
|
+
- graph execution, `call` flow composition, setup/start-state/proof/teardown ordering;
|
|
54
|
+
- standard core adapters such as `command`, `wait`, `assert_json`, `watch_logs`, and `end`;
|
|
55
|
+
- standard UI adapter wiring through a project-provided transport;
|
|
56
|
+
- artifact, trace, and summary writers;
|
|
57
|
+
- small runtime helpers for CDP, browser-extension pages, and React Native bridge transports.
|
|
58
|
+
|
|
59
|
+
Project-specific behavior stays outside this package. For example,
|
|
60
|
+
`example.trade.place_order`, `checkout.ensure_cart`, or `backend.seed_user`
|
|
61
|
+
belong in the project runner that imports this package.
|
|
62
|
+
|
|
63
|
+
## Minimal runner
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { createRecipeRunner, createStandardCoreAdapters } from '@farmslot/recipe-harness';
|
|
67
|
+
import { getRecipeActionManifestActionNames } from '@farmslot/protocol';
|
|
68
|
+
|
|
69
|
+
const runner = createRecipeRunner({
|
|
70
|
+
actionManifest,
|
|
71
|
+
adapters: createStandardCoreAdapters({
|
|
72
|
+
actions: getRecipeActionManifestActionNames(actionManifest),
|
|
73
|
+
}),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const result = await runner.run({
|
|
77
|
+
recipePath: 'recipes/smoke.recipe.json',
|
|
78
|
+
artifactsDir: 'artifacts/recipe-run',
|
|
79
|
+
projectRoot: process.cwd(),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (result.status !== 'pass') process.exitCode = 1;
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The manifest is not documentation only. Runner construction fails when:
|
|
86
|
+
|
|
87
|
+
- a recipe uses an action missing from the manifest;
|
|
88
|
+
- a manifest action has no adapter;
|
|
89
|
+
- an adapter is registered for an undeclared action, unless it is explicitly test-only;
|
|
90
|
+
- a recipe references an undeclared or unimplemented precondition.
|
|
91
|
+
|
|
92
|
+
## CLI usage
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
farmslot-recipe run recipes/smoke.recipe.json \
|
|
96
|
+
--artifacts-dir artifacts/recipe-run \
|
|
97
|
+
--action-manifest action-manifest.json
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The CLI is useful for backend/CLI projects whose recipes only need core adapters.
|
|
101
|
+
UI, CDP, React Native, browser-extension, and domain actions require a project
|
|
102
|
+
runner that registers the appropriate adapters.
|
|
103
|
+
|
|
104
|
+
In this monorepo, build the package before invoking the bin directly from a
|
|
105
|
+
clean checkout because the published bin imports compiled `dist/` output:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
yarn workspace @farmslot/recipe-harness build
|
|
109
|
+
yarn workspace @farmslot/recipe-harness farmslot-recipe --help
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Custom adapter
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
import { defineActionAdapter } from '@farmslot/recipe-harness';
|
|
116
|
+
|
|
117
|
+
export const echoAdapter = defineActionAdapter({
|
|
118
|
+
action: 'example.echo',
|
|
119
|
+
async execute(node, context) {
|
|
120
|
+
const message = String(node.message ?? '');
|
|
121
|
+
context.logger.info(`echo: ${message}`);
|
|
122
|
+
return { output: { message } };
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The project manifest must declare `example.echo` before the adapter is registered.
|
|
128
|
+
Keep action names namespaced and durable; avoid task-specific actions that encode
|
|
129
|
+
one Jira ticket or one temporary assertion.
|
|
130
|
+
|
|
131
|
+
## Standard UI transport
|
|
132
|
+
|
|
133
|
+
The harness provides the official UI action shell; the project supplies the
|
|
134
|
+
transport that knows how to press, scroll, type, navigate, capture screenshots,
|
|
135
|
+
or update the HUD in that runtime.
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { createStandardUiAdapters } from '@farmslot/recipe-harness';
|
|
139
|
+
|
|
140
|
+
const uiAdapters = createStandardUiAdapters({
|
|
141
|
+
actions: ['ui.press', 'ui.set_input', 'ui.scroll', 'ui.screenshot', 'app.hud'],
|
|
142
|
+
transport: {
|
|
143
|
+
async execute(action, node, context) {
|
|
144
|
+
return projectUiBridge.execute(action, node, context);
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Use `ui.*` for user-visible proof flows. Use project-domain actions for fast
|
|
151
|
+
setup/teardown when the goal is state convergence rather than visual proof.
|
|
152
|
+
Never mutate UI state directly to manufacture proof.
|
|
153
|
+
|
|
154
|
+
## HUD guidance
|
|
155
|
+
|
|
156
|
+
`app.hud` is first-class for UI projects. The HUD should communicate the current
|
|
157
|
+
human intent, not internal noise.
|
|
158
|
+
|
|
159
|
+
Default guidance:
|
|
160
|
+
|
|
161
|
+
- show one concise intent line;
|
|
162
|
+
- show a second line only for explicit parent-flow/subflow context;
|
|
163
|
+
- keep debug labels, node ids, and action names out of the default view;
|
|
164
|
+
- keep the display minimal enough that it does not hide the proof interaction;
|
|
165
|
+
- record detailed metadata in trace instead of crowding the screen.
|
|
166
|
+
|
|
167
|
+
Projects may configure the HUD layout, but should preserve these semantics so
|
|
168
|
+
reviewers can understand what the agent is doing from screenshots or videos.
|
|
169
|
+
|
|
170
|
+
## Security and portability
|
|
171
|
+
|
|
172
|
+
- Run recipes from trusted sources only; `command` executes local shell commands.
|
|
173
|
+
- Keep artifact paths relative to the artifact directory.
|
|
174
|
+
- Do not put secrets in recipe text, HUD text, trace output, screenshots, or
|
|
175
|
+
artifact paths.
|
|
176
|
+
- Prefer typed domain actions or state reads over open-ended eval/debug escape
|
|
177
|
+
hatches.
|
|
178
|
+
- Treat generated artifacts as reviewer evidence: deterministic, portable, and
|
|
179
|
+
meaningful without access to the original machine.
|
|
180
|
+
|
|
181
|
+
## Maintenance rules
|
|
182
|
+
|
|
183
|
+
1. **Keep the harness generic.** No project names, ticket-specific actions, or
|
|
184
|
+
domain behavior in this package.
|
|
185
|
+
2. **Adapters are the boundary.** The harness orchestrates; transports and
|
|
186
|
+
domain runners perform runtime-specific work.
|
|
187
|
+
3. **Manifest truth is mandatory.** Actions and preconditions must be declared
|
|
188
|
+
before they can run.
|
|
189
|
+
4. **Parameterize before multiplying.** Add one flexible adapter/flow before
|
|
190
|
+
adding several near-duplicate files.
|
|
191
|
+
5. **Trace details, HUD intent.** Put diagnostics in `trace.json`; reserve HUD
|
|
192
|
+
text for concise human-facing intent.
|
|
193
|
+
6. **Fail loudly.** Invalid recipes, unsafe paths, missing adapters, and broken
|
|
194
|
+
artifacts should fail the run with useful trace/summary output.
|
|
195
|
+
|
|
196
|
+
## Local quality
|
|
197
|
+
|
|
198
|
+
From the Farmslot repository root:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
yarn workspace @farmslot/recipe-harness quality
|
|
202
|
+
yarn test:recipe-harness
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Before publishing, also run package dry-run checks once publish metadata is in
|
|
206
|
+
place. Do not publish unless package docs, canonical docs, exports, and tests all
|
|
207
|
+
match the same Recipe Protocol v1 contract.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runRecipeHarnessCli } from '../dist/cli.js';
|
|
3
|
+
|
|
4
|
+
try {
|
|
5
|
+
await runRecipeHarnessCli(process.argv.slice(2));
|
|
6
|
+
} catch (error) {
|
|
7
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
8
|
+
console.error(message);
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"core.d.ts","sourceRoot":"","sources":["../../src/adapters/core.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,wBAAgB,0BAA0B,CACxC,OAAO,GAAE;IAAE,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAA;CAAO,GAC3C,aAAa,EAAE,CAkBjB"}
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { access, copyFile, mkdir, readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
5
|
+
import { asNumber, asOptionalString, asString, getJsonPathValue, normalizeRelativePath, } from '../json.js';
|
|
6
|
+
export function createStandardCoreAdapters(options = {}) {
|
|
7
|
+
const adapters = [
|
|
8
|
+
defineEndAdapter(),
|
|
9
|
+
defineWaitAdapter(),
|
|
10
|
+
defineCommandAdapter(),
|
|
11
|
+
defineAssertFileAdapter(),
|
|
12
|
+
defineAssertJsonAdapter(),
|
|
13
|
+
defineAssertExitCodeAdapter(),
|
|
14
|
+
defineAssertOutputAdapter(),
|
|
15
|
+
defineWatchLogsAdapter(),
|
|
16
|
+
defineIndexArtifactsAdapter(),
|
|
17
|
+
defineStateReadAdapter(),
|
|
18
|
+
defineSwitchAdapter(),
|
|
19
|
+
defineManualAdapter(),
|
|
20
|
+
];
|
|
21
|
+
if (!options.actions)
|
|
22
|
+
return adapters;
|
|
23
|
+
const actions = new Set(options.actions);
|
|
24
|
+
return adapters.filter((adapter) => actions.has(adapter.action));
|
|
25
|
+
}
|
|
26
|
+
function defineEndAdapter() {
|
|
27
|
+
return {
|
|
28
|
+
action: 'end',
|
|
29
|
+
async execute(node) {
|
|
30
|
+
const rawStatus = node.status ?? 'unknown';
|
|
31
|
+
if (rawStatus !== 'pass' && rawStatus !== 'fail' && rawStatus !== 'unknown') {
|
|
32
|
+
throw new Error('end.status must be pass, fail, or unknown.');
|
|
33
|
+
}
|
|
34
|
+
return { status: rawStatus };
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function defineWaitAdapter() {
|
|
39
|
+
return {
|
|
40
|
+
action: 'wait',
|
|
41
|
+
async execute(node) {
|
|
42
|
+
const durationMs = node.duration_ms ?? node.ms;
|
|
43
|
+
const duration = asNumber(durationMs, 'wait.duration_ms');
|
|
44
|
+
if (!Number.isInteger(duration) || duration < 0 || duration > 60_000) {
|
|
45
|
+
throw new Error('wait.duration_ms must be an integer from 0 through 60000.');
|
|
46
|
+
}
|
|
47
|
+
await new Promise((resolve) => setTimeout(resolve, duration));
|
|
48
|
+
return { output: { durationMs: duration } };
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function defineCommandAdapter() {
|
|
53
|
+
return {
|
|
54
|
+
action: 'command',
|
|
55
|
+
async execute(node, context) {
|
|
56
|
+
const command = asString(node.cmd ?? node.command, 'command.cmd');
|
|
57
|
+
const cwd = asOptionalString(node.cwd, 'command.cwd');
|
|
58
|
+
const timeoutMs = node.timeout_ms == null ? undefined : asNumber(node.timeout_ms, 'command.timeout_ms');
|
|
59
|
+
const result = await runShellCommand(command, {
|
|
60
|
+
cwd: cwd ? context.resolveProjectPath(cwd) : context.projectRoot,
|
|
61
|
+
env: { ...process.env, ...context.env },
|
|
62
|
+
timeoutMs,
|
|
63
|
+
});
|
|
64
|
+
if (node.allow_failure !== true && result.exitCode !== 0) {
|
|
65
|
+
throw new Error(`Command exited with ${result.exitCode}: ${command}\n${result.stderr}`);
|
|
66
|
+
}
|
|
67
|
+
return { output: result };
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function defineAssertFileAdapter() {
|
|
72
|
+
return {
|
|
73
|
+
action: 'assert_file',
|
|
74
|
+
async execute(node, context) {
|
|
75
|
+
const filePath = asString(node.path, 'assert_file.path');
|
|
76
|
+
const absolutePath = context.resolveProjectPath(filePath);
|
|
77
|
+
await access(absolutePath);
|
|
78
|
+
const stats = await stat(absolutePath);
|
|
79
|
+
if (node.contains != null) {
|
|
80
|
+
const content = await readFile(absolutePath, 'utf-8');
|
|
81
|
+
const expected = asString(node.contains, 'assert_file.contains');
|
|
82
|
+
if (!content.includes(expected)) {
|
|
83
|
+
throw new Error(`File ${filePath} does not contain expected text ${JSON.stringify(expected)}.`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { output: { path: filePath, sizeBytes: stats.size } };
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function defineAssertJsonAdapter() {
|
|
91
|
+
return {
|
|
92
|
+
action: 'assert_json',
|
|
93
|
+
async execute(node, context) {
|
|
94
|
+
const filePath = asString(node.path, 'assert_json.path');
|
|
95
|
+
const content = await readFile(context.resolveProjectPath(filePath), 'utf-8');
|
|
96
|
+
const document = JSON.parse(content);
|
|
97
|
+
const assertion = parseAssertionNode(node.assert, 'assert_json.assert');
|
|
98
|
+
assertAssertion(document, assertion, `JSON ${filePath}`);
|
|
99
|
+
return { output: { path: filePath, assertion } };
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function defineAssertExitCodeAdapter() {
|
|
104
|
+
return {
|
|
105
|
+
action: 'assert_exit_code',
|
|
106
|
+
async execute(node, context) {
|
|
107
|
+
const source = asString(node.source ?? node.node, 'assert_exit_code.source');
|
|
108
|
+
const expected = node.expected == null ? 0 : asNumber(node.expected, 'assert_exit_code.expected');
|
|
109
|
+
const output = context.getOutput(source);
|
|
110
|
+
const actual = getOutputField(output, 'exitCode');
|
|
111
|
+
assertAtomicValue(actual, 'eq', expected, `${source}.exitCode`);
|
|
112
|
+
return { output: { source, expected, actual } };
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function defineAssertOutputAdapter() {
|
|
117
|
+
return {
|
|
118
|
+
action: 'assert_output',
|
|
119
|
+
async execute(node, context) {
|
|
120
|
+
const source = asString(node.source ?? node.node, 'assert_output.source');
|
|
121
|
+
const stream = asOptionalString(node.stream, 'assert_output.stream') ?? 'stdout';
|
|
122
|
+
const output = context.getOutput(source);
|
|
123
|
+
const actual = String(getOutputField(output, stream) ?? '');
|
|
124
|
+
if (node.contains != null) {
|
|
125
|
+
const expected = asString(node.contains, 'assert_output.contains');
|
|
126
|
+
if (!actual.includes(expected)) {
|
|
127
|
+
throw new Error(`${source}.${stream} does not contain ${JSON.stringify(expected)}.`);
|
|
128
|
+
}
|
|
129
|
+
return { output: { source, stream, contains: expected } };
|
|
130
|
+
}
|
|
131
|
+
if (node.match != null) {
|
|
132
|
+
const pattern = asString(node.match, 'assert_output.match');
|
|
133
|
+
if (!new RegExp(pattern).test(actual)) {
|
|
134
|
+
throw new Error(`${source}.${stream} does not match ${pattern}.`);
|
|
135
|
+
}
|
|
136
|
+
return { output: { source, stream, match: pattern } };
|
|
137
|
+
}
|
|
138
|
+
const assertion = parseAssertion(node.assert, 'assert_output.assert');
|
|
139
|
+
const selected = assertion.path === '$' ? actual : getJsonPathValue(output, assertion.path);
|
|
140
|
+
assertAtomicValue(selected, assertion.operator, assertion.value, `${source}.${assertion.path}`);
|
|
141
|
+
return { output: { source, stream, assertion: { ...assertion, actual: selected } } };
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function defineStateReadAdapter() {
|
|
146
|
+
return {
|
|
147
|
+
action: 'state_read',
|
|
148
|
+
async execute(node, context) {
|
|
149
|
+
const source = asString(node.source ?? node.node, 'state_read.source');
|
|
150
|
+
const output = context.getOutput(source);
|
|
151
|
+
const statePath = asOptionalString(node.path, 'state_read.path') ?? '$';
|
|
152
|
+
const value = statePath === '$' ? output : getJsonPathValue(output, statePath);
|
|
153
|
+
return { output: { source, path: statePath, value } };
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function defineSwitchAdapter() {
|
|
158
|
+
return {
|
|
159
|
+
action: 'switch',
|
|
160
|
+
async execute(node, context) {
|
|
161
|
+
if (!Array.isArray(node.cases)) {
|
|
162
|
+
throw new Error('switch.cases must be an array of { when, next } entries.');
|
|
163
|
+
}
|
|
164
|
+
for (const [index, rawCase] of node.cases.entries()) {
|
|
165
|
+
if (!rawCase || typeof rawCase !== 'object' || Array.isArray(rawCase)) {
|
|
166
|
+
throw new Error(`switch.cases[${index}] must be an object.`);
|
|
167
|
+
}
|
|
168
|
+
const switchCase = rawCase;
|
|
169
|
+
const next = asString(switchCase.next, `switch.cases[${index}].next`);
|
|
170
|
+
const predicate = parseAssertionNode(switchCase.when, `switch.cases[${index}].when`);
|
|
171
|
+
if (evaluateAssertion(context.outputs, predicate)) {
|
|
172
|
+
return { next, output: { case: index, next } };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const defaultNext = asOptionalString(node.default, 'switch.default');
|
|
176
|
+
if (!defaultNext)
|
|
177
|
+
throw new Error('switch did not match any case and has no default.');
|
|
178
|
+
return { next: defaultNext, output: { case: 'default', next: defaultNext } };
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function defineManualAdapter() {
|
|
183
|
+
return {
|
|
184
|
+
action: 'manual',
|
|
185
|
+
async execute(node) {
|
|
186
|
+
return {
|
|
187
|
+
output: {
|
|
188
|
+
manual: true,
|
|
189
|
+
instruction: asOptionalString(node.instruction ?? node.description, 'manual.instruction'),
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function defineWatchLogsAdapter() {
|
|
196
|
+
return {
|
|
197
|
+
action: 'watch_logs',
|
|
198
|
+
async execute(node, context) {
|
|
199
|
+
const filePath = asString(node.path, 'watch_logs.path');
|
|
200
|
+
const contains = asOptionalString(node.contains, 'watch_logs.contains');
|
|
201
|
+
const content = await readFile(context.resolveProjectPath(filePath), 'utf-8');
|
|
202
|
+
if (contains && !content.includes(contains)) {
|
|
203
|
+
throw new Error(`Log ${filePath} does not contain ${JSON.stringify(contains)}.`);
|
|
204
|
+
}
|
|
205
|
+
return { output: { path: filePath, matched: contains ?? null } };
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
function defineIndexArtifactsAdapter() {
|
|
210
|
+
return {
|
|
211
|
+
action: 'index_artifacts',
|
|
212
|
+
async execute(node, context) {
|
|
213
|
+
if (!Array.isArray(node.artifacts)) {
|
|
214
|
+
throw new Error('index_artifacts.artifacts must be an array of relative paths or entries.');
|
|
215
|
+
}
|
|
216
|
+
const entries = [];
|
|
217
|
+
for (const artifact of node.artifacts) {
|
|
218
|
+
const entry = parseArtifactEntry(artifact, context.nodeId);
|
|
219
|
+
const sourcePath = context.resolveProjectPath(entry.path);
|
|
220
|
+
const outputPath = context.resolveArtifactPath(entry.path);
|
|
221
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
222
|
+
await copyFile(sourcePath, outputPath);
|
|
223
|
+
context.registerArtifact(entry);
|
|
224
|
+
entries.push(entry);
|
|
225
|
+
}
|
|
226
|
+
return { artifacts: entries, output: { artifacts: entries.map((entry) => entry.path) } };
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
function parseArtifactEntry(value, nodeId) {
|
|
231
|
+
if (typeof value === 'string') {
|
|
232
|
+
const artifactPath = normalizeRelativePath(value).split(path.sep).join('/');
|
|
233
|
+
return { path: artifactPath, type: inferArtifactType(artifactPath), nodeId };
|
|
234
|
+
}
|
|
235
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
236
|
+
throw new Error('Artifact entries must be strings or objects.');
|
|
237
|
+
}
|
|
238
|
+
const record = value;
|
|
239
|
+
const artifactPath = normalizeRelativePath(asString(record.path, 'artifact.path'))
|
|
240
|
+
.split(path.sep)
|
|
241
|
+
.join('/');
|
|
242
|
+
return {
|
|
243
|
+
path: artifactPath,
|
|
244
|
+
type: asOptionalString(record.type, 'artifact.type') ?? inferArtifactType(artifactPath),
|
|
245
|
+
label: asOptionalString(record.label, 'artifact.label'),
|
|
246
|
+
nodeId: asOptionalString(record.nodeId, 'artifact.nodeId') ?? nodeId,
|
|
247
|
+
mimeType: asOptionalString(record.mimeType, 'artifact.mimeType'),
|
|
248
|
+
category: asOptionalString(record.category, 'artifact.category'),
|
|
249
|
+
proofTarget: asOptionalString(record.proofTarget, 'artifact.proofTarget'),
|
|
250
|
+
covers: Array.isArray(record.covers)
|
|
251
|
+
? record.covers.map((cover) => asString(cover, 'artifact.covers[]'))
|
|
252
|
+
: undefined,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
function inferArtifactType(artifactPath) {
|
|
256
|
+
if (/\.(png|jpg|jpeg|gif|webp)$/i.test(artifactPath))
|
|
257
|
+
return 'screenshot';
|
|
258
|
+
if (/\.(mp4|mov|webm)$/i.test(artifactPath))
|
|
259
|
+
return 'video';
|
|
260
|
+
if (/\.log$/i.test(artifactPath))
|
|
261
|
+
return 'log';
|
|
262
|
+
if (/\.json$/i.test(artifactPath))
|
|
263
|
+
return 'json';
|
|
264
|
+
if (/\.(md|html)$/i.test(artifactPath))
|
|
265
|
+
return 'report';
|
|
266
|
+
return 'other';
|
|
267
|
+
}
|
|
268
|
+
function runShellCommand(command, options) {
|
|
269
|
+
return new Promise((resolve, reject) => {
|
|
270
|
+
const child = spawn(command, {
|
|
271
|
+
cwd: options.cwd,
|
|
272
|
+
env: options.env,
|
|
273
|
+
shell: true,
|
|
274
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
275
|
+
});
|
|
276
|
+
let stdout = '';
|
|
277
|
+
let stderr = '';
|
|
278
|
+
let settled = false;
|
|
279
|
+
const timer = options.timeoutMs
|
|
280
|
+
? setTimeout(() => {
|
|
281
|
+
if (settled)
|
|
282
|
+
return;
|
|
283
|
+
settled = true;
|
|
284
|
+
child.kill('SIGTERM');
|
|
285
|
+
reject(new Error(`Command timed out after ${options.timeoutMs}ms: ${command}`));
|
|
286
|
+
}, options.timeoutMs)
|
|
287
|
+
: undefined;
|
|
288
|
+
child.stdout.setEncoding('utf-8');
|
|
289
|
+
child.stderr.setEncoding('utf-8');
|
|
290
|
+
child.stdout.on('data', (chunk) => {
|
|
291
|
+
stdout += chunk;
|
|
292
|
+
});
|
|
293
|
+
child.stderr.on('data', (chunk) => {
|
|
294
|
+
stderr += chunk;
|
|
295
|
+
});
|
|
296
|
+
child.on('error', (error) => {
|
|
297
|
+
if (settled)
|
|
298
|
+
return;
|
|
299
|
+
settled = true;
|
|
300
|
+
if (timer)
|
|
301
|
+
clearTimeout(timer);
|
|
302
|
+
reject(error);
|
|
303
|
+
});
|
|
304
|
+
child.on('close', (code) => {
|
|
305
|
+
if (settled)
|
|
306
|
+
return;
|
|
307
|
+
settled = true;
|
|
308
|
+
if (timer)
|
|
309
|
+
clearTimeout(timer);
|
|
310
|
+
resolve({ exitCode: code ?? 1, stdout, stderr });
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
function parseAssertion(value, label) {
|
|
315
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
316
|
+
throw new Error(`${label} must be an object.`);
|
|
317
|
+
}
|
|
318
|
+
const record = value;
|
|
319
|
+
return {
|
|
320
|
+
path: asOptionalString(record.path, `${label}.path`) ?? '$',
|
|
321
|
+
operator: asString(record.operator, `${label}.operator`),
|
|
322
|
+
value: record.value,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
function parseAssertionNode(value, label) {
|
|
326
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
327
|
+
throw new Error(`${label} must be an object.`);
|
|
328
|
+
}
|
|
329
|
+
const record = value;
|
|
330
|
+
for (const key of ['all', 'any', 'none']) {
|
|
331
|
+
if (record[key] == null)
|
|
332
|
+
continue;
|
|
333
|
+
const entries = record[key];
|
|
334
|
+
if (!Array.isArray(entries))
|
|
335
|
+
throw new Error(`${label}.${key} must be an array.`);
|
|
336
|
+
const assertions = entries.map((entry, index) => parseAssertionNode(entry, `${label}.${key}[${index}]`));
|
|
337
|
+
if (key === 'all')
|
|
338
|
+
return { all: assertions };
|
|
339
|
+
if (key === 'any')
|
|
340
|
+
return { any: assertions };
|
|
341
|
+
return { none: assertions };
|
|
342
|
+
}
|
|
343
|
+
return parseAssertion(value, label);
|
|
344
|
+
}
|
|
345
|
+
function assertAssertion(actualRoot, assertion, label) {
|
|
346
|
+
if (!evaluateAssertion(actualRoot, assertion)) {
|
|
347
|
+
throw new Error(`${label} failed assertion ${JSON.stringify(assertion)}.`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
function evaluateAssertion(actualRoot, assertion) {
|
|
351
|
+
if ('all' in assertion)
|
|
352
|
+
return assertion.all.every((entry) => evaluateAssertion(actualRoot, entry));
|
|
353
|
+
if ('any' in assertion)
|
|
354
|
+
return assertion.any.some((entry) => evaluateAssertion(actualRoot, entry));
|
|
355
|
+
if ('none' in assertion)
|
|
356
|
+
return !assertion.none.some((entry) => evaluateAssertion(actualRoot, entry));
|
|
357
|
+
const actual = assertion.path === '$' ? actualRoot : getJsonPathValue(actualRoot, assertion.path);
|
|
358
|
+
return evaluateAtomicValue(actual, assertion.operator, assertion.value);
|
|
359
|
+
}
|
|
360
|
+
function assertAtomicValue(actual, operator, expected, label) {
|
|
361
|
+
if (!evaluateAtomicValue(actual, operator, expected)) {
|
|
362
|
+
throw new Error(`${label} expected ${operator} ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}.`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
function evaluateAtomicValue(actual, operator, expected) {
|
|
366
|
+
switch (operator) {
|
|
367
|
+
case 'exists':
|
|
368
|
+
return actual !== undefined;
|
|
369
|
+
case 'not_null':
|
|
370
|
+
return actual != null;
|
|
371
|
+
case 'truthy':
|
|
372
|
+
return Boolean(actual);
|
|
373
|
+
case 'falsy':
|
|
374
|
+
return !actual;
|
|
375
|
+
case 'eq':
|
|
376
|
+
return actual === expected;
|
|
377
|
+
case 'ne':
|
|
378
|
+
case 'neq':
|
|
379
|
+
return actual !== expected;
|
|
380
|
+
case 'deep_eq':
|
|
381
|
+
return isDeepStrictEqual(actual, expected);
|
|
382
|
+
case 'contains':
|
|
383
|
+
return containsValue(actual, expected);
|
|
384
|
+
case 'not_contains':
|
|
385
|
+
return !containsValue(actual, expected);
|
|
386
|
+
case 'matches':
|
|
387
|
+
return typeof expected === 'string' && new RegExp(expected).test(String(actual ?? ''));
|
|
388
|
+
case 'one_of':
|
|
389
|
+
return Array.isArray(expected) && expected.some((entry) => isDeepStrictEqual(entry, actual));
|
|
390
|
+
case 'length_eq':
|
|
391
|
+
return getLength(actual) === expected;
|
|
392
|
+
case 'length_gt':
|
|
393
|
+
return typeof expected === 'number' && getLength(actual) > expected;
|
|
394
|
+
case 'length_gte':
|
|
395
|
+
return typeof expected === 'number' && getLength(actual) >= expected;
|
|
396
|
+
case 'length_lt':
|
|
397
|
+
return typeof expected === 'number' && getLength(actual) < expected;
|
|
398
|
+
case 'length_lte':
|
|
399
|
+
return typeof expected === 'number' && getLength(actual) <= expected;
|
|
400
|
+
case 'gt':
|
|
401
|
+
case 'gte':
|
|
402
|
+
case 'lt':
|
|
403
|
+
case 'lte': {
|
|
404
|
+
if (typeof actual !== 'number' || typeof expected !== 'number') {
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
if (operator === 'gt')
|
|
408
|
+
return actual > expected;
|
|
409
|
+
if (operator === 'gte')
|
|
410
|
+
return actual >= expected;
|
|
411
|
+
if (operator === 'lt')
|
|
412
|
+
return actual < expected;
|
|
413
|
+
return actual <= expected;
|
|
414
|
+
}
|
|
415
|
+
default:
|
|
416
|
+
throw new Error(`Unsupported assertion operator ${operator}.`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
function containsValue(actual, expected) {
|
|
420
|
+
if (typeof actual === 'string')
|
|
421
|
+
return actual.includes(String(expected));
|
|
422
|
+
if (Array.isArray(actual))
|
|
423
|
+
return actual.some((entry) => isDeepStrictEqual(entry, expected));
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
function getLength(actual) {
|
|
427
|
+
if (typeof actual === 'string' || Array.isArray(actual))
|
|
428
|
+
return actual.length;
|
|
429
|
+
if (actual && typeof actual === 'object')
|
|
430
|
+
return Object.keys(actual).length;
|
|
431
|
+
return Number.NaN;
|
|
432
|
+
}
|
|
433
|
+
function getOutputField(output, field) {
|
|
434
|
+
if (!output || typeof output !== 'object' || Array.isArray(output))
|
|
435
|
+
return undefined;
|
|
436
|
+
return output[field];
|
|
437
|
+
}
|
|
438
|
+
//# sourceMappingURL=core.js.map
|