Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/web-wallet/docs/qa-automation-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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.
Expand All @@ -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).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: checklist step 4 points at a checksum that doesn't exist yet

Item 6 is explicitly out of scope for this PR and driver/flask.ts has no checksum today, so anyone following this checklist right now stalls on a step with nothing to update. Worth marking it as pending until that PR lands.


## Traceability

| QA case | Spec |
Expand Down
5 changes: 3 additions & 2 deletions packages/web-wallet/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
43 changes: 43 additions & 0 deletions packages/web-wallet/src/constants/__tests__/timeouts.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
41 changes: 31 additions & 10 deletions packages/web-wallet/tests/e2e/driver/MetaMaskDriver.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -158,20 +161,38 @@ export class MetaMaskDriver {
private async revealAndSaveSeed(): Promise<string[]> {
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion(non-blocking): seedReveal bounds the loop but not a single pass

The deadline is only checked after readSeedWords returns, and each chip read inherits actionTimeout: 45_000 (playwright.e2e.config.ts:67). For attached-but-empty chips — the case your comment describes — this works as intended. If a chip isn't attached yet, one pass alone can burn 45s × 12 and blow the 180s test timeout, so Playwright kills the test generically and neither dumpScreen('recovery-phrase-missing-word') nor the actionable error message ever fires.

Passing a short per-read { timeout } inside readSeedWords would make the 15s bound real.

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<string[]> {
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;
}

Expand Down
43 changes: 41 additions & 2 deletions packages/web-wallet/tests/e2e/driver/flask.ts
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: an unparseable manifest silently bypasses the version guard

findManifestDir only tests existsSync(manifest.json) — it never parses it. So a truncated or half-extracted .cache/flask-*/manifest.json throws here and returns, skipping the guard entirely; that is one of the two scenarios the docstring above names (a stale .cache). Re-throwing, or at least warning, would keep the failure loud.

}
// 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.
*
Expand All @@ -30,6 +64,7 @@ export async function resolveFlaskPath(): Promise<string> {
`Point it at an unpacked MetaMask Flask build directory.`,
);
}
assertFlaskVersion(found);
return found;
}

Expand All @@ -47,7 +82,10 @@ export async function resolveFlaskPath(): Promise<string> {

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);
Expand All @@ -69,6 +107,7 @@ export async function resolveFlaskPath(): Promise<string> {
if (!unpacked) {
throw new Error(`Downloaded Flask ${version} but no manifest.json found under ${dest}.`);
}
assertFlaskVersion(unpacked);
return unpacked;
}

Expand Down
4 changes: 4 additions & 0 deletions packages/web-wallet/tests/e2e/driver/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
16 changes: 16 additions & 0 deletions packages/web-wallet/tests/e2e/e2e.md
Original file line number Diff line number Diff line change
Expand Up @@ -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* —
Expand Down Expand Up @@ -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` —

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion(non-blocking): file map still lists timeouts.ts under driver/ after the move

Pinned to this changed line; the concern is the ## File map block at e2e.md:266, outside the diff. It still nests timeouts.ts under driver/, and tests/e2e/timeouts.ts is missing from it — so the doc now contradicts the section right here. Since e2e.md opens with Read this before reading or writing any E2E test, that map is what the next contributor navigates by: they look under driver/, find nothing, and inline a magic number — the regression the move was meant to prevent.

Worth knowing a stale-path grep won't catch this; the map is an indented tree, so the path never appears as a literal string.

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
Expand Down
Loading
Loading