#!/usr/bin/gjs -m // Exercises SessionStore against real files, under real GJS/GIO. // // sessions.js deliberately imports nothing from resource:///org/gnome/shell, // which is what makes this runnable outside the compositor -- the part of the // extension most likely to be wrong (liveness, ordering, monitoring, partial // reads) is also the part that can be tested for real. // // Run: gjs -m tests/test-sessions.js import GLib from 'gi://GLib'; import Gio from 'gi://Gio'; import { SessionStore, stateRank } from '../lib/sessions.js'; const DIR = GLib.build_filenamev([GLib.get_tmp_dir(), `ccs-test-${GLib.random_int()}`]); GLib.setenv('XDG_STATE_HOME', DIR, true); const STATE = GLib.build_filenamev([DIR, 'claude-code-status']); GLib.mkdir_with_parents(STATE, 0o755); let failures = 0; function check(name, condition, detail = '') { const mark = condition ? 'ok ' : 'FAIL'; if (!condition) failures++; print(`${mark} ${name}${detail ? ` (${detail})` : ''}`); } const now = GLib.get_real_time() / 1e6; function write(id, state, cwd, agoSeconds, pid, pidStart = 0) { const payload = { session_id: id, state, cwd, since: now - agoSeconds, event_ts: now, pid, pid_start: pidStart, event: 'test', notification_type: '', message: '', zellij_session: 'ztest', zellij_pane: '1', transcript: '', }; GLib.file_set_contents( GLib.build_filenamev([STATE, `${id}.json`]), JSON.stringify(payload)); } // A process that exists for the duration of the test, and one that does not. const livePid = new TextDecoder().decode( GLib.file_get_contents('/proc/self/stat')[1]).split(' ')[0]; const deadPid = 4194303; // above the default pid_max, so it cannot exist write('s-busy', 'busy', '/home/u/proj-busy', 30, livePid); write('s-blocked', 'blocked', '/home/u/proj-blocked', 10, livePid); write('s-wait-new', 'waiting', '/home/u/proj-new', 60, livePid); write('s-wait-old', 'waiting', '/home/u/proj-old', 3600, livePid); write('s-dead', 'waiting', '/home/u/proj-dead', 5, deadPid); // Written by the older hook, which had a fourth state. Files like this // survive an upgrade in a session that was already open. write('s-legacy', 'idle', '/home/u/proj-legacy', 5, livePid); // Survived a reboot: the pid exists again, but belongs to something else now. // Without an identity check this sits in the panel forever as a live session. write('s-ghost', 'waiting', '/home/u/proj-ghost', 99999, livePid, 1); // Left by a session that ended: the hook keeps the lock inode deliberately, // so nobody but the reader would ever clear it. GLib.file_set_contents(GLib.build_filenamev([STATE, 's-gone.json.lock']), ''); GLib.spawn_command_line_sync( `touch -d '1 hour ago' ${GLib.build_filenamev([STATE, 's-gone.json.lock'])}`); // A state file far larger than any real one must not be read at all. GLib.file_set_contents(GLib.build_filenamev([STATE, 'huge.json']), `{"session_id":"huge","state":"waiting","pid":1,"cwd":"${'x'.repeat(70000)}"}`); GLib.file_set_contents(GLib.build_filenamev([STATE, 'notes.txt']), 'ignored'); GLib.file_set_contents(GLib.build_filenamev([STATE, 'half.json']), '{"broken'); const loop = new GLib.MainLoop(null, false); const store = new SessionStore(); let round = 0; store.connect('changed', () => { round++; const s = store.sessions; if (round === 1) { check('ranks order blocked before waiting before busy', stateRank('blocked') < stateRank('waiting') && stateRank('waiting') < stateRank('busy')); check('dead session dropped', !s.some(x => x.sessionId === 's-dead')); check('reused pid from a previous boot dropped', !s.some(x => x.sessionId === 's-ghost')); check('and its file removed', !GLib.file_test(GLib.build_filenamev([STATE, 's-ghost.json']), GLib.FileTest.EXISTS)); check('truncated file skipped, others survive', s.length === 5, `got ${s.length}: ${s.map(x => x.sessionId).join(',')}`); check('non-json ignored', !s.some(x => x.sessionId.includes('notes'))); check('blocked sorts first', s[0]?.sessionId === 's-blocked', s[0]?.sessionId); check('longest wait precedes newer wait', s[1]?.sessionId === 's-wait-old' && s[2]?.sessionId === 's-wait-new', `${s[1]?.sessionId}, ${s[2]?.sessionId}`); const legacy = s.find(x => x.sessionId === 's-legacy'); check('a retired state reads as waiting, not dropped', legacy?.state === 'waiting', legacy?.state); check('and sorts by age among the waiting ones', s[3]?.sessionId === 's-legacy', s[3]?.sessionId); check('busy after waiting', s[4]?.sessionId === 's-busy', s[4]?.sessionId); check('oversized state file not loaded', !s.some(x => x.sessionId === 'huge')); check('orphaned lock swept', !GLib.file_test(GLib.build_filenamev([STATE, 's-gone.json.lock']), GLib.FileTest.EXISTS)); check('dead session file removed from disk', !GLib.file_test(GLib.build_filenamev([STATE, 's-dead.json']), GLib.FileTest.EXISTS)); // A hook writing a new session must reach the panel without polling. write('s-fresh', 'blocked', '/home/u/proj-fresh', 1, livePid); return; } if (s.some(x => x.sessionId === 's-fresh')) { check('directory monitor picked up a new session', true); check('new blocked session takes the top slot', s[0].state === 'blocked', s[0].sessionId); store.destroy(); loop.quit(); } }); store.start(); GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 10, () => { check('finished before timeout', false, 'monitor never fired'); loop.quit(); return GLib.SOURCE_REMOVE; }); loop.run(); Gio.File.new_for_path(DIR).trash_async?.(GLib.PRIORITY_LOW, null, null); GLib.spawn_command_line_sync(`rm -rf ${DIR}`); print(failures ? `\n${failures} failure(s)` : '\nall passed'); imports.system.exit(failures ? 1 : 0);