LibrePortal/scripts/dev/lp-storage-step-test
librelad 00b6926605 Storage step: the dropdown picks a drive, paths are their own section
Follow-up to fd0a0fd, which put a "Custom path…" entry in the drive dropdown.
That was the wrong shape: the dropdown answers "which disk", and an entry
meaning "actually, let me type a directory" sitting in the same list makes
typing one look like one of the normal answers. Picking a disk is the whole
question for most people.

The dropdown now offers drives and nothing else. Exact paths are a section
under it, advanced only — the same reason Metrics is advanced-only, since the
directory under a chosen drive is operator detail and the beginner path
deliberately does not get a wall of that. Beginners get the drive's default,
which is what they would have typed anyway.

Each field is prefilled from the selected drive and follows it when the drive
changes, so a path belonging to the old drive is never left behind. The
LibrePortal row appears only for a non-primary drive: relocating it onto the
drive it already sits on is not a move.

storageSystemChoice stays a drive, and the new storageSystemTarget holds the
relocate path. collectStorage() registers what the dropdowns point at, and a
system directory is not an app-data location — registering .../libreportal-system
as one would be wrong. Asserted directly.

The validator skips an untouched default: that is whatever the install already
uses, and second-guessing it would reject a legitimate layout.

lp-storage-custom-test -> lp-storage-step-test, and it no longer waits for a
drive to happen to be unplugged: it injects an unmounted candidate and
re-renders, so the offline assertions run everywhere rather than only on a
machine where ambient state obliges. That injection had its own trap worth
recording — renderStorage() rebuilds the selects, so a reference held across it
points at a detached node and setting .value on it succeeds while changing
nothing. Three assertions passed against a control no longer in the page.

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

202 lines
9.9 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) return JSON.stringify({ error: 'wizard handle missing' });
const fire = (el, ev) => el.dispatchEvent(new Event(ev, { bubbles: true }));
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 : '';
out.systemPathWant = disk + '/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(3);
const probe = (v) => { inp.value = v; fire(inp, 'input');
return { blocks: !!w.validateStep(3),
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;
}
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 "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
[[ $fail -eq 0 ]] && echo "storage step test: OK"
exit $fail