A registered drive that is unplugged rendered through the same path as any
other candidate — a "needs care" badge, "free of" with no numbers on either
side, an empty meter. To a first-time installer that reads as two broken disks
the scan turned up, with nothing tying the card back to a drive they registered
and later unplugged. Say "not connected", name the path, and draw no meter: a
meter with nothing in it is a claim about free space nobody measured. The same
locations are withheld from the dropdowns, since the wizard cannot stat a
directory on a drive that is absent.
Both dropdowns now end in "Custom path…", for a NAS mount or an LVM volume the
disk heuristics never rank as a candidate. Validation goes through
validateStep(3) rather than a disabled button: the apply side already refuses a
relative or system path, but its refusal is to fall back to the system disk,
and that is indistinguishable from having chosen the system disk on purpose.
A typed path is not a registered location, so setup_apply registers it via
storageAdd — which is what keeps the empty-directory admission rule and the
fitness checks in play — named after its basename, so it reads as "nas" rather
than "location-3" in the placement menus.
libreportal-storage: accept the name the listing prints. remove matched id and
path only, so `remove location-3` failed against a row displayed as
location-3. Root-owned helper changed, so footprint_version 10 -> 11.
Expose window.setupWizard: the instance was local to a promise in the
orchestrator and unreachable from the console or a test.
lp-storage-custom-test drives the step in a browser. Two holes it found in the
tests themselves, both the shape it exists to catch — a check whose failure
mode is to not run:
- It counted the cards that say "not connected" and asserted over those.
Turn the feature off and the count is zero, every() over an empty list is
true, and the block passed having checked nothing. The expectation now
comes from the feed.
- Both browser tests exited 0 whenever the page returned nothing. Under sudo,
where chromium will not start, they reported PASS having asserted nothing.
They now probe with `lp-shot --url` and curl: if the WebUI answers HTTP the
browser is the only thing that can have broken, and that is a failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
139 lines
6.8 KiB
Bash
Executable File
139 lines
6.8 KiB
Bash
Executable File
#!/bin/bash
|
|
# Drive the wizard's Storage step in a real browser.
|
|
#
|
|
# scripts/dev/lp-storage-custom-test # needs a running WebUI
|
|
#
|
|
# Two 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 "this is a drive you registered and it is unplugged", so it looked like the
|
|
# scan had found two broken disks. It must now say it is not connected, name the
|
|
# path, and draw no meter.
|
|
#
|
|
# And a typed custom path must never reach the payload half-typed. The apply
|
|
# side refuses a relative or system path and falls back to the system disk —
|
|
# a fallback that looks exactly like having chosen the system disk on purpose,
|
|
# which is the failure shape this project keeps having to fix. validateStep
|
|
# has to block 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 sel = document.querySelector('#sw-storage-apps');
|
|
const inp = document.querySelector('#sw-storage-apps-custom');
|
|
const row = document.querySelector('#sw-storage-apps-custom-row');
|
|
const err = document.querySelector('#sw-storage-apps-custom-err');
|
|
if (!sel || !inp || !row) return JSON.stringify({ error: 'storage step not rendered' });
|
|
|
|
// An unmounted location must not be offered as somewhere to put app data:
|
|
// the wizard cannot check its free space or write to it.
|
|
// How many SHOULD be offline comes from the feed, not from the rendering.
|
|
// Counting the cards that say "not connected" would make every assertion
|
|
// below vacuous the moment the feature stopped working.
|
|
const cards = Array.from(document.querySelectorAll('.setup-app-card, .setup-storage-card'));
|
|
const expected = (w.storageCandidates || []).filter(c => c.state === 'unmounted');
|
|
out.expectedOffline = expected.length;
|
|
const offline = cards.filter(c =>
|
|
expected.some(e => e.path && c.textContent.includes(e.path)));
|
|
out.offlineCards = offline.length;
|
|
out.offlineSayNotConnected = offline.length > 0
|
|
&& offline.every(c => /not connected/i.test(c.textContent));
|
|
out.offlineSaysNotMounted = offline.length > 0 && offline.every(c => /not mounted right now/i.test(c.textContent));
|
|
out.offlineNamesPath = offline.length > 0 && offline.every(c => /·\s*\//.test(c.textContent));
|
|
out.offlineHasNoMeter = offline.length > 0 && offline.every(c => !c.querySelector('.setup-storage-meter'));
|
|
out.offlineSaysNeedsCare = offline.some(c => /needs care/i.test(c.textContent));
|
|
out.offlineOffered = Array.from(sel.options)
|
|
.some(o => offline.some(c => o.value && c.textContent.includes(o.value)));
|
|
|
|
out.hasCustomOption = Array.from(sel.options).some(o => o.value === '__custom__');
|
|
out.rowHiddenAtRest = row.style.display === 'none';
|
|
|
|
sel.value = '__custom__'; fire(sel, 'change');
|
|
out.rowShownOnPick = row.style.display !== 'none';
|
|
out.emptyBlocks = !!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('/mnt/nas/apps');
|
|
out.goodStored = w.storageDefault;
|
|
|
|
// Back to a scanned option: never blocked, and the error clears.
|
|
sel.value = 'primary'; fire(sel, 'change');
|
|
out.primaryBlocks = !!w.validateStep(3);
|
|
out.primaryStored = w.storageDefault;
|
|
return JSON.stringify(out);
|
|
JS
|
|
|
|
J=$("$SHOT" --eval "/?step=3" "$DRIVE" 2>/dev/null | tr "'" '"' | sed 's/\bTrue\b/true/g; s/\bFalse\b/false/g')
|
|
if [[ -z "$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(){ echo "$J" | jq -r "$1" 2>/dev/null; }
|
|
|
|
if [[ "$(g '.error // empty')" != "" ]]; then echo " FAIL $(g .error)"; exit 1; fi
|
|
|
|
echo "unmounted locations"
|
|
if [[ "$(g .expectedOffline)" == "0" ]]; then
|
|
echo " SKIP no unmounted location registered — nothing to check"
|
|
else
|
|
chk "every one has a card" "$(g .offlineCards)" "$(g .expectedOffline)"
|
|
chk "badged 'not connected'" "$(g .offlineSayNotConnected)" true
|
|
chk "say they are not mounted" "$(g .offlineSaysNotMounted)" true
|
|
chk "name the path" "$(g .offlineNamesPath)" true
|
|
chk "draw no free-space meter" "$(g .offlineHasNoMeter)" true
|
|
chk "do not say 'needs care'" "$(g .offlineSaysNeedsCare)" false
|
|
chk "are not offered as a target" "$(g .offlineOffered)" false
|
|
fi
|
|
|
|
echo "custom path"
|
|
chk "the option exists" "$(g .hasCustomOption)" true
|
|
chk "input hidden until picked" "$(g .rowHiddenAtRest)" true
|
|
chk "input shown once picked" "$(g .rowShownOnPick)" true
|
|
chk "empty blocks the step" "$(g .emptyBlocks)" true
|
|
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)" /mnt/nas/apps
|
|
chk "a scanned option never blocks" "$(g .primaryBlocks)" false
|
|
chk "and replaces the custom value" "$(g .primaryStored)" primary
|
|
|
|
[[ $fail -eq 0 ]] && echo "storage custom-path test: OK"
|
|
exit $fail
|