diff --git a/packages/web-wallet/docs/qa-automation-strategy.md b/packages/web-wallet/docs/qa-automation-strategy.md index de8b4873..29a5585e 100644 --- a/packages/web-wallet/docs/qa-automation-strategy.md +++ b/packages/web-wallet/docs/qa-automation-strategy.md @@ -128,6 +128,11 @@ yarn e2e # runs Playwright; auto-starts the snap (:8080) and web-wall yarn e2e:headed # forces a visible browser (useful for debugging) ``` +> `yarn e2e` first builds `@hathor/hathor-rpc-handler` (its `dist/` is consumed by the snap's +> dev build). This runs automatically now, so a clean checkout works from these steps alone — +> and a change under `packages/hathor-rpc-handler/src/**` is always picked up (no stale-handler +> trap). + Verified against **Flask 13.31.0**. The onboarding `data-testid`s are version-sensitive; if a Flask bump breaks onboarding, re-capture them (an earlier throwaway probe spec in git history walks and dumps each screen). @@ -151,10 +156,17 @@ swapping in your own): 2. Run the bootstrap to print the Snap-derived **testnet** address. 3. Fund that address via the Hathor testnet faucet / faucet service. +> The committed `funded` seed is a **disposable testnet stub** — its funds have no real value +> and are topped up by the testnet faucet. If it runs dry, the funded journeys (`import`, +> `feature-example`, `token-lifecycle`) simply fail at their first funded step; re-fund the +> Snap-derived **testnet** address via the steps above. Onboarding needs no funds. + ## Honest limitations - **Selector drift:** MetaMask's DOM changes between versions. Pin one Flask version (`E2E_FLASK_VERSION` / `E2E_METAMASK_PATH`); when it changes, update `driver/selectors.ts`. + The resolver enforces the pin (`EXPECTED_FLASK_VERSION`) and fails loudly on a mismatch — see + the upgrade checklist below. - **On-chain timing:** testnet confirmation is non-deterministic. Happy paths assert up to "tx submitted + success UI"; post-confirmation balance updates are a soft, generously-timed check — not a hard gate. @@ -163,6 +175,18 @@ swapping in your own): - **Permanent test data:** created tokens / sent txs persist on testnet and can't be cleaned up. Token names are unique per run; runs are on-demand to limit fund usage. +### Upgrading the pinned MetaMask Flask version + +Selectors are pinned to one Flask build (`EXPECTED_FLASK_VERSION` in `driver/selectors.ts`); the +resolver fails loudly on a mismatch (`E2E_ALLOW_FLASK_MISMATCH=1` overrides). To move the pin: + +1. Bump `E2E_FLASK_VERSION` (in `.env.e2e`) to the new release and clear `tests/e2e/.cache/`. +2. Run with `E2E_ALLOW_FLASK_MISMATCH=1 yarn e2e:headed --project=onboarding` and walk the + onboarding + import screens, confirming each `data-testid` in `driver/selectors.ts` + (`MetaMask.onboarding.*`, `MetaMask.importing.*`, `Srp.*`, `Unlock.*`) still resolves. +3. Update any changed selectors, then set `EXPECTED_FLASK_VERSION` to the new version. +4. Update the pinned download checksum (see the Flask-integrity note, item 6 / PR 4). + ## Traceability | QA case | Spec | diff --git a/packages/web-wallet/package.json b/packages/web-wallet/package.json index 818ebb73..26061a9d 100644 --- a/packages/web-wallet/package.json +++ b/packages/web-wallet/package.json @@ -12,8 +12,9 @@ "test": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest --coverage", - "e2e": "playwright test --config playwright.e2e.config.ts", - "e2e:headed": "E2E_HEADED=1 playwright test --config playwright.e2e.config.ts" + "e2e:deps": "yarn workspace @hathor/hathor-rpc-handler build", + "e2e": "yarn e2e:deps && playwright test --config playwright.e2e.config.ts", + "e2e:headed": "yarn e2e:deps && E2E_HEADED=1 playwright test --config playwright.e2e.config.ts" }, "dependencies": { "@hathor/hathor-rpc-handler": "workspace:", diff --git a/packages/web-wallet/src/constants/__tests__/timeouts.test.ts b/packages/web-wallet/src/constants/__tests__/timeouts.test.ts new file mode 100644 index 00000000..fddcab48 --- /dev/null +++ b/packages/web-wallet/src/constants/__tests__/timeouts.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +/** + * `SNAP_TIMEOUT_MS` is a production-facing build-time knob (webpack DefinePlugin). It defaults to + * empty, which MUST resolve to the shipped 10s. These tests pin that default so a future refactor + * cannot silently change production behavior. The module reads the env at import time, so each + * case re-imports against a fresh module registry. + */ +async function loadSnapTimeouts() { + vi.resetModules(); + return (await import('../timeouts')).SNAP_TIMEOUTS; +} + +describe('SNAP_TIMEOUTS default (SNAP_TIMEOUT_MS)', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('resolves every timeout to the shipped 10s when SNAP_TIMEOUT_MS is empty', async () => { + vi.stubEnv('SNAP_TIMEOUT_MS', ''); + const snapTimeouts = await loadSnapTimeouts(); + expect(Object.values(snapTimeouts).length).toBeGreaterThan(0); + for (const value of Object.values(snapTimeouts)) { + expect(value).toBe(10000); + } + }); + + it('applies a valid positive override to every timeout', async () => { + vi.stubEnv('SNAP_TIMEOUT_MS', '60000'); + const snapTimeouts = await loadSnapTimeouts(); + for (const value of Object.values(snapTimeouts)) { + expect(value).toBe(60000); + } + }); + + it.each(['0', '-5', 'abc'])('falls back to 10s for invalid SNAP_TIMEOUT_MS=%s', async (raw) => { + vi.stubEnv('SNAP_TIMEOUT_MS', raw); + const snapTimeouts = await loadSnapTimeouts(); + for (const value of Object.values(snapTimeouts)) { + expect(value).toBe(10000); + } + }); +}); diff --git a/packages/web-wallet/tests/e2e/driver/MetaMaskDriver.ts b/packages/web-wallet/tests/e2e/driver/MetaMaskDriver.ts index 95318eb1..6b886659 100644 --- a/packages/web-wallet/tests/e2e/driver/MetaMaskDriver.ts +++ b/packages/web-wallet/tests/e2e/driver/MetaMaskDriver.ts @@ -1,10 +1,13 @@ import type { BrowserContext, Locator, Page } from '@playwright/test'; import { MetaMask, MetaMaskText, Srp, Unlock } from './selectors'; import { SPLIT_WINDOWS } from './windows'; -import { TIMEOUTS } from './timeouts'; +import { TIMEOUTS } from '../timeouts'; const DEFAULT_PASSWORD = 'Test1234!Test'; +/** This Flask build's SRP length. Kept explicit so the reveal read is bounded and checkable. */ +const SEED_WORD_COUNT = 12; + /** * Drives the real MetaMask Flask extension: onboarding a fresh wallet, and approving the * Snap connect/install/dialog flows. @@ -158,20 +161,38 @@ export class MetaMaskDriver { private async revealAndSaveSeed(): Promise { const page = await this.openHome(); await page.getByTestId(MetaMask.onboarding.recoveryReveal).click(); + + // Chips populate asynchronously; on a slow render a one-shot read can catch an empty chip + // and turn a transient into a hard failure. Poll the read (bounded by TIMEOUTS.seedReveal) + // until every word is present, THEN keep the fail-fast throw for a genuinely missing word. + const deadline = Date.now() + TIMEOUTS.seedReveal; + let words: string[] = []; + for (;;) { + words = await this.readSeedWords(page); + if (words.length === SEED_WORD_COUNT && words.every((word) => word)) break; + if (Date.now() >= deadline) { + await this.dumpScreen('recovery-phrase-missing-word'); + throw new Error( + `Failed to read all ${SEED_WORD_COUNT} MetaMask recovery words within ` + + `${TIMEOUTS.seedReveal}ms (read: ${JSON.stringify(words)}).`, + ); + } + await page.waitForTimeout(300); + } + + this.seedPhrase = words.join(' '); + await page.getByTestId(MetaMask.onboarding.recoveryContinue).click(); await page.waitForTimeout(1200); + return words; + } + + /** Reads the SRP chips once; empty/missing chips come back as empty strings (letters only). */ + private async readSeedWords(page: Page): Promise { const words: string[] = []; - for (let i = 0; i < 12; i++) { + for (let i = 0; i < SEED_WORD_COUNT; i++) { const raw = (await page.getByTestId(Srp.chip(i)).textContent().catch(() => '')) || ''; words.push(raw.replace(/[^a-z]/gi, '')); } - // An empty word breaks the quiz later as an opaque timeout — fail at the source. - if (words.some((word) => !word)) { - await this.dumpScreen('recovery-phrase-missing-word'); - throw new Error('Failed to read all MetaMask recovery words.'); - } - this.seedPhrase = words.join(' '); - await page.getByTestId(MetaMask.onboarding.recoveryContinue).click(); - await page.waitForTimeout(1200); return words; } diff --git a/packages/web-wallet/tests/e2e/driver/flask.ts b/packages/web-wallet/tests/e2e/driver/flask.ts index cc43e460..2f8443a3 100644 --- a/packages/web-wallet/tests/e2e/driver/flask.ts +++ b/packages/web-wallet/tests/e2e/driver/flask.ts @@ -1,11 +1,45 @@ import { execFileSync } from 'node:child_process'; -import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'; +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { EXPECTED_FLASK_VERSION } from './selectors'; const here = dirname(fileURLToPath(import.meta.url)); const CACHE_DIR = resolve(here, '..', '.cache'); +/** + * Guards against silent MetaMask drift: selectors are pinned to EXPECTED_FLASK_VERSION, so a + * different build (e.g. a silent auto-update, or a stale `.cache`) rots selectors into baffling + * mid-journey failures. Fail loudly and actionably at resolve time instead. + * `E2E_ALLOW_FLASK_MISMATCH=1` downgrades to a warning for someone intentionally validating a new + * version. + */ +function assertFlaskVersion(manifestDir: string): void { + let actual = ''; + try { + const manifest = JSON.parse(readFileSync(join(manifestDir, 'manifest.json'), 'utf8')) as { + version?: string; + }; + actual = manifest.version ?? ''; + } catch { + return; // an unreadable/absent manifest is already handled by findManifestDir; don't double-fail + } + // The manifest reports a 4-part build (e.g. "13.31.0.150"); the pin is the 3-part prefix. + if (actual === EXPECTED_FLASK_VERSION || actual.startsWith(`${EXPECTED_FLASK_VERSION}.`)) return; + + const message = + `MetaMask Flask version mismatch: driver/selectors.ts is pinned to ` + + `${EXPECTED_FLASK_VERSION}, but the loaded build is "${actual || 'unknown'}". ` + + `Selectors are version-sensitive — re-verify them (see "Upgrading the pinned MetaMask ` + + `version" in docs/qa-automation-strategy.md), then bump EXPECTED_FLASK_VERSION. ` + + `Set E2E_ALLOW_FLASK_MISMATCH=1 to proceed anyway.`; + if (process.env.E2E_ALLOW_FLASK_MISMATCH === '1') { + console.warn(`[flask] WARNING: ${message}`); + return; + } + throw new Error(message); +} + /** * Resolves a path to an *unpacked* MetaMask Flask build to load as a Chromium extension. * @@ -30,6 +64,7 @@ export async function resolveFlaskPath(): Promise { `Point it at an unpacked MetaMask Flask build directory.`, ); } + assertFlaskVersion(found); return found; } @@ -47,7 +82,10 @@ export async function resolveFlaskPath(): Promise { const dest = join(CACHE_DIR, `flask-${version}`); const cached = existsSync(dest) ? findManifestDir(dest) : null; - if (cached) return cached; + if (cached) { + assertFlaskVersion(cached); + return cached; + } mkdirSync(dest, { recursive: true }); const assetUrl = await discoverFlaskAssetUrl(version); @@ -69,6 +107,7 @@ export async function resolveFlaskPath(): Promise { if (!unpacked) { throw new Error(`Downloaded Flask ${version} but no manifest.json found under ${dest}.`); } + assertFlaskVersion(unpacked); return unpacked; } diff --git a/packages/web-wallet/tests/e2e/driver/selectors.ts b/packages/web-wallet/tests/e2e/driver/selectors.ts index 0e9163de..e7175522 100644 --- a/packages/web-wallet/tests/e2e/driver/selectors.ts +++ b/packages/web-wallet/tests/e2e/driver/selectors.ts @@ -7,6 +7,10 @@ * The Snap connect/install/dialog flows happen on `notification.html` and are driven by * visible role/text (see MetaMaskText) because those screens vary most between versions. */ + +/** The single MetaMask Flask version these selectors are verified against (item 8 guard). */ +export const EXPECTED_FLASK_VERSION = '13.31.0'; + export const MetaMask = { onboarding: { /** "Create a new wallet" on the welcome screen. */ diff --git a/packages/web-wallet/tests/e2e/e2e.md b/packages/web-wallet/tests/e2e/e2e.md index 3c87e5bb..a205feef 100644 --- a/packages/web-wallet/tests/e2e/e2e.md +++ b/packages/web-wallet/tests/e2e/e2e.md @@ -2,6 +2,12 @@ **Read this before reading or writing any E2E test in `tests/e2e/`.** +> **This suite is on-demand and NON-GATING.** It is not run per-PR and does not block merges. +> A red run here means "a real-MetaMask journey didn't pass on this machine right now" — often +> a slow cold-start, not a broken product. Run it deliberately with `yarn e2e` (see +> `docs/qa-automation-strategy.md`); investigate failures, but never treat this suite as a +> required check. + These tests drive the **real MetaMask Flask extension** + the **Hathor Snap** against the web-wallet's actual UI. The rules below are the architectural decisions the suite is built on. They exist to prevent recurring confusion about *where each piece of logic belongs* — @@ -232,6 +238,16 @@ These bit us once each — check for them when a `getByRole`/`getByText` fails: `flex justify-between` row the label text and the toggle button are siblings, so from the label go up two levels (`xpath=ancestor::div[2]`) before selecting the button. +## Two timeout knobs — which to reach for + +- **`E2E_TIMEOUT_SCALE`** (test side) multiplies every deadline in `tests/e2e/timeouts.ts` — + the MetaMask driver's waits *and* the page object's `TIMEOUTS.dapp` waits. Reach for it to + stretch the whole suite for a slow machine/CI (e.g. `E2E_TIMEOUT_SCALE=2`). +- **`SNAP_TIMEOUT_MS`** (dApp side) raises the web-wallet's own hard snap-RPC cap (default 10s; + see `src/constants/timeouts.ts`), injected at build time via webpack `DefinePlugin`. Reach + for it when a slow snap **cold-start** trips the dApp's internal RPC race (the e2e webServer + already sets `SNAP_TIMEOUT_MS=60000`). It does NOT affect Playwright waits. + ## File map ```text diff --git a/packages/web-wallet/tests/e2e/helpers/webWallet.ts b/packages/web-wallet/tests/e2e/helpers/webWallet.ts index 53da7600..f376e9db 100644 --- a/packages/web-wallet/tests/e2e/helpers/webWallet.ts +++ b/packages/web-wallet/tests/e2e/helpers/webWallet.ts @@ -1,4 +1,5 @@ import { expect, type Page } from '@playwright/test'; +import { TIMEOUTS } from '../timeouts'; type NetworkName = 'Mainnet' | 'Testnet'; @@ -116,7 +117,9 @@ export class WebWallet { /** The connection succeeded once the home screen ("Assets summary") is visible. */ async expectConnected(): Promise { await this.focus(); - await expect(this.page.getByText(/assets summary/i)).toBeVisible({ timeout: 60_000 }); + await expect(this.page.getByText(/assets summary/i)).toBeVisible({ + timeout: TIMEOUTS.dapp.connectedHome, + }); } /** Header network button shows the current Hathor network (Mainnet/Testnet). */ @@ -128,7 +131,7 @@ export class WebWallet { await this.focus(); await expect( this.page.getByRole('button', { name: new RegExp(name, 'i') }).first(), - ).toBeVisible({ timeout: 60_000 }); + ).toBeVisible({ timeout: TIMEOUTS.dapp.network }); } /** Opens the "Change network" dialog from the header. */ @@ -160,7 +163,9 @@ export class WebWallet { // The address loads asynchronously (snap htr_getAddress), so the

