// 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); }); });