@zhin.js/testing 0.0.0-stage → 1.0.2

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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +115 -2
  3. package/lib/delivery-capture.d.ts +3 -0
  4. package/lib/delivery-capture.d.ts.map +1 -0
  5. package/lib/delivery-capture.js +54 -0
  6. package/lib/delivery-capture.js.map +1 -0
  7. package/lib/fake-clock.d.ts +36 -0
  8. package/lib/fake-clock.d.ts.map +1 -0
  9. package/lib/fake-clock.js +100 -0
  10. package/lib/fake-clock.js.map +1 -0
  11. package/lib/fake-hosts.d.ts +51 -0
  12. package/lib/fake-hosts.d.ts.map +1 -0
  13. package/lib/fake-hosts.js +113 -0
  14. package/lib/fake-hosts.js.map +1 -0
  15. package/lib/fake-plugin.d.ts +83 -0
  16. package/lib/fake-plugin.d.ts.map +1 -0
  17. package/lib/fake-plugin.js +130 -0
  18. package/lib/fake-plugin.js.map +1 -0
  19. package/lib/fake-runners.d.ts +47 -0
  20. package/lib/fake-runners.d.ts.map +1 -0
  21. package/lib/fake-runners.js +60 -0
  22. package/lib/fake-runners.js.map +1 -0
  23. package/lib/index.d.ts +74 -0
  24. package/lib/index.d.ts.map +1 -0
  25. package/lib/index.js +168 -0
  26. package/lib/index.js.map +1 -0
  27. package/lib/partial.d.ts +7 -0
  28. package/lib/partial.d.ts.map +1 -0
  29. package/lib/partial.js +9 -0
  30. package/lib/partial.js.map +1 -0
  31. package/lib/test-module-runtime.d.ts +5 -0
  32. package/lib/test-module-runtime.d.ts.map +1 -0
  33. package/lib/test-module-runtime.js +34 -0
  34. package/lib/test-module-runtime.js.map +1 -0
  35. package/package.json +50 -3
  36. package/src/delivery-capture.ts +48 -0
  37. package/src/fake-clock.ts +116 -0
  38. package/src/fake-hosts.ts +131 -0
  39. package/src/fake-plugin.ts +171 -0
  40. package/src/fake-runners.ts +117 -0
  41. package/src/index.ts +202 -0
  42. package/src/partial.ts +8 -0
  43. package/src/test-module-runtime.ts +38 -0
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "@zhin.js/testing",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.0.2",
4
+ "description": "Real Plugin Runtime integration fixtures with synthetic IM endpoints",
5
+ "type": "module",
6
+ "main": "./lib/index.js",
7
+ "types": "./lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "import": "./lib/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "lib",
16
+ "src",
17
+ "README.md"
18
+ ],
19
+ "dependencies": {
20
+ "@zhin.js/adapter": "1.1.19",
21
+ "@zhin.js/command": "1.1.7",
22
+ "@zhin.js/core": "1.1.44",
23
+ "@zhin.js/feature-kit": "1.1.5",
24
+ "@zhin.js/handler": "1.1.5",
25
+ "@zhin.js/middleware": "1.1.6",
26
+ "@zhin.js/im-contract": "1.1.4",
27
+ "@zhin.js/plugin-runtime": "1.1.14",
28
+ "@zhin.js/runtime": "1.1.7"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^26.1.2",
32
+ "typescript": "^6.0.3",
33
+ "vitest": "^4.1.10"
34
+ },
35
+ "engines": {
36
+ "node": ">=22.19.0"
37
+ },
38
+ "license": "MIT",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/zhinjs/zhin.git",
42
+ "directory": "packages/toolkit/testing"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public",
46
+ "registry": "https://registry.npmjs.org"
47
+ },
48
+ "scripts": {
49
+ "build": "tsc",
50
+ "clean": "rimraf lib",
51
+ "test": "vitest run --root ../../.. packages/toolkit/testing/tests"
52
+ }
6
53
  }
