LibrePortal/scripts/dev/lp-backup-dialog-test
librelad 7b0cf0bbba test: cover the Connect option's not-yet-available state
The dialog test caught the new option immediately, which is what it is for.
Updated for five backends, plus checks specific to Connect: it must be offered,
disabled, labelled so the reason is visible rather than just greyed out, carry
no credential field while unusable, and point at the free equivalent that works
today.

connect is left out of the per-type field-swap loop on purpose — a disabled
option cannot be selected, which is the behaviour we want and is asserted
directly instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:27:30 +01:00

195 lines
9.3 KiB
Bash
Executable File

#!/bin/bash
# Drive the wizard's "Add a backup destination" dialog in a real browser.
#
# scripts/dev/lp-backup-dialog-test # needs a running WebUI
#
# Everything here is behind a click, which is why it needs driving rather than
# reading. The step shipped once with its add button carrying a class that does
# not exist (.setup-add-domain — the real one is .setup-domain-add), so it
# rendered as a bare browser button in the middle of a styled form, and nothing
# that inspected the DOM structurally noticed.
#
# The assertion that matters most is the last: a credential typed here must
# leave as a REFERENCE. The wizard payload is base64'd into a task's command
# string and tasks are recorded world-readable, so a secret travelling as itself
# would be readable by any local account.
#
# One page load, because a cold SPA boot is slow: the whole interaction runs in
# a single `lp-shot --eval` and reports one JSON blob for bash to assert on.
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; }
read -r -d '' DRIVE <<'JS'
const DUMMY = 'dummy-not-a-real-secret-0000';
const out = {};
const wait = ms => new Promise(r => setTimeout(r, ms));
const groupsShown = () => ['local','sftp','s3','b2']
.filter(g => {
const el = document.querySelector(`[data-bk-group="${g}"]`);
return el && el.style.display !== 'none';
});
const add = document.getElementById('sw-backup-add');
if (!add) return JSON.stringify({ error: 'add button missing' });
// "Is it styled" cannot be a fixed colour (themes) nor "is it transparent"
// (a native button is grey, not transparent). Compare it against a bare
// button dropped into the same parent: if nothing differs, no rule matched
// and the class in the markup is one the stylesheet never defines.
{
const bare = document.createElement('button');
bare.type = 'button';
add.parentElement.appendChild(bare);
const a = getComputedStyle(add), b = getComputedStyle(bare);
out.addButtonStyled = ['background-color','border-radius','color','padding']
.some(prop => a.getPropertyValue(prop) !== b.getPropertyValue(prop));
out.addButtonBg = a.backgroundColor;
bare.remove();
}
out.rowsBefore = document.querySelectorAll('[data-backup-edit]').length;
add.click();
await wait(800);
const type = document.getElementById('bk-type');
out.dialogOpen = !!type;
out.typeEnhanced = !!(type && type.closest('.custom-select'));
out.typeOptions = type ? [...type.options].map(o => o.value) : [];
out.labelsStyled = !!document.querySelector('.setup-field label');
out.groupsAtOpen = groupsShown();
// The popup must be REACHABLE, not merely present. custom-select portals it
// into <body> at z-index 1200 — above eo-modal's 1100 — but the wizard raises
// its modal to 10000, which put the popup behind the dialog that owns it. The
// control still reported as enhanced and simply did not respond, so only a
// hit test catches it: what is actually on top at the popup's own centre?
{
const btn = type.closest('.custom-select').querySelector('.custom-select-button');
btn.click();
await wait(400);
const popup = document.querySelector('.custom-select-popup');
out.popupOpens = !!popup;
if (popup) {
const r = popup.getBoundingClientRect();
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + 12);
out.popupZ = parseInt(getComputedStyle(popup).zIndex, 10);
out.modalZ = parseInt(getComputedStyle(document.querySelector('.eo-modal')).zIndex, 10);
out.popupOnTop = !!(hit && hit.closest('.custom-select-popup'));
out.popupOptions = popup.querySelectorAll('.custom-select-option').length;
// And picking one has to take effect, the way a person would do it.
const opt = [...popup.querySelectorAll('.custom-select-option')]
.find(o => /SFTP/i.test(o.textContent));
if (opt) { opt.click(); await wait(400); }
out.pickedValue = type.value;
out.pickedLabel = btn.textContent.trim();
out.pickedGroups = groupsShown();
}
}
// Connect is offered but cannot be picked until the service exists. The
// option must be disabled AND say why — an option that is merely greyed out
// with no explanation reads as something broken.
{
const opt = [...type.options].find(o => o.value === 'connect');
out.connectPresent = !!opt;
out.connectDisabled = opt ? opt.disabled : null;
out.connectLabel = opt ? opt.text : null;
type.value = 'connect'; type.dispatchEvent(new Event('change'));
await wait(150);
const panel = document.querySelector('[data-bk-group="connect"]');
out.connectPanel = panel ? panel.innerText.replace(/\s+/g, ' ') : '';
out.connectHasToken = !!document.getElementById('bk-cn-token');
}
// Each type shows only its own fields.
out.swap = {};
for (const want of ['sftp','s3','b2','local']) {
type.value = want; type.dispatchEvent(new Event('change'));
await wait(120);
out.swap[want] = groupsShown();
}
// The SSH password appears only for password auth.
type.value = 'sftp'; type.dispatchEvent(new Event('change'));
await wait(150);
const auth = document.getElementById('bk-auth');
out.pwWithKey = document.querySelector('[data-bk-auth="password"]').style.display !== 'none';
auth.value = 'password'; auth.dispatchEvent(new Event('change'));
await wait(150);
out.pwWithPassword = document.querySelector('[data-bk-auth="password"]').style.display !== 'none';
out.pwInputType = (document.getElementById('bk-sshpass') || {}).type || null;
// Fill it in and submit.
document.getElementById('bk-name').value = 'Offsite';
document.getElementById('bk-host').value = 'backup.example.org';
document.getElementById('bk-user').value = 'lp';
document.getElementById('bk-rpath').value = '/srv/lp';
document.getElementById('bk-sshpass').value = DUMMY;
[...document.querySelectorAll('button')].find(b => b.textContent.trim() === 'Add').click();
await wait(2000);
out.rowsAfter = document.querySelectorAll('[data-backup-edit]').length;
out.listsOffsite = /Offsite/.test((document.getElementById('sw-backup-dests') || {}).innerText || '');
out.leaksInDom = document.body.innerHTML.includes(DUMMY);
return JSON.stringify(out);
JS
OUT=$("$SHOT" --eval "/?step=4" "$DRIVE" 2>/dev/null)
if [[ -z "$OUT" ]] || ! jq -e . >/dev/null 2>&1 <<< "$OUT"; then
echo " SKIP no WebUI reachable, or the step did not load"
exit 0
fi
if [[ "$(jq -r '.error // ""' <<< "$OUT")" != "" ]]; then
echo " FAIL $(jq -r .error <<< "$OUT")"; exit 1
fi
echo "--- the dialog opens, styled ---"
chk "dialog open" "$(jq -r .dialogOpen <<< "$OUT")" "true"
chk "type enhanced" "$(jq -r .typeEnhanced <<< "$OUT")" "true"
chk "every backend offered" "$(jq -r '.typeOptions | join(",")' <<< "$OUT")" "local,sftp,s3,b2,connect"
chk "labels styled" "$(jq -r .labelsStyled <<< "$OUT")" "true"
chk "opens on local" "$(jq -r '.groupsAtOpen | join(",")' <<< "$OUT")" "local"
chk "add button is styled" "$(jq -r .addButtonStyled <<< "$OUT")" "true"
echo "--- the dropdown is reachable, not just enhanced ---"
chk "popup opens" "$(jq -r .popupOpens <<< "$OUT")" "true"
chk "five options" "$(jq -r .popupOptions <<< "$OUT")" "5"
chk "popup above modal" "$(jq -r 'if .popupZ > .modalZ then "true" else "false" end' <<< "$OUT")" "true"
chk "popup is on top" "$(jq -r .popupOnTop <<< "$OUT")" "true"
chk "picking applies" "$(jq -r .pickedValue <<< "$OUT")" "sftp"
chk "button label" "$(jq -r .pickedLabel <<< "$OUT")" "SFTP"
chk "fields followed" "$(jq -r '.pickedGroups | join(",")' <<< "$OUT")" "sftp"
echo "--- Connect is offered, and honest about not being ready ---"
chk "offered" "$(jq -r .connectPresent <<< "$OUT")" "true"
chk "cannot be picked" "$(jq -r .connectDisabled <<< "$OUT")" "true"
chk "label says so" "$(jq -r '.connectLabel | test("not available") ' <<< "$OUT")" "true"
chk "no credential field" "$(jq -r .connectHasToken <<< "$OUT")" "false"
chk "points at the free equivalent" \
"$(jq -r '.connectPanel | test("SFTP and S3")' <<< "$OUT")" "true"
echo "--- each type shows only its own fields ---"
# connect is deliberately absent: its option is disabled while the service does
# not exist, and a disabled option cannot be selected — which is the behaviour we
# want, and is asserted directly above rather than through this loop.
for ty in local sftp s3 b2; do
chk "$ty" "$(jq -r --arg t "$ty" '.swap[$t] | join(",")' <<< "$OUT")" "$ty"
done
echo "--- the SSH password follows the auth choice ---"
chk "hidden for key auth" "$(jq -r .pwWithKey <<< "$OUT")" "false"
chk "shown for password auth" "$(jq -r .pwWithPassword <<< "$OUT")" "true"
chk "masked" "$(jq -r .pwInputType <<< "$OUT")" "password"
echo "--- submitting adds it, and the credential does not stay behind ---"
chk "a row was added" "$(jq -r '(.rowsAfter - .rowsBefore)' <<< "$OUT")" "1"
chk "listed by name" "$(jq -r .listsOffsite <<< "$OUT")" "true"
chk "not left in the DOM" "$(jq -r .leaksInDom <<< "$OUT")" "false"
echo ""
if (( fail )); then echo "FAILED"; exit 1; fi
echo "All backup-dialog checks passed."