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
78 changes: 74 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# ─────────────────────────────────────────────────────────────────────────────
# CrewForm — CI Pipeline
# ─────────────────────────────────────────────────────────────────────────────
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (C) 2026 CrewForm

Expand All @@ -10,6 +13,7 @@ on:
branches: [main]

jobs:
# ── Frontend: Build, Lint, Test ─────────────────────────────────────────
build-and-test:
name: Build, Lint & Test
runs-on: ubuntu-latest
Expand All @@ -25,22 +29,87 @@ jobs:
cache: 'npm'

- name: Install dependencies
run: |
npm ci
cd task-runner && npm ci
run: npm ci

- name: Lint
run: npm run lint

- name: Type check
run: npx tsc --noEmit

- name: Test
- name: Unit tests
run: npm run test -- --run

- name: Build
run: npm run build

# ── Task Runner: Lint & Test ────────────────────────────────────────────
task-runner-test:
name: Task Runner — Lint & Test
runs-on: ubuntu-latest

defaults:
run:
working-directory: task-runner

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

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: task-runner/package-lock.json

- name: Install dependencies
run: npm ci

- name: Type check
run: npx tsc --noEmit

- name: Unit tests
run: npx vitest run

# ── E2E: Playwright (manual trigger) ────────────────────────────────────
e2e:
name: E2E — Playwright
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch'

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

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'

- name: Install dependencies
run: npm ci

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

- name: Run Playwright tests
run: npx playwright test
env:
E2E_EMAIL: ${{ secrets.E2E_EMAIL }}
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
E2E_BASE_URL: ${{ secrets.E2E_BASE_URL }}

- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 14

# ── Sensitive Doc Guard ─────────────────────────────────────────────────
doc-guard:
name: Sensitive Doc Guard
runs-on: ubuntu-latest
Expand All @@ -64,6 +133,7 @@ jobs:
fi
echo "✅ No sensitive docs found — all clear"

# ── AGPL Licence Header Check ───────────────────────────────────────────
licence-check:
name: AGPL Licence Headers
runs-on: ubuntu-latest
Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ dist-ssr
# Environment
.env
.env.local

# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
e2e/.auth/
.env.development.local
.env.test.local
.env.production.local
Expand Down
71 changes: 71 additions & 0 deletions e2e/agents.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 CrewForm

/**
* E2E — Critical path 2: Agent CRUD
*
* Verifies: create agent → appears in list → edit → delete
*/

import { test, expect } from '@playwright/test'

const AGENT_NAME = `E2E Test Agent ${Date.now()}`
const AGENT_NAME_EDITED = `${AGENT_NAME} (edited)`

test.describe('Agent CRUD', () => {
test.describe.configure({ mode: 'serial' })

test('create a new agent', async ({ page }) => {
await page.goto('/agents')

// Click create button
await page.getByRole('button', { name: /create|new|add/i }).click()

// Fill in agent form
await page.getByLabel(/name/i).fill(AGENT_NAME)
await page.getByLabel(/description/i).fill('E2E test agent — safe to delete')

// Submit
await page.getByRole('button', { name: /save|create|submit/i }).click()

// Agent should appear in the list
await expect(page.getByText(AGENT_NAME)).toBeVisible({ timeout: 10_000 })
})

test('edit the agent', async ({ page }) => {
await page.goto('/agents')

// Find and click on the agent
await page.getByText(AGENT_NAME).click()

// Edit name
const nameInput = page.getByLabel(/name/i)
await nameInput.clear()
await nameInput.fill(AGENT_NAME_EDITED)

// Save
await page.getByRole('button', { name: /save|update/i }).click()

// Updated name should be visible
await expect(page.getByText(AGENT_NAME_EDITED)).toBeVisible({ timeout: 10_000 })
})

test('delete the agent', async ({ page }) => {
await page.goto('/agents')

// Find the agent
await page.getByText(AGENT_NAME_EDITED).click()

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

// Confirm deletion if there's a confirmation dialog
const confirmBtn = page.getByRole('button', { name: /confirm|yes|delete/i })
if (await confirmBtn.isVisible({ timeout: 2_000 }).catch(() => false)) {
await confirmBtn.click()
}

// Agent should no longer be in the list
await expect(page.getByText(AGENT_NAME_EDITED)).not.toBeVisible({ timeout: 10_000 })
})
})
34 changes: 34 additions & 0 deletions e2e/auth.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 CrewForm

/**
* Auth setup — creates and saves authenticated browser state.
*
* This runs once before all other tests. The storage state is reused
* by all test projects so we don't log in for every spec.
*
* Requires E2E_EMAIL and E2E_PASSWORD env vars (or defaults for local dev).
*/

import { test as setup, expect } from '@playwright/test'

const AUTH_FILE = 'e2e/.auth/user.json'

