mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
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
173 lines
9.3 KiB
JavaScript
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');
|
|
});
|