@junoflow/testkit 0.3.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 +399 -0
- package/src/index.js +52 -69
- 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/index.d.ts
CHANGED
|
@@ -94,6 +94,7 @@ export interface WebhookRequestInit {
|
|
|
94
94
|
/** A string is sent as-is; anything else is JSON.stringify'd. */
|
|
95
95
|
body?: unknown;
|
|
96
96
|
url?: string;
|
|
97
|
+
subpath?: string;
|
|
97
98
|
}
|
|
98
99
|
|
|
99
100
|
export interface WebhookResult {
|
|
@@ -219,3 +220,401 @@ export function conformance(
|
|
|
219
220
|
): string[];
|
|
220
221
|
|
|
221
222
|
export { AssertionError } from 'node:assert';
|
|
223
|
+
|
|
224
|
+
// ─── flowHarness ─────────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The parsed contents of a flow's manifest.json, as loosely as `Manifest` and
|
|
228
|
+
* for the same reason: a JSON import widens every literal, and the server is
|
|
229
|
+
* where the vocabulary is enforced.
|
|
230
|
+
*/
|
|
231
|
+
export interface FlowManifest {
|
|
232
|
+
id?: string;
|
|
233
|
+
name?: string;
|
|
234
|
+
/** Keyed by the trigger's name, which is also its handler's key. */
|
|
235
|
+
triggers?: Record<string, { type: string; [k: string]: unknown }>;
|
|
236
|
+
/**
|
|
237
|
+
* Keyed by binding name. Exactly one of access, slot and signal grants the
|
|
238
|
+
* verbs: get (read), get/set (read_write), claim/release, signal/retract.
|
|
239
|
+
*/
|
|
240
|
+
states?: Record<
|
|
241
|
+
string,
|
|
242
|
+
{
|
|
243
|
+
state?: string;
|
|
244
|
+
control?: string;
|
|
245
|
+
access?: string;
|
|
246
|
+
slot?: string;
|
|
247
|
+
signal?: string;
|
|
248
|
+
}
|
|
249
|
+
>;
|
|
250
|
+
/** Keyed by binding name; `integration` and `flow` decide the methods. */
|
|
251
|
+
targets?: Record<
|
|
252
|
+
string,
|
|
253
|
+
{
|
|
254
|
+
integration: string;
|
|
255
|
+
flow?: { flow_id?: string; label?: string };
|
|
256
|
+
actions?: string[];
|
|
257
|
+
[k: string]: unknown;
|
|
258
|
+
}
|
|
259
|
+
>;
|
|
260
|
+
controls?: Record<string, { id: string }>;
|
|
261
|
+
findings?: Array<{
|
|
262
|
+
slug: string;
|
|
263
|
+
title?: string;
|
|
264
|
+
severity?: string;
|
|
265
|
+
[k: string]: unknown;
|
|
266
|
+
}>;
|
|
267
|
+
credentials?: string[];
|
|
268
|
+
[k: string]: unknown;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The flow's default export, as the shim loads it. Generic so a module typed
|
|
273
|
+
* `Flow` fits: its triggers are the generated FlowHandlers interface, and an
|
|
274
|
+
* interface is not assignable to an index signature.
|
|
275
|
+
*/
|
|
276
|
+
export interface FlowModule<
|
|
277
|
+
Handlers extends Record<keyof Handlers, (ctx: never) => unknown> = Record<
|
|
278
|
+
string,
|
|
279
|
+
(ctx: never) => unknown
|
|
280
|
+
>,
|
|
281
|
+
> {
|
|
282
|
+
triggers: Handlers;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** What ctx.solar() answers; see SolarResult in the flow's types. */
|
|
286
|
+
export interface SolarAnswer {
|
|
287
|
+
above_horizon?: boolean;
|
|
288
|
+
elevation?: number;
|
|
289
|
+
azimuth?: number;
|
|
290
|
+
rising?: boolean;
|
|
291
|
+
next_rising?: string | null;
|
|
292
|
+
next_setting?: string | null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export interface FlowHarnessOptions<
|
|
296
|
+
Handlers extends Record<keyof Handlers, (ctx: never) => unknown> = Record<
|
|
297
|
+
string,
|
|
298
|
+
(ctx: never) => unknown
|
|
299
|
+
>,
|
|
300
|
+
> {
|
|
301
|
+
manifest: FlowManifest;
|
|
302
|
+
flow: FlowModule<Handlers>;
|
|
303
|
+
/** As h.now(). */
|
|
304
|
+
now?: string;
|
|
305
|
+
/** As h.timezone(); UTC when omitted. */
|
|
306
|
+
timezone?: string;
|
|
307
|
+
/** As h.solar(). */
|
|
308
|
+
solar?: SolarAnswer;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export interface TriggerOptions {
|
|
312
|
+
/**
|
|
313
|
+
* The trigger's payload. An object is spread onto ctx.event beside
|
|
314
|
+
* `trigger`; an array (a batch) stays under `event.payload`; null (a timer
|
|
315
|
+
* edge) adds nothing. Omitted, the scenario's `event` is used, and with no
|
|
316
|
+
* scenario event nothing is added. Fields not given are not invented.
|
|
317
|
+
*/
|
|
318
|
+
event?: unknown;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** One journaled step, as step.run or step.llm recorded it. */
|
|
322
|
+
export interface RecordedStep {
|
|
323
|
+
/** The journal id: the id the source says, then `id#1`, `id#2` on repeats. */
|
|
324
|
+
id: string;
|
|
325
|
+
/** What the journal holds: the JSON of the return value, null for void. */
|
|
326
|
+
output: unknown;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export interface FlowLogEntry {
|
|
330
|
+
level: 'debug' | 'warn' | 'error';
|
|
331
|
+
message: string;
|
|
332
|
+
data?: unknown;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** One ctx.state write, claim or signal, in the order the handler made it. */
|
|
336
|
+
export interface RecordedStateCall {
|
|
337
|
+
binding: string;
|
|
338
|
+
verb: 'set' | 'claim' | 'release' | 'signal' | 'retract';
|
|
339
|
+
/** set and claim. */
|
|
340
|
+
value?: unknown;
|
|
341
|
+
/** claim and signal: `{ ttl }`. */
|
|
342
|
+
opts?: { ttl: string };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** One target call. `action` is the method the source called. */
|
|
346
|
+
export interface RecordedTargetCall {
|
|
347
|
+
target: string;
|
|
348
|
+
action: string;
|
|
349
|
+
/** What the SDK sends: `{}` when an action is called bare, `null` for an invoke without input. */
|
|
350
|
+
params: unknown;
|
|
351
|
+
/** invoke/invokeAll only, when given. */
|
|
352
|
+
opts?: { timeout?: string };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export interface RecordedControlCall {
|
|
356
|
+
binding: string;
|
|
357
|
+
verb: 'press' | 'activate' | 'extend' | 'release';
|
|
358
|
+
value?: string;
|
|
359
|
+
for?: string;
|
|
360
|
+
until?: string;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** What a control command answers. Fields left out take the SDK's defaults. */
|
|
364
|
+
export interface ControlAnswer {
|
|
365
|
+
outcome?: string;
|
|
366
|
+
active?: boolean;
|
|
367
|
+
value?: unknown;
|
|
368
|
+
expires_at?: string | null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export interface FlowTargets {
|
|
372
|
+
/**
|
|
373
|
+
* Answers `binding.method` (`kitchenSpeaker.ttsCloudSay`, `child.invoke`):
|
|
374
|
+
* a value, or a function of the params (an invoke's input) returning one.
|
|
375
|
+
* An action with no result is answered with `undefined`, explicitly.
|
|
376
|
+
*/
|
|
377
|
+
answer(
|
|
378
|
+
key: string,
|
|
379
|
+
answer: (params: any) => unknown | Promise<unknown>
|
|
380
|
+
): FlowTargets;
|
|
381
|
+
answer(key: string, answer: unknown): FlowTargets;
|
|
382
|
+
calls(): RecordedTargetCall[];
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export interface FlowControls {
|
|
386
|
+
/** Answers `binding.verb` (`doorbell.press`). */
|
|
387
|
+
answer(
|
|
388
|
+
key: string,
|
|
389
|
+
answer:
|
|
390
|
+
| ControlAnswer
|
|
391
|
+
| ((args: {
|
|
392
|
+
value?: string;
|
|
393
|
+
for?: string;
|
|
394
|
+
until?: string;
|
|
395
|
+
}) => ControlAnswer | Promise<ControlAnswer>)
|
|
396
|
+
): FlowControls;
|
|
397
|
+
calls(): RecordedControlCall[];
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* A scenario's reserved answer forms. `{ $replay: [a, b, …] }` answers calls
|
|
402
|
+
* in order and fails on one past the end; `{ $error: message }` makes the call
|
|
403
|
+
* throw `new Error(message)`, as a failed capability call does. A $replay item
|
|
404
|
+
* may be an $error. Only an object whose sole key is one of these is one.
|
|
405
|
+
*/
|
|
406
|
+
export type ReplayAnswer<T> = { $replay: Array<T | ErrorAnswer> };
|
|
407
|
+
export type ErrorAnswer = { $error: string };
|
|
408
|
+
export type ScenarioAnswer<T> = T | ReplayAnswer<T> | ErrorAnswer;
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* The world a handler runs in, as JSON: what `h.scenario(s)` applies through
|
|
412
|
+
* the individual setters. Keys are what the source says — binding names, and
|
|
413
|
+
* `binding.method` / `binding.verb` / a step id for answers. A key left out
|
|
414
|
+
* sets nothing; an unknown key fails.
|
|
415
|
+
*/
|
|
416
|
+
export interface Scenario {
|
|
417
|
+
/** The trigger the test runs. Not applied: pass it to h.trigger. */
|
|
418
|
+
trigger?: string;
|
|
419
|
+
/** As h.now(). */
|
|
420
|
+
now?: string;
|
|
421
|
+
/** As h.timezone(). */
|
|
422
|
+
timezone?: string;
|
|
423
|
+
/** As h.solar(). */
|
|
424
|
+
solar?: SolarAnswer;
|
|
425
|
+
/** As h.states(). */
|
|
426
|
+
states?: Record<string, unknown>;
|
|
427
|
+
/** As h.controls.answer, keyed `binding.verb`. */
|
|
428
|
+
controls?: Record<string, ScenarioAnswer<ControlAnswer>>;
|
|
429
|
+
/** As h.targets.answer, keyed `binding.method`. */
|
|
430
|
+
targets?: Record<string, ScenarioAnswer<unknown>>;
|
|
431
|
+
/** As h.credentials(). */
|
|
432
|
+
credentials?: Record<string, string>;
|
|
433
|
+
/** Flows: the payload an h.trigger(name) without one runs with. */
|
|
434
|
+
event?: unknown;
|
|
435
|
+
/** Flows only: as h.llm, keyed by step id. */
|
|
436
|
+
llm?: Record<string, ScenarioAnswer<string>>;
|
|
437
|
+
/** What the test asserts. Not applied. */
|
|
438
|
+
expect?: unknown;
|
|
439
|
+
/** Flows: the version a recorded run ran. Not applied. */
|
|
440
|
+
flow_version?: number;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export interface FlowHarness {
|
|
444
|
+
/**
|
|
445
|
+
* Runs the declared trigger's handler and resolves to what it returned — a
|
|
446
|
+
* Status for most triggers, any value for a `call` trigger. A refusal the
|
|
447
|
+
* handler caught still fails here.
|
|
448
|
+
*/
|
|
449
|
+
trigger(name: string, options?: TriggerOptions): Promise<unknown>;
|
|
450
|
+
/**
|
|
451
|
+
* Applies a scenario through the setters below, and keeps its `event` for
|
|
452
|
+
* an h.trigger(name) that passes none. Ignores `trigger`, `expect` and
|
|
453
|
+
* `flow_version`.
|
|
454
|
+
*/
|
|
455
|
+
scenario(s: Scenario): FlowHarness;
|
|
456
|
+
/**
|
|
457
|
+
* Freezes ctx.now(). An offset (`Z`, `+02:00`) makes it an instant; without
|
|
458
|
+
* one it is wall time in h.timezone(), read when the handler asks.
|
|
459
|
+
*/
|
|
460
|
+
now(iso: string): FlowHarness;
|
|
461
|
+
/** The site timezone, an IANA name. UTC unless set. */
|
|
462
|
+
timezone(tz: string): FlowHarness;
|
|
463
|
+
/** What ctx.solar() answers. Fields left out take the SDK's defaults. */
|
|
464
|
+
solar(values: SolarAnswer): FlowHarness;
|
|
465
|
+
/**
|
|
466
|
+
* Answers step.llm(id, …): text, or a function of the options returning it.
|
|
467
|
+
* The bare id answers every occurrence; `id#N` only that one, and wins.
|
|
468
|
+
*/
|
|
469
|
+
llm(
|
|
470
|
+
id: string,
|
|
471
|
+
answer: string | ((options: unknown) => string | Promise<string>)
|
|
472
|
+
): FlowHarness;
|
|
473
|
+
/** Every step.run and step.llm, in order. */
|
|
474
|
+
steps(): RecordedStep[];
|
|
475
|
+
logs(): FlowLogEntry[];
|
|
476
|
+
/** Triggers declared vs implemented, both ways; empty when they agree. */
|
|
477
|
+
conformance(): string[];
|
|
478
|
+
/** Seeds read and read_write bindings by name. */
|
|
479
|
+
states(values: Record<string, unknown>): FlowHarness;
|
|
480
|
+
/** A value binding's current value: its seed, or what set() last wrote. */
|
|
481
|
+
state(name: string): unknown;
|
|
482
|
+
stateCalls(): RecordedStateCall[];
|
|
483
|
+
readonly targets: FlowTargets;
|
|
484
|
+
readonly controls: FlowControls;
|
|
485
|
+
/** Findings by (slug, key), as the host collapses them. */
|
|
486
|
+
findings(): RecordedFinding[];
|
|
487
|
+
/** Sets ctx.credentials by declared id. Kept across reset(). */
|
|
488
|
+
credentials(values: Record<string, string>): FlowHarness;
|
|
489
|
+
/**
|
|
490
|
+
* Clears recordings, answers, seeded states, the scenario's event and
|
|
491
|
+
* scoped memory; keeps the clock, timezone, solar and credentials.
|
|
492
|
+
*/
|
|
493
|
+
reset(): FlowHarness;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export function flowHarness<
|
|
497
|
+
Handlers extends Record<keyof Handlers, (ctx: never) => unknown>,
|
|
498
|
+
>(options: FlowHarnessOptions<Handlers>): FlowHarness;
|
|
499
|
+
|
|
500
|
+
// ─── appHarness ──────────────────────────────────────────────────────────────
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* The parsed contents of an app's manifest.json, as loosely as FlowManifest.
|
|
504
|
+
* A handler's entry is policy only: its input and output are its TypeScript.
|
|
505
|
+
*/
|
|
506
|
+
export interface AppManifest {
|
|
507
|
+
id?: string;
|
|
508
|
+
name?: string;
|
|
509
|
+
/** Keyed by the handler's name, which is also its key in `handlers`. */
|
|
510
|
+
handlers?: Record<
|
|
511
|
+
string,
|
|
512
|
+
{ description?: string; read_only?: boolean; timeout?: string }
|
|
513
|
+
>;
|
|
514
|
+
/** Keyed by binding name: `access: "read"` (get) or a `slot` (claim/release). */
|
|
515
|
+
states?: FlowManifest['states'];
|
|
516
|
+
targets?: FlowManifest['targets'];
|
|
517
|
+
controls?: FlowManifest['controls'];
|
|
518
|
+
credentials?: string[];
|
|
519
|
+
/** Keyed by event name; `payload` is the JSON Schema an emit is held to. */
|
|
520
|
+
events?: Record<string, { payload?: object; [k: string]: unknown }>;
|
|
521
|
+
[k: string]: unknown;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* The app's default export, as the shim loads it. Generic so a module typed
|
|
526
|
+
* `App` fits: its handlers are the generated interface, and an interface is
|
|
527
|
+
* not assignable to an index signature.
|
|
528
|
+
*/
|
|
529
|
+
export interface AppModule<
|
|
530
|
+
Handlers extends Record<
|
|
531
|
+
keyof Handlers,
|
|
532
|
+
(ctx: never, input: never) => unknown
|
|
533
|
+
> = Record<string, (ctx: never, input: never) => unknown>,
|
|
534
|
+
> {
|
|
535
|
+
handlers: Handlers;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export interface AppHarnessOptions<
|
|
539
|
+
Handlers extends Record<
|
|
540
|
+
keyof Handlers,
|
|
541
|
+
(ctx: never, input: never) => unknown
|
|
542
|
+
> = Record<string, (ctx: never, input: never) => unknown>,
|
|
543
|
+
> {
|
|
544
|
+
manifest: AppManifest;
|
|
545
|
+
app: AppModule<Handlers>;
|
|
546
|
+
/** As h.now(). */
|
|
547
|
+
now?: string;
|
|
548
|
+
/** As h.timezone(); UTC when omitted. */
|
|
549
|
+
timezone?: string;
|
|
550
|
+
/** As h.solar(). */
|
|
551
|
+
solar?: SolarAnswer;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** One ctx.events.<event>.emit that passed the host's checks. */
|
|
555
|
+
export interface RecordedEvent {
|
|
556
|
+
event: string;
|
|
557
|
+
/** What the bus carries: the payload after a JSON round trip; null when omitted. */
|
|
558
|
+
payload: unknown;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** One ctx.states claim or release, in the order the handler made it. */
|
|
562
|
+
export interface RecordedAppStateCall {
|
|
563
|
+
binding: string;
|
|
564
|
+
verb: 'claim' | 'release';
|
|
565
|
+
/** claim. */
|
|
566
|
+
value?: unknown;
|
|
567
|
+
/** claim: `{ ttl }`. */
|
|
568
|
+
opts?: { ttl: string };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export interface AppHarness {
|
|
572
|
+
/**
|
|
573
|
+
* Calls a declared handler as juno.handlers.<name>(input) does, and
|
|
574
|
+
* resolves to what the page receives: the result after a JSON round trip
|
|
575
|
+
* (a Date becomes its ISO string, an undefined field disappears, undefined
|
|
576
|
+
* becomes null). An omitted input arrives as `{}`. A refusal the handler
|
|
577
|
+
* caught still fails here; in a `read_only` handler, claim, release, a
|
|
578
|
+
* control command, an emit and a call on a homeassistant or mqtt target
|
|
579
|
+
* fail with the host's message.
|
|
580
|
+
*/
|
|
581
|
+
call(name: string, input?: unknown): Promise<unknown>;
|
|
582
|
+
/** Every emit that passed, in order. */
|
|
583
|
+
events(): RecordedEvent[];
|
|
584
|
+
logs(): FlowLogEntry[];
|
|
585
|
+
/** Handlers declared vs implemented, both ways; empty when they agree. */
|
|
586
|
+
conformance(): string[];
|
|
587
|
+
/**
|
|
588
|
+
* Applies a scenario through the setters below. Ignores `trigger`, `event`
|
|
589
|
+
* and `expect`; `llm` and `flow_version` are flow keys and fail here.
|
|
590
|
+
*/
|
|
591
|
+
scenario(s: Omit<Scenario, 'llm' | 'flow_version'>): AppHarness;
|
|
592
|
+
/**
|
|
593
|
+
* Freezes ctx.now(). An offset (`Z`, `+02:00`) makes it an instant; without
|
|
594
|
+
* one it is wall time in h.timezone(), read when the handler asks.
|
|
595
|
+
*/
|
|
596
|
+
now(iso: string): AppHarness;
|
|
597
|
+
/** The site timezone, an IANA name. UTC unless set. */
|
|
598
|
+
timezone(tz: string): AppHarness;
|
|
599
|
+
/** What ctx.solar() answers. Fields left out take the SDK's defaults. */
|
|
600
|
+
solar(values: SolarAnswer): AppHarness;
|
|
601
|
+
/** Seeds read bindings by name. */
|
|
602
|
+
states(values: Record<string, unknown>): AppHarness;
|
|
603
|
+
stateCalls(): RecordedAppStateCall[];
|
|
604
|
+
readonly targets: FlowTargets;
|
|
605
|
+
readonly controls: FlowControls;
|
|
606
|
+
/** Sets ctx.credentials by declared id. Kept across reset(). */
|
|
607
|
+
credentials(values: Record<string, string>): AppHarness;
|
|
608
|
+
/**
|
|
609
|
+
* Clears recordings, answers and seeded states; keeps the clock, timezone,
|
|
610
|
+
* solar and credentials.
|
|
611
|
+
*/
|
|
612
|
+
reset(): AppHarness;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
export function appHarness<
|
|
616
|
+
Handlers extends Record<
|
|
617
|
+
keyof Handlers,
|
|
618
|
+
(ctx: never, input: never) => unknown
|
|
619
|
+
>,
|
|
620
|
+
>(options: AppHarnessOptions<Handlers>): AppHarness;
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { fail } from './assert.js';
|
|
2
|
+
import { setFinding } from './findings.js';
|
|
3
|
+
import { names } from './names.js';
|
|
2
4
|
import { conformance } from './conformance.js';
|
|
3
5
|
import { createHttpMock, withFetch } from './http.js';
|
|
4
6
|
import { assertValid } from './schema.js';
|
|
@@ -121,7 +123,7 @@ export function harness({ manifest, integration } = {}) {
|
|
|
121
123
|
);
|
|
122
124
|
if (result !== undefined && result !== null) {
|
|
123
125
|
fail(
|
|
124
|
-
`schedule "${name}" returned a value; a schedule reports through ctx.
|
|
126
|
+
`schedule "${name}" returned a value; a schedule reports through ctx.events and returns nothing`
|
|
125
127
|
);
|
|
126
128
|
}
|
|
127
129
|
},
|
|
@@ -306,53 +308,6 @@ function buildContext(manifest, state) {
|
|
|
306
308
|
};
|
|
307
309
|
}
|
|
308
310
|
|
|
309
|
-
/**
|
|
310
|
-
* The one call behind open/clear/set, mirroring the SDK and the host: the verbs
|
|
311
|
-
* differ only in whether the boolean is already decided, and the answer is the
|
|
312
|
-
* transition this call made. Both false means it refreshed an instance that was
|
|
313
|
-
* already standing, or retracted one that was already absent.
|
|
314
|
-
*
|
|
315
|
-
* The name is checked against the manifest for the same reason emit's is: Juno
|
|
316
|
-
* rejects an undeclared finding at runtime, and finding that out from an
|
|
317
|
-
* installer is not a test.
|
|
318
|
-
*/
|
|
319
|
-
function setFinding(manifest, state, slug, standing, opts) {
|
|
320
|
-
const declared = manifest.findings?.[slug];
|
|
321
|
-
if (!declared) {
|
|
322
|
-
fail(
|
|
323
|
-
`ctx.findings — "${slug}" is not declared in manifest.json (have: ${names(manifest.findings)}). ` +
|
|
324
|
-
'Juno rejects an undeclared finding at runtime.'
|
|
325
|
-
);
|
|
326
|
-
}
|
|
327
|
-
const key = opts.key ?? '';
|
|
328
|
-
const existing = state.findings.find((f) => f.slug === slug && f.key === key);
|
|
329
|
-
|
|
330
|
-
if (!standing) {
|
|
331
|
-
// A retraction says nothing about severity or text, and clearing what was
|
|
332
|
-
// never open is a no-op here exactly as it is in the store.
|
|
333
|
-
if (!existing || !existing.standing)
|
|
334
|
-
return { opened: false, cleared: false };
|
|
335
|
-
existing.standing = false;
|
|
336
|
-
return { opened: false, cleared: true };
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
const severity = opts.severity ?? declared.severity ?? 'error';
|
|
340
|
-
if (!['info', 'warn', 'error'].includes(severity)) {
|
|
341
|
-
fail(
|
|
342
|
-
`ctx.findings severity must be "info", "warn" or "error", got ${JSON.stringify(severity)}`
|
|
343
|
-
);
|
|
344
|
-
}
|
|
345
|
-
const text = opts.text ?? '';
|
|
346
|
-
if (existing) {
|
|
347
|
-
// Refresh in place — never a second instance for the same (slug, key).
|
|
348
|
-
const opened = !existing.standing;
|
|
349
|
-
Object.assign(existing, { severity, text, standing: true });
|
|
350
|
-
return { opened, cleared: false };
|
|
351
|
-
}
|
|
352
|
-
state.findings.push({ slug, key, severity, text, standing: true });
|
|
353
|
-
return { opened: true, cleared: false };
|
|
354
|
-
}
|
|
355
|
-
|
|
356
311
|
/**
|
|
357
312
|
* The one call behind ctx.states.publish. Every name is checked against the
|
|
358
313
|
* manifest before any is recorded — a batch is all-or-nothing, mirroring
|
|
@@ -422,22 +377,34 @@ function describeJSON(value) {
|
|
|
422
377
|
return typeof value === 'object' ? 'an object' : typeof value;
|
|
423
378
|
}
|
|
424
379
|
|
|
425
|
-
/** Adds ctx.
|
|
380
|
+
/** Adds ctx.events, which every surface but an action has. */
|
|
426
381
|
function emitting(baseCtx, manifest, state) {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
382
|
+
const events = new Proxy(
|
|
383
|
+
{},
|
|
384
|
+
{
|
|
385
|
+
get(_own, event) {
|
|
386
|
+
if (typeof event !== 'string') return undefined;
|
|
387
|
+
return {
|
|
388
|
+
async emit(payload) {
|
|
389
|
+
const declared = manifest.events?.[event];
|
|
390
|
+
if (!declared) {
|
|
391
|
+
fail(
|
|
392
|
+
`ctx.events.${event}.emit() — that event is not declared in manifest.json (have: ${names(manifest.events)}). ` +
|
|
393
|
+
'Juno rejects an undeclared event at runtime.'
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
assertValid(
|
|
397
|
+
declared.payload,
|
|
398
|
+
payload,
|
|
399
|
+
`payload of event "${event}"`
|
|
400
|
+
);
|
|
401
|
+
state.emitted.push({ event, payload });
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
},
|
|
405
|
+
}
|
|
406
|
+
);
|
|
407
|
+
return { ...baseCtx, events };
|
|
441
408
|
}
|
|
442
409
|
|
|
443
410
|
function defaultConfig(manifest) {
|
|
@@ -506,16 +473,35 @@ function buildWebhookRequest(request) {
|
|
|
506
473
|
typeof request.body === 'string'
|
|
507
474
|
? request.body
|
|
508
475
|
: JSON.stringify(request.body ?? '');
|
|
476
|
+
const headers = request.headers ?? {};
|
|
509
477
|
return {
|
|
510
478
|
method: (request.method ?? 'POST').toUpperCase(),
|
|
511
|
-
headers
|
|
479
|
+
headers,
|
|
512
480
|
query: request.query ?? {},
|
|
513
481
|
body,
|
|
514
482
|
json: parseJSONOrNull(body),
|
|
515
483
|
url: request.url ?? '/',
|
|
484
|
+
subpath: request.subpath ?? '',
|
|
485
|
+
form: parseFormOrNull(headers, body),
|
|
516
486
|
};
|
|
517
487
|
}
|
|
518
488
|
|
|
489
|
+
// The host's rule: lower-cased content-type, media type
|
|
490
|
+
// application/x-www-form-urlencoded (a charset parameter is ignored), first
|
|
491
|
+
// value per key — else null.
|
|
492
|
+
function parseFormOrNull(headers, body) {
|
|
493
|
+
const contentType = (headers['content-type'] ?? headers['Content-Type'] ?? '')
|
|
494
|
+
.toLowerCase()
|
|
495
|
+
.split(';')[0]
|
|
496
|
+
.trim();
|
|
497
|
+
if (contentType !== 'application/x-www-form-urlencoded') return null;
|
|
498
|
+
const form = {};
|
|
499
|
+
for (const [key, value] of new URLSearchParams(body)) {
|
|
500
|
+
if (!(key in form)) form[key] = value;
|
|
501
|
+
}
|
|
502
|
+
return form;
|
|
503
|
+
}
|
|
504
|
+
|
|
519
505
|
// The host's rule (cmd/control-plane readWebhookRequest): whatever parses is
|
|
520
506
|
// parsed, whatever does not is null — content-type is not consulted.
|
|
521
507
|
function parseJSONOrNull(body) {
|
|
@@ -552,11 +538,6 @@ function assertWebhookResponse(name, result) {
|
|
|
552
538
|
}
|
|
553
539
|
}
|
|
554
540
|
|
|
555
|
-
function names(obj) {
|
|
556
|
-
const keys = Object.keys(obj ?? {});
|
|
557
|
-
return keys.length ? keys.join(', ') : '(none)';
|
|
558
|
-
}
|
|
559
|
-
|
|
560
541
|
/** Polls a condition, resolving true when it holds or false at the deadline. */
|
|
561
542
|
async function waitFor(condition, timeout) {
|
|
562
543
|
const deadline = Date.now() + timeout;
|
|
@@ -580,3 +561,5 @@ async function withTimeout(promise, ms) {
|
|
|
580
561
|
|
|
581
562
|
export { conformance } from './conformance.js';
|
|
582
563
|
export { AssertionError } from './assert.js';
|
|
564
|
+
export { flowHarness } from './flow.js';
|
|
565
|
+
export { appHarness } from './app.js';
|
package/src/names.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The keys of a manifest section, for the "(have: …)" half of a refusal. Both
|
|
3
|
+
* harnesses end every "not declared" message with it, so the fix is visible in
|
|
4
|
+
* the failure itself rather than one manifest lookup away.
|
|
5
|
+
*/
|
|
6
|
+
export function names(obj) {
|
|
7
|
+
const keys = Object.keys(obj ?? {});
|
|
8
|
+
return keys.length ? keys.join(', ') : '(none)';
|
|
9
|
+
}
|
package/src/scenario.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { fail } from './assert.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What each scenario key does, per harness kind. A key is applied through the
|
|
5
|
+
* harness's own setter, so it passes the same checks and fails with the same
|
|
6
|
+
* message as the call a hand-written test would make; `null` is a key the
|
|
7
|
+
* harness accepts and leaves to the test.
|
|
8
|
+
*
|
|
9
|
+
* - `trigger` names the handler the test runs (`h.trigger(s.trigger)`), and
|
|
10
|
+
* `expect` is what the test asserts; neither is world.
|
|
11
|
+
* - `event` is kept by flowHarness for an event-less `h.trigger(name)`.
|
|
12
|
+
* - `flow_version` is the version a recorded run ran, which a fixture made
|
|
13
|
+
* from that run carries.
|
|
14
|
+
* - An app has no trigger, event or step.llm. It ignores `trigger` and
|
|
15
|
+
* `event`, so one scenario can describe both, and `llm` is unknown to it:
|
|
16
|
+
* an answer it would never read is a mistake, not a no-op.
|
|
17
|
+
*/
|
|
18
|
+
const SHARED = {
|
|
19
|
+
trigger: null,
|
|
20
|
+
now: (h, v) => h.now(v),
|
|
21
|
+
timezone: (h, v) => h.timezone(v),
|
|
22
|
+
solar: (h, v) => h.solar(v),
|
|
23
|
+
states: (h, v) => h.states(v),
|
|
24
|
+
controls: (h, v) =>
|
|
25
|
+
answerAll(v, 'controls', (k, a) => h.controls.answer(k, a)),
|
|
26
|
+
targets: (h, v) => answerAll(v, 'targets', (k, a) => h.targets.answer(k, a)),
|
|
27
|
+
credentials: (h, v) => h.credentials(v),
|
|
28
|
+
event: null,
|
|
29
|
+
expect: null,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const KEYS = {
|
|
33
|
+
flow: {
|
|
34
|
+
...SHARED,
|
|
35
|
+
llm: (h, v) => answerAll(v, 'llm', (k, a) => h.llm(k, a)),
|
|
36
|
+
flow_version: null,
|
|
37
|
+
},
|
|
38
|
+
app: SHARED,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Applies scenario s to harness h, key by key through h's own setters. A key
|
|
43
|
+
* the scenario leaves out sets nothing, so the harness's "not answered"
|
|
44
|
+
* refusals still apply to it; an unknown key fails before anything is set.
|
|
45
|
+
*/
|
|
46
|
+
export function applyScenario(h, s, { kind }) {
|
|
47
|
+
const keys = KEYS[kind];
|
|
48
|
+
if (s === null || typeof s !== 'object' || Array.isArray(s)) {
|
|
49
|
+
fail(`h.scenario — expected a scenario object, got ${JSON.stringify(s)}`);
|
|
50
|
+
}
|
|
51
|
+
for (const key of Object.keys(s)) {
|
|
52
|
+
if (!Object.hasOwn(keys, key)) {
|
|
53
|
+
fail(
|
|
54
|
+
`h.scenario — "${key}" is not a scenario key for ${kind === 'app' ? 'an app' : 'a flow'} (have: ${Object.keys(keys).join(', ')})`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
for (const [key, apply] of Object.entries(keys)) {
|
|
59
|
+
if (apply && s[key] !== undefined) apply(h, s[key]);
|
|
60
|
+
}
|
|
61
|
+
return h;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Answers every `key: answer` of one scenario map with set. */
|
|
65
|
+
function answerAll(map, name, set) {
|
|
66
|
+
if (map === null || typeof map !== 'object' || Array.isArray(map)) {
|
|
67
|
+
fail(
|
|
68
|
+
`h.scenario — "${name}" maps keys to answers; got ${JSON.stringify(map)}`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
for (const [key, answer] of Object.entries(map)) set(key, answer);
|
|
72
|
+
}
|
package/src/schema.js
CHANGED
|
@@ -31,11 +31,8 @@ export function assertValid(schema, value, what) {
|
|
|
31
31
|
if (!schema) return value; // nothing declared, nothing to check
|
|
32
32
|
const validate = compile(schema);
|
|
33
33
|
if (validate(value)) return value;
|
|
34
|
-
const detail = (validate.errors ?? [])
|
|
35
|
-
.map((e) => ` ${e.instancePath || '(root)'} ${e.message}`)
|
|
36
|
-
.join('\n');
|
|
37
34
|
fail(
|
|
38
|
-
`${what} does not match the schema declared in manifest.json:\n${
|
|
35
|
+
`${what} does not match the schema declared in manifest.json:\n${describeErrors(validate.errors)}`,
|
|
39
36
|
{
|
|
40
37
|
actual: value,
|
|
41
38
|
expected: schema,
|
|
@@ -43,6 +40,13 @@ export function assertValid(schema, value, what) {
|
|
|
43
40
|
);
|
|
44
41
|
}
|
|
45
42
|
|
|
43
|
+
/** ajv's errors as the testkit prints them: one indented line per error. */
|
|
44
|
+
export function describeErrors(errors) {
|
|
45
|
+
return (errors ?? [])
|
|
46
|
+
.map((e) => ` ${e.instancePath || '(root)'} ${e.message}`)
|
|
47
|
+
.join('\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
46
50
|
/** Reports whether a schema compiles, for conformance(). */
|
|
47
51
|
export function schemaError(schema) {
|
|
48
52
|
try {
|