Files
claude-code-gnome-extension/tests/test-sessions.js
T
av 626c2b6d2c Merge "idle" into "waiting"
"Idle" was set by SessionStart and by nothing else, and there was no path
back into it. So it never meant "sitting unused" -- it meant "opened and
never asked anything yet", a state a few seconds long that you would
almost never catch. Meanwhile a session that finished an hour ago and was
forgotten showed as waiting, which is correct but leaves the fourth state
with nothing to describe.

A session that has just opened is waiting for your first prompt exactly
as one that finished a turn is waiting for your next. They are the same
thing, and now they are the same state. Three glyphs instead of four,
which also gives the remaining three more room to be told apart in a
monochrome panel.

Files written by the previous hook still say "idle", and a session open
across the upgrade must not disappear, so unrecognised states now read as
waiting rather than being treated as unknown. Covered by a test that
feeds an "idle" file to the store and asserts it comes back as waiting,
sorted by age among the others.

The "hide when nothing is running" setting goes with it. Its condition
was "no sessions, or all of them idle"; with idle gone the second half is
unreachable and the first was already unconditional, so the switch could
no longer change anything. A control that does nothing is worse than no
control.

The compaction test also got stronger in passing: it now checks that a
mid-turn SessionStart leaves a *busy* session alone, which is the case
that matters. It used to assert from waiting, where the state it was
guarding against happened to be the state already stored.
2026-08-09 19:14:34 +03:00

117 lines
4.6 KiB
JavaScript

#!/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) {
const payload = {
session_id: id, state, cwd, since: now - agoSeconds,
event_ts: now, pid, 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);
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('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('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);