Skip to content
Merged
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
56 changes: 56 additions & 0 deletions .github/workflows/e2e-playwright.yml
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=<public key>.
PLAYWRIGHT_TEST_WALLET_SECRET=
5 changes: 5 additions & 0 deletions web/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/

86 changes: 86 additions & 0 deletions web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<public> 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:
Expand Down
3 changes: 3 additions & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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",
Expand Down
35 changes: 35 additions & 0 deletions web/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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",
},
},
});
66 changes: 66 additions & 0 deletions web/src/lib/testSigner.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading