mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 06:29:42 +02:00
- 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>
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 });
|
|
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');
|
|
});
|
|
});
|