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.
@@ -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
- resolved(): Promise<MatchResult>;
19
- next(filter: MatchSpec<M>): Promise<MatchResult>;
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;;;UChBzD,WAAA;EACf,EAAA;EACA,MAAM;AAAA;AAAA,UAGS,SAAA,WAAoB,OAAA;EACnC,MAAA,EAAQ,WAAA;EACR,QAAA,EAAU,CAAA;EACV,QAAA,IAAY,OAAA,CAAQ,WAAA;EACpB,IAAA,CAAK,MAAA,EAAQ,SAAA,CAAU,CAAA,IAAK,OAAA,CAAQ,WAAA;EACpC,KAAA,CAAM,MAAA,GAAS,SAAA,CAAU,CAAA;AAAA;AAAA,UAGV,WAAA;EACf,MAAA,EAAQ,WAAA;EACR,OAAA,IAAW,OAAO;AAAA;AAAA,iBAKJ,eAAA,WAA0B,OAAA,EACxC,MAAA,EAAQ,SAAA,CAAU,CAAA,GAClB,IAAA;EAAS,KAAA;AAAA,IACR,SAAA,CAAU,CAAA;AAAA,iBAwEG,iBAAA,IAAqB,WAAW;;;;iBCjGhC,UAAA,CAAW,IAAA,UAAc,QAAkB"}
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"}
@@ -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
- waiters.push(resolve);
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
- matcher = toMatcher(nextFilter);
87
- return resolved();
128
+ next(nextFilter, timeoutMs) {
129
+ setSpec(nextFilter);
130
+ return resolved(timeoutMs);
88
131
  },
89
132
  reset(newFilter) {
90
- if (newFilter) matcher = toMatcher(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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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "posipaki",
4
- "version": "0.25.0",
4
+ "version": "0.26.0",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
7
7
  "files": [