The advanced Storage step offered /mnt/disk/apps/libreportal-system as the
default home for LibrePortal's own tree. A registered location's path is where
APP DATA goes and is usually a subdirectory of the drive, so deriving anything
else from it nests that thing inside the app data — LibrePortal's own files
buried under it, on a path that reads as a mistake because it is one.
Both defaults now come off the location's mount point, which meant adding
"mount" to each entry in the storage feed; only the system block carried one.
LibrePortal /mnt/disk/apps/libreportal-system
-> /mnt/disk/libreportal-system
New apps, unregistered drive /mnt/disk
-> /mnt/disk/libreportal-apps
New apps, registered location unchanged — it exists and may hold data, and
proposing a different directory on the same
drive would strand it
Names follow the layout the rest of the product uses (libreportal-system,
libreportal-containers, libreportal-backups) rather than a bare "apps", so a
drive shared with anything else stays legible.
collectStorage() no longer registers the drive picked for LibrePortal. A
storage location is somewhere app data lives; the system tree is not app data
and relocate creates that directory itself as root. Picking a drive there was
producing a location nobody asked for, on a mount chosen for something else.
Also in this change, from the Backup step:
- The backend-specific fields are boxed under their own heading with a note,
so choosing SFTP reveals "the SFTP part" rather than three more loose rows.
- Fields had no vertical spacing. .setup-step gives its DIRECT children a
16px gap, which is where every other step's fields get theirs; these sit a
level deeper inside a .setup-section and inherited none of it, so each
input ran into the next field's label.
- Two field icons carried U+FE0F. Those codepoints have a text form and the
selector only requests the emoji one, so they sat on a different baseline
to the plain emoji beside them — the box measured perfectly centred while
the glyph did not look it.
- ?mode=restore&type=sftp makes the restore branch reachable by URL. Getting
there previously took a click and a change event, so every screenshot and
test had to drive the page before it could look at it.
Two test bugs fixed while doing it: a duplicate `const visible` in one scope
(a parse error, so the whole eval silently returned nothing), and a stub that
covered the POST but not the poll, leaving a 60s loop running that kept the
page from ever going network-idle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
278 lines
14 KiB
Bash
Executable File
278 lines
14 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 }));
|
|
// 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
|