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:
2026-08-10 08:53:18 +00:00
parent 6cb4b9f1b5
commit a60b75f075
99 changed files with 6238 additions and 2954 deletions
+37
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
});
});
+37
View File
@@ -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,
});
});
});
+41
View File
@@ -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
View File
@@ -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 });
}
});
});
+17
View File
@@ -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;
};
};
+26
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}`,
};
-152
View File
@@ -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 */ }
}
});
});
});
-90
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 });
});
});
-37
View File
@@ -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');
});
});
-104
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
});
});
+1
View File
@@ -10,6 +10,7 @@
"noEmit": true,
"esModuleInterop": true,
"isolatedModules": true,
"types": [],
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
-31
View File
@@ -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();
});
});