taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

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:
Apackages/taler-merchant-webui/contrib/qa/save-feedback-check.mjs | 256+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-merchant-webui/package.json | 12++++++++++--
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" + ] } }