import { describe, it, expect, vi, afterEach } from 'vitest'; import { readUiUrlState, buildUiUrlStateSearch, paramsEqualExcept, COLUMN_LABELS, COLUMN_LIST, type UiUrlState, } from './urlState'; const DEFAULT_STATE: UiUrlState = { page: 'tasks', repo: '', status: 'all', search: '', sort: 'updated', scope: 'mine', detailTab: 'overview', mobileTab: 'chat', taskId: null, }; function stubLocation(search: string) { vi.stubGlobal('window', { location: { search } }); } afterEach(() => vi.unstubAllGlobals()); describe('readUiUrlState', () => { it('returns defaults when window is undefined (SSR/node)', () => { // node environment: no window global by default expect(readUiUrlState()).toEqual(DEFAULT_STATE); }); it('returns defaults for an empty query string', () => { stubLocation(''); const state = readUiUrlState(); expect(state).toEqual({ ...DEFAULT_STATE, section: undefined, piece: undefined, }); }); it('parses every valid parameter', () => { stubLocation( '?page=settings&repo=local%2Ftask-1&status=running&q=hello&sort=title' + '&tab=files&mobileTab=activity&task=42§ion=auth&piece=chat' + '&pieceSource=user-custom&dashboardWidget=gpu&help=intro', ); const state = readUiUrlState(); expect(state.page).toBe('settings'); expect(state.repo).toBe('local/task-1'); expect(state.status).toBe('running'); expect(state.search).toBe('hello'); expect(state.sort).toBe('title'); expect(state.detailTab).toBe('files'); expect(state.mobileTab).toBe('activity'); expect(state.taskId).toBe(42); expect(state.section).toBe('auth'); expect(state.piece).toBe('chat'); expect(state.pieceSource).toBe('user-custom'); expect(state.dashboardWidget).toBe('gpu'); expect(state.help).toBe('intro'); }); it('falls back to defaults on invalid enum values', () => { stubLocation('?page=bogus&status=bogus&sort=bogus&tab=bogus&mobileTab=bogus§ion=bogus&pieceSource=bogus'); const state = readUiUrlState(); expect(state.page).toBe('tasks'); expect(state.status).toBe('all'); expect(state.sort).toBe('updated'); expect(state.detailTab).toBe('overview'); expect(state.mobileTab).toBe('chat'); expect(state.section).toBeUndefined(); expect(state.pieceSource).toBeUndefined(); }); it('rejects non-positive and non-numeric task ids', () => { for (const task of ['0', '-5', 'abc', '']) { stubLocation(`?task=${task}`); expect(readUiUrlState().taskId).toBeNull(); } stubLocation('?task=7'); expect(readUiUrlState().taskId).toBe(7); }); it('still parses legacy settings section ids (soft bookmark landing)', () => { for (const legacy of ['gateway-keys', 'provider', 'workspace', 'browser-sessions']) { stubLocation(`?section=${legacy}`); expect(readUiUrlState().section).toBe(legacy); } }); it('treats an empty piece param as undefined', () => { stubLocation('?piece='); expect(readUiUrlState().piece).toBeUndefined(); }); it('omits optional spread keys entirely when their params are absent', () => { stubLocation('?page=tasks'); const state = readUiUrlState(); expect('pieceSource' in state).toBe(false); expect('dashboardWidget' in state).toBe(false); expect('help' in state).toBe(false); }); }); describe('buildUiUrlStateSearch', () => { it('produces an empty string for the default state', () => { expect(buildUiUrlStateSearch(DEFAULT_STATE)).toBe(''); }); it('only serializes non-default values', () => { const search = buildUiUrlStateSearch({ ...DEFAULT_STATE, status: 'failed', taskId: 12, }); const params = new URLSearchParams(search); expect(params.get('status')).toBe('failed'); expect(params.get('task')).toBe('12'); expect(Array.from(params.keys()).sort()).toEqual(['status', 'task']); }); it('omits the default dashboardWidget (worker-status) but keeps others', () => { expect( buildUiUrlStateSearch({ ...DEFAULT_STATE, dashboardWidget: 'worker-status' }), ).toBe(''); expect( buildUiUrlStateSearch({ ...DEFAULT_STATE, dashboardWidget: 'gpu' }), ).toBe('dashboardWidget=gpu'); }); it('round-trips through readUiUrlState', () => { const state: UiUrlState = { page: 'help', repo: 'local/task-9', status: 'waiting_human', search: 'foo bar', sort: 'status', scope: 'all', detailTab: 'trace', mobileTab: 'files', taskId: 99, section: 'reflection', piece: 'research', pieceSource: 'builtin', dashboardWidget: 'queue', help: 'pieces', }; stubLocation(`?${buildUiUrlStateSearch(state)}`); expect(readUiUrlState()).toEqual(state); }); }); describe('paramsEqualExcept', () => { it('considers identical params equal', () => { const a = new URLSearchParams('a=1&b=2'); const b = new URLSearchParams('a=1&b=2'); expect(paramsEqualExcept(a, b, [])).toBe(true); }); it('ignores key order', () => { const a = new URLSearchParams('b=2&a=1'); const b = new URLSearchParams('a=1&b=2'); expect(paramsEqualExcept(a, b, [])).toBe(true); }); it('detects value differences', () => { const a = new URLSearchParams('a=1'); const b = new URLSearchParams('a=2'); expect(paramsEqualExcept(a, b, [])).toBe(false); }); it('skips ignored keys on either side', () => { const a = new URLSearchParams('a=1&tab=files'); const b = new URLSearchParams('a=1&tab=trace'); expect(paramsEqualExcept(a, b, ['tab'])).toBe(true); const c = new URLSearchParams('a=1'); expect(paramsEqualExcept(a, c, ['tab'])).toBe(true); }); it('detects extra non-ignored keys', () => { const a = new URLSearchParams('a=1&extra=x'); const b = new URLSearchParams('a=1'); expect(paramsEqualExcept(a, b, [])).toBe(false); }); it('handles repeated keys deterministically', () => { const a = new URLSearchParams('k=2&k=1'); const b = new URLSearchParams('k=1&k=2'); expect(paramsEqualExcept(a, b, [])).toBe(true); }); }); describe('column constants', () => { it('every status column has a label', () => { for (const col of COLUMN_LIST) { expect(COLUMN_LABELS[col], `label for ${col}`).toBeTruthy(); } }); });