setup: fix the Backups dialog, and make dialogs testable at all
Reported after looking at the step: the add button unstyled, the dialog missing
the fields a backup location actually has, and its dropdowns not working. Three
real faults, and one reason all three shipped.
* "+ Add destination" carried class .setup-add-domain, which I invented. The
real one is .setup-domain-add, so no rule matched and it rendered as a bare
browser button in the middle of a styled form.
* The dialog asked for name / type / host / user / path / password. A backup
location has SSH port and auth method (key or password — key is the default
and needs nothing typed), S3 access and secret keys, B2 account id and key,
and a path mode. It now asks for what each backend needs, with the wording
taken from the location config so the wizard and the Backup page describe
the same thing the same way.
* .setup-field styled input[type=text] and [type=email] but not [type=password]
or [type=number], so a credential field and the SSH port rendered unstyled
even inside a correct container.
Only the credentials go through the secret channel — SSH password, S3 secret
key, B2 account key. The rest is ordinary configuration and travels as itself.
The reason all three shipped is that I checked the step by querying the DOM and
never looked at it. Structural checks cannot see an unstyled control, and a
dialog is behind a click so a screenshot cannot reach it either. So:
lp-shot --eval <route> <js> run JS in the page and print the result
LP_SHOT_EVAL=<js> run JS before a capture — open a dialog, then shoot
and scripts/dev/lp-backup-dialog-test drives the whole thing in a real browser:
opens it, swaps every backend and asserts only that backend's fields show,
toggles SSH auth and asserts the password field follows, submits, and asserts
the credential is not left in the DOM.
Its styling check needed two attempts, which is the point of mutation-testing
it: "is the background transparent" passes for an unstyled button, because a
native button is grey rather than transparent. It now compares the control
against a bare <button> in the same parent, so "no rule matched" is what fails.
Verified: reintroducing the wrong class fails the test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
34512f7b28
commit
00114a6ce2
@ -295,6 +295,11 @@ body.setup-wizard-open {
|
||||
|
||||
.setup-field input[type=text],
|
||||
.setup-field input[type=email],
|
||||
/* password and number were missing, so any field using them — a backup
|
||||
destination's credentials, an SSH port — rendered as a bare browser input in
|
||||
the middle of styled ones. */
|
||||
.setup-field input[type=password],
|
||||
.setup-field input[type=number],
|
||||
.setup-field select {
|
||||
width: 100%;
|
||||
background: rgba(var(--text-rgb), 0.06);
|
||||
@ -312,6 +317,8 @@ body.setup-wizard-open {
|
||||
|
||||
.setup-field input[type=text]:focus,
|
||||
.setup-field input[type=email]:focus,
|
||||
.setup-field input[type=password]:focus,
|
||||
.setup-field input[type=number]:focus,
|
||||
.setup-field select:focus {
|
||||
outline: none;
|
||||
background: rgba(var(--text-rgb), 0.10);
|
||||
|
||||
@ -71,6 +71,22 @@ class SetupWizard {
|
||||
// Indices of the steps actually shown, in order. Everything else (progress,
|
||||
// next/prev, validation, submit) works off this rather than raw indices, so
|
||||
// hiding a step never leaves a gap in the numbering.
|
||||
// Which step to open on. Out-of-range or missing means the first one; the
|
||||
// value is an index into the VISIBLE steps, so it matches what the progress
|
||||
// bar says rather than the raw list.
|
||||
_stepFromQuery() {
|
||||
try {
|
||||
const raw = new URLSearchParams(window.location.search).get('step');
|
||||
if (raw === null) return 0;
|
||||
const n = parseInt(raw, 10);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
const max = this._visibleSteps().length - 1;
|
||||
return Math.min(Math.max(n, 0), Math.max(max, 0));
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
_visibleSteps() {
|
||||
return this.stepNames.map((_, i) => i).filter((i) => this._stepVisible(i));
|
||||
}
|
||||
@ -92,7 +108,11 @@ class SetupWizard {
|
||||
// Same shape: the step is usable immediately and fills in when the
|
||||
// install's existing destinations come back.
|
||||
this.loadBackupLocations();
|
||||
this.showStep(0);
|
||||
// ?step=N opens the wizard on a given step. Nothing about setup depends on
|
||||
// the order, and it makes a step reachable without clicking through the
|
||||
// ones before it — which is what lets a screenshot or a headless test look
|
||||
// at, say, Backups directly.
|
||||
this.showStep(this._stepFromQuery());
|
||||
}
|
||||
|
||||
getWizardApps() {
|
||||
@ -787,7 +807,7 @@ class SetupWizard {
|
||||
}).join('');
|
||||
|
||||
box.innerHTML = rows + `
|
||||
<button type="button" class="setup-add-domain" id="sw-backup-add">+ Add destination</button>`;
|
||||
<button type="button" class="setup-domain-add" id="sw-backup-add"><span>+</span> Add destination</button>`;
|
||||
|
||||
box.querySelectorAll('[data-backup-edit]').forEach(b => {
|
||||
b.addEventListener('click', () => this.showBackupDestModal(Number(b.dataset.backupEdit)));
|
||||
@ -813,43 +833,67 @@ class SetupWizard {
|
||||
if (typeof window.openEoModal !== 'function') return;
|
||||
const adding = index < 0;
|
||||
const loc = adding
|
||||
? { name: '', type: 'local', path: '' }
|
||||
: Object.assign({}, this.backupLocations[index]);
|
||||
? { name: '', type: 'local', path: '', ssh_port: '22', ssh_auth: 'key' }
|
||||
: Object.assign({ ssh_port: '22', ssh_auth: 'key' }, this.backupLocations[index]);
|
||||
|
||||
const types = [
|
||||
['local', 'This machine or a plugged-in disk'],
|
||||
['sftp', 'SFTP server'],
|
||||
['s3', 'S3'],
|
||||
['b2', 'Backblaze B2']
|
||||
];
|
||||
const esc = (v) => this.escapeHtml(v == null ? '' : String(v));
|
||||
// Fields and wording follow the location config itself, so what is asked
|
||||
// here and what the Backup page shows afterwards are the same thing.
|
||||
const field = (id, label, hint, input) => `
|
||||
<div class="setup-field">
|
||||
<label for="${id}">${esc(label)}</label>
|
||||
${input}
|
||||
${hint ? `<span class="setup-section-hint">${esc(hint)}</span>` : ''}
|
||||
</div>`;
|
||||
const text = (id, val, ph = '') =>
|
||||
`<input type="text" id="${id}" class="form-control" value="${esc(val)}" placeholder="${esc(ph)}">`;
|
||||
const secret = (id) =>
|
||||
`<input type="password" id="${id}" class="form-control" autocomplete="new-password">`;
|
||||
|
||||
const body = `
|
||||
<div class="setup-field">
|
||||
<label class="setup-label">Name</label>
|
||||
<input type="text" id="bk-name" class="form-control" value="${this.escapeHtml(loc.name || '')}" placeholder="Offsite">
|
||||
</div>
|
||||
<div class="setup-field">
|
||||
<label class="setup-label">Where</label>
|
||||
${field('bk-name', 'Name', 'Shown wherever this destination appears.',
|
||||
text('bk-name', loc.name, 'Offsite'))}
|
||||
${field('bk-type', 'Type', 'Backend this destination uses.', `
|
||||
<select id="bk-type" class="form-control">
|
||||
${types.map(([v, l]) => `<option value="${v}"${v === loc.type ? ' selected' : ''}>${this.escapeHtml(l)}</option>`).join('')}
|
||||
</select>
|
||||
<option value="local"${loc.type === 'local' ? ' selected' : ''}>Local / mounted path</option>
|
||||
<option value="sftp"${loc.type === 'sftp' ? ' selected' : ''}>SFTP</option>
|
||||
<option value="s3"${loc.type === 's3' ? ' selected' : ''}>S3</option>
|
||||
<option value="b2"${loc.type === 'b2' ? ' selected' : ''}>Backblaze B2</option>
|
||||
</select>`)}
|
||||
|
||||
<div data-bk-group="local">
|
||||
${field('bk-path', 'Custom Path', 'Filesystem path on this server. Leave blank to use the default backup folder.',
|
||||
text('bk-path', loc.path, '/mnt/usb/libreportal-backups'))}
|
||||
</div>
|
||||
<div id="bk-local">
|
||||
<div class="setup-field">
|
||||
<label class="setup-label">Folder</label>
|
||||
<input type="text" id="bk-path" class="form-control" value="${this.escapeHtml(loc.path || '')}" placeholder="/mnt/usb/libreportal-backups">
|
||||
|
||||
<div data-bk-group="sftp">
|
||||
${field('bk-host', 'SSH Host', '', text('bk-host', loc.ssh_host, 'backup.example.org'))}
|
||||
${field('bk-user', 'SSH User', '', text('bk-user', loc.ssh_user, 'libreportal'))}
|
||||
${field('bk-rpath', 'SSH Remote Path', 'Path on the remote host where the repo lives.',
|
||||
text('bk-rpath', loc.ssh_path, '/srv/backups'))}
|
||||
${field('bk-port', 'SSH Port', '', `<input type="number" id="bk-port" class="form-control" value="${esc(loc.ssh_port || '22')}" min="1" max="65535">`)}
|
||||
${field('bk-auth', 'SSH Authentication', 'A key is managed by LibrePortal and needs nothing from you here.', `
|
||||
<select id="bk-auth" class="form-control">
|
||||
<option value="key"${loc.ssh_auth !== 'password' ? ' selected' : ''}>SSH key (managed by LibrePortal)</option>
|
||||
<option value="password"${loc.ssh_auth === 'password' ? ' selected' : ''}>Password</option>
|
||||
</select>`)}
|
||||
<div data-bk-auth="password">
|
||||
${field('bk-sshpass', 'SSH Password', 'Sent straight to this machine and stored where only LibrePortal can read it — never part of the task log.',
|
||||
secret('bk-sshpass'))}
|
||||
</div>
|
||||
</div>
|
||||
<div id="bk-remote" style="display:none;">
|
||||
<div class="setup-field"><label class="setup-label">Host</label>
|
||||
<input type="text" id="bk-host" class="form-control" value="${this.escapeHtml(loc.ssh_host || '')}"></div>
|
||||
<div class="setup-field"><label class="setup-label">User</label>
|
||||
<input type="text" id="bk-user" class="form-control" value="${this.escapeHtml(loc.ssh_user || '')}"></div>
|
||||
<div class="setup-field"><label class="setup-label">Path on that host</label>
|
||||
<input type="text" id="bk-rpath" class="form-control" value="${this.escapeHtml(loc.ssh_path || '')}"></div>
|
||||
<div class="setup-field"><label class="setup-label">Password</label>
|
||||
<input type="password" id="bk-pass" class="form-control" autocomplete="new-password">
|
||||
<span class="setup-section-hint">Sent straight to this machine and stored where only LibrePortal can read it — it is never part of the task log.</span></div>
|
||||
|
||||
<div data-bk-group="s3">
|
||||
${field('bk-s3uri', 'Bucket', 'For example s3:s3.amazonaws.com/my-bucket.',
|
||||
text('bk-s3uri', loc.uri, 's3:s3.amazonaws.com/my-bucket'))}
|
||||
${field('bk-s3key', 'S3 Access Key', '', text('bk-s3key', loc.s3_access_key))}
|
||||
${field('bk-s3secret', 'S3 Secret Key', 'Stored where only LibrePortal can read it.', secret('bk-s3secret'))}
|
||||
</div>
|
||||
|
||||
<div data-bk-group="b2">
|
||||
${field('bk-b2uri', 'Bucket', 'For example b2:my-bucket.', text('bk-b2uri', loc.uri, 'b2:my-bucket'))}
|
||||
${field('bk-b2id', 'B2 Account ID', '', text('bk-b2id', loc.b2_account_id))}
|
||||
${field('bk-b2key', 'B2 Account Key', 'Stored where only LibrePortal can read it.', secret('bk-b2key'))}
|
||||
</div>`;
|
||||
|
||||
const m = window.openEoModal({
|
||||
@ -859,31 +903,48 @@ class SetupWizard {
|
||||
body,
|
||||
actions: [
|
||||
{ label: 'Cancel', variant: 'secondary' },
|
||||
{ label: adding ? 'Add' : 'Save', variant: 'primary', keep: true, onClick: async () => {
|
||||
const root = document;
|
||||
const type = root.querySelector('#bk-type').value;
|
||||
const name = (root.querySelector('#bk-name').value || '').trim() || (type === 'local' ? 'Local disk' : type);
|
||||
const next = Object.assign({}, loc, { name, type });
|
||||
{ label: adding ? 'Add' : 'Save', variant: 'primary', keep: true,
|
||||
onClick: async () => {
|
||||
const v = (id) => (document.getElementById(id)?.value || '').trim();
|
||||
const type = v('bk-type') || 'local';
|
||||
const next = Object.assign({}, loc, {
|
||||
type,
|
||||
name: v('bk-name') || (type === 'local' ? 'Local disk' : type.toUpperCase())
|
||||
});
|
||||
|
||||
// Only the secrets go through the drop; everything else is ordinary
|
||||
// configuration and travels in the payload as itself.
|
||||
const stash = async (id, key) => {
|
||||
const raw = document.getElementById(id)?.value || '';
|
||||
if (!raw) return true;
|
||||
const ref = await this.stashSecret(raw);
|
||||
if (!ref) return false;
|
||||
next[key] = ref;
|
||||
return true;
|
||||
};
|
||||
|
||||
if (type === 'local') {
|
||||
next.path = (root.querySelector('#bk-path').value || '').trim();
|
||||
} else {
|
||||
next.ssh_host = (root.querySelector('#bk-host').value || '').trim();
|
||||
next.ssh_user = (root.querySelector('#bk-user').value || '').trim();
|
||||
next.ssh_path = (root.querySelector('#bk-rpath').value || '').trim();
|
||||
const pw = root.querySelector('#bk-pass').value || '';
|
||||
if (pw) {
|
||||
const ref = await this.stashSecret(pw);
|
||||
if (!ref) return; // stashSecret already reported why
|
||||
next.password_ref = ref;
|
||||
}
|
||||
next.path = v('bk-path');
|
||||
} else if (type === 'sftp') {
|
||||
next.ssh_host = v('bk-host');
|
||||
next.ssh_user = v('bk-user');
|
||||
next.ssh_path = v('bk-rpath');
|
||||
next.ssh_port = v('bk-port') || '22';
|
||||
next.ssh_auth = v('bk-auth') || 'key';
|
||||
if (next.ssh_auth === 'password' && !await stash('bk-sshpass', 'ssh_pass_ref')) return;
|
||||
} else if (type === 's3') {
|
||||
next.uri = v('bk-s3uri');
|
||||
next.s3_access_key = v('bk-s3key');
|
||||
if (!await stash('bk-s3secret', 's3_secret_ref')) return;
|
||||
} else if (type === 'b2') {
|
||||
next.uri = v('bk-b2uri');
|
||||
next.b2_account_id = v('bk-b2id');
|
||||
if (!await stash('bk-b2key', 'b2_key_ref')) return;
|
||||
}
|
||||
|
||||
if (adding) {
|
||||
this.backupLocations.push(next);
|
||||
} else {
|
||||
// Mark it so the payload carries it: an existing destination is
|
||||
// only submitted when the user actually changed something.
|
||||
next.dirty = true;
|
||||
this.backupLocations[index] = next;
|
||||
}
|
||||
@ -893,14 +954,21 @@ class SetupWizard {
|
||||
]
|
||||
});
|
||||
|
||||
// Local and remote want different fields; swap them as the type changes.
|
||||
// Show only the fields the chosen type actually has, and the SSH password
|
||||
// only when password auth is selected.
|
||||
const sync = () => {
|
||||
const type = document.querySelector('#bk-type').value;
|
||||
document.querySelector('#bk-local').style.display = type === 'local' ? '' : 'none';
|
||||
document.querySelector('#bk-remote').style.display = type === 'local' ? 'none' : '';
|
||||
const type = document.getElementById('bk-type')?.value || 'local';
|
||||
document.querySelectorAll('[data-bk-group]').forEach(g => {
|
||||
g.style.display = g.dataset.bkGroup === type ? '' : 'none';
|
||||
});
|
||||
const auth = document.getElementById('bk-auth')?.value || 'key';
|
||||
document.querySelectorAll('[data-bk-auth]').forEach(g => {
|
||||
g.style.display = (type === 'sftp' && auth === 'password') ? '' : 'none';
|
||||
});
|
||||
};
|
||||
const sel = document.querySelector('#bk-type');
|
||||
if (sel) { sel.addEventListener('change', sync); sync(); }
|
||||
document.getElementById('bk-type')?.addEventListener('change', sync);
|
||||
document.getElementById('bk-auth')?.addEventListener('change', sync);
|
||||
sync();
|
||||
}
|
||||
|
||||
// Hand a secret to the host and get back a reference to put in the payload.
|
||||
|
||||
130
scripts/dev/lp-backup-dialog-test
Executable file
130
scripts/dev/lp-backup-dialog-test
Executable file
@ -0,0 +1,130 @@
|
||||
#!/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();
|
||||
|
||||
// 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 "--- 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."
|
||||
@ -13,6 +13,9 @@ Arguments (all optional after the route):
|
||||
Environment:
|
||||
LP_SHOT_URL base URL of the WebUI (default: auto-detected, else http://localhost:3179)
|
||||
LP_SHOT_VIEWPORT WIDTHxHEIGHT (default 1440x900)
|
||||
--eval ROUTE JS run JS in the page and print the result; no screenshot
|
||||
LP_SHOT_EVAL JS run in the page before capture — open a dialog, pick a
|
||||
tab, expand a row. Awaited, so async handlers finish.
|
||||
LP_SHOT_SCALE device pixel ratio (default 2 — that's the "crisp")
|
||||
LP_SHOT_SETTLE extra seconds after load (default 1.5)
|
||||
LP_SHOT_CHROME chromium binary to use (default: first found on PATH)
|
||||
@ -380,6 +383,23 @@ def main():
|
||||
print(__doc__.strip())
|
||||
sys.exit(0 if len(sys.argv) > 1 else 2)
|
||||
|
||||
# --eval: drive the page and print what the expression returns, instead of
|
||||
# taking a picture.
|
||||
#
|
||||
# A screenshot shows a route; it cannot assert anything about a dialog, and
|
||||
# a dialog is state you reach by clicking. Without this, anything behind a
|
||||
# click gets checked by reading the source and hoping — which is how a step
|
||||
# ships with a button styled by a class that does not exist.
|
||||
#
|
||||
# lp-shot --eval /route 'document.querySelectorAll("x").length'
|
||||
eval_mode = sys.argv[1] == "--eval"
|
||||
if eval_mode:
|
||||
if len(sys.argv) < 4:
|
||||
die("usage: lp-shot --eval <route> <javascript>")
|
||||
route = sys.argv[2]
|
||||
eval_expr = sys.argv[3]
|
||||
out, pad, selector = None, 0.0, None
|
||||
else:
|
||||
route = sys.argv[1]
|
||||
out = os.path.abspath(sys.argv[2]) if len(sys.argv) > 2 else DEFAULT_OUT
|
||||
pad = float(sys.argv[3]) if len(sys.argv) > 3 else 0.0
|
||||
@ -459,6 +479,27 @@ def main():
|
||||
die(AUTH_HELP)
|
||||
time.sleep(settle)
|
||||
|
||||
# LP_SHOT_EVAL: run something in the page before capturing.
|
||||
#
|
||||
# A screenshot answers "does this route render". It cannot answer "does
|
||||
# this dialog look right", because a dialog is state you reach by
|
||||
# clicking — so verifying one meant driving a real browser by hand, and
|
||||
# anything only reachable that way tends to get checked structurally
|
||||
# and never actually looked at. This lets a capture open the thing
|
||||
# first. Awaited, so an async handler finishes before the shutter.
|
||||
if eval_mode:
|
||||
# Awaited, so an async body finishes before the value is read.
|
||||
print(cdp.eval(f"(async () => {{ {eval_expr} }})()"))
|
||||
return
|
||||
|
||||
pre = os.environ.get("LP_SHOT_EVAL")
|
||||
if pre:
|
||||
try:
|
||||
cdp.eval(f"(async () => {{ {pre} }})()")
|
||||
except Exception as exc:
|
||||
print(f" LP_SHOT_EVAL failed: {exc}", file=sys.stderr)
|
||||
time.sleep(settle if settle else 0.6)
|
||||
|
||||
for e in cdp.events:
|
||||
if e["method"] == "Log.entryAdded" and e["params"]["entry"].get("level") == "error":
|
||||
print(f" page error: {e['params']['entry'].get('text')}", file=sys.stderr)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user