@@ -0,0 +1,48 @@
1
+ import { Stream } from 'node:stream';
2
+
3
+ /** Streams are transport-owned handles: record their identity without draining or freezing them. */
4
+ export function captureDeliveryValue<T>(value: T): T {
5
+ return capture(value, new WeakMap()) as T;
6
+ }
7
+
8
+ function capture(value: unknown, seen: WeakMap<object, unknown>): unknown {
9
+ if (value instanceof Stream || (typeof ReadableStream !== 'undefined' && value instanceof ReadableStream)) return value;
10
+ if (!value || typeof value !== 'object') return structuredClone(value);
11
+ if (seen.has(value)) return seen.get(value);
12
+ if (Buffer.isBuffer(value) || ArrayBuffer.isView(value)) {
13
+ const copy = Buffer.isBuffer(value) ? Buffer.from(value) : structuredClone(value);
14
+ seen.set(value, copy);
15
+ return copy;
16
+ }
17
+ if (Array.isArray(value)) {
18
+ const copy: unknown[] = [];
19
+ seen.set(value, copy);
20
+ for (const item of value) copy.push(capture(item, seen));
21
+ return Object.freeze(copy);
22
+ }
23
+ if (value instanceof Map) {
24
+ const copy = new Map();
25
+ seen.set(value, copy);
26
+ for (const [key, item] of value) copy.set(capture(key, seen), capture(item, seen));
27
+ return Object.freeze(copy);
28
+ }
29
+ if (value instanceof Set) {
30
+ const copy = new Set();
31
+ seen.set(value, copy);
32
+ for (const item of value) copy.add(capture(item, seen));
33
+ return Object.freeze(copy);
34
+ }
35
+ // Native built-ins retain structuredClone semantics. SDK DTO classes use
36
+ // their enumerable data, just as structuredClone does, but may contain streams.
37
+ if (Object.prototype.toString.call(value) !== '[object Object]') {
38
+ const copy = Object.freeze(structuredClone(value));
39
+ seen.set(value, copy);
40
+ return copy;
41
+ }
42
+ const copy: Record<string, unknown> = {};
43
+ seen.set(value, copy);
44
+ for (const [key, item] of Object.entries(value)) Object.defineProperty(copy, key, {
45
+ value: capture(item, seen), enumerable: true, writable: true, configurable: true,
46
+ });
47
+ return Object.freeze(copy);
48
+ }
@@ -0,0 +1,116 @@
1
+ import type { PluginId, ScheduleHost, ScheduleJobRegistration } from '@zhin.js/plugin-runtime';
2
+
3
+ /** Controllable wall clock. It never touches the real `Date` or timers. */
4
+ export class FakeClock {
5
+ #now: number;
6
+ readonly #listeners = new Set<(from: number, to: number) => Promise<void>>();
7
+
8
+ constructor(start: number | Date = Date.UTC(2026, 0, 1, 0, 0, 0)) {
9
+ this.#now = typeof start === 'number' ? start : start.getTime();
10
+ }
11
+
12
+ now(): number { return this.#now; }
13
+ date(): Date { return new Date(this.#now); }
14
+
15
+ /** Jump without firing anything (like setting the system time). */
16
+ set(time: number | Date): void { this.#now = typeof time === 'number' ? time : time.getTime(); }
17
+
18
+ /** Move forward and run every cron job that became due, in time order. */
19
+ async advance(ms: number): Promise<void> {
20
+ if (!(ms >= 0)) throw new RangeError('FakeClock.advance requires a non-negative duration');
21
+ const from = this.#now;
22
+ const to = from + ms;
23
+ for (const listener of [...this.#listeners]) await listener(from, to);
24
+ this.#now = to;
25
+ }
26
+
27
+ /** @internal */
28
+ onAdvance(listener: (from: number, to: number) => Promise<void>): () => void {
29
+ this.#listeners.add(listener);
30
+ return () => { this.#listeners.delete(listener); };
31
+ }
32
+ }
33
+
34
+ type Field = ReadonlySet<number>;
35
+
36
+ function parseField(text: string, min: number, max: number): Field {
37
+ const values = new Set<number>();
38
+ for (const part of text.split(',')) {
39
+ const [range = '', stepText] = part.split('/');
40
+ const step = stepText === undefined ? 1 : Number(stepText);
41
+ if (!Number.isInteger(step) || step < 1) throw new TypeError(`Invalid cron step: ${part}`);
42
+ let lo = min;
43
+ let hi = max;
44
+ if (range !== '*') {
45
+ const [a, b] = range.split('-');
46
+ lo = Number(a);
47
+ hi = b === undefined ? (stepText === undefined ? lo : max) : Number(b);
48
+ }
49
+ if (!Number.isInteger(lo) || !Number.isInteger(hi) || lo < min || hi > max || lo > hi) {
50
+ throw new TypeError(`Invalid cron field: ${text}`);
51
+ }
52
+ for (let v = lo; v <= hi; v += step) values.add(v);
53
+ }
54
+ return values;
55
+ }
56
+
57
+ /** Six-field cron (`sec min hour day month weekday`, Sunday = 0) evaluated in local time. */
58
+ export function parseCron(cron: string): (date: Date) => boolean {
59
+ const parts = cron.trim().split(/\s+/);
60
+ if (parts.length !== 6) throw new TypeError(`Cron must have six fields: ${cron}`);
61
+ const sec = parseField(parts[0]!, 0, 59);
62
+ const min = parseField(parts[1]!, 0, 59);
63
+ const hour = parseField(parts[2]!, 0, 23);
64
+ const day = parseField(parts[3]!, 1, 31);
65
+ const month = parseField(parts[4]!, 1, 12);
66
+ const week = parseField(parts[5]!, 0, 7);
67
+ return (d) => sec.has(d.getSeconds()) && min.has(d.getMinutes()) && hour.has(d.getHours())
68
+ && day.has(d.getDate()) && month.has(d.getMonth() + 1)
69
+ && (week.has(d.getDay()) || (d.getDay() === 0 && week.has(7)));
70
+ }
71
+
72
+ export interface FakeScheduledJob {
73
+ readonly id: string;
74
+ readonly cron: string;
75
+ readonly description?: string;
76
+ readonly owner?: PluginId;
77
+ }
78
+
79
+ interface Entry extends ScheduleJobRegistration { readonly matches: (d: Date) => boolean }
80
+
81
+ /**
82
+ * In-memory process Schedule Host driven by a {@link FakeClock}. The harness wraps it with the real
83
+ * `PluginScheduleHost` to get the exact facade plugins see.
84
+ */
85
+ export class FakeScheduleRootHost implements ScheduleHost {
86
+ readonly #jobs = new Set<Entry>();
87
+
88
+ constructor(readonly clock: FakeClock) {
89
+ clock.onAdvance(async (from, to) => {
90
+ for (let t = Math.floor(from / 1000) * 1000 + 1000; t <= to; t += 1000) {
91
+ const date = new Date(t);
92
+ for (const job of [...this.#jobs]) {
93
+ if (this.#jobs.has(job) && job.isActive?.() !== false && job.matches(date)) await job.execute();
94
+ }
95
+ }
96
+ });
97
+ }
98
+
99
+ register(job: ScheduleJobRegistration): () => void {
100
+ const entry: Entry = Object.freeze({ ...job, matches: parseCron(job.cron) });
101
+ this.#jobs.add(entry);
102
+ return () => { this.#jobs.delete(entry); };
103
+ }
104
+
105
+ list(): readonly FakeScheduledJob[] {
106
+ return [...this.#jobs].map(({ id, cron, description, owner }) =>
107
+ ({ id, cron, ...(description === undefined ? {} : { description }), ...(owner === undefined ? {} : { owner }) }));
108
+ }
109
+
110
+ /** Run a registered job now, by its qualified id; rejects if it is not registered. */
111
+ async trigger(id: string): Promise<void> {
112
+ const job = [...this.#jobs].find(item => item.id === id);
113
+ if (!job) throw new Error(`No scheduled job: ${id}`);
114
+ await job.execute();
115
+ }
116
+ }
@@ -0,0 +1,131 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type {
3
+ DatabaseHost, DatabaseHostModel, DatabaseHostSelection, DeliveryTarget, JsonValue,
4
+ OutboundEditInput, OutboundReactionInput, OutboundRecallInput, OutboundRootHost, OutboundSendInput,
5
+ OutboundTypingInput, PersistentStatePort, PluginId, StateRecord, StateScope,
6
+ } from '@zhin.js/plugin-runtime';
7
+
8
+ type Row = Record<string, unknown>;
9
+ const matches = (row: Row, query: Row): boolean => Object.entries(query).every(([k, v]) => row[k] === v);
10
+
11
+ class MemoryModel implements DatabaseHostModel {
12
+ readonly rows: Row[] = [];
13
+
14
+ select<Fields extends string[]>(...fields: Fields): DatabaseHostSelection {
15
+ let query: Row = {};
16
+ let order: { field: string; direction: 'ASC' | 'DESC' } | undefined;
17
+ let max = Infinity;
18
+ const run = (): Row[] => {
19
+ let out = this.rows.filter(row => matches(row, query));
20
+ if (order) {
21
+ const { field, direction } = order;
22
+ out = [...out].sort((a, b) => ((a[field] as never) < (b[field] as never) ? -1
23
+ : (a[field] as never) > (b[field] as never) ? 1 : 0) * (direction === 'DESC' ? -1 : 1));
24
+ }
25
+ return out.slice(0, max).map(row => fields.length === 0 ? { ...row }
26
+ : Object.fromEntries(fields.map(f => [f, row[f]])));
27
+ };
28
+ const selection: DatabaseHostSelection = {
29
+ where: (q) => { query = { ...query, ...q }; return selection; },
30
+ orderBy: (field, direction = 'ASC') => { order = { field, direction }; return selection; },
31
+ limit: (count) => { max = count; return selection; },
32
+ then: (ok, bad) => Promise.resolve().then(run).then(ok, bad),
33
+ };
34
+ return selection;
35
+ }
36
+
37
+ async insert(row: Row): Promise<unknown> { this.rows.push({ ...row }); return undefined; }
38
+ delete(): { where(query: Row): Promise<unknown> } {
39
+ return { where: async (query: Row) => {
40
+ for (let i = this.rows.length - 1; i >= 0; i--) if (matches(this.rows[i]!, query)) this.rows.splice(i, 1);
41
+ } };
42
+ }
43
+ update(patch: Row): { where(query: Row): Promise<unknown> } {
44
+ return { where: async (query: Row) => { for (const row of this.rows) if (matches(row, query)) Object.assign(row, patch); } };
45
+ }
46
+ async count(where: Row = {}): Promise<number> { return this.rows.filter(row => matches(row, where)).length; }
47
+ }
48
+
49
+ class MemoryState implements PersistentStatePort {
50
+ readonly #entries = new Map<string, StateRecord>();
51
+ static key(owner: PluginId, scope: StateScope, key: string): string { return JSON.stringify([owner, scope, key]); }
52
+
53
+ async read(owner: PluginId, scope: StateScope, key: string): Promise<StateRecord | undefined> {
54
+ return this.#entries.get(MemoryState.key(owner, scope, key));
55
+ }
56
+ async compareExchange(owner: PluginId, scope: StateScope, key: string, revision: string | undefined,
57
+ value: JsonValue | undefined, expiresAt: number | null): Promise<boolean> {
58
+ const id = MemoryState.key(owner, scope, key);
59
+ if (this.#entries.get(id)?.revision !== revision) return false;
60
+ if (value === undefined) this.#entries.delete(id);
61
+ else this.#entries.set(id, { revision: randomUUID(), value: JSON.parse(JSON.stringify(value)) as JsonValue, expiresAt });
62
+ return true;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Process-wide in-memory Database Host. Tables appear on `define()` (plugins define in `setup`);
68
+ * `where` is equality-only, there are no SQL semantics. `state` is a real compare-exchange port, so
69
+ * `PluginStateStore` behaves as in production (TTL uses the real `Date.now()`).
70
+ */
71
+ export class FakeDatabaseRootHost implements DatabaseHost {
72
+ readonly dialect = 'memory';
73
+ started = true;
74
+ readonly state: PersistentStatePort = new MemoryState();
75
+ readonly #models = new Map<string, MemoryModel>();
76
+ readonly models = { get: (name: string): DatabaseHostModel | undefined => this.#models.get(name) };
77
+
78
+ define(name: string, _definition: Record<string, unknown>): void {
79
+ if (!this.#models.has(name)) this.#models.set(name, new MemoryModel());
80
+ }
81
+ tables(): readonly string[] { return [...this.#models.keys()]; }
82
+ getRawDatabase(): undefined { return undefined; }
83
+ async start(): Promise<void> { this.started = true; }
84
+ async stop(): Promise<void> { this.started = false; }
85
+ /** Rows by physical (owner-qualified) table name. */
86
+ rowsOf(physical: string): readonly Row[] { return this.#models.get(physical)?.rows ?? []; }
87
+ }
88
+
89
+ export interface FakeOutboundCall {
90
+ readonly kind: 'send' | 'sendTarget' | 'addReaction' | 'removeReaction' | 'recall' | 'edit' | 'typing';
91
+ readonly input: unknown;
92
+ /** Present for `sendTarget`: the sending plugin, as bound by the real facade. */
93
+ readonly requester?: PluginId;
94
+ readonly id: string | null;
95
+ }
96
+
97
+ /**
98
+ * Records proactive sends. It stands in for the Root outbound port, so the real `PluginOutboundHost`
99
+ * facade (owner binding) still wraps it. It does NOT run the renderer/middleware chain; for that use
100
+ * `PluginTestKit` with a synthetic endpoint.
101
+ */
102
+ export class FakeOutboundRootHost implements OutboundRootHost {
103
+ readonly calls: FakeOutboundCall[] = [];
104
+ #failure: unknown;
105
+ #nextId = 1;
106
+
107
+ /** Make the next outbound operation reject once. */
108
+ failNext(error: unknown = new Error('fake outbound failure')): void { this.#failure = error; }
109
+
110
+ #record(kind: FakeOutboundCall['kind'], input: unknown, requester?: PluginId, hasId = true): Promise<string | null> {
111
+ if (this.#failure !== undefined) {
112
+ const error = this.#failure;
113
+ this.#failure = undefined;
114
+ return Promise.reject(error);
115
+ }
116
+ const id = hasId ? `fake-${this.#nextId++}` : null;
117
+ this.calls.push(Object.freeze({ kind, input, ...(requester === undefined ? {} : { requester }), id }));
118
+ return Promise.resolve(id);
119
+ }
120
+
121
+ send(input: OutboundSendInput): Promise<string | null> { return this.#record('send', input); }
122
+ sendTarget(target: DeliveryTarget, content: unknown, requester: PluginId): Promise<string | null> {
123
+ return this.#record('sendTarget', { target, content }, requester);
124
+ }
125
+ addReaction(input: OutboundReactionInput): Promise<string | null> { return this.#record('addReaction', input); }
126
+ async removeReaction(input: unknown): Promise<void> { await this.#record('removeReaction', input, undefined, false); }
127
+ async recall(input: OutboundRecallInput): Promise<void> { await this.#record('recall', input, undefined, false); }
128
+ edit(input: OutboundEditInput): Promise<string | null> { return this.#record('edit', input); }
129
+ async typing(input: OutboundTypingInput): Promise<void> { await this.#record('typing', input, undefined, false); }
130
+ runWithView<T>(operation: () => Promise<T>): Promise<T> { return operation(); }
131
+ }
@@ -0,0 +1,171 @@
1
+ import {
2
+ DisposeStack, GenerationHandoffStack, PluginDatabaseHost, PluginOutboundHost, PluginScheduleHost,
3
+ Scope, databaseHostToken, outboundHostToken, qualifyPluginResourceName, rootPluginId, scheduleHostToken,
4
+ type Dispose, type FeatureId, type PluginDefinition, type PluginId, type PluginNodeSnapshot,
5
+ type PluginSetupContext, type Token,
6
+ } from '@zhin.js/plugin-runtime';
7
+ import type { CapabilityContext } from '@zhin.js/feature-kit';
8
+ import { FakeClock, FakeScheduleRootHost } from './fake-clock.js';
9
+ import { FakeDatabaseRootHost, FakeOutboundRootHost, type FakeOutboundCall } from './fake-hosts.js';
10
+
11
+ /** A typed `token -> value` binding; create with {@link fakeResource}. */
12
+ export interface FakeResource { readonly token: Token<unknown>; readonly value: unknown }
13
+
14
+ /** Bind a value to a token with the token's own type, so tests need no cast. */
15
+ export function fakeResource<T>(token: Token<T>, value: T): FakeResource {
16
+ return { token: token as Token<unknown>, value };
17
+ }
18
+
19
+ export interface FakePluginOptions<TConfig = unknown> {
20
+ /** Plugin name (also the instance key). Default `test-plugin`. */
21
+ readonly name?: string;
22
+ /** Value returned by `context.config.get()`. Default `{}`. */
23
+ readonly config?: TConfig;
24
+ /** Extra resources to `provide()` on the Scope, e.g. a hand-written fake for a custom token. */
25
+ readonly resources?: readonly FakeResource[];
26
+ /** Install the fake Database / Schedule / Outbound hosts (default: all `true`). */
27
+ readonly hosts?: { readonly database?: boolean; readonly schedule?: boolean; readonly outbound?: boolean };
28
+ /** Start time or an existing clock shared by several fakes. */
29
+ readonly clock?: FakeClock | number | Date;
30
+ /** Feature projections returned by `capabilityContext().project()`. */
31
+ readonly projections?: ReadonlyMap<FeatureId, unknown>;
32
+ readonly generation?: number;
33
+ }
34
+
35
+ /** A capability registered through `context.addFeature` / `context.addCommand` / `context.addXxx`. */
36
+ export interface FakeRegisteredFeature {
37
+ readonly kind: string;
38
+ readonly localName: string;
39
+ readonly definition: unknown;
40
+ }
41
+
42
+ /**
43
+ * Typed fake of one plugin's runtime surface. Built from the real `Scope`, `DisposeStack`,
44
+ * `GenerationHandoffStack` and the real owner-bound `PluginDatabaseHost` / `PluginScheduleHost` /
45
+ * `PluginOutboundHost` facades, over in-memory process hosts. No `as never` needed in tests.
46
+ */
47
+ export class FakePlugin<TConfig = unknown> {
48
+ readonly id: PluginId = rootPluginId();
49
+ readonly name: string;
50
+ readonly config: TConfig;
51
+ readonly resources: Scope;
52
+ readonly lifecycle = new DisposeStack();
53
+ readonly handoff = new GenerationHandoffStack();
54
+ readonly clock: FakeClock;
55
+ readonly generation: number;
56
+ readonly projections: ReadonlyMap<FeatureId, unknown>;
57
+ /** Features registered by `setup()` through `addFeature` / `addCommand` / `addMiddleware` ... */
58
+ readonly features: FakeRegisteredFeature[] = [];
59
+ /** Process-level fakes behind the facades. */
60
+ readonly databaseHost = new FakeDatabaseRootHost();
61
+ readonly outboundHost = new FakeOutboundRootHost();
62
+ readonly scheduleHost: FakeScheduleRootHost;
63
+ /** Facades exactly as `resources.use(token)` returns them. */
64
+ readonly database: PluginDatabaseHost;
65
+ readonly schedule: PluginScheduleHost;
66
+ readonly outbound: PluginOutboundHost;
67
+ readonly #abort = new AbortController();
68
+ readonly #context: PluginSetupContext<TConfig>;
69
+
70
+ constructor(options: FakePluginOptions<TConfig> = {}) {
71
+ this.name = options.name ?? 'test-plugin';
72
+ this.config = (options.config ?? {}) as TConfig;
73
+ this.generation = options.generation ?? 1;
74
+ this.projections = options.projections ?? new Map();
75
+ this.clock = options.clock instanceof FakeClock ? options.clock : new FakeClock(options.clock);
76
+ this.scheduleHost = new FakeScheduleRootHost(this.clock);
77
+ this.resources = new Scope(this.id, undefined, this.lifecycle);
78
+ this.database = new PluginDatabaseHost(this.id, this.databaseHost);
79
+ this.schedule = new PluginScheduleHost(this.id, this.scheduleHost);
80
+ this.outbound = new PluginOutboundHost(this.id, this.outboundHost);
81
+ const hosts = options.hosts ?? {};
82
+ if (hosts.database !== false) this.resources.provide(databaseHostToken, this.database);
83
+ if (hosts.schedule !== false) this.resources.provide(scheduleHostToken, this.schedule);
84
+ if (hosts.outbound !== false) this.resources.provide(outboundHostToken, this.outbound);
85
+ for (const { token, value } of options.resources ?? []) this.resources.provide(token, value);
86
+ this.#context = this.#createSetupContext();
87
+ }
88
+
89
+ /** The `context` argument `setup()` receives. */
90
+ get context(): PluginSetupContext<TConfig> { return this.#context; }
91
+ get signal(): AbortSignal { return this.#abort.signal; }
92
+
93
+ /** Messages recorded by the fake outbound host (every kind of call). */
94
+ get sent(): readonly FakeOutboundCall[] { return this.outboundHost.calls; }
95
+
96
+ /** Rows of a table the plugin created with `database.define(name, ...)`. */
97
+ rows(name: string): readonly Record<string, unknown>[] {
98
+ return this.databaseHost.rowsOf(qualifyPluginResourceName(this.id, name));
99
+ }
100
+
101
+ /** Run `definition.setup()` against this fake; a returned disposer joins `lifecycle`. */
102
+ async setup(definition: PluginDefinition<TConfig>): Promise<void> {
103
+ const dispose: void | Dispose = await definition.setup?.(this.#context);
104
+ if (dispose) this.lifecycle.add(dispose);
105
+ }
106
+
107
+ /** Tokens declared in `requires` that this fake did not provide. */
108
+ missingRequirements(definition: PluginDefinition<TConfig>): readonly Token<unknown>[] {
109
+ return (definition.requires ?? []).filter(token => !this.resources.has(token));
110
+ }
111
+
112
+ /** A `CapabilityContext` for running commands / middleware / handlers against this fake. */
113
+ capabilityContext(): CapabilityContext<TConfig> {
114
+ const owner: PluginNodeSnapshot = Object.freeze({
115
+ id: this.id, instanceKey: this.name, packageName: this.name, packageRoot: '/fake', children: [],
116
+ });
117
+ return Object.freeze({
118
+ signal: this.signal, owner, generation: this.generation,
119
+ config: this.config as Readonly<TConfig>,
120
+ use: <T>(token: Token<T>): T => this.resources.use(token),
121
+ project: <T>(feature: FeatureId): T => {
122
+ if (!this.projections.has(feature)) throw new Error(`Missing Feature projection ${feature}`);
123
+ return this.projections.get(feature) as T;
124
+ },
125
+ });
126
+ }
127
+
128
+ /** Abort `signal` and run every disposer (reverse order). */
129
+ async dispose(): Promise<void> {
130
+ this.#abort.abort();
131
+ await this.lifecycle.dispose();
132
+ }
133
+
134
+ #createSetupContext(): PluginSetupContext<TConfig> {
135
+ const base = {
136
+ signal: this.signal,
137
+ plugin: Object.freeze({ id: this.id, instanceKey: this.name, root: this.id, role: 'root' as const }),
138
+ config: Object.freeze({ get: () => this.config as Readonly<TConfig> }),
139
+ resources: this.resources,
140
+ lifecycle: this.lifecycle,
141
+ handoff: this.handoff,
142
+ addFeature: (feature: FeatureId | string, localName: string, definition: unknown) => {
143
+ this.features.push({ kind: String(feature), localName, definition });
144
+ },
145
+ };
146
+ // Feature packages augment the setup context with `addCommand` / `addMiddleware` / ...; record them all.
147
+ return new Proxy(base, {
148
+ get: (target, key, receiver) => {
149
+ if (Reflect.has(target, key) || typeof key !== 'string' || !/^add[A-Z]/.test(key)) {
150
+ return Reflect.get(target, key, receiver);
151
+ }
152
+ const kind = key.slice(3).toLowerCase();
153
+ return (localName: string, definition: unknown) => { this.features.push({ kind, localName, definition }); };
154
+ },
155
+ }) as unknown as PluginSetupContext<TConfig>;
156
+ }
157
+ }
158
+
159
+ export function createFakePlugin<TConfig = unknown>(options?: FakePluginOptions<TConfig>): FakePlugin<TConfig> {
160
+ return new FakePlugin(options);
161
+ }
162
+
163
+ /** Create a fake and run `definition.setup()` in one step. Call `dispose()` when done. */
164
+ export async function runPluginSetup<TConfig = unknown>(
165
+ definition: PluginDefinition<TConfig>,
166
+ options?: Omit<FakePluginOptions<TConfig>, 'name'>,
167
+ ): Promise<FakePlugin<TConfig>> {
168
+ const fake = new FakePlugin<TConfig>({ ...options, name: definition.name });
169
+ await fake.setup(definition);
170
+ return fake;
171
+ }
@@ -0,0 +1,117 @@
1
+ import type {
2
+ CommandContext, CommandDefinition, CommandMessage, CommandParameterValue,
3
+ } from '@zhin.js/command';
4
+ import type { HandlerContext, HandlerDefinition } from '@zhin.js/handler';
5
+ import type {
6
+ MiddlewareContext, MiddlewareDefinition, MiddlewareNext,
7
+ } from '@zhin.js/middleware';
8
+ import type { FakePlugin } from './fake-plugin.js';
9
+
10
+ /** Inbound message with recorded replies. */
11
+ export interface FakeMessage extends CommandMessage {
12
+ /** Everything passed to `$reply`, in order. */
13
+ readonly replies: unknown[];
14
+ }
15
+
16
+ export interface FakeMessageInit {
17
+ readonly content?: string;
18
+ readonly sender?: { readonly id: string; readonly name?: string; readonly roles?: readonly string[] };
19
+ readonly conversation?: Partial<CommandMessage['conversation']>;
20
+ readonly id?: string;
21
+ readonly metadata?: Readonly<Record<string, unknown>>;
22
+ readonly $client?: unknown;
23
+ }
24
+
25
+ export function createFakeMessage(init: FakeMessageInit = {}): FakeMessage {
26
+ const replies: unknown[] = [];
27
+ const conversation: CommandMessage['conversation'] = {
28
+ endpoint: { id: 'test', adapter: 'test' }, kind: 'group', id: 'test-group', ...init.conversation,
29
+ };
30
+ return {
31
+ conversation, content: init.content ?? '',
32
+ sender: init.sender ?? { id: 'test-user', name: 'Tester', roles: [] },
33
+ ...(init.id === undefined ? {} : { id: init.id }),
34
+ ...(init.metadata === undefined ? {} : { metadata: init.metadata }),
35
+ ...(init.$client === undefined ? {} : { $client: init.$client }),
36
+ replies,
37
+ async $reply(content: unknown) { replies.push(content); return `reply-${replies.length}`; },
38
+ };
39
+ }
40
+
41
+ export interface RunCommandOptions<TInput extends CommandMessage = FakeMessage> {
42
+ readonly input?: TInput;
43
+ readonly args?: readonly string[];
44
+ readonly params?: Readonly<Record<string, CommandParameterValue>>;
45
+ readonly options?: Readonly<Record<string, CommandParameterValue>>;
46
+ readonly segments?: CommandContext['segments'];
47
+ readonly interaction?: CommandContext['interaction'];
48
+ }
49
+
50
+ /** Build the context a command receives from the fake plugin; usable for direct `execute()` calls. */
51
+ export function createCommandContext<TConfig, TInput extends CommandMessage = FakeMessage>(
52
+ fake: FakePlugin<TConfig>,
53
+ options: RunCommandOptions<TInput> = {},
54
+ ): CommandContext<TConfig, TInput, string | undefined> {
55
+ const input = (options.input ?? createFakeMessage()) as TInput;
56
+ const conversation = input.conversation;
57
+ const sender = input.sender;
58
+ return {
59
+ ...fake.capabilityContext(),
60
+ args: options.args ?? [],
61
+ params: options.params ?? {},
62
+ options: options.options ?? {},
63
+ segments: options.segments ?? [],
64
+ input,
65
+ $client: input.$client as never,
66
+ interaction: options.interaction,
67
+ adapter: conversation.endpoint.adapter,
68
+ endpoint: conversation.endpoint.id,
69
+ scene: input.scene ?? { id: conversation.id, type: conversation.kind },
70
+ ...(sender ? { sender: { id: sender.id, name: sender.name, role: sender.roles ?? [] } } : {}),
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Execute a command definition against the fake. This calls `execute()` directly: it does NOT run
76
+ * route matching, parameter parsing, `permit`, or admission. Use `PluginTestKit` for those.
77
+ */
78
+ export async function runCommand<TConfig, TResult, TInput extends CommandMessage = FakeMessage>(
79
+ fake: FakePlugin<TConfig>,
80
+ definition: CommandDefinition<TConfig, TResult, TInput, string | undefined>,
81
+ options?: RunCommandOptions<TInput>,
82
+ ): Promise<TResult> {
83
+ return definition.execute(createCommandContext(fake, options));
84
+ }
85
+
86
+ export interface RunMiddlewareResult {
87
+ /** `true` when the middleware called `next()`. */
88
+ readonly continued: boolean;
89
+ readonly result: unknown;
90
+ }
91
+
92
+ /** Execute an inbound or outbound middleware's `handle()`; `next` records whether it was called. */
93
+ export async function runMiddleware<TConfig, TInput = FakeMessage>(
94
+ fake: FakePlugin<TConfig>,
95
+ definition: MiddlewareDefinition<TInput, TConfig, string | undefined>,
96
+ input: TInput = createFakeMessage() as TInput,
97
+ ): Promise<RunMiddlewareResult> {
98
+ let continued = false;
99
+ const context: MiddlewareContext<TInput, TConfig, string | undefined> = {
100
+ ...fake.capabilityContext(),
101
+ input,
102
+ $client: (input as { $client?: unknown } | undefined)?.$client as never,
103
+ };
104
+ const next = (async () => { continued = true; return undefined; }) as unknown as MiddlewareNext;
105
+ const result = await definition.handle(context, next as never);
106
+ return { continued, result };
107
+ }
108
+
109
+ /** Execute a handler's `handle()` with `this` bound to a capability context. */
110
+ export async function runHandler<TConfig>(
111
+ fake: FakePlugin<TConfig>,
112
+ definition: HandlerDefinition,
113
+ ...args: unknown[]
114
+ ): Promise<void> {
115
+ const context: HandlerContext<TConfig> = fake.capabilityContext();
116
+ await definition.handle.call(context as HandlerContext, ...args);
117
+ }