commit 577ebe51348eaae035a5928520eabc59e34a2693
parent 26d9884255678297948a8be2f8f6ba5007f5bb96
Author: Florian Dold <dold@taler.net>
Date: Wed, 26 Aug 2026 00:04:22 +0200
taler-harness: update mytops onboarding for current UI
Diffstat:
1 file changed, 59 insertions(+), 68 deletions(-)
diff --git a/packages/taler-harness/src/stagefright/merchant-mytops.ts b/packages/taler-harness/src/stagefright/merchant-mytops.ts
@@ -101,32 +101,26 @@ function normalizeBaseUrl(url: string): string {
return url.endsWith("/") ? url : `${url}/`;
}
-type ChallengeDialog = "solve" | "choose" | "logged-in";
+type RegistrationState = "email" | "sms" | "choose" | "logged-in";
/**
- * Wait for whatever comes after submitting a form that needs MFA: a dialog
- * asking for a code, a dialog asking which channel to use, or the portal
- * itself once no challenge is left.
- *
- * The channel dialog is only expected right after submitting the form. Once
- * a challenge has been solved it shows up again while the original request is
- * retried, and answering it there would wait for a code that is never sent.
+ * Wait for whatever comes after submitting the registration form: a code
+ * field, a choice of MFA channel, or the portal once onboarding is complete.
*/
-async function waitForChallengeDialog(
+async function waitForRegistrationState(
page: Page,
timeoutMs: number,
- allowChannelChoice: boolean,
-): Promise<ChallengeDialog> {
+): Promise<RegistrationState> {
const deadline = Date.now() + timeoutMs;
let lastComplaint: string | undefined = undefined;
for (;;) {
- if ((await page.locator('input[name="code"]').count()) > 0) {
- return "solve";
+ if ((await page.locator("#signup-email-code").count()) > 0) {
+ return "email";
}
- if (
- allowChannelChoice &&
- (await page.locator('input[name="challenge_id"]').count()) > 0
- ) {
+ if ((await page.locator("#signup-sms-code").count()) > 0) {
+ return "sms";
+ }
+ if ((await page.locator('input[name="signup_2fa_channel"]').count()) > 0) {
return "choose";
}
// The navigation sidebar only exists for a logged-in instance.
@@ -135,7 +129,7 @@ async function waitForChallengeDialog(
}
// Error notifications dismiss themselves after a while, so remember what
// the last one said instead of only reporting a timeout.
- const complaint = page.locator(".message.is-danger");
+ const complaint = page.locator("[data-error-banner]");
if ((await complaint.count()) > 0) {
lastComplaint = (await complaint.first().innerText())
.replace(/\s+/g, " ")
@@ -146,7 +140,7 @@ async function waitForChallengeDialog(
throw Error(`the deployment refused the request: ${lastComplaint}`);
}
throw Error(
- `neither an MFA challenge nor the merchant portal showed up within ${timeoutMs}ms`,
+ `neither a registration challenge nor the merchant portal showed up within ${timeoutMs}ms`,
);
}
await page.waitForTimeout(250);
@@ -154,36 +148,34 @@ async function waitForChallengeDialog(
}
/**
- * Type the code into the dialog and wait for the dialog to disappear. The
- * last digit submits the form, so there is no button to press.
+ * Submit one registration challenge and wait until the UI advances. Waiting
+ * for the input to be cleared as well as removed covers consecutive
+ * challenges that use the same channel and therefore reuse the same element.
*/
-async function enterChallengeCode(
+async function submitChallengeCode(
page: Page,
+ selector: string,
code: string,
timeoutMs: number,
): Promise<void> {
- const digits = page.locator('input[name="code"]');
- const numDigits = await digits.count();
- if (numDigits !== code.length) {
- throw Error(
- `the dialog asks for ${numDigits} digits, but the code has ${code.length}`,
- );
- }
- // Hold on to the first input, so that we can tell this dialog apart from
- // the one of the next challenge, which looks exactly the same.
- const dialog = await digits.first().elementHandle();
- for (let i = 0; i < numDigits; i++) {
- await digits.nth(i).fill(code[i]);
- }
- if (!dialog) {
+ const input = page.locator(selector);
+ const oldInput = await input.elementHandle();
+ await input.fill(code);
+ await page.click('button[type="submit"]');
+ if (!oldInput) {
return;
}
try {
- await page.waitForFunction((el) => !el.isConnected, dialog, {
- timeout: timeoutMs,
- });
+ await page.waitForFunction(
+ (el) =>
+ !el.isConnected ||
+ ((el as { value: string; disabled: boolean }).value === "" &&
+ !(el as { value: string; disabled: boolean }).disabled),
+ oldInput,
+ { timeout: timeoutMs },
+ );
} finally {
- await dialog.dispose();
+ await oldInput.dispose();
}
}
@@ -210,56 +202,51 @@ export async function runStagefrightMerchantMytops(
await stage.run(async () => {
await stage.step("open the merchant portal", async (page) => {
await page.goto(new URL("webui/", baseUrl).href);
- await page.waitForSelector('input[name="username"]');
+ await page.waitForSelector("#signin-account");
});
await stage.step("start the onboarding", async (page) => {
- await page.click('a[href="#/account/new"]');
- await page.waitForSelector('input[name="id"]');
+ await page.click('a[href="#/signup"]');
+ await page.waitForSelector("#signup-business");
});
await stage.step("fill in the account details", async (page) => {
- await page.fill('input[name="id"]', instanceId);
- await page.fill('input[name="name"]', businessName);
- await page.fill('input[name="password"]', password);
- await page.fill('input[name="repeat"]', password);
+ await page.fill("#signup-business", businessName);
+ await page.fill("#signup-username", instanceId);
+ await page.fill("#signup-password", password);
+ await page.fill("#signup-confirm-password", password);
// Which of the two the deployment asks for depends on its configured
// mandatory TAN channels.
- if ((await page.locator('input[name="email"]').count()) > 0) {
- await page.fill('input[name="email"]', email);
+ if ((await page.locator("#signup-email").count()) > 0) {
+ await page.fill("#signup-email", email);
}
- if ((await page.locator('input[name="phone"]').count()) > 0) {
- await page.fill('input[name="phone"]', phone);
+ if ((await page.locator("#signup-phone").count()) > 0) {
+ await page.fill("#signup-phone", phone);
}
- // The toggle for the terms of service is styled away, so it can't be
- // clicked the way a user would.
- await page.locator('input[name="tos"]').dispatchEvent("click");
+ await page.locator('input[type="checkbox"]').check();
});
await stage.step("request the account", async (page) => {
- await page.click("footer.modal-card-foot button.is-success");
+ await page.click('button[type="submit"]');
});
// A deployment can require more than one channel, in which case the next
// challenge is sent as soon as the previous one is solved.
for (let round = 1; ; round++) {
- const dialog = await waitForChallengeDialog(
- stage.page,
- timeoutMs,
- round === 1,
- );
- if (dialog === "logged-in") {
+ const state = await waitForRegistrationState(stage.page, timeoutMs);
+ if (state === "logged-in") {
break;
}
- if (dialog === "choose") {
+ if (state === "choose") {
await stage.step(
`pick the channel for challenge ${round}`,
async (page) => {
- await page.locator('input[name="challenge_id"]').first().check();
- await page.click(
- ".modal.is-active .modal-card-foot button.is-success",
- );
- await page.waitForSelector('input[name="code"]');
+ const channel = page.locator('input[name="signup_2fa_channel"]');
+ if (!(await channel.first().isChecked())) {
+ await channel.first().check();
+ }
+ await page.click('button[type="submit"]');
+ await page.waitForSelector("#signup-email-code, #signup-sms-code");
},
);
}
@@ -276,7 +263,11 @@ export async function runStagefrightMerchantMytops(
await stage.step(
`enter the code for challenge ${round}`,
async (page) => {
- await enterChallengeCode(page, code, timeoutMs);
+ const selector =
+ (await page.locator("#signup-email-code").count()) > 0
+ ? "#signup-email-code"
+ : "#signup-sms-code";
+ await submitChallengeCode(page, selector, code, timeoutMs);
},
);
}