@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +207 -0
  3. package/bin/farmslot-recipe.mjs +10 -0
  4. package/dist/adapters/core.d.ts +5 -0
  5. package/dist/adapters/core.d.ts.map +1 -0
  6. package/dist/adapters/core.js +438 -0
  7. package/dist/adapters/core.js.map +1 -0
  8. package/dist/adapters/ui.d.ts +23 -0
  9. package/dist/adapters/ui.d.ts.map +1 -0
  10. package/dist/adapters/ui.js +41 -0
  11. package/dist/adapters/ui.js.map +1 -0
  12. package/dist/cli/run-command.d.ts +3 -0
  13. package/dist/cli/run-command.d.ts.map +1 -0
  14. package/dist/cli/run-command.js +41 -0
  15. package/dist/cli/run-command.js.map +1 -0
  16. package/dist/cli/validate-command.d.ts +3 -0
  17. package/dist/cli/validate-command.d.ts.map +1 -0
  18. package/dist/cli/validate-command.js +31 -0
  19. package/dist/cli/validate-command.js.map +1 -0
  20. package/dist/cli-support.d.ts +14 -0
  21. package/dist/cli-support.d.ts.map +1 -0
  22. package/dist/cli-support.js +94 -0
  23. package/dist/cli-support.js.map +1 -0
  24. package/dist/cli.d.ts +4 -0
  25. package/dist/cli.d.ts.map +1 -0
  26. package/dist/cli.js +29 -0
  27. package/dist/cli.js.map +1 -0
  28. package/dist/index.d.ts +15 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +10 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/json.d.ts +9 -0
  33. package/dist/json.d.ts.map +1 -0
  34. package/dist/json.js +74 -0
  35. package/dist/json.js.map +1 -0
  36. package/dist/runner.d.ts +4 -0
  37. package/dist/runner.d.ts.map +1 -0
  38. package/dist/runner.js +1172 -0
  39. package/dist/runner.js.map +1 -0
  40. package/dist/runtime/browser-extension.d.ts +17 -0
  41. package/dist/runtime/browser-extension.d.ts.map +1 -0
  42. package/dist/runtime/browser-extension.js +42 -0
  43. package/dist/runtime/browser-extension.js.map +1 -0
  44. package/dist/runtime/cdp.d.ts +86 -0
  45. package/dist/runtime/cdp.d.ts.map +1 -0
  46. package/dist/runtime/cdp.js +512 -0
  47. package/dist/runtime/cdp.js.map +1 -0
  48. package/dist/runtime/react-native-bridge.d.ts +17 -0
  49. package/dist/runtime/react-native-bridge.d.ts.map +1 -0
  50. package/dist/runtime/react-native-bridge.js +24 -0
  51. package/dist/runtime/react-native-bridge.js.map +1 -0
  52. package/dist/types.d.ts +143 -0
  53. package/dist/types.d.ts.map +1 -0
  54. package/dist/types.js +2 -0
  55. package/dist/types.js.map +1 -0
  56. package/dist/writers.d.ts +23 -0
  57. package/dist/writers.d.ts.map +1 -0
  58. package/dist/writers.js +72 -0
  59. package/dist/writers.js.map +1 -0
  60. package/package.json +79 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@farmslot/recipe-harness` are tracked here.
4
+
5
+ ## Unreleased
6
+
7
+ - Active-development baseline; add user-facing changes here before release or package publication.
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,5 @@
1
+ import type { ActionAdapter } from '../types.js';
2
+ export declare function createStandardCoreAdapters(options?: {
3
+ actions?: Iterable<string>;
4
+ }): ActionAdapter[];
5
+ //# sourceMappingURL=core.d.ts.map
@@ -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