Skip to content
Draft
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
156 changes: 156 additions & 0 deletions .github/workflows/examples-smoke-test.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions e2e/.env.test.example
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions e2e/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules/
test-results/
playwright-report/
.auth/
.env.test
*.local
223 changes: 223 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
@@ -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')` — `<p>` 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 `<div id="widget-app-expanded-container-{suffix}">`.
The suffix varies per build session, so always use the starts-with attribute selector:
`page.locator('[id^="widget-app-expanded-container"]')`. This selector works in both
the playground (`<main>` → widget) and any example app (`<div id="root">` → widget directly).
**Never use `locator('main')`** — it is playground-specific and will not match widget content
in example apps (`examples/vite`, `examples/next`, etc.).

**Key pitfall:** The widget header's back button and Settings button
are siblings. `querySelector('[id*="widget-header"] button')` grabs the first button
(often the wrong one). Use `getByRole('button', {name: 'Settings', exact: true})`.

### Token Selection

The widget fetches the token list on page load (not on selector open) via:

```
GET https://li.quest/v1/tokens?chainTypes=EVM,SVM,UTXO,MVM&...
```

Before interacting with the token selector, wait for this response to ensure the list is
populated. Use `waitForTokens(page)` exported from `base.fixture.ts`, paired with
`page.goto()` in a `Promise.all` so the response is never missed:

```typescript
import { waitForTokens } from '../fixtures/base.fixture.js'

await Promise.all([waitForTokens(page), page.goto('/')])
```

All items in the token list are real clickable token rows (each is a `listitem` containing
a `button`). Use `tokenSelector.selectFirstToken()` or `tokenSelector.selectTokenByIndex(n)`
from `TokenSelectorView`. After clicking a token the widget automatically navigates back
to the Exchange view — no explicit back-navigation needed.

> **Note:** The `@example` tests do **not** require `buildUrl: true` in widget configs.
> Never add `buildUrl: true` to example apps for testing purposes.

## Smoke Tests

All smoke tests run with `pnpm smoketest`.

| # | Test | What it verifies |
|---|---|---|
| 1 | Playground sidebar visible | LI.FI Widget heading · Design/Code tabs · Variant/Subvariant controls |
| 2 | Widget container displayed | Widget root (`[id^="widget-app-expanded-container"]`) visible · Exchange heading · From/To buttons · Send amount input |
| 3 | Settings accessible | Cog icon opens Settings · all setting rows visible · back returns to Exchange |
| 4 | Token route setup | Waits for token list API · opens From selector · selects first token · opens To selector · selects second token · Exchange view reflects both selections |

## Test Results

Results are written to `playwright-report/` (HTML) and `test-results/results.json`.

Failures include screenshot, video, and trace attachments in `test-results/`.

```bash
# View trace for a failing test
pnpm exec playwright show-trace test-results/<test-dir>/trace.zip
```

### Compatible (pass all @example tests)

| Example | Notes |
|---|---|
| vite | Reference target |
| connectkit | ✅ |
| nextjs | ✅ |
| nextjs15 | ✅ |
| privy | ✅ |
| privy-ethers | ✅ |
| rainbowkit | ✅ |
| reown | ✅ |
| svelte | ✅ |
| zustand-widget-config | ✅ after scoping `fromButton`/`toButton` to `widgetRoot` (sidebar had extra From/To buttons causing strict mode violations) |

### Incompatible — requires separate test approach

| Example | Reason |
|---|---|
| deposit-flow | `subvariant: 'custom'` renders "Deposit" heading, not "Exchange" |
| nft-checkout | `subvariant: 'custom'` renders NFT checkout UI, not "Exchange" |
| tanstack-router | Widget renders at `/widget` route, not `/` |
| vite-iframe | Widget inside `<iframe>` — requires `page.frameLocator()` |
| vite-iframe-wagmi | Same iframe isolation |
| vue | React-in-Vue wrapper (veaury) — widget root ID not found in DOM |
| dynamic | Requires wallet auth init before widget renders |

### Build or serve failures (not test issues)

| Example | Issue |
|---|---|
| nextjs14 | `@metamask/connect-evm` missing |
| nextjs14-page-router | Same missing dependency |
| nuxt | Rollup SSR polyfill error (`isIP` not exported) |
| react-router-7 | ESM directory import rejected (`use-sync-external-store/shim`) |
| remix | Same ESM issue |

### Skipped

| Example | Reason |
|---|---|
| nextjs-page-router | No `package.json` — empty directory |

---

## Known Findings / Notes

- **No `data-testid` attributes** exist in the widget codebase (only a handful of
`aria-label` attributes). All selectors rely on ARIA roles and visible text — this
means selector updates may be needed if i18n strings change.
- The widget's `<p>` heading elements (`Exchange`, `Settings`) have **no implicit ARIA
role** in Playwright's accessibility model. Use `locator('p', {hasText: ...})` instead
of `getByRole('paragraph')`.
- In Wide variant (default), the token selector opens a **second panel** (chain sidebar)
to the right. The first `getByRole('list')` is the token list; the second is the
chain sidebar.
- The token list (without a wallet connection) contains only real clickable token rows —
no section headers like "Pinned tokens" / "All tokens". Clicking by index (0 = first,
1 = second, …) is safe. Tokens are ordered by 24 h volume descending.
Loading