experiments in a post-browser web
10
fork

Configure Feed

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

fix(tile-ipc): allow cmd:execute:* as infra topic for commands capability

The tile capability allowlist was silently dropping `cmd:execute:
{name}:result:{ts}:{rand}` publishes from tile command handlers
because the result topic pattern isn't in any tile's manifest
`pubsub.topics` array — and realistically can't be, since it's a
runtime-generated topic owned by the cmd system.

Symptom from manual testing: user dispatches a programmatic command
(e.g. "hello"), the tile's handler runs (window shows up), but the
cmd panel's spinner stays on screen until the 30s proxy-command
timeout. The result reply never reaches the panel because
`tile-ipc.ts::tile:pubsub:publish` rejects the topic for the
handler's tile.

Fix: treat any `cmd:execute:*` topic as infrastructure, alongside
the existing `cmd:register` / `cmd:register-batch` / `noun:*` set.
Gated on the tile having the `commands` capability so non-command
tiles can't spoof dispatch topics. This covers both the dispatch
direction (main → handler) and the reply direction (handler →
panel).

- `backend/electron/tile-ipc.ts` — extend `isInfraTopic` with a
`cmd:execute:` prefix check.

- `tests/desktop/cmd-execute-twice.spec.ts` — new Playwright repro
that publishes `cmd:execute:hello` three times and asserts each
gets a result back. Fires the dispatch directly on the pubsub
bus (bypassing the panel UI) so the test pins the round-trip,
not the panel close behavior. Waits for hello-world to be
running AND for `cmd:query-commands` to include `hello` before
firing — firing too early otherwise delivers to zero
subscribers.

Tests: 2249/2249 unit + 239/239 Playwright (219 desktop + 20
desktop-serial, includes the new repro).

