@aion0/forge 0.10.12 → 0.10.17

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 (35) hide show
  1. package/RELEASE_NOTES.md +6 -3
  2. package/app/api/public-info/[resource]/route.ts +40 -0
  3. package/components/SettingsModal.tsx +42 -33
  4. package/components/WorkspaceView.tsx +5 -3
  5. package/lib/agents/known-models.ts +75 -0
  6. package/lib/public-info/fetch.ts +116 -0
  7. package/lib/public-info/types.ts +38 -0
  8. package/lib/public-info/use-models-registry.ts +66 -0
  9. package/lib/settings.ts +9 -0
  10. package/next-env.d.ts +1 -1
  11. package/package.json +1 -1
  12. package/lib/__tests__/foreach-batch-yaml.test.ts +0 -33
  13. package/lib/__tests__/foreach-before.test.ts +0 -201
  14. package/lib/__tests__/foreach-parse.test.ts +0 -114
  15. package/lib/__tests__/foreach-snapshot.test.ts +0 -112
  16. package/lib/__tests__/foreach-source.test.ts +0 -105
  17. package/lib/__tests__/foreach-template.test.ts +0 -112
  18. package/lib/workspace/__tests__/state-machine.test.ts +0 -388
  19. package/lib/workspace/__tests__/workspace.test.ts +0 -311
  20. package/scripts/bench/README.md +0 -66
  21. package/scripts/bench/results/.gitignore +0 -2
  22. package/scripts/bench/run.ts +0 -635
  23. package/scripts/bench/tasks/01-text-utils/task.md +0 -26
  24. package/scripts/bench/tasks/01-text-utils/validator.sh +0 -46
  25. package/scripts/bench/tasks/02-pagination/setup.sh +0 -19
  26. package/scripts/bench/tasks/02-pagination/task.md +0 -48
  27. package/scripts/bench/tasks/02-pagination/validator.sh +0 -69
  28. package/scripts/bench/tasks/03-bug-fix/setup.sh +0 -82
  29. package/scripts/bench/tasks/03-bug-fix/task.md +0 -30
  30. package/scripts/bench/tasks/03-bug-fix/validator.sh +0 -29
  31. package/scripts/test-agents-migrate.ts +0 -149
  32. package/scripts/test-mantis.ts +0 -223
  33. package/scripts/test-memory-local.ts +0 -139
  34. package/scripts/test-memory-upsert.ts +0 -106
  35. package/scripts/verify-usage.ts +0 -178
