LibrePortal/scripts/dev/lp-backup-dialog-test
librelad d51e014cad setup: the wizard's select popups opened behind its own modal
Reported: the dropdowns in Add destination don't work. They rendered correctly,
reported as enhanced, and did nothing when clicked.

custom-select portals its popup into <body> at z-index 1200, chosen — as
forms.css says in as many words — to clear eo-modal at 1100. The wizard raises
its modal to 10000, because at 1100 a modal opened from inside the wizard
rendered behind the wizard itself. That fix silently broke the other invariant:
the popup then opened behind the dialog that owns it. Raise the popup with it,
scoped to the wizard so nothing else's stacking moves.

The test already asserted the select was enhanced, which was true and useless —
the control was enhanced, it just could not be reached. So it now hit-tests:
open the popup and ask what is actually on top at its own centre, then click an
option and check the value, the button label and the field group all follow.
Verified by removing the rule again: two checks fail.

That is the second time this pair has bitten (the modal itself did the same
thing earlier), so the rule and the reason now sit together in one comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:25:41 +01:00

169 lines
7.9 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();
}
}
// 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 "all four backends" "$(jq -r '.typeOptions | join(",")' <<< "$OUT")" "local,sftp,s3,b2"
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 "four options" "$(jq -r .popupOptions <<< "$OUT")" "4"
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 "--- each type shows only its own fields ---"
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."