mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 23:19:43 +02:00
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).
123 lines
4.7 KiB
TypeScript
123 lines
4.7 KiB
TypeScript
// 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);
|
|
});
|
|
});
|