Storage step: the dropdown picks a drive, paths are their own section

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>
This commit is contained in:
librelad 2026-08-29 03:56:12 +01:00
parent fd0a0fd08c
commit 00b6926605
5 changed files with 370 additions and 189 deletions

View File

@ -1431,6 +1431,7 @@ body.setup-wizard-open .custom-select-popup { z-index: 10001; }
opacity: 0.92;
}
.setup-storage-choice select,
.setup-storage-choice input[type="text"],
.setup-storage-choice .custom-select { flex: 1; min-width: 180px; }
.setup-storage-choice-msg {
margin: 2px 0 4px;
@ -1450,7 +1451,6 @@ body.setup-wizard-open .custom-select-popup { z-index: 10001; }
/* A custom storage path that cannot be used says so under the box, rather
than being quietly swapped for the system disk on the apply side. Its own
flex line, offset to sit under the input rather than under the label. */
.setup-storage-choice-custom { padding-left: 132px; }
.setup-storage-choice-err {
flex: 0 0 100%;
margin: -4px 0 0;

View File

@ -7,8 +7,6 @@
// Marker for "I will type a path" in the two storage dropdowns. Not a valid
// path itself, so it can never be mistaken for one.
const CUSTOM_PATH = '__custom__';
class SetupWizard {
constructor() {
this.container = null;
@ -33,6 +31,10 @@ class SetupWizard {
this.hasStorageCandidates = false;
this.storageCandidates = [];
this.storageSystem = null;
this.storagePrimary = '';
// The relocate target for LibrePortal itself — never registered as an
// app-data location, so it is kept apart from storageSystemChoice.
this.storageSystemTarget = '';
this.selectedStorage = [];
// Paths the user asked us to make permanent in /etc/fstab.
this.fstabWanted = [];
@ -283,6 +285,7 @@ class SetupWizard {
<span class="setup-tooltip" tabindex="0" data-tip="Apps normally live on the system disk. If you have another drive, you can register it here and choose per app where its data goes.">?</span>
</div>
<div id="sw-storage-choices"></div>
<div id="sw-storage-paths" style="display:none;"></div>
<div class="setup-storage-divider" id="sw-storage-drives-head" style="display:none;"><span>Drives</span></div>
<div id="sw-storage-list"></div>
<p class="setup-section-hint" id="sw-storage-note" style="margin-top:10px;"></p>
@ -410,6 +413,9 @@ class SetupWizard {
r.addEventListener('change', () => {
if (!r.checked) return;
this.installLevel = (r.value === 'advanced') ? 'advanced' : 'beginner';
// Exact paths are advanced-only, so the section has to appear or go
// away when the level changes rather than only at first render.
this.renderStoragePaths(false);
this.totalSteps = this._effectiveTotalSteps();
// Re-paint progress so "Step 1 of N" updates immediately.
this.showStep(this.currentStep);
@ -496,10 +502,12 @@ class SetupWizard {
this.storageCandidates = registered.concat(
unregistered.filter(c => !seen.has((c.path || '').replace(/\/$/, ''))));
this.storageSystem = data.system || null;
this.storagePrimary = data.primary || '';
} catch (e) {
console.log('[setup] storage scan unavailable:', e.message);
this.storageCandidates = [];
this.storageSystem = null;
this.storagePrimary = '';
}
this.hasStorageCandidates = this.storageCandidates.some(c => c.verdict !== 'refuse');
this.totalSteps = this._effectiveTotalSteps();
@ -655,7 +663,6 @@ class SetupWizard {
// Somewhere the scan did not find: a NAS mount, an LVM volume, a directory
// on a drive already in use. The scan lists whole filesystems, so anything
// that is a path rather than a disk had no way in before this.
opts.push({ value: CUSTOM_PATH, label: 'Custom path\u2026' });
return opts;
}
@ -703,62 +710,146 @@ class SetupWizard {
box.innerHTML = '';
this.storageDefault = 'primary';
this.storageSystemChoice = 'primary';
this.storageSystemTarget = '';
return;
}
// A value that is not one of the offered options is a path the user typed,
// so the control has to come back up showing Custom path rather than
// silently snapping to the first entry.
const isCustom = (v) => !!v && v !== 'primary' && !opts.some(o => o.value === v);
// The dropdown picks a DRIVE and nothing else. Exact paths are a separate
// section below, because most people installing this want to pick a disk
// and move on — a "Custom path…" entry sitting in the same list makes
// typing a directory look like one of the normal answers.
const row = (id, label, tip, value) => `
<div class="setup-storage-choice">
<span class="setup-storage-choice-label">${label}
<span class="setup-tooltip" tabindex="0" data-tip="${this.escapeHtml(tip)}">?</span>
</span>
<select id="${id}" class="form-control">
${opts.map(o => `<option value="${this.escapeHtml(o.value)}"${(o.value === value || (o.value === CUSTOM_PATH && isCustom(value))) ? ' selected' : ''}>${this.escapeHtml(o.label)}</option>`).join('')}
${opts.map(o => `<option value="${this.escapeHtml(o.value)}"${o.value === value ? ' selected' : ''}>${this.escapeHtml(o.label)}</option>`).join('')}
</select>
</div>
<div class="setup-storage-choice setup-storage-choice-custom" id="${id}-custom-row" style="${isCustom(value) ? '' : 'display:none;'}">
<input type="text" id="${id}-custom" class="form-control"
placeholder="/mnt/nas/libreportal" value="${isCustom(value) ? this.escapeHtml(value) : ''}">
<span class="setup-storage-choice-err" id="${id}-custom-err"></span>
</div>`;
// A path from a previous visit belongs to whichever drive contains it, so
// the dropdown reopens on that drive rather than snapping to the first
// entry and quietly discarding the path.
const driveOf = (v) => {
if (!v || v === 'primary' || opts.some(o => o.value === v)) return v || 'primary';
const hit = opts.filter(o => o.value !== 'primary' && v.startsWith(o.value + '/'))
.sort((a, b) => b.value.length - a.value.length)[0];
return hit ? hit.value : 'primary';
};
box.innerHTML =
row('sw-storage-system', 'LibrePortal',
'Settings, database and logs. Around 20 MB, and it stays small. Moving this after install needs a root command — the wizard will tell you which.',
this.storageSystemChoice) +
this.storageSystemChoice || 'primary') +
row('sw-storage-apps', 'New apps',
'Where an app keeps its data unless you place that app somewhere else. You can move any app later with `libreportal app move`.',
this.storageDefault) +
driveOf(this.storageDefault)) +
'<div class="setup-storage-choice-msg" id="sw-storage-system-msg" style="display:none;"></div>';
// Choosing "Custom path" reveals the input and holds the value at empty
// until something is typed — the sentinel must never reach the payload.
const wire = (id, apply) => {
const sel = box.querySelector(`#${id}`);
const row = box.querySelector(`#${id}-custom-row`);
const inp = box.querySelector(`#${id}-custom`);
if (!sel) return;
sel.addEventListener('change', (e) => {
const custom = e.target.value === CUSTOM_PATH;
if (row) row.style.display = custom ? '' : 'none';
apply(custom ? (inp && inp.value.trim()) || '' : e.target.value);
if (custom && inp) inp.focus();
this._syncStorageNav();
});
if (inp) inp.addEventListener('input', () => {
if (sel.value === CUSTOM_PATH) apply(inp.value.trim());
apply(e.target.value);
// The drive changed, so a path under the old one is stale.
this.renderStoragePaths(true);
this._syncStorageNav();
});
};
wire('sw-storage-system', (v) => { this.storageSystemChoice = v; this.renderStorageSystemMsg(); });
wire('sw-storage-apps', (v) => { this.storageDefault = v; });
this.renderStorageSystemMsg();
wire('sw-storage-system', (v) => { this.storageSystemChoice = v; });
wire('sw-storage-apps', () => {});
this.renderStoragePaths(false);
this._syncStorageNav();
}
// Where a chosen drive puts things when nobody says otherwise.
_defaultPathFor(which, drive) {
if (!drive || drive === 'primary') {
return which === 'system' ? '' : (this.storagePrimary || '');
}
const base = drive.replace(/\/$/, '');
return which === 'system' ? `${base}/libreportal-system` : base;
}
// Exact paths — advanced only.
//
// Picking a disk is the whole question for most people; the directory under
// it is operator detail, and the beginner path deliberately does not get a
// wall of that (the same reason Metrics is advanced-only). Beginners get the
// drive's default, which is what they would have typed anyway.
renderStoragePaths(resetToDefault) {
const box = this.container.querySelector('#sw-storage-paths');
const sysSel = this.container.querySelector('#sw-storage-system');
const appSel = this.container.querySelector('#sw-storage-apps');
if (!box || !sysSel || !appSel) return;
// Read the old inputs before they are thrown away, so switching level or
// re-rendering does not silently drop an edit.
const held = {};
if (!resetToDefault) {
['sw-path-system', 'sw-path-apps'].forEach((id) => {
const el = this.container.querySelector(`#${id}`);
if (el) held[id] = el.value;
});
}
const rows = [];
// Relocating LibrePortal onto the drive it already sits on is not a move,
// so there is no path to offer for it.
if (sysSel.value !== 'primary') {
rows.push({ id: 'sw-path-system', label: 'LibrePortal',
def: this._defaultPathFor('system', sysSel.value) });
}
rows.push({ id: 'sw-path-apps', label: 'New apps',
def: this._defaultPathFor('apps', appSel.value) });
if (this.installLevel === 'advanced') {
box.style.display = '';
box.innerHTML =
'<div class="setup-storage-divider"><span>Exact paths</span></div>' +
rows.map(r => `
<div class="setup-storage-choice">
<span class="setup-storage-choice-label">${r.label}</span>
<input type="text" id="${r.id}" class="form-control"
data-default="${this.escapeHtml(r.def)}"
placeholder="${this.escapeHtml(r.def)}"
value="${this.escapeHtml(held[r.id] !== undefined ? held[r.id] : r.def)}">
<span class="setup-storage-choice-err" id="${r.id}-err"></span>
</div>`).join('');
box.querySelectorAll('input').forEach(i => i.addEventListener('input', () => {
this._applyStoragePaths();
this._syncStorageNav();
}));
} else {
box.style.display = 'none';
box.innerHTML = '';
}
this._applyStoragePaths();
}
// The effective values: an edited path when there is one, the drive's own
// default otherwise. storageDefault stays a thing collectStorage can
// register; storageSystemTarget only ever feeds the relocate command, since
// a system directory is not an app-data location and must not be registered
// as one.
_applyStoragePaths() {
const sysSel = this.container.querySelector('#sw-storage-system');
const appSel = this.container.querySelector('#sw-storage-apps');
if (!sysSel || !appSel) return;
const typed = (id) => {
const el = this.container.querySelector(`#${id}`);
return el ? el.value.trim() : '';
};
this.storageSystemChoice = sysSel.value;
this.storageSystemTarget = sysSel.value === 'primary'
? '' : (typed('sw-path-system') || this._defaultPathFor('system', sysSel.value));
this.storageDefault = appSel.value === 'primary' && !typed('sw-path-apps')
? 'primary'
: (typed('sw-path-apps') || this._defaultPathFor('apps', appSel.value) || 'primary');
this.renderStorageSystemMsg();
}
// A half-typed custom path must not be able to reach the payload. The apply
// side would refuse it and fall back to the system disk, and a silent
// fallback is indistinguishable from having chosen the system disk on
@ -777,13 +868,14 @@ class SetupWizard {
_syncStorageNav() {
if (!this.container) return '';
let blocked = '';
['sw-storage-system', 'sw-storage-apps'].forEach((id) => {
const sel = this.container.querySelector('#' + id);
const inp = this.container.querySelector('#' + id + '-custom');
const err = this.container.querySelector('#' + id + '-custom-err');
if (!sel || !inp) return;
const problem = sel.value === CUSTOM_PATH
? this._customPathProblem(inp.value.trim()) : '';
['sw-path-system', 'sw-path-apps'].forEach((id) => {
const inp = this.container.querySelector('#' + id);
const err = this.container.querySelector('#' + id + '-err');
if (!inp) return;
const v = inp.value.trim();
// An untouched default is whatever this install already uses, so it is
// not the wizard's place to second-guess it. Only an edit gets checked.
const problem = (v === (inp.dataset.default || '')) ? '' : this._customPathProblem(v);
if (err) err.textContent = problem;
inp.classList.toggle('is-invalid', !!problem);
if (problem) blocked = problem;
@ -791,12 +883,6 @@ class SetupWizard {
return blocked;
}
// Moving LibrePortal's own tree re-bakes the root-owned helpers, the systemd
// unit and the WebUI's own bind-mounts. That is real root, not the scoped
// sudo the manager holds — a helper that re-baked the other helpers from a
// manager-supplied path would hand the manager the trust boundary those
// helpers exist to defend. So the wizard hands over the command instead of
// pretending it can do it.
renderStorageSystemMsg() {
const msg = this.container.querySelector('#sw-storage-system-msg');
if (!msg) return;
@ -808,7 +894,7 @@ class SetupWizard {
msg.style.display = '';
msg.innerHTML = `Moving LibrePortal itself needs root, so it happens outside the WebUI.
Finish setup, then run:<br>
<code>sudo libreportal-relocate --system-dir=${this.escapeHtml(this.storageSystemChoice)}/libreportal-system</code>`;
<code>sudo libreportal-relocate --system-dir=${this.escapeHtml(this.storageSystemTarget || (this.storageSystemChoice + '/libreportal-system'))}</code>`;
}
// Backups: one question, asked at setup rather than left to be discovered.
@ -1768,7 +1854,7 @@ class SetupWizard {
storage_default: (this.storageDefault && this.storageDefault !== 'primary') ? this.storageDefault : 'primary',
// Recorded so the finished-setup screen can repeat the relocate command.
// Nothing acts on it: moving LibrePortal's own tree needs real root.
storage_system: (this.storageSystemChoice && this.storageSystemChoice !== 'primary') ? this.storageSystemChoice : 'primary',
storage_system: (this.storageSystemChoice && this.storageSystemChoice !== 'primary') ? (this.storageSystemTarget || this.storageSystemChoice) : 'primary',
// '' = don't configure backups; 'primary' = system disk; else a drive path.
backup_dest: this.backupDest || '',
backup_mode: this.backupMode || 'automatic',

View File

@ -560,7 +560,29 @@ there would have anywhere to write.
**Custom paths.** The dropdowns offered only what the scan turned up, which is
wrong for a NAS mount, an LVM volume, or anything else the disk heuristics do
not rank as a candidate. Both now end in *Custom path…*, revealing a text box.
not rank as a candidate.
The first attempt put a *Custom path…* entry in the dropdown itself, which was
wrong: the dropdown answers "which disk", and an entry that means "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.
So the dropdown offers drives and nothing else, and exact paths are their own
section under it — **advanced only**, for the same reason Metrics is: the
beginner path deliberately does not get a wall of operator detail, and the
directory under a chosen drive is exactly that. A beginner gets the drive's
default, which is what they would have typed anyway.
Each path 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 when a non-primary drive is chosen — relocating it
onto the drive it already sits on is not a move.
The two are kept apart in state, which matters more than it looks:
`storageSystemChoice` stays a *drive* and `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. The test asserts that specifically.
The validation lives in `validateStep(3)` rather than in a disabled button. The
apply side already refuses a relative or system path — but its refusal is to
@ -569,6 +591,10 @@ the system disk on purpose*. This is the failure shape this project keeps
having to fix (§10, and the compose-guard and `app-data-remove` bugs before it),
so the step blocks and says which rule the path broke.
An untouched default is skipped by the validator: it is whatever this install
already uses, and second-guessing it would reject a legitimate layout. Only an
edit gets checked.
A path typed here is not a registered location, so `setup_apply.sh` registers it
through `storageAdd` before writing `CFG_STORAGE_DEFAULT`. Going through
`storageAdd` rather than writing the path straight into the config is what keeps
@ -583,15 +609,21 @@ table displayed as `location-3`.
### What the test found about the tests
`lp-storage-custom-test` drives the step in a real browser. Two things it caught
`lp-storage-step-test` drives the step in a real browser. Two things it caught
about itself are worth recording, because both are the same shape as the bugs it
exists to prevent — a check whose failure mode is to not run.
It first counted "how many cards say *not connected*" and asserted things about
those. Disabling the feature makes that count zero, `every()` over an empty list
is true, and the whole block passed while asserting nothing. The expected count
now comes from the feed (`storageCandidates` in state `unmounted`), so removing
the rendering is a failure rather than an empty set.
is true, and the whole block passed while asserting nothing. It now *injects* an
unmounted candidate and re-renders, so the assertion runs on every machine
rather than only where a drive happens to be unplugged — ambient state is not a
guard either.
(That injection had its own trap: `renderStorage()` rebuilds the selects, so a
reference held across it points at a detached node, and setting `.value` on one
of those succeeds silently while changing nothing. Three assertions passed
against a control that was no longer in the page.)
And both browser tests skipped — exit 0 — whenever the page returned nothing.
Run under `sudo`, where chromium refuses to start, they reported PASS having

View File

@ -1,138 +0,0 @@
#!/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

201
scripts/dev/lp-storage-step-test Executable file
View File

@ -0,0 +1,201 @@
#!/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