Files
claude-code-gnome-extension/tests/test-prefs.js
T
av be69bec17f Make the panel position and the label width settings
Two things that were constants and had no business being constants.

Placement: the box (left, centre, right) and the index within it. The
default is unchanged -- centre, index 1, immediately right of the clock --
and an index past the end of a box lands at the end, so a large one means
"last". Applied at once, no reload.

Moving is done by rebuilding the indicator rather than by moving the actor.
addToStatusArea is what registers it under the uuid and there is no
documented call to move one between boxes; everything else reaches into
Main.panel's private boxes. It costs one re-read of a few small state files
and only when the setting is touched.

Label width: three characters by default, settable up to ten, not down.
Three is enough to keep initials apart and narrow enough not to shove the
clock about; below three, distinct projects start sharing a label. A wider
label takes more initials rather than a longer prefix -- a prefix collapses
dev-skills and dev-conventions at any width -- so dev-skills stays "ds"
however wide the setting, while claude-code-gnome-extension becomes "ccge"
at four. A disambiguating digit still eats into the label instead of
extending past the width, so a chip that gains one does not push the row.

Sticky labels work against that setting: kept labels are kept whatever
width they were cut at, so widening would leave every session on screen at
its old width until it ended. The width is therefore the one thing that
discards the map -- a relabelling that was asked for is not a label moving
under your hand.

The combo row is bound by hand: Gio.Settings.bind maps a boolean to
'active' and an int to 'value', but a string to a selected index needs
bind_with_mapping, which is not introspectable. Only the write direction is
wired up, and the test covers it, because a row that stores its index
instead of its value looks fine until the shell reads the key.

Fixed on the way, found by watching the centre box grow 2 -> 3 -> 4 -> 5
across four moves: PanelMenu.ButtonBox connects `this._onDestroy.bind(this)`
in its _init, and its _onDestroy is what destroys the container -- the
St.Bin the panel box actually holds. The name resolves through the
prototype chain, so this extension's own _onDestroy had been silently
replacing the shell's since the beginning, leaving an empty container in
the panel on every teardown. Renamed to _teardown; the box now stays at two
children across moves and across enable/disable cycles.

Verified in a nested shell on a copy of the extension carrying temporary
logging, since the shell refuses screenshots to non-portal callers: every
box, indices 0, 1 and 9, and widths 3, 5, 8, 10 and back, with no JS errors
and no leftover actors.
2026-08-09 21:56:10 +03:00

118 lines
4.8 KiB
JavaScript

#!/usr/bin/gjs -m
// Builds the preferences window for real, under real Adw.
//
// prefs.js runs in its own process, not in the compositor, so a nested-shell
// check cannot reach it -- without this it is the one file in the extension
// that never executes until a user opens it and finds it broken.
//
// The shell's own base class and gettext are stubbed rather than loaded: they
// resolve an extension by walking the caller's URL up to a registered UUID,
// which needs the whole extension manager. Stubbing them keeps the test about
// this extension's code, which is the part that can be wrong.
//
// Run: gjs -m tests/test-prefs.js
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import Adw from 'gi://Adw';
Adw.init();
const HERE = GLib.path_get_dirname(
GLib.filename_from_uri(import.meta.url)[0]);
const EXT = GLib.path_get_dirname(HERE);
const SHELL_PREFS = 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
const tmp = GLib.dir_make_tmp('ccs-prefs-XXXXXX');
const [, source] = GLib.file_get_contents(`${EXT}/prefs.js`);
GLib.file_set_contents(`${tmp}/stub.js`,
'export class ExtensionPreferences {}\nexport const gettext = s => s;\n');
GLib.file_set_contents(`${tmp}/prefs.js`,
new TextDecoder().decode(source).replace(SHELL_PREFS, './stub.js'));
const schemas = Gio.SettingsSchemaSource.new_from_directory(
`${EXT}/schemas`, Gio.SettingsSchemaSource.get_default(), false);
const { default: Prefs } = await import(`file://${tmp}/prefs.js`);
const prefs = new Prefs();
Object.defineProperty(prefs, 'path', { value: EXT }); // a getter upstream
// A memory backend, not the default one: the test writes a key to check the
// hand-rolled combo binding, and it has no business touching the settings of
// whoever is running it.
const backend = Gio.memory_settings_backend_new();
prefs.getSettings = () => Gio.Settings.new_full(
schemas.lookup('org.gnome.shell.extensions.claude-code-status', true),
backend, '/org/gnome/shell/extensions/claude-code-status/');
const window = new Adw.PreferencesWindow();
prefs.fillPreferencesWindow(window);
const rows = [];
const walk = widget => {
for (let c = widget.get_first_child?.(); c; c = c.get_next_sibling()) {
const type = c.constructor.$gtype.name;
if (type === 'AdwSwitchRow' || type === 'AdwSpinRow' ||
type === 'AdwComboRow' || type === 'AdwActionRow')
rows.push({ type, title: c.title, subtitle: c.subtitle });
walk(c);
}
};
walk(window);
let failures = 0;
function check(name, condition, detail = '') {
if (!condition)
failures++;
print(`${condition ? 'ok ' : 'FAIL'} ${name}${detail ? ` (${detail})` : ''}`);
}
for (const row of rows)
print(` ${row.type.replace('Adw', '').padEnd(10)} ${row.title}`);
// One control per settings key, so a key added without a row to change it is
// caught here rather than by a user wondering why nothing happens.
const keys = schemas.lookup('org.gnome.shell.extensions.claude-code-status', true)
.list_keys().length;
const controls = rows.filter(
r => r.type === 'AdwSwitchRow' || r.type === 'AdwSpinRow' ||
r.type === 'AdwComboRow').length;
check('a control for every settings key', controls === keys, `${controls} of ${keys}`);
// The combo is bound by hand rather than through Gio.Settings.bind, so the
// binding is worth a test: it must start on the stored value and write back
// the value, not the row index.
const combo = [];
const findCombos = widget => {
for (let c = widget.get_first_child?.(); c; c = c.get_next_sibling()) {
if (c.constructor.$gtype.name === 'AdwComboRow')
combo.push(c);
findCombos(c);
}
};
findCombos(window);
const settings = prefs.getSettings();
check('the panel box row exists', combo.length === 1, `${combo.length} combo rows`);
if (combo.length) {
check('starts on the stored value',
combo[0].selected === 1, `selected ${combo[0].selected}`); // 'center'
combo[0].selected = 2;
check('writing back stores the value, not the index',
settings.get_string('panel-box') === 'right',
settings.get_string('panel-box'));
}
// The hook status line is the reason this page is worth opening at all: a
// silent panel looks the same whether nothing runs or nothing is installed.
const status = rows.find(r => r.title === 'Status');
check('hook status reported', !!status?.subtitle, status?.subtitle);
check('status names the events it found',
/SessionStart|Not installed/.test(status?.subtitle ?? ''), status?.subtitle);
const dir = rows.find(r => r.title === 'State directory');
check('state directory shown', (dir?.subtitle ?? '').endsWith('claude-code-status'),
dir?.subtitle);
GLib.spawn_command_line_sync(`rm -rf ${tmp}`);
print(failures ? `\n${failures} failure(s)` : '\nall passed');
imports.system.exit(failures ? 1 : 0);