+114
+8
backend/electron/tile-ipc.ts
··· 510 510 'cmd:register-batch', 511 511 'cmd:unregister', 512 512 ]); 513 + // `cmd:execute:*` covers both the dispatch topic and its result 514 + // reply (e.g. `cmd:execute:hello:result:1776…:abc`). A tile's 515 + // command handler MUST be able to publish the result-reply back 516 + // to the cmd panel; without this exemption the reply is silently 517 + // dropped and the panel's proxy times out at 30s, leaving a 518 + // spinner on screen. Gated on the `commands` capability so non- 519 + // command tiles can't spoof dispatch topics. 513 520 const isInfraTopic = hasCapability(grant, 'commands') && ( 514 521 INFRA_EXACT_TOPICS.has(args.topic) || 522 + args.topic.startsWith('cmd:execute:') || 515 523 args.topic.startsWith('noun:') 516 524 ); 517 525
+106
tests/desktop/cmd-execute-twice.spec.ts
··· 1 + /** 2 + * Repro: dispatching the same `cmd:execute:X` topic twice in a row 3 + * should work both times. Manual testing shows the second invocation 4 + * stalls — the result never comes back. This test pins that behavior 5 + * down so the fix is verifiable. 6 + * 7 + * Uses the hello-world tile because its `hello` command is the 8 + * simplest programmatic command in the codebase: a single tile entry 9 + * (resident `home`) that registers `hello` via 10 + * `api.commands.register(...)` with an async execute that returns a 11 + * plain object. 12 + */ 13 + 14 + import { test, expect, getSharedApp, type DesktopApp } from '../fixtures/desktop-app'; 15 + import { Page } from '@playwright/test'; 16 + import { waitForExtensionsReady } from '../helpers/window-utils'; 17 + 18 + let sharedApp: DesktopApp; 19 + let sharedBg: Page; 20 + 21 + test.beforeAll(async () => { 22 + sharedApp = await getSharedApp(); 23 + sharedBg = await sharedApp.getBackgroundWindow(); 24 + await waitForExtensionsReady(sharedBg); 25 + 26 + // Wait for hello-world's command registration to actually reach the 27 + // main-process pubsub bus. `waitForExtensionsReady` gates on extension 28 + // *load* but not on the `cmd:register`/`cmd:register-batch` round- 29 + // trip that announces `hello` to the cmd panel. Firing before that 30 + // round-trip means zero subscribers exist for `cmd:execute:hello`. 31 + // 32 + // Approach: poll `api.extensions.list()` for hello-world === running, 33 + // THEN do a single pubsub query-round-trip to confirm the command is 34 + // registered. 35 + await sharedBg.waitForFunction(async () => { 36 + const api = (window as { app?: Record<string, unknown> }).app as unknown as { 37 + extensions: { list: () => Promise<{ success: boolean; data?: Array<{ id: string; status: string }> }> }; 38 + subscribe: (topic: string, cb: (msg: unknown) => void, scope: number) => () => void; 39 + publish: (topic: string, data: unknown, scope: number) => void; 40 + scopes: { GLOBAL: number }; 41 + }; 42 + try { 43 + const list = await api.extensions.list(); 44 + const running = list?.data?.some(e => e.id === 'hello-world' && e.status === 'running'); 45 + if (!running) return false; 46 + } catch { 47 + return false; 48 + } 49 + // Now verify `hello` is in the cmd-panel's known command set. 50 + return await new Promise<boolean>((resolve) => { 51 + const unsubscribe = api.subscribe('cmd:query-commands-response', (msg: unknown) => { 52 + unsubscribe?.(); 53 + const cmds = (msg as { commands?: Array<{ name: string }> })?.commands || []; 54 + resolve(cmds.some(c => c.name === 'hello')); 55 + }, api.scopes.GLOBAL); 56 + api.publish('cmd:query-commands', {}, api.scopes.GLOBAL); 57 + setTimeout(() => { unsubscribe?.(); resolve(false); }, 200); 58 + }); 59 + }, null, { timeout: 15000 }); 60 + }); 61 + 62 + /** 63 + * Publish `cmd:execute:<name>` with a fresh resultTopic; await the 64 + * reply or resolve undefined after `timeoutMs`. Mirrors what the cmd 65 + * panel's proxy-command does in app/cmd/commands.js. 66 + */ 67 + async function invoke(bg: Page, name: string, timeoutMs = 5000): Promise<unknown> { 68 + return bg.evaluate(async ({ cmdName, timeout }) => { 69 + const api = (window as { app?: Record<string, unknown> }).app as unknown as { 70 + subscribe: (topic: string, cb: (msg: unknown) => void, scope: number) => () => void; 71 + publish: (topic: string, data: unknown, scope: number) => void; 72 + scopes: { GLOBAL: number }; 73 + }; 74 + return new Promise<unknown>((resolve) => { 75 + const resultTopic = `cmd:execute:${cmdName}:result:${Date.now()}:${Math.random().toString(36).slice(2, 7)}`; 76 + let settled = false; 77 + const unsubscribe = api.subscribe(resultTopic, (result: unknown) => { 78 + if (settled) return; 79 + settled = true; 80 + unsubscribe?.(); 81 + resolve(result); 82 + }, api.scopes.GLOBAL); 83 + api.publish(`cmd:execute:${cmdName}`, { expectResult: true, resultTopic }, api.scopes.GLOBAL); 84 + setTimeout(() => { 85 + if (settled) return; 86 + settled = true; 87 + unsubscribe?.(); 88 + resolve(undefined); 89 + }, timeout); 90 + }); 91 + }, { cmdName: name, timeout: timeoutMs }); 92 + } 93 + 94 + test('cmd:execute:hello returns a result on repeat invocations', async () => { 95 + // First invocation — expected to succeed per manual repro. 96 + const first = await invoke(sharedBg, 'hello', 10000); 97 + expect(first, 'first invocation result').toBeDefined(); 98 + 99 + // Second invocation — user-reported stall happens here. 100 + const second = await invoke(sharedBg, 'hello', 10000); 101 + expect(second, 'second invocation result (user-reported stall)').toBeDefined(); 102 + 103 + // Third invocation — sanity check that it's not just a "works once" fluke. 104 + const third = await invoke(sharedBg, 'hello', 10000); 105 + expect(third, 'third invocation result').toBeDefined(); 106 + });