Skip to content
Open
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
47 changes: 47 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: E2E

on:
pull_request:
branches:
- main
workflow_dispatch:

permissions:
contents: read

concurrency:
group: e2e-${{ github.ref }}
cancel-in-progress: true

jobs:
playwright:
name: Playwright E2E
runs-on: ubuntu-latest
timeout-minutes: 20

steps:
- name: Checkout
uses: actions/checkout@v7

- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 20
cache: npm

- name: Install dependencies
run: npm ci

- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium

- name: Run Playwright tests
run: npm run test:e2e -- --project=chromium --reporter=html

- name: Upload Playwright report
uses: actions/upload-artifact@v6
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 14
3 changes: 3 additions & 0 deletions components/sidebar-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -790,7 +790,10 @@ export function SidebarPanel({
{SIDEBAR_TABS.map(tab => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
aria-pressed={activeTab === tab.id}
aria-label={`${tab.label} tab`}
style={{
flex: 1,
minHeight: variant === "mobile" ? 46 : undefined,
Expand Down
25 changes: 25 additions & 0 deletions e2e/admin-passport.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { expect, test } from '@playwright/test';

test.describe('admin passport flow', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/admin');
});

test('opens the passport tab with mint, verification, payment, and replay controls', async ({ page }) => {
await page.getByRole('button', { name: /Agent Passport/i }).click();

await expect(page.getByText('Zero-knowledge trust layer')).toBeVisible();
await expect(page.getByText('Mint passport')).toBeVisible();
await expect(page.getByRole('button', { name: /Generate proof/i })).toBeVisible();

await expect(page.getByText('Simulate on-chain verification')).toBeVisible();
await expect(page.getByRole('button', { name: /Simulate verification/i })).toBeDisabled();

await expect(page.getByText('Authorize x402 payment')).toBeVisible();
await expect(page.getByRole('button', { name: /Request payment/i })).toBeDisabled();

await expect(page.getByText('Replay attack')).toBeVisible();
await expect(page.getByRole('button', { name: /Replay spent proof/i })).toBeVisible();
await expect(page.getByText('Passport collection')).toBeVisible();
});
});
26 changes: 26 additions & 0 deletions e2e/agent-wallet.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { expect, test } from '@playwright/test';

test.describe('agent selection and wallet surface', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem('onboarding-seen', '1');
});
await page.goto('/');
});

test('selects an agent from the canvas and keeps the wallet workflow reachable', async ({ page }) => {
const cityCanvas = page.getByRole('listbox', { name: 'Agents on city canvas' });
const nexusAgent = cityCanvas.getByRole('option', { name: /Nexus-7/i });

await nexusAgent.click();
await expect(nexusAgent).toHaveAttribute('aria-selected', 'true');

const walletTab = page.getByRole('button', { name: 'Wallet tab', exact: true });
await walletTab.click();
await expect(walletTab).toHaveAttribute('aria-pressed', 'true');

await expect(
page.getByText(/Checking Freighter wallet|Freighter Not Detected|Freighter detected|Connect Freighter Wallet|Get Freighter/i).first(),
).toBeVisible();
});
});
60 changes: 60 additions & 0 deletions e2e/onboarding-modal.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { expect, test } from '@playwright/test';

test.describe('onboarding modal', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.clear();
});
});

test('shows the first-visit tour with the expected first step', async ({ page }) => {
await page.goto('/');

await expect(page.getByText('Step 1 of 3')).toBeVisible();
await expect(page.getByText('Agent City')).toBeVisible();
await expect(page.getByText('The canvas shows your AI agents roaming a pixel city.')).toBeVisible();
await expect(page.getByRole('button', { name: 'Next', exact: true })).toBeVisible();
});

test('steps through all onboarding panels', async ({ page }) => {
await page.goto('/');

await expect(page.locator('text=Agent City')).toBeVisible();

const nextButton = page.getByRole('button', { name: 'Next', exact: true });
await nextButton.click();

await expect(page.locator('text=Sidebar Controls')).toBeVisible();
await expect(page.getByText('The sidebar has four tabs')).toBeVisible();
await nextButton.click();

await expect(page.locator('text=Admin Console')).toBeVisible();
await expect(page.getByText('Visit /admin to manage ZK passports')).toBeVisible();
await expect(page.getByRole('button', { name: /get started/i })).toBeVisible();
});

