verstak-sync-server/scripts/smoke-web-browser.mjs

153 lines
9.2 KiB
JavaScript
Executable File

#!/usr/bin/env node
// Browser smoke driver for scripts/smoke-web.sh. It uses Node's bundled
// undici WebSocket client and Chromium DevTools; no npm package is required.
import { createRequire } from "node:module";
import { writeFile } from "node:fs/promises";
const require = createRequire(import.meta.url);
const { WebSocket } = require("undici");
const [baseURL, outputDir] = process.argv.slice(2);
if (!baseURL || !outputDir) throw new Error("usage: smoke-web-browser.mjs <base-url> <output-dir>");
const debugURL = process.env.CHROME_DEBUG_URL || "http://127.0.0.1:9223";
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitFor(check, label) {
for (let attempt = 0; attempt < 100; attempt += 1) {
if (await check()) return;
await delay(100);
}
throw new Error(`timed out waiting for ${label}`);
}
const targets = await (await fetch(`${debugURL}/json/list`)).json();
const target = targets.find((item) => item.type === "page");
if (!target?.webSocketDebuggerUrl) throw new Error("Chromium DevTools page target is unavailable");
const socket = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((resolve, reject) => {
socket.addEventListener("open", resolve, { once: true });
socket.addEventListener("error", reject, { once: true });
});
let nextID = 1;
const pending = new Map();
socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (!message.id) return;
const request = pending.get(message.id);
if (!request) return;
pending.delete(message.id);
if (message.error) request.reject(new Error(`${message.error.message} (${message.error.code})`));
else request.resolve(message.result);
});
function cdp(method, params = {}) {
const id = nextID++;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
socket.send(JSON.stringify({ id, method, params }));
});
}
async function evaluate(expression) {
const result = await cdp("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
if (result.exceptionDetails) throw new Error(result.exceptionDetails.text || "browser evaluation failed");
return result.result.value;
}
async function navigate(path) {
await cdp("Page.navigate", { url: new URL(path, baseURL).href });
await waitFor(async () => String(await evaluate("location.href")).startsWith(new URL(path, baseURL).href), path);
await waitFor(() => evaluate("document.readyState === 'complete'"), `${path} document readiness`);
}
async function submit(selector, values, expectedPath) {
const payload = JSON.stringify(values);
await evaluate(`(() => { const form = document.querySelector(${JSON.stringify(selector)}); if (!form) throw new Error('form not found: ${selector}'); const values = ${payload}; for (const [name, value] of Object.entries(values)) { const input = form.querySelector('[name="' + name + '"]'); if (!input) throw new Error('input not found: ' + name); input.value = value; } form.requestSubmit(); })()`);
await waitFor(async () => new URL(await evaluate("location.href")).pathname === expectedPath, expectedPath);
}
async function screenshot(name) {
const image = await cdp("Page.captureScreenshot", { format: "png" });
await writeFile(`${outputDir}/${name}.png`, Buffer.from(image.data, "base64"));
}
async function confirmDialog(expectedPath) {
await waitFor(() => evaluate("document.querySelector('#confirm-dialog')?.open === true"), "confirmation dialog");
await evaluate("document.querySelector('#confirm-dialog button[value=confirm]').click()");
await waitFor(() => evaluate("document.querySelector('#confirm-dialog')?.open === false"), "confirmation dialog close");
await delay(300);
await waitFor(async () => new URL(await evaluate("location.href")).pathname === expectedPath, expectedPath);
}
await cdp("Page.enable");
await cdp("Runtime.enable");
// Public locale selector, then a real admin login through the rendered form.
await navigate("/");
await screenshot("public-en");
await waitFor(() => evaluate("Boolean(document.querySelector('.locale-form select'))"), "locale selector");
await evaluate("document.querySelector('.locale-form select').value = 'ru'; document.querySelector('.locale-form').requestSubmit()");
await waitFor(() => evaluate("document.documentElement?.lang === 'ru'"), "Russian locale");
await screenshot("public-ru");
await navigate("/admin/login");
await submit('form[action="/admin/login"]', { username: "admin", password: "browser-smoke-admin-password" }, "/admin/dashboard");
// Seed data before rendering admin views, so screenshots cover non-empty
// users/devices/vaults/storage/audit states.
await navigate("/admin/create-user");
await submit('form[action="/admin/create-user"]', { username: "browser-smoke-user", email: "browser-smoke@example.test", password: "browser-smoke-password" }, "/admin/users");
const paired = await fetch(`${baseURL}/api/client/pair`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ login: "browser-smoke-user", password: "browser-smoke-password", device_name: "Browser smoke laptop", vault_id: "browser-smoke-vault", client_version: "smoke" }) });
if (!paired.ok) throw new Error(`pairing smoke device failed: ${paired.status}`);
await navigate("/admin/dashboard");
await screenshot("admin-dashboard");
// Core admin navigation with populated data.
for (const [path, name] of [["/admin/users", "admin-users"], ["/admin/devices", "admin-devices"], ["/admin/vaults", "admin-vaults"], ["/admin/storage", "admin-storage"], ["/admin/audit", "admin-audit"], ["/admin/settings", "admin-settings"], ["/admin/diagnostics", "admin-diagnostics"]]) {
await navigate(path);
await screenshot(name);
}
await navigate("/admin/vaults");
const vaultDetail = await evaluate("document.querySelector('a[href^=\"/admin/vault/\"]')?.getAttribute('href')");
if (!vaultDetail) throw new Error("vault detail link not found");
await navigate(vaultDetail);
await screenshot("admin-vault-detail");
await navigate("/admin/users?q=browser-smoke-user");
// Exercise the destructive confirmation dialog by blocking and unblocking the
// temporary user. requestSubmit is intentionally not used for this action.
async function toggleTemporaryUser() {
await evaluate(`(() => { const row = [...document.querySelectorAll('tbody tr')].find((item) => item.textContent.includes('browser-smoke-user')); if (!row) throw new Error('temporary user row not found'); const details = row.querySelector('details'); details.open = true; const form = [...row.querySelectorAll('form')].find((item) => item.querySelector('[name=action]')?.value === 'toggle-user'); form.querySelector('[name=password]').value = 'browser-smoke-admin-password'; form.querySelector('button').click(); })()`);
await confirmDialog("/admin/users");
await navigate("/admin/users?q=browser-smoke-user");
}
await toggleTemporaryUser();
await toggleTemporaryUser();
// A password reset is generated server-side, rendered once with no secret in
// the URL, and invalidates any prior user session. Keep it only in this test
// process so the subsequent user login exercises the generated credential.
await evaluate(`(() => { const row = [...document.querySelectorAll('tbody tr')].find((item) => item.textContent.includes('browser-smoke-user')); const details = row.querySelector('details'); details.open = true; const form = [...row.querySelectorAll('form')].find((item) => item.querySelector('[name=action]')?.value === 'reset-user-password'); form.querySelector('[name=password]').value = 'browser-smoke-admin-password'; form.querySelector('button').click(); })()`);
await confirmDialog("/admin/password-result");
await screenshot("admin-password-result");
const generatedPassword = await evaluate("document.querySelector('.one-time-secret')?.textContent.trim()");
if (!generatedPassword || (await evaluate("location.href")).includes(generatedPassword)) throw new Error("one-time password result is missing or leaked into the URL");
await navigate("/admin/users?q=browser-smoke-user");
// Revoke the real temporary device through the browser UI and assert the
// modal path again.
await navigate("/admin/devices?q=Browser%20smoke");
await evaluate(`(() => { const row = [...document.querySelectorAll('tbody tr')].find((item) => item.textContent.includes('Browser smoke laptop')); if (!row) throw new Error('smoke device row not found'); const form = row.querySelector('form'); form.querySelector('[name=password]').value = 'browser-smoke-admin-password'; form.querySelector('button').click(); })()`);
await confirmDialog("/admin/devices");
// Server-side filters and HTML logout complete the browser path.
await navigate("/admin/audit?q=device");
await screenshot("admin-audit-filtered");
await evaluate("document.querySelector('form[action=\"/admin/logout\"]').requestSubmit()");
await waitFor(async () => new URL(await evaluate("location.href")).pathname === "/admin/login", "admin logout");
await navigate("/login");
await submit('form[action="/login"]', { username: "browser-smoke-user", password: generatedPassword }, "/dashboard");
await screenshot("user-dashboard");
await evaluate("document.querySelector('form[action=\"/logout\"]').requestSubmit()");
await waitFor(async () => new URL(await evaluate("location.href")).pathname === "/login", "user logout");
socket.close();
console.log("interactive Chromium web smoke passed");