'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'); });