LibrePortal/scripts/dev/lp-storage-step-test
librelad f736ec6501 Drop the section headings — the step name is already at the top
Every step's name is in the progress bar. Repeating it as a heading directly
beneath was the same word twice with nothing between them: "Step 4 of 8 —
Storage" followed immediately by "STORAGE". Eight headings across both
branches, all gone.

Three of them carried a tooltip worth keeping — what a storage location is,
that a backup password cannot be recovered, what a .lpapp is. Those moved to a
tooltip beside the step name in the progress bar, which is where the heading
actually is now. Steps that never had an explanation show no marker.

Also removed the Backup step's "Where is it? Backups live in a repository…"
paragraph. The step is called Backup, the first field asks where it lives, and
the repository-not-a-file point is what the step tooltip is for.

lp-restore-wizard-test asserted the old heading matched the step name; it now
asserts there are no headings left anywhere and that the progress bar carries
both the name and the explanation.

Both browser tests reported "wizard handle missing" when the wizard simply was
not on screen — it only renders while setup is incomplete, and a stray
.setup_complete from an earlier run hides it. They now say which of the two it
is, since the first sends you looking for a JS error that is not there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 08:25:14 +01:00

287 lines
15 KiB
Bash
Executable File

#!/bin/bash
# Drive the wizard's Storage step in a real browser.
#
# scripts/dev/lp-storage-step-test # needs a running WebUI
#
# Three things this guards.
#
# A location whose drive is absent used to render as a warn card — "needs care",
# "free of" with no numbers, an empty meter. A new user has no way to read that
# as "a drive you registered is unplugged", so it looked like the scan had found
# two broken disks. It must say it is not connected, name the path, draw no
# meter, and not be offered as somewhere to put data — the wizard cannot stat a
# directory on a drive that is not there.
#
# The drive dropdown must offer drives and NOTHING else. It shipped once with a
# "Custom path…" entry in the same list, which made typing a directory look like
# one of the normal answers when most people just want to pick a disk.
#
# Exact paths are advanced-only, and an edited one must not reach the payload
# half-typed. The apply side refuses a relative or system path by falling back
# to the system disk — indistinguishable from having chosen the system disk on
# purpose, which is the failure shape this project keeps having to fix. So
# validateStep blocks it at the step instead.
#
# One page load: the whole interaction runs in a single `lp-shot --eval`.
REPO="$(cd "$(dirname "$0")/../.." && pwd)"
SHOT="$REPO/scripts/dev/lp-shot"
fail=0
chk(){ if [[ "$2" == "$3" ]]; then echo " ok $1"; else echo " FAIL $1: got '$2' want '$3'"; fail=1; fi; }
command -v jq >/dev/null 2>&1 || { echo " SKIP jq not installed"; exit 0; }
# A skip must not be able to stand in for a pass. If the WebUI answers HTTP
# then the browser is the only thing that can have failed, and that is a
# failure — skipping there is how this test reported success under sudo, where
# chromium refuses to start at all.
lp_reachable() {
local u; u=$("$SHOT" --url 2>/dev/null) || return 1
[[ -n "$u" ]] || return 1
curl -fsS -o /dev/null --max-time 5 "$u" 2>/dev/null
}
read -r -d '' DRIVE <<'JS'
const out = {};
const w = window.setupWizard;
if (!w) {
// Distinguish "the wizard is broken" from "the wizard is not on screen".
// It only renders while setup is incomplete, so a stray .setup_complete —
// which a previous test run can leave behind — hides it entirely, and
// "wizard handle missing" sends you looking for a JS error that is not
// there.
return JSON.stringify({ error: typeof window.SetupWizard === 'function'
? 'the wizard did not open — setup is already marked complete on this install (remove frontend/data/.setup_complete)'
: 'wizard handle missing' });
}
const fire = (el, ev) => el.dispatchEvent(new Event(ev, { bubbles: true }));
// Looked up by name, never hardcoded. Inserting the Start step moved Storage
// from 3 to 4, and a test pinned to the old number silently began validating
// the Domains step instead — reporting that nothing blocked, which is
// indistinguishable from validation being broken.
const STORAGE = w.stepNames.indexOf('Storage');
if (STORAGE < 0) return JSON.stringify({ error: 'no Storage step' });
const $ = s => document.querySelector(s);
// Re-queried after every renderStorage(), which rebuilds the selects — a
// reference held across a re-render points at a detached node, and setting
// .value on one of those succeeds silently while changing nothing.
const sel = () => ({ sys: $('#sw-storage-system'), app: $('#sw-storage-apps') });
let { sys: sysSel, app: appSel } = sel();
const paths = $('#sw-storage-paths');
if (!sysSel || !appSel || !paths) return JSON.stringify({ error: 'storage step not rendered' });
// --- the dropdown offers drives, and only drives ---
const labels = Array.from(appSel.options).map(o => o.textContent.trim());
out.optionCount = labels.length;
out.offersCustomPath = labels.some(l => /custom path/i.test(l));
out.offersPrimary = labels.some(l => /system disk/i.test(l));
// Every non-primary option is a path, i.e. a drive, not an instruction.
out.allOptionsArePaths = Array.from(appSel.options)
.filter(o => o.value !== 'primary').every(o => o.value.startsWith('/'));
// --- unmounted locations ---
// Injected rather than waited for: whether a drive happens to be mounted is
// ambient state, and an assertion that only runs on some machines is not a
// guard. Counting the cards that say "not connected" would be worse still —
// turn the feature off and the count is zero, every() over an empty list is
// true, and the whole block passes having checked nothing.
const FAKE = '/mnt/__lp_test_offline';
w.storageCandidates = w.storageCandidates.concat([{
id: '99', name: 'unplugged-disk', path: FAKE, state: 'unmounted',
verdict: 'warn', size: '', free: '', apps: '', registered: true }]);
w.renderStorage();
({ sys: sysSel, app: appSel } = sel());
const card = Array.from(document.querySelectorAll('.setup-app-card, .setup-storage-card'))
.find(c => c.textContent.includes(FAKE));
out.offlineHasCard = !!card;
const txt = card ? card.textContent.replace(/\s+/g, ' ') : '';
out.offlineSaysNotConnected = /not connected/i.test(txt);
out.offlineSaysNotMounted = /not mounted right now/i.test(txt);
out.offlineSaysNeedsCare = /needs care/i.test(txt);
out.offlineHasNoMeter = !!card && !card.querySelector('.setup-storage-meter');
out.offlineOffered = Array.from($('#sw-storage-apps').options).some(o => o.value === FAKE);
// --- exact paths: hidden for a beginner ---
w.installLevel = 'beginner'; w.renderStoragePaths(false);
out.pathsHiddenForBeginner = paths.style.display === 'none';
out.beginnerStoresDrive = w.storageDefault;
w.installLevel = 'advanced'; w.renderStoragePaths(false);
out.pathsShownForAdvanced = paths.style.display !== 'none';
// On the primary drive there is nothing to relocate, so no LibrePortal row.
out.systemRowOnPrimary = !!$('#sw-path-system');
out.appsRowAlways = !!$('#sw-path-apps');
// --- picking a drive fills the path in from that drive ---
const disk = Array.from(appSel.options).map(o => o.value)
.find(v => v !== 'primary');
if (!disk) return JSON.stringify(Object.assign(out, { noSecondDrive: true }));
appSel.value = disk; fire(appSel, 'change');
out.pathFollowsDrive = $('#sw-path-apps').value;
out.pathFollowsDriveWant = disk;
sysSel.value = disk; fire(sysSel, 'change');
out.systemRowAppears = !!$('#sw-path-system');
out.systemPathDefault = $('#sw-path-system') ? $('#sw-path-system').value : '';
// Off the drive's MOUNT, never the location's path. A location's path is
// where app data goes and is usually a subdirectory, so deriving from it
// produced /mnt/disk/apps/libreportal-system — LibrePortal's own tree buried
// inside the app data.
const chosen = (w._storageChoices() || []).find(o => o.value === disk);
const mount = String((chosen && chosen.mount) || disk).replace(/\/$/, '');
out.systemPathWant = mount + '/libreportal-system';
out.systemPathIsOutsideAppData = !out.systemPathDefault.startsWith(disk.replace(/\/$/, '') + '/');
// A storage location is somewhere APP DATA lives. LibrePortal's own tree is
// not app data, and relocate creates that directory itself — registering the
// drive because it was picked here produced a location nobody asked for.
out.systemChoiceRegistersNothingExtra =
(w.collectStorage() || []).every(v => v !== mount + '/libreportal-system');
// The system directory is not an app-data location and must never be
// registered as one — collectStorage takes the drive, not the subdirectory.
out.registersDriveNotSystemDir = (w.collectStorage() || []).includes(out.systemPathDefault);
// --- editing a path ---
const inp = $('#sw-path-apps'), err = $('#sw-path-apps-err');
out.untouchedDefaultBlocks = !!w.validateStep(STORAGE);
const probe = (v) => { inp.value = v; fire(inp, 'input');
return { blocks: !!w.validateStep(STORAGE),
marked: inp.classList.contains('is-invalid'),
said: (err && err.textContent) || '' }; };
out.relative = probe('mnt/nas');
out.systemDir = probe('/etc/libreportal');
out.root = probe('/');
out.spaces = probe('/mnt/my disk');
out.good = probe(disk + '/myapps');
out.goodStored = w.storageDefault;
out.goodRegistered = (w.collectStorage() || []).includes(disk + '/myapps');
// Changing drive must not leave a path belonging to the old one behind.
const other = Array.from(appSel.options).map(o => o.value)
.find(v => v !== 'primary' && v !== disk);
if (other) {
appSel.value = other; fire(appSel, 'change');
out.staleAfterDriveChange = $('#sw-path-apps').value;
out.staleWant = other;
}
// --- the relocate hand-off ---
// Moving LibrePortal's own tree cannot be a WebUI action: it re-bakes the
// paths inside every root helper, and those are baked at install so the
// manager cannot redirect a privileged operation. So the wizard prints a
// command — and the watcher makes that bearable by noticing the move
// landing and following it.
//
// "Landed" must mean the host REPORTS the new system dir, never "the server
// restarted": an ordinary container restart looks identical, and announcing
// a relocation that never happened is worse than saying nothing.
const W = window.RelocateWatcher;
out.watcherExists = !!W;
if (W) {
out.notLandedForAnUnrelatedPath =
await new W({ target: '/mnt/__nope/libreportal-system', cmd: 'x' }).landed();
const feed = await (await fetch('/data/system/storage.json', { cache: 'no-store' })).json();
out.feedReportsSystemDir = !!feed.system_dir;
out.landsWhenFeedMatches =
await new W({ target: feed.system_dir, cmd: 'x' }).landed();
out.trailingSlashTolerated =
await new W({ target: feed.system_dir + '/', cmd: 'x' }).landed();
localStorage.removeItem('lp.pendingRelocate');
W.start();
out.silentWhenNothingPending = !document.querySelector('.lp-relocate-banner');
localStorage.setItem('lp.pendingRelocate', JSON.stringify({
target: '/mnt/__nope/libreportal-system',
cmd: 'sudo libreportal-relocate --system-dir=/mnt/__nope/libreportal-system' }));
W.start();
await new Promise(r => setTimeout(r, 300));
const b = document.querySelector('.lp-relocate-banner');
out.bannerCarriesTheCommand = !!b && b.textContent.includes('libreportal-relocate --system-dir=/mnt/__nope');
out.bannerOffersCopy = !!b && !!b.querySelector('[data-act="copy"]');
if (b) b.remove();
// Already done before this page loaded: clear, do not nag.
localStorage.setItem('lp.pendingRelocate', JSON.stringify({ target: feed.system_dir, cmd: 'x' }));
W.start();
await new Promise(r => setTimeout(r, 300));
out.clearsWhenAlreadyLanded =
!document.querySelector('.lp-relocate-banner') && !localStorage.getItem('lp.pendingRelocate');
localStorage.removeItem('lp.pendingRelocate');
}
return JSON.stringify(out);
JS
J=$("$SHOT" --eval "/?step=3" "$DRIVE" 2>/dev/null)
if [[ -z "$J" ]] || ! jq -e . >/dev/null 2>&1 <<< "$J"; then
if lp_reachable; then
echo " FAIL the WebUI is up but the page returned nothing (browser failed?)"; exit 1
fi
echo " SKIP no WebUI reachable"; exit 0
fi
g(){ jq -r "$1" <<< "$J" 2>/dev/null; }
if [[ "$(g '.error // empty')" != "" ]]; then echo " FAIL $(g .error)"; exit 1; fi
echo "the drive dropdown"
chk "offers the system disk" "$(g .offersPrimary)" true
chk "offers no 'Custom path' entry" "$(g .offersCustomPath)" false
chk "every other entry is a drive" "$(g .allOptionsArePaths)" true
echo "an unmounted location"
chk "still gets a card" "$(g .offlineHasCard)" true
chk "badged 'not connected'" "$(g .offlineSaysNotConnected)" true
chk "says it is not mounted" "$(g .offlineSaysNotMounted)" true
chk "does not say 'needs care'" "$(g .offlineSaysNeedsCare)" false
chk "draws no free-space meter" "$(g .offlineHasNoMeter)" true
chk "is not offered as a target" "$(g .offlineOffered)" false
echo "exact paths"
chk "hidden for a beginner" "$(g .pathsHiddenForBeginner)" true
chk "beginner keeps the drive" "$(g .beginnerStoresDrive)" primary
chk "shown for advanced" "$(g .pathsShownForAdvanced)" true
chk "no LibrePortal row on primary" "$(g .systemRowOnPrimary)" false
chk "New apps row always" "$(g .appsRowAlways)" true
if [[ "$(g '.noSecondDrive // false')" == "true" ]]; then
echo " SKIP only one drive registered — nothing to switch to"
else
chk "path follows the chosen drive" "$(g .pathFollowsDrive)" "$(g .pathFollowsDriveWant)"
chk "LibrePortal row appears" "$(g .systemRowAppears)" true
chk "and defaults under that drive" "$(g .systemPathDefault)" "$(g .systemPathWant)"
chk "not nested in the app data" "$(g .systemPathIsOutsideAppData)" true
chk "and registers no extra location" "$(g .systemChoiceRegistersNothingExtra)" true
chk "system dir is not registered" "$(g .registersDriveNotSystemDir)" false
echo "editing a path"
chk "an untouched default passes" "$(g .untouchedDefaultBlocks)" false
chk "relative path blocks" "$(g .relative.blocks)" true
chk "relative path is marked" "$(g .relative.marked)" true
chk "relative path says why" "$(g '.relative.said != ""')" true
chk "system dir blocks" "$(g .systemDir.blocks)" true
chk "/ blocks" "$(g .root.blocks)" true
chk "path with spaces blocks" "$(g .spaces.blocks)" true
chk "an absolute path passes" "$(g .good.blocks)" false
chk "and is not marked" "$(g .good.marked)" false
chk "and its error is cleared" "$(g '.good.said == ""')" true
chk "and it is what gets stored" "$(g .goodStored)" "$(g .pathFollowsDriveWant)/myapps"
chk "and what gets registered" "$(g .goodRegistered)" true
if [[ "$(g '.staleWant // empty')" != "" ]]; then
chk "changing drive drops the old path" "$(g .staleAfterDriveChange)" "$(g .staleWant)"
fi
fi
echo "the relocate hand-off"
chk "the watcher is loaded" "$(g .watcherExists)" true
chk "the feed reports the system dir" "$(g .feedReportsSystemDir)" true
chk "an unrelated path has not landed" "$(g .notLandedForAnUnrelatedPath)" false
chk "landed when the feed matches" "$(g .landsWhenFeedMatches)" true
chk "a trailing slash is tolerated" "$(g .trailingSlashTolerated)" true
chk "silent when nothing is pending" "$(g .silentWhenNothingPending)" true
chk "the banner carries the command" "$(g .bannerCarriesTheCommand)" true
chk "and offers to copy it" "$(g .bannerOffersCopy)" true
chk "clears when already landed" "$(g .clearsWhenAlreadyLanded)" true
[[ $fail -eq 0 ]] && echo "storage step test: OK"
exit $fail