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>
287 lines
15 KiB
Bash
Executable File
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
|