@junoflow/testkit 0.6.0 → 0.9.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/README.md +221 -2
- package/package.json +2 -2
- package/src/app.js +274 -0
- package/src/bindings.js +726 -0
- package/src/findings.js +49 -0
- package/src/flow-conformance.js +43 -0
- package/src/flow.js +290 -0
- package/src/index.d.ts +398 -0
- package/src/index.js +32 -68
- package/src/names.js +9 -0
- package/src/scenario.js +72 -0
- package/src/schema.js +8 -4
- package/src/time.js +131 -0
package/src/findings.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { fail } from './assert.js';
|
|
2
|
+
import { names } from './names.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one call behind open/clear/set, mirroring the SDK and the host: the verbs
|
|
6
|
+
* differ only in whether the boolean is already decided, and the answer is the
|
|
7
|
+
* transition this call made. Both false means it refreshed an instance that was
|
|
8
|
+
* already standing, or retracted one that was already absent.
|
|
9
|
+
*
|
|
10
|
+
* The name is checked against the manifest for the same reason emit's is: Juno
|
|
11
|
+
* rejects an undeclared finding at runtime, and finding that out from an
|
|
12
|
+
* installer is not a test.
|
|
13
|
+
*/
|
|
14
|
+
export function setFinding(manifest, state, slug, standing, opts) {
|
|
15
|
+
const declared = manifest.findings?.[slug];
|
|
16
|
+
if (!declared) {
|
|
17
|
+
fail(
|
|
18
|
+
`ctx.findings — "${slug}" is not declared in manifest.json (have: ${names(manifest.findings)}). ` +
|
|
19
|
+
'Juno rejects an undeclared finding at runtime.'
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
const key = opts.key ?? '';
|
|
23
|
+
const existing = state.findings.find((f) => f.slug === slug && f.key === key);
|
|
24
|
+
|
|
25
|
+
if (!standing) {
|
|
26
|
+
// A retraction says nothing about severity or text, and clearing what was
|
|
27
|
+
// never open is a no-op here exactly as it is in the store.
|
|
28
|
+
if (!existing || !existing.standing)
|
|
29
|
+
return { opened: false, cleared: false };
|
|
30
|
+
existing.standing = false;
|
|
31
|
+
return { opened: false, cleared: true };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const severity = opts.severity ?? declared.severity ?? 'error';
|
|
35
|
+
if (!['info', 'warn', 'error'].includes(severity)) {
|
|
36
|
+
fail(
|
|
37
|
+
`ctx.findings severity must be "info", "warn" or "error", got ${JSON.stringify(severity)}`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
const text = opts.text ?? '';
|
|
41
|
+
if (existing) {
|
|
42
|
+
// Refresh in place — never a second instance for the same (slug, key).
|
|
43
|
+
const opened = !existing.standing;
|
|
44
|
+
Object.assign(existing, { severity, text, standing: true });
|
|
45
|
+
return { opened, cleared: false };
|
|
46
|
+
}
|
|
47
|
+
state.findings.push({ slug, key, severity, text, standing: true });
|
|
48
|
+
return { opened: true, cleared: false };
|
|
49
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** The only key a flow's default export carries (BaseFlow in flow-api.d.ts). */
|
|
2
|
+
const FLOW_KEYS = ['triggers'];
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Checks a flow's manifest and module against each other and returns a list of
|
|
6
|
+
* problems, empty when they agree — conformance.js's pairing, for triggers.
|
|
7
|
+
*
|
|
8
|
+
* A trigger declared and not implemented fails its first run in production
|
|
9
|
+
* ("no handler for trigger"); one implemented and not declared never runs at
|
|
10
|
+
* all. Neither is visible until the house does something.
|
|
11
|
+
*/
|
|
12
|
+
export function flowConformance(manifest, flow) {
|
|
13
|
+
const problems = [];
|
|
14
|
+
|
|
15
|
+
for (const key of Object.keys(flow ?? {})) {
|
|
16
|
+
if (!FLOW_KEYS.includes(key)) {
|
|
17
|
+
problems.push(
|
|
18
|
+
`default export has an unknown key "${key}" (expected ${FLOW_KEYS.join(', ')})`
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (typeof flow?.triggers !== 'object' || flow.triggers === null) {
|
|
23
|
+
problems.push('default export has no "triggers" object');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const declared = Object.keys(manifest?.triggers ?? {});
|
|
27
|
+
for (const name of declared) {
|
|
28
|
+
if (typeof flow?.triggers?.[name] !== 'function') {
|
|
29
|
+
problems.push(
|
|
30
|
+
`trigger "${name}" is declared in manifest.json but not implemented`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const name of Object.keys(flow?.triggers ?? {})) {
|
|
35
|
+
if (!declared.includes(name)) {
|
|
36
|
+
problems.push(
|
|
37
|
+
`trigger "${name}" is implemented but not declared in manifest.json — Juno will never call it`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return problems;
|
|
43
|
+
}
|
package/src/flow.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { fail } from './assert.js';
|
|
2
|
+
import {
|
|
3
|
+
createAnswers,
|
|
4
|
+
createBindings,
|
|
5
|
+
createWorld,
|
|
6
|
+
jsonCopy,
|
|
7
|
+
} from './bindings.js';
|
|
8
|
+
import { flowConformance } from './flow-conformance.js';
|
|
9
|
+
import { names } from './names.js';
|
|
10
|
+
import { applyScenario } from './scenario.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* flowHarness({ manifest, flow }) builds the ctx the flow shim builds, in
|
|
14
|
+
* process, and runs one trigger handler against it.
|
|
15
|
+
*
|
|
16
|
+
* Strict where the shim is permissive, on purpose. Production refuses an
|
|
17
|
+
* undeclared name and asks the world for everything else, and neither is
|
|
18
|
+
* something a test should find out from the house. So a name the manifest does
|
|
19
|
+
* not declare fails here, and so does any read of the world — the clock, the
|
|
20
|
+
* sun, a step.llm — that the test did not answer.
|
|
21
|
+
*/
|
|
22
|
+
export function flowHarness({ manifest, flow, now, timezone, solar } = {}) {
|
|
23
|
+
if (!manifest || typeof manifest !== 'object') {
|
|
24
|
+
fail(
|
|
25
|
+
"flowHarness({ manifest, flow }): manifest is required — import manifest from '../manifest.json'"
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
if (!flow || typeof flow !== 'object') {
|
|
29
|
+
fail(
|
|
30
|
+
"flowHarness({ manifest, flow }): flow is required — import flow from '../src/flow'"
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
const triggers = manifest.triggers ?? {};
|
|
34
|
+
|
|
35
|
+
// What the test said about the world: h.now, h.timezone, h.solar.
|
|
36
|
+
const world = createWorld('flowHarness', () => h);
|
|
37
|
+
|
|
38
|
+
// What handlers did, and the answers the test gave for this test. Cleared
|
|
39
|
+
// by reset().
|
|
40
|
+
const rec = {
|
|
41
|
+
steps: [],
|
|
42
|
+
logs: [],
|
|
43
|
+
llm: createAnswers(),
|
|
44
|
+
// The last scenario's event, for an h.trigger(name) that gives none.
|
|
45
|
+
event: undefined,
|
|
46
|
+
};
|
|
47
|
+
// Chainable setters on the binding half return the harness itself.
|
|
48
|
+
const bindings = createBindings(manifest, () => h);
|
|
49
|
+
|
|
50
|
+
const h = {
|
|
51
|
+
/**
|
|
52
|
+
* Runs the handler for a declared trigger and resolves to what it returned.
|
|
53
|
+
* `event` is the payload, flattened onto ctx.event as the shim flattens it;
|
|
54
|
+
* without one, the scenario's `event` is, the same way.
|
|
55
|
+
*/
|
|
56
|
+
async trigger(name, options = {}) {
|
|
57
|
+
const def = triggers[name];
|
|
58
|
+
if (!def) {
|
|
59
|
+
fail(
|
|
60
|
+
`trigger "${name}" is not declared in manifest.json (have: ${names(triggers)})`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
for (const key of Object.keys(options ?? {})) {
|
|
64
|
+
if (key !== 'event') {
|
|
65
|
+
fail(
|
|
66
|
+
`h.trigger("${name}", …) takes { event } — got "${key}"; pass the payload as { event: { … } }`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const handler = flow.triggers?.[name];
|
|
71
|
+
if (typeof handler !== 'function') {
|
|
72
|
+
fail(
|
|
73
|
+
`trigger "${name}" is declared in manifest.json but the default export does not implement it`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const payload = Object.hasOwn(options ?? {}, 'event')
|
|
78
|
+
? options.event
|
|
79
|
+
: jsonCopy(rec.event);
|
|
80
|
+
|
|
81
|
+
const run = { failures: [] };
|
|
82
|
+
const ctx = buildContext(run, name, payload);
|
|
83
|
+
|
|
84
|
+
let result;
|
|
85
|
+
try {
|
|
86
|
+
result = await handler(ctx);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
// A refusal the handler caught and turned into something else is
|
|
89
|
+
// still the cause; report it rather than whatever it became.
|
|
90
|
+
throw run.failures[0] ?? err;
|
|
91
|
+
}
|
|
92
|
+
// A handler that swallowed a refusal (try/catch around a target call,
|
|
93
|
+
// say) must not turn it into a green test.
|
|
94
|
+
if (run.failures.length) throw run.failures[0];
|
|
95
|
+
|
|
96
|
+
if (def.type === 'call') {
|
|
97
|
+
// The shim's check before it sends the result: a call's return value
|
|
98
|
+
// crosses as JSON, and one that cannot fails the run.
|
|
99
|
+
try {
|
|
100
|
+
JSON.stringify(result);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`call handler return value is not JSON-serializable: ${err}`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
...world.h,
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Applies a scenario through the setters: now, timezone, solar, states,
|
|
114
|
+
* controls, targets, credentials and llm, and keeps its event for an
|
|
115
|
+
* h.trigger(name) that gives none. Ignores trigger, expect and
|
|
116
|
+
* flow_version; fails on any other key.
|
|
117
|
+
*/
|
|
118
|
+
scenario(s) {
|
|
119
|
+
applyScenario(h, s, { kind: 'flow' });
|
|
120
|
+
if (s.event !== undefined) {
|
|
121
|
+
rec.event = jsonCopy(s.event, 'h.scenario — "event"');
|
|
122
|
+
}
|
|
123
|
+
return h;
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* What step.llm(id, …) answers: a string, or a function of the options
|
|
128
|
+
* that returns one. `id` is the id the source says and answers every
|
|
129
|
+
* occurrence; `id#N` answers only that occurrence and wins over it.
|
|
130
|
+
*/
|
|
131
|
+
llm(id, answer) {
|
|
132
|
+
rec.llm.set(id, answer, 'h.llm');
|
|
133
|
+
return h;
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
/** Every step.run and step.llm, in order, as the journal records them. */
|
|
137
|
+
steps: () => rec.steps.map((s) => ({ ...s })),
|
|
138
|
+
/** Everything handlers logged, with its level. */
|
|
139
|
+
logs: () => rec.logs.slice(),
|
|
140
|
+
/** Manifest-vs-module problems; empty when they agree. */
|
|
141
|
+
conformance: () => flowConformance(manifest, flow),
|
|
142
|
+
|
|
143
|
+
...bindings.h,
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Clears recordings, answers, seeded states, the scenario's event and
|
|
147
|
+
* scoped memory; keeps the clock, timezone, solar and credentials.
|
|
148
|
+
*/
|
|
149
|
+
reset() {
|
|
150
|
+
rec.steps.length = 0;
|
|
151
|
+
rec.logs.length = 0;
|
|
152
|
+
rec.llm.clear();
|
|
153
|
+
rec.event = undefined;
|
|
154
|
+
bindings.reset();
|
|
155
|
+
return h;
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
world.init({ now, timezone, solar });
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* One run's ctx, as buildContext in sdk/src/context.ts builds it. Built per
|
|
163
|
+
* trigger call because the step occurrence counters are per run.
|
|
164
|
+
*/
|
|
165
|
+
function buildContext(run, trigger, payload) {
|
|
166
|
+
// Every refusal from inside the handler goes through here, so h.trigger
|
|
167
|
+
// can rethrow one the handler caught.
|
|
168
|
+
const refuse = (message) => {
|
|
169
|
+
try {
|
|
170
|
+
fail(message);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
run.failures.push(err);
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// stepJournalID in the SDK: the first occurrence keeps the bare id, the
|
|
178
|
+
// second is id#1, the third id#2.
|
|
179
|
+
const stepCounts = new Map();
|
|
180
|
+
const stepJournalID = (id) => {
|
|
181
|
+
const seen = stepCounts.get(id) ?? 0;
|
|
182
|
+
stepCounts.set(id, seen + 1);
|
|
183
|
+
return seen === 0 ? id : `${id}#${seen}`;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const log = (message, data) =>
|
|
187
|
+
rec.logs.push({ level: 'debug', message, data });
|
|
188
|
+
log.warn = (message, data) =>
|
|
189
|
+
rec.logs.push({ level: 'warn', message, data });
|
|
190
|
+
log.error = (message, data) =>
|
|
191
|
+
rec.logs.push({ level: 'error', message, data });
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
event: flattenTriggerEvent({ trigger, payload }),
|
|
195
|
+
...bindings.ctx(refuse),
|
|
196
|
+
|
|
197
|
+
step: {
|
|
198
|
+
async run(id, fn) {
|
|
199
|
+
const stepId = stepJournalID(id);
|
|
200
|
+
const result = await fn();
|
|
201
|
+
const output = result !== undefined ? result : null;
|
|
202
|
+
let json;
|
|
203
|
+
try {
|
|
204
|
+
json = JSON.stringify(output);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`step.run("${stepId}") result is not JSON-serializable: ${err}`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
// The journal holds what crossed the wire, not the live object.
|
|
211
|
+
rec.steps.push({ id: stepId, output: JSON.parse(json) });
|
|
212
|
+
return result;
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
async llm(id, options) {
|
|
216
|
+
const stepId = stepJournalID(id);
|
|
217
|
+
const key = rec.llm.has(stepId) ? stepId : id;
|
|
218
|
+
if (!rec.llm.has(key)) {
|
|
219
|
+
refuse(
|
|
220
|
+
`step.llm("${stepId}") was not answered — h.llm('${id}', text)`
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
const output = await rec.llm.resolve(key, options, refuse);
|
|
224
|
+
if (typeof output !== 'string') {
|
|
225
|
+
refuse(
|
|
226
|
+
`step.llm("${stepId}") was answered with ${typeof output}; an LLM step returns a string`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
rec.steps.push({ id: stepId, output });
|
|
230
|
+
return output;
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
status: {
|
|
235
|
+
ok: (text) => ({ kind: 'ok', text }),
|
|
236
|
+
error: (text) => ({ kind: 'error', text }),
|
|
237
|
+
skip: (reason) => ({
|
|
238
|
+
kind: 'skip',
|
|
239
|
+
text: reason != null ? `Skipped: ${reason}` : 'Skipped',
|
|
240
|
+
}),
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
async sleep(...args) {
|
|
244
|
+
refuse(
|
|
245
|
+
`a handler cannot suspend in a test: sleep(${renderArgs(args)})`
|
|
246
|
+
);
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
async waitForEvent(...args) {
|
|
250
|
+
refuse(
|
|
251
|
+
`a handler cannot suspend in a test: waitForEvent(${renderArgs(args)})`
|
|
252
|
+
);
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
log,
|
|
256
|
+
...world.ctx(refuse),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return h;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* ctx.event, built as the shim builds it: flattenTriggerEvent in
|
|
265
|
+
* sdk/src/event.ts applied to a wire event carrying only the trigger name and
|
|
266
|
+
* the test's payload. A plain object is spread onto the event; null or absent
|
|
267
|
+
* (every timer edge) adds nothing; an array (batch) or any other primitive
|
|
268
|
+
* stays under `payload`. Nothing the test did not give is synthesized.
|
|
269
|
+
*/
|
|
270
|
+
export function flattenTriggerEvent({
|
|
271
|
+
payload,
|
|
272
|
+
source: _s,
|
|
273
|
+
type: _t,
|
|
274
|
+
...rest
|
|
275
|
+
}) {
|
|
276
|
+
if (payload === null || payload === undefined) return { ...rest };
|
|
277
|
+
if (typeof payload === 'object' && !Array.isArray(payload)) {
|
|
278
|
+
return { ...rest, ...payload };
|
|
279
|
+
}
|
|
280
|
+
return { ...rest, payload };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** A call's arguments as the source would spell them, for a refusal. */
|
|
284
|
+
function renderArgs(args) {
|
|
285
|
+
const given = args.slice();
|
|
286
|
+
while (given.length && given[given.length - 1] === undefined) given.pop();
|
|
287
|
+
return given
|
|
288
|
+
.map((a) => (a === undefined ? 'undefined' : JSON.stringify(a)))
|
|
289
|
+
.join(', ');
|
|
290
|
+
}
|