diff --git a/containers/libreportal/frontend/core/setup/css/setup-wizard.css b/containers/libreportal/frontend/core/setup/css/setup-wizard.css index 30d6084..8ae7750 100755 --- a/containers/libreportal/frontend/core/setup/css/setup-wizard.css +++ b/containers/libreportal/frontend/core/setup/css/setup-wizard.css @@ -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; diff --git a/containers/libreportal/frontend/core/setup/js/setup-wizard.js b/containers/libreportal/frontend/core/setup/js/setup-wizard.js index 0888d4e..7225e46 100755 --- a/containers/libreportal/frontend/core/setup/js/setup-wizard.js +++ b/containers/libreportal/frontend/core/setup/js/setup-wizard.js @@ -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 { ?
+

@@ -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) => `
${label} ? -
-
- -
`; + // 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)) + ''; - // 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 = + '
Exact paths
' + + rows.map(r => ` +
+ ${r.label} + + +
`).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:
- sudo libreportal-relocate --system-dir=${this.escapeHtml(this.storageSystemChoice)}/libreportal-system`; + sudo libreportal-relocate --system-dir=${this.escapeHtml(this.storageSystemTarget || (this.storageSystemChoice + '/libreportal-system'))}`; } // 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', diff --git a/docs/roadmap/storage-locations.md b/docs/roadmap/storage-locations.md index f651c2e..a514738 100644 --- a/docs/roadmap/storage-locations.md +++ b/docs/roadmap/storage-locations.md @@ -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 diff --git a/scripts/dev/lp-storage-custom-test b/scripts/dev/lp-storage-custom-test deleted file mode 100755 index de81110..0000000 --- a/scripts/dev/lp-storage-custom-test +++ /dev/null @@ -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 diff --git a/scripts/dev/lp-storage-step-test b/scripts/dev/lp-storage-step-test new file mode 100755 index 0000000..a43b119 --- /dev/null +++ b/scripts/dev/lp-storage-step-test @@ -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