@zhin.js/testing 0.0.0-stage → 1.0.1

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/src/index.ts ADDED
@@ -0,0 +1,202 @@
1
+ import { realpath } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import { defineAdapter, type AdapterDefinition, type AdapterSegmentPolicy,
4
+ type EndpointIdentity, type EndpointEventSink, type EndpointIncomingMessage, type EndpointSendRequest } from '@zhin.js/adapter';
5
+ import { ImRuntime, type ConversationAddress, type MessageDispatchResult,
6
+ type SendContent } from '@zhin.js/core/runtime';
7
+ import { type ConversationRef, type DeliveryReceipt } from '@zhin.js/im-contract';
8
+ import { rootPluginId, type ConfigPatch, type PluginId, type RuntimeConfigDocument } from '@zhin.js/plugin-runtime';
9
+ import { NativeDevelopmentModuleRuntime, NodePackageResolver, RootRuntime,
10
+ type ModuleRuntime, type RootResourceInstaller } from '@zhin.js/runtime';
11
+ import { captureDeliveryValue } from './delivery-capture.js';
12
+ import { withTestEndpoints } from './test-module-runtime.js';
13
+
14
+ export { FakeClock, FakeScheduleRootHost, parseCron, type FakeScheduledJob } from './fake-clock.js';
15
+ export { FakeDatabaseRootHost, FakeOutboundRootHost, type FakeOutboundCall } from './fake-hosts.js';
16
+ export { FakePlugin, createFakePlugin, runPluginSetup, fakeResource,
17
+ type FakePluginOptions, type FakeResource, type FakeRegisteredFeature } from './fake-plugin.js';
18
+ export { fakeOf } from './partial.js';
19
+ export { createFakeMessage, createCommandContext, runCommand, runMiddleware, runHandler,
20
+ type FakeMessage, type FakeMessageInit, type RunCommandOptions, type RunMiddlewareResult } from './fake-runners.js';
21
+
22
+ export interface PluginTestKitOptions {
23
+ /** A real, disposable test app with its own package.json, plugin entry, dependencies and mounts. */
24
+ readonly projectRoot: string;
25
+ readonly config?: RuntimeConfigDocument;
26
+ /** Adapter names reserved for synthetic transports, default: ['test']. */
27
+ readonly endpoints?: readonly string[];
28
+ /** Default endpoint renders HTML and buttons as text; override to verify negotiated capabilities. */
29
+ readonly segments?: AdapterSegmentPolicy;
30
+ readonly commandPrefix?: string;
31
+ /** Supply your test runner's module adapter if it owns ESM loading. Native Node is the default. */
32
+ readonly modules?: ModuleRuntime;
33
+ readonly installResources?: RootResourceInstaller;
34
+ }
35
+
36
+ /** A terminal send attempt, after the real renderer, policy negotiation and outbound middleware. */
37
+ export interface TestDelivery {
38
+ readonly endpoint: string;
39
+ readonly conversation: ConversationRef;
40
+ readonly payload: unknown;
41
+ readonly id: string;
42
+ }
43
+
44
+ export type TestReceiveInput = EndpointIncomingMessage & { readonly endpoint?: string };
45
+
46
+ export type TestReloadResult =
47
+ | Readonly<{ status: 'reloaded' | 'unchanged'; generation: number }>
48
+ | Readonly<{ status: 'failed'; generation: number; error: unknown }>
49
+ | Readonly<{ status: 'restart-required'; generation: number; reasons: readonly string[] }>;
50
+
51
+ /**
52
+ * Owns a test IM host and synthetic transports. Every operation enters the real Root Runtime.
53
+ * It does not install dependencies or replace providers. Discovered plugins still execute normally;
54
+ * use disposable applications without real credentials or adapters. This is not a sandbox.
55
+ */
56
+ export class PluginTestKit {
57
+ readonly #im: ImRuntime;
58
+ readonly #root: RootRuntime;
59
+ readonly #sinks = new Map<string, EndpointEventSink>();
60
+ readonly #identities = new Map<string, EndpointIdentity>();
61
+ #nextDelivery = 1;
62
+ readonly #attempts: TestDelivery[] = [];
63
+ readonly #defaultEndpoint: string;
64
+ readonly #hmr: ReturnType<RootRuntime['createHmrCoordinator']>;
65
+ #restartReasons?: readonly string[];
66
+ #started = false;
67
+ #closed = false;
68
+ #startResult?: Promise<number>;
69
+ #closeResult?: Promise<void>;
70
+
71
+ private constructor(options: PluginTestKitOptions, projectRoot: string, rootEntry: string) {
72
+ this.#im = new ImRuntime({ commandPrefix: options.commandPrefix });
73
+ const names = options.endpoints ?? ['test'];
74
+ if (!names.length || new Set(names).size !== names.length
75
+ || names.some(name => !/^[a-z][a-z0-9-]*$/.test(name))) {
76
+ throw new TypeError('Test endpoints must have unique lowercase names');
77
+ }
78
+ this.#defaultEndpoint = names[0]!;
79
+ const definitions = new Map<string, AdapterDefinition>(names.map(name => [name, defineAdapter({
80
+ capabilities: ['inbound', 'outbound'],
81
+ segments: options.segments ?? { html: 'text', interactive: 'text', markdown: 'text' },
82
+ create: () => ({
83
+ client: Object.freeze({ synthetic: true }),
84
+ connect: ({ events, identity }) => {
85
+ this.#identities.set(name, identity);
86
+ this.#sinks.set(name, events);
87
+ return () => { if (this.#sinks.get(name) === events) { this.#sinks.delete(name); this.#identities.delete(name); } };
88
+ },
89
+ send: (request: EndpointSendRequest) => {
90
+ const id = `test-${this.#nextDelivery++}`;
91
+ this.#attempts.push(Object.freeze({ endpoint: name, id,
92
+ conversation: captureDeliveryValue(request.conversation),
93
+ payload: captureDeliveryValue(request.payload),
94
+ }));
95
+ return id;
96
+ },
97
+ }),
98
+ })]));
99
+ const modules = options.modules ?? new NativeDevelopmentModuleRuntime({ projectRoot, watch: false });
100
+ this.#root = new RootRuntime({ projectRoot,
101
+ modules: withTestEndpoints(modules, rootEntry, definitions),
102
+ environment: { name: 'test', mode: 'test', platform: 'node' },
103
+ config: options.config,
104
+ installResources: async context => {
105
+ this.#im.install(context.resources);
106
+ await options.installResources?.(context);
107
+ },
108
+ });
109
+ this.#im.attach(this.#root.snapshots);
110
+ this.#hmr = this.#root.createHmrCoordinator({
111
+ onRestartRequired: plan => { this.#restartReasons = plan.reasons; },
112
+ onError: () => undefined,
113
+ });
114
+ }
115
+
116
+ static async create(options: PluginTestKitOptions): Promise<PluginTestKit> {
117
+ const projectRoot = await realpath(resolve(options.projectRoot));
118
+ const resolver = await NodePackageResolver.create(projectRoot);
119
+ const root = await resolver.root(projectRoot);
120
+ if (root.packageJson.zhin.type !== 'plugin' || root.packageJson.zhin.runtime === 'isolated') {
121
+ throw new TypeError('Test application must be a trusted Plugin root');
122
+ }
123
+ return new PluginTestKit(options, projectRoot, resolve(projectRoot, root.packageJson.zhin.entry));
124
+ }
125
+
126
+ /** The same PermissionHost consumed by real command admission. */
127
+ get permissions(): ImRuntime['permissionHost'] { return this.#im.permissionHost; }
128
+ get generation(): number { return this.#root.snapshot.generation; }
129
+ get deliveries(): readonly TestDelivery[] { return Object.freeze(this.#attempts.map(item => captureDeliveryValue(item))); }
130
+ clearDeliveries(): void { this.#attempts.splice(0); }
131
+
132
+ start(): Promise<number> {
133
+ if (this.#closed) return Promise.reject(new Error('Plugin test kit is closed'));
134
+ if (this.#startResult) return this.#startResult;
135
+ this.#startResult = this.#root.start().then(snapshot => {
136
+ this.#started = true;
137
+ return snapshot.generation;
138
+ });
139
+ return this.#startResult;
140
+ }
141
+
142
+ async receive(input: TestReceiveInput): Promise<MessageDispatchResult | undefined> {
143
+ this.#assertStarted();
144
+ const { endpoint, ...message } = input;
145
+ return await this.#sink(endpoint).message(message) as MessageDispatchResult | undefined;
146
+ }
147
+
148
+ async emit(name: string, payload: unknown, endpoint?: string): Promise<unknown> {
149
+ this.#assertStarted();
150
+ return this.#sink(endpoint).emit(name, payload);
151
+ }
152
+
153
+ async send(input: { readonly conversation: ConversationAddress; readonly content: SendContent;
154
+ readonly requester?: PluginId; readonly endpoint?: string }): Promise<DeliveryReceipt> {
155
+ this.#assertStarted();
156
+ const name = input.endpoint ?? this.#defaultEndpoint;
157
+ this.#sink(name);
158
+ const identity = this.#identities.get(name)!;
159
+ return this.#im.send({ conversation: { ...input.conversation, endpoint: {
160
+ id: String(identity.id), adapter: identity.adapter,
161
+ } }, content: input.content, requester: input.requester ?? rootPluginId() });
162
+ }
163
+
164
+ /** Call after editing a file in your test app; returns the real HMR transaction outcome. */
165
+ async reloadSource(source: string): Promise<TestReloadResult> {
166
+ this.#assertStarted();
167
+ const previous = this.generation;
168
+ this.#restartReasons = undefined;
169
+ try {
170
+ await this.#hmr.enqueue(source);
171
+ if (this.#restartReasons) return Object.freeze({ status: 'restart-required',
172
+ generation: this.generation, reasons: Object.freeze([...this.#restartReasons]) });
173
+ return Object.freeze({ status: this.generation === previous ? 'unchanged' : 'reloaded', generation: this.generation });
174
+ } catch (error) {
175
+ return Object.freeze({ status: 'failed', generation: this.generation, error });
176
+ }
177
+ }
178
+
179
+ async patchConfig(patches: readonly ConfigPatch[]): Promise<number> {
180
+ this.#assertStarted();
181
+ return (await this.#root.patchConfig(patches)).generation;
182
+ }
183
+
184
+ close(): Promise<void> {
185
+ if (this.#closeResult) return this.#closeResult;
186
+ this.#closed = true;
187
+ this.#closeResult = (async () => {
188
+ try { await this.#hmr.stop(); }
189
+ finally { await this.#root.stop(); }
190
+ })();
191
+ return this.#closeResult;
192
+ }
193
+
194
+ #assertStarted(): void {
195
+ if (this.#closed || !this.#started) throw new Error('Plugin test kit is not running');
196
+ }
197
+ #sink(endpoint = this.#defaultEndpoint): EndpointEventSink {
198
+ const sink = this.#sinks.get(endpoint);
199
+ if (!sink) throw new Error(`Unknown test endpoint: ${endpoint}`);
200
+ return sink;
201
+ }
202
+ }
package/src/partial.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Typed partial fake: the one deliberate cast lives here so tests do not need `as never`.
3
+ * Only the members you list exist; touching any other member of a real `T` is your test's bug.
4
+ * Prefer a real or kit-provided fake when one exists; use this for narrow collaborators and payloads.
5
+ */
6
+ export function fakeOf<T extends object>(partial: Partial<T>): T {
7
+ return partial as T;
8
+ }
@@ -0,0 +1,38 @@
1
+ import { resolve } from 'node:path';
2
+ import { definePlugin, type PluginDefinition } from '@zhin.js/plugin-runtime';
3
+ import type { AdapterDefinition } from '@zhin.js/adapter';
4
+ import type { ModuleRuntime } from '@zhin.js/runtime';
5
+
6
+ /** Only the test application's setup is decorated; discovery and module caching stay native. */
7
+ export function withTestEndpoints(
8
+ delegate: ModuleRuntime,
9
+ rootEntry: string,
10
+ endpoints: ReadonlyMap<string, AdapterDefinition>,
11
+ ): ModuleRuntime {
12
+ return {
13
+ async load<T>(source: string): Promise<T> {
14
+ const loaded = await delegate.load<T>(source);
15
+ const candidate = loaded as { default?: PluginDefinition };
16
+ if (resolve(source) !== rootEntry || !candidate.default) return loaded;
17
+ const original = candidate.default;
18
+ const definition = definePlugin({
19
+ ...original,
20
+ async setup(context) {
21
+ for (const [name, endpoint] of endpoints) context.addAdapter(name, endpoint);
22
+ return original.setup?.(context);
23
+ },
24
+ });
25
+ return { ...loaded, default: definition } as T;
26
+ },
27
+ ...(delegate.loadClientModule ? { loadClientModule: delegate.loadClientModule.bind(delegate) } : {}),
28
+ ...(delegate.invalidate ? { invalidate: delegate.invalidate.bind(delegate) } : {}),
29
+ ...(delegate.affectedSources ? { affectedSources: delegate.affectedSources.bind(delegate) } : {}),
30
+ ...(delegate.requiresProcessRestart ? { requiresProcessRestart: delegate.requiresProcessRestart.bind(delegate) } : {}),
31
+ ...(delegate.beginGeneration ? { beginGeneration: delegate.beginGeneration.bind(delegate) } : {}),
32
+ ...(delegate.commitGeneration ? { commitGeneration: delegate.commitGeneration.bind(delegate) } : {}),
33
+ ...(delegate.rollbackGeneration ? { rollbackGeneration: delegate.rollbackGeneration.bind(delegate) } : {}),
34
+ ...(delegate.updateWatchRoots ? { updateWatchRoots: delegate.updateWatchRoots.bind(delegate) } : {}),
35
+ ...(delegate.watch ? { watch: delegate.watch.bind(delegate) } : {}),
36
+ close: () => delegate.close(),
37
+ };
38
+ }