test(tracker-web): make e2e deterministic + add axe a11y and console checks
Rewrite the Playwright suite to mock the platform-service at the Next.js proxy boundary (/api/tracker, /api/auth) so no live backend is required, and provide the env vars /api/health needs via webServer.env. Adds a login->dashboard happy path, board/list toggle and vote-prompt coverage, axe-core accessibility assertions (resolved from the workspace, no new dependency), and a no-unexpected-console-errors check. The axe gate surfaced a real bug: the roadmap type-filter and submit-modal <select> elements had no accessible name. Fixed by adding aria-labels. Also ignore coverage/test-results/playwright-report in eslint. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
f0911e65ed
commit
1c231d6659
@ -1,16 +1,167 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect, type Page, type Route } from '@playwright/test';
|
||||||
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* E2E tests for the Tracker dashboard.
|
* E2E tests for the Tracker dashboard.
|
||||||
*
|
*
|
||||||
* Tests cover login page, authentication redirects, public roadmap,
|
* These tests are deterministic: every call that would hit the backend
|
||||||
* and dashboard structure.
|
* platform-service is mocked at the Next.js proxy boundary (`/api/tracker/**`,
|
||||||
|
* `/api/auth/**`). No live platform-service is required. The only real server
|
||||||
|
* handler exercised end-to-end is `/api/health`, whose required env vars are
|
||||||
|
* provided by the Playwright `webServer.env` config.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locate the axe-core browser source so we can inject it into the page.
|
||||||
|
*
|
||||||
|
* axe-core is already present in this pnpm monorepo as a transitive dependency
|
||||||
|
* of eslint-plugin-jsx-a11y (pulled in by eslint-config-next), so we resolve it
|
||||||
|
* from the store/node_modules rather than declaring a redundant dependency.
|
||||||
|
* Playwright runs this suite with cwd at the package root (dashboards/tracker-web).
|
||||||
|
*/
|
||||||
|
function resolveAxeSource(): string {
|
||||||
|
const candidates = [
|
||||||
|
join(process.cwd(), 'node_modules', 'axe-core', 'axe.js'),
|
||||||
|
join(process.cwd(), '..', '..', 'node_modules', 'axe-core', 'axe.js'),
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (existsSync(candidate)) return readFileSync(candidate, 'utf-8');
|
||||||
|
}
|
||||||
|
// Fall back to scanning the pnpm store at the monorepo root.
|
||||||
|
const storeDir = join(process.cwd(), '..', '..', 'node_modules', '.pnpm');
|
||||||
|
const entry = readdirSync(storeDir).find(d => d.startsWith('axe-core@'));
|
||||||
|
if (!entry) {
|
||||||
|
throw new Error('Could not locate axe-core in node_modules or the pnpm store');
|
||||||
|
}
|
||||||
|
return readFileSync(join(storeDir, entry, 'node_modules', 'axe-core', 'axe.js'), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
const AXE_SOURCE = resolveAxeSource();
|
||||||
|
|
||||||
|
// ── Fixtures ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type RoadmapItem = {
|
||||||
|
id: string;
|
||||||
|
productId: string;
|
||||||
|
type: 'bug' | 'feature' | 'task';
|
||||||
|
status: 'open' | 'in_progress' | 'done';
|
||||||
|
priority: 'critical' | 'high' | 'medium' | 'low';
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
labels: string[];
|
||||||
|
assignee: string | null;
|
||||||
|
reportedBy: string;
|
||||||
|
source: 'internal' | 'user_submitted' | 'auto_detected';
|
||||||
|
visibility: 'internal' | 'public';
|
||||||
|
voteCount: number;
|
||||||
|
commentCount: number;
|
||||||
|
targetRelease: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeItem(partial: Partial<RoadmapItem> & { id: string; title: string }): RoadmapItem {
|
||||||
|
return {
|
||||||
|
productId: 'tracker-e2e',
|
||||||
|
type: 'feature',
|
||||||
|
status: 'open',
|
||||||
|
priority: 'medium',
|
||||||
|
description: 'A sample roadmap item used in e2e tests.',
|
||||||
|
labels: [],
|
||||||
|
assignee: null,
|
||||||
|
reportedBy: 'system',
|
||||||
|
source: 'internal',
|
||||||
|
visibility: 'public',
|
||||||
|
voteCount: 0,
|
||||||
|
commentCount: 0,
|
||||||
|
targetRelease: null,
|
||||||
|
createdAt: '2025-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2025-01-01T00:00:00.000Z',
|
||||||
|
...partial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROADMAP_ITEMS: RoadmapItem[] = [
|
||||||
|
makeItem({ id: '1', title: 'Dark mode toggle', status: 'open', type: 'feature', voteCount: 12 }),
|
||||||
|
makeItem({
|
||||||
|
id: '2',
|
||||||
|
title: 'Faster sync engine',
|
||||||
|
status: 'in_progress',
|
||||||
|
type: 'feature',
|
||||||
|
priority: 'high',
|
||||||
|
voteCount: 8,
|
||||||
|
}),
|
||||||
|
makeItem({ id: '3', title: 'Export to CSV', status: 'done', type: 'task', voteCount: 5 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const ROADMAP_STATS = {
|
||||||
|
total: 3,
|
||||||
|
byStatus: { open: 1, in_progress: 1, done: 1 },
|
||||||
|
byType: { feature: 2, task: 1 },
|
||||||
|
totalVotes: 25,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Mock the public roadmap proxy endpoints so the page renders deterministically. */
|
||||||
|
async function mockRoadmap(page: Page): Promise<void> {
|
||||||
|
await page.route('**/api/tracker/**', async (route: Route) => {
|
||||||
|
const url = route.request().url();
|
||||||
|
if (url.includes('/public/roadmap/stats')) {
|
||||||
|
return route.fulfill({ json: ROADMAP_STATS });
|
||||||
|
}
|
||||||
|
if (url.includes('/public/roadmap')) {
|
||||||
|
return route.fulfill({
|
||||||
|
json: { items: ROADMAP_ITEMS, total: ROADMAP_ITEMS.length, limit: 100, offset: 0 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Default: empty success so nothing else hangs on the network.
|
||||||
|
return route.fulfill({ json: {} });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inject axe-core and return only serious/critical accessibility violations. */
|
||||||
|
async function seriousA11yViolations(page: Page): Promise<{ id: string; impact: string }[]> {
|
||||||
|
await page.addScriptTag({ content: AXE_SOURCE });
|
||||||
|
const violations = await page.evaluate(async () => {
|
||||||
|
// axe is injected onto window by the script tag above.
|
||||||
|
const axe = (
|
||||||
|
window as unknown as {
|
||||||
|
axe: {
|
||||||
|
run: (
|
||||||
|
ctx: unknown,
|
||||||
|
opts: unknown
|
||||||
|
) => Promise<{ violations: { id: string; impact: string | null }[] }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).axe;
|
||||||
|
const result = await axe.run(document, {
|
||||||
|
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] },
|
||||||
|
});
|
||||||
|
return result.violations.map(v => ({ id: v.id, impact: v.impact ?? 'unknown' }));
|
||||||
|
});
|
||||||
|
return violations.filter(v => v.impact === 'serious' || v.impact === 'critical');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect uncaught page errors and console.error messages (minus dev-only noise). */
|
||||||
|
function collectPageErrors(page: Page): string[] {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const benign = ['favicon', 'Download the React DevTools', 'Failed to load resource', 'net::ERR_'];
|
||||||
|
page.on('pageerror', err => errors.push(`pageerror: ${err.message}`));
|
||||||
|
page.on('console', msg => {
|
||||||
|
if (msg.type() !== 'error') return;
|
||||||
|
const text = msg.text();
|
||||||
|
if (benign.some(b => text.includes(b))) return;
|
||||||
|
errors.push(`console.error: ${text}`);
|
||||||
|
});
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Login page ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
test.describe('Tracker Login Page', () => {
|
test.describe('Tracker Login Page', () => {
|
||||||
test('shows login form with correct branding', async ({ page }) => {
|
test('shows login form with correct branding', async ({ page }) => {
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
await expect(page.getByText('Tracker')).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Tracker' })).toBeVisible();
|
||||||
await expect(page.getByText('Feature requests, bugs & task management')).toBeVisible();
|
await expect(page.getByText('Feature requests, bugs & task management')).toBeVisible();
|
||||||
await expect(page.getByLabel('Email')).toBeVisible();
|
await expect(page.getByLabel('Email')).toBeVisible();
|
||||||
await expect(page.getByLabel('Password')).toBeVisible();
|
await expect(page.getByLabel('Password')).toBeVisible();
|
||||||
@ -23,20 +174,28 @@ test.describe('Tracker Login Page', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('shows error for invalid credentials', async ({ page }) => {
|
test('shows error for invalid credentials', async ({ page }) => {
|
||||||
|
// Mock the login proxy to reject the credentials deterministically.
|
||||||
|
await page.route('**/api/auth/login', (route: Route) =>
|
||||||
|
route.fulfill({ status: 401, json: { error: 'Invalid email or password' } })
|
||||||
|
);
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
await page.getByLabel('Email').fill('bad@user.com');
|
await page.getByLabel('Email').fill('bad@user.com');
|
||||||
await page.getByLabel('Password').fill('wrongpassword');
|
await page.getByLabel('Password').fill('wrongpassword');
|
||||||
await page.getByRole('button', { name: /sign in/i }).click();
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
await expect(page.getByText(/failed|error|invalid/i)).toBeVisible({
|
await expect(page.getByText('Invalid email or password')).toBeVisible({ timeout: 10000 });
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows loading state on submit', async ({ page }) => {
|
test('shows loading state on submit', async ({ page }) => {
|
||||||
// Block API to keep loading state visible
|
// Delay the login response so the loading state stays visible.
|
||||||
await page.route(
|
await page.route(
|
||||||
'**/api/auth/**',
|
'**/api/auth/login',
|
||||||
route => new Promise(resolve => setTimeout(() => resolve(route.abort()), 3000))
|
route =>
|
||||||
|
new Promise<void>(resolve =>
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve();
|
||||||
|
route.fulfill({ status: 401, json: { error: 'nope' } });
|
||||||
|
}, 3000)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
await page.getByLabel('Email').fill('test@example.com');
|
await page.getByLabel('Email').fill('test@example.com');
|
||||||
@ -44,8 +203,17 @@ test.describe('Tracker Login Page', () => {
|
|||||||
await page.getByRole('button', { name: /sign in/i }).click();
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
await expect(page.getByText('Signing in...')).toBeVisible();
|
await expect(page.getByText('Signing in...')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('has no serious accessibility violations', async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Tracker' })).toBeVisible();
|
||||||
|
const violations = await seriousA11yViolations(page);
|
||||||
|
expect(violations, JSON.stringify(violations, null, 2)).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Protected routes ────────────────────────────────────────────────
|
||||||
|
|
||||||
test.describe('Tracker — Protected Routes', () => {
|
test.describe('Tracker — Protected Routes', () => {
|
||||||
test('/ redirects to login when not authenticated', async ({ page }) => {
|
test('/ redirects to login when not authenticated', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
@ -58,57 +226,133 @@ test.describe('Tracker — Protected Routes', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Tracker — Public Roadmap', () => {
|
// ── Login → dashboard happy path (fully mocked) ─────────────────────
|
||||||
test('roadmap page renders board layout', async ({ page }) => {
|
|
||||||
await page.goto('/roadmap');
|
test.describe('Tracker — Authenticated dashboard', () => {
|
||||||
// The roadmap page is public — no auth required
|
test('login redirects to dashboard and renders stats', async ({ page }) => {
|
||||||
await expect(page.getByText(/roadmap/i).first()).toBeVisible({
|
await page.route('**/api/auth/login', (route: Route) =>
|
||||||
timeout: 10000,
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
accessToken: 'fake-e2e-token',
|
||||||
|
user: { id: 'u1', email: 'admin@example.com', role: 'admin', displayName: 'Admin' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/auth/me', (route: Route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: { id: 'u1', email: 'admin@example.com', role: 'admin', displayName: 'Admin' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/tracker/**', (route: Route) => {
|
||||||
|
if (route.request().url().includes('/items/stats')) {
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
productId: 'tracker-e2e',
|
||||||
|
total: 42,
|
||||||
|
byType: { bug: 10, feature: 30, task: 2 },
|
||||||
|
byStatus: { open: 20, in_progress: 12, done: 10 },
|
||||||
|
byPriority: { critical: 2, high: 10, medium: 20, low: 10 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.fulfill({ json: {} });
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
test('roadmap page has search and filter controls', async ({ page }) => {
|
await page.goto('/login');
|
||||||
await page.goto('/roadmap');
|
await page.getByLabel('Email').fill('admin@example.com');
|
||||||
// Should have a search input
|
await page.getByLabel('Password').fill('correct-password');
|
||||||
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible({ timeout: 10000 });
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
});
|
|
||||||
|
|
||||||
test('roadmap page has submit suggestion button', async ({ page }) => {
|
await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 });
|
||||||
await page.goto('/roadmap');
|
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: /suggest|submit|new/i }).first()).toBeVisible({
|
await expect(page.getByText('42')).toBeVisible();
|
||||||
timeout: 10000,
|
await expect(page.getByText('admin@example.com')).toBeVisible();
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('roadmap page shows status columns in board view', async ({ page }) => {
|
|
||||||
await page.goto('/roadmap');
|
|
||||||
// Board view shows Planned, In Progress, Complete columns
|
|
||||||
await expect(page.getByText('Planned').first()).toBeVisible({
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
await expect(page.getByText('In Progress').first()).toBeVisible();
|
|
||||||
await expect(page.getByText('Complete').first()).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('roadmap page can toggle between board and list view', async ({ page }) => {
|
|
||||||
await page.goto('/roadmap');
|
|
||||||
// Look for view toggle buttons
|
|
||||||
const listBtn = page.getByRole('button', { name: /list/i });
|
|
||||||
if (await listBtn.isVisible()) {
|
|
||||||
await listBtn.click();
|
|
||||||
// Should now show list view
|
|
||||||
await expect(page.locator("table, [role='list']").first()).toBeVisible({
|
|
||||||
timeout: 5000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Public roadmap (mocked) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Tracker — Public Roadmap', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await mockRoadmap(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders header and stats from mocked data', async ({ page }) => {
|
||||||
|
await page.goto('/roadmap');
|
||||||
|
await expect(page.getByRole('heading', { name: /product roadmap/i })).toBeVisible();
|
||||||
|
await expect(page.getByText('Total Items')).toBeVisible();
|
||||||
|
// totalVotes stat from ROADMAP_STATS
|
||||||
|
await expect(page.getByText('25')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('has search and filter controls', async ({ page }) => {
|
||||||
|
await page.goto('/roadmap');
|
||||||
|
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('has a submit-idea button', async ({ page }) => {
|
||||||
|
await page.goto('/roadmap');
|
||||||
|
await expect(page.getByRole('button', { name: /submit idea/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows status columns in board view with items', async ({ page }) => {
|
||||||
|
await page.goto('/roadmap');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Planned' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'In Progress' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Complete' })).toBeVisible();
|
||||||
|
// Items land in the correct columns
|
||||||
|
await expect(page.getByRole('heading', { name: 'Dark mode toggle' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Faster sync engine' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Export to CSV' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can toggle between board and list view', async ({ page }) => {
|
||||||
|
await page.goto('/roadmap');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Dark mode toggle' })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'List', exact: true }).click();
|
||||||
|
// List view still shows the items (rendered as rows, not a <table>)
|
||||||
|
await expect(page.getByRole('heading', { name: 'Dark mode toggle' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Export to CSV' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opens the submit-idea modal', async ({ page }) => {
|
||||||
|
await page.goto('/roadmap');
|
||||||
|
await page.getByRole('button', { name: /submit idea/i }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: /submit an idea/i })).toBeVisible();
|
||||||
|
await expect(page.getByPlaceholder('Your email')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prompts for email when voting without a saved email', async ({ page }) => {
|
||||||
|
await page.goto('/roadmap');
|
||||||
|
await page.getByRole('button', { name: /upvote dark mode toggle/i }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: /enter your email to vote/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('has no serious accessibility violations', async ({ page }) => {
|
||||||
|
await page.goto('/roadmap');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Dark mode toggle' })).toBeVisible();
|
||||||
|
const violations = await seriousA11yViolations(page);
|
||||||
|
expect(violations, JSON.stringify(violations, null, 2)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logs no unexpected console errors', async ({ page }) => {
|
||||||
|
const errors = collectPageErrors(page);
|
||||||
|
await page.goto('/roadmap');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Dark mode toggle' })).toBeVisible();
|
||||||
|
expect(errors, errors.join('\n')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Health endpoint (real handler, env provided by webServer) ───────
|
||||||
|
|
||||||
test.describe('Tracker — Health', () => {
|
test.describe('Tracker — Health', () => {
|
||||||
test('GET /api/health returns ok', async ({ request }) => {
|
test('GET /api/health returns ok with required env set', async ({ request }) => {
|
||||||
const res = await request.get('/api/health');
|
const res = await request.get('/api/health');
|
||||||
expect(res.ok()).toBeTruthy();
|
expect(res.ok()).toBeTruthy();
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.status).toBe('ok');
|
expect(body.status).toBe('ok');
|
||||||
|
expect(body.service).toBe('tracker-dashboard');
|
||||||
|
expect(Array.isArray(body.checks)).toBe(true);
|
||||||
|
expect(body.checks.every((c: { status: string }) => c.status === 'pass')).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -15,6 +15,9 @@ const eslintConfig = defineConfig([
|
|||||||
".next/**",
|
".next/**",
|
||||||
"out/**",
|
"out/**",
|
||||||
"build/**",
|
"build/**",
|
||||||
|
"coverage/**",
|
||||||
|
"test-results/**",
|
||||||
|
"playwright-report/**",
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -22,5 +22,12 @@ export default defineConfig({
|
|||||||
url: 'http://localhost:3003',
|
url: 'http://localhost:3003',
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
|
// Provide the env vars /api/health requires so the health gate is deterministic.
|
||||||
|
// These are non-secret placeholders only used to exercise the health handler.
|
||||||
|
env: {
|
||||||
|
PLATFORM_API_URL: 'http://localhost:4003',
|
||||||
|
JWT_SECRET: 'e2e-placeholder-not-a-real-secret',
|
||||||
|
DEFAULT_PRODUCT_ID: 'tracker-e2e',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -256,6 +256,7 @@ export default function RoadmapPage() {
|
|||||||
<select
|
<select
|
||||||
value={typeFilter}
|
value={typeFilter}
|
||||||
onChange={e => setTypeFilter(e.target.value)}
|
onChange={e => setTypeFilter(e.target.value)}
|
||||||
|
aria-label="Filter by type"
|
||||||
className="px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-sm"
|
className="px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-sm"
|
||||||
>
|
>
|
||||||
<option value="">All Types</option>
|
<option value="">All Types</option>
|
||||||
@ -462,6 +463,7 @@ export default function RoadmapPage() {
|
|||||||
<select
|
<select
|
||||||
value={submitForm.type}
|
value={submitForm.type}
|
||||||
onChange={e => setSubmitForm({ ...submitForm, type: e.target.value })}
|
onChange={e => setSubmitForm({ ...submitForm, type: e.target.value })}
|
||||||
|
aria-label="Request type"
|
||||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-sm"
|
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-sm"
|
||||||
>
|
>
|
||||||
<option value="feature">Feature Request</option>
|
<option value="feature">Feature Request</option>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user