#!/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