Skip to content

Commit 225197c

Browse files
test(service-automation): characterize a config-less wait / boundary_event node at run time (#17937)
Fixes #17843 Clause-②: no Refs objectstack-ai/objectui#9109 (that card's grade depends on the reading below; nothing over there gates this one). ## What this is **A measurement, converted from a source reading into an observed run.** Executor behaviour is deliberately unchanged — the card says so itself, and the `?? 'timer'` fallback in `wait-node.ts` carries a comment declaring it intentional. The triage seat answered the card from source (comment 5651444086). This PR runs it. The run **agrees with that reading on the `wait` half**, so the p1 escalation on objectui#9109 stands and needs no rollback. The `boundary_event` half — which triage explicitly did **not** measure — comes out the **opposite** shape, which is exactly why the card refused to extrapolate. ## The four readings, as recorded All four come from real `engine.execute()` runs through `AutomationEngine`, with the node-type vocabulary sealed (what `AutomationServicePlugin` does at `kernel:bootstrapped`), a job service present, an `InMemorySuspendedRunStore` attached, and one recording logger behind both the engine and the plugin context so "any log line at any level" is answerable. ### 1. `wait`, no config block at all ``` node: { id: 'pause', type: 'wait', label: 'Wait' } executor return: { success: true, suspend: true, correlation: 'timer:pause', output: undefined } engine result: { success: true, status: 'paused', runId: 'run_caa5b321-...' } scheduled jobs: [] stored variables: { previous: null, $runId: 'run_caa5b321-...', $flowName: 'f', $flowLabel: 'F' } -- no 'pause.waitUntil' key log lines during run: [] nodes that ran: [ 'before' ] -- the run reached the node and never got past it ``` ⇒ the `'timer'` default applies, the timer branch computes no deadline, **no wake-up job is armed even though a job service was available**, **no `waitUntil` is persisted**, and **not one log line is emitted at any level**. The run parks until something external calls `resume(runId)`. ⚠️ One correction to the source reading's rendering, found only by running it: `output` is a **present key carrying `undefined`**, not an absent key. A JSON dump of the return value drops it and reads as the second. The test pins it with `toStrictEqual` for that reason — `toEqual` cannot tell those two apart. ### 2. CONTROL — the same node WITH `eventType: 'timer'` and `timerDuration: 'PT1H'` ``` executor return: { success: true, suspend: true, correlation: 'flow-wait:run_ab5a389c-...:pause', output: { waitUntil: '2026-09-13T07:38:41.591Z' } } scheduled jobs: [ { name: 'flow-wait:run_ab5a389c-...:pause', schedule: { type: 'once', at: '2026-09-13T07:38:41.591Z' } } ] stored variables: ... 'pause.waitUntil': '2026-09-13T07:38:41.591Z' ``` ⇒ **every channel that read zero above reads non-zero here**, so the zeros are a reading of the absent-config path and not of an inert harness. The one channel that does *not* discriminate is `status: 'paused'` — both halves park the run — and the test records that too, so a later reader does not mistake it for a signal. ### 3. `boundary_event`, no config block at all — measured separately, NOT extrapolated ``` registered node types: [ 'mark', 'wait' ] -- nothing registers 'boundary_event' engine result: { success: false, status: 'failed', error: "No executor registered for node type 'boundary_event'" } summary node 'b': { nodeType: 'boundary_event', status: 'failure', runs: 1, failures: 1 } at vocabulary seal: warn -- "Flow 'f' references node type(s) with no registered executor or descriptor ... these nodes fail at execution time with NO_EXECUTOR." meta: { unknownTypes: [ 'boundary_event' ], knownTypes: [...] } during the run: info -- "[automation] run flow=f run=... status=failed ... failed=1" nodes that ran: [ 'before' ] ``` ⇒ ⭐ **the opposite of the `wait` half: loud, and loud twice.** The type has no executor at all (`installBuiltinNodes` seeds twelve packs, none of them this one; the package README files `boundary_event` as the BPMN *interop* representation rather than the native authoring model), so the dispatch fails before any config block could be read. There is no silent suspension on this branch to find. ### 4. CONTROL — `boundary_event` with a fully populated `boundaryConfig` Fails **identically** (`success: false`, same `error` string, same step failure) ⇒ the block is never consulted. A third control puts a **registered** type in the same slot in the same flow and the run completes (`ran: [ 'before', 'b', 'after' ]`), so the failure above is the node type, not the fixture. ## Premise check The card's premise holds on the tip, verified rather than assumed, and is pinned in the test: `FlowNodeSchema` **accepts** a `wait` node with no `waitEventConfig` at all, **refuses** one whose block is present with `eventType` omitted, and **accepts** a `boundary_event` node with no `boundaryConfig`. So the documents measured above are ones an author can really save. ## The durable form `packages/services/service-automation/src/builtin/absent-config-node-characterization.test.ts` — named, titled and headed as a **characterization**, with the header stating in as many words that every assertion is a photograph and not an endorsement, that a red here means "the recorded behaviour moved" (possibly on purpose), and that whether a duration-less timer wait should warn or refuse is a product question for a successor card rather than anything this file blesses. ## Verification - `pnpm --filter @objectstack/service-automation test` — **133 files, 1571 tests, all pass** (the new file is 7 of them). - `pnpm --filter @objectstack/service-automation typecheck` — exit 0. - `pnpm --filter @objectstack/service-automation build` — exit 0; dependency closure built first via `pnpm --filter '@objectstack/service-automation^...' build`. - Gate families derived from the diff with `scripts/pm/dispatch-gates.mjs` and reconciled with `--ran`: **53 derived, 51 run green, 0 unrun, 2 NOT MEASURED** — `check:dual-build-cjs-loads` and `check:type-check-debt`, both exit **3** (`PREREQUISITE NOT MET`: each reads built output for the whole workspace, which `lint.yml` builds before invoking them). Neither is a finding, and this diff emits nothing into any `dist/` for them to read. - `pnpm lint` (the repo-wide `eslint . --no-inline-config`) — exit 0, whole population, no narrowing claimed. - `pnpm check:nul-bytes` green, plus a direct control-character scan of the added file (`grep -naP` over the C0 set plus DEL) — no matches. Gate log and reconciliation taken at `d5e7d32c3`, the final commit. ## Changeset `skip-changeset`, measured rather than assumed: the package publishes `files: ["dist", "README.md", "CHANGELOG.md"]`, and after a real build a probe symbol unique to the added file appears in **0** of those paths, while the positive control `rearmSuspendedWaitTimers` appears in **2** files under `dist/`. Nothing already published moves. **Clause-② re-derived from the delivered diff: `no`.** The diff is exactly one added file, a `*.test.ts` under `src/`, outside `files[]` and unreachable from the published entry (`exports: { "." }` resolves into `dist/`). It adds no exported symbol reachable from a published entry and no key on an already-published payload — the file declares no exports at all, and in any case the grading test is reachability, not the appearance of the word export in a diff. ## Acceptance notes Out of scope for this card, noted rather than filed: - **The silent permanent suspension itself is confirmed, and is deliberately not repaired here.** The card states it is not a request to change anything, and the dispatching seat reserved the successor question — should a duration-less timer wait warn or refuse? — for itself. - `output` being a present-but-`undefined` key rather than an absent one is invisible to a JSON rendering of the return value. An observation about how the value reads, not a defect. - A **paused** run emits no run-level log line at all, where a `completed` or `failed` run gets an `[automation] run flow=... status=...` line. Consistent with "paused is not terminal"; recorded because it is the other half of why the absent-config wait is silent end to end. - `boundary_event` parses clean and then fails the run with `NO_EXECUTOR`. Not filed: the engine treats "a node type nothing registered" as a declared, handled state with a startup warning that names the type, and the package README declares this type interop-only. Loud by design, not a trap. --- _Generated by [Claude Code](https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent bdb247d commit 225197c

1 file changed

Lines changed: 326 additions & 0 deletions

File tree

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ⚠️ CHARACTERIZATION — this file RECORDS what the executor does today with a
5+
* node that carries **no config block at all**. It does **not** endorse it, and
6+
* a green run here is **not** a statement that the behaviour is correct.
7+
*
8+
* A later reader: every `expect` below is a photograph, not a contract. If the
9+
* behaviour is deliberately changed, **update the photograph** — a red here is
10+
* "the recorded behaviour moved", which may be exactly what a fix intends. The
11+
* one thing it must never do is drift *unnoticed*.
12+
*
13+
* **Why the photograph was taken (#17843).** `@objectstack/spec` ACCEPTS a
14+
* `wait` node whose `waitEventConfig` block is absent entirely — that is the
15+
* state a freshly created node is in — while it REFUSES a node whose block is
16+
* present with `eventType` omitted. So "block absent" is a document an author
17+
* can really save, and what it then DOES at run time had never been observed:
18+
* the question was answered from source, and this file is the observed run that
19+
* closes that gap. It measures both halves the card asked for, because they are
20+
* different execution branches and neither predicts the other:
21+
*
22+
* - **`wait`** — the executor defaults `eventType` to `'timer'` on purpose (the
23+
* `?? 'timer'` in `wait-node.ts` carries a comment saying a wait node without
24+
* a config block is a valid timer wait), and the timer branch with no
25+
* `timerDuration` computes no deadline: no wake-up job, no `waitUntil`, not
26+
* one log line, and `success: true, suspend: true`. The run parks there until
27+
* something external calls `resume(runId)`. ⭐ Recorded, not blessed:
28+
* whether a duration-less timer wait should warn or refuse is a product
29+
* question that belongs to a successor card, not to this file.
30+
* - **`boundary_event`** — ⭐ the opposite shape, and the reason extrapolating
31+
* from `wait` would have been wrong. No executor is registered for it at all
32+
* (`installBuiltinNodes` seeds twelve packs, none of them this one; the
33+
* README files it as BPMN *interop* representation rather than the native
34+
* authoring model), so the run fails LOUDLY with `NO_EXECUTOR` before the
35+
* config block is ever read — and it fails identically whether that block is
36+
* absent or fully populated.
37+
*/
38+
39+
import { describe, it, expect } from 'vitest';
40+
import type { PluginContext } from '@objectstack/core';
41+
import { AutomationEngine } from '../engine.js';
42+
import type { NodeExecutionResult, NodeExecutor } from '../engine.js';
43+
import { InMemorySuspendedRunStore } from '../suspended-run-store.js';
44+
import { registerWaitNode } from './wait-node.js';
45+
import { FlowNodeSchema } from '@objectstack/spec/automation';
46+
import type { AutomationResult } from '@objectstack/spec/contracts';
47+
import type { IJobService, JobHandler, JobSchedule } from '@objectstack/spec/contracts';
48+
49+
type LogLine = { level: string; text: string };
50+
51+
/**
52+
* Every level the `Logger` contract offers, funnelled into ONE ordered sink —
53+
* the engine's logger and the plugin ctx's logger are the same recorder, so
54+
* "did anything at all get logged" is answerable rather than "did the level I
55+
* happened to spy on get logged".
56+
*/
57+
function recordingLogger(sink: LogLine[]): PluginContext['logger'] {
58+
const at = (level: string) => (...args: unknown[]) => {
59+
sink.push({ level, text: args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') });
60+
};
61+
const logger = { info: at('info'), warn: at('warn'), error: at('error'), debug: at('debug') };
62+
return { ...logger, child: () => logger } as unknown as PluginContext['logger'];
63+
}
64+
65+
/** A job service that records what was scheduled — the instrument for "was a wake-up armed". */
66+
function jobCtx(sink: LogLine[]) {
67+
const scheduled: Array<{ name: string; schedule: JobSchedule }> = [];
68+
const job: IJobService = {
69+
async schedule(name: string, schedule: JobSchedule, _handler: JobHandler) {
70+
scheduled.push({ name, schedule });
71+
},
72+
async cancel() {},
73+
async trigger() {},
74+
};
75+
const ctx = {
76+
logger: recordingLogger(sink),
77+
getService: (id: string) => (id === 'job' ? job : undefined),
78+
} as unknown as PluginContext;
79+
return { ctx, scheduled };
80+
}
81+
82+
/** Records the order nodes ran, so "the run never got past the node" is observed, not assumed. */
83+
function marker(ran: string[]): NodeExecutor {
84+
return { type: 'mark', async execute(node) { ran.push(node.id); return { success: true }; } };
85+
}
86+
87+
/**
88+
* start → before(mark) → <the node under measurement> → after(mark) → end.
89+
*
90+
* `before` proves the run REACHED the node; `after` proves whether it got past
91+
* it. Without those two the readings below would be compatible with a flow that
92+
* never ran at all.
93+
*/
94+
const flowWith = (node: Record<string, unknown>) => ({
95+
name: 'f',
96+
label: 'F',
97+
type: 'autolaunched',
98+
nodes: [
99+
{ id: 'start', type: 'start', label: 'Start' },
100+
{ id: 'before', type: 'mark', label: 'Before' },
101+
node,
102+
{ id: 'after', type: 'mark', label: 'After' },
103+
{ id: 'end', type: 'end', label: 'End' },
104+
],
105+
edges: [
106+
{ id: 'e0', source: 'start', target: 'before' },
107+
{ id: 'e1', source: 'before', target: String(node.id) },
108+
{ id: 'e2', source: String(node.id), target: 'after' },
109+
{ id: 'e3', source: 'after', target: 'end' },
110+
],
111+
});
112+
113+
/**
114+
* One real, engine-driven run of a `wait` node, reporting every channel the
115+
* measurement needs.
116+
*
117+
* The executor's own return value is captured by wrapping the executor
118+
* `registerWaitNode` publishes — the value recorded is the one the ENGINE
119+
* received from a genuine run, never a second invocation staged by the test.
120+
*/
121+
async function runWaitNode(node: Record<string, unknown>) {
122+
const logs: LogLine[] = [];
123+
const ran: string[] = [];
124+
const engine = new AutomationEngine(recordingLogger(logs));
125+
const store = new InMemorySuspendedRunStore();
126+
engine.setSuspendedRunStore(store);
127+
engine.registerNodeExecutor(marker(ran));
128+
129+
const { ctx, scheduled } = jobCtx(logs);
130+
let returned: NodeExecutionResult | undefined;
131+
const realRegister = engine.registerNodeExecutor.bind(engine);
132+
const patchable = engine as unknown as { registerNodeExecutor: (e: NodeExecutor) => void };
133+
patchable.registerNodeExecutor = (exec: NodeExecutor) => {
134+
if (exec.type !== 'wait') return realRegister(exec);
135+
const inner = exec.execute.bind(exec);
136+
return realRegister({
137+
...exec,
138+
async execute(n, v, c) { const r = await inner(n, v, c); returned = r; return r; },
139+
});
140+
};
141+
registerWaitNode(engine, ctx);
142+
delete (engine as unknown as Record<string, unknown>).registerNodeExecutor;
143+
144+
engine.registerFlow('f', flowWith(node));
145+
// Production seals the vocabulary at `kernel:bootstrapped`
146+
// (`AutomationServicePlugin`); sealing here keeps the engine's own
147+
// "never sealed" warning out of the window, so a log line seen during the run
148+
// is one the NODE produced.
149+
engine.sealNodeTypeVocabulary();
150+
const from = logs.length;
151+
const result = await engine.execute('f');
152+
return {
153+
result,
154+
returned,
155+
scheduled,
156+
suspended: engine.listSuspendedRuns(),
157+
stored: await store.list(),
158+
logsDuringRun: logs.slice(from),
159+
ran,
160+
};
161+
}
162+
163+
/** One real, engine-driven run of a node whose type has no executor registered. */
164+
async function runUnexecutableNode(node: Record<string, unknown>) {
165+
const logs: LogLine[] = [];
166+
const ran: string[] = [];
167+
const engine = new AutomationEngine(recordingLogger(logs));
168+
engine.registerNodeExecutor(marker(ran));
169+
registerWaitNode(engine, jobCtx(logs).ctx);
170+
engine.registerFlow('f', flowWith(node));
171+
172+
const beforeSeal = logs.length;
173+
engine.sealNodeTypeVocabulary();
174+
const sealLogs = logs.slice(beforeSeal);
175+
176+
const from = logs.length;
177+
const result = await engine.execute('f');
178+
return { result, registeredTypes: engine.getRegisteredNodeTypes(), sealLogs, logsDuringRun: logs.slice(from), ran };
179+
}
180+
181+
const nodeStatus = (result: AutomationResult, nodeId: string) =>
182+
result.summary?.nodes?.find((n) => n.nodeId === nodeId)?.status;
183+
184+
/**
185+
* The premise, pinned: the documents measured below are ones an author can
186+
* really save. Without this leg the readings are untethered — if the spec ever
187+
* starts refusing a block-less node, this whole file characterizes a document
188+
* that can no longer exist, and its reader must be told that rather than left
189+
* reading run-time behaviour for an unreachable input.
190+
*/
191+
describe('premise (#17843) — the parse contract ACCEPTS a node with no config block', () => {
192+
const parse = (node: Record<string, unknown>) => FlowNodeSchema.safeParse(node);
193+
194+
it('accepts a `wait` node with no `waitEventConfig` at all, and refuses one whose block omits `eventType`', () => {
195+
expect(parse({ id: 'pause', type: 'wait', label: 'Wait' }).success).toBe(true);
196+
// The boundary the measurement turns on: "omitted key inside a present
197+
// block" and "block absent" are different documents with different verdicts.
198+
expect(parse({ id: 'pause', type: 'wait', label: 'Wait', waitEventConfig: {} }).success).toBe(false);
199+
});
200+
201+
it('accepts a `boundary_event` node with no `boundaryConfig` at all', () => {
202+
expect(parse({ id: 'b', type: 'boundary_event', label: 'Boundary' }).success).toBe(true);
203+
});
204+
});
205+
206+
describe('characterization (#17843) — a `wait` node with NO config block at all · recorded, NOT endorsed', () => {
207+
it('defaults to a timer, arms nothing, writes no deadline and says nothing — and the run parks there', async () => {
208+
const m = await runWaitNode({ id: 'pause', type: 'wait', label: 'Wait' });
209+
210+
// The run really reached the node and really stopped at it.
211+
expect(m.ran, 'the run must have reached the wait node').toEqual(['before']);
212+
expect(m.result.status).toBe('paused');
213+
expect(m.result.success).toBe(true);
214+
expect(m.suspended).toHaveLength(1);
215+
216+
// ① The executor's return value, verbatim. ⚠️ `output` is a PRESENT key
217+
// carrying `undefined` — not an absent one (`toStrictEqual`, because a
218+
// plain `toEqual` cannot tell those two apart, and a JSON dump of this
219+
// object drops the key entirely and reads as the second).
220+
expect(m.returned).toStrictEqual({
221+
success: true, suspend: true, correlation: 'timer:pause', output: undefined,
222+
});
223+
expect(m.returned?.output, 'nothing to write: no deadline was computed').toBeUndefined();
224+
225+
// ② No wake-up job was scheduled, though a job service WAS available —
226+
// which is what makes the silence below unconditional rather than a
227+
// consequence of a job-less host.
228+
expect(m.scheduled).toEqual([]);
229+
230+
// ③ No `waitUntil` is written, so a later cold boot's re-arm pass
231+
// (`rearmSuspendedWaitTimers`) skips this run as "not a timer wait".
232+
const vars = (m.stored[0]?.variables ?? {}) as Record<string, unknown>;
233+
expect(Object.keys(vars).filter((k) => k.endsWith('.waitUntil'))).toEqual([]);
234+
235+
// ④ ⭐ Not one log line — no `warn`, no `error`, nothing at any level.
236+
expect(m.logsDuringRun, 'the node emits nothing an operator could see').toEqual([]);
237+
238+
// ⇒ the correlation is a bare `timer:<nodeId>`, which arms nothing and which
239+
// `onSuspensionReleased` deliberately does not treat as a job name. The
240+
// only exit from this pause is an external `resume(runId)`.
241+
expect(m.suspended[0]).toMatchObject({ nodeId: 'pause', correlation: 'timer:pause' });
242+
});
243+
244+
it('CONTROL — the same node WITH `eventType: timer` + `timerDuration` behaves differently on every channel', async () => {
245+
const control = await runWaitNode({
246+
id: 'pause', type: 'wait', label: 'Wait',
247+
waitEventConfig: { eventType: 'timer', timerDuration: 'PT1H' },
248+
});
249+
const absent = await runWaitNode({ id: 'pause', type: 'wait', label: 'Wait' });
250+
251+
// A wake-up job IS armed, one shot, ~1h out.
252+
expect(control.scheduled).toHaveLength(1);
253+
expect(control.scheduled[0].schedule.type).toBe('once');
254+
255+
// …and the deadline IS persisted, under the key the re-arm pass reads.
256+
const controlVars = (control.stored[0]?.variables ?? {}) as Record<string, unknown>;
257+
expect(typeof controlVars['pause.waitUntil']).toBe('string');
258+
expect(control.returned?.output).toEqual({ waitUntil: controlVars['pause.waitUntil'] });
259+
260+
// The correlation is the JOB's name here, the inert `timer:<nodeId>` there.
261+
expect(control.returned?.correlation).toBe(control.scheduled[0].name);
262+
expect(control.returned?.correlation).not.toBe(absent.returned?.correlation);
263+
264+
// ⇒ the instrument discriminates: every channel that read zero above reads
265+
// non-zero here, so the zeros are a reading of the absent-config path and
266+
// not of a harness that measures nothing.
267+
expect([absent.scheduled.length, control.scheduled.length]).toEqual([0, 1]);
268+
expect([
269+
Object.keys((absent.stored[0]?.variables ?? {}) as Record<string, unknown>).some((k) => k.endsWith('.waitUntil')),
270+
Object.keys(controlVars).some((k) => k.endsWith('.waitUntil')),
271+
]).toEqual([false, true]);
272+
273+
// Both halves park the run, so "paused" is the one thing that does NOT
274+
// discriminate — recorded so a reader does not mistake it for a signal.
275+
expect([absent.result.status, control.result.status]).toEqual(['paused', 'paused']);
276+
});
277+
});
278+
279+
describe('characterization (#17843) — a `boundary_event` node · a DIFFERENT branch from `wait`, measured separately', () => {
280+
it('fails the run loudly with NO_EXECUTOR — nothing is registered for the type, silent suspension never enters it', async () => {
281+
const m = await runUnexecutableNode({ id: 'b', type: 'boundary_event', label: 'Boundary' });
282+
283+
// The premise: the platform ships no `boundary_event` executor.
284+
expect(m.registeredTypes).not.toContain('boundary_event');
285+
286+
// The run reached the node (`before` ran) and did NOT get past it.
287+
expect(m.ran).toEqual(['before']);
288+
289+
// ⭐ Loud, not silent — the opposite of the `wait` half above.
290+
expect(m.result.success).toBe(false);
291+
expect(m.result.status).toBe('failed');
292+
expect(m.result.error).toBe("No executor registered for node type 'boundary_event'");
293+
expect(nodeStatus(m.result, 'b')).toBe('failure');
294+
295+
// Loud TWICE: sealing the vocabulary names the type before any run.
296+
expect(m.sealLogs.some((l) => l.level === 'warn' && l.text.includes('boundary_event'))).toBe(true);
297+
// …and the run itself is reported, rather than passing unremarked.
298+
expect(m.logsDuringRun.some((l) => l.text.includes('status=failed'))).toBe(true);
299+
});
300+
301+
it('CONTROL — a fully populated `boundaryConfig` fails identically ⇒ the config block is never consulted', async () => {
302+
const populated = await runUnexecutableNode({
303+
id: 'b', type: 'boundary_event', label: 'Boundary',
304+
boundaryConfig: {
305+
attachedToNodeId: 'before', eventType: 'timer', timerDuration: 'PT1H', interrupting: true,
306+
},
307+
});
308+
309+
expect(populated.result.success).toBe(false);
310+
expect(populated.result.error).toBe("No executor registered for node type 'boundary_event'");
311+
expect(populated.ran).toEqual(['before']);
312+
// ⇒ presence or absence of the block changes NOTHING here: the dispatch
313+
// fails before any executor could read it. Which is exactly why the
314+
// `wait` reading could not have been extrapolated onto this type.
315+
});
316+
317+
it('CONTROL — the identical flow with a REGISTERED type in that slot runs to completion', async () => {
318+
const ok = await runUnexecutableNode({ id: 'b', type: 'mark', label: 'Marker in the boundary slot' });
319+
320+
expect(ok.result.success).toBe(true);
321+
expect(ok.result.status).not.toBe('failed');
322+
// ⇒ the flow shape and the harness are sound; the failure above is the
323+
// node type, not the fixture.
324+
expect(ok.ran).toEqual(['before', 'b', 'after']);
325+
});
326+
});

0 commit comments

Comments
 (0)