screentinker/server/test/raspberry-pi-setup.test.js
ScreenTinker 6ac272af57 Pi installer: stop advertising what was never installed (#245)
Three reports from the same operator, two of them the script describing a
state it never reached — the same shape as the first round of #245.

Guide missing sudo: frontend/guides/raspberry-pi-digital-signage.html said
`curl -sL … | bash` while the script's own header and its root check both say
`| sudo bash`. The script fails loudly with the right command, so nothing is
half-installed, but the guide should not have to be corrected by an error
message. Also documents the --player-only form, which the guide never showed.

MOTD advertised commands that mode did not create: section 11 creates
screentinker-status/update/logs only when PLAYER_ONLY is false, while section
12 wrote an /etc/motd listing all three unconditionally. A Player-Only Pi
therefore greeted its operator with three commands that were not on it, at
every SSH login. The command list is now appended per-mode.

The cheap fix would have been to print nothing on a player. That trades a
wrong banner for a machine nobody can inspect over SSH, so Player-Only now
gets its own screentinker-status (kiosk state, which server it points at, and
whether that server is actually reachable) and screentinker-logs (kiosk).
screentinker-update is genuinely not applicable — there is no local server to
update — and is not offered.

Wayland cursor never hidden: the launcher stated the compositor cursor config
was written "below when wayfire.ini exists". It never was — wayfire.ini and
hide_cursor each appeared exactly once in the whole script, both inside that
comment. unclutter is installed but only runs on the X11 branch, so a Wayland
Pi kept a mouse pointer on the sign while the install looked complete. Now
configures wayfire's hide-cursor plugin at install time, idempotently and
after backing the file up, and says plainly that labwc has no equivalent
rather than failing silently.

Tests: raspberry-pi-setup.test.js gains a check that no MOTD advertises a
command its mode does not install (both modes, extracted from the script
rather than re-typed), that a player is not left with zero diagnostics, and
that the Wayland cursor claim is backed by code outside a comment. Both
mutations verified to fail: putting screentinker-update back in the player
MOTD, and removing the hide-cursor write.

NOT fixed, and not guessed at: the ALT+F4-on-first-pairing symptom and the
reconnect storm. The crash-restore fix those would need is already in 1.9.33
and targets a different symptom, and `observed=6/5 per 10000ms` is six
reconnects in ten seconds, which matches neither the solo-widget cycle nor the
kiosk RestartSec=10. Both need the kiosk-side log — which, until this commit,
a Player-Only Pi had no command to read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 10:50:34 -05:00

173 lines
9.3 KiB
JavaScript

'use strict';
/*
* The Pi installer generates another script (the kiosk launcher) and writes it to disk. Nothing
* ever executed either one in CI, so every defect in them was found by a user on real hardware —
* which is how #245 arrived: a menu that ignored the operator, a keyring prompt, X11 tools
* no-oping on a Wayland Pi, and a banner spelling the product's own name wrong.
*
* These tests check the two things a repo can check without a Pi: that the generated script is
* syntactically valid bash, and that the flags/guards the bug reports turned on are actually
* present. `bash -n` on the OUTER script would not have caught any of it — the kiosk script lives
* inside a heredoc, where a syntax error is just text until it reaches a screen.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const ROOT = path.join(__dirname, '..', '..');
const SCRIPT = path.join(ROOT, 'scripts', 'raspberry-pi-setup.sh');
const SRC = fs.readFileSync(SCRIPT, 'utf8');
// The kiosk launcher as the installer will write it, with the install-time expansions applied.
function generatedKioskScript() {
const m = SRC.match(/cat > "\$PI_HOME\/screentinker-kiosk\.sh" << KIOSKEOF\n([\s\S]*?)\nKIOSKEOF/);
assert.ok(m, 'kiosk heredoc not found — did the installer restructure?');
return m[1]
.replace(/\$\{KIOSK_URL\}/g, 'http://localhost:3001/player')
.replace(/\$\{SCREENTINKER_PORT\}/g, '3001')
.replace(/\$\{CHROMIUM_BIN\}/g, '/usr/bin/chromium-browser')
.replace(/\\\$/g, '$')
.replace(/\\\\/g, '\\');
}
function bashSyntaxOk(text) {
const p = path.join(os.tmpdir(), `st-kiosk-${process.pid}-${Math.abs(text.length)}.sh`);
fs.writeFileSync(p, text);
try {
execFileSync('bash', ['-n', p], { stdio: 'pipe' });
return true;
} catch (e) {
throw new Error(`generated script is not valid bash:\n${e.stderr?.toString() || e.message}`);
} finally {
try { fs.unlinkSync(p); } catch { /* best-effort */ }
}
}
test('#245: the installer itself is valid bash', () => {
assert.ok(bashSyntaxOk(SRC));
});
test('#245: the kiosk launcher it generates is valid bash', () => {
assert.ok(bashSyntaxOk(generatedKioskScript()));
});
test('#245: prompts read the terminal, not the pipe', () => {
// `curl … | sudo bash` makes stdin the SCRIPT. bash has consumed it by the time any read
// runs, so a plain `read` gets EOF instantly and the menu "chooses" the default without the
// operator touching anything — reported as the menu being skipped, because it was.
assert.match(SRC, /exec 3<\/dev\/tty/, 'prompts must come from the controlling terminal');
// Any prompting `read` that is not the one inside ask() itself (which is the tty read).
const reads = SRC.match(/^\s*read (?!.*-u 3).*-p /gm) || [];
assert.equal(reads.length, 0, `every prompt must go through ask(); found a raw read: ${reads[0]}`);
assert.match(SRC, /read .*-u 3/, 'ask() must read from the tty fd');
// And when there is genuinely no terminal, it must SAY which way it went rather than let an
// empty answer look like a decision.
assert.match(SRC, /No terminal available for the menu/);
});
test('#245: Chromium is told not to ask for a keyring', () => {
// "Choose password for keyring" on every boot: Chromium reaching for gnome-keyring on a
// desktop session. A kiosk has nobody to answer it.
assert.match(generatedKioskScript(), /--password-store=basic/);
});
test('#245: X11-only tools are guarded by the session type', () => {
// Pi 5 on Bookworm defaults to Wayland, where xset/unclutter/xrandr are no-ops that log an
// error and silently do nothing — so the Pi got no blanking suppression and no cursor
// hiding while looking configured.
const kiosk = generatedKioskScript();
assert.match(kiosk, /SESSION_TYPE/, 'the launcher must detect the display server');
const x11Block = kiosk.slice(kiosk.indexOf('if [ "$SESSION_TYPE" = "wayland" ]'), kiosk.indexOf('# Clean Chromium crash flags'));
assert.ok(x11Block.length > 0, 'session-type branch not found');
for (const tool of ['xset', 'unclutter']) {
assert.ok(x11Block.includes(tool), `${tool} must live inside the session-type branch`);
}
assert.match(kiosk, /--ozone-platform=wayland/, 'Wayland needs the ozone backend named');
});
test('#245: the crash-restore surface is cleared, not just flagged', () => {
// The white page on every boot after the first: a kiosk is killed by shutdown, never exits
// cleanly, and Chromium returns with a restore surface over the player. ALT+F4 "fixed" it
// because it closed that surface, not the player. Rewriting the flags is not enough on its
// own — Chromium also replays the previous window set from Sessions/.
const kiosk = generatedKioskScript();
assert.match(kiosk, /exited_cleanly/, 'the clean-exit flag must be rewritten');
assert.match(kiosk, /rm -rf .*Sessions/, 'the stored session must be removed too');
assert.match(kiosk, /--disable-session-crashed-bubble/);
});
test('#245: the login banner spells the product name', () => {
// It read "Scree Tinker" — the n was missing from the ASCII art, and it is the first thing
// anyone sees over SSH.
const motd = SRC.slice(SRC.indexOf("cat > /etc/motd << 'MOTDEOF'"), SRC.indexOf('MOTDEOF\n', SRC.indexOf("cat > /etc/motd") + 30));
const lines = motd.split('\n').filter((l) => /[_\\\/|()]/.test(l) && l.trim().length > 20);
assert.ok(lines.length >= 5, 'expected the 5-row banner');
// Row 4 of figlet "standard" carries the distinguishing strokes: 'n' contributes "| | | |".
const banner = lines.join('\n');
assert.ok(banner.includes('| | | |'), 'the n glyph is missing from the banner — it reads "Scree Tinker"');
});
// ---------------------------------------------------------------------------------------------
// #245 round two: the installer advertised what it had not installed.
//
// The MOTD is written unconditionally and listed three commands, but section 11 creates them only
// on an All-in-One install. So a Player-Only Pi greeted its operator at every SSH login with three
// commands that were not on it. Same failure shape as the first round — the script describing a
// state it never reached — and the same reporter found both.
// The generated MOTD for a given mode: the base heredoc plus whichever command block that mode
// appends. Extracted rather than re-typed, so a future edit to either half shows up here.
function motdFor(playerOnly) {
const base = SRC.match(/cat > \/etc\/motd << 'MOTDEOF'\n([\s\S]*?)\nMOTDEOF/);
assert.ok(base, 'MOTD heredoc not found — did the installer restructure?');
const blocks = [...SRC.matchAll(/cat >> \/etc\/motd << 'MOTDCMDEOF'\n([\s\S]*?)\nMOTDCMDEOF/g)];
assert.equal(blocks.length, 2, 'expected exactly two per-mode MOTD command blocks');
// The all-in-one block is written in the `if [ "$PLAYER_ONLY" = false ]` arm, which comes first.
return base[1] + (playerOnly ? blocks[1][1] : blocks[0][1]);
}
// Which management commands the installer actually creates in a given mode.
function commandsCreated(playerOnly) {
const all = [...SRC.matchAll(/cat > \/usr\/local\/bin\/(screentinker-[a-z]+)/g)].map((m) => m[1]);
// Section 11 is an if/else: the all-in-one arm creates update, the player arm does not.
return playerOnly ? all.filter((c) => c !== 'screentinker-update') : all;
}
test('#245: the MOTD never advertises a command that mode did not install', () => {
for (const playerOnly of [false, true]) {
const motd = motdFor(playerOnly);
const created = commandsCreated(playerOnly);
const advertised = [...motd.matchAll(/(screentinker-[a-z]+)/g)].map((m) => m[1]);
assert.ok(advertised.length > 0, `${playerOnly ? 'player' : 'all-in-one'} MOTD lists no commands at all`);
for (const cmd of advertised) {
assert.ok(created.includes(cmd),
`the ${playerOnly ? 'Player-Only' : 'All-in-One'} MOTD advertises ${cmd}, which that mode does not install`);
}
}
});
test('#245: a Player-Only Pi is not left with no diagnostics at all', () => {
// The cheap fix would have been to print nothing on a player. That trades a wrong banner for a
// machine an operator cannot inspect over SSH, which is the harder support call.
const motd = motdFor(true);
assert.match(motd, /screentinker-status/, 'a player still needs to answer "is it running?"');
assert.match(motd, /screentinker-logs/, 'and "why did it stop?"');
assert.doesNotMatch(motd, /screentinker-update/,
'there is no local server to update on a player-only install, so it must not be offered');
});
test('#245: the Wayland cursor claim is backed by something that runs', () => {
// The launcher used to state that the installer wrote the compositor cursor config "below". It
// did not: wayfire.ini and hide_cursor each appeared exactly once, both inside that comment.
const code = SRC.split('\n').filter((l) => !/^\s*#/.test(l)).join('\n');
assert.match(code, /wayfire\.ini/, 'no wayfire.ini handling outside comments');
assert.match(code, /\[hide-cursor\]/, 'the hide-cursor section is never written');
assert.match(code, /hide_delay/, 'the plugin is configured without a delay');
// Non-destructive: a Pi whose owner already tuned wayfire must not silently lose it.
assert.match(code, /screentinker-bak/, 'wayfire.ini is edited without a backup');
});