Files
Codeman/test/url-session-fragment.test.ts
T
Codeman maintainer 846c62fbf7 fix(web): bound a pending #session= link and retire it on Home or a web tab (#507 review)
- A #session=<id> link whose session never appears (closed, a typo, or
  another user's session in multi-user mode) is dropped after
  URL_SESSION_WAIT_MS (30 s) with a "Session not found" toast instead of
  waiting forever. One stored timer per link, cleared whenever the link is
  followed, replaced by a newer link, or retired.
- goHome() and opening a web tab now retire a waiting link, so a session
  that turns up later no longer takes the screen. App-made web tab opens
  (frame self-recovery, the fallback after the active web tab closes) pass
  auto: true and keep it, as selectSession() does.
- zh-CN translation for the new toast.
- selectSession's auto: true comment now lists the #session=<id> link.
- docs: the 30 s bound, a win.location.replace() tip that avoids piling up
  history entries, and the fragment declared a stable SemVer surface in
  versioning-policy.md.
- Tests: timeout drops and toasts, an early arrival is still selected, the
  wait does not restart, goHome and a web tab retire it, an auto web tab
  open keeps it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 11:20:14 +02:00

313 lines
13 KiB
TypeScript

// test/url-session-fragment.test.ts
// Port: N/A (no server/browser — loads constants.js and app.js via `vm`, like session-select-ack-gate.test.ts).
//
// A page that holds the dashboard's window switches its tab with a
// `#session=<id>` link, and sessionIdFromFragment() is what reads the link.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { performance } from 'node:perf_hooks';
import { afterEach, describe, expect, it, vi } from 'vitest';
function loadHelper() {
const context = vm.createContext({ window: {}, globalThis: {}, URLSearchParams });
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
vm.runInContext(source, context, { filename: 'constants.js' });
return (context.window as { CodemanUrlSession: { sessionIdFromFragment: (hash: unknown) => string | null } })
.CodemanUrlSession;
}
describe('CodemanUrlSession.sessionIdFromFragment', () => {
const { sessionIdFromFragment } = loadHelper();
it('reads the id from a #session= fragment', () => {
expect(sessionIdFromFragment('#session=76763752-fa3a-40aa-a025-e1684c82d00e')).toBe(
'76763752-fa3a-40aa-a025-e1684c82d00e'
);
});
it('accepts the fragment without its leading #', () => {
expect(sessionIdFromFragment('session=abc')).toBe('abc');
});
it('decodes an encoded id', () => {
expect(sessionIdFromFragment('#session=' + encodeURIComponent('w1 my/app'))).toBe('w1 my/app');
});
it('finds the id beside other fragment parameters', () => {
expect(sessionIdFromFragment('#tab=2&session=abc')).toBe('abc');
});
it('asks for nothing when the fragment names no session', () => {
expect(sessionIdFromFragment('')).toBeNull();
expect(sessionIdFromFragment('#')).toBeNull();
expect(sessionIdFromFragment('#settings')).toBeNull();
expect(sessionIdFromFragment('#session=')).toBeNull();
expect(sessionIdFromFragment('#session=%20')).toBeNull();
expect(sessionIdFromFragment(undefined)).toBeNull();
});
});
// The dashboard side: reading the link, holding an id it does not list yet,
// and handing the selection over. Loaded like session-select-ack-gate.test.ts,
// on a bare instance whose DOM-touching methods are stubbed. webview-tabs.js
// rides along because opening a web tab is one of the ways a waiting link ends.
function loadApp() {
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
const app = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const webviewTabs = readFileSync(resolve(import.meta.dirname, '../src/web/public/webview-tabs.js'), 'utf8');
const location = { hash: '', pathname: '/', search: '' };
const history = {
state: null,
replaceState: vi.fn((_state: unknown, _title: string, url: string) => {
location.hash = url.includes('#') ? url.slice(url.indexOf('#')) : '';
}),
};
const context = vm.createContext({
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
performance,
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: { OPEN: 1 },
fetch: vi.fn(),
URLSearchParams,
location,
history,
document: { addEventListener: vi.fn(), getElementById: () => null, querySelector: () => null },
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
MobileDetection: { isTouchDevice: () => false },
});
vm.runInContext(
`${constants}\n${app}\n${webviewTabs}\nglobalThis.__CodemanApp = CodemanApp;\nglobalThis.__waitMs = URL_SESSION_WAIT_MS;`,
context
);
const CodemanApp = (context as { __CodemanApp: { prototype: object } }).__CodemanApp;
const waitMs = (context as { __waitMs: number }).__waitMs;
const make = (ids: string[]) => {
const inst = Object.create(CodemanApp.prototype) as Record<string, any>;
inst.sessions = new Map(ids.map((id) => [id, { id, name: id }]));
inst.sessionOrder = [...ids];
inst.detachedSessions = new Set();
inst.detachedWindows = new Map();
inst.isSoloWindow = false;
inst._urlSessionId = null;
inst._urlSessionWaitTimer = null;
inst.selectSession = vi.fn();
inst.showToast = vi.fn();
for (const stub of [
'saveSessionOrder',
'markSessionTabEntering',
'markTerminalEntering',
'renderSessionTabs',
'updateCost',
'startSystemStatsPolling',
]) {
inst[stub] = vi.fn();
}
return inst;
};
return { make, location, history, CodemanApp, waitMs };
}
describe('dashboard handling of a #session=<id> link', () => {
it('reads the link and removes the fragment, so the same link counts as a change next time', () => {
const { make, location, history } = loadApp();
const app = make(['a']);
location.hash = '#session=a';
expect(app._takeUrlSession()).toBe('a');
expect(history.replaceState).toHaveBeenCalledWith(null, '', '/');
expect(location.hash).toBe('');
});
it('leaves a URL without a session link alone', () => {
const { make, location, history } = loadApp();
location.hash = '#settings';
expect(make([])._takeUrlSession()).toBeNull();
expect(history.replaceState).not.toHaveBeenCalled();
});
it('selects a listed session as an app selection, which leaves its idle alert armed', () => {
const { make } = loadApp();
const app = make(['a']);
app._urlSessionId = 'a';
expect(app._selectUrlSession()).toBe(true);
expect(app.selectSession).toHaveBeenCalledWith('a', { auto: true });
expect(app._urlSessionId).toBeNull();
});
it('holds an unlisted id until session:created names it', () => {
const { make } = loadApp();
const app = make([]);
app._urlSessionId = 'new';
expect(app._selectUrlSession()).toBe(false);
expect(app.selectSession).not.toHaveBeenCalled();
app._onSessionCreated({ id: 'other', name: 'other' });
expect(app.selectSession).not.toHaveBeenCalled();
app._onSessionCreated({ id: 'new', name: 'new' });
expect(app.selectSession).toHaveBeenCalledWith('new', { auto: true });
expect(app._urlSessionId).toBeNull();
});
it('retires a waiting link when you pick another tab yourself', async () => {
const { make, CodemanApp } = loadApp();
const app = make(['a', 'b']);
app.selectSession = (CodemanApp.prototype as Record<string, any>).selectSession;
app._urlSessionId = 'later';
await app.selectSession('b').catch(() => {});
expect(app._urlSessionId).toBeNull();
});
it('keeps a waiting link through a selection the app makes itself', async () => {
const { make, CodemanApp } = loadApp();
const app = make(['a', 'b']);
app.selectSession = (CodemanApp.prototype as Record<string, any>).selectSession;
app._urlSessionId = 'later';
await app.selectSession('b', { auto: true }).catch(() => {});
expect(app._urlSessionId).toBe('later');
});
it('starts the wait for an unlisted link once the page has loaded its session list', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const link = source.indexOf('if (this._urlSessionId && this.sessions.has(this._urlSessionId))');
const wait = source.indexOf('if (this._urlSessionId) this._armUrlSessionWait(this._urlSessionId);');
const restore = source.indexOf("restoreId = localStorage.getItem('codeman-active-session')");
expect(wait).toBeGreaterThan(link);
expect(wait).toBeLessThan(restore);
});
it('puts the link ahead of restoring the last active tab when the page loads', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const link = source.indexOf('if (this._urlSessionId && this.sessions.has(this._urlSessionId))');
const restore = source.indexOf("restoreId = localStorage.getItem('codeman-active-session')");
expect(link).toBeGreaterThan(-1);
expect(link).toBeLessThan(restore);
});
it('never reads the link in a solo window', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
expect(source).toContain('this._urlSessionId = this.isSoloWindow ? null : this._takeUrlSession();');
expect(source).toMatch(/if \(!this\.isSoloWindow\) \{\s*window\.addEventListener\('hashchange'/);
});
});
// A link whose session never turns up: a stale link (the session is closed), a
// typo, or in multi-user mode another user's session, which is never in this
// client's list. It must not wait forever with nothing on screen, and choosing
// something else must end it, or a session turning up later takes the screen.
describe('a #session=<id> link that is still waiting', () => {
afterEach(() => {
vi.useRealTimers();
});
it('waits 30 seconds', () => {
expect(loadApp().waitMs).toBe(30_000);
});
it('is dropped with a toast when its session has not appeared in time', () => {
vi.useFakeTimers();
const { make, waitMs } = loadApp();
const app = make([]);
app._urlSessionId = 'gone';
expect(app._selectUrlSession()).toBe(false);
vi.advanceTimersByTime(waitMs - 1);
expect(app._urlSessionId).toBe('gone');
expect(app.showToast).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(app._urlSessionId).toBeNull();
expect(app._urlSessionWaitTimer).toBeNull();
expect(app.showToast).toHaveBeenCalledWith('Session not found', 'warning');
// Retired for good: the session turning up afterwards does not take the tab.
app._onSessionCreated({ id: 'gone', name: 'gone' });
expect(app.selectSession).not.toHaveBeenCalled();
});
it('still selects a session that arrives before the wait runs out, and stops the timer', () => {
vi.useFakeTimers();
const { make, waitMs } = loadApp();
const app = make([]);
app._urlSessionId = 'new';
app._selectUrlSession();
vi.advanceTimersByTime(waitMs - 1);
app._onSessionCreated({ id: 'new', name: 'new' });
expect(app.selectSession).toHaveBeenCalledWith('new', { auto: true });
expect(app._urlSessionWaitTimer).toBeNull();
vi.advanceTimersByTime(waitMs);
expect(app.showToast).not.toHaveBeenCalled();
});
it('does not restart its wait when handleInit asks again', () => {
vi.useFakeTimers();
const { make, waitMs } = loadApp();
const app = make([]);
app._urlSessionId = 'gone';
app._armUrlSessionWait('gone');
vi.advanceTimersByTime(waitMs - 1000);
app._armUrlSessionWait('gone');
vi.advanceTimersByTime(1000);
expect(app._urlSessionId).toBeNull();
expect(app.showToast).toHaveBeenCalledTimes(1);
});
it('is retired by going Home', () => {
vi.useFakeTimers();
const { make, waitMs } = loadApp();
const app = make([]);
app.terminal = { clear: vi.fn() };
app.showWelcome = vi.fn();
app.renderRalphStatePanel = vi.fn();
app._urlSessionId = 'later';
app._selectUrlSession();
app.goHome();
expect(app._urlSessionId).toBeNull();
expect(app._urlSessionWaitTimer).toBeNull();
app._onSessionCreated({ id: 'later', name: 'later' });
expect(app.selectSession).not.toHaveBeenCalled();
vi.advanceTimersByTime(waitMs);
expect(app.showToast).not.toHaveBeenCalled();
});
function withWebTab(app: Record<string, any>) {
const webview = { id: 'dash', name: 'Dash', url: 'http://127.0.0.1:8080/' };
app.webviews = new Map([['dash', webview]]);
app.webviewOrder = ['dash'];
app._persistWebviewOrder = vi.fn();
app._apiJson = vi.fn(async () => ({ webview, embedUrl: '/webview/cap/' }));
app._mountWebviewFrame = vi.fn();
app.hideWelcome = vi.fn();
app._updateActiveWebviewTab = vi.fn();
app.closeSessionSidebarOnHandheld = vi.fn();
return app;
}
it('is retired by opening a web tab', async () => {
vi.useFakeTimers();
const { make, waitMs } = loadApp();
const app = withWebTab(make([]));
app._urlSessionId = 'later';
app._selectUrlSession();
const opening = app.openWebview('dash');
// Before the open's await: a session:created landing inside it finds no link.
expect(app._urlSessionId).toBeNull();
expect(app._urlSessionWaitTimer).toBeNull();
await opening;
expect(app.activeWebviewId).toBe('dash');
app._onSessionCreated({ id: 'later', name: 'later' });
expect(app.selectSession).not.toHaveBeenCalled();
vi.advanceTimersByTime(waitMs);
expect(app.showToast).not.toHaveBeenCalled();
});
it('survives a web tab the app opens itself', async () => {
const { make } = loadApp();
const app = withWebTab(make([]));
app._urlSessionId = 'later';
await app.openWebview('dash', { auto: true });
expect(app._urlSessionId).toBe('later');
});
});