@@ -1,201 +0,0 @@
1
- /**
2
- * for_each.before: tests — covers:
3
- * - parseWorkflow validates `before:` field shape + node existence
4
- * - parseForEach passes through `before:` correctly
5
- * - resolveForEachSource accepts node outputs (`{{nodes.X.outputs.Y}}`)
6
- *
7
- * Setup-phase orchestration (startPipeline scheduling, checkPipelineCompletion
8
- * transition) needs running tasks + file system + scheduler — not unit-testable
9
- * here. Verified end-to-end in fortinet-mr-review-batch v0.3.0.
10
- *
11
- * Run: npx tsx lib/__tests__/foreach-before.test.ts
12
- */
13
-
14
- import { parseWorkflow, resolveForEachSource } from '../pipeline';
15
-
16
- let passed = 0, failed = 0;
17
- function check(name: string, fn: () => void) {
18
- try { fn(); console.log(` ✓ ${name}`); passed++; }
19
- catch (e) { console.log(` ✗ ${name}\n ${(e as Error).message}`); failed++; }
20
- }
21
- function expectThrow(fn: () => void, msgIncludes: string) {
22
- try { fn(); throw new Error(`expected throw containing "${msgIncludes}"`); }
23
- catch (e) {
24
- const m = (e as Error).message;
25
- if (!m.includes(msgIncludes)) throw new Error(`got "${m}", expected to include "${msgIncludes}"`);
26
- }
27
- }
28
- function eqArr(a: unknown[], b: unknown[], label = '') {
29
- if (a.length !== b.length || a.some((v, i) => v !== b[i])) {
30
- throw new Error(`${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`);
31
- }
32
- }
33
-
34
- console.log('for_each.before parsing + node-output source resolution tests');
35
-
36
- // ── parseForEach / parseWorkflow: shape + validation ──
37
-
38
- check('parseWorkflow accepts valid before: [<node>]', () => {
39
- const wf = parseWorkflow(`
40
- name: test
41
- for_each:
42
- source: "{{nodes.list-ids.outputs.ids}}"
43
- as: id
44
- before: [list-ids]
45
- nodes:
46
- list-ids:
47
- project: x
48
- prompt: "echo 1,2,3"
49
- body:
50
- project: x
51
- prompt: "consume {{id}}"
52
- depends_on: [list-ids]
53
- `);
54
- if (!wf.for_each) throw new Error('for_each missing');
55
- if (!wf.for_each.before) throw new Error('before missing');
56
- eqArr(wf.for_each.before, ['list-ids']);
57
- });
58
-
59
- check('parseWorkflow accepts multi-node before: [a, b]', () => {
60
- const wf = parseWorkflow(`
61
- name: test
62
- for_each:
63
- source: "{{nodes.b.outputs.list}}"
64
- as: x
65
- before: [a, b]
66
- nodes:
67
- a: { project: x, prompt: "1" }
68
- b: { project: x, prompt: "2", depends_on: [a] }
69
- body: { project: x, prompt: "{{x}}" }
70
- `);
71
- eqArr(wf.for_each!.before!, ['a', 'b']);
72
- });
73
-
74
- check('parseWorkflow with NO before: → before undefined (back-compat)', () => {
75
- const wf = parseWorkflow(`
76
- name: test
77
- for_each:
78
- source: "{{input.ids}}"
79
- as: id
80
- nodes:
81
- body: { project: x, prompt: "{{id}}" }
82
- `);
83
- if (wf.for_each!.before !== undefined) throw new Error(`expected undefined, got ${JSON.stringify(wf.for_each!.before)}`);
84
- });
85
-
86
- check('parseWorkflow throws on before referencing unknown node', () => {
87
- expectThrow(() => parseWorkflow(`
88
- name: test
89
- for_each:
90
- source: "{{nodes.missing.outputs.x}}"
91
- as: id
92
- before: [missing]
93
- nodes:
94
- body: { project: x, prompt: "1" }
95
- `), "references unknown node id 'missing'");
96
- });
97
-
98
- check('parseWorkflow throws on before: not-an-array', () => {
99
- expectThrow(() => parseWorkflow(`
100
- name: test
101
- for_each:
102
- source: "{{nodes.list-ids.outputs.x}}"
103
- as: id
104
- before: "list-ids"
105
- nodes:
106
- list-ids: { project: x, prompt: "1" }
107
- body: { project: x, prompt: "{{id}}" }
108
- `), 'must be an array of node id strings');
109
- });
110
-
111
- check('parseWorkflow throws on before: [empty-string]', () => {
112
- expectThrow(() => parseWorkflow(`
113
- name: test
114
- for_each:
115
- source: "{{nodes.x.outputs.x}}"
116
- as: id
117
- before: [""]
118
- nodes:
119
- x: { project: x, prompt: "1" }
120
- body: { project: x, prompt: "{{id}}" }
121
- `), 'must be an array of node id strings');
122
- });
123
-
124
- check('parseWorkflow throws on before: [non-string]', () => {
125
- expectThrow(() => parseWorkflow(`
126
- name: test
127
- for_each:
128
- source: "{{nodes.x.outputs.x}}"
129
- as: id
130
- before: [123]
131
- nodes:
132
- x: { project: x, prompt: "1" }
133
- body: { project: x, prompt: "{{id}}" }
134
- `), 'must be an array of node id strings');
135
- });
136
-
137
- // ── resolveForEachSource: ctx now includes nodes ──
138
-
139
- check('resolveForEachSource reads {{nodes.X.outputs.Y}} from pipeline.nodes', () => {
140
- const nodes = {
141
- 'list-ids': { status: 'done' as const, outputs: { ids: '7,8,9' }, iterations: 0 },
142
- };
143
- const items = resolveForEachSource(
144
- { source: '{{nodes.list-ids.outputs.ids}}' },
145
- {},
146
- {},
147
- nodes,
148
- );
149
- eqArr(items, ['7', '8', '9']);
150
- });
151
-
152
- check('resolveForEachSource trims whitespace from node-output split items', () => {
153
- const nodes = {
154
- 'list-ids': { status: 'done' as const, outputs: { ids: ' 1, 2 ,3 ' }, iterations: 0 },
155
- };
156
- const items = resolveForEachSource(
157
- { source: '{{nodes.list-ids.outputs.ids}}' },
158
- {},
159
- {},
160
- nodes,
161
- );
162
- eqArr(items, ['1', '2', '3']);
163
- });
164
-
165
- check('resolveForEachSource empty node output → empty array', () => {
166
- const nodes = {
167
- 'list-ids': { status: 'done' as const, outputs: { ids: '' }, iterations: 0 },
168
- };
169
- const items = resolveForEachSource(
170
- { source: '{{nodes.list-ids.outputs.ids}}' },
171
- {},
172
- {},
173
- nodes,
174
- );
175
- eqArr(items, []);
176
- });
177
-
178
- check('resolveForEachSource with custom split + node output', () => {
179
- const nodes = {
180
- 'list-ids': { status: 'done' as const, outputs: { ids: 'a|b|c' }, iterations: 0 },
181
- };
182
- const items = resolveForEachSource(
183
- { source: '{{nodes.list-ids.outputs.ids}}', split: '|' },
184
- {},
185
- {},
186
- nodes,
187
- );
188
- eqArr(items, ['a', 'b', 'c']);
189
- });
190
-
191
- check('resolveForEachSource without nodes param (back-compat) still works', () => {
192
- const items = resolveForEachSource(
193
- { source: '{{input.ids}}' },
194
- { ids: '1,2' },
195
- {},
196
- );
197
- eqArr(items, ['1', '2']);
198
- });
199
-
200
- console.log(`\n${passed} passed, ${failed} failed`);
201
- process.exit(failed === 0 ? 0 : 1);
@@ -1,114 +0,0 @@
1
- /**
2
- * Step-2 verification: parseWorkflow handles `for_each:` correctly.
3
- *
4
- * No test framework — just a tsx script that throws on first failure.
5
- * Run: npx tsx lib/__tests__/foreach-parse.test.ts
6
- */
7
-
8
- import { parseWorkflow } from '../pipeline';
9
-
10
- let passed = 0;
11
- let failed = 0;
12
-
13
- function check(name: string, fn: () => void) {
14
- try { fn(); console.log(` ✓ ${name}`); passed++; }
15
- catch (e) { console.log(` ✗ ${name}\n ${(e as Error).message}`); failed++; }
16
- }
17
-
18
- function expectThrow(yaml: string, expectedSubstring: string) {
19
- try {
20
- parseWorkflow(yaml);
21
- throw new Error(`expected throw containing "${expectedSubstring}", got none`);
22
- } catch (e) {
23
- const msg = (e as Error).message;
24
- if (!msg.includes(expectedSubstring)) {
25
- throw new Error(`expected throw containing "${expectedSubstring}", got: ${msg}`);
26
- }
27
- }
28
- }
29
-
30
- console.log('parseWorkflow + for_each: parser tests');
31
-
32
- check('no for_each → workflow.for_each is undefined', () => {
33
- const w = parseWorkflow('name: foo\nnodes:\n a: { project: x, prompt: "echo hi" }\n');
34
- if (w.for_each !== undefined) throw new Error(`expected undefined, got ${JSON.stringify(w.for_each)}`);
35
- });
36
-
37
- check('valid for_each with string source', () => {
38
- const w = parseWorkflow(`
39
- name: foo
40
- for_each:
41
- source: "{{input.ids}}"
42
- as: bug_id
43
- nodes:
44
- a: { project: x, prompt: "echo {{bug_id}}" }
45
- `);
46
- if (!w.for_each) throw new Error('for_each missing');
47
- if (w.for_each.source !== '{{input.ids}}') throw new Error('source wrong');
48
- if (w.for_each.as !== 'bug_id') throw new Error('as wrong');
49
- if (w.for_each.on_failure !== 'continue') throw new Error(`on_failure default should be continue, got ${w.for_each.on_failure}`);
50
- });
51
-
52
- check('default as = "item"', () => {
53
- const w = parseWorkflow('name: foo\nfor_each:\n source: "{{input.x}}"\nnodes:\n a: { project: x, prompt: "" }\n');
54
- if (w.for_each!.as !== 'item') throw new Error('default as should be item');
55
- });
56
-
57
- check('on_failure: stop accepted', () => {
58
- const w = parseWorkflow('name: foo\nfor_each:\n source: x\n on_failure: stop\nnodes:\n a: { project: x, prompt: "" }\n');
59
- if (w.for_each!.on_failure !== 'stop') throw new Error('on_failure not preserved');
60
- });
61
-
62
- check('array source literal', () => {
63
- const w = parseWorkflow('name: foo\nfor_each:\n source: [1, 2, 3]\nnodes:\n a: { project: x, prompt: "" }\n');
64
- if (!Array.isArray(w.for_each!.source)) throw new Error('array literal not preserved');
65
- if ((w.for_each!.source as any[]).length !== 3) throw new Error('array length wrong');
66
- });
67
-
68
- check('split: ";" accepted', () => {
69
- const w = parseWorkflow('name: foo\nfor_each:\n source: x\n split: ";"\nnodes:\n a: { project: x, prompt: "" }\n');
70
- if (w.for_each!.split !== ';') throw new Error('split not preserved');
71
- });
72
-
73
- console.log('\nError-path tests:');
74
-
75
- check('throws on missing source', () => {
76
- expectThrow('name: foo\nfor_each:\n as: bug\nnodes:\n a: { project: x, prompt: "" }\n', 'source is required');
77
- });
78
-
79
- check('throws on invalid as identifier', () => {
80
- expectThrow('name: foo\nfor_each:\n source: x\n as: "bad-name"\nnodes:\n a: { project: x, prompt: "" }\n', 'must be a valid identifier');
81
- });
82
-
83
- check('throws on reserved as: input', () => {
84
- expectThrow('name: foo\nfor_each:\n source: x\n as: input\nnodes:\n a: { project: x, prompt: "" }\n', 'reserved template namespace');
85
- });
86
-
87
- check('throws on reserved as: run', () => {
88
- expectThrow('name: foo\nfor_each:\n source: x\n as: run\nnodes:\n a: { project: x, prompt: "" }\n', 'reserved template namespace');
89
- });
90
-
91
- check('throws on bad on_failure value', () => {
92
- expectThrow('name: foo\nfor_each:\n source: x\n on_failure: maybe\nnodes:\n a: { project: x, prompt: "" }\n', "must be 'continue' or 'stop'");
93
- });
94
-
95
- check('throws on for_each on conversation type', () => {
96
- expectThrow(`
97
- name: foo
98
- type: conversation
99
- for_each:
100
- source: x
101
- agents:
102
- - id: a
103
- agent: claude
104
- initial_prompt: hi
105
- nodes: {}
106
- `, "only supported on type='dag'");
107
- });
108
-
109
- check('throws on for_each scalar (not object)', () => {
110
- expectThrow('name: foo\nfor_each: "{{input.ids}}"\nnodes:\n a: { project: x, prompt: "" }\n', 'must be an object');
111
- });
112
-
113
- console.log(`\n${passed} passed, ${failed} failed`);
114
- process.exit(failed === 0 ? 0 : 1);
@@ -1,112 +0,0 @@
1
- /**
2
- * Step-5 verification: snapshotIteration captures node states + correctly
3
- * advances the iterations[] history. (Full checkPipelineCompletion has
4
- * filesystem + scheduling side effects — exercised end-to-end in Step 7.)
5
- *
6
- * Run: npx tsx lib/__tests__/foreach-snapshot.test.ts
7
- */
8
-
9
- import { snapshotIteration } from '../pipeline';
10
-
11
- let passed = 0, failed = 0;
12
- function check(name: string, fn: () => void) {
13
- try { fn(); console.log(` ✓ ${name}`); passed++; }
14
- catch (e) { console.log(` ✗ ${name}\n ${(e as Error).message}`); failed++; }
15
- }
16
-
17
- function makePipeline(items: unknown[], currentIndex = 0): any {
18
- return {
19
- id: 'test123',
20
- workflowName: 'test',
21
- status: 'running',
22
- input: {},
23
- vars: {},
24
- nodes: {
25
- a: { status: 'done', outputs: { x: '1' } },
26
- b: { status: 'done', outputs: { y: '2' }, error: undefined },
27
- },
28
- nodeOrder: ['a', 'b'],
29
- createdAt: '2026-01-01T00:00:00.000Z',
30
- forEach: {
31
- items,
32
- currentIndex,
33
- total: items.length,
34
- asName: 'item',
35
- onFailure: 'continue',
36
- iterations: [],
37
- },
38
- };
39
- }
40
-
41
- console.log('snapshotIteration tests');
42
-
43
- check('snapshot captures all-done nodes with status="done"', () => {
44
- const p = makePipeline([1, 2, 3]);
45
- snapshotIteration(p, false);
46
- if (p.forEach.iterations.length !== 1) throw new Error('iterations length wrong');
47
- const iter = p.forEach.iterations[0];
48
- if (iter.status !== 'done') throw new Error(`status wrong: ${iter.status}`);
49
- if (iter.index !== 0) throw new Error('index wrong');
50
- if (iter.nodes.a.status !== 'done') throw new Error('node a status wrong');
51
- if (iter.nodes.a.outputs.x !== '1') throw new Error('node a output not preserved');
52
- });
53
-
54
- check('snapshot captures failed iter with status="failed"', () => {
55
- const p = makePipeline([1, 2]);
56
- p.nodes.b.status = 'failed';
57
- p.nodes.b.error = 'boom';
58
- snapshotIteration(p, true);
59
- const iter = p.forEach.iterations[0];
60
- if (iter.status !== 'failed') throw new Error(`status wrong: ${iter.status}`);
61
- if (iter.nodes.b.error !== 'boom') throw new Error('error not preserved');
62
- });
63
-
64
- check('startedAt = createdAt for first iter', () => {
65
- const p = makePipeline([1, 2]);
66
- snapshotIteration(p, false);
67
- if (p.forEach.iterations[0].startedAt !== p.createdAt) throw new Error('first startedAt should = createdAt');
68
- });
69
-
70
- check('startedAt = previous completedAt for subsequent iters', () => {
71
- const p = makePipeline([1, 2]);
72
- snapshotIteration(p, false);
73
- const firstCompleted = p.forEach.iterations[0].completedAt;
74
- p.forEach.currentIndex = 1;
75
- snapshotIteration(p, false);
76
- if (p.forEach.iterations[1].startedAt !== firstCompleted) {
77
- throw new Error('second iter startedAt should chain from prev completedAt');
78
- }
79
- });
80
-
81
- check('outputs are deep-copied (mutation safety)', () => {
82
- const p = makePipeline([1]);
83
- snapshotIteration(p, false);
84
- const iter = p.forEach.iterations[0];
85
- // Mutate live node — snapshot should NOT change
86
- p.nodes.a.outputs.x = 'MUTATED';
87
- if (iter.nodes.a.outputs.x !== '1') throw new Error('snapshot was mutated; need defensive copy');
88
- });
89
-
90
- check('multiple iterations accumulate', () => {
91
- const p = makePipeline([1, 2, 3]);
92
- snapshotIteration(p, false);
93
- p.forEach.currentIndex = 1;
94
- snapshotIteration(p, false);
95
- p.forEach.currentIndex = 2;
96
- snapshotIteration(p, true);
97
- if (p.forEach.iterations.length !== 3) throw new Error('expected 3 iters');
98
- if (p.forEach.iterations[2].status !== 'failed') throw new Error('iter 3 should be failed');
99
- if (p.forEach.iterations[0].index !== 0) throw new Error('iter 0 index wrong');
100
- if (p.forEach.iterations[1].index !== 1) throw new Error('iter 1 index wrong');
101
- if (p.forEach.iterations[2].index !== 2) throw new Error('iter 2 index wrong');
102
- });
103
-
104
- check('no-op when pipeline.forEach undefined', () => {
105
- const p: any = { id: 'x', nodes: {}, createdAt: '2026-01-01' };
106
- snapshotIteration(p, false);
107
- // Should not throw, should not add anything
108
- if (p.forEach !== undefined) throw new Error('should leave forEach undefined');
109
- });
110
-
111
- console.log(`\n${passed} passed, ${failed} failed`);
112
- process.exit(failed === 0 ? 0 : 1);
@@ -1,105 +0,0 @@
1
- /**
2
- * Step-4 verification: resolveForEachSource resolves + splits items.
3
- * Run: npx tsx lib/__tests__/foreach-source.test.ts
4
- */
5
-
6
- import { resolveForEachSource } from '../pipeline';
7
-
8
- let passed = 0, failed = 0;
9
- function check(name: string, fn: () => void) {
10
- try { fn(); console.log(` ✓ ${name}`); passed++; }
11
- catch (e) { console.log(` ✗ ${name}\n ${(e as Error).message}`); failed++; }
12
- }
13
- function eqArr(a: unknown[], b: unknown[], label = '') {
14
- if (a.length !== b.length || a.some((v, i) => v !== b[i])) {
15
- throw new Error(`${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`);
16
- }
17
- }
18
-
19
- console.log('resolveForEachSource tests');
20
-
21
- check('string source via {{input.x}} template, default split=","', () => {
22
- const items = resolveForEachSource(
23
- { source: '{{input.ids}}' },
24
- { ids: '111,222,333' },
25
- {},
26
- );
27
- eqArr(items, ['111', '222', '333']);
28
- });
29
-
30
- check('string source with whitespace around commas — trimmed', () => {
31
- const items = resolveForEachSource(
32
- { source: '{{input.ids}}' },
33
- { ids: ' 111 ,222 , 333 ' },
34
- {},
35
- );
36
- eqArr(items, ['111', '222', '333']);
37
- });
38
-
39
- check('string source with empty entries dropped', () => {
40
- const items = resolveForEachSource(
41
- { source: '{{input.ids}}' },
42
- { ids: '111,,222, ,333,' },
43
- {},
44
- );
45
- eqArr(items, ['111', '222', '333']);
46
- });
47
-
48
- check('empty input.ids → empty array', () => {
49
- const items = resolveForEachSource({ source: '{{input.ids}}' }, { ids: '' }, {});
50
- eqArr(items, []);
51
- });
52
-
53
- check('input missing → empty string template → empty array', () => {
54
- const items = resolveForEachSource({ source: '{{input.missing}}' }, {}, {});
55
- eqArr(items, []);
56
- });
57
-
58
- check('custom split: ";"', () => {
59
- const items = resolveForEachSource(
60
- { source: '{{input.ids}}', split: ';' },
61
- { ids: '111;222;333' },
62
- {},
63
- );
64
- eqArr(items, ['111', '222', '333']);
65
- });
66
-
67
- check('array literal source — used as-is', () => {
68
- const items = resolveForEachSource(
69
- { source: ['a', 'b', 'c'] },
70
- {},
71
- {},
72
- );
73
- eqArr(items, ['a', 'b', 'c']);
74
- });
75
-
76
- check('array literal with objects — preserved', () => {
77
- const items = resolveForEachSource(
78
- { source: [{ iid: 1 }, { iid: 2 }] },
79
- {},
80
- {},
81
- );
82
- if (items.length !== 2) throw new Error('length wrong');
83
- if ((items[0] as any).iid !== 1) throw new Error('first item wrong');
84
- });
85
-
86
- check('vars source resolves', () => {
87
- const items = resolveForEachSource(
88
- { source: '{{vars.list}}' },
89
- {},
90
- { list: 'a,b,c' },
91
- );
92
- eqArr(items, ['a', 'b', 'c']);
93
- });
94
-
95
- check('literal string with no template, just split', () => {
96
- const items = resolveForEachSource(
97
- { source: 'foo,bar' },
98
- {},
99
- {},
100
- );
101
- eqArr(items, ['foo', 'bar']);
102
- });
103
-
104
- console.log(`\n${passed} passed, ${failed} failed`);
105
- process.exit(failed === 0 ? 0 : 1);
@@ -1,112 +0,0 @@
1
- /**
2
- * Step-3 verification: resolveTemplate handles for_each context.
3
- * Run: npx tsx lib/__tests__/foreach-template.test.ts
4
- */
5
-
6
- import { resolveTemplate } from '../pipeline';
7
-
8
- let passed = 0;
9
- let failed = 0;
10
- function check(name: string, fn: () => void) {
11
- try { fn(); console.log(` ✓ ${name}`); passed++; }
12
- catch (e) { console.log(` ✗ ${name}\n ${(e as Error).message}`); failed++; }
13
- }
14
- function eq(actual: string, expected: string, label = '') {
15
- if (actual !== expected) {
16
- throw new Error(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
17
- }
18
- }
19
-
20
- const baseCtx = { input: {}, vars: {}, nodes: {} };
21
-
22
- console.log('resolveTemplate + for_each tests');
23
-
24
- check('no forEach in ctx — {{item}} is literal passthrough', () => {
25
- eq(resolveTemplate('hello {{item}}', baseCtx), 'hello {{item}}');
26
- });
27
-
28
- check('scalar item: {{<as>}} substitutes', () => {
29
- const ctx = { ...baseCtx, forEach: { asName: 'bug_id', item: 1213844, index: 0, total: 3 } };
30
- eq(resolveTemplate('Fix bug {{bug_id}} now', ctx), 'Fix bug 1213844 now');
31
- });
32
-
33
- check('string item: {{<as>}} preserves string', () => {
34
- const ctx = { ...baseCtx, forEach: { asName: 'name', item: 'alice', index: 0, total: 1 } };
35
- eq(resolveTemplate('hi {{name}}', ctx), 'hi alice');
36
- });
37
-
38
- check('object item: {{<as>.field}} dotted access', () => {
39
- const ctx = { ...baseCtx, forEach: { asName: 'mr', item: { iid: 42, title: 'fix x' }, index: 0, total: 1 } };
40
- eq(resolveTemplate('MR !{{mr.iid}}: {{mr.title}}', ctx), 'MR !42: fix x');
41
- });
42
-
43
- check('object item: missing field → empty string', () => {
44
- const ctx = { ...baseCtx, forEach: { asName: 'mr', item: { iid: 42 }, index: 0, total: 1 } };
45
- eq(resolveTemplate('author=@{{mr.author}}', ctx), 'author=@');
46
- });
47
-
48
- check('object item: {{<as>}} alone → JSON', () => {
49
- const ctx = { ...baseCtx, forEach: { asName: 'mr', item: { iid: 42, t: 'x' }, index: 0, total: 1 } };
50
- eq(resolveTemplate('raw={{mr}}', ctx), 'raw={"iid":42,"t":"x"}');
51
- });
52
-
53
- check('{{loop.index}} + {{loop.total}}', () => {
54
- const ctx = { ...baseCtx, forEach: { asName: 'item', item: 'x', index: 2, total: 5 } };
55
- eq(resolveTemplate('Step {{loop.index}} of {{loop.total}}', ctx), 'Step 2 of 5');
56
- });
57
-
58
- check('shellMode escapes scalar item', () => {
59
- const ctx = { ...baseCtx, forEach: { asName: 'name', item: "o'brien", index: 0, total: 1 } };
60
- // shellEscapeAnsiC: ' → \'
61
- const out = resolveTemplate("echo '{{name}}'", ctx, true);
62
- if (!out.includes("o\\'brien")) throw new Error(`expected escaped apostrophe, got: ${out}`);
63
- });
64
-
65
- check('{{raw:<as>}} bypasses shell escape', () => {
66
- const ctx = { ...baseCtx, forEach: { asName: 'name', item: "o'brien", index: 0, total: 1 } };
67
- // raw: keeps verbatim
68
- eq(resolveTemplate('{{raw:name}}', ctx, true), "o'brien");
69
- });
70
-
71
- check('input.x still works alongside forEach', () => {
72
- const ctx = { input: { proj: 'FortiNAC' }, vars: {}, nodes: {}, forEach: { asName: 'bug_id', item: 1, index: 0, total: 1 } };
73
- eq(resolveTemplate('{{input.proj}}/mantis-{{bug_id}}', ctx), 'FortiNAC/mantis-1');
74
- });
75
-
76
- check('nodes.X.outputs.Y still works alongside forEach', () => {
77
- const ctx: any = {
78
- input: {},
79
- vars: {},
80
- nodes: { ingest: { status: 'done', outputs: { ctx: 'hello' }, iterations: 1 } },
81
- forEach: { asName: 'item', item: 'x', index: 0, total: 1 },
82
- };
83
- eq(resolveTemplate('{{nodes.ingest.outputs.ctx}}/{{item}}', ctx), 'hello/x');
84
- });
85
-
86
- check('run.tmp_dir still works alongside forEach', () => {
87
- const ctx = {
88
- ...baseCtx,
89
- tmpDir: '/path/.forge/worktrees/pipeline-abc',
90
- forEach: { asName: 'bug_id', item: 42, index: 0, total: 1 },
91
- };
92
- eq(resolveTemplate('{{run.tmp_dir}}/mantis-{{bug_id}}', ctx),
93
- '/path/.forge/worktrees/pipeline-abc/mantis-42');
94
- });
95
-
96
- check('forEach.item = null → empty string', () => {
97
- const ctx = { ...baseCtx, forEach: { asName: 'x', item: null, index: 0, total: 1 } };
98
- eq(resolveTemplate('[{{x}}]', ctx), '[]');
99
- });
100
-
101
- check('forEach.item = false → "false"', () => {
102
- const ctx = { ...baseCtx, forEach: { asName: 'x', item: false, index: 0, total: 1 } };
103
- eq(resolveTemplate('[{{x}}]', ctx), '[false]');
104
- });
105
-
106
- check('loop.index works even when item is undefined-but-asName-set (defensive)', () => {
107
- const ctx = { ...baseCtx, forEach: { asName: 'item', item: undefined, index: 7, total: 9 } };
108
- eq(resolveTemplate('{{loop.index}}/{{loop.total}}', ctx), '7/9');
109
- });
110
-
111
- console.log(`\n${passed} passed, ${failed} failed`);
112
- process.exit(failed === 0 ? 0 : 1);