posipaki 0.25.0 → 0.26.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/dist/testing/index.d.ts
CHANGED
|
@@ -6,17 +6,38 @@ type Matcher<M extends Message> = (msg: M, history: M[]) => boolean;
|
|
|
6
6
|
type MatchSpec<M extends Message> = Matcher<M> | Partial<M> | Array<Partial<M>>;
|
|
7
7
|
/** Convert a matcher spec to a canonical matcher function. */
|
|
8
8
|
declare function toMatcher<M extends Message>(spec: MatchSpec<M>): Matcher<M>;
|
|
9
|
+
/**
|
|
10
|
+
* Occurrence matcher: matches when at least `n` messages in the history
|
|
11
|
+
* satisfy `spec`. This is the canonical form for "Nth occurrence" waits:
|
|
12
|
+
*
|
|
13
|
+
* const c = createCollector<PongMsg>({ type: "PONG" });
|
|
14
|
+
* await c.next(times({ type: "PONG" }, 3)); // wait for the 3rd PONG
|
|
15
|
+
*
|
|
16
|
+
* Note: `history` accumulates, so this counts occurrences over the whole
|
|
17
|
+
* collected history, not since the previous `next()`. Use `reset()` with a
|
|
18
|
+
* fresh collector for windowed counts.
|
|
19
|
+
*/
|
|
20
|
+
declare function times<M extends Message>(spec: Partial<M> | Matcher<M>, n: number): Matcher<M>;
|
|
9
21
|
//#endregion
|
|
10
22
|
//#region src/testing/test-plugin.d.ts
|
|
11
23
|
interface MatchResult {
|
|
12
24
|
ok: boolean;
|
|
25
|
+
/** Human-readable diagnosis on failure (timeout / exit-before-match). */
|
|
13
26
|
detail?: string;
|
|
14
27
|
}
|
|
15
28
|
interface Collector<M extends Message> {
|
|
16
29
|
plugin: ActorPlugin;
|
|
17
30
|
messages: M[];
|
|
18
|
-
|
|
19
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Wait until the collected history matches the current filter.
|
|
33
|
+
*
|
|
34
|
+
* @param timeoutMs - optional deadline. Without one, the promise settles
|
|
35
|
+
* when the actor exits (with `ok: false`) — pass a timeout for a hard
|
|
36
|
+
* failure with a diagnostic instead of relying on the test runner's
|
|
37
|
+
* global timeout.
|
|
38
|
+
*/
|
|
39
|
+
resolved(timeoutMs?: number): Promise<MatchResult>;
|
|
40
|
+
next(filter: MatchSpec<M>, timeoutMs?: number): Promise<MatchResult>;
|
|
20
41
|
reset(filter?: MatchSpec<M>): void;
|
|
21
42
|
}
|
|
22
43
|
interface RootTracker {
|
|
@@ -32,5 +53,5 @@ declare function createRootTracker(): RootTracker;
|
|
|
32
53
|
/** True if `name` matches any of `patterns`. */
|
|
33
54
|
declare function pnameMatch(name: string, patterns: string[]): boolean;
|
|
34
55
|
//#endregion
|
|
35
|
-
export { type Collector, type MatchResult, type MatchSpec, type Matcher, type RootTracker, createCollector, createRootTracker, pnameMatch, toMatcher };
|
|
56
|
+
export { type Collector, type MatchResult, type MatchSpec, type Matcher, type RootTracker, createCollector, createRootTracker, pnameMatch, times, toMatcher };
|
|
36
57
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","names":[],"sources":["../../src/testing/msg-matcher.ts","../../src/testing/test-plugin.ts","../../src/testing/pname-match.ts"],"mappings":";;;;KAaY,OAAA,WAAkB,OAAA,KAAY,GAAA,EAAK,CAAA,EAAG,OAAA,EAAS,CAAA;AAAA,KAE/C,SAAA,WAAoB,OAAA,IAC5B,OAAA,CAAQ,CAAA,IACR,OAAA,CAAQ,CAAA,IACR,KAAA,CAAM,OAAA,CAAQ,CAAA;;iBAUF,SAAA,WAAoB,OAAA,EAAS,IAAA,EAAM,SAAA,CAAU,CAAA,IAAK,OAAA,CAAQ,CAAA;;;
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../../src/testing/msg-matcher.ts","../../src/testing/test-plugin.ts","../../src/testing/pname-match.ts"],"mappings":";;;;KAaY,OAAA,WAAkB,OAAA,KAAY,GAAA,EAAK,CAAA,EAAG,OAAA,EAAS,CAAA;AAAA,KAE/C,SAAA,WAAoB,OAAA,IAC5B,OAAA,CAAQ,CAAA,IACR,OAAA,CAAQ,CAAA,IACR,KAAA,CAAM,OAAA,CAAQ,CAAA;;iBAUF,SAAA,WAAoB,OAAA,EAAS,IAAA,EAAM,SAAA,CAAU,CAAA,IAAK,OAAA,CAAQ,CAAA;;;;;;;;;;;;iBA2B1D,KAAA,WAAgB,OAAA,EAC9B,IAAA,EAAM,OAAA,CAAQ,CAAA,IAAK,OAAA,CAAQ,CAAA,GAC3B,CAAA,WACC,OAAA,CAAQ,CAAA;;;UCxCM,WAAA;EACf,EAAA;EDNiB;ECQjB,MAAM;AAAA;AAAA,UAGS,SAAA,WAAoB,OAAA;EACnC,MAAA,EAAQ,WAAA;EACR,QAAA,EAAU,CAAA;EDbgD;;;;;;;;ECsB1D,QAAA,CAAS,SAAA,YAAqB,OAAA,CAAQ,WAAA;EACtC,IAAA,CAAK,MAAA,EAAQ,SAAA,CAAU,CAAA,GAAI,SAAA,YAAqB,OAAA,CAAQ,WAAA;EACxD,KAAA,CAAM,MAAA,GAAS,SAAA,CAAU,CAAA;AAAA;AAAA,UAGV,WAAA;EACf,MAAA,EAAQ,WAAA;EACR,OAAA,IAAW,OAAO;AAAA;AAAA,iBAoBJ,eAAA,WAA0B,OAAA,EACxC,MAAA,EAAQ,SAAA,CAAU,CAAA,GAClB,IAAA;EAAS,KAAA;AAAA,IACR,SAAA,CAAU,CAAA;AAAA,iBAiGG,iBAAA,IAAqB,WAAW;;;;iBCxJhC,UAAA,CAAW,IAAA,UAAc,QAAkB"}
|
package/dist/testing/index.js
CHANGED
|
@@ -14,6 +14,21 @@ function toMatcher(spec) {
|
|
|
14
14
|
};
|
|
15
15
|
return (msg) => shallowMatch(msg, spec);
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Occurrence matcher: matches when at least `n` messages in the history
|
|
19
|
+
* satisfy `spec`. This is the canonical form for "Nth occurrence" waits:
|
|
20
|
+
*
|
|
21
|
+
* const c = createCollector<PongMsg>({ type: "PONG" });
|
|
22
|
+
* await c.next(times({ type: "PONG" }, 3)); // wait for the 3rd PONG
|
|
23
|
+
*
|
|
24
|
+
* Note: `history` accumulates, so this counts occurrences over the whole
|
|
25
|
+
* collected history, not since the previous `next()`. Use `reset()` with a
|
|
26
|
+
* fresh collector for windowed counts.
|
|
27
|
+
*/
|
|
28
|
+
function times(spec, n) {
|
|
29
|
+
const single = typeof spec === "function" ? spec : (m) => shallowMatch(m, spec);
|
|
30
|
+
return (_msg, history) => history.filter((m) => single(m, history)).length >= n;
|
|
31
|
+
}
|
|
17
32
|
//#endregion
|
|
18
33
|
//#region src/testing/pname-match.ts
|
|
19
34
|
/** True if `name` matches any of `patterns`. */
|
|
@@ -29,12 +44,27 @@ function pnameMatch(name, patterns) {
|
|
|
29
44
|
}
|
|
30
45
|
//#endregion
|
|
31
46
|
//#region src/testing/test-plugin.ts
|
|
47
|
+
function describeSpec(spec) {
|
|
48
|
+
if (typeof spec === "function") return "predicate";
|
|
49
|
+
if (Array.isArray(spec)) return `sequence [${spec.map((s) => JSON.stringify(s)).join(" → ")}]`;
|
|
50
|
+
return JSON.stringify(spec);
|
|
51
|
+
}
|
|
52
|
+
function lastSummary(messages) {
|
|
53
|
+
const last = messages[messages.length - 1];
|
|
54
|
+
if (last === void 0) return "none";
|
|
55
|
+
return JSON.stringify(last).slice(0, 120);
|
|
56
|
+
}
|
|
32
57
|
function createCollector(filter, opts) {
|
|
33
58
|
const messages = [];
|
|
34
59
|
let matcher = toMatcher(filter);
|
|
60
|
+
let currentSpec = filter;
|
|
35
61
|
let defaultScope = [];
|
|
36
62
|
let installed = false;
|
|
37
63
|
let waiters = [];
|
|
64
|
+
function setSpec(spec) {
|
|
65
|
+
currentSpec = spec;
|
|
66
|
+
matcher = toMatcher(spec);
|
|
67
|
+
}
|
|
38
68
|
function scopePatterns() {
|
|
39
69
|
if (opts?.scope === void 0) return defaultScope;
|
|
40
70
|
return Array.isArray(opts.scope) ? opts.scope : [opts.scope];
|
|
@@ -46,14 +76,27 @@ function createCollector(filter, opts) {
|
|
|
46
76
|
for (const w of ws) w({ ok: true });
|
|
47
77
|
}
|
|
48
78
|
}
|
|
49
|
-
function resolved() {
|
|
79
|
+
function resolved(timeoutMs) {
|
|
50
80
|
return new Promise((resolve) => {
|
|
51
81
|
const last = messages[messages.length - 1];
|
|
52
82
|
if (last !== void 0 && matcher(last, messages)) {
|
|
53
83
|
resolve({ ok: true });
|
|
54
84
|
return;
|
|
55
85
|
}
|
|
56
|
-
|
|
86
|
+
let timer;
|
|
87
|
+
const settle = (result) => {
|
|
88
|
+
const i = waiters.indexOf(settle);
|
|
89
|
+
if (i >= 0) waiters.splice(i, 1);
|
|
90
|
+
if (timer) clearTimeout(timer);
|
|
91
|
+
resolve(result);
|
|
92
|
+
};
|
|
93
|
+
waiters.push(settle);
|
|
94
|
+
if (timeoutMs !== void 0) timer = setTimeout(() => {
|
|
95
|
+
settle({
|
|
96
|
+
ok: false,
|
|
97
|
+
detail: `timeout after ${timeoutMs}ms — expected: ${describeSpec(currentSpec)} — received ${messages.length} message(s), last: ${lastSummary(messages)}`
|
|
98
|
+
});
|
|
99
|
+
}, timeoutMs);
|
|
57
100
|
});
|
|
58
101
|
}
|
|
59
102
|
const collectorPlugin = (config) => {
|
|
@@ -82,12 +125,12 @@ function createCollector(filter, opts) {
|
|
|
82
125
|
plugin: collectorPlugin,
|
|
83
126
|
messages,
|
|
84
127
|
resolved,
|
|
85
|
-
next(nextFilter) {
|
|
86
|
-
|
|
87
|
-
return resolved();
|
|
128
|
+
next(nextFilter, timeoutMs) {
|
|
129
|
+
setSpec(nextFilter);
|
|
130
|
+
return resolved(timeoutMs);
|
|
88
131
|
},
|
|
89
132
|
reset(newFilter) {
|
|
90
|
-
if (newFilter)
|
|
133
|
+
if (newFilter) setSpec(newFilter);
|
|
91
134
|
}
|
|
92
135
|
};
|
|
93
136
|
}
|
|
@@ -113,6 +156,6 @@ function createRootTracker() {
|
|
|
113
156
|
};
|
|
114
157
|
}
|
|
115
158
|
//#endregion
|
|
116
|
-
export { createCollector, createRootTracker, pnameMatch, toMatcher };
|
|
159
|
+
export { createCollector, createRootTracker, pnameMatch, times, toMatcher };
|
|
117
160
|
|
|
118
161
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../src/testing/msg-matcher.ts","../../src/testing/pname-match.ts","../../src/testing/test-plugin.ts"],"sourcesContent":["// ── Message Matcher ──────────────────────────────────────────────────────\n//\n// Canonical form: `fn(msg, history) => boolean`. `msg` is the just-arrived\n// message (also the last element of `history`); `history` is the full\n// accumulated list *including* `msg`.\n//\n// Two shortcuts:\n// - a message literal `{ type: \"X\" }` → matches the latest message\n// - a list `[{...}, {...}]` → matches the tail of history,\n// in order, from oldest to newest\n\nimport type { Message } from \"../types.js\";\n\nexport type Matcher<M extends Message> = (msg: M, history: M[]) => boolean;\n\nexport type MatchSpec<M extends Message> =\n | Matcher<M>\n | Partial<M>\n | Array<Partial<M>>;\n\nfunction shallowMatch<M extends Message>(msg: M, spec: Partial<M>): boolean {\n for (const key of Object.keys(spec) as Array<keyof M>) {\n if (msg[key] !== spec[key]) return false;\n }\n return true;\n}\n\n/** Convert a matcher spec to a canonical matcher function. */\nexport function toMatcher<M extends Message>(spec: MatchSpec<M>): Matcher<M> {\n if (typeof spec === \"function\") return spec as Matcher<M>;\n\n if (Array.isArray(spec)) {\n // Sequence: the tail of history must equal `spec`, in order.\n return (_msg, history) => {\n if (history.length < spec.length) return false;\n const tail = history.slice(-spec.length);\n return spec.every((s, i) => shallowMatch(tail[i], s));\n };\n }\n\n // Single literal: the latest message must shallow-match.\n return (msg) => shallowMatch(msg, spec);\n}\n","// ── Process Name Match ───────────────────────────────────────────────────\n//\n// Tree-prefix matching for posipaki process names (`parent:child:...`).\n//\n// Pattern language (same as debug-logger's DEBUG env filtering):\n// - \"*\" matches any name\n// - \"name\" matches exactly\n// - \"prefix:*\" matches `prefix` and `prefix:` + anything below it\n\n/** True if `name` matches any of `patterns`. */\nexport function pnameMatch(name: string, patterns: string[]): boolean {\n for (const p of patterns) {\n if (p === \"*\") return true;\n if (p.endsWith(\":*\")) {\n const prefix = p.slice(0, -2);\n if (name === prefix || name.startsWith(prefix + \":\")) return true;\n } else if (p === name) {\n return true;\n }\n }\n return false;\n}\n","// ── Test Plugins ─────────────────────────────────────────────────────────\n//\n// createCollector: per-actor message collector plugin.\n// createRootTracker: global process tracker for cleanup.\n\nimport { mergeConfigs, type ActorPlugin } from \"../hooks.js\";\nimport type { Message, StopMessage, SenderInfo, ProcessCtx } from \"../types.js\";\nimport { toMatcher, type MatchSpec } from \"./msg-matcher.js\";\nimport { pnameMatch } from \"./pname-match.js\";\n\n// ── types ────────────────────────────────────────────────────────────────\n\nexport interface MatchResult {\n ok: boolean;\n detail?: string;\n}\n\nexport interface Collector<M extends Message> {\n plugin: ActorPlugin;\n messages: M[];\n resolved(): Promise<MatchResult>;\n next(filter: MatchSpec<M>): Promise<MatchResult>;\n reset(filter?: MatchSpec<M>): void;\n}\n\nexport interface RootTracker {\n plugin: ActorPlugin;\n stopAll(): Promise<void>;\n}\n\n// ── createCollector ──────────────────────────────────────────────────────\n\nexport function createCollector<M extends Message>(\n filter: MatchSpec<M>,\n opts?: { scope?: string | string[] },\n): Collector<M> {\n const messages: M[] = [];\n let matcher = toMatcher(filter);\n // Captured from the root's beforeStart; used as the default scope.\n let defaultScope: string[] = [];\n // The plugin is installed on the root first (spawn assembles before any\n // child is forked), so the first install's beforeStart is the root.\n let installed = false;\n let waiters: Array<(result: MatchResult) => void> = [];\n\n function scopePatterns(): string[] {\n if (opts?.scope === undefined) return defaultScope;\n return Array.isArray(opts.scope) ? opts.scope : [opts.scope];\n }\n\n function checkAndNotify() {\n const last = messages[messages.length - 1];\n if (last !== undefined && matcher(last, messages)) {\n const ws = waiters.splice(0);\n for (const w of ws) w({ ok: true });\n }\n }\n\n function resolved(): Promise<MatchResult> {\n return new Promise((resolve) => {\n const last = messages[messages.length - 1];\n if (last !== undefined && matcher(last, messages)) {\n resolve({ ok: true });\n return;\n }\n waiters.push(resolve);\n });\n }\n\n const collectorPlugin: ActorPlugin = (config) => {\n const isRoot = !installed;\n installed = true;\n return mergeConfigs(config, {\n beforeStart() {\n if (isRoot) defaultScope = [this.name];\n },\n onEmit(msg: Message, sender: SenderInfo) {\n if (!pnameMatch(sender.fromName, scopePatterns())) return;\n messages.push(msg as M);\n checkAndNotify();\n },\n beforeEnd() {\n const ws = waiters.splice(0);\n for (const w of ws) w({ ok: false, detail: \"actor exited before match\" });\n },\n });\n };\n\n // Named function so framework deduplication works.\n Object.defineProperty(collectorPlugin, \"name\", { value: \"messageCollector\" });\n\n return {\n plugin: collectorPlugin,\n messages,\n resolved,\n next(nextFilter) {\n matcher = toMatcher(nextFilter);\n return resolved();\n },\n reset(newFilter) {\n if (newFilter) matcher = toMatcher(newFilter);\n },\n };\n}\n\n// ── createRootTracker ────────────────────────────────────────────────────\n\nexport function createRootTracker(): RootTracker {\n const roots = new Set<{ sendSelf: (msg: StopMessage) => void }>();\n\n const rootTrackerPlugin: ActorPlugin = (config) =>\n mergeConfigs(config, {\n beforeStart(this: { ctx: ProcessCtx<unknown, unknown, Message, Message> }) {\n roots.add(this.ctx);\n },\n afterEnd(this: { ctx: ProcessCtx<unknown, unknown, Message, Message> }) {\n roots.delete(this.ctx);\n },\n });\n\n Object.defineProperty(rootTrackerPlugin, \"name\", { value: \"rootTracker\" });\n\n return {\n plugin: rootTrackerPlugin,\n async stopAll() {\n for (const root of roots) {\n try {\n root.sendSelf({ type: \"STOP\" });\n } catch {}\n }\n roots.clear();\n },\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../src/testing/msg-matcher.ts","../../src/testing/pname-match.ts","../../src/testing/test-plugin.ts"],"sourcesContent":["// ── Message Matcher ──────────────────────────────────────────────────────\n//\n// Canonical form: `fn(msg, history) => boolean`. `msg` is the just-arrived\n// message (also the last element of `history`); `history` is the full\n// accumulated list *including* `msg`.\n//\n// Two shortcuts:\n// - a message literal `{ type: \"X\" }` → matches the latest message\n// - a list `[{...}, {...}]` → matches the tail of history,\n// in order, from oldest to newest\n\nimport type { Message } from \"../types.js\";\n\nexport type Matcher<M extends Message> = (msg: M, history: M[]) => boolean;\n\nexport type MatchSpec<M extends Message> =\n | Matcher<M>\n | Partial<M>\n | Array<Partial<M>>;\n\nfunction shallowMatch<M extends Message>(msg: M, spec: Partial<M>): boolean {\n for (const key of Object.keys(spec) as Array<keyof M>) {\n if (msg[key] !== spec[key]) return false;\n }\n return true;\n}\n\n/** Convert a matcher spec to a canonical matcher function. */\nexport function toMatcher<M extends Message>(spec: MatchSpec<M>): Matcher<M> {\n if (typeof spec === \"function\") return spec as Matcher<M>;\n\n if (Array.isArray(spec)) {\n // Sequence: the tail of history must equal `spec`, in order.\n return (_msg, history) => {\n if (history.length < spec.length) return false;\n const tail = history.slice(-spec.length);\n return spec.every((s, i) => shallowMatch(tail[i], s));\n };\n }\n\n // Single literal: the latest message must shallow-match.\n return (msg) => shallowMatch(msg, spec);\n}\n\n/**\n * Occurrence matcher: matches when at least `n` messages in the history\n * satisfy `spec`. This is the canonical form for \"Nth occurrence\" waits:\n *\n * const c = createCollector<PongMsg>({ type: \"PONG\" });\n * await c.next(times({ type: \"PONG\" }, 3)); // wait for the 3rd PONG\n *\n * Note: `history` accumulates, so this counts occurrences over the whole\n * collected history, not since the previous `next()`. Use `reset()` with a\n * fresh collector for windowed counts.\n */\nexport function times<M extends Message>(\n spec: Partial<M> | Matcher<M>,\n n: number,\n): Matcher<M> {\n const single: Matcher<M> =\n typeof spec === \"function\" ? spec : (m) => shallowMatch(m, spec);\n return (_msg, history) => history.filter((m) => single(m, history)).length >= n;\n}\n","// ── Process Name Match ───────────────────────────────────────────────────\n//\n// Tree-prefix matching for posipaki process names (`parent:child:...`).\n//\n// Pattern language (same as debug-logger's DEBUG env filtering):\n// - \"*\" matches any name\n// - \"name\" matches exactly\n// - \"prefix:*\" matches `prefix` and `prefix:` + anything below it\n\n/** True if `name` matches any of `patterns`. */\nexport function pnameMatch(name: string, patterns: string[]): boolean {\n for (const p of patterns) {\n if (p === \"*\") return true;\n if (p.endsWith(\":*\")) {\n const prefix = p.slice(0, -2);\n if (name === prefix || name.startsWith(prefix + \":\")) return true;\n } else if (p === name) {\n return true;\n }\n }\n return false;\n}\n","// ── Test Plugins ─────────────────────────────────────────────────────────\n//\n// createCollector: per-actor message collector plugin.\n// createRootTracker: global process tracker for cleanup.\n//\n// Observation model: the collector is a plugin installed on the root. It\n// sees every message that flows through the process's onEmit, filtered by\n// a pname scope (default: the root and everything below it). Waiting is\n// event-driven — `resolved()` settles when a matching message arrives, the\n// actor exits, or an explicit timeout fires. No polling.\n\nimport { mergeConfigs, type ActorPlugin } from \"../hooks.js\";\nimport type { Message, StopMessage, SenderInfo, ProcessCtx } from \"../types.js\";\nimport { toMatcher, type MatchSpec } from \"./msg-matcher.js\";\nimport { pnameMatch } from \"./pname-match.js\";\n\n// ── types ────────────────────────────────────────────────────────────────\n\nexport interface MatchResult {\n ok: boolean;\n /** Human-readable diagnosis on failure (timeout / exit-before-match). */\n detail?: string;\n}\n\nexport interface Collector<M extends Message> {\n plugin: ActorPlugin;\n messages: M[];\n /**\n * Wait until the collected history matches the current filter.\n *\n * @param timeoutMs - optional deadline. Without one, the promise settles\n * when the actor exits (with `ok: false`) — pass a timeout for a hard\n * failure with a diagnostic instead of relying on the test runner's\n * global timeout.\n */\n resolved(timeoutMs?: number): Promise<MatchResult>;\n next(filter: MatchSpec<M>, timeoutMs?: number): Promise<MatchResult>;\n reset(filter?: MatchSpec<M>): void;\n}\n\nexport interface RootTracker {\n plugin: ActorPlugin;\n stopAll(): Promise<void>;\n}\n\n// ── diagnostics helpers ──────────────────────────────────────────────────\n\nfunction describeSpec<M extends Message>(spec: MatchSpec<M>): string {\n if (typeof spec === \"function\") return \"predicate\";\n if (Array.isArray(spec))\n return `sequence [${spec.map((s) => JSON.stringify(s)).join(\" → \")}]`;\n return JSON.stringify(spec);\n}\n\nfunction lastSummary<M extends Message>(messages: M[]): string {\n const last = messages[messages.length - 1];\n if (last === undefined) return \"none\";\n return JSON.stringify(last).slice(0, 120);\n}\n\n// ── createCollector ──────────────────────────────────────────────────────\n\nexport function createCollector<M extends Message>(\n filter: MatchSpec<M>,\n opts?: { scope?: string | string[] },\n): Collector<M> {\n const messages: M[] = [];\n let matcher = toMatcher(filter);\n // The spec currently active — kept in sync with `matcher` so timeout\n // diagnostics can say what was expected.\n let currentSpec: MatchSpec<M> = filter;\n // Captured from the root's beforeStart; used as the default scope.\n let defaultScope: string[] = [];\n // The plugin is installed on the root first (spawn assembles before any\n // child is forked), so the first install's beforeStart is the root.\n let installed = false;\n let waiters: Array<(result: MatchResult) => void> = [];\n\n function setSpec(spec: MatchSpec<M>) {\n currentSpec = spec;\n matcher = toMatcher(spec);\n }\n\n function scopePatterns(): string[] {\n if (opts?.scope === undefined) return defaultScope;\n return Array.isArray(opts.scope) ? opts.scope : [opts.scope];\n }\n\n function checkAndNotify() {\n const last = messages[messages.length - 1];\n if (last !== undefined && matcher(last, messages)) {\n const ws = waiters.splice(0);\n for (const w of ws) w({ ok: true });\n }\n }\n\n function resolved(timeoutMs?: number): Promise<MatchResult> {\n return new Promise((resolve) => {\n const last = messages[messages.length - 1];\n if (last !== undefined && matcher(last, messages)) {\n resolve({ ok: true });\n return;\n }\n let timer: ReturnType<typeof setTimeout> | undefined;\n const settle = (result: MatchResult) => {\n const i = waiters.indexOf(settle);\n if (i >= 0) waiters.splice(i, 1);\n if (timer) clearTimeout(timer);\n resolve(result);\n };\n waiters.push(settle);\n if (timeoutMs !== undefined) {\n timer = setTimeout(() => {\n settle({\n ok: false,\n detail:\n `timeout after ${timeoutMs}ms — expected: ${describeSpec(currentSpec)} ` +\n `— received ${messages.length} message(s), last: ${lastSummary(messages)}`,\n });\n }, timeoutMs);\n }\n });\n }\n\n const collectorPlugin: ActorPlugin = (config) => {\n const isRoot = !installed;\n installed = true;\n return mergeConfigs(config, {\n beforeStart() {\n if (isRoot) defaultScope = [this.name];\n },\n onEmit(msg: Message, sender: SenderInfo) {\n if (!pnameMatch(sender.fromName, scopePatterns())) return;\n messages.push(msg as M);\n checkAndNotify();\n },\n beforeEnd() {\n const ws = waiters.splice(0);\n for (const w of ws) w({ ok: false, detail: \"actor exited before match\" });\n },\n });\n };\n\n // Named function so framework deduplication works.\n Object.defineProperty(collectorPlugin, \"name\", { value: \"messageCollector\" });\n\n return {\n plugin: collectorPlugin,\n messages,\n resolved,\n next(nextFilter, timeoutMs) {\n setSpec(nextFilter);\n return resolved(timeoutMs);\n },\n reset(newFilter) {\n if (newFilter) setSpec(newFilter);\n },\n };\n}\n\n// ── createRootTracker ────────────────────────────────────────────────────\n\nexport function createRootTracker(): RootTracker {\n const roots = new Set<{ sendSelf: (msg: StopMessage) => void }>();\n\n const rootTrackerPlugin: ActorPlugin = (config) =>\n mergeConfigs(config, {\n beforeStart(this: { ctx: ProcessCtx<unknown, unknown, Message, Message> }) {\n roots.add(this.ctx);\n },\n afterEnd(this: { ctx: ProcessCtx<unknown, unknown, Message, Message> }) {\n roots.delete(this.ctx);\n },\n });\n\n Object.defineProperty(rootTrackerPlugin, \"name\", { value: \"rootTracker\" });\n\n return {\n plugin: rootTrackerPlugin,\n async stopAll() {\n for (const root of roots) {\n try {\n root.sendSelf({ type: \"STOP\" });\n } catch {}\n }\n roots.clear();\n },\n };\n}\n"],"mappings":"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"}
|