@ai-composer/core 0.1.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.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -0
  3. package/dist/commands/builtins.d.ts +41 -0
  4. package/dist/commands/builtins.d.ts.map +1 -0
  5. package/dist/commands/builtins.js +116 -0
  6. package/dist/commands/builtins.js.map +1 -0
  7. package/dist/commands/registry.d.ts +27 -0
  8. package/dist/commands/registry.d.ts.map +1 -0
  9. package/dist/commands/registry.js +29 -0
  10. package/dist/commands/registry.js.map +1 -0
  11. package/dist/editor.d.ts +185 -0
  12. package/dist/editor.d.ts.map +1 -0
  13. package/dist/editor.js +558 -0
  14. package/dist/editor.js.map +1 -0
  15. package/dist/errors.d.ts +15 -0
  16. package/dist/errors.d.ts.map +1 -0
  17. package/dist/errors.js +22 -0
  18. package/dist/errors.js.map +1 -0
  19. package/dist/events/event-bus.d.ts +19 -0
  20. package/dist/events/event-bus.d.ts.map +1 -0
  21. package/dist/events/event-bus.js +51 -0
  22. package/dist/events/event-bus.js.map +1 -0
  23. package/dist/events/events.d.ts +94 -0
  24. package/dist/events/events.d.ts.map +1 -0
  25. package/dist/events/events.js +6 -0
  26. package/dist/events/events.js.map +1 -0
  27. package/dist/factory.d.ts +17 -0
  28. package/dist/factory.d.ts.map +1 -0
  29. package/dist/factory.js +18 -0
  30. package/dist/factory.js.map +1 -0
  31. package/dist/history/history.d.ts +48 -0
  32. package/dist/history/history.d.ts.map +1 -0
  33. package/dist/history/history.js +71 -0
  34. package/dist/history/history.js.map +1 -0
  35. package/dist/index.d.ts +43 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +30 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/model/document.d.ts +54 -0
  40. package/dist/model/document.d.ts.map +1 -0
  41. package/dist/model/document.js +177 -0
  42. package/dist/model/document.js.map +1 -0
  43. package/dist/model/nodes.d.ts +80 -0
  44. package/dist/model/nodes.d.ts.map +1 -0
  45. package/dist/model/nodes.js +64 -0
  46. package/dist/model/nodes.js.map +1 -0
  47. package/dist/model/selection.d.ts +40 -0
  48. package/dist/model/selection.d.ts.map +1 -0
  49. package/dist/model/selection.js +79 -0
  50. package/dist/model/selection.js.map +1 -0
  51. package/dist/nodes/registry.d.ts +39 -0
  52. package/dist/nodes/registry.d.ts.map +1 -0
  53. package/dist/nodes/registry.js +93 -0
  54. package/dist/nodes/registry.js.map +1 -0
  55. package/dist/package.json +21 -0
  56. package/dist/plugins/registry.d.ts +53 -0
  57. package/dist/plugins/registry.d.ts.map +1 -0
  58. package/dist/plugins/registry.js +102 -0
  59. package/dist/plugins/registry.js.map +1 -0
  60. package/dist/serialization/builtins.d.ts +30 -0
  61. package/dist/serialization/builtins.d.ts.map +1 -0
  62. package/dist/serialization/builtins.js +102 -0
  63. package/dist/serialization/builtins.js.map +1 -0
  64. package/dist/serialization/index.d.ts +4 -0
  65. package/dist/serialization/index.d.ts.map +1 -0
  66. package/dist/serialization/index.js +3 -0
  67. package/dist/serialization/index.js.map +1 -0
  68. package/dist/serialization/registry.d.ts +17 -0
  69. package/dist/serialization/registry.d.ts.map +1 -0
  70. package/dist/serialization/registry.js +35 -0
  71. package/dist/serialization/registry.js.map +1 -0
  72. package/dist/serialization/serializers.d.ts +12 -0
  73. package/dist/serialization/serializers.d.ts.map +1 -0
  74. package/dist/serialization/serializers.js +3 -0
  75. package/dist/serialization/serializers.js.map +1 -0
  76. package/dist/state/state.d.ts +54 -0
  77. package/dist/state/state.d.ts.map +1 -0
  78. package/dist/state/state.js +6 -0
  79. package/dist/state/state.js.map +1 -0
  80. package/dist/triggers/engine.d.ts +102 -0
  81. package/dist/triggers/engine.d.ts.map +1 -0
  82. package/dist/triggers/engine.js +235 -0
  83. package/dist/triggers/engine.js.map +1 -0
  84. package/dist/types.d.ts +37 -0
  85. package/dist/types.d.ts.map +1 -0
  86. package/dist/types.js +3 -0
  87. package/dist/types.js.map +1 -0
  88. package/dist/utils/html.d.ts +3 -0
  89. package/dist/utils/html.d.ts.map +1 -0
  90. package/dist/utils/html.js +10 -0
  91. package/dist/utils/html.js.map +1 -0
  92. package/package.json +20 -0
  93. package/project.json +56 -0
  94. package/src/commands/builtins.ts +142 -0
  95. package/src/commands/registry.ts +53 -0
  96. package/src/commands.test.ts +118 -0
  97. package/src/editor.test.ts +311 -0
  98. package/src/editor.ts +826 -0
  99. package/src/errors.ts +25 -0
  100. package/src/events/event-bus.test.ts +62 -0
  101. package/src/events/event-bus.ts +72 -0
  102. package/src/events/events.ts +120 -0
  103. package/src/factory.ts +21 -0
  104. package/src/history/history.ts +108 -0
  105. package/src/index.ts +201 -0
  106. package/src/model/document.test.ts +130 -0
  107. package/src/model/document.ts +207 -0
  108. package/src/model/nodes.ts +151 -0
  109. package/src/model/selection.ts +107 -0
  110. package/src/nodes/registry.ts +142 -0
  111. package/src/plugins/registry.ts +163 -0
  112. package/src/serialization/builtins.ts +123 -0
  113. package/src/serialization/index.ts +20 -0
  114. package/src/serialization/registry.ts +49 -0
  115. package/src/serialization/serializers.ts +15 -0
  116. package/src/state/state.ts +57 -0
  117. package/src/triggers/engine.ts +317 -0
  118. package/src/triggers.test.ts +138 -0
  119. package/src/types.ts +42 -0
  120. package/src/utils/html.ts +9 -0
  121. package/tsconfig.json +15 -0
  122. package/tsconfig.lib.json +15 -0
  123. package/tsconfig.spec.json +16 -0
  124. package/vitest.config.ts +11 -0
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { createAIComposer } from './factory';
3
+ import { createMentionNode, createTextNode } from './model/nodes';
4
+ import { BUILTIN_COMMANDS } from './commands/builtins';
5
+
6
+ describe('command system', () => {
7
+ it('executes built-in commands by id', async () => {
8
+ const editor = createAIComposer({ value: 'hello' });
9
+ await editor.executeCommand(BUILTIN_COMMANDS.clear);
10
+ expect(editor.getState().empty).toBe(true);
11
+ await editor.executeCommand(BUILTIN_COMMANDS.undo);
12
+ expect(editor.serialize('text')).toBe('hello');
13
+ });
14
+
15
+ it('insertText/insertNode work through commands', async () => {
16
+ const editor = createAIComposer();
17
+ await editor.executeCommand(BUILTIN_COMMANDS.insertText, { text: 'hi ' });
18
+ await editor.executeCommand(BUILTIN_COMMANDS.insertNode, {
19
+ node: createMentionNode({ id: 'u1', label: 'Ada' }),
20
+ });
21
+ expect(editor.serialize('text')).toBe('hi @Ada ');
22
+ });
23
+
24
+ it('throws for unknown commands', async () => {
25
+ const editor = createAIComposer();
26
+ await expect(editor.executeCommand('nope')).rejects.toThrowError('nope');
27
+ });
28
+
29
+ it('canExecute gates execution', async () => {
30
+ const editor = createAIComposer({ value: 'x', disabled: true });
31
+ await editor.executeCommand(BUILTIN_COMMANDS.clear); // no-op: disabled
32
+ expect(editor.serialize('text')).toBe('x');
33
+ });
34
+
35
+ it('emits commandExecute', async () => {
36
+ const editor = createAIComposer();
37
+ const onExecute = vi.fn();
38
+ editor.on('commandExecute', onExecute);
39
+ await editor.executeCommand(BUILTIN_COMMANDS.insertText, 'hello');
40
+ expect(onExecute).toHaveBeenCalledWith({ id: 'insertText', payload: 'hello' });
41
+ expect(editor.serialize('text')).toBe('hello');
42
+ });
43
+
44
+ it('plugins contribute custom commands', async () => {
45
+ const editor = createAIComposer({
46
+ plugins: [
47
+ {
48
+ name: 'shout',
49
+ commands: [
50
+ {
51
+ id: 'shout',
52
+ execute: (ctx) => {
53
+ const text = ctx.editor.serialize('text') as string;
54
+ ctx.editor.setValue(text.toUpperCase());
55
+ },
56
+ },
57
+ ],
58
+ },
59
+ ],
60
+ value: 'make me loud',
61
+ });
62
+ await editor.executeCommand('shout');
63
+ expect(editor.serialize('text')).toBe('MAKE ME LOUD');
64
+ });
65
+
66
+ it('openTrigger command validates the trigger id', async () => {
67
+ const editor = createAIComposer();
68
+ await expect(editor.executeCommand(BUILTIN_COMMANDS.openTrigger, 'ghost')).rejects.toThrowError();
69
+ });
70
+ });
71
+
72
+ describe('serialization', () => {
73
+ const editor = createAIComposer({
74
+ value: [createTextNode('hello '), createMentionNode({ id: 'u1', label: 'Ada' }), createTextNode('!')],
75
+ });
76
+
77
+ it('json round-trips the document', () => {
78
+ const json = editor.serialize('json') as ReturnType<typeof editor.getValue>;
79
+ const restored = createAIComposer({ value: json as never });
80
+ expect(restored.serialize('text')).toBe('hello @Ada!');
81
+ });
82
+
83
+ it('text projects chips as readable text', () => {
84
+ expect(editor.serialize('text')).toBe('hello @Ada!');
85
+ });
86
+
87
+ it('markdown renders mention links', () => {
88
+ expect(editor.serialize('markdown')).toBe('hello [@Ada](mention:u1)!');
89
+ });
90
+
91
+ it('html escapes user content', () => {
92
+ const unsafe = createAIComposer({ value: '<script>alert(1)</script>' });
93
+ const html = unsafe.serialize('html') as string;
94
+ expect(html).toContain('&lt;script&gt;');
95
+ expect(html).not.toContain('<script>');
96
+ });
97
+
98
+ it('ai payload separates text and entities', () => {
99
+ const payload = editor.serialize('ai') as {
100
+ text: string;
101
+ entities: Array<{ type: string; id?: string }>;
102
+ };
103
+ expect(payload.text).toBe('hello @Ada!');
104
+ expect(payload.entities).toEqual([{ type: 'mention', key: expect.any(String), id: 'u1', label: 'Ada' }]);
105
+ });
106
+
107
+ it('custom serializers can be registered', () => {
108
+ const custom = createAIComposer({
109
+ serializers: [
110
+ {
111
+ format: 'text',
112
+ serialize: () => 'OVERRIDE',
113
+ },
114
+ ],
115
+ });
116
+ expect(custom.serialize('text')).toBe('OVERRIDE');
117
+ });
118
+ });
@@ -0,0 +1,311 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { createAIComposer } from './factory';
3
+ import { createMentionNode, createTextNode } from './model/nodes';
4
+ import { createPosition, createSelection, toGlobalOffset } from './model/selection';
5
+ import { ErrorCode } from './errors';
6
+
7
+ describe('AIComposer aliases', () => {
8
+ it('exposes the new AIComposer factory aliases', () => {
9
+ const aiComposer = createAIComposer();
10
+ const legacyAIComposer = createAIComposer();
11
+
12
+ expect(aiComposer.getState().empty).toBe(true);
13
+ expect(legacyAIComposer.getState().empty).toBe(true);
14
+ });
15
+ });
16
+
17
+ describe('AIComposer — value API', () => {
18
+ it('starts empty', () => {
19
+ const editor = createAIComposer();
20
+ expect(editor.getValue().nodes).toEqual([]);
21
+ expect(editor.getState().empty).toBe(true);
22
+ });
23
+
24
+ it('setValue accepts string, nodes and documents', () => {
25
+ const editor = createAIComposer();
26
+ editor.setValue('hello');
27
+ expect(editor.serialize('text')).toBe('hello');
28
+
29
+ editor.setValue([createTextNode('a '), createMentionNode({ id: 'u1', label: 'Ada' })]);
30
+ expect(editor.serialize('text')).toBe('a @Ada');
31
+
32
+ editor.setValue({ nodes: [createTextNode('doc')] });
33
+ expect(editor.serialize('text')).toBe('doc');
34
+ });
35
+
36
+ it('insertText appends and moves the caret', () => {
37
+ const editor = createAIComposer();
38
+ editor.insertText('hello');
39
+ editor.insertText(' world');
40
+ expect(editor.serialize('text')).toBe('hello world');
41
+ const focus = editor.getSelection().focus;
42
+ expect(toGlobalOffset(editor.getValue(), focus)).toBe(11);
43
+ });
44
+
45
+ it('insertText replaces an active selection', () => {
46
+ const editor = createAIComposer({ value: 'hello world' });
47
+ editor.setSelection(
48
+ createSelection(createPosition(0, 0), createPosition(0, 5)),
49
+ );
50
+ editor.insertText('goodbye');
51
+ expect(editor.serialize('text')).toBe('goodbye world');
52
+ });
53
+
54
+ it('insertNode adds an atomic node with trailing space', () => {
55
+ const editor = createAIComposer({ value: 'hi ' });
56
+ editor.insertNode(createMentionNode({ id: 'u1', label: 'Ada' }));
57
+ expect(editor.serialize('text')).toBe('hi @Ada ');
58
+ // caret after the trailing space ('hi '=3 + chip=1 + space=1)
59
+ expect(toGlobalOffset(editor.getValue(), editor.getSelection().focus)).toBe(5);
60
+ });
61
+
62
+ it('removeNode deletes by key and reports the event', () => {
63
+ const editor = createAIComposer();
64
+ const onRemove = vi.fn();
65
+ editor.on('nodeRemove', onRemove);
66
+
67
+ const mention = createMentionNode({ id: 'u1', label: 'Ada' });
68
+ editor.setValue([createTextNode('a '), mention, createTextNode('b')]);
69
+ editor.removeNode(mention.key);
70
+
71
+ expect(editor.serialize('text')).toBe('a b');
72
+ expect(onRemove).toHaveBeenCalledWith(expect.objectContaining({ node: mention }));
73
+ });
74
+
75
+ it('clear empties the document', () => {
76
+ const editor = createAIComposer({ value: 'x' });
77
+ editor.clear();
78
+ expect(editor.getState().empty).toBe(true);
79
+ });
80
+ });
81
+
82
+ describe('AIComposer — events and state', () => {
83
+ it('emits change/input and notifies subscribers', () => {
84
+ const editor = createAIComposer();
85
+ const change = vi.fn();
86
+ const input = vi.fn();
87
+ const stateListener = vi.fn();
88
+ editor.on('change', change);
89
+ editor.on('input', input);
90
+ editor.subscribe(stateListener);
91
+
92
+ editor.insertText('hi');
93
+
94
+ expect(change).toHaveBeenCalledWith(expect.objectContaining({ source: 'api' }));
95
+ // programmatic edits are 'api' — input only fires for user edits
96
+ expect(input).not.toHaveBeenCalled();
97
+ expect(stateListener).toHaveBeenCalled();
98
+ expect(editor.getState().empty).toBe(false);
99
+ });
100
+
101
+ it('applyViewUpdate is treated as user input', () => {
102
+ const editor = createAIComposer();
103
+ const input = vi.fn();
104
+ editor.on('input', input);
105
+ editor.applyViewUpdate({ nodes: [createTextNode('typed')] });
106
+ expect(editor.serialize('text')).toBe('typed');
107
+ expect(input).toHaveBeenCalledWith(expect.objectContaining({ source: 'user' }));
108
+ });
109
+
110
+ it('selection changes are clamped and emitted', () => {
111
+ const editor = createAIComposer({ value: 'abc' });
112
+ const onChange = vi.fn();
113
+ editor.on('selectionChange', onChange);
114
+ editor.setSelection(createSelection(createPosition(0, 2)));
115
+ expect(onChange).toHaveBeenCalledTimes(1);
116
+ expect(editor.getSelection().focus).toEqual(createPosition(0, 2));
117
+
118
+ editor.setSelection(createSelection(createPosition(99, 99)));
119
+ expect(editor.getSelection().focus).toEqual(createPosition(0, 3));
120
+ });
121
+
122
+ it('focus/blur toggle focused state and emit events', () => {
123
+ const editor = createAIComposer();
124
+ const focus = vi.fn();
125
+ const blur = vi.fn();
126
+ editor.on('focus', focus);
127
+ editor.on('blur', blur);
128
+ editor.focus();
129
+ editor.blur();
130
+ expect(focus).toHaveBeenCalledWith({ focused: true });
131
+ expect(blur).toHaveBeenCalledWith({ focused: false });
132
+ });
133
+ });
134
+
135
+ describe('AIComposer — submit', () => {
136
+ it('runs the async handler and emits submit', async () => {
137
+ const onSubmit = vi.fn(async () => undefined);
138
+ const editor = createAIComposer({ value: 'question', submit: { onSubmit } });
139
+ const beforeSubmit = vi.fn();
140
+ const submitted = vi.fn();
141
+ editor.on('beforeSubmit', beforeSubmit);
142
+ editor.on('submit', submitted);
143
+
144
+ await editor.submit();
145
+
146
+ expect(onSubmit).toHaveBeenCalledTimes(1);
147
+ expect(beforeSubmit).toHaveBeenCalled();
148
+ expect(submitted).toHaveBeenCalledWith(expect.objectContaining({ value: editor.getValue() }));
149
+ expect(editor.getState().submitting).toBe(false);
150
+ });
151
+
152
+ it('beforeSubmit can cancel', async () => {
153
+ const onSubmit = vi.fn();
154
+ const editor = createAIComposer({ value: 'x', submit: { onSubmit } });
155
+ editor.on('beforeSubmit', (event) => event.preventDefault());
156
+ await editor.submit();
157
+ expect(onSubmit).not.toHaveBeenCalled();
158
+ });
159
+
160
+ it('skips empty documents unless allowEmpty', async () => {
161
+ const onSubmit = vi.fn();
162
+ const strict = createAIComposer({ submit: { onSubmit } });
163
+ await strict.submit();
164
+ expect(onSubmit).not.toHaveBeenCalled();
165
+
166
+ const lax = createAIComposer({ submit: { onSubmit, allowEmpty: true } });
167
+ await lax.submit();
168
+ expect(onSubmit).toHaveBeenCalledTimes(1);
169
+ });
170
+
171
+ it('clearOnSubmit resets the document', async () => {
172
+ const editor = createAIComposer({
173
+ value: 'hello',
174
+ submit: { clearOnSubmit: true },
175
+ });
176
+ await editor.submit();
177
+ expect(editor.getState().empty).toBe(true);
178
+ });
179
+
180
+ it('disabled editors do not submit', async () => {
181
+ const onSubmit = vi.fn();
182
+ const editor = createAIComposer({ value: 'x', disabled: true, submit: { onSubmit } });
183
+ await editor.submit();
184
+ expect(onSubmit).not.toHaveBeenCalled();
185
+ });
186
+ });
187
+
188
+ describe('AIComposer — history', () => {
189
+ it('undoes and redoes setValue steps', () => {
190
+ const editor = createAIComposer();
191
+ editor.setValue('one');
192
+ editor.setValue('two');
193
+ editor.undo();
194
+ expect(editor.serialize('text')).toBe('one');
195
+ editor.undo();
196
+ expect(editor.getState().empty).toBe(true);
197
+ editor.redo();
198
+ expect(editor.serialize('text')).toBe('one');
199
+ editor.redo();
200
+ expect(editor.serialize('text')).toBe('two');
201
+ });
202
+
203
+ it('a transaction is a single undo step', () => {
204
+ const editor = createAIComposer();
205
+ editor.transaction(() => {
206
+ editor.insertText('Hello');
207
+ editor.insertNode(createMentionNode({ id: 'u1', label: 'Ada' }));
208
+ editor.insertText('rest');
209
+ });
210
+ expect(editor.serialize('text')).toBe('Hello@Ada rest');
211
+ editor.undo();
212
+ expect(editor.getState().empty).toBe(true);
213
+ });
214
+
215
+ it('rapid user input merges into one undo step', async () => {
216
+ const editor = createAIComposer();
217
+ const type = (text: string) =>
218
+ editor.applyViewUpdate({ nodes: [createTextNode(editor.serialize('text') + text)] });
219
+
220
+ type('a');
221
+ type('b');
222
+ type('c');
223
+ expect(editor.serialize('text')).toBe('abc');
224
+ editor.undo();
225
+ expect(editor.getState().empty).toBe(true);
226
+ });
227
+
228
+ it('tracks canUndo/canRedo in state', () => {
229
+ const editor = createAIComposer();
230
+ expect(editor.getState().canUndo).toBe(false);
231
+ editor.setValue('x');
232
+ expect(editor.getState().canUndo).toBe(true);
233
+ expect(editor.getState().canRedo).toBe(false);
234
+ editor.undo();
235
+ expect(editor.getState().canUndo).toBe(false);
236
+ expect(editor.getState().canRedo).toBe(true);
237
+ });
238
+
239
+ it('setValue can skip history', () => {
240
+ const editor = createAIComposer({ value: 'keep' });
241
+ editor.setValue('staged', { label: 'stage' });
242
+ editor.setValue('external', { history: false });
243
+ // the history-less setValue left no step, so undo returns to before 'staged'
244
+ editor.undo();
245
+ expect(editor.serialize('text')).toBe('keep');
246
+ });
247
+ });
248
+
249
+ describe('AIComposer — disabled / readonly / destroy', () => {
250
+ it('disabled blocks editing commands', async () => {
251
+ const editor = createAIComposer({ value: 'x', disabled: true });
252
+ expect(editor.canExecuteCommand('submit')).toBe(false);
253
+ editor.setDisabled(false);
254
+ expect(editor.canExecuteCommand('submit')).toBe(true);
255
+ });
256
+
257
+ it('readonly blocks submit but allows programmatic reads', async () => {
258
+ const editor = createAIComposer({ value: 'x', readonly: true });
259
+ await editor.submit();
260
+ expect(editor.serialize('text')).toBe('x');
261
+ });
262
+
263
+ it('destroyed editors throw on mutation', () => {
264
+ const editor = createAIComposer();
265
+ const onDestroy = vi.fn();
266
+ editor.on('destroy', onDestroy);
267
+ editor.destroy();
268
+ expect(onDestroy).toHaveBeenCalled();
269
+ expect(() => editor.insertText('nope')).toThrowError(ErrorCode.EDITOR_DESTROYED);
270
+ });
271
+
272
+ it('configure/setMode update state and emit modeChange', () => {
273
+ const editor = createAIComposer();
274
+ const onMode = vi.fn();
275
+ editor.on('modeChange', onMode);
276
+ editor.setMode('chat');
277
+ expect(editor.getState().mode).toBe('chat');
278
+ expect(onMode).toHaveBeenCalledWith({ mode: 'chat' });
279
+ editor.configure({ placeholder: 'Ask anything' });
280
+ expect(editor.getState().placeholder).toBe('Ask anything');
281
+ });
282
+ });
283
+
284
+ describe('AIComposer — registries', () => {
285
+ it('exposes built-in commands', () => {
286
+ const editor = createAIComposer();
287
+ for (const id of ['submit', 'clear', 'undo', 'redo', 'insertText', 'insertNode', 'focus']) {
288
+ expect(editor.commands.has(id), id).toBe(true);
289
+ }
290
+ });
291
+
292
+ it('serializes through registered serializers', () => {
293
+ const editor = createAIComposer({ value: 'plain' });
294
+ expect(editor.serialize('json')).toMatchObject({ nodes: [{ type: 'text', text: 'plain' }] });
295
+ expect(editor.serialize('text')).toBe('plain');
296
+ expect(() => editor.serialize('nope' as never)).toThrowError();
297
+ });
298
+
299
+ it('supports runtime plugin registration with cleanup', () => {
300
+ const editor = createAIComposer();
301
+ const cleanup = vi.fn();
302
+ const plugin = {
303
+ name: 'test-plugin',
304
+ setup: vi.fn((ctx: { onCleanup: (fn: () => void) => void }) => ctx.onCleanup(cleanup)),
305
+ };
306
+ const off = editor.registerPlugin(plugin);
307
+ expect(plugin.setup).toHaveBeenCalled();
308
+ off();
309
+ expect(cleanup).toHaveBeenCalled();
310
+ });
311
+ });