From ccd501ea02f35c6adb6378860c8c214d100ec93e Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 08:00:08 -0500 Subject: [PATCH] feat: add Playwright e2e tests for onboarding wizard flow Scaffolds end-to-end testing with Playwright for the onboarding wizard. Runs in skip_llm mode by default (UI-only, no LLM costs). Set PAPERCLIP_E2E_SKIP_LLM=false for full heartbeat verification. - tests/e2e/playwright.config.ts: Playwright config with webServer - tests/e2e/onboarding.spec.ts: 4-step wizard flow test - .github/workflows/e2e.yml: manual workflow_dispatch CI workflow - package.json: test:e2e and test:e2e:headed scripts Co-Authored-By: Claude Opus 4.6 --- .github/workflows/e2e.yml | 44 +++++++++ .gitignore | 6 +- package.json | 5 +- pnpm-lock.yaml | 47 +++++++-- tests/e2e/onboarding.spec.ts | 172 +++++++++++++++++++++++++++++++++ tests/e2e/playwright.config.ts | 35 +++++++ 6 files changed, 298 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 tests/e2e/onboarding.spec.ts create mode 100644 tests/e2e/playwright.config.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..8d154627 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,44 @@ +name: E2E Tests + +on: + workflow_dispatch: + inputs: + skip_llm: + description: "Skip LLM-dependent assertions (default: true)" + type: boolean + default: true + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + PAPERCLIP_E2E_SKIP_LLM: ${{ inputs.skip_llm && 'true' || 'false' }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - run: pnpm install --frozen-lockfile + - run: pnpm build + - run: npx playwright install --with-deps chromium + + - name: Run e2e tests + run: pnpm run test:e2e + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: | + tests/e2e/playwright-report/ + tests/e2e/test-results/ + retention-days: 14 diff --git a/.gitignore b/.gitignore index 9d9f5e35..066fcc68 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,8 @@ tmp/ *.tmp .vscode/ .claude/settings.local.json -.paperclip-local/ \ No newline at end of file +.paperclip-local/ + +# Playwright +tests/e2e/test-results/ +tests/e2e/playwright-report/ \ No newline at end of file diff --git a/package.json b/package.json index 45c02b8b..e19fb785 100644 --- a/package.json +++ b/package.json @@ -25,10 +25,13 @@ "docs:dev": "cd docs && npx mintlify dev", "smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh", "smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh", - "smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh" + "smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh", + "test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts", + "test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed" }, "devDependencies": { "@changesets/cli": "^2.30.0", + "@playwright/test": "^1.58.2", "esbuild": "^0.27.3", "typescript": "^5.7.3", "vitest": "^3.0.5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff4f3e35..f2885e8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@changesets/cli': specifier: ^2.30.0 version: 2.30.0(@types/node@25.2.3) + '@playwright/test': + specifier: ^1.58.2 + version: 1.58.2 esbuild: specifier: ^0.27.3 version: 0.27.3 @@ -35,9 +38,6 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local - '@paperclipai/adapter-openclaw': - specifier: workspace:* - version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -261,9 +261,6 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local - '@paperclipai/adapter-openclaw': - specifier: workspace:* - version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -379,9 +376,6 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local - '@paperclipai/adapter-openclaw': - specifier: workspace:* - version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -1696,6 +1690,11 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@radix-ui/colors@3.0.0': resolution: {integrity: sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==} @@ -4012,6 +4011,11 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4798,6 +4802,16 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -7394,6 +7408,10 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@radix-ui/colors@3.0.0': {} '@radix-ui/number@1.1.1': {} @@ -9872,6 +9890,9 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -10923,6 +10944,14 @@ snapshots: mlly: 1.8.1 pathe: 2.0.3 + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + points-on-curve@0.2.0: {} points-on-path@0.2.1: diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts new file mode 100644 index 00000000..f1dbd0f5 --- /dev/null +++ b/tests/e2e/onboarding.spec.ts @@ -0,0 +1,172 @@ +import { test, expect } from "@playwright/test"; + +/** + * E2E: Onboarding wizard flow (skip_llm mode). + * + * Walks through the 4-step OnboardingWizard: + * Step 1 — Name your company + * Step 2 — Create your first agent (adapter selection + config) + * Step 3 — Give it something to do (task creation) + * Step 4 — Ready to launch (summary + open issue) + * + * By default this runs in skip_llm mode: we do NOT assert that an LLM + * heartbeat fires. Set PAPERCLIP_E2E_SKIP_LLM=false to enable LLM-dependent + * assertions (requires a valid ANTHROPIC_API_KEY). + */ + +const SKIP_LLM = process.env.PAPERCLIP_E2E_SKIP_LLM !== "false"; + +const COMPANY_NAME = `E2E-Test-${Date.now()}`; +const AGENT_NAME = "CEO"; +const TASK_TITLE = "E2E test task"; + +test.describe("Onboarding wizard", () => { + test("completes full wizard flow", async ({ page }) => { + // Navigate to root — should auto-open onboarding when no companies exist + await page.goto("/"); + + // If the wizard didn't auto-open (company already exists), click the button + const wizardHeading = page.locator("h3", { hasText: "Name your company" }); + const newCompanyBtn = page.getByRole("button", { name: "New Company" }); + + // Wait for either the wizard or the start page + await expect( + wizardHeading.or(newCompanyBtn) + ).toBeVisible({ timeout: 15_000 }); + + if (await newCompanyBtn.isVisible()) { + await newCompanyBtn.click(); + } + + // ----------------------------------------------------------- + // Step 1: Name your company + // ----------------------------------------------------------- + await expect(wizardHeading).toBeVisible({ timeout: 5_000 }); + await expect(page.locator("text=Step 1 of 4")).toBeVisible(); + + const companyNameInput = page.locator('input[placeholder="Acme Corp"]'); + await companyNameInput.fill(COMPANY_NAME); + + // Click Next + const nextButton = page.getByRole("button", { name: "Next" }); + await nextButton.click(); + + // ----------------------------------------------------------- + // Step 2: Create your first agent + // ----------------------------------------------------------- + await expect( + page.locator("h3", { hasText: "Create your first agent" }) + ).toBeVisible({ timeout: 10_000 }); + await expect(page.locator("text=Step 2 of 4")).toBeVisible(); + + // Agent name should default to "CEO" + const agentNameInput = page.locator('input[placeholder="CEO"]'); + await expect(agentNameInput).toHaveValue(AGENT_NAME); + + // Claude Code adapter should be selected by default + await expect( + page.locator("button", { hasText: "Claude Code" }).locator("..") + ).toBeVisible(); + + // Select the "Process" adapter to avoid needing a real CLI tool installed + await page.locator("button", { hasText: "Process" }).click(); + + // Fill in process adapter fields + const commandInput = page.locator('input[placeholder="e.g. node, python"]'); + await commandInput.fill("echo"); + const argsInput = page.locator( + 'input[placeholder="e.g. script.js, --flag"]' + ); + await argsInput.fill("hello"); + + // Click Next (process adapter skips environment test) + await page.getByRole("button", { name: "Next" }).click(); + + // ----------------------------------------------------------- + // Step 3: Give it something to do + // ----------------------------------------------------------- + await expect( + page.locator("h3", { hasText: "Give it something to do" }) + ).toBeVisible({ timeout: 10_000 }); + await expect(page.locator("text=Step 3 of 4")).toBeVisible(); + + // Clear default title and set our test title + const taskTitleInput = page.locator( + 'input[placeholder="e.g. Research competitor pricing"]' + ); + await taskTitleInput.clear(); + await taskTitleInput.fill(TASK_TITLE); + + // Click Next + await page.getByRole("button", { name: "Next" }).click(); + + // ----------------------------------------------------------- + // Step 4: Ready to launch + // ----------------------------------------------------------- + await expect( + page.locator("h3", { hasText: "Ready to launch" }) + ).toBeVisible({ timeout: 10_000 }); + await expect(page.locator("text=Step 4 of 4")).toBeVisible(); + + // Verify summary displays our created entities + await expect(page.locator("text=" + COMPANY_NAME)).toBeVisible(); + await expect(page.locator("text=" + AGENT_NAME)).toBeVisible(); + await expect(page.locator("text=" + TASK_TITLE)).toBeVisible(); + + // Click "Open Issue" + await page.getByRole("button", { name: "Open Issue" }).click(); + + // Should navigate to the issue page + await expect(page).toHaveURL(/\/issues\//, { timeout: 10_000 }); + + // ----------------------------------------------------------- + // Verify via API that entities were created + // ----------------------------------------------------------- + const baseUrl = page.url().split("/").slice(0, 3).join("/"); + + // List companies and find ours + const companiesRes = await page.request.get(`${baseUrl}/api/companies`); + expect(companiesRes.ok()).toBe(true); + const companies = await companiesRes.json(); + const company = companies.find( + (c: { name: string }) => c.name === COMPANY_NAME + ); + expect(company).toBeTruthy(); + + // List agents for our company + const agentsRes = await page.request.get( + `${baseUrl}/api/companies/${company.id}/agents` + ); + expect(agentsRes.ok()).toBe(true); + const agents = await agentsRes.json(); + const ceoAgent = agents.find( + (a: { name: string }) => a.name === AGENT_NAME + ); + expect(ceoAgent).toBeTruthy(); + expect(ceoAgent.role).toBe("ceo"); + expect(ceoAgent.adapterType).toBe("process"); + + // List issues for our company + const issuesRes = await page.request.get( + `${baseUrl}/api/companies/${company.id}/issues` + ); + expect(issuesRes.ok()).toBe(true); + const issues = await issuesRes.json(); + const task = issues.find( + (i: { title: string }) => i.title === TASK_TITLE + ); + expect(task).toBeTruthy(); + expect(task.assigneeAgentId).toBe(ceoAgent.id); + + if (!SKIP_LLM) { + // LLM-dependent: wait for the heartbeat to transition the issue + await expect(async () => { + const res = await page.request.get( + `${baseUrl}/api/issues/${task.id}` + ); + const issue = await res.json(); + expect(["in_progress", "done"]).toContain(issue.status); + }).toPass({ timeout: 120_000, intervals: [5_000] }); + } + }); +}); diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts new file mode 100644 index 00000000..5ae1b677 --- /dev/null +++ b/tests/e2e/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig } from "@playwright/test"; + +const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3100); +const BASE_URL = `http://127.0.0.1:${PORT}`; + +export default defineConfig({ + testDir: ".", + testMatch: "**/*.spec.ts", + timeout: 60_000, + retries: 0, + use: { + baseURL: BASE_URL, + headless: true, + screenshot: "only-on-failure", + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { browserName: "chromium" }, + }, + ], + // The webServer directive starts `paperclipai run` before tests. + // Expects `pnpm paperclipai` to be runnable from repo root. + webServer: { + command: `pnpm paperclipai run --yes`, + url: `${BASE_URL}/api/health`, + reuseExistingServer: !!process.env.CI, + timeout: 120_000, + stdout: "pipe", + stderr: "pipe", + }, + outputDir: "./test-results", + reporter: [["list"], ["html", { open: "never", outputFolder: "./playwright-report" }]], +});