// 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=` 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; 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= 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).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).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= 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) { 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'); }); });