···11+/**
22+ * Unit tests for tile-lazy load-on-dispatch failure UX (Phase 7).
33+ *
44+ * Exercises the two user-visible failure paths when a lazy tile can't
55+ * accept a dispatched command:
66+ * 1. Load timeout — waitForTileReady never resolves; the 10s timer
77+ * fires; a `notification:show` publish surfaces the failure and
88+ * the caller's result topic is published with `{error}` so the
99+ * cmd panel proxy promise settles immediately.
1010+ * 2. Crashed during load — `tile:state-changed` fires a
1111+ * `loading → crashed` transition while a dispatch is still in
1212+ * flight; pending dispatches reject immediately (no 10s wait).
1313+ *
1414+ * See docs/pubsub-state-machine.md §Load-on-dispatch — timeout UX.
1515+ */
1616+1717+import { describe, it, beforeEach, before, after } from 'node:test';
1818+import * as assert from 'node:assert';
1919+2020+import {
2121+ publish, subscribe, unsubscribe,
2222+ getSystemAddress,
2323+ __clearPrePublishHooksForTest,
2424+} from './pubsub.js';
2525+import {
2626+ registerLazyTile,
2727+ installLoadOnDispatchHook,
2828+ __setTileLazyLauncherForTest,
2929+ __resetTileLazyStateForTest,
3030+} from './tile-lazy.js';
3131+import type { TileManifestV2 } from './tile-manifest.js';
3232+import type { TileLaunchOptions, TileLaunchResult } from './tile-launcher.js';
3333+3434+// ─── Fake launcher that never reaches ready ──────────────────────────
3535+3636+interface FakeLauncherState {
3737+ launchCalls: TileLaunchOptions[];
3838+ loaded: Set<string>;
3939+ /**
4040+ * Unresolved ready promises — the whole point is that we never resolve
4141+ * these so the load timeout fires. Each `waitForTileReady` call
4242+ * returns a fresh pending promise.
4343+ */
4444+ pendingReady: Array<(value: void) => void>;
4545+}
4646+4747+function makeFakeLauncher(state: FakeLauncherState) {
4848+ return {
4949+ launchTile: (options: TileLaunchOptions): TileLaunchResult | void => {
5050+ state.launchCalls.push(options);
5151+ const entryId = options.entryId || options.manifest.tiles[0]?.id || 'background';
5252+ queueMicrotask(() => {
5353+ state.loaded.add(`${options.manifest.id}:${entryId}`);
5454+ });
5555+ },
5656+ waitForTileReady: (_tileId: string, _entryId: string): Promise<void> => {
5757+ // Return a promise that intentionally never resolves. The timeout
5858+ // inside tile-lazy's `loadLazyTile` is responsible for racing this.
5959+ return new Promise<void>((resolve) => {
6060+ state.pendingReady.push(resolve);
6161+ });
6262+ },
6363+ isTileLoaded: (tileId: string, entryId: string): boolean => {
6464+ return state.loaded.has(`${tileId}:${entryId}`);
6565+ },
6666+ };
6767+}
6868+6969+// ─── Manifest fixture ─────────────────────────────────────────────────
7070+7171+function makeCommandManifest(id: string, cmdName: string): TileManifestV2 {
7272+ return {
7373+ manifestVersion: 2,
7474+ id,
7575+ name: id,
7676+ builtin: true,
7777+ tiles: [
7878+ {
7979+ id: 'background',
8080+ type: 'background',
8181+ url: 'background.html',
8282+ lazy: true,
8383+ },
8484+ ],
8585+ commands: [
8686+ {
8787+ name: cmdName,
8888+ description: `${cmdName} description`,
8989+ action: { type: 'execute' },
9090+ },
9191+ ],
9292+ capabilities: {
9393+ pubsub: { scopes: ['self', 'global'] },
9494+ },
9595+ };
9696+}
9797+9898+function flushMicrotasks(): Promise<void> {
9999+ return new Promise(resolve => setImmediate(resolve));
100100+}
101101+102102+// ─── Tests ────────────────────────────────────────────────────────────
103103+104104+describe('tile-lazy load-on-dispatch timeout UX (Phase 7)', () => {
105105+ let launcherState: FakeLauncherState;
106106+ // Collected notification:show publishes.
107107+ const notifications: Array<{
108108+ type?: string;
109109+ title?: string;
110110+ body?: string;
111111+ context?: { tileId?: string; cmdName?: string; reason?: string };
112112+ }> = [];
113113+ const notifySource = 'peek://tile-lazy-timeout-test/notify-observer';
114114+115115+ before(() => {
116116+ subscribe(notifySource, 'notification:show', (msg: unknown) => {
117117+ notifications.push(msg as typeof notifications[number]);
118118+ });
119119+ });
120120+121121+ after(() => {
122122+ unsubscribe(notifySource, 'notification:show');
123123+ });
124124+125125+ beforeEach(() => {
126126+ __resetTileLazyStateForTest();
127127+ __clearPrePublishHooksForTest();
128128+ installLoadOnDispatchHook();
129129+ launcherState = {
130130+ launchCalls: [],
131131+ loaded: new Set(),
132132+ pendingReady: [],
133133+ };
134134+ __setTileLazyLauncherForTest(makeFakeLauncher(launcherState));
135135+ notifications.length = 0;
136136+ });
137137+138138+ it('publishes a structured notification and resolves the resultTopic with error on load timeout', async () => {
139139+ const manifest = makeCommandManifest('tile-timeout-1', 'slow-cmd');
140140+ registerLazyTile(manifest, '/fake/path', '/fake/preload.js');
141141+142142+ // Collect whatever comes back on the resultTopic so we can assert
143143+ // the proxy's promise settles (rather than hanging 30s).
144144+ const resultTopic = 'cmd:execute:slow-cmd:result:unit-test-1';
145145+ let resultPayload: unknown = null;
146146+ subscribe('peek://test-dispatcher/result', resultTopic, (msg) => {
147147+ resultPayload = msg;
148148+ });
149149+150150+ // Use node:test's mock timers so we can jump past LAZY_LOAD_TIMEOUT_MS
151151+ // (10000ms) without waiting 10 real seconds.
152152+ const { mock } = await import('node:test');
153153+ mock.timers.enable({ apis: ['setTimeout'] });
154154+155155+ try {
156156+ publish('peek://test-dispatcher', 'cmd:execute:slow-cmd', {
157157+ expectResult: true,
158158+ resultTopic,
159159+ });
160160+161161+ // Let the hook register the pending dispatch and kick off the
162162+ // (pending, never-resolving) launch.
163163+ for (let i = 0; i < 5; i++) await flushMicrotasks();
164164+ assert.strictEqual(launcherState.launchCalls.length, 1,
165165+ 'tile should have been launched');
166166+167167+ // Fire the load timer. reportDispatchFailure runs in the same tick,
168168+ // so both the notification and the resultTopic publish happen
169169+ // synchronously before the timeout handler's reject propagates.
170170+ mock.timers.tick(11000);
171171+ for (let i = 0; i < 10; i++) await flushMicrotasks();
172172+ } finally {
173173+ mock.timers.reset();
174174+ }
175175+176176+ // Notification assertions — structured error shape the plan spec'd.
177177+ assert.strictEqual(notifications.length, 1,
178178+ `expected exactly one notification:show publish, got ${notifications.length}`);
179179+ const n = notifications[0];
180180+ assert.strictEqual(n.type, 'error');
181181+ assert.ok(n.title && n.title.toLowerCase().includes("didn't load"),
182182+ `notification title should signal load failure, got: ${n.title}`);
183183+ assert.ok(n.body && n.body.includes('slow-cmd'),
184184+ `notification body should name the command, got: ${n.body}`);
185185+ assert.ok(n.body && /10000|10 second/.test(n.body),
186186+ `notification body should mention the timeout duration, got: ${n.body}`);
187187+ assert.strictEqual(n.context?.tileId, 'tile-timeout-1');
188188+ assert.strictEqual(n.context?.cmdName, 'slow-cmd');
189189+ assert.strictEqual(n.context?.reason, 'timeout');
190190+191191+ // Proxy promise settlement — resultTopic must have been published
192192+ // with a structured error payload, so the cmd panel's subscribe
193193+ // resolves immediately instead of hanging on its 30s fallback timer.
194194+ assert.ok(resultPayload,
195195+ 'resultTopic should have been published (dispatcher promise hung otherwise)');
196196+ const rp = resultPayload as { error?: string };
197197+ assert.ok(typeof rp.error === 'string' && rp.error.length > 0,
198198+ `resultTopic payload should carry an error message, got: ${JSON.stringify(rp)}`);
199199+200200+ unsubscribe('peek://test-dispatcher/result', resultTopic);
201201+ });
202202+203203+ it('rejects pending dispatches immediately when the tile transitions loading → crashed', async () => {
204204+ const manifest = makeCommandManifest('tile-crash-2', 'crashy-cmd');
205205+ registerLazyTile(manifest, '/fake/path', '/fake/preload.js');
206206+207207+ const resultTopic = 'cmd:execute:crashy-cmd:result:unit-test-2';
208208+ let resultPayload: unknown = null;
209209+ subscribe('peek://test-dispatcher/result', resultTopic, (msg) => {
210210+ resultPayload = msg;
211211+ });
212212+213213+ // Kick off the dispatch — the hook will register a pending entry
214214+ // against `tile-crash-2` and begin awaiting waitForTileReady (which
215215+ // never resolves in this fixture).
216216+ publish('peek://test-dispatcher', 'cmd:execute:crashy-cmd', {
217217+ expectResult: true,
218218+ resultTopic,
219219+ });
220220+ for (let i = 0; i < 5; i++) await flushMicrotasks();
221221+222222+ assert.strictEqual(launcherState.launchCalls.length, 1);
223223+224224+ // Simulate the `loading → crashed` transition. The tile-lifecycle
225225+ // engine publishes `tile:state-changed` as System when a render
226226+ // process crashes during load; the crash observer in tile-lazy
227227+ // subscribes to that topic and drains pending dispatches. Publish
228228+ // directly here so we don't depend on wiring a real BrowserWindow.
229229+ publish(getSystemAddress(), 'tile:state-changed', {
230230+ tileId: 'tile-crash-2',
231231+ entryId: 'background',
232232+ from: 'loading',
233233+ to: 'crashed',
234234+ trigger: 'RENDER_GONE',
235235+ ts: Date.now(),
236236+ });
237237+238238+ // Observer runs synchronously under the publish; a microtask flush
239239+ // covers any async subscribe handlers elsewhere.
240240+ for (let i = 0; i < 3; i++) await flushMicrotasks();
241241+242242+ assert.strictEqual(notifications.length, 1,
243243+ `expected one notification, got ${notifications.length}`);
244244+ const n = notifications[0];
245245+ assert.strictEqual(n.type, 'error');
246246+ assert.ok(n.body && n.body.toLowerCase().includes('crashed'),
247247+ `crashed-path body should mention 'crashed', got: ${n.body}`);
248248+ assert.strictEqual(n.context?.reason, 'crashed');
249249+ assert.strictEqual(n.context?.tileId, 'tile-crash-2');
250250+ assert.strictEqual(n.context?.cmdName, 'crashy-cmd');
251251+252252+ assert.ok(resultPayload,
253253+ 'resultTopic should have been published on crash (pending dispatch otherwise still hung)');
254254+ const rp = resultPayload as { error?: string };
255255+ assert.ok(typeof rp.error === 'string' && rp.error.length > 0,
256256+ `crashed-path resultTopic payload should carry an error, got: ${JSON.stringify(rp)}`);
257257+258258+ unsubscribe('peek://test-dispatcher/result', resultTopic);
259259+ });
260260+});
+149-7
backend/electron/tile-lazy.ts
···2828 */
29293030import {
3131- publish, registerPrePublishHook, getSystemAddress,
3131+ publish, subscribe, registerPrePublishHook, getSystemAddress,
3232 type PrePublishHookResult,
3333} from './pubsub.js';
3434import {
···8080 lazyTileRegistry.clear();
8181 loadingTiles.clear();
8282 declaredLazyEventTopics.clear();
8383+ pendingDispatchesByTile.clear();
8384 _hookInstalled = false;
8585+ _crashObserverInstalled = false;
8486}
85878688// ─── State ───────────────────────────────────────────────────────────
···105107/** Timeout for lazy load (ms) */
106108const LAZY_LOAD_TIMEOUT_MS = 10000;
107109110110+/**
111111+ * Phase 7 — Timeout UX for load-on-dispatch.
112112+ *
113113+ * Each entry in this map tracks a `cmd:execute:{name}` publish that was
114114+ * routed through the hook while its target tile was in `registered`. If
115115+ * the tile never reaches `ready` — either because the load timer fires
116116+ * or because the render process crashes during boot — we reject every
117117+ * pending dispatch for that tile with a user-visible notification
118118+ * instead of letting the cmd panel's 30s proxy timeout absorb the
119119+ * failure silently.
120120+ *
121121+ * Keyed by tileId. A tile can have multiple concurrent dispatches
122122+ * (user fires command, navigates, fires another while the first is
123123+ * still loading); each one is a separate `PendingDispatch` entry.
124124+ */
125125+interface PendingDispatch {
126126+ cmdName: string;
127127+ resultTopic: string | null;
128128+}
129129+const pendingDispatchesByTile = new Map<string, Set<PendingDispatch>>();
130130+131131+function registerPendingDispatch(tileId: string, entry: PendingDispatch): void {
132132+ let set = pendingDispatchesByTile.get(tileId);
133133+ if (!set) {
134134+ set = new Set();
135135+ pendingDispatchesByTile.set(tileId, set);
136136+ }
137137+ set.add(entry);
138138+}
139139+140140+function clearPendingDispatch(tileId: string, entry: PendingDispatch): void {
141141+ const set = pendingDispatchesByTile.get(tileId);
142142+ if (!set) return;
143143+ set.delete(entry);
144144+ if (set.size === 0) pendingDispatchesByTile.delete(tileId);
145145+}
146146+147147+function drainPendingDispatches(tileId: string): PendingDispatch[] {
148148+ const set = pendingDispatchesByTile.get(tileId);
149149+ if (!set) return [];
150150+ const entries = Array.from(set);
151151+ pendingDispatchesByTile.delete(tileId);
152152+ return entries;
153153+}
154154+155155+/**
156156+ * Publish a user-visible error notification and resolve the caller's
157157+ * result topic (if any) with a structured error — so the cmd panel's
158158+ * proxy promise settles immediately and the state machine returns to
159159+ * IDLE instead of hanging on the 30s proxy fallback timeout.
160160+ *
161161+ * The notification topic is `notification:show` per docs/pubsub-state-machine.md
162162+ * §Load-on-dispatch — timeout UX. No dedicated notification subscriber
163163+ * exists yet in Peek; any future notification surface (HUD overlay,
164164+ * toast, OS notification) listens to this topic.
165165+ */
166166+function reportDispatchFailure(
167167+ tileId: string,
168168+ entry: PendingDispatch,
169169+ reason: 'timeout' | 'crashed',
170170+ timeoutMs: number,
171171+): void {
172172+ const body =
173173+ reason === 'timeout'
174174+ ? `Command "${entry.cmdName}" couldn't run because its tile failed to load within ${timeoutMs}ms.`
175175+ : `Command "${entry.cmdName}" couldn't run because its tile crashed while loading.`;
176176+177177+ try {
178178+ publish(getSystemAddress(), 'notification:show', {
179179+ type: 'error',
180180+ title: "Tile didn't load",
181181+ body,
182182+ context: { tileId, cmdName: entry.cmdName, reason },
183183+ });
184184+ } catch (err) {
185185+ console.error('[tile-lazy] notification:show publish threw:', err);
186186+ }
187187+188188+ // Resolve the proxy's result topic with an error shape so the cmd
189189+ // panel returns to IDLE immediately. This mirrors the "error result"
190190+ // contract other handlers use (see app/cmd/commands.js proxy
191191+ // subscribe of `cmd:execute:{name}:result`).
192192+ if (entry.resultTopic) {
193193+ try {
194194+ publish(getSystemAddress(), entry.resultTopic, { error: body });
195195+ } catch (err) {
196196+ console.error(
197197+ `[tile-lazy] resultTopic publish threw for ${entry.resultTopic}:`,
198198+ err,
199199+ );
200200+ }
201201+ }
202202+}
203203+108204// ─── Registration ────────────────────────────────────────────────────
109205110206/**
···181277 * Call once at app init, before any command can fire. Idempotent.
182278 */
183279let _hookInstalled = false;
280280+let _crashObserverInstalled = false;
184281export function installLoadOnDispatchHook(): void {
185185- if (_hookInstalled) return;
186186- registerPrePublishHook(dispatchHookPredicate, dispatchHookBody);
187187- _hookInstalled = true;
282282+ if (!_hookInstalled) {
283283+ registerPrePublishHook(dispatchHookPredicate, dispatchHookBody);
284284+ _hookInstalled = true;
285285+ }
286286+ installCrashObserver();
287287+}
288288+289289+/**
290290+ * Subscribe to the lifecycle observer topic `tile:state-changed` and
291291+ * reject any pending dispatches when a tile transitions `loading →
292292+ * crashed`. Rejecting eagerly (not waiting for the 10s timeout) is
293293+ * the whole point of Phase 7's crashed-during-load path.
294294+ */
295295+function installCrashObserver(): void {
296296+ if (_crashObserverInstalled) return;
297297+ _crashObserverInstalled = true;
298298+ subscribe('peek://tile-lazy/crash-observer', 'tile:state-changed', (msg: unknown) => {
299299+ const t = msg as { tileId?: string; from?: string; to?: string } | null;
300300+ if (!t || typeof t.tileId !== 'string') return;
301301+ // Only the `loading → crashed` edge matters here. A crash from
302302+ // ready/visible is not a load failure — those are regular runtime
303303+ // crashes and aren't the concern of this path.
304304+ if (t.from !== 'loading' || t.to !== 'crashed') return;
305305+ const pending = drainPendingDispatches(t.tileId);
306306+ for (const entry of pending) {
307307+ reportDispatchFailure(t.tileId, entry, 'crashed', LAZY_LOAD_TIMEOUT_MS);
308308+ }
309309+ });
188310}
189311190312/**
···266388 // Programmatic command: load the tile (if needed), then let the
267389 // publish continue — by which point the tile's background has
268390 // subscribed its real handler.
391391+ const typedMsg = msg as { resultTopic?: string } | null;
392392+ const pending: PendingDispatch = {
393393+ cmdName: name,
394394+ resultTopic: typedMsg?.resultTopic ?? null,
395395+ };
396396+ registerPendingDispatch(tileId, pending);
269397 try {
270398 await ensureTileLoaded(tileId);
399399+ // Load succeeded — drop the pending entry so a later crash or
400400+ // timeout on a sibling dispatch doesn't fire a stale notification
401401+ // for this one.
402402+ clearPendingDispatch(tileId, pending);
271403 } catch (err) {
404404+ // If the crash observer already drained this entry (loading →
405405+ // crashed fired before the load timer), don't double-report. The
406406+ // observer removes drained entries from the map.
407407+ const set = pendingDispatchesByTile.get(tileId);
408408+ const stillPending = set?.has(pending) === true;
409409+ if (stillPending) {
410410+ clearPendingDispatch(tileId, pending);
411411+ reportDispatchFailure(tileId, pending, 'timeout', LAZY_LOAD_TIMEOUT_MS);
412412+ }
272413 console.error(`[tile-lazy] Failed to load tile ${tileId} for command ${name}:`, err);
273273- // Delivery continues anyway so that any already-registered
274274- // fallback subscriber sees the message. If none exist, the cmd
275275- // panel's result-timeout will eventually surface the failure.
414414+ // Skip delivery of the original cmd:execute publish. No handler
415415+ // is alive to receive it, and the notification + resultTopic
416416+ // error publish above have already settled the caller's promise.
417417+ return 'skip';
276418 }
277419 return 'continue';
278420}