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>
254 lines
13 KiB
Bash
Executable File
254 lines
13 KiB
Bash
Executable File
#!/bin/bash
|
|
# The wizard's New install / Restore branch, driven in a real browser.
|
|
#
|
|
# scripts/dev/lp-restore-wizard-test # needs a running WebUI
|
|
#
|
|
# The branch point is the whole design: Start asks new-or-restore, and the
|
|
# answer selects one of two DISJOINT step sets. A restore must never be asked
|
|
# for an install name, domains or an app list — the backup answers all three,
|
|
# and asking invites the user to contradict what is about to be written over
|
|
# their answer. So the test asserts the sets do not overlap, in both
|
|
# directions, rather than only that the restore steps appear.
|
|
#
|
|
# It also asserts the two things that would be invisible until someone had
|
|
# already lost by them: that the repository password leaves through the
|
|
# one-shot secret channel and does not linger in the DOM, and that submit()
|
|
# routes to the restore path — the normal payload is built from steps a restore
|
|
# never showed, so submitting it posts an empty install name and is rejected by
|
|
# the route, which is a confusing way to find out the branch was never wired.
|
|
|
|
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; }
|
|
|
|
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);
|
|
const visible = () => w.stepNames.filter((n, i) => w._stepVisible(i));
|
|
|
|
const newRadio = $('input[name="sw-mode"][value="new"]');
|
|
const resRadio = $('input[name="sw-mode"][value="restore"]');
|
|
if (!newRadio || !resRadio) return JSON.stringify({ error: 'Start step has no mode cards' });
|
|
|
|
out.newSteps = visible();
|
|
out.newIsDefault = w.installMode === 'new';
|
|
|
|
resRadio.checked = true; fire(resRadio, 'change');
|
|
out.restoreSteps = visible();
|
|
out.mode = w.installMode;
|
|
|
|
// Disjoint in both directions, apart from Start itself.
|
|
const NEW_ONLY = ['Experience', 'Identity', 'Domains', 'Storage', 'Backups', 'Import', 'Recommended', 'Metrics'];
|
|
const RESTORE_ONLY = ['Backup', 'Contents', 'Rebuild'];
|
|
out.restoreLeaksNewStep = out.restoreSteps.some(s => NEW_ONLY.includes(s));
|
|
out.newLeaksRestoreStep = out.newSteps.some(s => RESTORE_ONLY.includes(s));
|
|
out.startAlwaysShown = out.newSteps[0] === 'Start' && out.restoreSteps[0] === 'Start';
|
|
|
|
// The source form offers every backend, and shows only the chosen one.
|
|
// The progress bar said "Source" while the heading said "Where is your
|
|
// backup?", which read as two different steps. They have to agree.
|
|
const sec = document.querySelector('.setup-step[data-step="9"] .setup-section-title');
|
|
out.titleMatchesStepName = !!sec && sec.textContent.trim() === 'Backup';
|
|
// Fields laid out like the rest of the wizard: label with a tooltip, and an
|
|
// icon beside the input — not the label-left rows the Storage step uses.
|
|
out.fieldsHaveIcons = document.querySelectorAll('#sw-rs-fields .setup-field-icon').length > 0;
|
|
out.fieldsHaveTooltips = document.querySelectorAll('#sw-rs-fields .setup-tooltip').length > 0;
|
|
out.passwordHasIcon = !!document.querySelector('#sw-rs-pass')
|
|
?.closest('.setup-input-row')?.querySelector('.setup-field-icon');
|
|
|
|
// Fields for one backend are boxed under their own heading. Loose rows
|
|
// appearing beneath the type dropdown gave no signal that they belonged to
|
|
// the choice above them.
|
|
const shownGroup = () => {
|
|
const g = Array.from(document.querySelectorAll('.setup-subgroup'))
|
|
.filter(el => el.style.display !== 'none');
|
|
return g.length === 1 ? g[0].querySelector('.setup-subgroup-title').textContent.trim() : null;
|
|
};
|
|
const selType = (v) => { const s = $('#sw-rs-type'); s.value = v; fire(s, 'change'); };
|
|
selType('sftp'); out.groupForSftp = shownGroup();
|
|
selType('b2'); out.groupForB2 = shownGroup();
|
|
selType('local'); out.groupForLocal = shownGroup();
|
|
|
|
// Every visible field must clear the one above it. The step's 16px gap only
|
|
// reaches .setup-step's DIRECT children, and these sit a level deeper inside
|
|
// a .setup-section — so each input ran straight into the next field's label.
|
|
selType('sftp');
|
|
w.showStep(1);
|
|
await new Promise(r => setTimeout(r, 200));
|
|
const onScreen = Array.from(document.querySelectorAll('.setup-step[data-step="9"] .setup-field'))
|
|
.filter(el => el.offsetParent !== null);
|
|
let minGap = Infinity;
|
|
for (let i = 1; i < onScreen.length; i++) {
|
|
const a = onScreen[i - 1].getBoundingClientRect(), b = onScreen[i].getBoundingClientRect();
|
|
minGap = Math.min(minGap, Math.round(b.top - a.bottom));
|
|
}
|
|
out.visibleFieldCount = onScreen.length;
|
|
out.smallestFieldGap = onScreen.length > 1 ? minGap : null;
|
|
|
|
out.kinds = Array.from(document.querySelectorAll('#sw-rs-type option')).map(o => o.value);
|
|
const groupsFor = (t) => {
|
|
const sel = $('#sw-rs-type'); sel.value = t; fire(sel, 'change');
|
|
return Array.from(document.querySelectorAll('[data-rs-group]'))
|
|
.filter(g => g.style.display !== 'none')
|
|
.map(g => g.dataset.rsGroup)
|
|
.filter((v, i, a) => a.indexOf(v) === i);
|
|
};
|
|
out.localShowsOnlyLocal = JSON.stringify(groupsFor('local')) === JSON.stringify(['local']);
|
|
out.sftpShowsOnlySftp = JSON.stringify(groupsFor('sftp')) === JSON.stringify(['sftp']);
|
|
|
|
// Validation, before anything is sent.
|
|
$('#sw-rs-type').value = 'local'; fire($('#sw-rs-type'), 'change');
|
|
$('#sw-rs-path').value = ''; $('#sw-rs-pass').value = '';
|
|
out.emptyPathRefused = !!w._restoreSourceProblem();
|
|
$('#sw-rs-path').value = 'relative/path';
|
|
out.relativePathRefused = !!w._restoreSourceProblem();
|
|
$('#sw-rs-path').value = '/somewhere/backups';
|
|
out.missingPasswordRefused = !!w._restoreSourceProblem();
|
|
$('#sw-rs-pass').value = 'x';
|
|
out.completeAccepted = !w._restoreSourceProblem();
|
|
|
|
// A password must leave as a reference and not linger in the DOM. Stubbed:
|
|
// the real channel is covered by lp-secret-channel-test, and what matters
|
|
// here is that readBackup routes through it at all rather than putting the
|
|
// value in the payload.
|
|
let stashedValue = null, sentBody = null;
|
|
w.stashSecret = async (v) => { stashedValue = v; return 'secret:' + '0'.repeat(32); };
|
|
const realFetch = window.fetch;
|
|
// Both halves are stubbed, not just the POST. readBackup polls the published
|
|
// document for a full minute before giving up, and leaving that loop running
|
|
// kept the page from ever going network-idle — the whole eval then died on
|
|
// the harness's 90s cap, which reads as "the browser failed" rather than as
|
|
// a test that never finished.
|
|
window.fetch = async (url, opts) => {
|
|
const u = String(url);
|
|
if (u.includes('/api/setup/restore/read')) {
|
|
sentBody = JSON.parse(opts.body);
|
|
return { ok: true, json: async () => ({ ok: true, taskId: 't', nonce: 'n' }) };
|
|
}
|
|
if (u.includes('/data/system/restore_read.json')) {
|
|
return { ok: true, json: async () => ({
|
|
nonce: 'n', host: 'oldbox', hosts: ['oldbox'],
|
|
system: { present: true, date: '2026-08-28T13:10:02+01:00', domains: [] },
|
|
apps: [] }) };
|
|
}
|
|
return realFetch(url, opts);
|
|
};
|
|
$('#sw-rs-pass').value = 'hunter2-not-a-real-password';
|
|
const readPromise = w.readBackup();
|
|
// Do not wait out the poll: what is under test is what left the browser.
|
|
await new Promise(r => setTimeout(r, 500));
|
|
out.passwordWasStashed = stashedValue === 'hunter2-not-a-real-password';
|
|
out.passwordClearedFromDom = $('#sw-rs-pass').value === '';
|
|
out.payloadCarriesRef = !!(sentBody && sentBody.location && sentBody.location.password_ref);
|
|
out.payloadCarriesNoPassword = !!(sentBody && sentBody.location
|
|
&& !JSON.stringify(sentBody.location).includes('hunter2'));
|
|
// Now that the poll is stubbed too, the read completes rather than hanging.
|
|
await readPromise.catch(() => {});
|
|
window.fetch = realFetch;
|
|
|
|
// The Contents step must present the two snapshot KINDS as two things. A
|
|
// repository holds one system=config snapshot and one per app, restored by
|
|
// different machinery; listing "Apps" and "Domains" as peers hid that, and
|
|
// hid that the domains come out of the system snapshot rather than being a
|
|
// third kind of thing in the backup.
|
|
w.restoreInfo = {
|
|
host: 'oldbox', hosts: ['oldbox'],
|
|
system: { present: true, date: '2026-08-28T13:10:02+01:00', domains: [] },
|
|
apps: [{ name: 'linkding', size: '1M', date: '2026-08-28T13:10:02+01:00' }]
|
|
};
|
|
await w.renderRestoreContents();
|
|
const contents = $('#sw-rs-contents').textContent.replace(/\s+/g, ' ');
|
|
out.showsSettingsSection = /Settings/.test(contents);
|
|
out.showsAppSection = /App data/.test(contents);
|
|
out.explainsSettingsFirst = /makes the others reachable/i.test(contents);
|
|
out.showsSnapshotDate = /28 Aug 2026/.test(contents);
|
|
// Domains belong under Settings, so with none there is no stray heading.
|
|
out.noDomainsHeadingWhenEmpty = !/Domains it will bring across/.test(contents);
|
|
|
|
// A repository with app data but no settings snapshot must say so: the
|
|
// user's repositories and logins will NOT come back, and finding that out
|
|
// afterwards is the worst possible time.
|
|
w.restoreInfo = { host: 'oldbox', hosts: ['oldbox'],
|
|
system: { present: false, date: '', domains: [] },
|
|
apps: [{ name: 'linkding', size: '1M', date: '' }] };
|
|
await w.renderRestoreContents();
|
|
const noSys = $('#sw-rs-contents').textContent.replace(/\s+/g, ' ');
|
|
out.warnsWhenNoSystemSnapshot = /no settings snapshot/i.test(noSys);
|
|
|
|
// submit() must route to the restore path, not the install payload.
|
|
let routedTo = null;
|
|
w.submitRestore = async () => { routedTo = 'restore'; };
|
|
w._submitting = false;
|
|
await w.submit();
|
|
out.submitRoutedToRestore = routedTo === 'restore';
|
|
|
|
return JSON.stringify(out);
|
|
JS
|
|
|
|
J=$("$SHOT" --eval "/" "$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 branch"
|
|
chk "new install is the default" "$(g .newIsDefault)" true
|
|
chk "picking restore switches mode" "$(g .mode)" restore
|
|
chk "Start shows in both" "$(g .startAlwaysShown)" true
|
|
chk "restore shows no install steps" "$(g .restoreLeaksNewStep)" false
|
|
chk "new shows no restore steps" "$(g .newLeaksRestoreStep)" false
|
|
chk "restore step set" "$(g '.restoreSteps | join(",")')" "Start,Backup,Contents,Rebuild"
|
|
|
|
chk "step name matches its title" "$(g .titleMatchesStepName)" true
|
|
|
|
echo "the backup source form"
|
|
chk "every backend offered" "$(g '.kinds | join(",")')" "local,sftp,rest,s3,b2"
|
|
chk "fields carry icons" "$(g .fieldsHaveIcons)" true
|
|
chk "fields carry tooltips" "$(g .fieldsHaveTooltips)" true
|
|
chk "so does the password field" "$(g .passwordHasIcon)" true
|
|
chk "local shows only its own" "$(g .localShowsOnlyLocal)" true
|
|
chk "sftp fields are boxed together" "$(g .groupForSftp)" "SFTP server"
|
|
chk "b2 fields are boxed together" "$(g .groupForB2)" "Backblaze B2"
|
|
chk "local fields are boxed too" "$(g .groupForLocal)" "On this machine"
|
|
chk "no field overlaps the next" "$(g '.smallestFieldGap >= 8')" true
|
|
chk "sftp shows only its own" "$(g .sftpShowsOnlySftp)" true
|
|
chk "empty path refused" "$(g .emptyPathRefused)" true
|
|
chk "relative path refused" "$(g .relativePathRefused)" true
|
|
chk "missing password refused" "$(g .missingPasswordRefused)" true
|
|
chk "a complete source accepted" "$(g .completeAccepted)" true
|
|
|
|
echo "the password"
|
|
chk "goes through the secret channel" "$(g .passwordWasStashed)" true
|
|
chk "leaves the payload as a ref" "$(g .payloadCarriesRef)" true
|
|
chk "and never as a value" "$(g .payloadCarriesNoPassword)" true
|
|
chk "and is cleared from the DOM" "$(g .passwordClearedFromDom)" true
|
|
|
|
echo "the contents step separates the two snapshot kinds"
|
|
chk "a Settings section" "$(g .showsSettingsSection)" true
|
|
chk "an App data section" "$(g .showsAppSection)" true
|
|
chk "says why settings come first" "$(g .explainsSettingsFirst)" true
|
|
chk "shows when each was taken" "$(g .showsSnapshotDate)" true
|
|
chk "no domain heading when there are none" "$(g .noDomainsHeadingWhenEmpty)" true
|
|
chk "warns when there is no settings snapshot" "$(g .warnsWhenNoSystemSnapshot)" true
|
|
|
|
echo "submit"
|
|
chk "routes to the restore path" "$(g .submitRoutedToRestore)" true
|
|
|
|
[[ $fail -eq 0 ]] && echo "restore wizard test: OK"
|
|
exit $fail
|