Three things. The snapshot times now sit under the count. They are the only thing about a snapshot legible without the key — the filename is an opaque hash and everything describing what is inside is in the encrypted object — so they answer "is this the backup I think it is, and did it run when I expect", which is the question someone actually has before typing a password into it. Next is disabled until the backup has been opened, rather than accepting the click and then arguing. validateStep still refuses, for anyone who arrives another way, but the disabled state says "something above me is unfinished" before the click instead of after. It re-enables on a successful read and goes back to disabled the moment the path, type or password changes, since that read is then about a different repository. Error text was rgb(220,53,69) on a 10%-opacity danger background — a mid red on a dark blue panel, legible in theory and squinted at in practice. Lighter text, a firmer border, more line-height. The test asserts perceived brightness rather than an exact colour, so a theme change cannot quietly undo it. One real bug on the way: _adoptSingleResult rebuilt the record by hand, naming four fields, so `times` was dropped and the list came out empty even though the data was right there. It passes the whole record through now — a field lost that way is invisible until something downstream needs it. Not done, and worth stating plainly: these times are not selectable. Picking one would be picking a hash — a restic snapshot is ONE app's data or the settings tree, not a whole machine, and which is which cannot be known until the repository is open. Choosing a point in time to restore from is a real thing to want and belongs on Contents, after unlocking, where the snapshots have names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
473 lines
25 KiB
Bash
Executable File
473 lines
25 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) {
|
|
// Distinguish "the wizard is broken" from "the wizard is not on screen".
|
|
// It only renders while setup is incomplete, so a stray .setup_complete —
|
|
// which a previous test run can leave behind — hides it entirely, and
|
|
// "wizard handle missing" sends you looking for a JS error that is not
|
|
// there.
|
|
return JSON.stringify({ error: typeof window.SetupWizard === 'function'
|
|
? 'the wizard did not open — setup is already marked complete on this install (remove frontend/data/.setup_complete)'
|
|
: '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 step's name lives in the progress bar. Repeating it as a heading
|
|
// directly beneath was the same word twice with nothing between them, so
|
|
// there are no section headings left in the wizard at all — and what those
|
|
// headings explained moved to a tooltip beside the name, which is where the
|
|
// name is.
|
|
out.noSectionHeadingsAnywhere = document.querySelectorAll('.setup-section-title').length;
|
|
w.showStep(1);
|
|
const bar = document.querySelector('#sw-progress-step');
|
|
out.progressShowsTheStepName =
|
|
(bar.querySelector('.setup-progress-name') || {}).textContent === 'Backup';
|
|
out.progressCarriesTheTip = !!bar.querySelector('.setup-tooltip');
|
|
// 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();
|
|
|
|
// Finding backups without a password. A restic repository keeps one file per
|
|
// snapshot under snapshots/, so "is this a backup, and how many" is a
|
|
// directory listing — nothing is decrypted. That is what lets the step tell
|
|
// the user something useful BEFORE asking for the password, which is the one
|
|
// thing a person rebuilding a server may not have to hand.
|
|
const t0 = Date.now();
|
|
while (Date.now() - t0 < 45000 && !(w.foundBackups || []).length) {
|
|
await new Promise(r => setTimeout(r, 700));
|
|
}
|
|
out.scanFoundSomething = (w.foundBackups || []).length > 0;
|
|
out.foundCarrySnapshotCounts = (w.foundBackups || []).every(f => typeof f.snapshots === 'number');
|
|
|
|
// When each snapshot was written, under the count. The times are the only
|
|
// thing about a snapshot legible without the key — the filename is a hash
|
|
// and everything describing what is inside is in the encrypted object — so
|
|
// this answers "is this the backup I think it is", which is what someone
|
|
// wants before typing a password into it.
|
|
// Re-rendered deliberately: the validation probes above type into the path,
|
|
// and the adopt step rightly declines to overwrite something the user has
|
|
// entered. Clearing it first asserts the behaviour rather than whichever
|
|
// order the scan happened to resolve in.
|
|
$('#sw-rs-path').value = '';
|
|
w.renderFoundBackups();
|
|
out.snapshotTimesListed = document.querySelectorAll('#sw-rs-verify-result .setup-snap-list li').length;
|
|
out.snapshotTimesMatchTheCount =
|
|
out.snapshotTimesListed === Math.min((w.foundBackups[0] || {}).snapshots || 0, 12);
|
|
// A field dropped by rebuilding the record by hand is invisible until
|
|
// something downstream needs it, which is exactly what happened here.
|
|
out.timesSurviveTheHandoff = Array.isArray((w.foundBackups[0] || {}).times);
|
|
|
|
|
|
// Both the list and the verdict belong to the Folder field, under it, rather
|
|
// than floating above the whole form: they are answers about that input.
|
|
const localGroup = $('.setup-subgroup[data-rs-group="local"]');
|
|
out.foundInsideTheGroup = !!(localGroup && $('#sw-rs-found') && localGroup.contains($('#sw-rs-found')));
|
|
out.verdictInsideTheGroup = !!(localGroup && $('#sw-rs-verify-result')
|
|
&& localGroup.contains($('#sw-rs-verify-result')));
|
|
|
|
// --- exactly one found: it is the answer, not a choice ---
|
|
$('#sw-rs-path').value = '';
|
|
w.foundBackups = [{ path: '/only/one', snapshots: 4, newest: '2026-08-29T05:51:00+01:00' }];
|
|
w.renderFoundBackups();
|
|
out.singlePrefillsThePath = ($('#sw-rs-path') || {}).value === '/only/one';
|
|
out.singleShowsItsVerdict = /4 snapshots/.test(($('#sw-rs-verify-result') || {}).textContent || '');
|
|
// Rendered as the same CARD the found list uses, not as a sentence. It is
|
|
// the same fact either way and should not look like two different things
|
|
// depending on how it was arrived at.
|
|
const chosen = $('#sw-rs-verify-result .setup-found-backup');
|
|
out.singleRendersAsACard = !!chosen;
|
|
out.singleCardCarriesTheBadge = !!(chosen && chosen.querySelector('.setup-storage-badge'));
|
|
// Not a button: there is nothing left to choose, and a card that looks
|
|
// clickable but is not is worse than one that does not.
|
|
out.singleCardIsNotClickable = !!chosen && chosen.tagName !== 'BUTTON';
|
|
// The card and the verdict would otherwise say the same thing twice.
|
|
out.singleDrawsNoDuplicateCard = $('#sw-rs-found .setup-found-backup') === null;
|
|
// Pressing Check must confirm, not reply "give a full path" about the backup
|
|
// shown directly above it — which is what an empty field did.
|
|
await w.verifyBackupPath();
|
|
out.singleCheckDoesNotScold = !/full path/i.test(($('#sw-rs-verify-result') || {}).textContent || '');
|
|
|
|
// --- several found: a real choice, so ask ---
|
|
$('#sw-rs-path').value = '';
|
|
w.foundBackups = [
|
|
{ path: '/repo/a', snapshots: 3, newest: '2026-08-01T10:00:00+01:00' },
|
|
{ path: '/repo/b', snapshots: 9, newest: '2026-08-20T10:00:00+01:00' }
|
|
];
|
|
w.renderFoundBackups();
|
|
out.multipleAreListed = document.querySelectorAll('#sw-rs-found .setup-found-backup').length;
|
|
out.multipleRenderedAsButtons =
|
|
Array.from(document.querySelectorAll('#sw-rs-found .setup-found-backup')).every(b => b.tagName === 'BUTTON');
|
|
// Never guessed when it is genuinely ambiguous.
|
|
out.multipleLeaveThePathEmpty = ($('#sw-rs-path') || {}).value === '';
|
|
$('#sw-rs-found .setup-found-backup').click();
|
|
out.pickingOneFillsThePath = ($('#sw-rs-path') || {}).value === '/repo/a';
|
|
out.pickingOneReportsWithoutPassword =
|
|
/snapshot/i.test(($('#sw-rs-verify-result') || {}).textContent || '');
|
|
|
|
// --- none found ---
|
|
$('#sw-rs-path').value = '';
|
|
w.foundBackups = [];
|
|
w.renderFoundBackups();
|
|
// "We looked and there is nothing here" is information; empty space is not.
|
|
out.noneSaysSo = /nothing found/i.test(($('#sw-rs-found') || {}).textContent || '');
|
|
out.placeholderIsReal = !/mnt\/usb/.test(($('#sw-rs-path') || {}).placeholder || '');
|
|
|
|
// Pointing at the folder that HOLDS the repositories is the common near-miss,
|
|
// and is worth one click to fix rather than a paragraph explaining it.
|
|
$('#sw-rs-path').value = '/libreportal-backups';
|
|
await w.verifyBackupPath();
|
|
out.parentFolderExplained = /holds backups rather than being one/i.test(
|
|
($('#sw-rs-verify-result') || {}).textContent || '');
|
|
out.offersTheRealPath = !!$('#sw-rs-usesuggest');
|
|
|
|
// Next is disabled, not merely refused. A button that looks available and
|
|
// then argues is worse than one that plainly is not ready.
|
|
const nextBtn = $('#sw-next');
|
|
w.restoreInfo = null;
|
|
$('#sw-rs-pass').value = '';
|
|
w._syncRestoreNav();
|
|
out.nextDisabledWithoutPassword = nextBtn.disabled;
|
|
$('#sw-rs-pass').value = 'x';
|
|
w._syncRestoreNav();
|
|
out.nextStillDisabledUntilRead = nextBtn.disabled;
|
|
w.restoreInfo = { host: 'h', hosts: ['h'], system: { present: true, date: '', domains: [] }, apps: [] };
|
|
w._syncRestoreNav();
|
|
out.nextEnabledOnceRead = !nextBtn.disabled;
|
|
$('#sw-rs-path').dispatchEvent(new Event('input', { bubbles: true }));
|
|
out.nextDisabledAgainAfterEdit = nextBtn.disabled;
|
|
|
|
// The error text was the raw danger colour on a translucent danger
|
|
// background — a mid red on a dark blue panel.
|
|
const errEl = $('#sw-error');
|
|
errEl.style.display = ''; errEl.textContent = 'x';
|
|
const rgb = (getComputedStyle(errEl).color.match(/\d+/g) || []).map(Number);
|
|
// Rough perceived brightness; the old colour scored ~96, which is what made
|
|
// it hard to read on this background.
|
|
out.errorBrightness = Math.round((rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000);
|
|
|
|
// The step will not advance on a promise: the next one renders what is IN
|
|
// the backup, so it has to have been opened. Gated rather than instructed —
|
|
// the old copy told the user to enter a password beneath a field that had
|
|
// one, and kept telling them after they had.
|
|
const BACKUP = w.stepNames.indexOf('Backup');
|
|
out.passwordLabel = ($('label[for="sw-rs-pass"]') || {}).textContent?.trim().split('\n')[0].trim();
|
|
// The action sits beside the field it acts on, the same way Check does for
|
|
// the folder. A button floating below the form reads as a step of its own.
|
|
const readBtn = $('#sw-rs-read');
|
|
const readRow = readBtn && readBtn.closest('.setup-input-row');
|
|
out.readSitsInThePasswordRow = !!(readRow && readRow.querySelector('#sw-rs-pass'));
|
|
out.readMatchesCheckStyling = !!(readBtn && $('#sw-rs-verify')
|
|
&& readBtn.className === $('#sw-rs-verify').className);
|
|
out.readIsShortEnoughToSitInline = !!readBtn && readBtn.textContent.trim().length <= 8;
|
|
out.verdictHasNoInstruction =
|
|
!/enter its password/i.test(($('#sw-rs-verify-result') || {}).textContent || '');
|
|
|
|
w.restoreInfo = null;
|
|
$('#sw-rs-path').value = '/libreportal-backups/1';
|
|
$('#sw-rs-pass').value = '';
|
|
out.blockedWithoutPassword = !!w.validateStep(BACKUP);
|
|
$('#sw-rs-pass').value = 'x';
|
|
out.blockedUntilRead = !!w.validateStep(BACKUP);
|
|
w.restoreInfo = { host: 'h', hosts: ['h'], system: { present: true, date: '', domains: [] }, apps: [] };
|
|
out.allowedOnceRead = !w.validateStep(BACKUP);
|
|
|
|
// Editing the source after a read makes that read stale — otherwise the
|
|
// wizard carries the old repository's contents forward under a new path.
|
|
$('#sw-rs-path').value = '/somewhere/else';
|
|
$('#sw-rs-path').dispatchEvent(new Event('input', { bubbles: true }));
|
|
out.readInvalidatedByEdit = !w.restoreInfo;
|
|
|
|
// 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 "no section headings anywhere" "$(g .noSectionHeadingsAnywhere)" 0
|
|
chk "the progress bar names the step" "$(g .progressShowsTheStepName)" true
|
|
chk "and carries its explanation" "$(g .progressCarriesTheTip)" 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 "finding backups without a password"
|
|
chk "the scan found one" "$(g .scanFoundSomething)" true
|
|
chk "with a snapshot count" "$(g .foundCarrySnapshotCounts)" true
|
|
chk "the list sits under the field" "$(g .foundInsideTheGroup)" true
|
|
chk "so does the verdict" "$(g .verdictInsideTheGroup)" true
|
|
|
|
echo " one result is the answer, not a choice"
|
|
chk "it fills the path in" "$(g .singlePrefillsThePath)" true
|
|
chk "and shows its verdict" "$(g .singleShowsItsVerdict)" true
|
|
chk "as a card, not a sentence" "$(g .singleRendersAsACard)" true
|
|
chk "carrying the snapshot badge" "$(g .singleCardCarriesTheBadge)" true
|
|
chk "and not looking clickable" "$(g .singleCardIsNotClickable)" true
|
|
chk "without a duplicate card" "$(g .singleDrawsNoDuplicateCard)" true
|
|
chk "Check confirms, not scolds" "$(g .singleCheckDoesNotScold)" true
|
|
|
|
echo " several is a real choice, so ask"
|
|
chk "all are listed" "$(g .multipleAreListed)" 2
|
|
chk "as buttons" "$(g .multipleRenderedAsButtons)" true
|
|
chk "and nothing is guessed" "$(g .multipleLeaveThePathEmpty)" true
|
|
chk "picking one fills the path" "$(g .pickingOneFillsThePath)" true
|
|
chk "and reports before any password" "$(g .pickingOneReportsWithoutPassword)" true
|
|
|
|
echo " none found"
|
|
chk "says so rather than nothing" "$(g .noneSaysSo)" true
|
|
chk "placeholder is a real path" "$(g .placeholderIsReal)" true
|
|
chk "the parent-folder mistake is explained" "$(g .parentFolderExplained)" true
|
|
chk "and the real path is offered" "$(g .offersTheRealPath)" true
|
|
|
|
echo "the snapshot list"
|
|
chk "times are listed" "$(g .snapshotTimesListed)" 4
|
|
chk "one row per snapshot" "$(g .snapshotTimesMatchTheCount)" true
|
|
chk "and survive the handoff" "$(g .timesSurviveTheHandoff)" true
|
|
|
|
echo "Next is disabled, not merely refused"
|
|
chk "disabled without a password" "$(g .nextDisabledWithoutPassword)" true
|
|
chk "still disabled until read" "$(g .nextStillDisabledUntilRead)" true
|
|
chk "enabled once read" "$(g .nextEnabledOnceRead)" true
|
|
chk "disabled again after an edit" "$(g .nextDisabledAgainAfterEdit)" true
|
|
chk "error text is light enough" "$(g '.errorBrightness > 170')" true
|
|
|
|
echo "the step gates rather than instructs"
|
|
chk "the field is called Backup Password" "$(g .passwordLabel)" "Backup Password"
|
|
chk "Read sits beside the password" "$(g .readSitsInThePasswordRow)" true
|
|
chk "styled like the folder's Check" "$(g .readMatchesCheckStyling)" true
|
|
chk "and short enough to sit inline" "$(g .readIsShortEnoughToSitInline)" true
|
|
chk "no 'enter its password' copy" "$(g .verdictHasNoInstruction)" true
|
|
chk "blocked without a password" "$(g .blockedWithoutPassword)" true
|
|
chk "blocked until the backup is read" "$(g .blockedUntilRead)" true
|
|
chk "allowed once it has been" "$(g .allowedOnceRead)" true
|
|
chk "editing the source invalidates it" "$(g .readInvalidatedByEdit)" 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
|