fix(remote): keep the wake banner hidden after switching to a local session

refreshHostWakeBanner clears _hostWake before calling _hostWakeTick, so the
clear branch's `if (this._hostWake)` guard skipped the repaint: once the
banner had appeared for an unreachable remote session it stayed up on every
chat (local ones included) until a reload, and the 30s ticker never cleared
it either. Render unconditionally in that branch — _renderHostWakeBanner is
idempotent with a null state.

Reproduced in a real browser (Puppeteer, mobile viewport): state went null
but banner.hidden stayed false. Regression test added in
test/host-wake-banner.test.ts (red before, green after).
This commit is contained in:
Randalix
2026-09-16 10:39:48 +02:00
parent 4a30f510e6
commit a7f74f374f
2 changed files with 128 additions and 4 deletions
+6 -4
View File
@@ -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;
+122
View File
@@ -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<string, El>([
['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<string, unknown>)();
app.$ = (id: string) => elements.get(id) ?? null;
app.activeSessionId = REMOTE_ID;
app.sessions = new Map<string, { remote?: Record<string, unknown> }>([
[
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);
});
});