first renders the // "No address available" fallback — wait until it's replaced by the real address. const addrLocator = this.page.locator('p.font-mono.break-all').first(); - await expect(addrLocator).not.toHaveText(/no address available/i, { timeout: 30_000 }); + await expect(addrLocator).not.toHaveText(/no address available/i, { + timeout: TIMEOUTS.dapp.asyncValue, + }); const address = (await addrLocator.textContent())?.trim() ?? ''; if (!address || /no address/i.test(address)) { throw new Error(`Receive address unavailable: "${address}"`); @@ -199,12 +204,16 @@ export class WebWallet { /** Success = the Send dialog closes and no error box is shown. */ async expectSendSuccess(): Promise { - await expect(this.page.getByRole('heading', { name: /send tokens/i })).toBeHidden({ timeout: 60_000 }); + await expect(this.page.getByRole('heading', { name: /send tokens/i })).toBeHidden({ + timeout: TIMEOUTS.dapp.dialogSettle, + }); } /** Asserts the Send dialog's transaction-error box matches `pattern` (for reject/edge cases). */ async expectSendError(pattern: RegExp): Promise { - await expect(this.page.getByText(pattern).first()).toBeVisible({ timeout: 60_000 }); + await expect(this.page.getByText(pattern).first()).toBeVisible({ + timeout: TIMEOUTS.dapp.dialogSettle, + }); } // --- Create token -------------------------------------------------------- @@ -249,7 +258,9 @@ export class WebWallet { /** Waits for "Token Created" and returns the displayed config string. */ async expectTokenCreated(): Promise { - await expect(this.page.getByRole('heading', { name: /token created/i })).toBeVisible({ timeout: 90_000 }); + await expect(this.page.getByRole('heading', { name: /token created/i })).toBeVisible({ + timeout: TIMEOUTS.dapp.tokenCreated, + }); const config = (await this.page.locator('span.font-mono.break-all').first().textContent())?.trim() ?? ''; if (!/^\[.+:.+:.+:.+\]$/.test(config)) { throw new Error(`Unexpected config string: "${config}"`); @@ -307,7 +318,9 @@ export class WebWallet { .locator('xpath=ancestor::div[2]').getByRole('button').click(); await this.page.getByRole('button', { name: /^unregister token$/i }).last().click(); // The success toast renders the text twice (visible div + aria-live status span). - await expect(this.page.getByText(/unregistered successfully/i).first()).toBeVisible({ timeout: 30_000 }); + await expect(this.page.getByText(/unregistered successfully/i).first()).toBeVisible({ + timeout: TIMEOUTS.dapp.toast, + }); } // --- Register (local, no Snap) ------------------------------------------ @@ -321,7 +334,9 @@ export class WebWallet { await this.page.getByRole('textbox').first().fill(configString); await this.page.getByRole('button', { name: /^register token$/i }).click(); // Specific text (not just /registered/) so a lingering "unregistered" toast can't match. - await expect(this.page.getByText(/token registered successfully/i).first()).toBeVisible({ timeout: 30_000 }); + await expect(this.page.getByText(/token registered successfully/i).first()).toBeVisible({ + timeout: TIMEOUTS.dapp.toast, + }); } // --- Import via "New tokens" banner (local, no Snap) -------------------- @@ -338,7 +353,7 @@ export class WebWallet { await this.focus(); await expect( this.page.getByRole('button', { name: 'Import tokens.', exact: true }), - ).toBeVisible({ timeout: 30_000 }); + ).toBeVisible({ timeout: TIMEOUTS.dapp.banner }); } /** @@ -360,7 +375,7 @@ export class WebWallet { // Select the target token's row by data-token-uid (present immediately, even // before the row's name/balance lazy-loads); the row's checkbox lives inside. const row = dialog.locator(`[data-token-uid="${uid}"]`); - await expect(row).toBeVisible({ timeout: 30_000 }); + await expect(row).toBeVisible({ timeout: TIMEOUTS.dapp.banner }); await row.getByRole('checkbox').check(); // Select → Confirm. "Continue" enables once a token is picked; the dialog @@ -370,7 +385,9 @@ export class WebWallet { // Confirm → success. "Import tokens" (no period) is the dialog's confirm button. await dialog.getByRole('button', { name: 'Import tokens', exact: true }).click(); - await expect(dialog.getByText(/tokens imported!/i)).toBeVisible({ timeout: 30_000 }); + await expect(dialog.getByText(/tokens imported!/i)).toBeVisible({ + timeout: TIMEOUTS.dapp.toast, + }); // Close the success screen, back to the connected home. await dialog.getByRole('button', { name: /^close$/i }).click(); @@ -381,7 +398,9 @@ export class WebWallet { /** Waits for the flag-gated "Token Type" selector to render after the toggle re-fetch. */ async waitForTokenTypeSelector(): Promise { - await expect(this.page.getByText(/^token type$/i)).toBeVisible({ timeout: 30_000 }); + await expect(this.page.getByText(/^token type$/i)).toBeVisible({ + timeout: TIMEOUTS.dapp.flagGatedUi, + }); } /** Selects the token type in the create-token dialog ("Deposit" or "Fee"). */ @@ -473,14 +492,18 @@ export class WebWallet { /** Waits out the loading check, then asserts the given mode's radio is selected. */ async expectAddressModeSelected(mode: 'single' | 'dynamic'): Promise { - await expect(this.page.getByText(/checking address usage/i)).toBeHidden({ timeout: 30_000 }); + await expect(this.page.getByText(/checking address usage/i)).toBeHidden({ + timeout: TIMEOUTS.dapp.addressUsage, + }); const name = mode === 'single' ? /single address/i : /dynamic address/i; await expect(this.page.getByRole('radio', { name })).toBeChecked(); } /** Picks an address mode by clicking its (sr-only radio) label. Waits loading out first. */ async chooseAddressMode(mode: 'single' | 'dynamic'): Promise { - await expect(this.page.getByText(/checking address usage/i)).toBeHidden({ timeout: 30_000 }); + await expect(this.page.getByText(/checking address usage/i)).toBeHidden({ + timeout: TIMEOUTS.dapp.addressUsage, + }); const label = mode === 'single' ? 'Single Address' : 'Dynamic Address'; await this.page.getByText(label, { exact: true }).click(); } diff --git a/packages/web-wallet/tests/e2e/driver/timeouts.ts b/packages/web-wallet/tests/e2e/timeouts.ts similarity index 56% rename from packages/web-wallet/tests/e2e/driver/timeouts.ts rename to packages/web-wallet/tests/e2e/timeouts.ts index aa7f813c..8c9e08d1 100644 --- a/packages/web-wallet/tests/e2e/driver/timeouts.ts +++ b/packages/web-wallet/tests/e2e/timeouts.ts @@ -1,10 +1,13 @@ /** - * Centralized, env-tunable deadlines for the MetaMask driver. + * Centralized, env-tunable deadlines for the whole E2E suite (driver + page object). * - * The driver's timeouts were scattered as magic numbers, so the suite couldn't be tuned for a - * slow machine/CI without editing many lines. Set `E2E_TIMEOUT_SCALE` (e.g. 2) to stretch every + * The deadlines were scattered as magic numbers, so the suite couldn't be tuned for a slow + * machine/CI without editing many lines. Set `E2E_TIMEOUT_SCALE` (e.g. 2) to stretch every * deadline at once. Only true deadlines/timeouts live here; sub-second pacing `delay()`s — which * are not environment-sensitive — stay inline. + * + * This lives at the suite root (not under `driver/`) so the MetaMask-blind page object + * (`helpers/webWallet.ts`) can share the same knob without importing from `driver/`. */ const scale = Number(process.env.E2E_TIMEOUT_SCALE) || 1; const ms = (base: number): number => Math.round(base * scale); @@ -23,6 +26,8 @@ export const TIMEOUTS = { srpInput: ms(10_000), /** "Confirm recovery phrase" (post-quiz) click. */ recoveryConfirm: ms(15_000), + /** Bounded poll for all SRP chips to populate on the reveal screen (slow renders). */ + seedReveal: ms(15_000), /** Flask experimental-risks gate — best-effort, but slow renders need a long wait. */ acceptRisks: ms(30_000), /** SRP "Continue" to enable after the fast (Paste) entry path. */ @@ -48,4 +53,29 @@ export const TIMEOUTS = { /** Hard cap for {@link MetaMaskDriver.driveApprovalsUntil} (runs until connected). */ untilMaxMs: ms(170_000), }, + /** + * dApp-side (page object) deadlines. These are the web-wallet's own UI waits — distinct from + * the driver's MetaMask deadlines above and from `SNAP_TIMEOUT_MS` (the dApp's 10s snap-RPC + * cap). Scaling `E2E_TIMEOUT_SCALE` now stretches these too. + */ + dapp: { + /** Connected-home marker ("Assets summary") after connect/import. */ + connectedHome: ms(60_000), + /** Header network button reflecting a switch. */ + network: ms(60_000), + /** An async-loaded value replacing its fallback (e.g. Receive address). */ + asyncValue: ms(30_000), + /** A Snap-backed dialog settling (send success/error). */ + dialogSettle: ms(60_000), + /** "Token Created" success screen (cold-starts a signing wallet). */ + tokenCreated: ms(90_000), + /** A success/status toast appearing. */ + toast: ms(30_000), + /** The "New tokens" discovery banner / its rows (async getTokens round-trip). */ + banner: ms(30_000), + /** Flag-gated UI after an async toggle re-fetch (e.g. Token Type selector). */ + flagGatedUi: ms(30_000), + /** "Checking address usage…" resolving in the Address mode dialog. */ + addressUsage: ms(30_000), + }, }; diff --git a/packages/web-wallet/tests/e2e/token-lifecycle.spec.ts b/packages/web-wallet/tests/e2e/token-lifecycle.spec.ts index 0d667d04..9f968f42 100644 --- a/packages/web-wallet/tests/e2e/token-lifecycle.spec.ts +++ b/packages/web-wallet/tests/e2e/token-lifecycle.spec.ts @@ -17,6 +17,10 @@ test.describe.serial('funded wallet token lifecycle (testnet)', () => { // Per-run unique symbols (≤5 chars). Testnet tokens persist across runs, so a fixed symbol // could collide with a prior run and make the symbol-based selectors target the wrong token. + // IMPORTANT: never put these in a `test(...)` title. Playwright evaluates the spec in both the + // main and worker processes at slightly different times, so a Date.now()-derived title would + // differ between them and abort with "Test not found in the worker process". Titles stay + // static; the dynamic symbols live in the test bodies only. const tokenA = `QA${Date.now().toString(36).slice(-3).toUpperCase()}`; // deposit token const tokenB = `QB${Date.now().toString(36).slice(-3).toUpperCase()}`; // fee-based token @@ -41,7 +45,7 @@ test.describe.serial('funded wallet token lifecycle (testnet)', () => { await wallet.expectSendSuccess(); }); - test(`creates a deposit token (${tokenA}) and shows the 1.00 HTR deposit`, async ({ wallet, metamask }) => { + test('creates a deposit token and shows the 1.00 HTR deposit', async ({ wallet, metamask }) => { await wallet.openCreateToken(); await wallet.fillCreateToken({ name: `${tokenA}${Date.now()}`, symbol: tokenA, amount: '100' }); expect(await wallet.readDepositAmount()).toBe('1.00'); // 100 tokens × 1% = 1.00 HTR @@ -51,7 +55,7 @@ test.describe.serial('funded wallet token lifecycle (testnet)', () => { await wallet.closeTokenCreated(); }); - test(`sends ${tokenA} to its own address`, async ({ wallet, metamask }) => { + test('sends the deposit token to its own address', async ({ wallet, metamask }) => { await wallet.openSend(); await wallet.fillSend({ token: tokenA, amount: '10', to: addr }); await wallet.submitSend(); @@ -59,13 +63,13 @@ test.describe.serial('funded wallet token lifecycle (testnet)', () => { await wallet.expectSendSuccess(); }); - test(`unregisters ${tokenA}`, async ({ wallet }) => { + test('unregisters the deposit token', async ({ wallet }) => { await wallet.openTokenHistory(tokenA); await wallet.unregisterCurrentToken(); await wallet.expectTokenNotVisible(tokenA); }); - test(`re-imports ${tokenA} via the New Tokens banner`, async ({ wallet }) => { + test('re-imports the deposit token via the New Tokens banner', async ({ wallet }) => { // Unregistering the token (one the wallet holds on-chain) makes it "discovered" // again, which surfaces the New Tokens banner. Import it back through that flow, // targeting it by its uid from the config string ([name:symbol:uid:checksum]). @@ -75,7 +79,7 @@ test.describe.serial('funded wallet token lifecycle (testnet)', () => { await wallet.expectTokenVisible(tokenA); }); - test(`unregisters ${tokenA} again`, async ({ wallet }) => { + test('unregisters the deposit token again', async ({ wallet }) => { // Set up the second re-registration path: unregister once more so the // config-string flow (next test) has a token to register back. await wallet.openTokenHistory(tokenA); @@ -83,14 +87,14 @@ test.describe.serial('funded wallet token lifecycle (testnet)', () => { await wallet.expectTokenNotVisible(tokenA); }); - test(`re-registers ${tokenA} from its config string`, async ({ wallet }) => { + test('re-registers the deposit token from its config string', async ({ wallet }) => { // Second registration path: Header menu → Register Tokens → paste config string. // Both paths (banner import above and config string here) must stay covered. await wallet.registerToken(configA); await wallet.expectTokenVisible(tokenA); }); - test(`creates a fee-based token (${tokenB}), 3 trillion units`, async ({ wallet, metamask }) => { + test('creates a fee-based token, 3 trillion units', async ({ wallet, metamask }) => { await wallet.openCreateToken(); await wallet.waitForTokenTypeSelector(); // Unleash flag resolved on testnet await wallet.selectTokenType('fee'); @@ -103,7 +107,7 @@ test.describe.serial('funded wallet token lifecycle (testnet)', () => { void configB; // captured for parity / future re-register }); - test(`sends ${tokenB} to its own address`, async ({ wallet, metamask }) => { + test('sends the fee-based token to its own address', async ({ wallet, metamask }) => { await wallet.openSend(); await wallet.fillSend({ token: tokenB, amount: '1000000', to: addr }); await wallet.submitSend();