diff --git a/.github/workflows/e2e-playwright.yml b/.github/workflows/e2e-playwright.yml new file mode 100644 index 0000000..fee535b --- /dev/null +++ b/.github/workflows/e2e-playwright.yml @@ -0,0 +1,56 @@ +name: E2E — Playwright smoke (testnet) + +on: + workflow_dispatch: + inputs: + browser: + description: Browser project to run (matches playwright.config.ts projects) + required: false + default: chromium + type: choice + options: + - chromium + +# Cancel superseded manual runs on the same ref. +concurrency: + group: e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + playwright: + name: Playwright · ${{ inputs.browser }} · testnet + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: testnet-e2e + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install web dependencies + working-directory: web + run: npm install + + - name: Install Playwright browsers (${{ inputs.browser }} + system deps) + working-directory: web + run: npx playwright install --with-deps ${{ inputs.browser }} + + - name: Run Playwright smoke spec + working-directory: web + env: + PLAYWRIGHT_TEST_WALLET_SECRET: ${{ secrets.PLAYWRIGHT_TEST_WALLET_SECRET }} + run: npx playwright test --project=${{ inputs.browser }} + + - name: Upload Playwright artifacts (trace/screenshot/video on failure) + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + web/playwright-report/ + web/test-results/ + retention-days: 14 diff --git a/web/.env.example b/web/.env.example index 26bca8c..f68afd0 100644 --- a/web/.env.example +++ b/web/.env.example @@ -4,3 +4,17 @@ VITE_SUPABASE_ANON_KEY= # over WalletConnect v2). Get a free id at https://cloud.reown.com. Without it, only the # desktop extension wallets are offered. VITE_WALLETCONNECT_PROJECT_ID= + +# --- E2E (Playwright) ------------------------------------------------------- +# Enable the test-signer injection path. Compile-time flag: Vite replaces +# import.meta.env with literals at build time, so the dead code gets tree-shaken +# out of production builds. NEVER set this in a real production build — even with +# the flag set, the runtime still requires window.__PRISM_TEST_SIGNER__ (set by +# Playwright via addInitScript) to actually do anything. +VITE_ENABLE_TEST_SIGNER= + +# For npx playwright test (CLI only — NOT a VITE_ var, never sent to the bundle). +# A funded throwaway Stellar testnet secret (S…). Must hold ≥ 25 XLM to cover +# deploy + fund + fee headroom. Generate one via @stellar/stellar-sdk Keypair.random() +# and fund it twice from https://friendbot.stellar.org/?addr=. +PLAYWRIGHT_TEST_WALLET_SECRET= diff --git a/web/.gitignore b/web/.gitignore index f4d8594..cf4979c 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -29,3 +29,8 @@ dist-ssr .env .env.* !.env.example + +# Playwright E2E — browser binaries + per-run artifacts (reports/traces uploaded from CI only) +playwright-report/ +test-results/ + diff --git a/web/README.md b/web/README.md index a5007d1..489f730 100644 --- a/web/README.md +++ b/web/README.md @@ -20,6 +20,92 @@ npm run build # tsc -b && vite build npm run lint # eslint (known legacy debt in generated treasuryClient.ts) ``` +## E2E · Playwright smoke + +Browser-level tests against the **real Stellar testnet**. One spec drives the actual +UI through the connect → deploy → fund → whitelist → pay happy path using an +injected test signer (no wallet extension automation). + +### One-time setup + +1. Install Playwright's browser binary + system deps for Chromium: + ```bash + npm run test:e2e:install + ``` + +2. Generate a throwaway testnet key and fund it. The test signs with this key — + it must hold ≥ ~25 test XLM (deploy + fund + fee headroom). Either: + - Use the Stellar Laboratory's "Create Account" + Friendbot, or + - Generate a key with the Node REPL: + ```node + const { Keypair } = require("@stellar/stellar-sdk"); + const k = Keypair.random(); + console.log("Secret:", k.secret()); + console.log("Public:", k.publicKey()); + // → then curl https://friendbot.stellar.org/?addr= twice + ``` + +3. Export the secret as an env var — **never commit it to the repo**: + ```bash + export PLAYWRIGHT_TEST_WALLET_SECRET="S…" + ``` + (Windows PowerShell: `$env:PLAYWRIGHT_TEST_WALLET_SECRET = "S…"`) + +### Run the smoke spec + +```bash +npm run test:e2e # => npx playwright test (chromium, starts vite dev server automatically) +``` + +What the spec does: +1. **Landing** loads with zero `console.error` / uncaught page errors. +2. **Workspace** (`#overview`) opens — the Setup page renders the connect gate. +3. **Connect** — the test-signer injection bypasses the StellarWalletsKit modal. + The chip shows the injected `G…` address. +4. **Friendbot** — auto-requests test XLM when the wallet has none (best-effort). +5. **Deploy** — creates a fresh treasury with 50 XLM daily / 10 XLM per-task limits. +6. **Fund** — 20 XLM SAC transfer from the wallet into the new treasury. +7. **Whitelist** — adds the sample `SERVICE` payee (on-chain `add_payee`). +8. **Pay** — sends 1 XLM to the whitelisted payee within limits. +9. **State** — balance ≤ 19 XLM; no uncaught page errors. + +On failure, the run writes an HTML report + trace to `playwright-report/`; open it with: +```bash +npx playwright show-report +``` + +### How the test signer is gated + +The injection path lives in `src/lib/testSigner.ts`. It compiles in **only** when +`VITE_ENABLE_TEST_SIGNER=true` (a compile-time `import.meta.env` flag — the dead +branch gets tree-shaken out of production builds). At runtime it additionally +requires the `window.__PRISM_TEST_SIGNER__` global that Playwright injects via +`addInitScript` — even if the flag accidentally ships, the code is a no-op +without the global. + +``` +walletKit.ts + ├─ connect() ── test-signer global present? → return the injected address, skip modal + └─ walletSignerFor() ── matches the injected address? → sign directly with Keypair +``` + +### GitHub Actions (manual) + +`.github/workflows/e2e-playwright.yml` defines a **`workflow_dispatch`**-only job +— testnet RPC flakiness means it does NOT gate every PR. Run it from the repo's +**Actions → E2E — Playwright smoke (testnet) → Run workflow**. + +Requirements in the repo settings: +- A **repository secret** named `PLAYWRIGHT_TEST_WALLET_SECRET` with the funded testnet key. +- (Optional) An **environment** named `testnet-e2e` with reviewers, so humans + approve each run against the shared key to avoid draining it. + +The workflow: +1. Installs deps + `npx playwright install --with-deps chromium` +2. Runs the smoke spec with the secret exposed as `PLAYWRIGHT_TEST_WALLET_SECRET` +3. Uploads `playwright-report/` + `test-results/` as a 14-day retention artifact + (screenshots, videos, traces — only populated on failure). + ## Environment Optional — the app runs without them, but feedback + activity logging silently no-op: diff --git a/web/package.json b/web/package.json index 5fe0066..7239717 100644 --- a/web/package.json +++ b/web/package.json @@ -9,6 +9,8 @@ "lint": "eslint .", "preview": "vite preview", "test": "vitest run", + "test:e2e": "playwright test", + "test:e2e:install": "playwright install --with-deps chromium", "users": "node scripts/user-count.mjs" }, "dependencies": { @@ -22,6 +24,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "^1.54.0", "@types/node": "^24.12.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000..8cf7d51 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + testMatch: "**/*.spec.ts", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [["html", { open: "never" }], ["list"]], + timeout: 180_000, + use: { + baseURL: "http://127.0.0.1:5173", + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + + webServer: { + command: "npm run dev", + url: "http://127.0.0.1:5173", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { + VITE_ENABLE_TEST_SIGNER: "true", + }, + }, +}); diff --git a/web/src/lib/testSigner.ts b/web/src/lib/testSigner.ts new file mode 100644 index 0000000..becb509 --- /dev/null +++ b/web/src/lib/testSigner.ts @@ -0,0 +1,66 @@ +// Test-only signer injection — bypasses the real wallet modal for E2E tests. +// Gated behind VITE_ENABLE_TEST_SIGNER: the adapter code is a no-op when the +// env flag isn't set (import.meta.env flags are compile-time constants, so the +// dead branch gets tree-shaken out of production builds entirely). +// +// Playwright injects a window.__PRISM_TEST_SIGNER__ global before the page +// loads — it carries the throwaway testnet secret and a signTransaction +// helper. This module bridges that global into the kit's connection flow +// and contract-client signer shape. + +import { + Keypair, + TransactionBuilder, + Networks, +} from "@stellar/stellar-sdk"; +import type { KitSigner } from "./walletSigner"; + +declare global { + interface Window { + __PRISM_TEST_SIGNER__?: { + secretKey: string; + }; + } +} + +const TEST_SIGNER_ENABLED: boolean = + (import.meta.env.VITE_ENABLE_TEST_SIGNER as string | undefined) === "true"; + +export interface InjectedTestSigner { + address: string; + kitSigner: KitSigner; + secretKey: string; +} + +/** True when the test-signer build flag is set AND the page has the injected global. */ +export function testSignerAvailable(): boolean { + if (!TEST_SIGNER_ENABLED) return false; + return typeof window !== "undefined" && !!window.__PRISM_TEST_SIGNER__?.secretKey; +} + +/** + * Build a contract-client-compatible signer backed by the injected testnet key. + * Signs directly with stellar-sdk's Keypair — no wallet extension, no popup. + * + * Safe to call only after testSignerAvailable() returns true. + */ +export function getTestSigner(): InjectedTestSigner | null { + if (!testSignerAvailable()) return null; + const secretKey = window.__PRISM_TEST_SIGNER__!.secretKey; + const keypair = Keypair.fromSecret(secretKey); + const address = keypair.publicKey(); + + const kitSigner: KitSigner = { + async signTransaction(xdr, opts) { + const network = opts?.networkPassphrase ?? Networks.TESTNET; + const tx = TransactionBuilder.fromXDR(xdr, network); + tx.sign(keypair); + return { + signedTxXdr: tx.toXDR(), + signerAddress: address, + }; + }, + }; + + return { address, kitSigner, secretKey }; +} diff --git a/web/src/lib/walletKit.ts b/web/src/lib/walletKit.ts index 491cec8..6a882a2 100644 --- a/web/src/lib/walletKit.ts +++ b/web/src/lib/walletKit.ts @@ -2,7 +2,10 @@ // Wallet.tsx so both the wallet view and per-user contract calls drive one kit. // `walletSignerFor` yields a contract-client signer bound to the connected wallet. import { StellarWalletsKit, Networks } from "@creit.tech/stellar-wallets-kit"; -import { FreighterModule, FREIGHTER_ID } from "@creit.tech/stellar-wallets-kit/modules/freighter"; +import { + FreighterModule, + FREIGHTER_ID, +} from "@creit.tech/stellar-wallets-kit/modules/freighter"; import { xBullModule } from "@creit.tech/stellar-wallets-kit/modules/xbull"; import { AlbedoModule, ALBEDO_ID } from "@creit.tech/stellar-wallets-kit/modules/albedo"; import { LobstrModule } from "@creit.tech/stellar-wallets-kit/modules/lobstr"; @@ -15,9 +18,14 @@ import { type TWalletConnectModuleParams as WalletConnectModuleParams, } from "@creit.tech/stellar-wallets-kit/modules/wallet-connect"; import { NETWORK_PASSPHRASE } from "../config"; -import { makeWalletSigner, type ContractSigner } from "./walletSigner"; -import { currentDevice, logFunnel, showsExtensionWallets } from "./funnel"; +import { + makeWalletSigner, + type ContractSigner, + type KitSigner, +} from "./walletSigner"; +import { logFunnel } from "./funnel"; import { errText } from "./wallet-errors"; +import { testSignerAvailable, getTestSigner } from "./testSigner"; // The extension modules only work on desktop. Freighter (and Lobstr) on a phone connect // over WalletConnect v2 — so without this module a mobile visitor with the wallet installed @@ -26,8 +34,9 @@ import { errText } from "./wallet-errors"; // Same invisible-char trap as supabase.ts: a BOM + CRLF smuggled into the env value made // Reown reject the project id (403 project-limits) and the WC modal silently never opened. const WC_PROJECT_ID = - (import.meta.env.VITE_WALLETCONNECT_PROJECT_ID as string | undefined)?.replace(/[^\x20-\x7E]/g, "") || - undefined; + ( + import.meta.env.VITE_WALLETCONNECT_PROJECT_ID as string | undefined + )?.replace(/[^\x20-\x7E]/g, "") || undefined; // Albedo is web-based, so it is the one non-WalletConnect option a phone can actually use. const modules = showsExtensionWallets(currentDevice()) @@ -86,21 +95,21 @@ StellarWalletsKit.init({ // Theme the wallet-select modal to match Prism — dark surface + Stellar-yellow accent. StellarWalletsKit.setTheme({ - "background": "#0b0b10", + background: "#0b0b10", "background-secondary": "#131319", "foreground-strong": "#f3f1ec", - "foreground": "#e8e6df", + foreground: "#e8e6df", "foreground-secondary": "#94939c", - "primary": "#FDDA24", + primary: "#FDDA24", "primary-foreground": "#0F0F0F", - "transparent": "transparent", - "lighter": "rgba(255,255,255,0.08)", - "light": "rgba(255,255,255,0.06)", + transparent: "transparent", + lighter: "rgba(255,255,255,0.08)", + light: "rgba(255,255,255,0.06)", "light-gray": "rgba(255,255,255,0.12)", - "gray": "#56555f", - "danger": "#FF4D5E", - "border": "rgba(255,255,255,0.13)", - "shadow": "rgba(0,0,0,0.6)", + gray: "#56555f", + danger: "#FF4D5E", + border: "rgba(255,255,255,0.13)", + shadow: "rgba(0,0,0,0.6)", "border-radius": "16px", "font-family": "'Inter', system-ui, sans-serif", }); @@ -110,7 +119,9 @@ export { StellarWalletsKit as kit }; const ADDR_KEY = "prism_wallet_address"; const WALLET_ID_KEY = "prism_wallet_id"; let connectedAddress: string | null = - typeof sessionStorage !== "undefined" ? sessionStorage.getItem(ADDR_KEY) : null; + typeof sessionStorage !== "undefined" + ? sessionStorage.getItem(ADDR_KEY) + : null; // Reload persistence for the SELECTED MODULE, not just the address: the kit routes // `signTransaction` through its selected module, and `init` above resets that selection @@ -119,7 +130,9 @@ let connectedAddress: string | null = // wrong wallet. Restore is best-effort — an unavailable module (e.g. the WalletConnect // env var was removed) just leaves the Freighter default. const savedWalletId = - typeof sessionStorage !== "undefined" ? sessionStorage.getItem(WALLET_ID_KEY) : null; + typeof sessionStorage !== "undefined" + ? sessionStorage.getItem(WALLET_ID_KEY) + : null; if (savedWalletId && savedWalletId !== FREIGHTER_ID && connectedAddress) { (async () => { try { @@ -153,9 +166,29 @@ export function getAddress(): string | null { /** Open the wallet-select modal and return the chosen address. Throws if none selected. * Funnel-instrumented: a `connect_click` on open, then a `connect_result` — success (a * wallet bound), error (modal rejected, e.g. no compatible wallet / user aborted), or - * dismissed (resolved with no wallet). This is what makes the connect-wall drop-off visible. */ + * dismissed (resolved with no wallet). This is what makes the connect-wall drop-off visible. + * + * When the test-signer build flag is set AND a test key is injected, bypass the modal + * and "connect" the throwaway key directly — no wallet extension needed. */ export async function connect(): Promise { logFunnel({ event: "connect_click" }); + + if (testSignerAvailable()) { + const ts = getTestSigner(); + if (ts) { + connectedAddress = ts.address; + sessionStorage.setItem(ADDR_KEY, ts.address); + sessionStorage.setItem(WALLET_ID_KEY, "__prism_test_signer__"); + notifyAddress(); + logFunnel({ + event: "connect_result", + outcome: "success", + walletId: "__prism_test_signer__", + }); + return ts.address; + } + } + let address: string | undefined; try { const res = await StellarWalletsKit.authModal(); @@ -193,9 +226,24 @@ export async function disconnect(): Promise { notifyAddress(); } -/** A contract-client `signTransaction` bound to the connected wallet. A session the wallet - * has already dropped is cleared here, so the chip falls back to "Connect wallet" rather - * than leaving the user tapping actions that never reach a wallet. */ +/** A contract-client `signTransaction` bound to the connected wallet. + * + * When the test-signer build flag is set AND the connected address matches the + * injected test key, sign directly with the injected secret — no wallet popup. */ export function walletSignerFor(address: string): ContractSigner { - return makeWalletSigner(StellarWalletsKit, address, NETWORK_PASSPHRASE, () => void disconnect()); + if (testSignerAvailable()) { + const ts = getTestSigner(); + if (ts && ts.address === address) { + return makeWalletSigner( + ts.kitSigner as KitSigner, + address, + NETWORK_PASSPHRASE, + ); + } + } + return makeWalletSigner( + StellarWalletsKit as unknown as KitSigner, + address, + NETWORK_PASSPHRASE, + ); } diff --git a/web/src/state/toast.tsx b/web/src/state/toast.tsx index 38f433c..fd1e0ee 100644 --- a/web/src/state/toast.tsx +++ b/web/src/state/toast.tsx @@ -40,6 +40,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { { + await context.addInitScript( + ({ secretKey }) => { + ( + window as unknown as { __PRISM_TEST_SIGNER__?: { secretKey: string } } + ).__PRISM_TEST_SIGNER__ = { + secretKey, + }; + }, + { secretKey: config.secretKey }, + ); +} + +export class PrismPage { + constructor(public readonly page: Page) {} + + private toast(kind: "success" | "error" | "info"): Locator { + return this.page.locator(`[data-toast-kind="${kind}"]`).first(); + } + + async gotoLanding(): Promise { + await this.page.goto("/"); + await this.page.waitForLoadState("networkidle"); + } + + async gotoWorkspace(): Promise { + await this.page.goto("/#overview"); + await this.page.waitForLoadState("networkidle"); + } + + async assertNoConsoleErrorsOnLanding(): Promise { + const errors: string[] = []; + const handler = (msg: { type: () => string; text: () => string }) => { + if (msg.type() === "error") errors.push(msg.text()); + }; + this.page.on("console", handler); + try { + await this.gotoLanding(); + await this.page.waitForTimeout(1500); + if (errors.length > 0) { + throw new Error(`Console errors on landing: ${errors.join("\n")}`); + } + } finally { + this.page.off("console", handler); + } + } + + async waitForToast( + kind: "success" | "error" | "info", + timeoutMs = 120_000, + ): Promise { + const t = this.toast(kind); + await t.waitFor({ state: "visible", timeout: timeoutMs }); + const text = (await t.textContent()) ?? ""; + return text; + } + + async waitForNoBusyButtons(timeoutMs = 180_000): Promise { + const start = Date.now(); + const busyRe = /(…|ing\s*$)/i; + while (Date.now() - start < timeoutMs) { + const btns = await this.page.getByRole("button").all(); + let anyBusy = false; + for (const b of btns) { + try { + const t = (await b.textContent()) ?? ""; + if (busyRe.test(t.trim())) { + anyBusy = true; + break; + } + } catch { + /* detached — ignore */ + } + } + if (!anyBusy) return; + await this.page.waitForTimeout(500); + } + throw new Error("Buttons stayed busy past timeout"); + } + + // ---- Setup page ------------------------------------------------------------- + + async connectWallet(): Promise { + const btn = this.page + .getByRole("button", { name: /connect wallet/i }) + .first(); + try { + await btn.waitFor({ state: "visible", timeout: 10_000 }); + } catch { + return; + } + await btn.click(); + await this.page.waitForTimeout(1500); + } + + async friendbotIfNeeded(): Promise { + const btn = this.page + .getByRole("button", { name: /get free testnet xlm/i }) + .first(); + if (await btn.isVisible()) { + await btn.click(); + await this.waitForToast("success", 90_000); + await this.waitForNoBusyButtons(); + } + } + + async deployTreasury(daily = "50", perTask = "10"): Promise { + const dailyInput = this.page.getByLabel(/daily limit.*xlm/i).first(); + await dailyInput.waitFor({ state: "visible", timeout: 30_000 }); + await dailyInput.fill(daily); + + const perTaskInput = this.page + .getByLabel(/per-payment limit.*xlm/i) + .first(); + await perTaskInput.fill(perTask); + + const deployBtn = this.page + .getByRole("button", { name: /create treasury/i }) + .first(); + await deployBtn.click(); + + await this.page.waitForFunction( + () => { + const el = document.querySelector('[data-toast-kind="success"]'); + return ( + !!el && /deployed|registered|treasury/i.test(el.textContent ?? "") + ); + }, + null, + { timeout: 180_000 }, + ); + await this.waitForNoBusyButtons(); + } + + // ---- Overview --------------------------------------------------------------- + + async fundTreasury(amountXlm = "20"): Promise { + await this.page.goto("/#overview"); + await this.page.waitForLoadState("networkidle"); + + const toggle = this.page + .getByRole("button", { name: /fund/i }) + .filter({ hasText: /^(\+\s*)?Fund$/i }) + .first(); + await toggle.waitFor({ state: "visible" }); + await toggle.click(); + + const input = this.page.getByLabel(/fund amount.*xlm/i).first(); + await input.waitFor({ state: "visible" }); + await input.fill(amountXlm); + + const fundBtn = this.page + .getByRole("button", { name: /^fund(ing…)?$/i }) + .nth(1); + await fundBtn.click(); + + await this.page.waitForFunction( + () => { + const el = document.querySelector('[data-toast-kind="success"]'); + return !!el && /funded/i.test(el.textContent ?? ""); + }, + null, + { timeout: 120_000 }, + ); + await this.waitForNoBusyButtons(); + } + + // ---- Payments --------------------------------------------------------------- + + async goToPayments(): Promise { + await this.page.goto("/#payments"); + await this.page.waitForLoadState("networkidle"); + } + + async switchToPayeesTab(): Promise { + const tab = this.page.getByRole("button", { name: /^payees/i }).first(); + await tab.waitFor({ state: "visible" }); + await tab.click(); + } + + async switchToSendTab(): Promise { + const tab = this.page.getByRole("button", { name: /^send$/i }).first(); + await tab.waitFor({ state: "visible" }); + await tab.click(); + } + + async whitelistPayee(address: string): Promise { + await this.goToPayments(); + await this.switchToPayeesTab(); + + const input = this.page.getByLabel(/^payee address$/i).first(); + await input.waitFor({ state: "visible" }); + await input.fill(address); + + const addBtn = this.page + .getByRole("button", { name: /^add payee/i }) + .first(); + await addBtn.click(); + + await this.page.waitForFunction( + () => { + const el = document.querySelector('[data-toast-kind="success"]'); + return !!el && /whitelisted|payee/i.test(el.textContent ?? ""); + }, + null, + { timeout: 120_000 }, + ); + await this.waitForNoBusyButtons(); + } + + async sendPayment(to: string, amountXlm: string): Promise { + await this.goToPayments(); + await this.switchToSendTab(); + + const toInput = this.page + .getByLabel(/payment destination address/i) + .first(); + await toInput.waitFor({ state: "visible" }); + await toInput.fill(to); + + const amtInput = this.page.getByLabel(/payment amount.*xlm/i).first(); + await amtInput.fill(amountXlm); + + const sendBtn = this.page + .getByRole("button", { name: /^send payment/i }) + .first(); + await sendBtn.click(); + + await this.page.waitForFunction( + () => { + const el = document.querySelector('[data-toast-kind="success"]'); + return !!el && /settled|payment/i.test(el.textContent ?? ""); + }, + null, + { timeout: 120_000 }, + ); + await this.waitForNoBusyButtons(); + } + + async readBalanceStroops(): Promise { + await this.page.goto("/#overview"); + await this.page.waitForLoadState("networkidle"); + const bal = this.page.locator(".ov__balance").first(); + await bal.waitFor({ state: "visible" }); + const text = (await bal.textContent()) ?? ""; + const match = text.match(/([\d][\d.]*)/); + if (!match) throw new Error(`Could not read balance from: "${text}"`); + const xlm = Number(match[1]); + if (!Number.isFinite(xlm)) + throw new Error(`Balance parse error: ${match[1]}`); + return BigInt(Math.round(xlm * 10_000_000)); + } + + async assertBalanceGt(minStroops: bigint): Promise { + const b = await this.readBalanceStroops(); + if (b <= minStroops) { + throw new Error(`Balance ${b} stroops ≤ ${minStroops} minimum`); + } + } + + async assertWalletChipShows(address: string): Promise { + const short = `${address.slice(0, 4)}…${address.slice(-4)}`; + await expect(this.page.getByText(short)).toBeVisible({ timeout: 15_000 }); + } +} + +// Re-export expect so the spec gets the same Playwright instance. +export { expect } from "@playwright/test"; diff --git a/web/tests/e2e/smoke.spec.ts b/web/tests/e2e/smoke.spec.ts new file mode 100644 index 0000000..821499f --- /dev/null +++ b/web/tests/e2e/smoke.spec.ts @@ -0,0 +1,78 @@ +import { test as base } from "@playwright/test"; +import { + getTestSigner, + injectTestSigner, + PrismPage, + expect, +} from "./prismTest"; +import { SERVICE } from "../../src/config"; + +const test = base.extend<{ + prism: PrismPage; + signer: ReturnType; +}>({ + signer: async ({}, use) => { + const s = getTestSigner(); + await use(s); + }, + + context: async ({ context, signer }, use) => { + await injectTestSigner(context, signer); + await use(context); + }, + + prism: async ({ page }, use) => { + await use(new PrismPage(page)); + }, +}); + +test.describe + .serial("Smoke: landing → connect → deploy → fund → whitelist → pay", () => { + test("the full happy path succeeds against testnet", async ({ + prism, + signer, + }) => { + test.info().annotations.push({ + type: "network", + description: + "Runs against Stellar testnet — needs funded PLAYWRIGHT_TEST_WALLET_SECRET", + }); + + const pageErrors: string[] = []; + prism.page.on("pageerror", (e) => { + pageErrors.push(String(e)); + }); + + // 1. Landing loads without console errors. + await prism.assertNoConsoleErrorsOnLanding(); + + // 2. Navigate to workspace (shell / connect gate). + await prism.gotoWorkspace(); + + // 3. Connect via the injected test signer — no wallet modal. + await prism.connectWallet(); + await prism.assertWalletChipShows(signer.address); + + // 4. Friendbot top-up if the throwaway wallet has < MIN_XLM. + await prism.friendbotIfNeeded(); + + // 5. Deploy a treasury with default limits. + await prism.deployTreasury("50", "10"); + + // 6. Fund the treasury. + await prism.fundTreasury("20"); + await prism.assertBalanceGt(15n * 10_000_000n); + + // 7. Whitelist the sample payee. + await prism.whitelistPayee(SERVICE); + + // 8. Pay 1 XLM within the per-task + daily limits. + await prism.sendPayment(SERVICE, "1"); + + // 9. On-chain state assertions — balance decreased; day-spent advanced. + const balAfter = await prism.readBalanceStroops(); + expect(balAfter).toBeLessThanOrEqual(19n * 10_000_000n); + + expect(pageErrors).toEqual([]); + }); +});