diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d6b938..296c4a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,3 +25,26 @@ jobs: run: npm run build - name: Test run: npm test -- --run --passWithNoTests + + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - name: Install Playwright browser + run: npm run test:e2e:install + - name: Run deterministic Playwright suite + run: npm run test:e2e + - name: Upload Playwright artifacts + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: playwright-artifacts + path: | + playwright-report/ + test-results/ + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index ad90a56..d512575 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ dist/ .claude/ *.log .DS_Store +playwright-report/ +test-results/ +.env.playwright-live diff --git a/README.md b/README.md index ed7a8c5..bb1956c 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,63 @@ npm run build npm run preview ``` +## Playwright E2E + +This repository includes two E2E modes: + +- Deterministic UI mode (CI-safe): runs against `?e2e=mock` with no live funds/keys required. +- Optional live testnet mode: runs against testnet endpoints with real identity/key validation and realistic click-through. + +### Install browser for E2E + +```bash +npm run test:e2e:install +``` + +### Deterministic E2E (used in CI) + +```bash +# Headless +npm run test:e2e + +# Headed (local debugging) +npm run test:e2e:headed +``` + +### Optional live testnet E2E + +Live suite is opt-in and skips when required variables are missing. +Never paste real private keys directly inline in shell commands. Inline secrets are saved in shell history. + +```bash +# Safer approach: store sensitive values in a protected env file and source it +cat > .env.playwright-live <<'EOF' +PW_E2E_LIVE=1 +PW_LIVE_TOPUP_IDENTITY_ID=<44-char-base58-id> +PW_LIVE_DPNS_IDENTITY_ID=<44-char-base58-id> +PW_LIVE_DPNS_PRIVATE_KEY_WIF= +PW_LIVE_MANAGE_IDENTITY_ID=<44-char-base58-id> +PW_LIVE_MANAGE_PRIVATE_KEY_WIF= +EOF + +chmod 600 .env.playwright-live +set -a +source .env.playwright-live +set +a +npm run test:e2e:live +``` + +Environment variables used by the live suite: + +- `PW_E2E_LIVE`: set to `1` to enable live tests. +- `PW_LIVE_TOPUP_IDENTITY_ID`: identity ID used for top-up navigation check. +- `PW_LIVE_DPNS_IDENTITY_ID`: identity ID used for DPNS key validation. +- `PW_LIVE_DPNS_PRIVATE_KEY_WIF`: DPNS AUTHENTICATION key for that identity. Prefer CRITICAL; HIGH is also accepted. +- `PW_LIVE_MANAGE_IDENTITY_ID`: identity ID used for key-management validation. +- `PW_LIVE_MANAGE_PRIVATE_KEY_WIF`: MASTER key for identity-management validation. +- `PLAYWRIGHT_BASE_URL` (optional): override app URL if running against a pre-started server. +- `PLAYWRIGHT_HOST` / `PLAYWRIGHT_PORT` (optional): host/port used by Playwright-managed dev server. + ## Deployment This project automatically deploys to GitHub Pages on push to `main` via GitHub Actions. diff --git a/e2e/deterministic.spec.ts b/e2e/deterministic.spec.ts new file mode 100644 index 0000000..0b145fd --- /dev/null +++ b/e2e/deterministic.spec.ts @@ -0,0 +1,124 @@ +import { expect, test } from '@playwright/test'; +import { + E2E_MOCK_DPNS_WIF, + E2E_MOCK_IDENTITY_ID, + E2E_MOCK_MANAGE_WIF, +} from '../src/e2e-mock-constants'; + +const MOCK_QUERY = '/?network=testnet&e2e=mock'; + +test.describe('Deterministic UI E2E (mock mode)', () => { + test('create identity flow transitions to completion and DPNS registration', async ({ page }) => { + await page.goto(MOCK_QUERY); + + await page.click('#mode-create-btn'); + await expect(page.getByRole('button', { name: 'Continue' })).toBeVisible(); + + await page.click('#continue-btn'); + + await expect(page.locator('.deposit-headline')).toBeVisible(); + await expect.poll(async () => { + return page.evaluate(() => typeof (window as { __e2eMockAdvance?: () => void }).__e2eMockAdvance); + }).toBe('function'); + await page.evaluate(() => (window as { __e2eMockAdvance?: () => void }).__e2eMockAdvance?.()); + + await expect(page.getByText('Save your keys')).toBeVisible(); + await expect(page.locator('.contract-id-section', { hasText: 'Your Identity ID' }).locator('.identity-id')).toHaveText(E2E_MOCK_IDENTITY_ID); + + await page.click('#dpns-from-identity-btn'); + await expect(page.getByText('Choose Your Usernames')).toBeVisible(); + + const usernameInput = page.locator('.dpns-username-input').first(); + await usernameInput.fill('alpha'); + await page.click('#check-availability-btn'); + + await expect(page.getByText('Review Usernames')).toBeVisible(); + await expect(page.getByText('Available (Contested)')).toBeVisible(); + await expect(page.locator('#dpns-contested-checkbox')).toBeVisible(); + await expect(page.locator('#register-dpns-btn')).toBeDisabled(); + + await page.locator('#dpns-contested-checkbox').click({ force: true }); + await expect(page.locator('#register-dpns-btn')).toBeEnabled(); + + await page.click('#register-dpns-btn'); + await expect(page.getByText('Registration Complete!')).toBeVisible(); + }); + + test('top up flow validates identity input and reaches completion', async ({ page }) => { + await page.goto(MOCK_QUERY); + + await page.click('#mode-topup-btn'); + await expect(page.locator('#identity-id-input')).toBeVisible(); + + await page.click('#continue-topup-btn'); + await expect(page.locator('#validation-msg')).toContainText('Please enter a valid identity ID'); + + await page.fill('#identity-id-input', E2E_MOCK_IDENTITY_ID); + await page.click('#continue-topup-btn'); + + await expect(page.locator('.deposit-headline')).toBeVisible(); + await expect.poll(async () => { + return page.evaluate(() => typeof (window as { __e2eMockAdvance?: () => void }).__e2eMockAdvance); + }).toBe('function'); + await page.evaluate(() => (window as { __e2eMockAdvance?: () => void }).__e2eMockAdvance?.()); + + await expect(page.getByText('Top-up complete!')).toBeVisible(); + await expect(page.locator('.contract-id-section', { hasText: 'Identity ID' }).locator('.identity-id')).toHaveText(E2E_MOCK_IDENTITY_ID); + }); + + test('manage identity flow validates key and applies changes', async ({ page }) => { + await page.goto(MOCK_QUERY); + + await page.click('#mode-manage-btn'); + await expect(page.locator('#manage-identity-id-input')).toBeVisible(); + + await page.fill('#manage-identity-id-input', E2E_MOCK_IDENTITY_ID); + await page.locator('#manage-identity-id-input').press('Tab'); + await expect(page.getByText('Identity found with 2 keys')).toBeVisible(); + + await page.fill('#manage-private-key-input', 'bad-key'); + await page.locator('#manage-private-key-input').press('Tab'); + await expect(page.getByText('Mock mode: use the configured test private key')).toBeVisible(); + + await page.fill('#manage-private-key-input', E2E_MOCK_MANAGE_WIF); + await page.locator('#manage-private-key-input').blur(); + await expect(page.getByText('Manage Keys')).toBeVisible(); + + await page.click('#add-manage-key-btn'); + await page.locator('.manage-disable-key-checkbox').first().click({ force: true }); + await expect(page.getByText('Will add 1 key, disable 1 key')).toBeVisible(); + + await page.click('#apply-manage-btn'); + await expect(page.getByText('Update Complete!')).toBeVisible(); + }); + + test('standalone DPNS flow validates identity + key and completes registration', async ({ page }) => { + await page.goto(MOCK_QUERY); + + await page.click('#mode-dpns-btn'); + await expect(page.getByText('Register a Username')).toBeVisible(); + + await page.click('#dpns-choose-existing-btn'); + await expect(page.locator('#dpns-identity-id-input')).toBeVisible(); + + await page.fill('#dpns-identity-id-input', E2E_MOCK_IDENTITY_ID); + await page.locator('#dpns-identity-id-input').press('Tab'); + await expect(page.getByText('Identity found with 2 keys')).toBeVisible(); + + await page.fill('#dpns-private-key-input', E2E_MOCK_DPNS_WIF); + await page.locator('#dpns-private-key-input').press('Tab'); + await expect(page.getByText('Key matches key #1 (CRITICAL level)')).toBeVisible(); + + await page.click('#dpns-identity-continue-btn'); + await expect(page.getByText('Choose Your Usernames')).toBeVisible(); + + await page.locator('.dpns-username-input').first().fill('noncontested123456789012345'); + await page.click('#check-availability-btn'); + + await expect(page.getByText('Review Usernames')).toBeVisible(); + await expect(page.locator('#register-dpns-btn')).toBeEnabled(); + + await page.click('#register-dpns-btn'); + await expect(page.getByText('Registration Complete!')).toBeVisible(); + }); +}); diff --git a/e2e/live.testnet.spec.ts b/e2e/live.testnet.spec.ts new file mode 100644 index 0000000..5b7c4a0 --- /dev/null +++ b/e2e/live.testnet.spec.ts @@ -0,0 +1,80 @@ +import { expect, test } from '@playwright/test'; + +const LIVE_ENABLED = process.env.PW_E2E_LIVE === '1'; +const LIVE_TOPUP_IDENTITY_ID = process.env.PW_LIVE_TOPUP_IDENTITY_ID; +const LIVE_DPNS_IDENTITY_ID = process.env.PW_LIVE_DPNS_IDENTITY_ID; +const LIVE_DPNS_PRIVATE_KEY_WIF = process.env.PW_LIVE_DPNS_PRIVATE_KEY_WIF; +const LIVE_MANAGE_IDENTITY_ID = process.env.PW_LIVE_MANAGE_IDENTITY_ID; +const LIVE_MANAGE_PRIVATE_KEY_WIF = process.env.PW_LIVE_MANAGE_PRIVATE_KEY_WIF; + +test.describe('Live testnet E2E (optional)', () => { + test.skip(!LIVE_ENABLED, 'Set PW_E2E_LIVE=1 to enable live testnet checks'); + + test('create mode renders real deposit details on testnet', async ({ page }) => { + await page.goto('/?network=testnet&e2e=live'); + + await page.click('#mode-create-btn'); + await page.click('#continue-btn'); + + await expect(page.locator('.deposit-headline')).toBeVisible({ timeout: 15000 }); + await expect(page.getByRole('button', { name: 'Request Testnet Funds' })).toBeVisible({ timeout: 15000 }); + await expect(page.locator('.address')).toBeVisible({ timeout: 15000 }); + }); + + test('top up mode validates and renders testnet deposit step', async ({ page }) => { + test.skip(!LIVE_TOPUP_IDENTITY_ID, 'Set PW_LIVE_TOPUP_IDENTITY_ID to run top-up live check'); + + await page.goto('/?network=testnet&e2e=live'); + + await page.click('#mode-topup-btn'); + await page.fill('#identity-id-input', LIVE_TOPUP_IDENTITY_ID!); + await page.click('#continue-topup-btn'); + + await expect(page.locator('.deposit-headline')).toContainText('Top up', { timeout: 15000 }); + await expect(page.locator('.address')).toBeVisible({ timeout: 15000 }); + }); + + test('dpns existing identity key validation works against testnet', async ({ page }) => { + test.skip( + !LIVE_DPNS_IDENTITY_ID || !LIVE_DPNS_PRIVATE_KEY_WIF, + 'Set PW_LIVE_DPNS_IDENTITY_ID and PW_LIVE_DPNS_PRIVATE_KEY_WIF to run DPNS live check' + ); + + await page.goto('/?network=testnet&e2e=live'); + + await page.click('#mode-dpns-btn'); + await page.click('#dpns-choose-existing-btn'); + + await page.fill('#dpns-identity-id-input', LIVE_DPNS_IDENTITY_ID!); + await page.locator('#dpns-identity-id-input').press('Tab'); + + await expect(page.locator('.identity-status.success')).toContainText('Identity found with', { timeout: 90000 }); + + await page.fill('#dpns-private-key-input', LIVE_DPNS_PRIVATE_KEY_WIF!); + await page.locator('#dpns-private-key-input').press('Tab'); + + await expect(page.locator('.key-status.success')).toContainText('Key matches key', { timeout: 30000 }); + await expect(page.locator('#dpns-identity-continue-btn')).toBeEnabled(); + }); + + test('manage identity key validation works against testnet', async ({ page }) => { + test.skip( + !LIVE_MANAGE_IDENTITY_ID || !LIVE_MANAGE_PRIVATE_KEY_WIF, + 'Set PW_LIVE_MANAGE_IDENTITY_ID and PW_LIVE_MANAGE_PRIVATE_KEY_WIF to run manage live check' + ); + + await page.goto('/?network=testnet&e2e=live'); + + await page.click('#mode-manage-btn'); + await page.fill('#manage-identity-id-input', LIVE_MANAGE_IDENTITY_ID!); + await page.locator('#manage-identity-id-input').press('Tab'); + + await expect(page.locator('.identity-status.success')).toContainText('Identity found with', { timeout: 90000 }); + + await page.fill('#manage-private-key-input', LIVE_MANAGE_PRIVATE_KEY_WIF!); + await page.locator('#manage-private-key-input').press('Tab'); + + await expect(page.locator('.key-status.success')).toContainText('MASTER level', { timeout: 30000 }); + await expect(page.locator('#manage-identity-continue-btn')).toBeEnabled(); + }); +}); diff --git a/package-lock.json b/package-lock.json index 747690b..a8a54c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "qrcode": "^1.5.4" }, "devDependencies": { + "@playwright/test": "^1.52.0", "@types/qrcode": "^1.5.6", "typescript": "^5.3.0", "vite": "^5.0.0", @@ -503,6 +504,22 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@playwright/test": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz", + "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.54.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", @@ -1683,6 +1700,53 @@ "dev": true, "license": "MIT" }, + "node_modules/playwright": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", + "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", + "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", diff --git a/package.json b/package.json index 1fc66ad..76197c4 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,11 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "test": "vitest" + "test": "vitest", + "test:e2e": "playwright test e2e/deterministic.spec.ts", + "test:e2e:headed": "playwright test e2e/deterministic.spec.ts --headed", + "test:e2e:live": "PW_E2E_LIVE=1 playwright test e2e/live.testnet.spec.ts", + "test:e2e:install": "playwright install --with-deps chromium" }, "dependencies": { "@dashevo/evo-sdk": "3.1.0-dev.1", @@ -21,6 +25,7 @@ "qrcode": "^1.5.4" }, "devDependencies": { + "@playwright/test": "^1.52.0", "@types/qrcode": "^1.5.6", "typescript": "^5.3.0", "vite": "^5.0.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..6c65ce1 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from '@playwright/test'; + +const PORT = Number(process.env.PLAYWRIGHT_PORT || 4173); +const HOST = process.env.PLAYWRIGHT_HOST || '127.0.0.1'; +const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || `http://${HOST}:${PORT}`; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? 2 : undefined, + reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : [['list']], + use: { + baseURL: BASE_URL, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + webServer: { + command: `npm run dev -- --host ${HOST} --port ${PORT}`, + url: BASE_URL, + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/src/e2e-mock-constants.ts b/src/e2e-mock-constants.ts new file mode 100644 index 0000000..b2abdee --- /dev/null +++ b/src/e2e-mock-constants.ts @@ -0,0 +1,3 @@ +export const E2E_MOCK_IDENTITY_ID = '11111111111111111111111111111111111111111111'; +export const E2E_MOCK_DPNS_WIF = 'cMockDpnsPrivateKeyWif'; +export const E2E_MOCK_MANAGE_WIF = 'cMockManagePrivateKeyWif'; diff --git a/src/main.ts b/src/main.ts index 580c5a5..cfff4a2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -99,6 +99,7 @@ import { checkMultipleAvailability, registerMultipleNames, getIdentityPublicKeys, + isContestedUsername, } from './platform/dpns.js'; import { findMatchingKeyIndex, @@ -110,14 +111,134 @@ import { } from './crypto/keys.js'; import { publishContract, extractDocumentSchemas } from './platform/contract.js'; import { estimateContractFee, parseContractJson } from 'dash-contract-fee-estimator'; -import type { KeyType, KeyPurpose, SecurityLevel, ManageNewKeyConfig } from './types.js'; -import type { BridgeState } from './types.js'; +import type { + BridgeState, + KeyType, + KeyPurpose, + SecurityLevel, + UTXO, + ManageNewKeyConfig, + DpnsUsernameEntry, + DpnsRegistrationResult, + IdentityPublicKeyInfo, + E2EMockWindow, +} from './types.js'; +import { + E2E_MOCK_DPNS_WIF, + E2E_MOCK_IDENTITY_ID, + E2E_MOCK_MANAGE_WIF, +} from './e2e-mock-constants.js'; // Global state let state: BridgeState; let insightClient: InsightClient; let dapiClient: DAPIClient; +const E2E_MOCK_ADVANCE_TIMEOUT_MS = 30000; +const E2E_MOCK_BUILD_ENABLED = !import.meta.env.PROD; + +function isE2EMockMode(): boolean { + if (!E2E_MOCK_BUILD_ENABLED) { + return false; + } + const urlParams = new URLSearchParams(window.location.search); + return urlParams.get('e2e') === 'mock'; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function createE2EMockUtxo(): UTXO { + return { + txid: 'a'.repeat(64), + vout: 0, + satoshis: 450000, + scriptPubKey: '76a914111111111111111111111111111111111111111188ac', + confirmations: 1, + }; +} + +function createE2EMockIdentityKeys(): IdentityPublicKeyInfo[] { + return [ + { + id: 0, + type: 0, + purpose: 0, + securityLevel: 0, + data: new Uint8Array(33), + isDisabled: false, + }, + { + id: 1, + type: 0, + purpose: 0, + securityLevel: 1, + data: new Uint8Array(33), + isDisabled: false, + }, + ]; +} + +function createE2EMockDpnsAvailability(entries: DpnsUsernameEntry[]): DpnsUsernameEntry[] { + return entries.map((entry) => { + if (!entry.isValid) { + return { ...entry, status: 'invalid' }; + } + + const lower = entry.label.toLowerCase(); + const normalizedLabel = entry.normalizedLabel || lower; + const isAvailable = !lower.includes('taken'); + return { + ...entry, + normalizedLabel, + isAvailable, + isContested: isAvailable ? isContestedUsername(normalizedLabel) : undefined, + status: isAvailable ? 'available' : 'taken', + }; + }); +} + +function createE2EMockDpnsResults(entries: DpnsUsernameEntry[]): DpnsRegistrationResult[] { + return entries + .filter((entry) => entry.isValid && entry.isAvailable) + .map((entry) => ({ + label: entry.label, + success: !entry.label.toLowerCase().includes('fail'), + error: entry.label.toLowerCase().includes('fail') ? 'Mock registration failure' : undefined, + isContested: entry.isContested ?? false, + })); +} + +function clearE2EMockAdvanceHook(): void { + (window as E2EMockWindow).__e2eMockAdvance = undefined; +} + +function waitForE2EMockAdvance(timeoutMs = E2E_MOCK_ADVANCE_TIMEOUT_MS): Promise { + return new Promise((resolve, reject) => { + const mockWindow = window as E2EMockWindow; + let timeoutId = 0; + + const cleanup = () => { + window.clearTimeout(timeoutId); + if (mockWindow.__e2eMockAdvance === resolveAdvance) { + mockWindow.__e2eMockAdvance = undefined; + } + }; + + const resolveAdvance = () => { + cleanup(); + resolve(); + }; + + mockWindow.__e2eMockAdvance = resolveAdvance; + timeoutId = window.setTimeout(() => { + cleanup(); + reject(new Error(`Timed out waiting for E2E mock advance after ${timeoutMs}ms`)); + }, timeoutMs); + }); +} + /** * Initialize the application */ @@ -537,6 +658,12 @@ function setupEventListeners(container: HTMLElement) { updateState(setDpnsIdentityFetching(state, identityId)); try { + if (isE2EMockMode()) { + await delay(30); + updateState(setDpnsIdentityFetched(state, createE2EMockIdentityKeys())); + return; + } + const keys = await getIdentityPublicKeys(identityId, state.network); updateState(setDpnsIdentityFetched(state, keys)); } catch (error) { @@ -592,6 +719,15 @@ function setupEventListeners(container: HTMLElement) { return; } + if (isE2EMockMode()) { + if (privateKeyWif === E2E_MOCK_DPNS_WIF) { + updateState(setDpnsKeyValidated(state, 1, privateKeyWif)); + } else { + updateState(setDpnsKeyValidationError(state, 'Mock mode: use the configured test private key')); + } + return; + } + // Find matching key const match = findMatchingKeyIndex(privateKeyWif, state.dpnsIdentityKeys, state.network); @@ -770,6 +906,12 @@ function setupEventListeners(container: HTMLElement) { updateState(setManageIdentityFetching(state, identityId)); try { + if (isE2EMockMode()) { + await delay(30); + updateState(setManageIdentityFetched(state, createE2EMockIdentityKeys())); + return; + } + const keys = await getIdentityPublicKeys(identityId, state.network); updateState(setManageIdentityFetched(state, keys)); } catch (error) { @@ -824,6 +966,15 @@ function setupEventListeners(container: HTMLElement) { return; } + if (isE2EMockMode()) { + if (privateKeyWif === E2E_MOCK_MANAGE_WIF) { + updateState(setManageKeyValidated(state, 0, 0, privateKeyWif)); + } else { + updateState(setManageKeyValidationError(state, 'Mock mode: use the configured test private key')); + } + return; + } + // Find matching key const match = findMatchingKeyIndex(privateKeyWif, state.manageIdentityKeys, state.network); @@ -981,6 +1132,12 @@ function setupEventListeners(container: HTMLElement) { const refreshedState = resetManageStateAndRefresh(state); updateState(refreshedState); + if (isE2EMockMode()) { + await delay(30); + updateState(setManageIdentityFetched(refreshedState, createE2EMockIdentityKeys())); + return; + } + // Refetch identity keys from the network const targetId = refreshedState.targetIdentityId; if (targetId) { @@ -1411,6 +1568,34 @@ function showValidationError(message: string): void { */ async function startTopUp() { try { + if (isE2EMockMode()) { + const network = getNetwork(state.network); + const assetLockKeyPair = generateKeyPair(); + const depositAddress = publicKeyToAddress(assetLockKeyPair.publicKey, network); + + updateState(setStep(state, 'generating_keys')); + await delay(120); + updateState(setOneTimeKeyPair(state, assetLockKeyPair, depositAddress)); + updateState(setStep(state, 'detecting_deposit')); + clearE2EMockAdvanceHook(); + await waitForE2EMockAdvance(); + updateState(setUtxoDetected(state, createE2EMockUtxo())); + await delay(120); + updateState(setTransactionSigned(state, 'ab'.repeat(180))); + await delay(120); + updateState(setTransactionBroadcast(state, 'b'.repeat(64))); + await delay(120); + updateState(setInstantLockReceived(state, new Uint8Array([1, 2, 3, 4]), { + transactionBytes: new Uint8Array([0xc0, 0xff, 0xee]), + instantLockBytes: new Uint8Array([1, 2, 3, 4]), + outputIndex: 0, + })); + updateState(setStep(state, 'topping_up')); + await delay(120); + updateState(setTopUpComplete(state)); + return; + } + const network = getNetwork(state.network); // Step 1: Generate random one-time key pair (NOT HD-derived) @@ -1509,6 +1694,9 @@ async function startTopUp() { updateState(setTopUpComplete(state)); } catch (error) { + if (isE2EMockMode()) { + clearE2EMockAdvanceHook(); + } console.error('Top-up error:', error); updateState(setError(state, toError(error))); } @@ -1621,6 +1809,33 @@ async function startSendToAddress() { */ async function startBridge() { try { + if (isE2EMockMode()) { + const network = getNetwork(state.network); + const assetLockKeyPair = generateKeyPair(); + const depositAddress = publicKeyToAddress(assetLockKeyPair.publicKey, network); + + clearE2EMockAdvanceHook(); + updateState(setStep(state, 'generating_keys')); + await delay(120); + updateState(setKeyPairs(state, assetLockKeyPair, depositAddress)); + updateState(setStep(state, 'detecting_deposit')); + await waitForE2EMockAdvance(); + updateState(setUtxoDetected(state, createE2EMockUtxo())); + await delay(120); + updateState(setTransactionSigned(state, 'cd'.repeat(180))); + await delay(120); + updateState(setTransactionBroadcast(state, 'd'.repeat(64))); + await delay(120); + updateState(setInstantLockReceived(state, new Uint8Array([5, 6, 7, 8]), { + transactionBytes: new Uint8Array([0xbe, 0xef]), + instantLockBytes: new Uint8Array([5, 6, 7, 8]), + outputIndex: 0, + })); + await delay(120); + updateState(setIdentityRegistered(state, E2E_MOCK_IDENTITY_ID)); + return; + } + const network = getNetwork(state.network); // Ensure mnemonic exists @@ -1734,6 +1949,9 @@ async function startBridge() { if (await autoPublishContractIfNeeded(result.identityId)) return; } catch (error) { + if (isE2EMockMode()) { + clearE2EMockAdvanceHook(); + } console.error('Bridge error:', error); updateState(setError(state, toError(error))); } @@ -1943,6 +2161,12 @@ async function startDpnsCheck() { // Transition to checking state updateState(setDpnsChecking(state)); + if (isE2EMockMode()) { + await delay(60); + updateState(setDpnsAvailability(state, createE2EMockDpnsAvailability(validUsernames))); + return; + } + // Check availability for all valid usernames const results = await checkMultipleAvailability(validUsernames, state.network); @@ -1983,6 +2207,17 @@ async function startDpnsRegistration() { // Transition to registering state updateState(setDpnsRegistering(state)); + if (isE2EMockMode()) { + const results = createE2EMockDpnsResults(availableUsernames); + for (let i = 0; i < results.length; i++) { + await delay(40); + updateState(setDpnsRegistrationProgress(state, i)); + } + await delay(40); + updateState(setDpnsResults(state, results)); + return; + } + // Register all available usernames const results = await registerMultipleNames( availableUsernames, @@ -2016,6 +2251,12 @@ async function startManageUpdate() { try { updateState(setManageUpdating(state)); + if (isE2EMockMode()) { + await delay(80); + updateState(setManageComplete(state, { success: true })); + return; + } + // Prepare keys to add const addPublicKeys: AddKeyConfig[] = (state.manageKeysToAdd || []).map(key => ({ keyType: key.keyType, diff --git a/src/types.ts b/src/types.ts index b85adc7..968a0c7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,6 +3,8 @@ export interface KeyPair { publicKey: Uint8Array; } +export type E2EMockWindow = Window & { __e2eMockAdvance?: () => void }; + export interface UTXO { txid: string; vout: number; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..9fa19e1 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + exclude: [ + 'e2e/**', + 'node_modules/**', + 'dist/**', + ], + }, +});