setup('authenticate', async ({ page }) => {
const email = process.env.E2E_EMAIL ?? 'test@crewform.local'
const password = process.env.E2E_PASSWORD ?? 'testpassword123'

// Navigate to login page
await page.goto('/login')

// Fill in credentials
await page.getByLabel('Email').fill(email)
await page.getByLabel('Password').fill(password)
await page.getByRole('button', { name: /sign in|log in/i }).click()

// Wait for redirect to dashboard
await expect(page).toHaveURL(/\/(dashboard)?$/, { timeout: 10_000 })

// Save authenticated state
await page.context().storageState({ path: AUTH_FILE })
})
37 changes: 37 additions & 0 deletions e2e/login.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 CrewForm

/**
* E2E — Critical path 1: Login flow
*
* Verifies: login → dashboard loads → sidebar navigation visible
*/

import { test, expect } from '@playwright/test'

test.describe('Login & Dashboard', () => {
test('dashboard loads with sidebar navigation', async ({ page }) => {
await page.goto('/')

// Should be on dashboard (auth setup already logged us in)
await expect(page).toHaveURL(/\/(dashboard)?$/)

// Sidebar should be visible with key navigation items
const sidebar = page.locator('nav, [role="navigation"]').first()
await expect(sidebar).toBeVisible()

// Key nav items should be present
await expect(page.getByText('Dashboard')).toBeVisible()
await expect(page.getByText('Agents')).toBeVisible()
await expect(page.getByText('Tasks')).toBeVisible()
})

test('dashboard shows analytics section', async ({ page }) => {
await page.goto('/')

// Analytics cards or charts should load
await expect(
page.getByText(/total|agents|tasks|usage/i).first(),
).toBeVisible({ timeout: 10_000 })
})
})
98 changes: 98 additions & 0 deletions e2e/marketplace.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 CrewForm

/**
* E2E — Critical path 4: Marketplace
*
* Verifies: browse → search → filter tags → view detail modal → install
*/

import { test, expect } from '@playwright/test'

test.describe('Marketplace', () => {
test('browse marketplace and see agent cards', async ({ page }) => {
await page.goto('/marketplace')

// Should see the marketplace heading
await expect(page.getByRole('heading', { name: /marketplace/i })).toBeVisible()

// Agent cards should load
await expect(
page.locator('button').filter({ hasText: /installs/i }).first(),
).toBeVisible({ timeout: 10_000 })
})

test('search filters agents', async ({ page }) => {
await page.goto('/marketplace')

// Wait for cards to load
await expect(
page.locator('button').filter({ hasText: /installs/i }).first(),
).toBeVisible({ timeout: 10_000 })

// Count initial cards
const initialCount = await page.locator('button').filter({ hasText: /installs/i }).count()

// Type in search
await page.getByPlaceholder(/search/i).fill('code')

// Wait for results to update
await page.waitForTimeout(500) // debounce

// Should have fewer or same cards (filtered)
const filteredCount = await page.locator('button').filter({ hasText: /installs/i }).count()
expect(filteredCount).toBeLessThanOrEqual(initialCount)
})

test('click tag pill filters results', async ({ page }) => {
await page.goto('/marketplace')

// Wait for tags to load
await expect(
page.locator('button').filter({ hasText: /installs/i }).first(),
).toBeVisible({ timeout: 10_000 })

// Click a tag pill
const tagPill = page.locator('button').filter({ hasText: /coding|research|writing/i }).first()
if (await tagPill.isVisible({ timeout: 3_000 }).catch(() => false)) {
await tagPill.click()
// Results should update (at least some cards visible)
await page.waitForTimeout(500)
}
})

test('open agent detail modal', async ({ page }) => {
await page.goto('/marketplace')

// Wait for cards to load
await expect(
page.locator('button').filter({ hasText: /installs/i }).first(),
).toBeVisible({ timeout: 10_000 })

// Click the first agent card
await page.locator('button').filter({ hasText: /installs/i }).first().click()

// Modal should open with agent details
await expect(page.getByText(/system prompt/i)).toBeVisible({ timeout: 5_000 })
await expect(page.getByRole('button', { name: /install agent/i })).toBeVisible()
})

test('close detail modal', async ({ page }) => {
await page.goto('/marketplace')

// Open modal
await expect(
page.locator('button').filter({ hasText: /installs/i }).first(),
).toBeVisible({ timeout: 10_000 })
await page.locator('button').filter({ hasText: /installs/i }).first().click()

// Wait for modal
await expect(page.getByText(/system prompt/i)).toBeVisible({ timeout: 5_000 })

// Close modal via X button
await page.locator('button').filter({ has: page.locator('svg') }).first().click()

// Modal should close — system prompt should not be visible
await expect(page.getByText(/system prompt/i)).not.toBeVisible({ timeout: 3_000 })
})
})
Loading
Loading