@brimveyn/aimux-plugin 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux-plugin",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Plugin authoring API for aimux — context, effects, events and RPC for in-process plugins.",
5
5
  "keywords": [
6
6
  "aimux",
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export {
32
32
  type PluginKeymapContribution,
33
33
  type PluginManifest,
34
34
  } from './manifest'
35
+ export { createTestUiSurface, type TestUiRegistrations, type TestUiSurface } from './test-ui'
35
36
  export {
36
37
  createTestContext,
37
38
  type TestContextHandle,
@@ -9,6 +9,7 @@ import type {
9
9
  import { EffectStack } from './effects'
10
10
  import { PluginEventBus } from './event-bus'
11
11
  import { PLUGIN_API_VERSION, type PluginHost, type PluginManifest } from './manifest'
12
+ import { createTestUiSurface, type TestUiSurface } from './test-ui'
12
13
 
13
14
  export interface TestLogEntry {
14
15
  level: 'debug' | 'info' | 'warn' | 'error'
@@ -73,6 +74,13 @@ export interface TestContextHandle {
73
74
  effectCount: () => number
74
75
  /** Unwind, exactly as an unload would. Resolves with any disposer errors. */
75
76
  dispose: () => Promise<unknown[]>
77
+ /**
78
+ * The recording UI half, when `host` is `'ui'` and no `extend` replaced it:
79
+ * what the plugin registered, the toasts it raised, and the levers that drive
80
+ * `ctx.ui.state`, `ctx.ui.settings` and `ctx.ui.themes`. Absent for a daemon
81
+ * plugin, which has no `ctx.ui` to record.
82
+ */
83
+ ui?: TestUiSurface
76
84
  }
77
85
 
78
86
  /**
@@ -191,9 +199,28 @@ export function createTestContext(options: TestContextOptions = {}): TestContext
191
199
  waterfall: async <T>(event: string, value: T) => bus.waterfall(event, value),
192
200
  }
193
201
 
202
+ /**
203
+ * A UI plugin's first line is `ctx.ui.something.register(...)`, so a `ui`
204
+ * context without `ctx.ui` cannot run one at all. `extend` still wins: the
205
+ * real hosts and `plugin doctor` pass their own services, and this stub is
206
+ * what an author outside aimux gets instead.
207
+ */
208
+ const surface = host === 'ui' ? createTestUiSurface(effects) : undefined
209
+ if (surface) {
210
+ const extended = ctx as PluginContext & {
211
+ ui: unknown
212
+ actions: unknown
213
+ store: unknown
214
+ }
215
+ extended.ui = surface.ui
216
+ extended.actions = surface.actions
217
+ extended.store = surface.store
218
+ }
219
+
194
220
  options.extend?.(ctx)
195
221
 
196
222
  return {
223
+ ...(surface === undefined ? {} : { ui: surface }),
197
224
  apply: async (definition) => {
198
225
  await (definition as PluginDefinition).apply(ctx)
199
226
  },
package/src/test-ui.ts ADDED
@@ -0,0 +1,228 @@
1
+ import type { EffectStack } from './effects'
2
+ import type { Disposer } from './types'
3
+ import type {
4
+ PluginActionsApi,
5
+ PluginSettingValue,
6
+ PluginStoreApi,
7
+ PluginThemeSnapshot,
8
+ PluginUiApi,
9
+ PluginUiState,
10
+ } from './ui'
11
+
12
+ /**
13
+ * The UI half of a plugin, with no aimux drawing anything.
14
+ *
15
+ * `createTestContext` gives a daemon plugin everything it needs — the real bus,
16
+ * the real effect stack, recorded RPC — but a UI plugin's first line is
17
+ * `ctx.ui.widgets.register(...)`, and `ctx.ui` simply was not there. So the
18
+ * test the scaffold generates, against the half the scaffold generates, threw
19
+ * on the first statement: the author's first run of `bun test` was a red they
20
+ * had not written. Inside this repo it was invisible, because aimux's own tests
21
+ * pass `extend` with the real services.
22
+ *
23
+ * Everything here records rather than draws, and every registration goes on the
24
+ * same effect stack the rest of the context uses — so `effectCount()` counts
25
+ * them and `dispose()` unwinds them, which is the property a plugin test is
26
+ * really there to check.
27
+ */
28
+
29
+ /** What the plugin registered, by surface. Mirrors what `plugin doctor` reports. */
30
+ export interface TestUiRegistrations {
31
+ widgets: string[]
32
+ views: string[]
33
+ modals: string[]
34
+ panes: string[]
35
+ statusBar: string[]
36
+ statsPages: string[]
37
+ themes: string[]
38
+ settingsSections: number
39
+ actions: string[]
40
+ effects: string[]
41
+ }
42
+
43
+ export interface TestUiSurface {
44
+ ui: PluginUiApi
45
+ actions: PluginActionsApi
46
+ store: PluginStoreApi
47
+ registrations: TestUiRegistrations
48
+ /** Everything `ctx.ui.toast` was asked to show, newest last. */
49
+ toasts: { level: 'info' | 'success' | 'error'; message: string }[]
50
+ /** Panes and views the plugin asked to open or close, in order. */
51
+ opened: string[]
52
+ /** Drives `ctx.ui.state`: set it, and subscribers hear about it. */
53
+ setState: (next: Partial<PluginUiState>) => void
54
+ /** Drives `ctx.ui.settings.watch` and what `get` answers. */
55
+ setSetting: (id: string, value: PluginSettingValue) => void
56
+ /** Drives `ctx.ui.themes.onChange` and what `current()` answers. */
57
+ setTheme: (snapshot: PluginThemeSnapshot) => void
58
+ }
59
+
60
+ const EMPTY_STATE: PluginUiState = {
61
+ activeTab: null,
62
+ activeTabId: null,
63
+ projectId: null,
64
+ tabs: [],
65
+ }
66
+
67
+ const DEFAULT_THEME: PluginThemeSnapshot = { colors: {}, mode: 'dark' }
68
+
69
+ /** A component that renders nothing: a test asserts on registrations, not pixels. */
70
+ const nothing = (): null => null
71
+
72
+ export function createTestUiSurface(effects: EffectStack): TestUiSurface {
73
+ const registrations: TestUiRegistrations = {
74
+ actions: [],
75
+ effects: [],
76
+ modals: [],
77
+ panes: [],
78
+ settingsSections: 0,
79
+ statsPages: [],
80
+ statusBar: [],
81
+ themes: [],
82
+ views: [],
83
+ widgets: [],
84
+ }
85
+ const toasts: TestUiSurface['toasts'] = []
86
+ const opened: string[] = []
87
+
88
+ let state: PluginUiState = EMPTY_STATE
89
+ const stateListeners = new Set<(next: PluginUiState) => void>()
90
+ const settings = new Map<string, PluginSettingValue>()
91
+ const settingListeners = new Map<string, Set<(value: PluginSettingValue) => void>>()
92
+ let theme: PluginThemeSnapshot = DEFAULT_THEME
93
+ const themeListeners = new Set<(snapshot: PluginThemeSnapshot) => void>()
94
+ let slice: unknown
95
+
96
+ /** Records a registration and hands back a disposer that unrecords it. */
97
+ function record(into: string[], id: string): Disposer {
98
+ into.push(id)
99
+ const dispose = (): void => {
100
+ const at = into.indexOf(id)
101
+ if (at !== -1) into.splice(at, 1)
102
+ }
103
+ effects.add(dispose)
104
+ return dispose
105
+ }
106
+
107
+ function own<T>(set: Set<T>, listener: T): Disposer {
108
+ set.add(listener)
109
+ const dispose = (): void => {
110
+ set.delete(listener)
111
+ }
112
+ effects.add(dispose)
113
+ return dispose
114
+ }
115
+
116
+ const ui: PluginUiApi = {
117
+ kit: {
118
+ KeyHint: nothing,
119
+ List: nothing,
120
+ Panel: nothing,
121
+ Row: nothing,
122
+ useTheme: () => theme.colors,
123
+ },
124
+ modals: {
125
+ close: () => opened.push('modal:close'),
126
+ open: (id) => opened.push(`modal:${id}`),
127
+ register: (modal) => record(registrations.modals, modal.id),
128
+ },
129
+ panes: {
130
+ close: (id) => opened.push(`pane:close:${id}`),
131
+ open: (id) => opened.push(`pane:${id}`),
132
+ register: (pane) => record(registrations.panes, pane.id),
133
+ },
134
+ settings: {
135
+ get: (id) => settings.get(id),
136
+ registerSection: () => {
137
+ registrations.settingsSections += 1
138
+ const dispose = (): void => {
139
+ registrations.settingsSections -= 1
140
+ }
141
+ effects.add(dispose)
142
+ return dispose
143
+ },
144
+ watch: (id, listener) => {
145
+ const set = settingListeners.get(id) ?? new Set()
146
+ settingListeners.set(id, set)
147
+ return own(set, listener)
148
+ },
149
+ },
150
+ state: {
151
+ get: () => state,
152
+ subscribe: (listener) => {
153
+ // Fires once immediately, exactly as the real one does.
154
+ listener(state)
155
+ return own(stateListeners, listener)
156
+ },
157
+ use: (select) => select(state),
158
+ },
159
+ stats: {
160
+ registerPage: (page) => record(registrations.statsPages, page.id),
161
+ },
162
+ statusBar: {
163
+ register: (segment) => record(registrations.statusBar, segment.id),
164
+ },
165
+ themes: {
166
+ current: () => theme,
167
+ onChange: (listener) => own(themeListeners, listener),
168
+ register: (id) => record(registrations.themes, id),
169
+ },
170
+ toast: {
171
+ error: (message) => toasts.push({ level: 'error', message }),
172
+ info: (message) => toasts.push({ level: 'info', message }),
173
+ success: (message) => toasts.push({ level: 'success', message }),
174
+ },
175
+ views: {
176
+ close: () => opened.push('view:close'),
177
+ open: (id) => opened.push(`view:${id}`),
178
+ register: (view) => record(registrations.views, view.id),
179
+ },
180
+ widgets: {
181
+ register: (widget) => record(registrations.widgets, widget.id),
182
+ },
183
+ }
184
+
185
+ const actions: PluginActionsApi = {
186
+ effect: (effectId) => record(registrations.effects, effectId),
187
+ register: (verb) => record(registrations.actions, verb),
188
+ }
189
+
190
+ const store: PluginStoreApi = {
191
+ dispatch: () => {
192
+ /* a slice reducer is the plugin's; a bare context has none to run */
193
+ },
194
+ get: () => slice,
195
+ reducer: () => {
196
+ const dispose = (): void => {
197
+ /* nothing was installed, so nothing comes off */
198
+ }
199
+ effects.add(dispose)
200
+ return dispose
201
+ },
202
+ set: (next) => {
203
+ slice = next
204
+ },
205
+ use: () => slice,
206
+ }
207
+
208
+ return {
209
+ actions,
210
+ opened,
211
+ registrations,
212
+ setSetting: (id, value) => {
213
+ settings.set(id, value)
214
+ for (const listener of settingListeners.get(id) ?? []) listener(value)
215
+ },
216
+ setState: (next) => {
217
+ state = { ...state, ...next }
218
+ for (const listener of stateListeners) listener(state)
219
+ },
220
+ setTheme: (snapshot) => {
221
+ theme = snapshot
222
+ for (const listener of themeListeners) listener(snapshot)
223
+ },
224
+ store,
225
+ toasts,
226
+ ui,
227
+ }
228
+ }