diff --git a/.github/workflows/examples-smoke-test.yml b/.github/workflows/examples-smoke-test.yml new file mode 100644 index 000000000..cb7204eb1 --- /dev/null +++ b/.github/workflows/examples-smoke-test.yml @@ -0,0 +1,156 @@ +name: Examples Smoke Test + +on: + pull_request: + types: [labeled, opened, reopened, synchronize] + branches: [main] + +permissions: + contents: read + pull-requests: write + +# Supported examples and their serve configuration. +env: + EXAMPLES_CONFIG: > + { + "vite-project": { "dir": "vite", "command": "preview", "port": 4173 }, + "connectkit": { "dir": "connectkit", "command": "preview", "port": 4173 }, + "privy": { "dir": "privy", "command": "preview", "port": 4173 }, + "privy-ethers-example": { "dir": "privy-ethers", "command": "preview", "port": 4173 }, + "rainbowkit": { "dir": "rainbowkit", "command": "preview", "port": 4173 }, + "reown": { "dir": "reown", "command": "preview", "port": 4173 }, + "svelte": { "dir": "svelte", "command": "preview", "port": 4173 }, + "zustand-widget-config": { "dir": "zustand-widget-config", "command": "preview", "port": 4173 }, + "nextjs": { "dir": "nextjs", "command": "start", "port": 3000 }, + "nextjs15": { "dir": "nextjs15", "command": "start", "port": 3000 } + } + +jobs: + detect-changes: + name: Detect affected examples + if: contains(github.event.pull_request.labels.*.name, 'check-examples') + runs-on: ubuntu-latest + timeout-minutes: 2 + outputs: + matrix: ${{ steps.detect.outputs.matrix }} + has-examples: ${{ steps.detect.outputs.has-examples }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Detect affected examples + id: detect + run: | + BASE_SHA=${{ github.event.pull_request.base.sha }} + HEAD_SHA=${{ github.event.pull_request.head.sha }} + + CHANGED_FILES=$(git diff --name-only "$BASE_SHA"..."$HEAD_SHA") + + COMPATIBLE=$(echo '${{ env.EXAMPLES_CONFIG }}' | jq -r 'keys[]') + + # Check if shared dependencies changed + SHARED_CHANGED=false + while IFS= read -r file; do + case "$file" in + packages/widget/*|packages/wallet-management/*|packages/widget-provider/*|packages/widget-provider-*/*|packages/widget-playground/*) + SHARED_CHANGED=true ;; + pnpm-workspace.yaml|pnpm-lock.yaml|package.json) + SHARED_CHANGED=true ;; + e2e/*) + SHARED_CHANGED=true ;; + esac + done <<< "$CHANGED_FILES" + + if [ "$SHARED_CHANGED" = "true" ]; then + AFFECTED=($COMPATIBLE) + else + AFFECTED=() + for name in $COMPATIBLE; do + DIR=$(echo '${{ env.EXAMPLES_CONFIG }}' | jq -r --arg name "$name" '.[$name].dir') + if echo "$CHANGED_FILES" | grep -q "^examples/${DIR}/"; then + AFFECTED+=("$name") + fi + done + fi + + if [ ${#AFFECTED[@]} -eq 0 ]; then + echo "matrix=[]" >> "$GITHUB_OUTPUT" + echo "has-examples=false" >> "$GITHUB_OUTPUT" + echo "No affected examples detected." + else + JSON=$(printf '%s\n' "${AFFECTED[@]}" | jq -R . | jq -sc .) + echo "matrix=$JSON" >> "$GITHUB_OUTPUT" + echo "has-examples=true" >> "$GITHUB_OUTPUT" + echo "Affected examples: $JSON" + fi + + smoke-test: + name: Smoke ${{ matrix.example }} + needs: detect-changes + if: needs.detect-changes.outputs.has-examples == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + example: ${{ fromJson(needs.detect-changes.outputs.matrix) }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install dependencies + uses: ./.github/actions/pnpm-install + + - name: Build workspace packages + run: pnpm -r --parallel --filter './packages/**' --filter !'*-playground-*' --filter !'*-embedded' build + + - name: Build example + run: | + set -euo pipefail + MATCH=$(pnpm --filter ${{ matrix.example }} exec pwd 2>/dev/null || true) + if [ -z "$MATCH" ]; then + echo "::error::No workspace package found matching filter '${{ matrix.example }}'" + exit 1 + fi + pnpm --filter ${{ matrix.example }} build + + - name: Resolve serve command and port + id: serve + run: | + CONFIG=$(echo '${{ env.EXAMPLES_CONFIG }}' | jq -r '.["${{ matrix.example }}"]') + echo "command=$(echo "$CONFIG" | jq -r '.command')" >> "$GITHUB_OUTPUT" + echo "port=$(echo "$CONFIG" | jq -r '.port')" >> "$GITHUB_OUTPUT" + + - name: Start example server + run: | + pnpm --filter ${{ matrix.example }} ${{ steps.serve.outputs.command }} & + + # Wait for server to be ready (max 60s) + URL="http://localhost:${{ steps.serve.outputs.port }}" + for i in $(seq 1 60); do + if curl -sf "$URL" > /dev/null 2>&1; then + echo "Server ready at $URL after ${i}s" + exit 0 + fi + sleep 1 + done + echo "::error::Server failed to start within 60s at $URL" + exit 1 + + - name: Install Playwright browsers + run: pnpm --filter @lifi/widget-e2e exec playwright install chromium --with-deps + + - name: Run @example smoke tests + env: + BASE_URL: http://localhost:${{ steps.serve.outputs.port }} + run: pnpm --filter @lifi/widget-e2e exec playwright test --grep @example --reporter=list + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: playwright-report-${{ matrix.example }} + path: e2e/playwright-report/ + retention-days: 7 diff --git a/e2e/.env.test.example b/e2e/.env.test.example new file mode 100644 index 000000000..9a036bd84 --- /dev/null +++ b/e2e/.env.test.example @@ -0,0 +1,3 @@ +# Base URL for the widget playground (default: http://localhost:3000) +# Override to run against other environments +BASE_URL=http://localhost:3000 diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 000000000..f78748108 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +test-results/ +playwright-report/ +.auth/ +.env.test +*.local diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 000000000..49e858291 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,223 @@ +# LI.FI Widget — E2E Test Suite + +Playwright TypeScript E2E tests for the LI.FI Widget playground +(`packages/widget-playground-vite`). + +## Prerequisites + +- Node.js ≥ 18 +- pnpm ≥ 9 +- Widget playground running locally (`pnpm dev` from the repo root — serves on port 3000) + +## Setup + +The `e2e/` directory is a workspace member (`pnpm-workspace.yaml` includes `e2e`). +Dependencies are installed automatically when you run `pnpm install` at the repo root. + +```bash +# From the repo root — installs everything including e2e deps +pnpm install + +# Install Playwright browsers (once) +pnpm --filter @lifi/widget-e2e exec playwright install chromium +``` + +## Running Tests + +Tests can be run from the **repo root** or from the **e2e directory**. + +**From the repo root:** + +| Command | Description | +|---|---| +| `pnpm smoketest` | All smoke tests (playground + example) | +| `pnpm smoke:example` | Only `@example`-tagged tests (works against any example app) | + +**From the e2e directory:** + +| Command | Description | +|---|---| +| `pnpm smoketest` | All smoke tests | +| `pnpm smoke:example` | Only `@example`-tagged tests | +| `pnpm test` | Full test suite | +| `pnpm test:headed` | Run with visible browser | +| `pnpm test:debug` | Playwright debug inspector | +| `pnpm test:ui` | Playwright interactive UI | +| `pnpm typecheck` | TypeScript type-check (no emit) | +| `pnpm report` | Open last HTML report | + +**Running against an example app:** + +```bash +# Terminal 1 — build and serve an example +pnpm --filter vite-project build +pnpm --filter vite-project preview + +# Terminal 2 — run @example tests against it +cd e2e +BASE_URL=http://localhost:4173 pnpm smoke:example +``` + +## Environment Variables + +Copy `.env.test.example` to `.env.test` and adjust as needed: + +```bash +cp .env.test.example .env.test +``` + +| Variable | Default | Description | +|---|---|---| +| `BASE_URL` | `http://localhost:3000` | Playground URL — override for staging/prod runs | + +## Architecture + +### Pattern: Component Object Model (COM) + +The widget is a single-page component with internal navigation (TanStack Router) that +does **not** change the URL on view transitions. A traditional Page Object Model with +URL-based page boundaries doesn't apply. Instead, each widget *view* (Exchange, Token +Selector, Settings) has its own Component Object encapsulating its selectors and +interactions. + +``` +tests/ +├── fixtures/ +│ └── base.fixture.ts # Extends Playwright test with widget fixtures + waitForTokens() +├── components/ +│ ├── PlaygroundSidebar.ts # Left sidebar: Design/Code tabs, variant controls +│ ├── WidgetExchange.ts # Exchange view: From/To buttons, Settings icon, Send input +│ ├── TokenSelectorView.ts # Token list (listitem rows), search input, chain sidebar +│ └── SettingsView.ts # Settings rows, back navigation +└── smoke/ + └── smoke.spec.ts # Smoke tests for quick UI verification +``` + +### Selector Strategy + +| ✅ Use | ❌ Avoid | +|---|---| +| `getByRole('button', {name: '...'})` | CSS class names (MUI generates dynamic names) | +| `getByText()` for stable visible text | `id*="widget-"` suffix — varies per build session | +| `locator('[id^="widget-app-expanded-container"]')` for widget root | `locator('main')` — only exists in the playground, not in example apps | +| `locator('p', {hasText: /^Exchange$/})` for headings | `getByRole('paragraph')` — `
` has no implicit ARIA role | +| `getByRole('list').locator('listitem')` for token rows | Positional index assumptions in non-searched token lists | + +**Widget root selector:** The widget renders into `