LibrePortal/scripts/dev/lp-restore-wizard-test
librelad 601ea03b1a Backup step: drop the timestamp list, and make both answers cards
The pre-password "Taken" list goes. It could only ever be a column of
timestamps, and the card directly above it already said how many snapshots
there were and how recent the newest was — so it answered a question that had
just been answered. The real choice now lives on Contents, where snapshots have
names. The times came out of the scan and verify payloads with it; carried but
unread is debt.

The read result is a card too, matching the folder's. They were a card and a
sentence sitting one above the other, looking like two different kinds of
thing. Shortened to the host and what it holds — "Change-Me · settings + 2
apps". No "continue to see what will happen": Next is right there and has just
become available, which says it better.

And a real bug, caught by asserting Next's state after a genuine read rather
than a simulated one: readBackup CLEARS the password field the moment it hands
the value to the host, so a gate that re-checked the source fields reported a
missing password about a repository it had already opened. Next stayed disabled
for good after a successful read. Both the button and validateStep now treat an
open backup as settling the question — they have to agree, because
enabled-but-refused is worse than either alone.

The test that caught it could not run at first: BACKUP was declared after the
block using it, so the whole eval died in the temporal dead zone and reported
as "the browser failed".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 14:14:17 +01:00

467 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();
const BACKUP = w.stepNames.indexOf('Backup');
// 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;
// readBackup CLEARS the password once it has handed the value over, so a
// gate that re-checks the source fields reports a missing password about a
// repository it has already opened — and Next stays disabled for good.
$('#sw-rs-pass').value = '';
w._syncRestoreNav();
out.stillEnabledWithTheFieldCleared = !nextBtn.disabled;
// The button and the click must agree; enabled-but-refused is worse than
// either on its own.
out.validateAgreesWithTheButton = !w.validateStep(BACKUP);
$('#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.
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);
// Choosing WHICH snapshot. This is the point at which it is meaningful:
// before the repository is open a snapshot is a hash, because a restic
// snapshot holds one app's data or the settings tree and which is which
// lives in the encrypted object. Here they have names and dates.
//
// restorePickSnapshot has always passed any id that is not the string
// "latest" straight through, so the chain supported this long before
// anything offered it.
w.restoreInfo = {
host: 'oldbox', hosts: ['oldbox'], location_idx: '1',
system: { present: true, date: '2026-08-29T05:50:00+01:00', domains: [],
snapshots: [{ id: 'cc5b6bcf', time: '2026-08-29T05:50:00+01:00' },
{ id: '28bedbb0', time: '2026-08-29T04:09:00+01:00' }] },
apps: [
{ name: 'linkding', size: '1M',
snapshots: [{ id: '56e95ba1', time: '2026-08-28T13:10:00+01:00' },
{ id: 'aaaa1111', time: '2026-08-20T02:00:00+01:00' }] },
{ name: 'ipinfo', size: '5K',
snapshots: [{ id: '6f8eed7d', time: '2026-08-28T13:10:00+01:00' }] }
]
};
w._initRestoreChoice();
await w.renderRestoreContents();
out.systemHasAPicker = !!$('#sw-rs-snap-system');
out.appWithTwoHasAPicker = !!$('#sw-rs-snap-app-linkding');
// A dropdown holding one entry is a control that cannot be operated, and it
// makes a repository with one backup look like it is hiding something.
out.appWithOneHasNoPicker = !$('#sw-rs-snap-app-ipinfo');
out.defaultsToNewest = JSON.stringify(w.restoreChoice) ===
'{"system":"cc5b6bcf","apps":{"linkding":"56e95ba1","ipinfo":"6f8eed7d"}}';
const sysSel = $('#sw-rs-snap-system');
sysSel.value = '28bedbb0'; fire(sysSel, 'change');
const appSel = $('#sw-rs-snap-app-linkding');
appSel.value = 'aaaa1111'; fire(appSel, 'change');
out.pickingIsRemembered = w.restoreChoice.system === '28bedbb0'
&& w.restoreChoice.apps.linkding === 'aaaa1111';
// The payload carries the ids AND the human times, so the restore's own
// output can name a date rather than a hash.
const payload = w._restoreChoicePayload();
out.payloadCarriesThePicks = payload.system === '28bedbb0' && payload.apps.linkding === 'aaaa1111';
out.payloadCarriesReadableTimes = /20 Aug 2026/.test(payload.times.linkding || '');
// Apps the user did not touch still travel, at their newest.
out.untouchedAppsStillSent = payload.apps.ipinfo === '6f8eed7d';
// 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 "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 "even with the field cleared" "$(g .stillEnabledWithTheFieldCleared)" true
chk "and the click is not refused" "$(g .validateAgreesWithTheButton)" 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 "choosing which snapshot"
chk "the settings offer a choice" "$(g .systemHasAPicker)" true
chk "so does an app with two" "$(g .appWithTwoHasAPicker)" true
chk "an app with one does not" "$(g .appWithOneHasNoPicker)" true
chk "everything defaults to newest" "$(g .defaultsToNewest)" true
chk "picking is remembered" "$(g .pickingIsRemembered)" true
chk "and reaches the payload" "$(g .payloadCarriesThePicks)" true
chk "with readable times beside it" "$(g .payloadCarriesReadableTimes)" true
chk "untouched apps still travel" "$(g .untouchedAppsStillSent)" true
echo "submit"
chk "routes to the restore path" "$(g .submitRoutedToRestore)" true
[[ $fail -eq 0 ]] && echo "restore wizard test: OK"
exit $fail