feat: full plan execution - CI/CD, critical fixes, UX polish, secondary/advanced features, E2E + docs
Phase 0 (CI/CD): fix root typecheck to cover api+worker+web; reconcile migration story into idempotent db:migrate (db:sync + db:triggers); add Gitea Actions quality/deploy/smoke workflow; rewrite README/AGENTS/DEPLOY docs; add requireWorkspaceAccess + recordActivityForEntity conventions. Phase 1 (critical fixes): calendar delete + drag/resize DnD; canvas card CRUD + bulk save + debounced autosave; logout route; graph edge workspaceId derivation; real analytics endpoints (drop Math.random); task board droppable columns + reorder persistence; Tiptap notes editor with sanitized HTML rendering; remove insecure passkey auth; domain/owner scoping (IDOR) on all by-ID routes + search/ export/realtime scoping; command palette routing + agent mention fetch; agent activity SSE handler; graph fly-to with tracked positions. Phase 2 (UX polish): login on design system; Sonner toasts app-wide; shared Loading/Empty/Error state components; working density/sidebarPos/reduce-motion settings; Inter typography; consolidated status-colors lib; unified detail routes; dashboard sort/realtime/responsive fixes; mobile responsive; a11y (radiogroups, sanitized snippets, badge labels). Phase 3 (features): daily notes timezone fix + delete + autosave + mood/energy create; active-domain store + topbar picker; graph domain picker + navigable entity links; tag assign/remove UI + server-side tag filter; real CSV export + import validation; custom fields on tasks. Phase 4 (advanced): migrate job worker into apps/worker (webhook delivery with HMAC, recurring spawn, ai_dispatch disabled); webhook queue helper + entity event enqueuing + test endpoint fix; recurring scheduledJobs pipeline; agents CRUD + permission editing + activity filters; real notifications feed; MCP polish (validation, error codes, domain scoping, dead sql leftover). Phase 5 (E2E + docs): rewrite Playwright suite for the Vite SPA (15 specs, new auth helpers, chromium-only in CI); add ephemeral-Postgres e2e CI job; rewrite docs/API.md for the real Hono API.
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
|
||||
test.describe('Agent Activity', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto('/agents/activity');
|
||||
await expect(page.getByRole('heading', { name: /agent activity/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('renders the filter bar and refresh button', async ({ page }) => {
|
||||
await expect(page.getByRole('combobox', { name: /all agents/i })).toBeVisible();
|
||||
await expect(page.getByRole('combobox', { name: /all actions/i })).toBeVisible();
|
||||
await expect(page.getByPlaceholder('From')).toBeVisible();
|
||||
await expect(page.getByPlaceholder('To')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /refresh/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('renders the timeline area', async ({ page }) => {
|
||||
// The initial activity query settles to either the empty state or a row of
|
||||
// live/fetched activity entries.
|
||||
const emptyState = page.getByText(/no activity found/i);
|
||||
const activityEntry = page.getByText(/created|updated|deleted|completed/i).last();
|
||||
|
||||
await expect(emptyState.or(activityEntry)).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('filters by action type', async ({ page }) => {
|
||||
await page.getByRole('combobox', { name: /all actions/i }).click();
|
||||
await page.getByRole('option', { name: 'Created', exact: true }).click();
|
||||
|
||||
// The trigger now shows the selected action and the list refetches.
|
||||
await expect(page.getByRole('combobox', { name: 'Created' })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
+10
-75
@@ -5,87 +5,22 @@ test.describe('Analytics', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto('/analytics');
|
||||
// Wait for the analytics page to load
|
||||
await expect(page.getByRole('heading', { name: /analytics/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe('Analytics page', () => {
|
||||
test('should display analytics heading and tagline', async ({ page }) => {
|
||||
await expect(page.getByRole('heading', { name: /analytics/i })).toBeVisible();
|
||||
await expect(page.getByText(/patterns behind your progress/i)).toBeVisible();
|
||||
});
|
||||
test('renders the analytics cards', async ({ page }) => {
|
||||
await expect(page.getByText(/tasks completed/i)).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText(/created vs completed/i)).toBeVisible();
|
||||
await expect(page.getByText(/habit completion/i)).toBeVisible();
|
||||
await expect(page.getByText(/productivity heatmap/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe('Summary cards', () => {
|
||||
test('should display summary stat cards', async ({ page }) => {
|
||||
// Should show 4 stat cards: Task Completion, Habit Consistency, Time Tracked, Active Streaks
|
||||
await expect(page.getByText(/task completion/i)).toBeVisible();
|
||||
await expect(page.getByText(/habit consistency/i)).toBeVisible();
|
||||
await expect(page.getByText(/time tracked/i)).toBeVisible();
|
||||
await expect(page.getByText(/active streaks/i)).toBeVisible();
|
||||
});
|
||||
test('switches the reporting range', async ({ page }) => {
|
||||
await page.getByRole('combobox', { name: /last 30 days/i }).click();
|
||||
await page.getByRole('option', { name: /last 90 days/i }).click();
|
||||
|
||||
test('should display percentage values', async ({ page }) => {
|
||||
// Task completion and habit consistency should show percentages
|
||||
const percentElements = page.locator('text=/\\d+%/');
|
||||
const count = await percentElements.count();
|
||||
expect(count).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Analytics tabs', () => {
|
||||
test('should show Trends, Habits, and Time tabs', async ({ page }) => {
|
||||
await expect(page.getByRole('tab', { name: /trends/i })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: /habits/i })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: /time/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should default to Trends tab', async ({ page }) => {
|
||||
await expect(page.getByRole('tab', { name: /trends/i })).toHaveAttribute('data-state', 'active');
|
||||
});
|
||||
|
||||
test('should switch to Habits tab', async ({ page }) => {
|
||||
await page.getByRole('tab', { name: /habits/i }).click();
|
||||
await expect(page.getByRole('tab', { name: /habits/i })).toHaveAttribute('data-state', 'active');
|
||||
|
||||
// Wait for chart to load
|
||||
await page.waitForTimeout(1_000);
|
||||
});
|
||||
|
||||
test('should switch to Time tab', async ({ page }) => {
|
||||
await page.getByRole('tab', { name: /time/i }).click();
|
||||
await expect(page.getByRole('tab', { name: /time/i })).toHaveAttribute('data-state', 'active');
|
||||
|
||||
// Wait for chart to load
|
||||
await page.waitForTimeout(1_000);
|
||||
});
|
||||
|
||||
test('should switch between all tabs', async ({ page }) => {
|
||||
// Start on Trends
|
||||
await expect(page.getByRole('tab', { name: /trends/i })).toHaveAttribute('data-state', 'active');
|
||||
|
||||
// Switch to Habits
|
||||
await page.getByRole('tab', { name: /habits/i }).click();
|
||||
await expect(page.getByRole('tab', { name: /habits/i })).toHaveAttribute('data-state', 'active');
|
||||
|
||||
// Switch to Time
|
||||
await page.getByRole('tab', { name: /time/i }).click();
|
||||
await expect(page.getByRole('tab', { name: /time/i })).toHaveAttribute('data-state', 'active');
|
||||
|
||||
// Switch back to Trends
|
||||
await page.getByRole('tab', { name: /trends/i }).click();
|
||||
await expect(page.getByRole('tab', { name: /trends/i })).toHaveAttribute('data-state', 'active');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Analytics charts', () => {
|
||||
test('should load chart components for each tab', async ({ page }) => {
|
||||
// Wait for charts to render (recharts is lazy loaded)
|
||||
await page.waitForTimeout(3_000);
|
||||
|
||||
// Verify the page has rendered without errors
|
||||
const mainContent = page.locator('main');
|
||||
await expect(mainContent).toBeVisible();
|
||||
await expect(page.getByRole('combobox', { name: /last 90 days/i })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+40
-80
@@ -3,103 +3,63 @@ import { login, logout, goToLogin } from './helpers/auth';
|
||||
import { TEST_USER, INVALID_USER } from './helpers/fixtures';
|
||||
|
||||
test.describe('Authentication Flow', () => {
|
||||
test.describe('Login', () => {
|
||||
test('should login with valid credentials and redirect to dashboard', async ({ page }) => {
|
||||
await goToLogin(page);
|
||||
test('logs in with valid credentials and redirects to the dashboard', async ({ page }) => {
|
||||
await goToLogin(page);
|
||||
|
||||
// Verify login page loaded
|
||||
await expect(page.getByRole('heading', { name: /project e/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible();
|
||||
// The login page renders a Card titled "Sign in".
|
||||
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible();
|
||||
|
||||
// Fill in credentials
|
||||
await page.getByLabel('Email').fill(TEST_USER.email);
|
||||
await page.getByLabel('Password').fill(TEST_USER.password);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
await page.getByLabel('Email').fill(TEST_USER.email);
|
||||
await page.getByLabel('Password').fill(TEST_USER.password);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
|
||||
// Should redirect to dashboard
|
||||
await page.waitForURL('**/dashboard', { timeout: 15_000 });
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
// Successful login navigates to the "/" dashboard.
|
||||
await page.waitForURL('**/', { timeout: 15_000 });
|
||||
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
|
||||
});
|
||||
|
||||
// Dashboard should be visible
|
||||
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
|
||||
});
|
||||
test('shows an error for invalid credentials', async ({ page }) => {
|
||||
await goToLogin(page);
|
||||
|
||||
test('should show error with invalid credentials', async ({ page }) => {
|
||||
await goToLogin(page);
|
||||
await page.getByLabel('Email').fill(INVALID_USER.email);
|
||||
await page.getByLabel('Password').fill(INVALID_USER.password);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
|
||||
await page.getByLabel('Email').fill(INVALID_USER.email);
|
||||
await page.getByLabel('Password').fill(INVALID_USER.password);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
|
||||
// Should still be on login page
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
|
||||
// Should show error message (toast or inline)
|
||||
// The app uses sonner toasts for errors
|
||||
await expect(
|
||||
page.getByText(/login failed|invalid/i),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('should prevent submitting empty form', async ({ page }) => {
|
||||
await goToLogin(page);
|
||||
|
||||
const submitButton = page.getByRole('button', { name: /sign in/i });
|
||||
await expect(submitButton).toBeVisible();
|
||||
|
||||
// HTML5 required attribute should prevent submission
|
||||
// The email field is required
|
||||
await page.getByLabel('Password').fill('somepassword');
|
||||
await submitButton.click();
|
||||
|
||||
// Should stay on login page (HTML5 validation prevents submit)
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
// Stay on the login page and surface the API error in a role="alert" box.
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.getByRole('alert')).toContainText('Invalid email or password', {
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Logout', () => {
|
||||
test('should logout and redirect to login page', async ({ page }) => {
|
||||
// First login
|
||||
await login(page);
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
test('logs out from the user menu and returns to the login page', async ({ page }) => {
|
||||
await login(page);
|
||||
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
|
||||
|
||||
// Clear cookies to simulate logout
|
||||
await logout(page);
|
||||
// The topbar avatar dropdown works on every viewport (the sidebar user menu
|
||||
// is desktop-only), so use it for the logout flow.
|
||||
const avatarButton = page.getByRole('banner').getByRole('button').last();
|
||||
await avatarButton.click();
|
||||
await page.getByRole('menuitem', { name: /log out/i }).click();
|
||||
|
||||
// Navigate to dashboard – should redirect to login
|
||||
await page.goto('/dashboard');
|
||||
await page.waitForURL('**/login', { timeout: 10_000 });
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
await page.waitForURL('**/login', { timeout: 15_000 });
|
||||
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe('Session persistence', () => {
|
||||
test('should stay logged in after page refresh', async ({ page }) => {
|
||||
await login(page);
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
test('keeps the session after a page reload', async ({ page }) => {
|
||||
await login(page);
|
||||
|
||||
// Refresh the page
|
||||
await page.reload();
|
||||
await page.reload();
|
||||
|
||||
// Should still be on dashboard
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
|
||||
});
|
||||
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe('Protected routes', () => {
|
||||
test('should redirect unauthenticated users from dashboard to login', async ({ page }) => {
|
||||
await logout(page);
|
||||
await page.goto('/dashboard');
|
||||
await page.waitForURL('**/login', { timeout: 10_000 });
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
test('redirects unauthenticated users from protected pages to login', async ({ page }) => {
|
||||
await logout(page);
|
||||
|
||||
test('should redirect unauthenticated users from tasks to login', async ({ page }) => {
|
||||
await logout(page);
|
||||
await page.goto('/tasks');
|
||||
await page.waitForURL('**/login', { timeout: 10_000 });
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
await page.goto('/tasks');
|
||||
await page.waitForURL('**/login', { timeout: 15_000 });
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
+56
-35
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
import { testCalendarEvents } from './helpers/fixtures';
|
||||
|
||||
test.describe('Calendar', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -8,47 +9,67 @@ test.describe('Calendar', () => {
|
||||
await expect(page.getByRole('heading', { name: /calendar/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display calendar with month view by default', async ({ page }) => {
|
||||
// Month view should be visible
|
||||
await expect(page.getByText(/today/i)).toBeVisible();
|
||||
/** Fill the New Event dialog and submit it, returning the POST response. */
|
||||
async function createEvent(page: import('@playwright/test').Page, title: string) {
|
||||
await page.getByRole('button', { name: /new event/i }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByRole('heading', { name: 'New Event' })).toBeVisible();
|
||||
|
||||
await dialog.getByLabel('Title').fill(title);
|
||||
|
||||
// The API requires an ISO start time; schedule the event for today so it
|
||||
// lands in the current month view.
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const start = new Date();
|
||||
const end = new Date(start.getTime() + 60 * 60 * 1000);
|
||||
const toLocal = (d: Date) =>
|
||||
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
await dialog.getByLabel('Start').fill(toLocal(start));
|
||||
await dialog.getByLabel('End').fill(toLocal(end));
|
||||
|
||||
const createResponse = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/api/calendar/events') && resp.request().method() === 'POST',
|
||||
);
|
||||
await dialog.getByRole('button', { name: /create event/i }).click();
|
||||
await createResponse;
|
||||
await expect(dialog).toBeHidden();
|
||||
}
|
||||
|
||||
test('renders the calendar page', async ({ page }) => {
|
||||
// react-big-calendar only mounts once events exist; otherwise a friendly
|
||||
// empty state is shown. Accept either so a fresh DB still passes.
|
||||
await expect(
|
||||
page.getByText(/no events yet/i).or(page.locator('.rbc-calendar-container')),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('should switch between month, week, and day views', async ({ page }) => {
|
||||
// Click week view
|
||||
const weekBtn = page.getByRole('button', { name: /week/i });
|
||||
if (await weekBtn.isVisible()) {
|
||||
await weekBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
test('creates an event via the New Event dialog', async ({ page }) => {
|
||||
await createEvent(page, testCalendarEvents.title);
|
||||
|
||||
// Click day view
|
||||
const dayBtn = page.getByRole('button', { name: /day/i });
|
||||
if (await dayBtn.isVisible()) {
|
||||
await dayBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Click month view
|
||||
const monthBtn = page.getByRole('button', { name: /month/i });
|
||||
if (await monthBtn.isVisible()) {
|
||||
await monthBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
// The calendar now renders and the event shows up on it.
|
||||
await expect(page.locator('.rbc-calendar-container')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(
|
||||
page.locator('.rbc-event').filter({ hasText: testCalendarEvents.title }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('should navigate between months', async ({ page }) => {
|
||||
// Click next month
|
||||
const nextBtn = page.getByRole('button', { name: /next/i });
|
||||
if (await nextBtn.isVisible()) {
|
||||
await nextBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
test('switches between month, week, and day views', async ({ page }) => {
|
||||
// The toolbar only exists when the calendar is rendered, so make sure at
|
||||
// least one event exists first.
|
||||
if (!(await page.locator('.rbc-calendar-container').isVisible().catch(() => false))) {
|
||||
await createEvent(page, `${testCalendarEvents.title} view`);
|
||||
}
|
||||
|
||||
// Click previous month
|
||||
const prevBtn = page.getByRole('button', { name: /prev/i });
|
||||
if (await prevBtn.isVisible()) {
|
||||
await prevBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
await expect(page.locator('.rbc-calendar-container')).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByRole('button', { name: 'Week', exact: true }).click();
|
||||
await expect(page.locator('.rbc-calendar-container')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Day', exact: true }).click();
|
||||
await expect(page.locator('.rbc-calendar-container')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Month', exact: true }).click();
|
||||
await expect(page.locator('.rbc-calendar-container')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
import { testCanvas } from './helpers/fixtures';
|
||||
|
||||
test.describe('Canvas', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto('/canvas');
|
||||
await expect(page.getByRole('heading', { name: /canvas/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('renders the canvas list', async ({ page }) => {
|
||||
await expect(page.getByRole('button', { name: /new canvas/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('creates a canvas and opens the editor', async ({ page }) => {
|
||||
await page.getByRole('button', { name: /new canvas/i }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByRole('heading', { name: 'New Canvas' })).toBeVisible();
|
||||
|
||||
await dialog.getByLabel('Name').fill(testCanvas.name);
|
||||
|
||||
const createResponse = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/api/canvas') && resp.request().method() === 'POST',
|
||||
);
|
||||
await dialog.getByRole('button', { name: /^create$/i }).click();
|
||||
expect((await createResponse).ok()).toBeTruthy();
|
||||
|
||||
// Creating a canvas navigates straight into the editor at /canvas/<id>.
|
||||
await page.waitForURL('**/canvas/*', { timeout: 10_000 });
|
||||
await expect(page.getByPlaceholder(/type \/ for commands/i)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
|
||||
test.describe('Daily Notes', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto('/daily');
|
||||
// Wait for the editor (mood/energy controls) to finish loading.
|
||||
await expect(page.getByRole('radiogroup', { name: 'Mood' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('renders the calendar sidebar and editor', async ({ page }) => {
|
||||
await expect(page.getByRole('button', { name: /today/i })).toBeVisible();
|
||||
await expect(page.getByText('Sun')).toBeVisible();
|
||||
await expect(page.getByRole('radiogroup', { name: 'Energy' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('selecting a mood rating creates the daily note', async ({ page }) => {
|
||||
await page.getByRole('radio', { name: 'Mood 5' }).click();
|
||||
await expect(page.getByRole('radio', { name: 'Mood 5' })).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
// Creating a mood on a day with no note materializes the editor.
|
||||
await expect(page.getByPlaceholder(/write your daily note/i)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('writes a daily note and it autosaves', async ({ page }) => {
|
||||
const textarea = page.getByPlaceholder(/write your daily note/i);
|
||||
|
||||
if (!(await textarea.isVisible().catch(() => false))) {
|
||||
await page.getByRole('radio', { name: 'Mood 5' }).click();
|
||||
await expect(textarea).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
await textarea.fill('E2E daily note content');
|
||||
|
||||
// Once a note exists the header shows the "Saved" badge.
|
||||
await expect(page.getByText('Saved', { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
+14
-11
@@ -4,20 +4,23 @@ import { login } from './helpers/auth';
|
||||
test.describe('Dashboard', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto('/dashboard');
|
||||
});
|
||||
|
||||
test('renders the dashboard with the widget area', async ({ page }) => {
|
||||
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
|
||||
|
||||
// Either the widget grid renders or the "add widget" entry point shows.
|
||||
await expect(page.getByRole('button', { name: /add widget/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display dashboard with widgets', async ({ page }) => {
|
||||
// Dashboard should show at least one widget area
|
||||
await expect(page.locator('[class*="grid"]').first()).toBeVisible();
|
||||
});
|
||||
test('adds the default widget set from an empty dashboard', async ({ page }) => {
|
||||
const addDefaults = page.getByRole('button', { name: /add default widgets/i });
|
||||
|
||||
test('should show today tasks widget', async ({ page }) => {
|
||||
await expect(page.getByText(/today/i).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show activity feed widget', async ({ page }) => {
|
||||
await expect(page.getByText(/activity/i).first()).toBeVisible();
|
||||
// Only the empty dashboard offers the default set; a dashboard that already
|
||||
// has widgets (from earlier runs) skips this.
|
||||
if (await addDefaults.isVisible().catch(() => false)) {
|
||||
await addDefaults.click();
|
||||
await expect(page.getByText(/tasks due today/i)).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Minimal ambient declarations for the E2E suite.
|
||||
*
|
||||
* The suite reads a handful of env vars for test credentials. Node type
|
||||
* definitions are not installed at the repo root (they only live under
|
||||
* apps/api/node_modules), so declare just the surface we use instead of
|
||||
* depending on @types/node.
|
||||
*/
|
||||
declare const process: {
|
||||
env: {
|
||||
CI?: string;
|
||||
E2E_EMAIL?: string;
|
||||
E2E_PASSWORD?: string;
|
||||
INITIAL_ADMIN_EMAIL?: string;
|
||||
INITIAL_ADMIN_PASSWORD?: string;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
|
||||
test.describe('Graph', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto('/graph');
|
||||
});
|
||||
|
||||
test('renders the graph toolbar with search and zoom controls', async ({ page }) => {
|
||||
await expect(page.getByPlaceholder(/find a node/i)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /filters/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /zoom in/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /zoom out/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /reset view/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('opens the filters panel with entity type toggles', async ({ page }) => {
|
||||
await page.getByRole('button', { name: /filters/i }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Filters' })).toBeVisible();
|
||||
await expect(page.getByRole('checkbox', { name: 'Tasks' })).toBeVisible();
|
||||
await expect(page.getByRole('checkbox', { name: 'Habits' })).toBeVisible();
|
||||
await expect(page.getByRole('checkbox', { name: 'Projects' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
+32
-70
@@ -2,87 +2,49 @@ import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
import { testHabits } from './helpers/fixtures';
|
||||
|
||||
test.describe('Habit Tracking', () => {
|
||||
test.describe('Habits', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto('/habits');
|
||||
// Wait for the habits page to load
|
||||
await expect(page.getByRole('heading', { name: /habits/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe('Habits page', () => {
|
||||
test('should display habits page with summary banner', async ({ page }) => {
|
||||
// Summary banner should show today's progress
|
||||
await expect(page.getByText(/today's progress/i)).toBeVisible();
|
||||
await expect(page.getByText(/completion rate/i)).toBeVisible();
|
||||
});
|
||||
test('shows the empty state when there are no habits', async ({ page }) => {
|
||||
if (await page.getByText('No habits yet').isVisible().catch(() => false)) {
|
||||
await expect(page.getByText(/create your first one/i)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('should show "New habit" button', async ({ page }) => {
|
||||
await expect(page.getByRole('button', { name: /new habit/i })).toBeVisible();
|
||||
test('creates a habit via the New Habit dialog', async ({ page }) => {
|
||||
await page.getByRole('button', { name: /new habit/i }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByRole('heading', { name: 'New Habit' })).toBeVisible();
|
||||
|
||||
await dialog.getByLabel('Name').fill(testHabits.name);
|
||||
await dialog.getByLabel('Description').fill(testHabits.description);
|
||||
await dialog.getByRole('button', { name: /create habit/i }).click();
|
||||
|
||||
// The dialog closes and the habit appears in the list.
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page.getByText(testHabits.name, { exact: true })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Create habit', () => {
|
||||
test('should open new habit dialog when clicking "New habit"', async ({ page }) => {
|
||||
test('marks a habit complete from the list', async ({ page }) => {
|
||||
// Ensure the fixture habit exists.
|
||||
if (!(await page.getByText(testHabits.name, { exact: true }).isVisible().catch(() => false))) {
|
||||
await page.getByRole('button', { name: /new habit/i }).click();
|
||||
// A dialog or form should appear
|
||||
// The habit creation might be a dialog or inline form
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
});
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByLabel('Name').fill(testHabits.name);
|
||||
await dialog.getByRole('button', { name: /create habit/i }).click();
|
||||
await expect(page.getByText(testHabits.name, { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
test.describe('Habit completion', () => {
|
||||
test('should display habit cards in a grid', async ({ page }) => {
|
||||
// Habit cards should be in a grid layout
|
||||
const habitCards = page.locator('.grid > div, [class*="habit"]');
|
||||
// Verify the grid container exists
|
||||
await expect(page.locator('.grid')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show consistency heatmap section', async ({ page }) => {
|
||||
// The heatmap section should exist
|
||||
await expect(page.getByText(/consistency overview/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Quick completion mode', () => {
|
||||
test('should complete a quick-mode habit with a single click', async ({ page }) => {
|
||||
// Find a habit card with a complete button
|
||||
const completeButtons = page.locator('button:has-text("Complete"), button[aria-label*="complete"]');
|
||||
const count = await completeButtons.count();
|
||||
|
||||
if (count > 0) {
|
||||
await completeButtons.first().click();
|
||||
// Should update without showing a dialog (quick mode)
|
||||
await page.waitForTimeout(1_000);
|
||||
} else {
|
||||
test.skip();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Detailed completion mode', () => {
|
||||
test('should open completion dialog for detailed habits', async ({ page }) => {
|
||||
// Detailed mode habits open a dialog with mood/quantity fields
|
||||
const detailedButtons = page.locator('button:has-text("Complete")');
|
||||
const count = await detailedButtons.count();
|
||||
|
||||
if (count > 0) {
|
||||
// Try clicking - if it's detailed mode, a dialog should open
|
||||
await detailedButtons.first().click();
|
||||
await page.waitForTimeout(1_000);
|
||||
} else {
|
||||
test.skip();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Habit streaks', () => {
|
||||
test('should display streak information on habit cards', async ({ page }) => {
|
||||
// Habit cards should show streak/fire icons
|
||||
const streakElements = page.locator('[class*="streak"], [class*="fire"], [class*="flame"]');
|
||||
// Just verify the page loaded properly
|
||||
await expect(page.getByRole('heading', { name: /habits/i })).toBeVisible();
|
||||
});
|
||||
// Completing today's habit bumps the streak to 1 day.
|
||||
await page.getByRole('button', { name: /mark .* complete/i }).first().click();
|
||||
await expect(page.getByText('1 day streak', { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
+25
-7
@@ -4,8 +4,13 @@ import { TEST_USER } from './fixtures';
|
||||
/**
|
||||
* Log the test user in via the login page.
|
||||
*
|
||||
* This hits the real UI flow (fill form → submit) so the auth cookie is
|
||||
* set exactly as a real user would experience it.
|
||||
* This exercises the real UI flow (fill form → submit) so the `session` cookie
|
||||
* is set exactly as a real user would experience it. Successful logins navigate
|
||||
* to the "/" dashboard.
|
||||
*
|
||||
* The very first login on an empty database auto-creates the admin user; when
|
||||
* several parallel workers race that first login one of them can hit a
|
||||
* transient server error, so the submit is retried once.
|
||||
*/
|
||||
export async function login(
|
||||
page: Page,
|
||||
@@ -16,17 +21,30 @@ export async function login(
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByLabel('Password').fill(password);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
|
||||
// Wait for navigation away from login page
|
||||
await page.waitForURL('**/dashboard', { timeout: 15_000 });
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
try {
|
||||
// Wait for navigation away from the login page to the "/" dashboard.
|
||||
await page.waitForURL('**/', { timeout: 10_000 });
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
// A transient failure leaves the form on the login page — refill and retry.
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByLabel('Password').fill(password);
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error('Login failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the user is logged out by clearing cookies.
|
||||
* Log the user out by hitting the logout endpoint, then land on the login page.
|
||||
*/
|
||||
export async function logout(page: Page) {
|
||||
await page.context().clearCookies();
|
||||
await page.request.post('/api/auth/logout');
|
||||
await page.goto('/login');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+21
-12
@@ -1,9 +1,14 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/** Test user credentials – matches PocketBase seed data or test fixtures. */
|
||||
/**
|
||||
* Shared test fixtures.
|
||||
*
|
||||
* The first login against a fresh database auto-creates the admin user from
|
||||
* INITIAL_ADMIN_EMAIL / INITIAL_ADMIN_PASSWORD (see apps/api/src/routes/auth.ts),
|
||||
* so the E2E suite prefers those env vars and only falls back to hardcoded
|
||||
* defaults when running against a pre-seeded local database.
|
||||
*/
|
||||
export const TEST_USER = {
|
||||
email: 'test@example.com',
|
||||
password: 'testpassword123',
|
||||
email: process.env.E2E_EMAIL || process.env.INITIAL_ADMIN_EMAIL || 'test@example.com',
|
||||
password: process.env.E2E_PASSWORD || process.env.INITIAL_ADMIN_PASSWORD || 'testpassword123',
|
||||
};
|
||||
|
||||
/** Fake credentials that should always fail login. */
|
||||
@@ -17,10 +22,7 @@ const ts = Date.now();
|
||||
|
||||
export const testTasks = {
|
||||
title: `E2E Test Task ${ts}`,
|
||||
editedTitle: `E2E Test Task Edited ${ts}`,
|
||||
description: 'This task was created by the E2E test suite.',
|
||||
domain: 'personal',
|
||||
priority: 'high' as const,
|
||||
};
|
||||
|
||||
export const testHabits = {
|
||||
@@ -36,14 +38,21 @@ export const testProjects = {
|
||||
export const testNotes = {
|
||||
title: `E2E Test Note ${ts}`,
|
||||
content: 'This note was created by the E2E test suite.',
|
||||
domain: 'personal',
|
||||
};
|
||||
|
||||
export const testReports = {
|
||||
title: `E2E Test Report ${ts}`,
|
||||
content: 'This report was created by the E2E test suite.',
|
||||
export const testCalendarEvents = {
|
||||
title: `E2E Test Event ${ts}`,
|
||||
};
|
||||
|
||||
export const testCanvas = {
|
||||
name: `E2E Test Canvas ${ts}`,
|
||||
};
|
||||
|
||||
export const testDomains = {
|
||||
name: `e2e-domain-${ts}`,
|
||||
};
|
||||
|
||||
export const testWebhooks = {
|
||||
name: `E2E Test Webhook ${ts}`,
|
||||
url: `https://example.com/hooks/${ts}`,
|
||||
};
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
test.describe('Import/Export', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto('/settings');
|
||||
// Wait for the settings page to load
|
||||
await expect(page.getByRole('heading', { name: /settings/i })).toBeVisible();
|
||||
|
||||
// Switch to Import & Export tab
|
||||
await page.getByRole('tab', { name: /import.*export/i }).click();
|
||||
});
|
||||
|
||||
test.describe('Export', () => {
|
||||
test('should show export section with collection selection', async ({ page }) => {
|
||||
await expect(page.getByText(/export data/i)).toBeVisible();
|
||||
await expect(page.getByLabel(/select all/i)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /export to json/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should have all collection checkboxes', async ({ page }) => {
|
||||
const collections = [
|
||||
'tasks', 'habits', 'projects', 'notes', 'reports',
|
||||
'milestones', 'domains', 'tags', 'agents', 'webhooks',
|
||||
];
|
||||
|
||||
for (const collection of collections) {
|
||||
await expect(page.getByLabel(new RegExp(`export ${collection}`, 'i'))).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('should toggle select all checkbox', async ({ page }) => {
|
||||
const selectAll = page.getByLabel(/select all/i);
|
||||
await expect(selectAll).toBeChecked();
|
||||
|
||||
// Uncheck select all
|
||||
await selectAll.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Individual checkboxes should be unchecked
|
||||
const tasksCheckbox = page.getByLabel(/export tasks/i);
|
||||
await expect(tasksCheckbox).not.toBeChecked();
|
||||
|
||||
// Check select all again
|
||||
await selectAll.click();
|
||||
await page.waitForTimeout(300);
|
||||
await expect(tasksCheckbox).toBeChecked();
|
||||
});
|
||||
|
||||
test('should export data as JSON', async ({ page }) => {
|
||||
// Set up download handler
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
|
||||
await page.getByRole('button', { name: /export to json/i }).click();
|
||||
|
||||
try {
|
||||
const download = await downloadPromise;
|
||||
// Verify download started
|
||||
const filename = download.suggestedFilename();
|
||||
expect(filename).toMatch(/project-e-export.*\.json/);
|
||||
} catch {
|
||||
// Export might fail if PocketBase isn't running – that's OK for structure test
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Import', () => {
|
||||
test('should show import section', async ({ page }) => {
|
||||
await expect(page.getByText(/import data/i)).toBeVisible();
|
||||
await expect(page.getByText(/restore from a previously exported/i)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /import from json/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should have file upload input', async ({ page }) => {
|
||||
// The file input is hidden, triggered by the button
|
||||
const fileInput = page.locator('input[type="file"][accept=".json"]');
|
||||
await expect(fileInput).toBeAttached();
|
||||
});
|
||||
|
||||
test('should import data from JSON file', async ({ page }) => {
|
||||
// Create a minimal valid import file
|
||||
const importData = {
|
||||
version: 1,
|
||||
exported_at: new Date().toISOString(),
|
||||
collections: {
|
||||
tasks: [],
|
||||
habits: [],
|
||||
projects: [],
|
||||
notes: [],
|
||||
reports: [],
|
||||
},
|
||||
};
|
||||
|
||||
const tempDir = os.tmpdir();
|
||||
const importFile = path.join(tempDir, `e2e-import-${Date.now()}.json`);
|
||||
fs.writeFileSync(importFile, JSON.stringify(importData, null, 2));
|
||||
|
||||
try {
|
||||
// Trigger file upload
|
||||
const fileInput = page.locator('input[type="file"][accept=".json"]');
|
||||
await fileInput.setInputFiles(importFile);
|
||||
|
||||
// Confirmation dialog should appear
|
||||
await expect(page.getByRole('dialog', { name: /confirm import/i })).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Click Import button in dialog
|
||||
await page.getByRole('button', { name: /import$/i }).click();
|
||||
|
||||
// Wait for import to complete
|
||||
await page.waitForTimeout(3_000);
|
||||
} finally {
|
||||
// Cleanup temp file
|
||||
try { fs.unlinkSync(importFile); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
test('should show import confirmation dialog', async ({ page }) => {
|
||||
const importData = {
|
||||
version: 1,
|
||||
exported_at: new Date().toISOString(),
|
||||
collections: { tasks: [], habits: [], projects: [], notes: [], reports: [] },
|
||||
};
|
||||
|
||||
const tempDir = os.tmpdir();
|
||||
const importFile = path.join(tempDir, `e2e-import-dialog-${Date.now()}.json`);
|
||||
fs.writeFileSync(importFile, JSON.stringify(importData, null, 2));
|
||||
|
||||
try {
|
||||
const fileInput = page.locator('input[type="file"][accept=".json"]');
|
||||
await fileInput.setInputFiles(importFile);
|
||||
|
||||
// Confirmation dialog
|
||||
const dialog = page.getByRole('dialog', { name: /confirm import/i });
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Should have Cancel and Import buttons
|
||||
await expect(dialog.getByRole('button', { name: /cancel/i })).toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: /import/i })).toBeVisible();
|
||||
|
||||
// Cancel the import
|
||||
await dialog.getByRole('button', { name: /cancel/i }).click();
|
||||
await expect(dialog).not.toBeVisible({ timeout: 3_000 });
|
||||
} finally {
|
||||
try { fs.unlinkSync(importFile); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
|
||||
test.describe('MCP Server', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
});
|
||||
|
||||
test('server/discover should return all expected tools', async ({ page }) => {
|
||||
const response = await page.request.post('/api/mcp', {
|
||||
data: {
|
||||
jsonrpc: '2.0',
|
||||
method: 'server/discover',
|
||||
id: 1,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const body = await response.json();
|
||||
expect(body.jsonrpc).toBe('2.0');
|
||||
expect(body.result).toBeDefined();
|
||||
expect(body.result.name).toBe('project-e');
|
||||
expect(body.result.tools).toBeDefined();
|
||||
expect(Array.isArray(body.result.tools)).toBeTruthy();
|
||||
|
||||
// Verify expected tools exist
|
||||
const toolNames = body.result.tools.map((t: { name: string }) => t.name);
|
||||
expect(toolNames).toContain('tasks.list');
|
||||
expect(toolNames).toContain('tasks.create');
|
||||
expect(toolNames).toContain('tasks.update');
|
||||
expect(toolNames).toContain('tasks.delete');
|
||||
expect(toolNames).toContain('tasks.complete');
|
||||
expect(toolNames).toContain('habits.list');
|
||||
expect(toolNames).toContain('habits.create');
|
||||
expect(toolNames).toContain('habits.complete');
|
||||
expect(toolNames).toContain('projects.list');
|
||||
expect(toolNames).toContain('projects.create');
|
||||
expect(toolNames).toContain('notes.list');
|
||||
expect(toolNames).toContain('notes.create');
|
||||
expect(toolNames).toContain('notes.update');
|
||||
expect(toolNames).toContain('notes.search');
|
||||
expect(toolNames).toContain('domains.list');
|
||||
expect(toolNames).toContain('domains.create');
|
||||
expect(toolNames).toContain('search.query');
|
||||
expect(toolNames).toContain('activity.list');
|
||||
});
|
||||
|
||||
test('should reject unauthenticated requests', async ({ page }) => {
|
||||
const response = await page.request.post('/api/mcp', {
|
||||
data: {
|
||||
jsonrpc: '2.0',
|
||||
method: 'server/discover',
|
||||
id: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// Without API key, should return 401
|
||||
expect(response.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('should return error for unknown method', async ({ page }) => {
|
||||
const response = await page.request.post('/api/mcp', {
|
||||
data: {
|
||||
jsonrpc: '2.0',
|
||||
method: 'unknown.method',
|
||||
id: 1,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const body = await response.json();
|
||||
expect(body.error).toBeDefined();
|
||||
expect(body.error.code).toBe(-32601); // METHOD_NOT_FOUND
|
||||
});
|
||||
|
||||
test('should reject invalid JSON-RPC request', async ({ page }) => {
|
||||
const response = await page.request.post('/api/mcp', {
|
||||
data: { invalid: true },
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(400);
|
||||
const body = await response.json();
|
||||
expect(body.error).toBeDefined();
|
||||
});
|
||||
|
||||
test('GET should return 405', async ({ page }) => {
|
||||
const response = await page.request.get('/api/mcp');
|
||||
expect(response.status()).toBe(405);
|
||||
});
|
||||
});
|
||||
+35
-28
@@ -6,40 +6,47 @@ test.describe('Navigation', () => {
|
||||
await login(page);
|
||||
});
|
||||
|
||||
test('should navigate between all main pages via sidebar', async ({ page }) => {
|
||||
test('every primary route renders its page', async ({ page }) => {
|
||||
const pages = [
|
||||
{ href: '/dashboard', name: /dashboard/i },
|
||||
{ href: '/tasks', name: /tasks/i },
|
||||
{ href: '/habits', name: /habits/i },
|
||||
{ href: '/projects', name: /projects/i },
|
||||
{ href: '/notes', name: /notes/i },
|
||||
{ href: '/graph', name: /graph/i },
|
||||
{ href: '/calendar', name: /calendar/i },
|
||||
{ href: '/search', name: /search/i },
|
||||
{ url: '/', check: page.getByRole('heading', { name: /dashboard/i }) },
|
||||
{ url: '/tasks', check: page.getByRole('heading', { name: /tasks/i }) },
|
||||
{ url: '/habits', check: page.getByRole('heading', { name: /habits/i }) },
|
||||
{ url: '/projects', check: page.getByRole('heading', { name: /projects/i }) },
|
||||
{ url: '/notes', check: page.getByRole('button', { name: /new note/i }) },
|
||||
{ url: '/calendar', check: page.getByRole('heading', { name: /calendar/i }) },
|
||||
{ url: '/graph', check: page.getByPlaceholder(/find a node/i) },
|
||||
{ url: '/search', check: page.getByPlaceholder(/search tasks, notes/i) },
|
||||
{ url: '/analytics', check: page.getByRole('heading', { name: /analytics/i }) },
|
||||
{ url: '/agents/activity', check: page.getByRole('heading', { name: /agent activity/i }) },
|
||||
{ url: '/canvas', check: page.getByRole('heading', { name: /canvas/i }) },
|
||||
{ url: '/daily', check: page.getByRole('button', { name: /today/i }) },
|
||||
{ url: '/settings', check: page.getByRole('button', { name: 'Appearance' }) },
|
||||
];
|
||||
|
||||
for (const { href, name } of pages) {
|
||||
await page.goto(href);
|
||||
await page.waitForURL(`**${href}`, { timeout: 10_000 });
|
||||
await expect(page.locator('h1, h2').filter({ hasText: name }).first()).toBeVisible();
|
||||
for (const { url, check } of pages) {
|
||||
await page.goto(url);
|
||||
await page.waitForURL(`**${url}`, { timeout: 10_000 });
|
||||
await expect(check).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
});
|
||||
|
||||
test('should open command palette with Cmd+K', async ({ page }) => {
|
||||
await page.goto('/dashboard');
|
||||
await page.keyboard.press('Meta+k');
|
||||
// Command palette should be visible
|
||||
await expect(page.getByPlaceholder(/type a command/i)).toBeVisible({ timeout: 5_000 });
|
||||
// Close with Escape
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
test('navigates via the sidebar links', async ({ page }) => {
|
||||
// The sidebar is desktop-only; on narrow viewports it lives behind the
|
||||
// topbar menu button.
|
||||
test.skip((page.viewportSize()?.width ?? 0) < 768, 'sidebar is hidden on mobile');
|
||||
|
||||
test('should open keyboard shortcuts help with ?', async ({ page }) => {
|
||||
await page.goto('/dashboard');
|
||||
await page.keyboard.press('?');
|
||||
// Shortcuts help dialog should be visible
|
||||
await expect(page.getByText(/keyboard shortcuts/i)).toBeVisible({ timeout: 5_000 });
|
||||
// Close with Escape
|
||||
await page.keyboard.press('Escape');
|
||||
const nav = page.getByRole('navigation', { name: 'Primary' });
|
||||
|
||||
await nav.getByRole('link', { name: 'Tasks' }).click();
|
||||
await page.waitForURL('**/tasks', { timeout: 10_000 });
|
||||
await expect(page.getByRole('heading', { name: /tasks/i })).toBeVisible();
|
||||
|
||||
await nav.getByRole('link', { name: 'Habits' }).click();
|
||||
await page.waitForURL('**/habits', { timeout: 10_000 });
|
||||
await expect(page.getByRole('heading', { name: /habits/i })).toBeVisible();
|
||||
|
||||
await nav.getByRole('link', { name: 'Settings' }).click();
|
||||
await page.waitForURL('**/settings', { timeout: 10_000 });
|
||||
await expect(page.getByRole('button', { name: 'Appearance' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
+42
-99
@@ -6,118 +6,61 @@ test.describe('Notes', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto('/notes');
|
||||
// Wait for the notes page to load
|
||||
await expect(page.getByRole('heading', { name: /notes/i })).toBeVisible();
|
||||
// The notes page has no h1; the "New Note" button marks it as loaded.
|
||||
await expect(page.getByRole('button', { name: /new note/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe('Notes page layout', () => {
|
||||
test('should display three-panel layout', async ({ page }) => {
|
||||
// Notes page has: notes list | editor | backlinks/graph
|
||||
await expect(page.getByText(/connect ideas/i)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /new note/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /daily note/i })).toBeVisible();
|
||||
});
|
||||
test('creates a note and opens it in the editor', async ({ page }) => {
|
||||
const createResponse = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/api/notes') && resp.request().method() === 'POST',
|
||||
);
|
||||
await page.getByRole('button', { name: /new note/i }).click();
|
||||
expect((await createResponse).ok()).toBeTruthy();
|
||||
|
||||
test('should show empty state when no notes exist', async ({ page }) => {
|
||||
const noteItems = page.locator('button[aria-label^="Open note:"]');
|
||||
const count = await noteItems.count();
|
||||
|
||||
if (count === 0) {
|
||||
await expect(page.getByText(/no notes yet/i)).toBeVisible();
|
||||
}
|
||||
// The new note is auto-selected and the TipTap editor becomes editable.
|
||||
await expect(page.locator('.note-editor [contenteditable="true"]')).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Create note', () => {
|
||||
test('should create a new note when clicking "New note"', async ({ page }) => {
|
||||
// Intercept the API call
|
||||
const createResponsePromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/api/notes') && resp.request().method() === 'POST',
|
||||
);
|
||||
test('typing in the editor autosaves the content', async ({ page }) => {
|
||||
// Ensure a note is open first.
|
||||
const createResponse = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/api/notes') && resp.request().method() === 'POST',
|
||||
);
|
||||
await page.getByRole('button', { name: /new note/i }).click();
|
||||
await createResponse;
|
||||
|
||||
await page.getByRole('button', { name: /new note/i }).click();
|
||||
const editor = page.locator('.note-editor [contenteditable="true"]');
|
||||
await expect(editor).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Wait for the API response
|
||||
const response = await createResponsePromise;
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
// The new note should appear in the list and be selected
|
||||
await page.waitForTimeout(1_000);
|
||||
});
|
||||
// Typing triggers the debounced autosave PATCH.
|
||||
const patchResponse = page.waitForResponse(
|
||||
(resp) => /^\/api\/notes\/[^/?]+$/.test(new URL(resp.url()).pathname) &&
|
||||
resp.request().method() === 'PATCH',
|
||||
);
|
||||
await editor.fill(testNotes.content);
|
||||
expect((await patchResponse).ok()).toBeTruthy();
|
||||
});
|
||||
|
||||
test.describe('Note editor', () => {
|
||||
test('should show editor when a note is selected', async ({ page }) => {
|
||||
const noteItems = page.locator('button[aria-label^="Open note:"]');
|
||||
const count = await noteItems.count();
|
||||
test('renames a note via the editor title input', async ({ page }) => {
|
||||
// Ensure a fresh note is open.
|
||||
const createResponse = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/api/notes') && resp.request().method() === 'POST',
|
||||
);
|
||||
await page.getByRole('button', { name: /new note/i }).click();
|
||||
await createResponse;
|
||||
|
||||
if (count > 0) {
|
||||
// Click first note to select it
|
||||
await noteItems.first().click();
|
||||
// The new note is titled "Untitled"; rename it via the uncontrolled input.
|
||||
const titleInput = page.locator('input[value="Untitled"]');
|
||||
await expect(titleInput).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Editor should be visible (title input at minimum)
|
||||
await expect(page.getByLabel('Note title')).toBeVisible();
|
||||
} else {
|
||||
// Create a note first
|
||||
await page.getByRole('button', { name: /new note/i }).click();
|
||||
await page.waitForTimeout(1_000);
|
||||
await expect(page.getByLabel('Note title')).toBeVisible();
|
||||
}
|
||||
});
|
||||
await titleInput.fill(testNotes.title);
|
||||
await titleInput.blur();
|
||||
|
||||
test('should update note title when edited', async ({ page }) => {
|
||||
// Ensure a note is selected
|
||||
const noteItems = page.locator('button[aria-label^="Open note:"]');
|
||||
const count = await noteItems.count();
|
||||
|
||||
if (count === 0) {
|
||||
await page.getByRole('button', { name: /new note/i }).click();
|
||||
await page.waitForTimeout(1_000);
|
||||
}
|
||||
|
||||
const titleInput = page.getByLabel('Note title');
|
||||
await expect(titleInput).toBeVisible();
|
||||
|
||||
// Update the title
|
||||
await titleInput.clear();
|
||||
await titleInput.fill(testNotes.title);
|
||||
|
||||
// Trigger blur to save
|
||||
await titleInput.blur();
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Note backlinks and graph', () => {
|
||||
test('should show backlinks and graph tabs', async ({ page }) => {
|
||||
// Right panel should have Backlinks and Graph tabs
|
||||
await expect(page.getByRole('tab', { name: /backlinks/i })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: /graph/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should switch between backlinks and graph views', async ({ page }) => {
|
||||
// Click graph tab
|
||||
await page.getByRole('tab', { name: /graph/i }).click();
|
||||
await expect(page.getByRole('tab', { name: /graph/i })).toHaveAttribute('data-state', 'active');
|
||||
|
||||
// Click backlinks tab
|
||||
await page.getByRole('tab', { name: /backlinks/i }).click();
|
||||
await expect(page.getByRole('tab', { name: /backlinks/i })).toHaveAttribute('data-state', 'active');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Daily note', () => {
|
||||
test('should show "Daily note" button', async ({ page }) => {
|
||||
await expect(page.getByRole('button', { name: /daily note/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should create or select daily note when clicking button', async ({ page }) => {
|
||||
await page.getByRole('button', { name: /daily note/i }).click();
|
||||
await page.waitForTimeout(1_000);
|
||||
|
||||
// After clicking, a note with today's date should be selected
|
||||
const today = new Date().toLocaleDateString();
|
||||
// The title might contain the date
|
||||
// The updated title shows up in the note list.
|
||||
await expect(page.getByText(testNotes.title, { exact: true })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+32
-11
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
import { testProjects } from './helpers/fixtures';
|
||||
|
||||
test.describe('Projects', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -8,19 +9,39 @@ test.describe('Projects', () => {
|
||||
await expect(page.getByRole('heading', { name: /projects/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display projects page with grid layout', async ({ page }) => {
|
||||
await expect(page.getByRole('button', { name: /new project/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should open new project dialog', async ({ page }) => {
|
||||
test('creates a project via the New Project dialog', async ({ page }) => {
|
||||
await page.getByRole('button', { name: /new project/i }).click();
|
||||
// Dialog should appear
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByRole('heading', { name: 'New Project' })).toBeVisible();
|
||||
|
||||
await dialog.getByLabel('Name').fill(testProjects.name);
|
||||
await dialog.getByLabel('Description').fill(testProjects.description);
|
||||
await dialog.getByRole('button', { name: /create project/i }).click();
|
||||
|
||||
// The dialog closes and the project card appears in the grid.
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page.getByText(testProjects.name, { exact: true })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('should show project cards in grid', async ({ page }) => {
|
||||
// The grid container should exist
|
||||
const grid = page.locator('.grid, [class*="grid"]').first();
|
||||
await expect(grid).toBeVisible();
|
||||
test('opens the project detail page from a project card', async ({ page }) => {
|
||||
// Ensure the fixture project exists.
|
||||
if (!(await page.getByText(testProjects.name, { exact: true }).isVisible().catch(() => false))) {
|
||||
await page.getByRole('button', { name: /new project/i }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByLabel('Name').fill(testProjects.name);
|
||||
await dialog.getByRole('button', { name: /create project/i }).click();
|
||||
await expect(page.getByText(testProjects.name, { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
await page.getByText(testProjects.name, { exact: true }).first().click();
|
||||
|
||||
await page.waitForURL('**/projects/*', { timeout: 10_000 });
|
||||
await expect(
|
||||
page.getByRole('heading', { name: testProjects.name, level: 3 }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
|
||||
test.describe('Realtime Updates', () => {
|
||||
test('should connect to SSE endpoint', async ({ page }) => {
|
||||
await login(page);
|
||||
|
||||
// Navigate to dashboard
|
||||
await page.goto('/dashboard');
|
||||
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
|
||||
|
||||
// The SSE connection is established automatically via the realtime hook
|
||||
// Verify the page loaded without errors
|
||||
const consoleMessages: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
consoleMessages.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Check for SSE-related errors
|
||||
const sseErrors = consoleMessages.filter(
|
||||
(m) => m.includes('realtime') || m.includes('SSE') || m.includes('EventSource')
|
||||
);
|
||||
expect(sseErrors.length).toBe(0);
|
||||
});
|
||||
|
||||
test('should have realtime API endpoint', async ({ page }) => {
|
||||
const response = await page.request.get('/api/realtime');
|
||||
// SSE endpoint should return 200 with text/event-stream content type
|
||||
expect(response.status()).toBe(200);
|
||||
const contentType = response.headers()['content-type'] || '';
|
||||
expect(contentType).toContain('text/event-stream');
|
||||
});
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
import { testReports } from './helpers/fixtures';
|
||||
|
||||
test.describe('Reports', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto('/reports');
|
||||
// Wait for the reports page to load
|
||||
await expect(page.getByRole('heading', { name: /reports/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe('Reports page layout', () => {
|
||||
test('should display two-panel layout', async ({ page }) => {
|
||||
// Reports page has: reports list | editor
|
||||
await expect(page.getByText(/step back and see/i)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /new report/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /from template/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show empty state when no reports exist', async ({ page }) => {
|
||||
const reportItems = page.locator('button[aria-label^="Open report:"]');
|
||||
const count = await reportItems.count();
|
||||
|
||||
if (count === 0) {
|
||||
await expect(page.getByText(/no reports yet/i)).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Create report', () => {
|
||||
test('should create a new report when clicking "New report"', async ({ page }) => {
|
||||
const createResponsePromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/api/reports') && resp.request().method() === 'POST',
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: /new report/i }).click();
|
||||
const response = await createResponsePromise;
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
await page.waitForTimeout(1_000);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Report from template', () => {
|
||||
test('should show templates view when clicking "From template"', async ({ page }) => {
|
||||
await page.getByRole('button', { name: /from template/i }).click();
|
||||
await page.waitForTimeout(1_000);
|
||||
|
||||
// Should show template options or a templates view
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Report editor', () => {
|
||||
test('should show editor when a report is selected', async ({ page }) => {
|
||||
const reportItems = page.locator('button[aria-label^="Open report:"]');
|
||||
const count = await reportItems.count();
|
||||
|
||||
if (count === 0) {
|
||||
// Create a report first
|
||||
await page.getByRole('button', { name: /new report/i }).click();
|
||||
await page.waitForTimeout(1_000);
|
||||
}
|
||||
|
||||
// Report title input should be visible
|
||||
await expect(page.getByLabel('Report title')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should update report title when edited', async ({ page }) => {
|
||||
const reportItems = page.locator('button[aria-label^="Open report:"]');
|
||||
const count = await reportItems.count();
|
||||
|
||||
if (count === 0) {
|
||||
await page.getByRole('button', { name: /new report/i }).click();
|
||||
await page.waitForTimeout(1_000);
|
||||
}
|
||||
|
||||
const titleInput = page.getByLabel('Report title');
|
||||
await expect(titleInput).toBeVisible();
|
||||
|
||||
await titleInput.clear();
|
||||
await titleInput.fill(testReports.title);
|
||||
await titleInput.blur();
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
|
||||
test('should show report metadata badges', async ({ page }) => {
|
||||
const reportItems = page.locator('button[aria-label^="Open report:"]');
|
||||
const count = await reportItems.count();
|
||||
|
||||
if (count > 0) {
|
||||
await reportItems.first().click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should show report type and domain badges
|
||||
const badges = page.locator('[class*="badge"]');
|
||||
const badgeCount = await badges.count();
|
||||
expect(badgeCount).toBeGreaterThan(0);
|
||||
} else {
|
||||
test.skip();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
+17
-10
@@ -4,19 +4,26 @@ import { login } from './helpers/auth';
|
||||
test.describe('Search', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto('/search');
|
||||
});
|
||||
|
||||
test('should display search page with search input', async ({ page }) => {
|
||||
await page.goto('/search');
|
||||
await expect(page.getByRole('heading', { name: /search/i })).toBeVisible();
|
||||
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible();
|
||||
test('renders the search page with the query input', async ({ page }) => {
|
||||
await expect(page.getByPlaceholder(/search tasks, notes/i)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Tasks' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Notes' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should perform search and show results', async ({ page }) => {
|
||||
await page.goto('/search');
|
||||
const searchInput = page.getByPlaceholder(/search/i).first();
|
||||
await searchInput.fill('test');
|
||||
// Wait for results
|
||||
await page.waitForTimeout(1000);
|
||||
test('shows a no-results state for an unmatched query', async ({ page }) => {
|
||||
await page.getByPlaceholder(/search tasks, notes/i).fill('zzz-no-such-thing-xyz');
|
||||
await expect(page.getByText(/no results found/i)).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('filters results by type', async ({ page }) => {
|
||||
await page.getByPlaceholder(/search tasks, notes/i).fill('meeting');
|
||||
await page.getByRole('button', { name: 'Habits' }).click();
|
||||
|
||||
// The filter buttons stay interactive after toggling a type off.
|
||||
await expect(page.getByRole('button', { name: 'Habits' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Tasks' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
+59
-157
@@ -6,174 +6,76 @@ test.describe('Settings', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto('/settings');
|
||||
// Wait for the settings page to load
|
||||
await expect(page.getByRole('heading', { name: /settings/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe('Settings page layout', () => {
|
||||
test('should display all settings tabs', async ({ page }) => {
|
||||
// Vertical tabs: Appearance, Domains, Keyboard Shortcuts, Agents, Webhooks, Import & Export, Error Log
|
||||
await expect(page.getByRole('tab', { name: /appearance/i })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: /domains/i })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: /keyboard shortcuts/i })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: /agents/i })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: /webhooks/i })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: /import.*export/i })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: /error log/i })).toBeVisible();
|
||||
test('renders every settings tab', async ({ page }) => {
|
||||
const tabs = [
|
||||
'Appearance',
|
||||
'Domains',
|
||||
'Tags',
|
||||
'Custom Fields',
|
||||
'Keyboard Shortcuts',
|
||||
'Agents & Permissions',
|
||||
'Webhooks',
|
||||
'Import & Export',
|
||||
'Error Log',
|
||||
];
|
||||
for (const tab of tabs) {
|
||||
await expect(page.getByRole('button', { name: tab })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('opens on the Appearance tab with theme controls', async ({ page }) => {
|
||||
await expect(page.getByRole('heading', { name: 'Theme' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Accent Color' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Font Size' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('creates a domain from the Domains tab', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Domains' }).click();
|
||||
await page.getByRole('button', { name: /new domain/i }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByRole('heading', { name: 'New Domain' })).toBeVisible();
|
||||
|
||||
await dialog.getByLabel('Name').fill(testDomains.name);
|
||||
|
||||
const createResponse = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/api/domains') && resp.request().method() === 'POST',
|
||||
);
|
||||
await dialog.getByRole('button', { name: /^create$/i }).click();
|
||||
expect((await createResponse).ok()).toBeTruthy();
|
||||
|
||||
await expect(page.getByText(testDomains.name, { exact: true })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Appearance settings', () => {
|
||||
test('should show theme mode selector', async ({ page }) => {
|
||||
// Appearance tab should be active by default
|
||||
await expect(page.getByLabel('Theme')).toBeVisible();
|
||||
});
|
||||
test('Webhooks tab opens the New Webhook dialog', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Webhooks' }).click();
|
||||
await expect(page.getByRole('button', { name: /new webhook/i })).toBeVisible();
|
||||
|
||||
test('should change theme mode', async ({ page }) => {
|
||||
// Find the theme mode select
|
||||
const themeSelect = page.getByLabel('Theme');
|
||||
await expect(themeSelect).toBeVisible();
|
||||
|
||||
// Click to open dropdown
|
||||
await themeSelect.click();
|
||||
|
||||
// Should show theme options (light, dark, system)
|
||||
await expect(page.getByRole('option', { name: /light/i })).toBeVisible();
|
||||
await expect(page.getByRole('option', { name: /dark/i })).toBeVisible();
|
||||
await expect(page.getByRole('option', { name: /system/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show accent color picker', async ({ page }) => {
|
||||
const colorPicker = page.getByRole('radiogroup', { name: /accent color/i });
|
||||
await expect(colorPicker).toBeVisible();
|
||||
|
||||
// Should have color buttons
|
||||
const colorButtons = colorPicker.getByRole('radio');
|
||||
const count = await colorButtons.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('should change accent color', async ({ page }) => {
|
||||
const colorPicker = page.getByRole('radiogroup', { name: /accent color/i });
|
||||
const colorButtons = colorPicker.getByRole('radio');
|
||||
const count = await colorButtons.count();
|
||||
|
||||
if (count > 1) {
|
||||
// Click a different color
|
||||
await colorButtons.nth(1).click();
|
||||
// Should update the accent color
|
||||
await expect(colorButtons.nth(1)).toHaveAttribute('aria-checked', 'true');
|
||||
}
|
||||
});
|
||||
await page.getByRole('button', { name: /new webhook/i }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByLabel('Name')).toBeVisible();
|
||||
await expect(dialog.getByLabel('URL')).toBeVisible();
|
||||
await expect(dialog.getByLabel(/events/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe('Domains settings', () => {
|
||||
test('should switch to domains tab', async ({ page }) => {
|
||||
await page.getByRole('tab', { name: /domains/i }).click();
|
||||
await expect(page.getByText(/manage your workspace domains/i)).toBeVisible();
|
||||
});
|
||||
test('Import & Export tab renders import and export sections', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Import & Export' }).click();
|
||||
|
||||
test('should add a new domain', async ({ page }) => {
|
||||
await page.getByRole('tab', { name: /domains/i }).click();
|
||||
|
||||
const domainInput = page.getByPlaceholder(/new domain name/i);
|
||||
await expect(domainInput).toBeVisible();
|
||||
|
||||
await domainInput.fill(testDomains.name);
|
||||
|
||||
// Intercept the API call
|
||||
const createResponsePromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/api/domains') && resp.request().method() === 'POST',
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: /add/i }).click();
|
||||
|
||||
const response = await createResponsePromise;
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
// The new domain should appear in the list
|
||||
await page.waitForTimeout(1_000);
|
||||
await expect(page.getByText(testDomains.name)).toBeVisible();
|
||||
});
|
||||
await expect(page.getByRole('heading', { name: 'Import' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Export' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /download export/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /^import$/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe('Keyboard shortcuts settings', () => {
|
||||
test('should show shortcuts list', async ({ page }) => {
|
||||
await page.getByRole('tab', { name: /keyboard shortcuts/i }).click();
|
||||
test('Error Log tab renders its controls', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Error Log' }).click();
|
||||
|
||||
// Should show shortcuts toggle and list
|
||||
await expect(page.getByLabel('Enable keyboard shortcuts')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /reset to defaults/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should toggle keyboard shortcuts', async ({ page }) => {
|
||||
await page.getByRole('tab', { name: /keyboard shortcuts/i }).click();
|
||||
|
||||
const toggle = page.getByLabel('Enable keyboard shortcuts');
|
||||
const initialState = await toggle.getAttribute('data-state');
|
||||
|
||||
await toggle.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// State should have changed
|
||||
const newState = await toggle.getAttribute('data-state');
|
||||
expect(newState).not.toBe(initialState);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Agents settings', () => {
|
||||
test('should show agents list and create button', async ({ page }) => {
|
||||
await page.getByRole('tab', { name: /agents/i }).click();
|
||||
|
||||
await expect(page.getByText(/agents.*permissions/i)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /new agent/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should open create agent dialog', async ({ page }) => {
|
||||
await page.getByRole('tab', { name: /agents/i }).click();
|
||||
await page.getByRole('button', { name: /new agent/i }).click();
|
||||
|
||||
// Dialog should open
|
||||
await expect(page.getByRole('dialog', { name: /create agent/i })).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Should have form fields
|
||||
await expect(page.getByLabel('Name')).toBeVisible();
|
||||
await expect(page.getByLabel('Permission tier')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Import & Export settings', () => {
|
||||
test('should show export and import sections', async ({ page }) => {
|
||||
await page.getByRole('tab', { name: /import.*export/i }).click();
|
||||
|
||||
await expect(page.getByText(/export data/i)).toBeVisible();
|
||||
await expect(page.getByText(/import data/i)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /export to json/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /import from json/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show collection selection for export', async ({ page }) => {
|
||||
await page.getByRole('tab', { name: /import.*export/i }).click();
|
||||
|
||||
// Should have checkboxes for each collection
|
||||
await expect(page.getByLabel(/select all/i)).toBeVisible();
|
||||
await expect(page.getByLabel(/export tasks/i)).toBeVisible();
|
||||
await expect(page.getByLabel(/export habits/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should toggle collection selection', async ({ page }) => {
|
||||
await page.getByRole('tab', { name: /import.*export/i }).click();
|
||||
|
||||
const selectAll = page.getByLabel(/select all/i);
|
||||
await expect(selectAll).toBeVisible();
|
||||
|
||||
// Toggle select all off
|
||||
await selectAll.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Tasks checkbox should be unchecked
|
||||
const tasksCheckbox = page.getByLabel(/export tasks/i);
|
||||
// The checkbox state should be unchecked now
|
||||
});
|
||||
await expect(page.getByRole('button', { name: /clear all/i })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
+40
-94
@@ -2,117 +2,63 @@ import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
import { testTasks } from './helpers/fixtures';
|
||||
|
||||
test.describe('Task Management', () => {
|
||||
test.describe('Tasks', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto('/tasks');
|
||||
// Wait for the tasks page to load
|
||||
await expect(page.getByRole('heading', { name: /tasks/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe('Tasks Kanban Board', () => {
|
||||
test('should display Kanban board with three columns', async ({ page }) => {
|
||||
// Should show the three Kanban columns
|
||||
await expect(page.getByRole('heading', { name: /to do/i })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: /in progress/i })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: /done/i })).toBeVisible();
|
||||
});
|
||||
test('shows the kanban board with the four status columns', async ({ page }) => {
|
||||
await expect(page.getByRole('heading', { name: 'Todo' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'In Progress' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Done' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Cancelled' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should switch between Board and List views', async ({ page }) => {
|
||||
// Default is Board (Kanban) view
|
||||
await expect(page.getByText('Board').first()).toHaveAttribute('data-state', 'active');
|
||||
test('creates a task via the New Task dialog and shows it on the board', async ({ page }) => {
|
||||
await page.getByRole('button', { name: /new task/i }).click();
|
||||
|
||||
// Switch to List view
|
||||
await page.getByRole('tab', { name: /list/i }).click();
|
||||
await expect(page.getByText('List').first()).toHaveAttribute('data-state', 'active');
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByRole('heading', { name: 'New Task' })).toBeVisible();
|
||||
|
||||
// Switch back to Board
|
||||
await page.getByRole('tab', { name: /board/i }).click();
|
||||
await expect(page.getByText('Board').first()).toHaveAttribute('data-state', 'active');
|
||||
await dialog.getByLabel('Title').fill(testTasks.title);
|
||||
await dialog.getByLabel('Description').fill(testTasks.description);
|
||||
await dialog.getByRole('button', { name: /create task/i }).click();
|
||||
|
||||
// The dialog closes and the task appears in the Todo column.
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page.getByText(testTasks.title, { exact: true })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Task CRUD', () => {
|
||||
test('should create a new task via detail panel', async ({ page }) => {
|
||||
// Intercept the API call
|
||||
const createResponsePromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/api/tasks') && resp.request().method() === 'POST',
|
||||
);
|
||||
test('opens the task detail page when a task card is clicked', async ({ page }) => {
|
||||
// Make sure a task exists before trying to open it.
|
||||
if (!(await page.getByText(testTasks.title, { exact: true }).isVisible().catch(() => false))) {
|
||||
await page.getByRole('button', { name: /new task/i }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByLabel('Title').fill(testTasks.title);
|
||||
await dialog.getByRole('button', { name: /create task/i }).click();
|
||||
await expect(page.getByText(testTasks.title, { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
// Click on the "To Do" column area to open task creation
|
||||
// The app uses a TaskDetailPanel for creation
|
||||
// We'll look for an existing task or the create mechanism
|
||||
// The kanban view fetches tasks - let's verify it loaded
|
||||
await expect(page.getByRole('list', { name: /to do/i })).toBeVisible();
|
||||
await page.getByText(testTasks.title, { exact: true }).first().click();
|
||||
|
||||
// Find and click a task if one exists, or trigger creation
|
||||
// The task detail panel opens when clicking a task
|
||||
// For creation, the API can be called directly - verify the flow works
|
||||
const taskCards = page.locator('[role="list"] [class*="cursor-grab"]');
|
||||
const count = await taskCards.count();
|
||||
await page.waitForURL('**/tasks/*', { timeout: 10_000 });
|
||||
|
||||
// If tasks exist, click one to open detail panel
|
||||
if (count > 0) {
|
||||
await taskCards.first().click();
|
||||
// Detail panel should open
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
});
|
||||
|
||||
test('should open task detail panel when clicking a task', async ({ page }) => {
|
||||
// Look for any task card in the kanban board
|
||||
const taskCards = page.locator('[role="list"] [class*="cursor-grab"]');
|
||||
const count = await taskCards.count();
|
||||
|
||||
if (count > 0) {
|
||||
await taskCards.first().click();
|
||||
// Detail panel should appear as a dialog/sheet
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 });
|
||||
} else {
|
||||
// No tasks yet – skip gracefully
|
||||
test.skip();
|
||||
}
|
||||
});
|
||||
// The detail page renders the task title as a heading.
|
||||
await expect(
|
||||
page.getByRole('heading', { name: testTasks.title, level: 3 }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test.describe('Task Drag and Drop', () => {
|
||||
test('should have draggable task cards', async ({ page }) => {
|
||||
// Verify draggable elements exist
|
||||
const draggableTasks = page.locator('[role="list"] [class*="cursor-grab"]');
|
||||
const count = await draggableTasks.count();
|
||||
test('switches between board and list views', async ({ page }) => {
|
||||
await page.getByRole('tab', { name: /list view/i }).click();
|
||||
await expect(page.getByRole('columnheader', { name: 'Title' })).toBeVisible();
|
||||
|
||||
if (count > 0) {
|
||||
// Verify the first task has cursor-grab (indicating draggable)
|
||||
await expect(draggableTasks.first()).toHaveClass(/cursor-grab/);
|
||||
} else {
|
||||
test.skip();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Task Filtering and Search', () => {
|
||||
test('should display domain badges on tasks', async ({ page }) => {
|
||||
// Tasks should show domain badges
|
||||
const domainBadges = page.locator('[role="list"] [class*="badge"]');
|
||||
const count = await domainBadges.count();
|
||||
|
||||
if (count > 0) {
|
||||
// At least one badge should be visible
|
||||
await expect(domainBadges.first()).toBeVisible();
|
||||
} else {
|
||||
test.skip();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Task List View', () => {
|
||||
test('should display tasks in list format', async ({ page }) => {
|
||||
// Switch to list view
|
||||
await page.getByRole('tab', { name: /list/i }).click();
|
||||
|
||||
// The list view should be rendered
|
||||
// TasksListView component renders tasks in a table or list format
|
||||
await page.waitForTimeout(500); // Wait for view transition
|
||||
});
|
||||
await page.getByRole('tab', { name: /board view/i }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Todo' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"types": [],
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
|
||||
test.describe('Webhooks', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
});
|
||||
|
||||
test('should display webhooks page with create button', async ({ page }) => {
|
||||
await page.goto('/settings/webhooks');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// The webhooks page should have a create button or heading
|
||||
const heading = page.getByRole('heading', { name: /webhook/i });
|
||||
const createBtn = page.getByRole('button', { name: /create|new webhook/i });
|
||||
|
||||
// At least one should be visible
|
||||
await expect(
|
||||
heading.or(createBtn)
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('should list webhook deliveries endpoint', async ({ page }) => {
|
||||
const response = await page.request.get('/api/webhook-deliveries?limit=5');
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const body = await response.json();
|
||||
expect(body).toHaveProperty('items');
|
||||
expect(body).toHaveProperty('totalItems');
|
||||
expect(Array.isArray(body.items)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user