mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 00:49:41 +02:00
selectSession gets the tile branch, right after its "already active" early return: a tiled session is focused in its tile (_selectTiledSession: an activeSessionId change, the shared _refreshSessionPanels and xterm.focus(), no cleanup, replay, resize or socket of the parked main terminal). Decision 1: a USER-initiated pick of a session that is not tiled leaves the grid for the single view (the grid is remembered), and so does an explicit leaveTiles; an app-driven pick (auto) never collapses it. A followed #session= link passes leaveTiles (navigation). App-driven paths pick a tile instead of the first sessionOrder entry: - closeSession on the focused tile focuses the neighbouring tile (next in grid order, else previous), captured before the await like wasActive, since the delete broadcast may already have removed the tile; the last tile closes the grid and falls back to the normal pick. - A tiled session deleted elsewhere loses its tile and a neighbour takes focus with auto (the last one lands on the welcome screen as before); a close from this tab only drops the tile and leaves the follow-up to closeSession. - A tiled session popped out to its own window leaves the grid. Focus rules: pressing a tile is a human selection (pointerdown, never preventDefault); Ctrl+Tab and Alt+[ / Alt+] cycle through the tiles; only a human selection acknowledges an idle alert. Home leaves the grid (remembered); killing every session closes it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
313 lines
13 KiB
TypeScript
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, leaveTiles: 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, leaveTiles: 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, leaveTiles: 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');
|
|
});
|
|
});
|