diff --git a/src/web/public/host-wake-ui.js b/src/web/public/host-wake-ui.js index bade31d7..f43c4774 100644 --- a/src/web/public/host-wake-ui.js +++ b/src/web/public/host-wake-ui.js @@ -85,10 +85,12 @@ Object.assign(CodemanApp.prototype, { const sessionId = this.activeSessionId; const session = sessionId && this.sessions ? this.sessions.get(sessionId) : null; if (!sessionId || !session || !session.remote) { - if (this._hostWake) { - this._hostWake = null; - this._renderHostWakeBanner(); - } + // Render unconditionally: `refreshHostWakeBanner` clears `_hostWake` BEFORE + // calling this tick, so a guard here would skip the repaint and leave the + // banner up on every chat (the clear and the repaint must not be coupled to + // whoever cleared the state). Idempotent — with a null state it just hides. + this._hostWake = null; + this._renderHostWakeBanner(); return; } let state = this._hostWake; diff --git a/test/host-wake-banner.test.ts b/test/host-wake-banner.test.ts new file mode 100644 index 00000000..3b5675f2 --- /dev/null +++ b/test/host-wake-banner.test.ts @@ -0,0 +1,122 @@ +// Port: none (pure frontend module in a node VM with a fake DOM — no browser, no server). +// +// The remote-host wake banner (src/web/public/host-wake-ui.js) is a SINGLE global +// element that is shown only for the active remote session. The regression this +// guards: `refreshHostWakeBanner` clears `_hostWake` when the tab switches, but +// `_hostWakeTick`'s clear branch only re-rendered when IT was the one clearing — +// so switching from an unreachable remote session to a LOCAL one left the banner +// visible ("Hufflepuff is not reachable") on every chat until a full reload. +// +// The bug is a pure ordering problem between two methods, so it can be reproduced +// here without a browser: render the remote state, switch to a local session, and +// assert the banner is hidden again. +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it } from 'vitest'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); + +const REMOTE_ID = 'remote-session-0001'; +const LOCAL_ID = 'local-session-0001'; + +type El = { hidden: boolean; textContent: string; disabled: boolean; classList: { add(): void; remove(): void } }; + +function fakeElement(): El { + return { hidden: false, textContent: '', disabled: false, classList: { add() {}, remove() {} } }; +} + +/** Load `host-wake-ui.js` with the minimal DOM it touches, and return a wired app. */ +function loadWakeApp() { + const elements = new Map([ + ['hostWakeBanner', fakeElement()], + ['hostWakeBannerText', fakeElement()], + ['hostWakeBannerDetail', fakeElement()], + ['hostWakeBannerAction', fakeElement()], + ]); + const CodemanApp = function CodemanApp(this: unknown) {}; + const context = vm.createContext({ + CodemanApp, + console, + setInterval: () => 1, + clearInterval: () => {}, + fetch: () => Promise.resolve({ json: () => Promise.resolve({ success: false }) }), + document: { + visibilityState: 'visible', + getElementById: (id: string) => elements.get(id) ?? null, + addEventListener: () => {}, + }, + window: {}, + }); + vm.runInContext(readFileSync(resolve(PUBLIC, 'host-wake-ui.js'), 'utf8'), context, { filename: 'host-wake-ui.js' }); + + const app = new (CodemanApp as new () => Record)(); + app.$ = (id: string) => elements.get(id) ?? null; + app.activeSessionId = REMOTE_ID; + app.sessions = new Map }>([ + [ + REMOTE_ID, + { remote: { hostId: 'hufflepuff', host: '192.168.50.137', label: 'Hufflepuff', wakeMac: '04:d9:f5:80:c6:58' } }, + ], + [LOCAL_ID, {}], + ]); + return { app, banner: elements.get('hostWakeBanner') as El, text: elements.get('hostWakeBannerText') as El }; +} + +describe('host wake banner visibility', () => { + it('hides the banner when switching from an unreachable remote session to a local one', () => { + const { app, banner, text } = loadWakeApp(); + + // The banner is up for the active, unreachable remote session. + app._hostWake = { + sessionId: REMOTE_ID, + reachable: false, + wakeConfigured: 'mac', + host: '192.168.50.137', + label: 'Hufflepuff', + waking: false, + error: '', + }; + (app._renderHostWakeBanner as () => void)(); + expect(banner.hidden).toBe(false); + expect(text.textContent).toBe('Hufflepuff is not reachable'); + + // Switch to a LOCAL session. `refreshHostWakeBanner` clears the state, and the + // tick that follows must still repaint the (now empty) banner as hidden. + app.activeSessionId = LOCAL_ID; + (app.refreshHostWakeBanner as (id: string) => void)(LOCAL_ID); + + expect(app._hostWake).toBeNull(); + expect(banner.hidden).toBe(true); + }); + + it('keeps the banner hidden on a later poller tick once the state is cleared', () => { + const { app, banner } = loadWakeApp(); + app.activeSessionId = LOCAL_ID; + app._hostWake = null; + // A page-wide tick on a local session must be idempotent and leave it hidden. + (app._hostWakeTick as () => void)(); + expect(banner.hidden).toBe(true); + }); + + it('shows the banner only while the active session is remote and unreachable', () => { + const { app, banner } = loadWakeApp(); + app._hostWake = { + sessionId: REMOTE_ID, + reachable: false, + wakeConfigured: 'mac', + host: '192.168.50.137', + label: 'Hufflepuff', + waking: false, + error: '', + }; + (app._renderHostWakeBanner as () => void)(); + expect(banner.hidden).toBe(false); + + // Reachable again → hidden, state intact (the banner must not leak across the + // reachable/unreachable transition either). + app._hostWake.reachable = true; + (app._renderHostWakeBanner as () => void)(); + expect(banner.hidden).toBe(true); + }); +});