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
40 changes: 40 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,43 @@ jobs:
yarn lint
yarn typecheck
yarn test --run

e2e:
runs-on: ubuntu-latest
needs: [test]
steps:
- uses: actions/checkout@v4

- name: Boot full Docker Compose stack
run: |
docker compose up -d --build
timeout 120 bash -c 'until curl -sf http://localhost:3000 > /dev/null 2>&1; do sleep 2; done'
timeout 60 bash -c 'until curl -sf http://localhost:8088/health > /dev/null 2>&1; do sleep 2; done'

- uses: actions/setup-node@v4
with:
node-version: '22'

- name: Enable Corepack and install deps
run: corepack enable

- name: Install frontend deps + Playwright
working-directory: frontend
run: |
yarn install --immutable
npx playwright install --with-deps chromium

- name: Run E2E tests
working-directory: frontend
run: npx playwright test --reporter=html

- name: Upload E2E report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: frontend/playwright-report/

- name: Teardown
if: always()
run: docker compose down -v
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,9 @@ demo.webm

docs/prd
backup
design
design

# Playwright
frontend/playwright-report/
frontend/test-results/
frontend/e2e/.auth/
50 changes: 50 additions & 0 deletions frontend/e2e/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { test, expect } from '@playwright/test';

test.describe('Authentication', () => {
test.use({ storageState: { cookies: [], origins: [] } }); // No pre-auth for login tests

test('signin page renders form fields', async ({ page }) => {
await page.goto('/signin');
await expect(page.locator('input#email')).toBeVisible();
await expect(page.locator('input#password')).toBeVisible();
await expect(page.locator('button[type="submit"]')).toBeVisible();
await expect(page.locator('button[type="submit"]')).toHaveText(/Sign In/);
});

test('signin form submits without error', async ({ page }) => {
await page.goto('/signin');
await page.fill('input#email', 'e2e@test.local');
await page.fill('input#password', 'e2e-test-password-123');
await page.click('button[type="submit"]');
await page.waitForTimeout(3000);
// Should either redirect or show no error (no destructive error banner)
const errorBanner = page.locator('[class*="destructive"]');
const errorCount = await errorBanner.count();
expect(errorCount).toBe(0);
});

test('signin with invalid credentials shows error', async ({ page }) => {
await page.goto('/signin');
await page.fill('input#email', 'wrong@test.local');
await page.fill('input#password', 'wrong-password');
await page.click('button[type="submit"]');
// Should stay on signin page or show error
await page.waitForTimeout(2000);
await expect(page).toHaveURL(/\/signin/);
});
});

test.describe('Authenticated navigation', () => {
test('authenticated user sees TopBar with user menu', async ({ page }) => {
await page.goto('/projects');
await expect(page.locator('button[aria-label="User menu"]')).toBeVisible();
await expect(page.locator('button[aria-label="Toggle theme"]')).toBeVisible();
});

test('unauthenticated user is redirected to signin', async ({ page, context }) => {
await context.clearCookies();
await page.goto('/projects');
await page.waitForURL('**/signin', { timeout: 10000 });
await expect(page).toHaveURL(/\/signin/);
});
});
53 changes: 53 additions & 0 deletions frontend/e2e/chat-panel.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { test, expect } from '@playwright/test';

test.describe('Chat Panel', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/projects');
await page.waitForTimeout(1000);
const cards = page.locator('[class*="card" i], [class*="Card" i]').filter({ hasText: /section/ });
if (await cards.count() === 0) { test.skip(); return; }
await cards.first().click();
await page.waitForURL(/\/projects\/[^/]+$/, { timeout: 10000 });
});

test('chat panel renders if enabled', async ({ page }) => {
const chatInput = page.locator('textarea[placeholder="Type a message..."]');
const chatVisible = await chatInput.isVisible().catch(() => false);

if (chatVisible) {
// Chat is enabled — verify UI elements
await expect(chatInput).toBeVisible();
await expect(page.getByRole('button', { name: /Send/ })).toBeVisible();
}
// If chat is not visible, it's disabled in settings — that's valid too
});

test('send button is disabled when input is empty', async ({ page }) => {
const chatInput = page.locator('textarea[placeholder="Type a message..."]');
if (await chatInput.isVisible().catch(() => false)) {
const sendButton = page.getByRole('button', { name: /Send/ });
await expect(sendButton).toBeDisabled();
}
});

test('typing a message enables send button', async ({ page }) => {
const chatInput = page.locator('textarea[placeholder="Type a message..."]');
if (await chatInput.isVisible().catch(() => false)) {
await chatInput.fill('Hello test message');
const sendButton = page.getByRole('button', { name: /Send/ });
await expect(sendButton).toBeEnabled();
}
});

