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>
202 lines
9.9 KiB
Bash
Executable File
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
|