@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 +1 -1
- package/src/index.ts +1 -0
- package/src/test-context.ts +27 -0
- package/src/test-ui.ts +228 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
package/src/test-context.ts
CHANGED
|
@@ -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
|
+
}
|