taler-typescript-core

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

commit ad824ad9162076c6b1894bee4ecba05debd78d6b
parent 675545ef924a385c4fd829b242d26b8b7877e602
Author: Florian Dold <dold@taler.net>
Date:   Sun, 13 Sep 2026 17:47:35 +0200

taler-harness: cover wallet payment choices and template parameters

Exercise switching between payable choices, directly paying fixed
templates, and validating and replacing editable template defaults.
Check the resulting parameters and selected choice at the merchant.

Use accessible controls and fixture data, scope field assertions to
payment inputs, and wait for state changes without assuming exact fees.

Diffstat:
Mpackages/taler-harness/README.md | 29+++++++++++++++++++++++++++++
Apackages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-payment-inputs.ts | 455+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 8++++++++
3 files changed, 492 insertions(+), 0 deletions(-)

diff --git a/packages/taler-harness/README.md b/packages/taler-harness/README.md @@ -287,6 +287,35 @@ worker, while the crash or timeout already makes the test fail. ## Headless Web Integration test +### Wallet Web UI coverage + +After `make install`, run the installed wallet browser suite with: + +```sh +taler-harness run-integrationtests --suites=wallet-webui '*' +``` + +The suite covers PWA workflows and Chrome/Firefox extension integration, +upgrades, and storage. Dedicated payment-input scenarios verify: + +- `wallet-web-ui-pwa-payment-choices`: switching between payable choices and + confirming the selected choice index at the merchant. +- `wallet-web-ui-pwa-fixed-template`: reviewing and paying merchant-fixed + parameters without an editable form. +- `wallet-web-ui-pwa-editable-template`: replacing suggested amounts and + descriptions, rejecting invalid input, preserving the required currency, + and checking the resulting order and payment at the merchant. + +The withdrawal scenarios also cover choices with required and issued tokens, +and fixed-template payments with a point-of-sale confirmation code. + +The payment-input scenarios use accessible roles and field labels, tolerate +currency suffixes and validation text in labels, and check merchant fixture data +instead of layout or styling. State checks wait for updates, and invalid input +cases each start from a valid form. Adding unrelated controls should not require +changes to these scenarios. Renaming an action or field may require updating the +shared selectors in `test-wallet-web-ui-pwa-payment-inputs.ts`. + ### Browser test dependencies Playwright and Selenium are optional dependencies. They are available after a diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-payment-inputs.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-payment-inputs.ts @@ -0,0 +1,455 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import path from "node:path"; +import type { Page } from "playwright-core"; +import { + Amounts, + Duration, + OrderVersion, + TalerMerchantInstanceHttpClient, + TemplateType, + succeedOrThrow, +} from "@gnu-taler/taler-util"; +import { createSimpleTestkudosEnvironmentV3 } from "../harness/environments.js"; +import { delayMs, GlobalTestState } from "../harness/harness.js"; +import { Stage } from "../stagefright/stage.js"; +import { + acknowledgeBestEffortStorageForTest, + confirmWithdrawalWhenReady, + findWalletWebUiDirectory, + reserveTcpPort, + startCorsBridge, + waitForPwa, +} from "./wallet-web-ui-test-support.js"; + +type PaymentEnvironment = Awaited< + ReturnType<typeof createSimpleTestkudosEnvironmentV3> +>; + +interface PaymentScenario { + stage: Stage; + pwaUrl: string; + merchant: PaymentEnvironment["merchant"]; + merchantAdminAccessToken: PaymentEnvironment["merchantAdminAccessToken"]; + merchantClient: TalerMerchantInstanceHttpClient; +} + +async function openAction(page: Page, pwaUrl: string, uri: string) { + await page.goto(`${pwaUrl}?talerUri=${encodeURIComponent(uri)}#/`); + await page.getByRole("button", { name: "Continue", exact: true }).click(); +} + +/** Poll state rather than assuming that a UI update has already rendered. */ +async function waitForCondition( + description: string, + ready: () => Promise<boolean>, +) { + const deadline = Date.now() + 30_000; + while (!(await ready())) { + if (Date.now() >= deadline) + throw Error(`Timed out waiting for ${description}`); + await delayMs(100); + } +} + +// Match the accessible field name, allowing currency suffixes and validation +// messages. Scope controls to main content so navigation additions do not affect +// these scenarios. Merchant-provided summaries and choice descriptions below +// are fixture data, not wallet wording. +function templateFields(page: Page) { + const main = page.getByRole("main"); + return { + amount: main.getByRole("textbox", { name: /^Amount\b/ }), + summary: main.getByRole("textbox", { name: /^Summary\b/ }), + currency: main.getByRole("combobox", { name: /^Currency\b/ }), + review: main.getByRole("button", { name: "Review payment", exact: true }), + }; +} + +function payButton(page: Page, amount: string) { + return page.getByRole("main").getByRole("button", { + name: `Pay ${amount}`, + exact: true, + }); +} + +/** Each scenario gets its own funded browser wallet and real merchant. */ +async function withFundedWallet( + t: GlobalTestState, + name: string, + scenario: (context: PaymentScenario) => Promise<void>, +) { + const { bankClient, merchant, merchantAdminAccessToken } = + await createSimpleTestkudosEnvironmentV3(t); + const bankUser = await bankClient.createRandomBankUser(); + bankClient.setAuth(bankUser); + const withdrawal = await bankClient.createWithdrawalOperation( + bankUser.username, + "TESTKUDOS:10", + ); + const bankBridge = await startCorsBridge(bankClient.baseUrl); + try { + const port = await reserveTcpPort(); + const pwaUrl = `http://127.0.0.1:${port}/`; + t.spawnService( + process.execPath, + [ + path.join(findWalletWebUiDirectory(), "test-server.mjs"), + `--port=${port}`, + ], + "wallet-webui-payment-inputs", + ); + await waitForPwa(pwaUrl); + const stage = await Stage.create(name, { + browserType: "chromium", + screenshotDir: path.join(t.testDir, "screenshots"), + viewport: { width: 390, height: 844 }, + }); + await stage.run(async () => { + await stage.page.addInitScript(acknowledgeBestEffortStorageForTest); + await stage.step("fund the browser wallet", async (page) => { + const uri = withdrawal.taler_withdraw_uri.replace( + new URL(bankClient.baseUrl).host, + new URL(bankBridge.baseUrl).host, + ); + await openAction(page, pwaUrl, uri); + await page + .getByRole("button", { name: "Withdraw", exact: true }) + .click(); + await page + .getByRole("heading", { name: "Review withdrawal" }) + .waitFor(); + await page + .getByRole("button", { name: "Withdraw", exact: true }) + .click(); + await confirmWithdrawalWhenReady(() => + bankClient.confirmWithdrawalOperation(bankUser.username, { + withdrawalOperationId: withdrawal.withdrawal_id, + }), + ); + await page.goto(pwaUrl); + const balance = page.getByRole("heading", { + name: /\bTESTKUDOS:\d/, + }); + await balance.waitFor(); + // Only require enough funds for the largest choice. Withdrawal fees + // and the balance's surrounding wording are not under test here. + await waitForCondition( + "at least TESTKUDOS:3 in the wallet", + async () => { + const label = await balance.getAttribute("aria-label"); + const amount = label?.match(/\bTESTKUDOS:\d+(?:\.\d+)?\b/)?.[0]; + return ( + amount !== undefined && Amounts.cmp(amount, "TESTKUDOS:3") >= 0 + ); + }, + ); + }); + await scenario({ + stage, + pwaUrl, + merchant, + merchantAdminAccessToken, + merchantClient: new TalerMerchantInstanceHttpClient( + merchant.makeInstanceBaseUrl(), + ), + }); + }); + } finally { + await bankBridge.close(); + } +} + +async function assertTemplateOrder( + t: GlobalTestState, + context: PaymentScenario, + summary: string, + amount: string, + status: "claimed" | "paid", +) { + const { merchantClient, merchantAdminAccessToken } = context; + const history = succeedOrThrow( + await merchantClient.listOrders(merchantAdminAccessToken), + ); + t.assertDeepEqual(history.orders.length, 1); + t.assertDeepEqual(history.orders[0].summary, summary); + t.assertAmountEquals(history.orders[0].amount, amount); + const order = succeedOrThrow( + await merchantClient.getOrderDetails( + merchantAdminAccessToken, + history.orders[0].order_id, + ), + ); + t.assertDeepEqual(order.order_status, status); +} + +/** Selecting a different payable choice must change what the merchant receives. */ +export async function runWalletWebUiPwaPaymentChoicesTest(t: GlobalTestState) { + await withFundedWallet( + t, + "wallet-web-ui-pwa-payment-choices", + async (context) => { + const { stage, pwaUrl, merchantClient, merchantAdminAccessToken } = + context; + const created = succeedOrThrow( + await merchantClient.createOrder(merchantAdminAccessToken, { + order: { + version: OrderVersion.V1, + summary: "Browser admission choices", + choices: [ + { amount: "TESTKUDOS:1", description: "Standard admission" }, + { amount: "TESTKUDOS:3", description: "Admission with donation" }, + ], + }, + }), + ); + const unpaid = succeedOrThrow( + await merchantClient.getOrderDetails( + merchantAdminAccessToken, + created.order_id, + ), + ); + t.assertTrue(unpaid.order_status === "unpaid"); + await stage.step("switch between payable choices", async (page) => { + await openAction(page, pwaUrl, unpaid.taler_pay_uri); + const main = page.getByRole("main"); + const standard = main.getByRole("radio", { + name: /Standard admission/, + }); + const donation = main.getByRole("radio", { + name: /Admission with donation/, + }); + for (const [choice, amount] of [ + [donation, "3"], + [standard, "1"], + [donation, "3"], + ] as const) { + await choice.check(); + t.assertTrue(await choice.isChecked()); + const pay = payButton(page, `TESTKUDOS:${amount}`); + await pay.waitFor(); + await waitForCondition( + `enabled payment for TESTKUDOS:${amount}`, + () => pay.isEnabled(), + ); + } + const reviewed = succeedOrThrow( + await merchantClient.getOrderDetails( + merchantAdminAccessToken, + created.order_id, + ), + ); + t.assertDeepEqual(reviewed.order_status, "claimed"); + await payButton(page, "TESTKUDOS:3").click(); + await main + .getByRole("heading", { name: "Payment complete", exact: true }) + .waitFor(); + const paid = succeedOrThrow( + await merchantClient.getOrderDetails( + merchantAdminAccessToken, + created.order_id, + ), + ); + t.assertTrue(paid.order_status === "paid"); + t.assertDeepEqual(paid.choice_index, 1); + }); + }, + ); +} + +/** A completely fixed template goes straight to review without editable fields. */ +export async function runWalletWebUiPwaFixedTemplateTest(t: GlobalTestState) { + await withFundedWallet( + t, + "wallet-web-ui-pwa-fixed-template", + async (context) => { + const { + stage, + pwaUrl, + merchant, + merchantClient, + merchantAdminAccessToken, + } = context; + const summary = "Fixed browser purchase"; + succeedOrThrow( + await merchantClient.addTemplate(merchantAdminAccessToken, { + template_id: "fixed-browser-order", + template_description: "All payment parameters fixed by the merchant", + template_contract: { + template_type: TemplateType.FIXED_ORDER, + amount: "TESTKUDOS:1.5", + summary, + minimum_age: 0, + pay_duration: Duration.toTalerProtocolDuration( + Duration.fromSpec({ hours: 1 }), + ), + }, + }), + ); + await stage.step( + "review fixed parameters without an entry form", + async (page) => { + await openAction( + page, + pwaUrl, + `taler+http://pay-template/localhost:${merchant.port}/fixed-browser-order`, + ); + await payButton(page, "TESTKUDOS:1.5").waitFor(); + await page.getByText(summary, { exact: true }).waitFor(); + const fields = templateFields(page); + t.assertDeepEqual(await fields.amount.count(), 0); + t.assertDeepEqual(await fields.summary.count(), 0); + t.assertDeepEqual(await fields.currency.count(), 0); + await assertTemplateOrder( + t, + context, + summary, + "TESTKUDOS:1.5", + "claimed", + ); + await payButton(page, "TESTKUDOS:1.5").click(); + await page + .getByRole("heading", { name: "Payment complete", exact: true }) + .waitFor(); + await assertTemplateOrder( + t, + context, + summary, + "TESTKUDOS:1.5", + "paid", + ); + }, + ); + }, + ); +} + +/** Editable defaults are suggestions, and invalid input must not create an order. */ +export async function runWalletWebUiPwaEditableTemplateTest( + t: GlobalTestState, +) { + await withFundedWallet( + t, + "wallet-web-ui-pwa-editable-template", + async (context) => { + const { + stage, + pwaUrl, + merchant, + merchantClient, + merchantAdminAccessToken, + } = context; + succeedOrThrow( + await merchantClient.addTemplate(merchantAdminAccessToken, { + template_id: "editable-browser-order", + template_description: "The payer supplies the amount and description", + template_contract: { + template_type: TemplateType.FIXED_ORDER, + currency: "TESTKUDOS", + minimum_age: 0, + pay_duration: Duration.toTalerProtocolDuration( + Duration.fromSpec({ hours: 1 }), + ), + }, + editable_defaults: { + amount: "TESTKUDOS:1", + summary: "Suggested purchase", + }, + }), + ); + await stage.step( + "validate and replace the suggested payment parameters", + async (page) => { + await openAction( + page, + pwaUrl, + `taler+http://pay-template/localhost:${merchant.port}/editable-browser-order`, + ); + const { amount, summary, currency, review } = templateFields(page); + await amount.waitFor(); + await waitForCondition( + "review enabled for suggested parameters", + () => review.isEnabled(), + ); + t.assertDeepEqual(await amount.inputValue(), "1"); + t.assertDeepEqual(await summary.inputValue(), "Suggested purchase"); + t.assertDeepEqual(await currency.inputValue(), "TESTKUDOS"); + t.assertTrue(await currency.isDisabled()); + t.assertTrue(await amount.isEnabled()); + t.assertTrue(await summary.isEnabled()); + for (const invalid of ["", "0", "-1", "not an amount"]) { + // Start each case from valid input so a stale disabled button from + // the previous case cannot satisfy the next validation assertion. + await amount.fill("1"); + await waitForCondition("review enabled for a valid amount", () => + review.isEnabled(), + ); + await amount.fill(invalid); + await waitForCondition( + `review disabled for invalid amount: ${JSON.stringify(invalid)}`, + () => review.isDisabled(), + ); + } + await amount.fill("2.75"); + await waitForCondition( + "review enabled for the replacement amount", + () => review.isEnabled(), + ); + await summary.fill(" "); + await waitForCondition("review disabled for an empty summary", () => + review.isDisabled(), + ); + t.assertDeepEqual( + succeedOrThrow( + await merchantClient.listOrders(merchantAdminAccessToken), + ).orders.length, + 0, + ); + await summary.fill(" Browser table 7 "); + await review.click(); + await payButton(page, "TESTKUDOS:2.75").waitFor(); + await page.getByText("Browser table 7", { exact: true }).waitFor(); + await assertTemplateOrder( + t, + context, + "Browser table 7", + "TESTKUDOS:2.75", + "claimed", + ); + await payButton(page, "TESTKUDOS:2.75").click(); + await page + .getByRole("heading", { name: "Payment complete", exact: true }) + .waitFor(); + await assertTemplateOrder( + t, + context, + "Browser table 7", + "TESTKUDOS:2.75", + "paid", + ); + }, + ); + }, + ); +} + +runWalletWebUiPwaPaymentChoicesTest.suites = ["wallet-webui"]; +runWalletWebUiPwaPaymentChoicesTest.timeoutMs = 180_000; +runWalletWebUiPwaFixedTemplateTest.suites = ["wallet-webui"]; +runWalletWebUiPwaFixedTemplateTest.timeoutMs = 180_000; +runWalletWebUiPwaEditableTemplateTest.suites = ["wallet-webui"]; +runWalletWebUiPwaEditableTemplateTest.timeoutMs = 180_000; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -248,6 +248,11 @@ import { runWalletWebUiPwaWithdrawalTest, } from "./test-wallet-web-ui-pwa-withdrawal.js"; import { runWalletWebUiPwaErrorsTest } from "./test-wallet-web-ui-pwa-errors.js"; +import { + runWalletWebUiPwaPaymentChoicesTest, + runWalletWebUiPwaFixedTemplateTest, + runWalletWebUiPwaEditableTemplateTest, +} from "./test-wallet-web-ui-pwa-payment-inputs.js"; import { runWalletWebUiPwaDonauTest } from "./test-wallet-web-ui-pwa-donau.js"; import { runWalletWebUiPwaPeerDepositTest } from "./test-wallet-web-ui-pwa-peer-deposit.js"; import { runWalletWebUiPwaPeerTosTest } from "./test-wallet-web-ui-pwa-peer-tos.js"; @@ -541,6 +546,9 @@ const allTests: TestMainFunction[] = [ runWalletWithdrawalRedenominateTest, runWalletWebUiPwaWithdrawalTest, runWalletWebUiPwaErrorsTest, + runWalletWebUiPwaPaymentChoicesTest, + runWalletWebUiPwaFixedTemplateTest, + runWalletWebUiPwaEditableTemplateTest, runWalletWebUiPwaFirefoxWithdrawalTest, runWalletWebUiPwaDonauTest, runWalletWebUiPwaPeerDepositTest,