test('persists completion after Get started', async ({ page }) => {
await page.goto('/');

const nextButton = page.getByRole('button', { name: 'Next', exact: true });
await nextButton.click();
await nextButton.click();

await page.getByRole('button', { name: /get started/i }).click();

await expect(page.locator('text=Agent City')).not.toBeVisible();
await expect(page.evaluate(() => localStorage.getItem('onboarding-seen'))).resolves.toBe('1');

await page.reload();
await expect(page.getByText('Step 1 of 3')).not.toBeVisible();
});

test('allows skipping the tour', async ({ page }) => {
await page.goto('/');

await page.getByRole('button', { name: /skip/i }).click();

await expect(page.getByText('Step 1 of 3')).not.toBeVisible();
await expect(page.evaluate(() => localStorage.getItem('onboarding-seen'))).resolves.toBe('1');
});
});
61 changes: 61 additions & 0 deletions e2e/sidebar-tabs.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { expect, test } from '@playwright/test';

test.describe('sidebar tab persistence', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem('onboarding-seen', '1');
});
await page.goto('/');
});

const tabButton = (page: Parameters<Parameters<typeof test>[2]>[0]['page'], tab: string) =>
page.getByRole('button', { name: `${tab} tab`, exact: true });

test('shows all desktop sidebar tabs', async ({ page }) => {
const tabs = ['Overview', 'Chat', 'Offers', 'Skills', 'Quests', 'Wallet', 'Appearance'];

for (const tab of tabs) {
await expect(tabButton(page, tab)).toBeVisible();
}
});

test('defaults to the Overview tab', async ({ page }) => {
const overviewTab = tabButton(page, 'Overview');

await expect(overviewTab).toHaveAttribute('aria-pressed', 'true');
await expect(page.getByText('City Overview')).toBeVisible();
});

test('switches tabs and updates active state', async ({ page }) => {
await tabButton(page, 'Chat').click();
await expect(tabButton(page, 'Chat')).toHaveAttribute('aria-pressed', 'true');

await tabButton(page, 'Skills').click();
await expect(tabButton(page, 'Skills')).toHaveAttribute('aria-pressed', 'true');
await expect(page.evaluate(() => localStorage.getItem('sidebar-tab'))).resolves.toBe('skills');
});

test('persists the selected tab after reload', async ({ page }) => {
await tabButton(page, 'Wallet').click();
await expect(tabButton(page, 'Wallet')).toHaveAttribute('aria-pressed', 'true');

await page.reload();

await expect(tabButton(page, 'Wallet')).toHaveAttribute('aria-pressed', 'true');
});

test('stores each tab selection in localStorage', async ({ page }) => {
const tabs = [
['Chat', 'chat'],
['Offers', 'offers'],
['Skills', 'skills'],
['Quests', 'quests'],
['Wallet', 'wallet'],
] as const;

for (const [label, storedValue] of tabs) {
await tabButton(page, label).click();
await expect(page.evaluate(() => localStorage.getItem('sidebar-tab'))).resolves.toBe(storedValue);
}
});
});
63 changes: 63 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
"secretlint": "secretlint \"**/*\"",
"size-limit": "size-limit",
"test": "vitest run",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"deploy:evm:guide": "node scripts/deploy/evm/guide.mjs",
Expand Down Expand Up @@ -89,6 +92,7 @@
"zod": "^3.24.1"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@secretlint/secretlint-rule-preset-recommend": "^12.0.0",
"@size-limit/file": "^12.1.0",
"@tailwindcss/postcss": "^4.2.0",
Expand Down
34 changes: 34 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { defineConfig, devices } from '@playwright/test';

/**
* Playwright E2E configuration for Open-Stellar
* Tests critical user flows with mocked wallet/payment interactions
*/
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? 'html' : 'list',

use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},

projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],

webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
});
2 changes: 1 addition & 1 deletion vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export default defineConfig({
globals: true,
// The create-app template is a scaffold compiled inside generated apps, not here;
// its imports (@/lib/agents/my-first-agent, ...) don't resolve in this repo.
exclude: ["**/node_modules/**", "**/dist/**", "packages/create-app/template/**"],
exclude: ["**/node_modules/**", "**/dist/**", "packages/create-app/template/**", "e2e/**"],
coverage: {
provider: "v8",
reporter: ["text", "lcov"],
Expand Down
Loading