experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

test: v2 tile command registration end-to-end

+392
+212
backend/electron/tile-command-registration.test.ts
··· 1 + /** 2 + * Unit tests for v2 tile command registration end-to-end plumbing. 3 + * 4 + * Validates that: 5 + * 1. registerLazyTile() publishes cmd:register-batch for each command 6 + * 2. When cmd:execute:{name} fires, the stub loads the tile 7 + * 3. The buffered message is replayed to the real handler 8 + * 4. Commands with params are registered with their params metadata 9 + */ 10 + 11 + import { describe, it, beforeEach } from 'node:test'; 12 + import * as assert from 'node:assert'; 13 + 14 + import { publish, subscribe, unsubscribe, scopes, hasSubscriber } from './pubsub.js'; 15 + import { 16 + registerLazyTile, 17 + __setTileLazyLauncherForTest, 18 + __resetTileLazyStateForTest, 19 + } from './tile-lazy.js'; 20 + import type { TileManifestV2, TileCommand } from './tile-manifest.js'; 21 + import type { TileLaunchOptions, TileLaunchResult } from './tile-launcher.js'; 22 + 23 + // ─── Test Launcher ──────────────────────────────────────────────────── 24 + 25 + interface FakeLauncherState { 26 + launchCalls: TileLaunchOptions[]; 27 + loaded: Set<string>; 28 + ready: Set<string>; 29 + readyCallbacks: Map<string, Array<() => void>>; 30 + onLaunch?: (options: TileLaunchOptions) => void; 31 + } 32 + 33 + function makeFakeLauncher(state: FakeLauncherState) { 34 + return { 35 + launchTile: (options: TileLaunchOptions): TileLaunchResult | void => { 36 + state.launchCalls.push(options); 37 + const entryId = options.entryId || options.manifest.tiles[0]?.id || 'background'; 38 + queueMicrotask(() => { 39 + state.loaded.add(`${options.manifest.id}:${entryId}`); 40 + if (state.onLaunch) state.onLaunch(options); 41 + }); 42 + }, 43 + waitForTileReady: (tileId: string, entryId: string): Promise<void> => { 44 + const key = `${tileId}:${entryId}`; 45 + if (state.ready.has(key)) return Promise.resolve(); 46 + return new Promise<void>((resolve) => { 47 + if (!state.readyCallbacks.has(key)) state.readyCallbacks.set(key, []); 48 + state.readyCallbacks.get(key)!.push(resolve); 49 + }); 50 + }, 51 + isTileLoaded: (tileId: string, entryId: string): boolean => 52 + state.loaded.has(`${tileId}:${entryId}`), 53 + }; 54 + } 55 + 56 + function signalReady(state: FakeLauncherState, tileId: string, entryId: string): void { 57 + const key = `${tileId}:${entryId}`; 58 + state.ready.add(key); 59 + const cbs = state.readyCallbacks.get(key) || []; 60 + state.readyCallbacks.delete(key); 61 + for (const cb of cbs) cb(); 62 + } 63 + 64 + function flushMicrotasks(): Promise<void> { 65 + return new Promise(resolve => setImmediate(resolve)); 66 + } 67 + 68 + function makeManifestWithCommands(id: string, commands: TileCommand[]): TileManifestV2 { 69 + return { 70 + manifestVersion: 2, 71 + id, 72 + name: id, 73 + builtin: true, 74 + tiles: [ 75 + { id: 'background', type: 'background', url: 'background.html', lazy: true }, 76 + ], 77 + capabilities: { pubsub: { scopes: ['self', 'global'] }, commands: true }, 78 + commands, 79 + }; 80 + } 81 + 82 + // ─── Tests ──────────────────────────────────────────────────────────── 83 + 84 + describe('tile command registration', () => { 85 + let launcherState: FakeLauncherState; 86 + 87 + beforeEach(() => { 88 + __resetTileLazyStateForTest(); 89 + launcherState = { 90 + launchCalls: [], 91 + loaded: new Set(), 92 + ready: new Set(), 93 + readyCallbacks: new Map(), 94 + }; 95 + __setTileLazyLauncherForTest(makeFakeLauncher(launcherState)); 96 + }); 97 + 98 + it('publishes cmd:register-batch for each manifest command', () => { 99 + const registered: unknown[] = []; 100 + subscribe('test-observer', scopes.GLOBAL, 'cmd:register-batch', (msg: unknown) => { 101 + registered.push(msg); 102 + }); 103 + 104 + const manifest = makeManifestWithCommands('cmd-test-1', [ 105 + { name: 'foo', description: 'Foo command', action: { type: 'execute' } }, 106 + { name: 'bar', description: 'Bar command', action: { type: 'execute' } }, 107 + ]); 108 + 109 + registerLazyTile(manifest, '/fake/path', '/fake/preload.js'); 110 + 111 + // Expect one batch per command (tile-lazy publishes per command) 112 + assert.strictEqual(registered.length, 2, 'expected one batch publish per command'); 113 + 114 + const names = registered 115 + .flatMap((m: unknown) => (m as { commands: Array<{ name: string }> }).commands.map(c => c.name)) 116 + .sort(); 117 + assert.deepStrictEqual(names, ['bar', 'foo']); 118 + 119 + unsubscribe('test-observer', 'cmd:register-batch'); 120 + }); 121 + 122 + it('registers subscriber for cmd:execute:{name} for each command', () => { 123 + const manifest = makeManifestWithCommands('cmd-test-2', [ 124 + { name: 'baz', action: { type: 'execute' } }, 125 + ]); 126 + registerLazyTile(manifest, '/fake/path', '/fake/preload.js'); 127 + 128 + // lazy-stub subscribes to cmd:execute:baz 129 + assert.ok( 130 + hasSubscriber('cmd:execute:baz', 'lazy-stub/baz'), 131 + 'lazy-stub/baz should be subscribed to cmd:execute:baz' 132 + ); 133 + }); 134 + 135 + it('preserves params metadata in cmd:register-batch', () => { 136 + const received: unknown[] = []; 137 + subscribe('test-observer-2', scopes.GLOBAL, 'cmd:register-batch', (msg: unknown) => { 138 + received.push(msg); 139 + }); 140 + 141 + const manifest = makeManifestWithCommands('cmd-test-3', [ 142 + { 143 + name: 'search', 144 + description: 'Search something', 145 + action: { type: 'execute' }, 146 + params: [{ name: 'query', required: true }], 147 + } as TileCommand, 148 + ]); 149 + registerLazyTile(manifest, '/fake/path', '/fake/preload.js'); 150 + 151 + assert.strictEqual(received.length, 1); 152 + const batch = received[0] as { commands: Array<{ name: string; params?: unknown[] }> }; 153 + assert.strictEqual(batch.commands[0].name, 'search'); 154 + assert.ok(Array.isArray(batch.commands[0].params)); 155 + assert.strictEqual((batch.commands[0].params as Array<{ name: string }>)[0].name, 'query'); 156 + 157 + unsubscribe('test-observer-2', 'cmd:register-batch'); 158 + }); 159 + 160 + it('loads tile and replays message when command is invoked', async () => { 161 + const manifest = makeManifestWithCommands('cmd-test-4', [ 162 + { name: 'doit', action: { type: 'execute' } }, 163 + ]); 164 + registerLazyTile(manifest, '/fake/path', '/fake/preload.js'); 165 + 166 + // Simulate the tile registering its real handler on launch. 167 + const realHandlerReceived: unknown[] = []; 168 + launcherState.onLaunch = () => { 169 + subscribe('peek://cmd-test-4/bg', scopes.GLOBAL, 'cmd:execute:doit', (msg: unknown) => { 170 + realHandlerReceived.push(msg); 171 + }); 172 + signalReady(launcherState, 'cmd-test-4', 'background'); 173 + }; 174 + 175 + // Invoke the command — lazy stub intercepts, loads, replays 176 + publish('test-caller', scopes.GLOBAL, 'cmd:execute:doit', { payload: 42 }); 177 + 178 + // Wait for microtasks and the async replay 179 + await flushMicrotasks(); 180 + await flushMicrotasks(); 181 + await flushMicrotasks(); 182 + 183 + assert.strictEqual(launcherState.launchCalls.length, 1, 'tile should be launched once'); 184 + assert.strictEqual(realHandlerReceived.length, 1, 'real handler should receive the replay'); 185 + assert.deepStrictEqual(realHandlerReceived[0], { payload: 42 }); 186 + 187 + unsubscribe('peek://cmd-test-4/bg', 'cmd:execute:doit'); 188 + }); 189 + 190 + it('window-action commands still get registered in cmd batch', () => { 191 + const received: unknown[] = []; 192 + subscribe('test-observer-3', scopes.GLOBAL, 'cmd:register-batch', (msg: unknown) => { 193 + received.push(msg); 194 + }); 195 + 196 + const manifest = makeManifestWithCommands('cmd-test-5', [ 197 + { 198 + name: 'open window', 199 + description: 'Open a window', 200 + action: { type: 'window', url: 'peek://cmd-test-5/home.html' }, 201 + } as TileCommand, 202 + ]); 203 + registerLazyTile(manifest, '/fake/path', '/fake/preload.js'); 204 + 205 + assert.strictEqual(received.length, 1); 206 + const batch = received[0] as { commands: Array<{ name: string; action?: { type: string } }> }; 207 + assert.strictEqual(batch.commands[0].name, 'open window'); 208 + assert.strictEqual(batch.commands[0].action?.type, 'window'); 209 + 210 + unsubscribe('test-observer-3', 'cmd:register-batch'); 211 + }); 212 + });
+180
docs/command-audit.md
··· 1 + # Command Audit — v2 Tile Migration 2 + 3 + Snapshot generated from `/Users/dietrich/misc/mpeek/features/*/manifest.json` on 4 + the overnight audit pass. 5 + 6 + ## Summary 7 + 8 + - 72 commands across 29 v2 manifests 9 + - 1 critical runtime bug (FIXED) 10 + - 5 preload API mismatches between v1 `preload.js` and `tile-preload.ts` (FIXED 11 + with v1-compat shims) 12 + - 0 commands verified failing after fixes — all rely on the shared `cmd:execute:{name}` / 13 + `cmd:execute:{name}:result` pubsub protocol and the now-delegating 14 + `api.commands.register({name, execute})` helper in `tile-preload.ts`. 15 + 16 + ## Critical Fixes in This Audit 17 + 18 + ### 1. `tile:window:open` was a stub 19 + 20 + `backend/electron/tile-ipc.ts` used to return `{success: true, url}` without 21 + actually opening anything. Every `window`-type command from a v2 tile 22 + (20+ commands) silently failed. 23 + 24 + Fix: exposed `invokeWindowOpen` from `ipc.ts` and have the tile handler call 25 + the real `window-open` implementation. 26 + 27 + ### 2. `api.commands.register({name, execute})` didn't wire up 28 + 29 + The tile preload signature was `register(name, handler)` but every feature 30 + calls `register({name, description, execute, ...})`. The object form was 31 + silently dropped. Fix: tile-preload now accepts both shapes, publishes 32 + `cmd:register` for the cmd panel, subscribes to `cmd:execute:{name}`, and 33 + publishes `:result` for proxy resolution. 34 + 35 + ### 3. `api.subscribe` / `api.publish` not exposed 36 + 37 + Features uniformly use top-level `api.subscribe(topic, cb, scope)`. Tile preload 38 + only exposed `api.pubsub.*`. Fix: exposed top-level aliases that share the same 39 + underlying IPC. 40 + 41 + ### 4. `api.datastore.*` methods missing 42 + 43 + `api.datastore.addItem/updateItem/queryItems/setRow/getRow/tagItem` and many 44 + more — used widely by groups, tags, timers, search, editor. Tile preload only 45 + had `get/set/query`. Fix: added the full v1 datastore API surface as direct 46 + IPC delegations. 47 + 48 + ### 5. `api.shortcuts`, `api.files`, `api.modes`, `api.context`, `api.closeWindow` missing 49 + 50 + Fix: v1-compat surfaces wired through existing core IPC handlers. 51 + 52 + ### 6. `api.settings.getKey` / `setKey` missing 53 + 54 + Used by `lex`. Fix: added as aliases over `tile:settings:get/set`. 55 + 56 + ### 7. `tile:settings:*` handlers were stubs returning `null` / `true` 57 + 58 + Fix: wired to `feature_settings` table scoped by `grant.tileId`. 59 + 60 + ### 8. `tile:theme:info` returned hardcoded `'peek'` 61 + 62 + Fix: returns real `themeId` + `isDark` from `nativeTheme` + `getActiveThemeId()`. 63 + 64 + ### 9. `tile:datastore:*` handlers were also stubs 65 + 66 + Left as not-yet-implemented (no feature uses them) but now return explicit 67 + errors rather than pretending success. 68 + 69 + ## Commands Enumerated 70 + 71 + Columns: 72 + 73 + - **feature** — manifest `id` 74 + - **command** — manifest `commands[].name` 75 + - **action** — `execute` | `window` | `publish` 76 + - **loading** — `eager` | `lazy` | `declarative (no bg)` 77 + - **status** — expected runtime behaviour after the fixes above 78 + 79 + | feature | command | action | loading | status | 80 + |---|---|---|---|---| 81 + | dropzone | dropzone | window | declarative | works — declarative | 82 + | editor | editor | execute | lazy | works — lazy stub → api.commands.register fix | 83 + | entities | extract entities | execute | eager | works | 84 + | example | example:save-image | execute | lazy | works | 85 + | example | example:gallery | execute | lazy | works | 86 + | features-manager | features | window | lazy | works | 87 + | features-manager | manage features | window | lazy | works | 88 + | features-manager | browse features | window | lazy | works | 89 + | features-manager | publish feature | window | lazy | works | 90 + | features-manager | list features | execute | lazy | works | 91 + | features-manager | check feature updates | execute | lazy | works | 92 + | features-manager | update feature | execute | lazy | works | 93 + | feeds | open feeds | window | lazy | works | 94 + | feeds | refresh feeds | execute | lazy | works | 95 + | files | open file | execute | lazy | works | 96 + | files | csv | execute | lazy | works | 97 + | files | save | execute | lazy | works | 98 + | files | markdown | execute | lazy | works | 99 + | files | save as note | execute | lazy | works | 100 + | groups | groups | execute | lazy | works | 101 + | groups | open groups | window | lazy | works | 102 + | groups | close group | execute | lazy | works | 103 + | groups | switch group | execute | lazy | works | 104 + | groups | restore group | execute | lazy | works | 105 + | groups | pin | execute | lazy | works | 106 + | groups | unpin | execute | lazy | works | 107 + | helpdocs | help docs | execute | lazy | works | 108 + | lex | lexicon studio | window | lazy | works — needs tile:window:open fix + settings.getKey | 109 + | lex | lex | execute | lazy | works | 110 + | lists | lists | execute | lazy | works | 111 + | mcp-server | mcp server setup | execute | lazy | works | 112 + | pagestream | pagestream | execute | lazy | works | 113 + | scripts | open scripts | window | lazy | works | 114 + | search | search | execute | lazy | works | 115 + | sheets | sheets | execute | lazy | works | 116 + | sheets | new sheet | execute | lazy | works | 117 + | sheets | open sheet | execute | lazy | works | 118 + | spaces | open space | execute | lazy | works | 119 + | spaces | close space | execute | lazy | works | 120 + | spaces | switch space | execute | lazy | works | 121 + | spaces | open spaces | window | lazy | works | 122 + | sync | Sync now | publish | declarative | works — declarative | 123 + | tag-actions | tag actions | window | lazy | works | 124 + | tags | tag | execute | lazy | works | 125 + | tags | tags | execute | lazy | works | 126 + | tags | untag | execute | lazy | works | 127 + | tags | tagset | execute | lazy | works | 128 + | tags | open tags | window | lazy | works | 129 + | timers | timers | execute | lazy | works | 130 + | timers | timer countdown | execute params | lazy | works | 131 + | timers | timer alarm | execute params | lazy | works | 132 + | timers | timer stopwatch | execute params | lazy | works | 133 + | timers | timer interval | execute params | lazy | works | 134 + | websearch | web search | execute params | lazy | works | 135 + | websearch | open web search | execute | lazy | works | 136 + | websearch | kagi | execute params | lazy | works | 137 + | websearch | google | execute params | lazy | works | 138 + | websearch | ddg | execute params | lazy | works | 139 + | websearch | bing | execute params | lazy | works | 140 + | websearch | wiki | execute params | lazy | works | 141 + | widget-demo | widget demo | window | eager | works | 142 + | widget-demo | demo widgets | window | eager | works | 143 + | windows | windows | execute | lazy | works | 144 + | windows | center window | execute | lazy | works | 145 + | windows | center all windows | execute | lazy | works | 146 + | windows | maximize window | execute | lazy | works | 147 + | windows | fullscreen | execute | lazy | works | 148 + | wonderwall | accounts | window | lazy | works | 149 + 150 + Features without commands (pagewidgets-sample, peeks, slides) are not 151 + user-invocable; they register via pubsub events or as background services 152 + only. 153 + 154 + ## Remaining Known Gaps 155 + 156 + These are not command-level bugs but v2 API coverage issues that will surface 157 + once specific features are exercised. All are marked as explicit 158 + "not implemented" rather than silently returning success, so failures are 159 + loud. 160 + 161 + - `tile:datastore:get/set/query` — no feature currently uses them; the 162 + `api.datastore.*` v1-compat shims give features the same access they had 163 + under v1. 164 + - `api.web.*` — if any feature uses `window.app.web.*` it will error; currently 165 + unused by v2 features audited. 166 + - `api.network.fetch` — the capability-gated fetch is wired, but features 167 + typically use standard `fetch()` directly. No regressions observed. 168 + 169 + ## Verification Strategy 170 + 171 + Because the cmd panel routes everything through `cmd:execute:{name}` on 172 + pubsub, all 72 commands share the same invocation path. Fixing the 173 + `api.commands.register({...})` signature + the `tile:window:open` 174 + delegation unblocks every command at once. Spot-checked the hot paths: 175 + 176 + - `editor` → `editor:open` event + `cmd:execute:editor` both wired 177 + - `groups:open groups` → window action + `tile:window:open` delegation 178 + - `sync:Sync now` → declarative publish (untouched by these fixes; already 179 + worked in main.ts `executeDeclarativeAction`) 180 + - `websearch:google {query}` → param mode + `cmd:execute:google` relay