test('attach file button is present', async ({ page }) => {
const chatInput = page.locator('textarea[placeholder="Type a message..."]');
if (await chatInput.isVisible().catch(() => false)) {
const attachButton = page.getByRole('button', { name: /Attach/i }).or(
page.locator('button').filter({ hasText: /attach/i })
);
// Attach might be an icon-only button
const fileInput = page.locator('input[type="file"]');
await expect(fileInput).toBeAttached();
}
});
});
61 changes: 61 additions & 0 deletions frontend/e2e/dependencies.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { test, expect } from '@playwright/test';

test.describe('Dependencies View', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/projects');
await page.waitForTimeout(1000);
const cards = page.locator('[class*="card" i], [class*="Card" i]').filter({ hasText: /section/ });
if (await cards.count() === 0) { test.skip(); return; }
await cards.first().click();
await page.waitForURL(/\/projects\/[^/]+$/, { timeout: 10000 });
await page.getByRole('tab', { name: 'Dependencies' }).click();
});

test('dependencies tab renders content', async ({ page }) => {
const tabPanel = page.getByRole('tabpanel');
await expect(tabPanel).toBeVisible();
});

test('graph/list view toggle exists', async ({ page }) => {
await page.waitForTimeout(1000);
// Look for graph/list toggle buttons
const graphButton = page.getByRole('button', { name: /Graph/i });
const listButton = page.getByRole('button', { name: /List/i });

const hasToggle = (await graphButton.count()) > 0 || (await listButton.count()) > 0;
expect(hasToggle).toBeTruthy();
});

test('toggling between graph and list view works', async ({ page }) => {
await page.waitForTimeout(1000);
const graphButton = page.getByRole('button', { name: /Graph/i });
const listButton = page.getByRole('button', { name: /List/i });

if (await graphButton.isVisible()) {
await graphButton.click();
await page.waitForTimeout(500);
// Should see SVG graph or graph container
}

if (await listButton.isVisible()) {
await listButton.click();
await page.waitForTimeout(500);
// Should see list view
}
});

test('dependency legend is visible in graph view', async ({ page }) => {
await page.waitForTimeout(1000);
// Check for dependency type labels
const types = ['references', 'implements', 'blocks', 'extends'];
let foundAny = false;
for (const t of types) {
const el = page.getByText(new RegExp(t, 'i'));
if (await el.count() > 0) {
foundAny = true;
break;
}
}
expect(foundAny).toBeTruthy();
});
});
87 changes: 87 additions & 0 deletions frontend/e2e/global-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { chromium, type FullConfig } from '@playwright/test';

const BASE_URL = 'http://localhost:3000';
const API_URL = 'http://localhost:8088';
const TEST_USER = {
name: 'E2E Test User',
email: 'e2e@test.local',
password: 'e2e-test-password-123',
};

async function globalSetup(_config: FullConfig) {
// Step 1: Try bootstrap (creates first user + org on fresh DB)
const setupRes = await fetch(`${BASE_URL}/api/auth/setup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(TEST_USER),
});

if (setupRes.status === 200) {
console.log('Global setup: created first user via /api/auth/setup');
} else if (setupRes.status === 409) {
console.log('Global setup: bootstrap already done, ensuring test user exists...');
const signupRes = await fetch(`${BASE_URL}/api/auth/sign-up/email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: TEST_USER.name,
email: TEST_USER.email,
password: TEST_USER.password,
}),
});
if (signupRes.ok) {
console.log('Global setup: created test user via signup');
} else {
console.log('Global setup: test user likely already exists');
}
} else {
const body = await setupRes.text();
throw new Error(`Auth setup failed (${setupRes.status}): ${body}`);
}

// Step 2: Sign in via browser to get session cookie
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();

await page.goto(`${BASE_URL}/signin`);
await page.fill('input#email', TEST_USER.email);
await page.fill('input#password', TEST_USER.password);
await page.click('button[type="submit"]');
await page.waitForURL('**/projects', { timeout: 15000 });

// Step 3: Ensure test user has at least one project
// Create a test project if none exist (uses the authenticated session)
const projectsRes = await page.evaluate(async () => {
const res = await fetch('/api/projects');
return res.json();
});

if (!projectsRes || !Array.isArray(projectsRes) || projectsRes.length === 0) {
console.log('Global setup: no projects found, creating test project...');
await page.evaluate(async () => {
await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'E2E Test Project',
description: 'Automated test project for E2E testing',
template: 'saas-mvp',
}),
});
});
// Wait for project to be created
await page.waitForTimeout(2000);
console.log('Global setup: test project created');
} else {
console.log(`Global setup: ${projectsRes.length} project(s) already exist`);
}

// Step 4: Save authenticated state for all tests
await context.storageState({ path: './e2e/.auth/user.json' });
await browser.close();

console.log('Global setup: complete');
}

export default globalSetup;
Loading
Loading