commit a9346a767f278d2f21294a12121b3e5c8b1ebb0d
parent aeb4084209cc6f6f422143ac20c31b6594d66631
Author: Florian Dold <dold@taler.net>
Date: Tue, 3 Nov 2026 21:51:31 +0100
merchant web UI: test action feedback in Chromium
Check nine workflows at desktop and mobile widths, covering long errors,
dismissal, and repeated failures.
Diffstat:
2 files changed, 266 insertions(+), 2 deletions(-)
diff --git a/packages/taler-merchant-webui/contrib/qa/save-feedback-check.mjs b/packages/taler-merchant-webui/contrib/qa/save-feedback-check.mjs
@@ -0,0 +1,256 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+ SPDX-License-Identifier: GPL-3.0-or-later
+ */
+// Run after building the merchant UI so the browser uses the production CSS.
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import http from "node:http";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { build } from "esbuild";
+import puppeteer from "puppeteer-core";
+
+const pkg = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
+const bundle = await build({
+ stdin: {
+ resolveDir: pkg,
+ loader: "tsx",
+ contents: `
+ import { render } from "preact";
+ import { CreateProductScreen } from "./src/screens/CreateProductScreen";
+ import { BusinessSettingsScreen } from "./src/screens/BusinessSettingsScreen";
+ import { InventoryScreen } from "./src/screens/InventoryScreen";
+ import { OrderRefundScreen } from "./src/screens/OrderRefundScreen";
+ import { DevicesScreen } from "./src/screens/DevicesScreen";
+ import { TemplateDetailsScreen } from "./src/screens/TemplateDetailsScreen";
+ import { AccessScreen } from "./src/screens/AccessScreen";
+ import { SelfProvisionScreen } from "./src/screens/SelfProvisionScreen";
+ import { PosScreen } from "./src/screens/PosScreen";
+ const fail = async () => { throw new Error("Save rejected. " + "Long diagnostic detail. ".repeat(120)); };
+ const product = { id: "test", name: "Test product", price: "EUR:2.50", stock: "", stockTracked: false, category: "", categories: [], unit: "Piece", totalStock: -1 };
+ const screen = new URLSearchParams(location.search).get("screen");
+ render(<main class={screen === "pos-cancel" ? "h-dvh" : "p-4 max-w-4xl"}>{
+ screen === "refund" ? <OrderRefundScreen preventNavigation onConfirmRefund={fail} /> :
+ screen === "rotation" ? <DevicesScreen authenticators={[{id: "test", name: "Test device", algorithm: 1}]} onRotateKey={fail} /> :
+ screen === "template" ? <TemplateDetailsScreen templateId="test" initialTemplate={{id: "test", name: "Test", amount: "EUR:5", summary: "Coffee", type: "fixed"}} onCreateOrder={fail} /> :
+ screen === "pairing" ? <AccessScreen tokens={[]} onCreatePairing={fail} /> :
+ screen === "signup" ? <SelfProvisionScreen onProvision={async () => ({type: "fail", detail: "Registration rejected. " + "Long diagnostic detail. ".repeat(120)})} /> :
+ screen === "pos-cancel" ? <PosScreen initialSubRoute="pay" activeOrderId="test" primaryCurrency="EUR" fetchedOrder={{orderId: "test", summary: "Coffee", amount: "EUR:5", status: "unpaid"}} onCancelOrder={fail} /> :
+ screen === "settings" ? <BusinessSettingsScreen settings={{name: "Test merchant"}} onSave={fail} /> :
+ screen === "dialog" ? <InventoryScreen initialTab="categories" categories={[]} onAddCategory={fail} /> :
+ <CreateProductScreen editId="test" initialProduct={product} currency="EUR" categories={[]} onUpdateProduct={fail} />
+ }</main>, document.body);
+ `,
+ },
+ bundle: true,
+ write: false,
+ format: "esm",
+ jsx: "automatic",
+ jsxImportSource: "preact",
+});
+const css = fs.readFileSync(path.join(pkg, "dist/prod/style.css"));
+const server = http.createServer((req, res) => {
+ const script = req.url === "/test.js";
+ const style = req.url === "/style.css";
+ res.setHeader(
+ "Content-Type",
+ script ? "text/javascript" : style ? "text/css" : "text/html",
+ );
+ res.end(
+ script
+ ? bundle.outputFiles[0].contents
+ : style
+ ? css
+ : '<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="/style.css"><script type="module" src="/test.js"></script>',
+ );
+});
+await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+const browser = await puppeteer.launch({
+ executablePath: process.env.CHROME_BIN || "/usr/bin/google-chrome",
+ headless: true,
+ args: ["--no-sandbox"],
+});
+const origin = `http://127.0.0.1:${server.address().port}`;
+try {
+ for (const width of [1440, 390]) {
+ for (const screen of [
+ "product",
+ "settings",
+ "dialog",
+ "refund",
+ "rotation",
+ "template",
+ "pairing",
+ "signup",
+ "pos-cancel",
+ ]) {
+ const page = await browser.newPage();
+ const errors = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ await page.setViewport({ width, height: 844 });
+ await page.goto(`${origin}/?screen=${screen}`);
+ await page.waitForSelector("main");
+ const clickText = async (text) => {
+ await page.waitForFunction(
+ (text) =>
+ [...document.querySelectorAll("button")].some(
+ (b) =>
+ (b.textContent.includes(text) ||
+ b.getAttribute("aria-label") === text) &&
+ b.getClientRects().length,
+ ),
+ {},
+ text,
+ );
+ await page.evaluate(
+ (text) =>
+ [...document.querySelectorAll("button")]
+ .find(
+ (b) =>
+ (b.textContent.includes(text) ||
+ b.getAttribute("aria-label") === text) &&
+ b.getClientRects().length,
+ )
+ .click(),
+ text,
+ );
+ };
+ if (screen === "settings") await clickText("Identity and logo");
+ if (screen === "dialog") await clickText("Add a category");
+ if (screen === "rotation") {
+ await clickText("Actions for Test device");
+ await clickText("Replace secret key");
+ }
+ if (screen === "pairing") {
+ await clickText("Pair a till");
+ await page.waitForSelector("#pair_dev_name");
+ await page.type("#pair_dev_name", "Checkout");
+ await page.type("#pair_password", "password");
+ await clickText("Show advanced settings");
+ }
+ if (screen === "dialog" || screen === "rotation") {
+ await page.waitForFunction(() =>
+ document.activeElement?.closest('[role="dialog"]'),
+ );
+ if (screen === "dialog")
+ await page.type("#cat_name_input", "New category");
+ }
+ if (screen === "signup") {
+ await page.type("#signup-business", "My shop");
+ await page.type("#signup-password", "test-password");
+ await page.type("#signup-confirm-password", "test-password");
+ await page.click('input[type="checkbox"]');
+ }
+ const form = ["dialog", "rotation", "pairing"].includes(screen)
+ ? '[role="dialog"]'
+ : screen === "template"
+ ? "main"
+ : screen === "pos-cancel"
+ ? "[data-pos-main]"
+ : "form";
+ const performAction = () =>
+ screen === "rotation"
+ ? clickText("Generate New Key & Rotate")
+ : screen === "template"
+ ? clickText("Create order from this template")
+ : screen === "pos-cancel"
+ ? clickText("Cancel Sale")
+ : page.$eval(`${form} button[type="submit"]`, (el) => el.click());
+ await page.$eval(form, (el) => el.scrollIntoView({ block: "end" }));
+ await performAction();
+ const errorSelector =
+ screen === "signup" ? '[role="alert"]' : "[data-dismiss-error]";
+ await page.waitForSelector(`${form} ${errorSelector}`);
+ for (const scroll of ["bottom", "middle"]) {
+ await page.$eval(
+ form,
+ (el, scroll) => {
+ const r = el.getBoundingClientRect();
+ if (el.hasAttribute("data-pos-main")) {
+ const pane = el.firstElementChild;
+ pane.scrollTop =
+ scroll === "middle" ? pane.scrollHeight / 3 : pane.scrollHeight;
+ return;
+ }
+ if (scroll === "middle")
+ window.scrollTo(
+ 0,
+ window.scrollY + r.top + r.height / 2 - innerHeight / 2,
+ );
+ else el.scrollIntoView({ block: "end" });
+ },
+ scroll,
+ );
+ const bounds = await page.$eval(
+ `${form} [data-form-action-footer]`,
+ (el) => {
+ const r = el.getBoundingClientRect();
+ const buttons = el.querySelectorAll("button");
+ const button = buttons[buttons.length - 1].getBoundingClientRect();
+ const dismiss = (
+ el.querySelector("[data-dismiss-error]") ??
+ el.querySelector('[role="alert"]')
+ ).getBoundingClientRect();
+ return {
+ dismissTop: dismiss.top,
+ dismissBottom: dismiss.bottom,
+ top: r.top,
+ bottom: r.bottom,
+ right: r.right,
+ height: innerHeight,
+ width: innerWidth,
+ buttonTop: button.top,
+ buttonBottom: button.bottom,
+ horizontalOverflow:
+ document.documentElement.scrollWidth > innerWidth,
+ };
+ },
+ );
+ assert.ok(
+ bounds.top >= 0 && bounds.bottom <= bounds.height + 1,
+ JSON.stringify({ screen, width, bounds }),
+ );
+ assert.ok(
+ bounds.dismissTop >= bounds.top &&
+ bounds.dismissBottom <= bounds.bottom,
+ "Dismiss remains visible with long error text",
+ );
+ assert.ok(
+ bounds.buttonTop >= bounds.top &&
+ bounds.buttonBottom <= bounds.bottom,
+ JSON.stringify(bounds),
+ );
+ assert.ok(
+ !bounds.horizontalOverflow && bounds.right <= bounds.width + 1,
+ JSON.stringify(bounds),
+ );
+ }
+ if (process.env.SAVE_FEEDBACK_SCREENSHOTS) {
+ await page.screenshot({
+ path: path.join(
+ process.env.SAVE_FEEDBACK_SCREENSHOTS,
+ `save-${screen}-${width}.png`,
+ ),
+ });
+ }
+ if (screen !== "signup") {
+ await page.$eval(`${form} [data-dismiss-error]`, (el) => el.click());
+ await page.waitForFunction(
+ () => document.activeElement?.textContent === "Show details",
+ );
+ }
+ await performAction();
+ await page.waitForSelector(`${form} ${errorSelector}`);
+ assert.deepEqual(errors, []);
+ console.log(
+ `${screen} ${width}px: persistent feedback, repeated failure and layout passed`,
+ );
+ await page.close();
+ }
+ }
+} finally {
+ await browser.close();
+ await new Promise((resolve) => server.close(resolve));
+}
diff --git a/packages/taler-merchant-webui/package.json b/packages/taler-merchant-webui/package.json
@@ -12,6 +12,7 @@
"compile": "tsc && ./build.mjs",
"dev": "./dev.mjs",
"test": "./test.mjs",
+ "test:save-feedback-browser": "node contrib/qa/save-feedback-check.mjs",
"test:exchange-status-browser": "node contrib/qa/exchange-status-check.mjs",
"test:stagefright": "node ../taler-harness/bin/taler-harness.mjs stagefright merchant",
"lint": "../qa-tooling/bin/eslint.mjs .",
@@ -46,7 +47,14 @@
},
"pogen": {
"domain": "taler-merchant-webui",
- "catalogDependencies": ["@gnu-taler/web-util/i18n"],
- "requiredLanguages": ["de", "de-CH", "fr", "it"]
+ "catalogDependencies": [
+ "@gnu-taler/web-util/i18n"
+ ],
+ "requiredLanguages": [
+ "de",
+ "de-CH",
+ "fr",
+ "it"
+ ]
}
}