From 42650733ab96c9e125d2664f4b7493575e33a601 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 21:31:34 +0000 Subject: [PATCH 1/3] fix(demo): store test_runs_cases created_at in milliseconds in seed The seed generator wrote created_at as Unix seconds into a timestamp_ms column, so typed reads yielded 1970 dates and any millisecond-based comparison against it (e.g. an age-window cutoff) matched nothing in the demo database. Store the millisecond value directly. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Co6VcMZLLwZWZ4FiUmR7fY --- application/public/demo/seed.version.json | 4 ++-- application/scripts/generate-demo-seed.mjs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/application/public/demo/seed.version.json b/application/public/demo/seed.version.json index 4013d755..71f79bbc 100644 --- a/application/public/demo/seed.version.json +++ b/application/public/demo/seed.version.json @@ -1,4 +1,4 @@ { - "hash": "957dc11f122aeb81e4bfc2d97b8582b12ee84f9c6a8ed59c9479c2b580cd66a2", - "generatedAt": "2026-07-16T19:52:23.939Z" + "hash": "0f746c67db995a91357b46d5775c2badc795b0e0817b89378899eb7eca9701aa", + "generatedAt": "2026-07-16T21:19:57.622Z" } diff --git a/application/scripts/generate-demo-seed.mjs b/application/scripts/generate-demo-seed.mjs index b7f82b4c..d697e907 100644 --- a/application/scripts/generate-demo-seed.mjs +++ b/application/scripts/generate-demo-seed.mjs @@ -1068,7 +1068,7 @@ for (const [pid, cfg] of Object.entries(PROJECT_CONFIGS)) { test_source_frames: isFailedCase ? testSourceFramesForCluster(clusterDef) : null, worker_index: workerIndex, started_at: caseStartMs, - created_at: Math.floor(caseStartMs / 1000), + created_at: caseStartMs, }; TEST_RUNS_CASES.push(trc); From 77b1651117cefc870dc9437600a62c04807f27f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 21:32:06 +0000 Subject: [PATCH 2/3] feat(app)!: paginate, filter and revamp the project test-cases catalog The project test-cases catalog (page and tab) loaded every case with no pagination, rendered flat icon counters, and duplicated ~110 lines of table markup across two call sites. Rework it end to end. Backend (getProjectTestCases): return a { items, total, limit, offset } envelope and accept limit/offset/q/status/maxAgeDays/sort/dir. Filtering, search (case-insensitive over title and file path), the last-run age window (EXISTS on test_runs_cases), sorting (NULLS LAST) and pagination all run in SQL. Timed-out runs fold into failedRuns; pass rate is computed over executed runs only (null when none); a derived status category powers the filter and badge. Parsing/clamping lives in a shared parseTestCasesQuery reused by the REST endpoint and the demo router; the MCP get_project_test_catalog tool now pushes paging into SQL and gains a query filter. Also aliases the flaky subquery so it is valid on PostgreSQL. UI: one shared ProjectTestCasesTable (search, status pills, age select, server-side sortable headers, pagination, flat/tree toggle, a desktop table plus a mobile card list, and loading/error/empty states) replaces both copies, with a ProjectTestCasesTree for per-spec grouping and a PassRateIndicator gauge. Results render as a TestStatusBar, file paths reuse OpenInIdeLink, and filter state syncs to the URL. formatDuration now rounds to whole milliseconds (avg durations no longer show long decimals) and renders zero as "0 seconds". BREAKING CHANGE: GET /api/projects/:id/test-cases now returns { items, total, limit, offset } instead of a bare array and accepts limit/offset/q/status/maxAgeDays/sort/dir query parameters. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Co6VcMZLLwZWZ4FiUmR7fY --- .../components/project/PassRateIndicator.vue | 26 + .../project/ProjectTestCasesTable.vue | 447 ++++++++++++++++++ .../project/ProjectTestCasesTree.vue | 243 ++++++++++ application/app/demo/api/router.ts | 3 +- application/app/pages/projects/[id]/index.vue | 148 +----- .../app/pages/projects/[id]/test-cases.vue | 135 +----- application/app/utils/help-content.ts | 2 +- application/app/utils/index.ts | 35 +- .../api/projects/[id]/test-cases.get.ts | 63 ++- application/server/utils/mcp/tools.ts | 10 +- application/shared/handlers/projects.ts | 184 ++++++- application/shared/mcp-tools.ts | 1 + application/shared/test-project-names.ts | 1 + .../tests/insights-and-spec-health.spec.ts | 2 +- application/tests/project-test-cases.spec.ts | 96 ++++ application/tests/unit/app-utils.test.ts | 24 + .../tests/unit/project-test-cases.test.ts | 243 ++++++++++ application/types/api.ts | 27 +- docs/ui-overview.md | 2 +- 19 files changed, 1368 insertions(+), 324 deletions(-) create mode 100644 application/app/components/project/PassRateIndicator.vue create mode 100644 application/app/components/project/ProjectTestCasesTable.vue create mode 100644 application/app/components/project/ProjectTestCasesTree.vue create mode 100644 application/tests/project-test-cases.spec.ts create mode 100644 application/tests/unit/project-test-cases.test.ts diff --git a/application/app/components/project/PassRateIndicator.vue b/application/app/components/project/PassRateIndicator.vue new file mode 100644 index 00000000..db8afd56 --- /dev/null +++ b/application/app/components/project/PassRateIndicator.vue @@ -0,0 +1,26 @@ + + + diff --git a/application/app/components/project/ProjectTestCasesTable.vue b/application/app/components/project/ProjectTestCasesTable.vue new file mode 100644 index 00000000..ed2fb87e --- /dev/null +++ b/application/app/components/project/ProjectTestCasesTable.vue @@ -0,0 +1,447 @@ + + + diff --git a/application/app/components/project/ProjectTestCasesTree.vue b/application/app/components/project/ProjectTestCasesTree.vue new file mode 100644 index 00000000..a2b03d00 --- /dev/null +++ b/application/app/components/project/ProjectTestCasesTree.vue @@ -0,0 +1,243 @@ + + + diff --git a/application/app/demo/api/router.ts b/application/app/demo/api/router.ts index ba92b01c..7f95d21b 100644 --- a/application/app/demo/api/router.ts +++ b/application/app/demo/api/router.ts @@ -24,6 +24,7 @@ import { getProject, getProjectPerformance, getProjectTestCases, + parseTestCasesQuery, getProjectSlowTests, getProjectFailureClusters, updateProject, @@ -176,7 +177,7 @@ const routes: RouteEntry[] = [ { method: 'GET', pattern: /^\/api\/projects\/(\d+)\/test-cases$/, - handler: async (m) => getProjectTestCases(await getDemoDb(), +m[1]!), + handler: async (m, _, q) => getProjectTestCases(await getDemoDb(), +m[1]!, parseTestCasesQuery(q)), }, { method: 'GET', diff --git a/application/app/pages/projects/[id]/index.vue b/application/app/pages/projects/[id]/index.vue index d0e37340..94904852 100644 --- a/application/app/pages/projects/[id]/index.vue +++ b/application/app/pages/projects/[id]/index.vue @@ -3,7 +3,6 @@ import type { TableColumn } from '@nuxt/ui'; import type { ProjectWithTestRuns, TestRunSummary, - TestCaseWithStats, PerformanceTrendPoint, SlowTest, TestRunForCompare, @@ -202,7 +201,7 @@ const tabItems = computed(() => [ { label: 'Flaky tests', icon: 'i-lucide-shuffle', value: 'flaky-tests', slot: 'flaky-tests' }, { label: 'Performance', icon: 'i-lucide-trending-up', value: 'performance', slot: 'performance' }, { - label: `Test cases${testCases.value.length > 0 ? ` (${testCases.value.length})` : ''}`, + label: `Test cases${testCasesTotal.value != null ? ` (${testCasesTotal.value})` : ''}`, icon: 'i-lucide-list-checks', value: 'test-cases', slot: 'test-cases', @@ -359,28 +358,9 @@ const runsColumns: TableColumn[] = [ ]; // === TEST CASES TAB === -// Only fetch when the tab is first visited -const testCases = ref([]); -watch( - activeTab, - async (tab) => { - if (tab === 'test-cases' && testCases.value.length === 0) { - testCases.value = await $fetch(`/api/projects/${projectId}/test-cases`).catch(() => []); - } - }, - { immediate: true }, -); - -const testCasesColumns: TableColumn[] = [ - { accessorKey: 'title', header: createSortHeader('Test case') }, - { accessorKey: 'status', header: createSortHeader('Status') }, - { accessorKey: 'totalRuns', header: createSortHeader('Runs') }, - { accessorKey: 'passRate', header: createSortHeader('Pass rate') }, - { accessorKey: 'results', header: 'Results' }, - { accessorKey: 'avgDuration', header: createSortHeader('Avg duration') }, - { accessorKey: 'lastRun', header: createSortHeader('Last run') }, - { id: 'actions', header: 'Actions' }, -]; +// The catalog table (ProjectTestCasesTable) owns its own fetch, filters and +// pagination; it emits its total row count so the tab label can show it. +const testCasesTotal = ref(null); // === PERFORMANCE TAB === const dateFrom = ref(''); @@ -1073,120 +1053,12 @@ const comparisonColumns: TableColumn[] = [ diff --git a/application/app/pages/projects/[id]/test-cases.vue b/application/app/pages/projects/[id]/test-cases.vue index 79a5b657..83970d56 100644 --- a/application/app/pages/projects/[id]/test-cases.vue +++ b/application/app/pages/projects/[id]/test-cases.vue @@ -1,29 +1,18 @@ diff --git a/application/app/utils/help-content.ts b/application/app/utils/help-content.ts index 6da178be..81457840 100644 --- a/application/app/utils/help-content.ts +++ b/application/app/utils/help-content.ts @@ -100,7 +100,7 @@ export const HELP_TOPICS = { }, 'project.test-cases': { title: 'Test cases', - text: 'Every distinct test in the project with its pass rate across runs. Click one to see its full history.', + text: 'Every distinct test in the project with its executed-only pass rate, result breakdown and average duration across runs. Search by title or file, filter by status, and switch to a per-spec tree. Cases not run within the selected age window are hidden by default (last 30 days) — pick "All time" to see obsolete ones. Click a test to see its full history.', doc: 'ui-overview#project-detail', }, 'project.compare': { diff --git a/application/app/utils/index.ts b/application/app/utils/index.ts index 97bf5657..b540f20c 100644 --- a/application/app/utils/index.ts +++ b/application/app/utils/index.ts @@ -1,7 +1,7 @@ import { h } from 'vue'; import { UIcon } from '#components'; import type { Column } from '@tanstack/vue-table'; -import type { CommitListItem, TestCaseWithStats } from '~~/types/api'; +import type { CommitListItem } from '~~/types/api'; import { formatDuration as formatDurationLib, formatDistanceToNow } from 'date-fns'; /** @@ -82,8 +82,12 @@ export function formatRelativeTime(date: string | Date | number | null | undefin export function formatDuration(ms?: number | null) { if (ms === null || ms === undefined) return 'N/A'; + // Round to whole milliseconds so fractional inputs (SQL averages) never + // render more than 3 decimals of seconds. + const rounded = Math.round(Math.abs(ms)); + if (rounded === 0) return '0 seconds'; const sign = ms < 0 ? '−' : ''; - return sign + formatDurationLib({ seconds: Math.abs(ms) / 1000 }); + return sign + formatDurationLib({ seconds: rounded / 1000 }); } export function reportIcon(type: string): string { @@ -155,6 +159,7 @@ export function getStatusColor(status: string) { export function formatStatusLabel(status: string): string { if (status === 'timedOut' || status === 'timedout') return 'failed'; if (status === 'didnotrun') return "didn't run"; + if (status === 'never-run') return 'never run'; return status; } @@ -477,20 +482,14 @@ export function passRate(run: { passedTests: number; totalTests: number }): numb return run.totalTests > 0 ? Math.round((run.passedTests / run.totalTests) * 100) : 0; } -/** Pass rate (0–100) across all of a test case's runs. */ -export function getPassRate(testCase: TestCaseWithStats): number { - if (testCase.totalRuns === 0) return 0; - return Math.round((testCase.passedRuns / testCase.totalRuns) * 100); -} - -/** Display status + badge color for a test case, treating recent flakiness as its own state. */ -export function getTestCaseStatus(testCase: TestCaseWithStats): { status: string; color: BadgeColor } { - const recentFlaky = testCase.recentFlakyRuns ?? testCase.flakyRuns; - if (recentFlaky > 0) { - return { status: 'flaky', color: 'warning' }; - } - return { - status: testCase.lastStatus || 'unknown', - color: getStatusColor(testCase.lastStatus || 'unknown') as BadgeColor, - }; +/** + * Badge color for a test case's derived status category (the server-computed + * `status` on `TestCaseWithStats`: flaky wins, timeouts fold into failed, + * `never-run` for cases without executions). + */ +export function testCaseCategoryColor(category: string): BadgeColor { + if (category === 'flaky') return 'warning'; + if (category === 'never-run') return 'neutral'; + if (category === 'didnotrun') return 'warning'; + return getStatusColor(category) as BadgeColor; } diff --git a/application/server/api/projects/[id]/test-cases.get.ts b/application/server/api/projects/[id]/test-cases.get.ts index 1ef707e8..f03a40b4 100644 --- a/application/server/api/projects/[id]/test-cases.get.ts +++ b/application/server/api/projects/[id]/test-cases.get.ts @@ -1,5 +1,5 @@ import { getDatabase } from '../../../database'; -import { getProjectTestCases } from '#shared/handlers/projects'; +import { getProjectTestCases, parseTestCasesQuery } from '#shared/handlers/projects'; import { Role } from '#shared/types'; import { requireProjectAccess, requireRouteId } from '../../../utils/project-access'; @@ -10,8 +10,63 @@ defineRouteMeta({ tags: ['Test Cases'], summary: 'List test cases for a project with aggregated stats', description: - 'Returns all test cases with total runs, pass/fail/skip/flaky counts, average duration, and last run status', - parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], + 'Paginated test-case catalog with per-case aggregates: total runs, pass/fail/skip/flaky counts, executed-only pass rate and average duration, derived status category, and last run. Returns `{ items, total, limit, offset }`. Timed-out runs are folded into the failed counts.', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'integer' } }, + { + name: 'limit', + in: 'query', + required: false, + schema: { type: 'integer', default: 50, minimum: 1, maximum: 1000 }, + description: 'Page size', + }, + { + name: 'offset', + in: 'query', + required: false, + schema: { type: 'integer', default: 0, minimum: 0 }, + description: 'Row offset for paging', + }, + { + name: 'q', + in: 'query', + required: false, + schema: { type: 'string' }, + description: 'Case-insensitive substring filter on title or file path', + }, + { + name: 'status', + in: 'query', + required: false, + schema: { type: 'string' }, + description: 'Comma-separated status categories to include: passed, failed, flaky, skipped, didnotrun', + }, + { + name: 'maxAgeDays', + in: 'query', + required: false, + schema: { type: 'integer', default: 0, minimum: 0 }, + description: 'Only include cases executed within the last N days (0 = all time)', + }, + { + name: 'sort', + in: 'query', + required: false, + schema: { + type: 'string', + enum: ['lastRun', 'title', 'totalRuns', 'passRate', 'avgDuration', 'status'], + default: 'lastRun', + }, + description: 'Sort column', + }, + { + name: 'dir', + in: 'query', + required: false, + schema: { type: 'string', enum: ['asc', 'desc'], default: 'desc' }, + description: 'Sort direction', + }, + ], 'x-required-roles': REQUIRED_ROLES, }, }); @@ -22,5 +77,5 @@ export default eventHandler(async (event) => { await requireProjectAccess(event, id); const db = await getDatabase(); - return getProjectTestCases(db, id); + return getProjectTestCases(db, id, parseTestCasesQuery(getQuery(event))); }); diff --git a/application/server/utils/mcp/tools.ts b/application/server/utils/mcp/tools.ts index db415f91..3bb4b5e3 100644 --- a/application/server/utils/mcp/tools.ts +++ b/application/server/utils/mcp/tools.ts @@ -1348,12 +1348,12 @@ const HANDLERS: Record = { assertProject(ctx, projectId); const pageSize = clampPageSize(params.pageSize); const offset = Math.max(0, Number(params.offset) || 0); - const all = (await getProjectTestCases(db, projectId)) as any[]; - const page = all.slice(offset, offset + pageSize); + const query = typeof params.query === 'string' && params.query.trim() ? params.query.trim() : undefined; + const page = await getProjectTestCases(db, projectId, { limit: pageSize, offset, q: query }); return { - total: all.length, + total: page.total, offset, - items: page.map((t: any) => + items: page.items.map((t: any) => dropNulls({ testCaseId: t.id, title: t.title, @@ -1366,7 +1366,7 @@ const HANDLERS: Record = { avgDuration: t.avgDuration != null ? Math.round(t.avgDuration) : null, }), ), - nextOffset: offset + pageSize < all.length ? offset + pageSize : null, + nextOffset: offset + pageSize < page.total ? offset + pageSize : null, }; }, diff --git a/application/shared/handlers/projects.ts b/application/shared/handlers/projects.ts index 143ce9fe..885fe84e 100644 --- a/application/shared/handlers/projects.ts +++ b/application/shared/handlers/projects.ts @@ -9,7 +9,7 @@ import { failureClusters, failureDiagnoses, } from '../../server/database/schema'; -import { desc, eq, sql, and, inArray, gte, lte, isNotNull, count } from 'drizzle-orm'; +import { asc, desc, eq, exists, sql, and, inArray, gte, lte, isNotNull, count } from 'drizzle-orm'; import type { BrowserConfig } from '../types'; import type { DrizzleDB } from './db'; @@ -447,44 +447,180 @@ export async function getProjectPerformance( // ─── getProjectTestCases ───────────────────────────────────────── -export async function getProjectTestCases(db: DrizzleDB, projectId: number) { - const testCasesWithStats: any[] = await db - .select({ - id: testCases.id, - filePath: testCases.filePath, - title: testCases.title, - totalRuns: sql`COUNT(${testRunsCases.id})`, - passedRuns: sql`SUM(CASE WHEN ${testRunsCases.status} = 'passed' THEN 1 ELSE 0 END)`, - failedRuns: sql`SUM(CASE WHEN ${testRunsCases.status} = 'failed' THEN 1 ELSE 0 END)`, - skippedRuns: sql`SUM(CASE WHEN ${testRunsCases.status} = 'skipped' THEN 1 ELSE 0 END)`, - timedOutRuns: sql`SUM(CASE WHEN ${testRunsCases.status} = 'timedOut' THEN 1 ELSE 0 END)`, - flakyRuns: sql`SUM(CASE WHEN ${testRunsCases.status} = 'passed' AND ${testRunsCases.retries} > 0 THEN 1 ELSE 0 END)`, - recentFlakyRuns: sql`( +export const TEST_CASE_SORTS = ['lastRun', 'title', 'totalRuns', 'passRate', 'avgDuration', 'status'] as const; +export type TestCasesSort = (typeof TEST_CASE_SORTS)[number]; + +/** Filterable per-case status categories (the derived `status` field, not raw run statuses). */ +export const TEST_CASE_STATUS_FILTERS = ['passed', 'failed', 'flaky', 'skipped', 'didnotrun'] as const; + +export interface TestCasesQuery { + limit: number; + offset: number; + q?: string; + statuses?: string[]; + maxAgeDays: number; + sort: TestCasesSort; + dir: 'asc' | 'desc'; +} + +/** + * Parse and clamp the test-cases catalog query parameters. Shared by the REST + * endpoint (`getQuery` record) and the demo router (`URLSearchParams`) so both + * apply identical defaults: limit 50 (max 1000), `maxAgeDays` 0 = all time + * (the UI sends its own default), sort by last run, newest first. + */ +export function parseTestCasesQuery(input?: URLSearchParams | Record | null): TestCasesQuery { + const get = (key: string): string | undefined => { + if (!input) return undefined; + const value = input instanceof URLSearchParams ? input.get(key) : (input as Record)[key]; + if (value == null) return undefined; + return String(Array.isArray(value) ? value[0] : value); + }; + const num = (key: string, fallback: number): number => { + const n = Number(get(key)); + return Number.isFinite(n) ? n : fallback; + }; + const statuses = (get('status') ?? '') + .split(',') + .map((s) => s.trim()) + .filter((s) => (TEST_CASE_STATUS_FILTERS as readonly string[]).includes(s)); + const rawSort = get('sort') ?? ''; + return { + limit: Math.min(1000, Math.max(1, Math.floor(num('limit', 50)))), + offset: Math.max(0, Math.floor(num('offset', 0))), + q: get('q')?.trim() || undefined, + statuses: statuses.length > 0 ? statuses : undefined, + maxAgeDays: Math.max(0, num('maxAgeDays', 0)), + sort: (TEST_CASE_SORTS as readonly string[]).includes(rawSort) ? (rawSort as TestCasesSort) : 'lastRun', + dir: get('dir') === 'asc' ? 'asc' : 'desc', + }; +} + +/** + * Normalize a `MAX(created_at)` aggregate to epoch milliseconds. The raw value + * is a ms integer on SQLite, a Date (or timestamp string) on PostgreSQL, and + * Unix seconds in demo databases seeded before the unit fix. + */ +function toEpochMs(value: unknown): number | null { + if (value == null) return null; + if (value instanceof Date) return value.getTime(); + const n = typeof value === 'number' ? value : Number(value); + if (Number.isFinite(n)) return n < 1e12 ? n * 1000 : n; + const parsed = Date.parse(String(value)); + return Number.isNaN(parsed) ? null : parsed; +} + +/** + * Paginated test-case catalog for a project with per-case aggregates. + * + * Timed-out runs (both the raw `timedOut` Playwright spelling and the declared + * lowercase `timedout`) are folded into `failedRuns`, matching how the rest of + * the UI treats timeouts. `passRate` is computed over executed runs only + * (passed + failed; skipped/didnotrun excluded) and is null when nothing ran. + * The derived `status` category is what the status filter and sort operate on: + * `flaky` when any of the last 10 executions is a retry-pass, otherwise the + * latest run's status (timeouts shown as failed), or `never-run`. + */ +export async function getProjectTestCases(db: DrizzleDB, projectId: number, options: Partial = {}) { + const { limit = 50, offset = 0, q, statuses, maxAgeDays = 0, sort = 'lastRun', dir = 'desc' } = options; + + const passed = sql`SUM(CASE WHEN ${testRunsCases.status} = 'passed' THEN 1 ELSE 0 END)`; + const failed = sql`SUM(CASE WHEN ${testRunsCases.status} IN ('failed', 'timedOut', 'timedout') THEN 1 ELSE 0 END)`; + const recentFlaky = sql`( SELECT COUNT(*) FROM ( SELECT ${testRunsCases.status} AS s, ${testRunsCases.retries} AS r FROM ${testRunsCases} WHERE ${testRunsCases.testCaseId} = ${testCases.id} ORDER BY ${testRunsCases.createdAt} DESC LIMIT 10 - ) WHERE s = 'passed' AND r > 0 - )`, - avgDuration: sql`AVG(${testRunsCases.duration})`, - lastRun: sql`MAX(${testRunsCases.createdAt})`, - lastStatus: sql`( + ) AS recent WHERE s = 'passed' AND r > 0 + )`; + const lastStatus = sql`( SELECT ${testRunsCases.status} FROM ${testRunsCases} WHERE ${testRunsCases.testCaseId} = ${testCases.id} ORDER BY ${testRunsCases.createdAt} DESC LIMIT 1 - )`, + )`; + const category = sql`CASE + WHEN ${recentFlaky} > 0 THEN 'flaky' + WHEN ${lastStatus} IN ('timedOut', 'timedout') THEN 'failed' + ELSE COALESCE(${lastStatus}, 'never-run') + END`; + const passRate = sql< + number | null + >`CASE WHEN (${passed} + ${failed}) > 0 THEN (${passed} * 1.0) / (${passed} + ${failed}) END`; + + const conditions = [eq(testCases.projectId, projectId)]; + if (q) { + const pattern = `%${q.toLowerCase()}%`; + conditions.push(sql`(lower(${testCases.title}) LIKE ${pattern} OR lower(${testCases.filePath}) LIKE ${pattern})`); + } + if (maxAgeDays > 0) { + const cutoff = new Date(Date.now() - maxAgeDays * 24 * 60 * 60 * 1000); + conditions.push( + exists( + db + .select({ one: sql`1` }) + .from(testRunsCases) + .where(and(eq(testRunsCases.testCaseId, testCases.id), gte(testRunsCases.createdAt, cutoff))), + ), + ); + } + if (statuses && statuses.length > 0) { + conditions.push(inArray(category, statuses)); + } + const where = and(...conditions); + + // Every predicate above is per-case (correlated on testCases.id only), so the + // total is a plain count over test_cases — no join or grouping needed. + const countRows: any[] = await db.select({ total: count() }).from(testCases).where(where); + const total = Number(countRows[0]?.total ?? 0); + + const sortExpressions: Record> = { + lastRun: sql`MAX(${testRunsCases.createdAt})`, + title: sql`lower(${testCases.title})`, + totalRuns: sql`COUNT(${testRunsCases.id})`, + passRate, + avgDuration: sql`AVG(CASE WHEN ${testRunsCases.status} NOT IN ('skipped', 'didnotrun') THEN ${testRunsCases.duration} END)`, + status: category, + }; + + const rows: any[] = await db + .select({ + id: testCases.id, + filePath: testCases.filePath, + suitePath: testCases.suitePath, + title: testCases.title, + status: category, + totalRuns: sql`COUNT(${testRunsCases.id})`, + passedRuns: passed, + failedRuns: failed, + skippedRuns: sql`SUM(CASE WHEN ${testRunsCases.status} = 'skipped' THEN 1 ELSE 0 END)`, + didNotRunRuns: sql`SUM(CASE WHEN ${testRunsCases.status} = 'didnotrun' THEN 1 ELSE 0 END)`, + flakyRuns: sql`SUM(CASE WHEN ${testRunsCases.status} = 'passed' AND ${testRunsCases.retries} > 0 THEN 1 ELSE 0 END)`, + recentFlakyRuns: recentFlaky, + passRate, + avgDuration: sql< + number | null + >`AVG(CASE WHEN ${testRunsCases.status} NOT IN ('skipped', 'didnotrun') THEN ${testRunsCases.duration} END)`, + lastRun: sql`MAX(${testRunsCases.createdAt})`, + lastStatus, }) .from(testCases) .leftJoin(testRunsCases, eq(testCases.id, testRunsCases.testCaseId)) - .where(eq(testCases.projectId, projectId)) - .groupBy(testCases.id, testCases.filePath, testCases.title) - .orderBy(desc(sql`MAX(${testRunsCases.createdAt})`)); + .where(where) + .groupBy(testCases.id, testCases.filePath, testCases.suitePath, testCases.title) + .orderBy(sql`${sortExpressions[sort]} ${sql.raw(dir === 'asc' ? 'ASC' : 'DESC')} NULLS LAST`, asc(testCases.id)) + .limit(limit) + .offset(offset); - return testCasesWithStats; + return { + items: rows.map((row) => ({ ...row, lastRun: toEpochMs(row.lastRun) })), + total, + limit, + offset, + }; } /** diff --git a/application/shared/mcp-tools.ts b/application/shared/mcp-tools.ts index 1d2b96cb..78f46fad 100644 --- a/application/shared/mcp-tools.ts +++ b/application/shared/mcp-tools.ts @@ -441,6 +441,7 @@ export const MCP_TOOL_DEFS = [ projectId: { type: 'number', description: 'Project ID' }, pageSize: { type: 'number', description: 'Results per page (default 10, max 50)' }, offset: { type: 'number', description: 'Row offset for paging (default 0)' }, + query: { type: 'string', description: 'Optional case-insensitive substring filter on title or file path' }, }, required: ['projectId'], }, diff --git a/application/shared/test-project-names.ts b/application/shared/test-project-names.ts index e5f47700..ca25f607 100644 --- a/application/shared/test-project-names.ts +++ b/application/shared/test-project-names.ts @@ -104,6 +104,7 @@ export const PROJECT = { STREAMING_TEST: 'streaming-test-project', TAG_ASSIGNMENT: 'tag-assignment-test-project', TEST_API: 'test-api-project', + TEST_CASES_CATALOG: 'test-cases-catalog-test', TEST_FLAKY: 'test-flaky-project', TEST_PROJECT: 'test-project', TEST_RUN_CASE_PAGE: 'test-run-case-page-test', diff --git a/application/tests/insights-and-spec-health.spec.ts b/application/tests/insights-and-spec-health.spec.ts index 007c83b8..3f38c308 100644 --- a/application/tests/insights-and-spec-health.spec.ts +++ b/application/tests/insights-and-spec-health.spec.ts @@ -104,7 +104,7 @@ test.describe.serial('Insights, spec health & flaky classification', () => { test('flaky-classify labels a timeout failure as "timing"', async ({ request }) => { const tcRes = await request.get(`/api/projects/${projectId}/test-cases`); expect(tcRes.ok()).toBeTruthy(); - const cases = (await tcRes.json()) as Array<{ id: number; title: string }>; + const { items: cases } = (await tcRes.json()) as { items: Array<{ id: number; title: string }> }; const checkout = cases.find((c) => c.title === 'checkout works'); expect(checkout, 'checkout test case exists').toBeTruthy(); diff --git a/application/tests/project-test-cases.spec.ts b/application/tests/project-test-cases.spec.ts new file mode 100644 index 00000000..a80a477b --- /dev/null +++ b/application/tests/project-test-cases.spec.ts @@ -0,0 +1,96 @@ +/** + * UI tests for the project test-cases catalog (`/projects/:id/test-cases`): + * server-driven search, status filtering and the flat/tree toggle on the + * revamped shared table. + */ +import { test, expect, type APIRequestContext } from './fixtures'; +import { waitForHydration, retryPost } from './utils'; +import { PROJECT } from '#shared/test-project-names'; + +async function seed(request: APIRequestContext) { + const res = await retryPost(request, '/api/test-runs/submit', { + data: { + projectName: PROJECT.TEST_CASES_CATALOG, + status: 'failed', + startTime: new Date().toISOString(), + duration: 5000, + totalTests: 4, + passedTests: 3, + failedTests: 1, + skippedTests: 0, + testCases: [ + { title: 'login works', status: 'passed', duration: 500, location: 'tests/auth/login.spec.ts:1:1' }, + { + title: 'login validation', + status: 'passed', + retries: 2, + duration: 800, + location: 'tests/auth/login.spec.ts:20:1', + }, + { + title: 'checkout works', + status: 'failed', + duration: 1200, + location: 'tests/shop/checkout.spec.ts:1:1', + error: 'Error: expected total to update\n at tests/shop/checkout.spec.ts:5:3', + }, + { title: 'checkout tax', status: 'passed', duration: 640, location: 'tests/shop/checkout.spec.ts:30:1' }, + ], + }, + timeout: 20000, + }); + expect(res.ok()).toBeTruthy(); + return (await res.json()) as { projectId: number }; +} + +test.describe.serial('Project test-cases catalog', () => { + let projectId = 0; + + test('seeds a project with four test cases', async ({ request }) => { + const { projectId: id } = await seed(request); + projectId = id; + expect(projectId).toBeGreaterThan(0); + }); + + test('lists cases with title and file path', async ({ page }) => { + await page.goto(`/projects/${projectId}/test-cases`); + await waitForHydration(page); + + await expect(page.getByText('4 cases')).toBeVisible(); + await expect(page.getByRole('link', { name: 'login works' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'checkout works' })).toBeVisible(); + await expect(page.getByText('tests/auth/login.spec.ts').first()).toBeVisible(); + }); + + test('search narrows the list to matching cases', async ({ page }) => { + await page.goto(`/projects/${projectId}/test-cases`); + await waitForHydration(page); + + await page.getByRole('textbox', { name: 'Search test cases' }).fill('checkout'); + // The query is debounced and refetched server-side; web-first assertions retry. + await expect(page.getByRole('link', { name: 'checkout works' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'login works' })).toHaveCount(0); + await expect(page.getByText('2 cases')).toBeVisible(); + }); + + test('the Failed status pill filters to failing cases', async ({ page }) => { + await page.goto(`/projects/${projectId}/test-cases`); + await waitForHydration(page); + + await page.getByRole('button', { name: 'Failed' }).click(); + await expect(page.getByRole('link', { name: 'checkout works' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'login works' })).toHaveCount(0); + await expect(page.getByText('1 case', { exact: true })).toBeVisible(); + }); + + test('tree view groups cases by spec file', async ({ page }) => { + await page.goto(`/projects/${projectId}/test-cases`); + await waitForHydration(page); + + await page.getByTitle('Tree view').click(); + // Both spec files appear as group headers with their case counts. + await expect(page.getByText('tests/auth/login.spec.ts')).toBeVisible(); + await expect(page.getByText('tests/shop/checkout.spec.ts')).toBeVisible(); + await expect(page.getByText('2 cases').first()).toBeVisible(); + }); +}); diff --git a/application/tests/unit/app-utils.test.ts b/application/tests/unit/app-utils.test.ts index a2e12e87..efb43ffb 100644 --- a/application/tests/unit/app-utils.test.ts +++ b/application/tests/unit/app-utils.test.ts @@ -6,6 +6,7 @@ import { formatRelativeTime, getStatusColor, formatStatusLabel, + testCaseCategoryColor, clusterStatusColor, clusterErrorTypeColor, getFileApiPath, @@ -45,6 +46,18 @@ describe('formatDuration', () => { expect(formatDuration(5000)).toBe('5 seconds'); expect(formatDuration(-5000)).toBe('−5 seconds'); }); + + test('rounds fractional milliseconds to at most 3 decimals of seconds', () => { + expect(formatDuration(8234.666666667)).toBe('8.235 seconds'); + expect(formatDuration(1234.5678)).toBe('1.235 seconds'); + expect(formatDuration(1234)).toBe('1.234 seconds'); + expect(formatDuration(-1234.5678)).toBe('−1.235 seconds'); + }); + + test('renders zero durations as 0 seconds instead of an empty string', () => { + expect(formatDuration(0)).toBe('0 seconds'); + expect(formatDuration(0.2)).toBe('0 seconds'); + }); }); describe('prettyDateFormat', () => { @@ -89,10 +102,21 @@ describe('formatStatusLabel', () => { expect(formatStatusLabel('timedOut')).toBe('failed'); expect(formatStatusLabel('timedout')).toBe('failed'); expect(formatStatusLabel('didnotrun')).toBe("didn't run"); + expect(formatStatusLabel('never-run')).toBe('never run'); expect(formatStatusLabel('passed')).toBe('passed'); }); }); +describe('testCaseCategoryColor', () => { + test('maps derived catalog categories to badge colors', () => { + expect(testCaseCategoryColor('flaky')).toBe('warning'); + expect(testCaseCategoryColor('never-run')).toBe('neutral'); + expect(testCaseCategoryColor('didnotrun')).toBe('warning'); + expect(testCaseCategoryColor('passed')).toBe('success'); + expect(testCaseCategoryColor('failed')).toBe('error'); + }); +}); + describe('cluster color helpers', () => { test('clusterStatusColor', () => { expect(clusterStatusColor('open')).toBe('warning'); diff --git a/application/tests/unit/project-test-cases.test.ts b/application/tests/unit/project-test-cases.test.ts new file mode 100644 index 00000000..60bf10af --- /dev/null +++ b/application/tests/unit/project-test-cases.test.ts @@ -0,0 +1,243 @@ +import { describe, test, expect, beforeAll } from 'vitest'; +import { fileURLToPath } from 'node:url'; +import { drizzle } from 'drizzle-orm/libsql'; +import { migrate } from 'drizzle-orm/libsql/migrator'; +import { createClient } from '@libsql/client'; +import * as schema from '../../server/database/schema.sqlite'; + +// The schema barrel (server/database/schema.ts) picks the PostgreSQL schema at +// import time when PIWI_DATABASE_URL is set, so clear it before the handler +// module (which imports the barrel) is loaded. +delete process.env.PIWI_DATABASE_URL; +const { getProjectTestCases, parseTestCasesQuery } = await import('../../shared/handlers/projects'); + +const DAY_MS = 24 * 60 * 60 * 1000; +const now = Date.now(); +/** Fresh execution timestamps, spaced a minute apart so ordering is deterministic. */ +const at = (minutesAgo: number) => new Date(now - minutesAgo * 60 * 1000); + +let db: ReturnType>; + +interface RunCaseSeed { + status: string; + retries?: number; + duration?: number | null; + createdAt: Date; +} + +async function seedCase( + projectId: number, + runId: number, + filePath: string, + suitePath: string, + title: string, + runCases: RunCaseSeed[], +): Promise { + const inserted = await db + .insert(schema.testCases) + .values({ projectId, filePath, suitePath, title }) + .returning({ id: schema.testCases.id }); + const caseId = inserted[0]!.id; + for (const rc of runCases) { + await db.insert(schema.testRunsCases).values({ + testRunId: runId, + testCaseId: caseId, + status: rc.status, + retries: rc.retries ?? 0, + duration: rc.duration === undefined ? 1000 : rc.duration, + createdAt: rc.createdAt, + }); + } + return caseId; +} + +beforeAll(async () => { + db = drizzle(createClient({ url: ':memory:' }), { schema }); + await migrate(db, { + migrationsFolder: fileURLToPath(new URL('../../server/database/migrations', import.meta.url)), + }); + + await db.insert(schema.projects).values({ id: 1, name: 'catalog-project' }); + await db.insert(schema.projects).values({ id: 2, name: 'other-project' }); + await db.insert(schema.testRuns).values({ id: 1, projectId: 1, status: 'failed', startTime: new Date(now) }); + await db.insert(schema.testRuns).values({ id: 2, projectId: 2, status: 'passed', startTime: new Date(now) }); + + // Flaky: latest 10 executions contain a retry-pass; one plain failure. + await seedCase(1, 1, 'auth/login.spec.ts', 'Auth\x1fLogin', 'login works', [ + { status: 'passed', createdAt: at(1), duration: 900 }, + { status: 'passed', retries: 2, createdAt: at(2), duration: 1100 }, + { status: 'failed', createdAt: at(3), duration: 2000 }, + { status: 'passed', createdAt: at(4), duration: 1000 }, + ]); + // Timed out (raw camelCase spelling) on the latest execution. + await seedCase(1, 1, 'shop/checkout.spec.ts', '', 'checkout total updates', [ + { status: 'timedOut', createdAt: at(1), duration: 30000 }, + { status: 'passed', createdAt: at(60), duration: 800 }, + ]); + // Stale: only execution happened 40 days ago. + await seedCase(1, 1, 'legacy/old.spec.ts', '', 'legacy flow still boots', [ + { status: 'passed', createdAt: new Date(now - 40 * DAY_MS), duration: 500 }, + ]); + // Skipped only: never executed for real. + await seedCase(1, 1, 'auth/sso.spec.ts', '', 'sso round trip', [ + { status: 'skipped', createdAt: at(5), duration: 0 }, + { status: 'skipped', createdAt: at(90), duration: 0 }, + ]); + // No executions at all. + await seedCase(1, 1, 'wip/new.spec.ts', '', 'brand new case', []); + // didnotrun on the latest execution; zero-duration rows must not drag the average. + await seedCase(1, 1, 'shop/cart.spec.ts', 'Shop\x1fCart', 'cart badge count', [ + { status: 'didnotrun', createdAt: at(1), duration: 0 }, + { status: 'passed', createdAt: at(2), duration: 400 }, + ]); + // Different project: must never leak into project 1 results. + await seedCase(2, 2, 'auth/login.spec.ts', '', 'login works', [{ status: 'passed', createdAt: at(1) }]); +}); + +describe('getProjectTestCases', () => { + test('returns a paginated envelope scoped to the project', async () => { + const page = await getProjectTestCases(db, 1); + expect(page.total).toBe(6); + expect(page.items).toHaveLength(6); + expect(page.limit).toBe(50); + expect(page.offset).toBe(0); + expect(page.items.every((i: any) => typeof i.suitePath === 'string')).toBe(true); + }); + + test('applies limit and offset with a stable order', async () => { + const first = await getProjectTestCases(db, 1, { limit: 2, offset: 0 }); + const second = await getProjectTestCases(db, 1, { limit: 2, offset: 2 }); + expect(first.total).toBe(6); + expect(first.items).toHaveLength(2); + expect(second.items).toHaveLength(2); + const ids = [...first.items, ...second.items].map((i: any) => i.id); + expect(new Set(ids).size).toBe(4); + }); + + test('search matches title and file path case-insensitively', async () => { + const byTitle = await getProjectTestCases(db, 1, { q: 'LOGIN' }); + expect(byTitle.items.map((i: any) => i.title)).toContain('login works'); + const byPath = await getProjectTestCases(db, 1, { q: 'shop/' }); + expect(byPath.total).toBe(2); + expect(byPath.items.map((i: any) => i.filePath).sort()).toEqual(['shop/cart.spec.ts', 'shop/checkout.spec.ts']); + }); + + test('folds timed-out runs into failedRuns and the failed category', async () => { + const page = await getProjectTestCases(db, 1, { q: 'checkout' }); + const checkout: any = page.items[0]; + expect(checkout.failedRuns).toBe(1); + expect(checkout.status).toBe('failed'); + const failedOnly = await getProjectTestCases(db, 1, { statuses: ['failed'] }); + expect(failedOnly.items.map((i: any) => i.title)).toEqual(['checkout total updates']); + expect(failedOnly.total).toBe(1); + }); + + test('flaky category wins over the latest run status', async () => { + const flaky = await getProjectTestCases(db, 1, { statuses: ['flaky'] }); + expect(flaky.items.map((i: any) => i.title)).toEqual(['login works']); + expect((flaky.items[0] as any).recentFlakyRuns).toBe(1); + }); + + test('derives skipped, didnotrun and never-run categories', async () => { + const page = await getProjectTestCases(db, 1); + const byTitle = new Map(page.items.map((i: any) => [i.title, i])); + expect(byTitle.get('sso round trip')!.status).toBe('skipped'); + expect(byTitle.get('cart badge count')!.status).toBe('didnotrun'); + expect(byTitle.get('cart badge count')!.didNotRunRuns).toBe(1); + expect(byTitle.get('brand new case')!.status).toBe('never-run'); + expect(byTitle.get('brand new case')!.lastRun).toBeNull(); + }); + + test('maxAgeDays hides cases not executed within the window, 0 keeps everything', async () => { + const windowed = await getProjectTestCases(db, 1, { maxAgeDays: 30 }); + expect(windowed.items.map((i: any) => i.title)).not.toContain('legacy flow still boots'); + // The never-run case has no executions, so an age window also hides it. + expect(windowed.total).toBe(4); + const all = await getProjectTestCases(db, 1, { maxAgeDays: 0 }); + expect(all.total).toBe(6); + }); + + test('computes pass rate over executed runs only', async () => { + const page = await getProjectTestCases(db, 1); + const byTitle = new Map(page.items.map((i: any) => [i.title, i])); + expect(byTitle.get('login works')!.passRate).toBeCloseTo(0.75); + expect(byTitle.get('checkout total updates')!.passRate).toBeCloseTo(0.5); + expect(byTitle.get('sso round trip')!.passRate).toBeNull(); + }); + + test('computes average duration over executed runs only', async () => { + const page = await getProjectTestCases(db, 1, { q: 'cart' }); + const cart: any = page.items[0]; + expect(cart.avgDuration).toBe(400); + const skippedOnly = await getProjectTestCases(db, 1, { q: 'sso' }); + expect((skippedOnly.items[0] as any).avgDuration).toBeNull(); + }); + + test('sorts by pass rate with nulls last in both directions', async () => { + const ascending = await getProjectTestCases(db, 1, { sort: 'passRate', dir: 'asc' }); + const ascRates = ascending.items.map((i: any) => i.passRate); + expect(ascRates.slice(0, 4)).toEqual([0.5, 0.75, 1, 1]); + expect(ascRates.slice(4)).toEqual([null, null]); + const descending = await getProjectTestCases(db, 1, { sort: 'passRate', dir: 'desc' }); + const descRates = descending.items.map((i: any) => i.passRate); + expect(descRates.slice(0, 4)).toEqual([1, 1, 0.75, 0.5]); + expect(descRates.slice(4)).toEqual([null, null]); + }); + + test('normalizes lastRun to epoch milliseconds', async () => { + const page = await getProjectTestCases(db, 1, { q: 'login' }); + const lastRun = (page.items[0] as any).lastRun; + expect(typeof lastRun).toBe('number'); + expect(Math.abs(lastRun - at(1).getTime())).toBeLessThan(1000); + }); +}); + +describe('parseTestCasesQuery', () => { + test('applies defaults on empty input', () => { + expect(parseTestCasesQuery(undefined)).toEqual({ + limit: 50, + offset: 0, + q: undefined, + statuses: undefined, + maxAgeDays: 0, + sort: 'lastRun', + dir: 'desc', + }); + }); + + test('parses URLSearchParams input', () => { + const parsed = parseTestCasesQuery( + new URLSearchParams('q=login&status=failed,flaky,bogus&maxAgeDays=30&sort=passRate&dir=asc&limit=25&offset=50'), + ); + expect(parsed).toEqual({ + limit: 25, + offset: 50, + q: 'login', + statuses: ['failed', 'flaky'], + maxAgeDays: 30, + sort: 'passRate', + dir: 'asc', + }); + }); + + test('clamps and whitelists record input', () => { + const parsed = parseTestCasesQuery({ + limit: '5000', + offset: '-3', + q: ' ', + status: 'nope', + maxAgeDays: '-5', + sort: 'DROP TABLE', + dir: 'sideways', + }); + expect(parsed).toEqual({ + limit: 1000, + offset: 0, + q: undefined, + statuses: undefined, + maxAgeDays: 0, + sort: 'lastRun', + dir: 'desc', + }); + }); +}); diff --git a/application/types/api.ts b/application/types/api.ts index 9241c99f..d682e90c 100644 --- a/application/types/api.ts +++ b/application/types/api.ts @@ -614,22 +614,39 @@ export interface ProjectFailureCluster { } /** - * Test case with statistics - returned by GET /api/projects/[id]/test-cases + * Test case with statistics - one item of GET /api/projects/[id]/test-cases. + * `failedRuns` includes timed-out runs; `status` is the derived category the + * status filter operates on (flaky wins over the last run's status, timeouts + * count as failed, `never-run` when the case has no executions). `passRate` + * is over executed runs only (0..1), null when nothing executed. */ export interface TestCaseWithStats { id: number; filePath: string; + suitePath: string; title: string; + status: string; totalRuns: number; passedRuns: number; failedRuns: number; skippedRuns: number; - timedOutRuns: number; + didNotRunRuns: number; flakyRuns: number; recentFlakyRuns?: number; - avgDuration: number; - lastRun: number; - lastStatus: string; + passRate: number | null; + avgDuration: number | null; + lastRun: number | null; + lastStatus: string | null; +} + +/** + * Paginated envelope returned by GET /api/projects/[id]/test-cases + */ +export interface TestCasesPage { + items: TestCaseWithStats[]; + total: number; + limit: number; + offset: number; } // ============================================================================ diff --git a/docs/ui-overview.md b/docs/ui-overview.md index 0412f537..646037b5 100644 --- a/docs/ui-overview.md +++ b/docs/ui-overview.md @@ -55,7 +55,7 @@ The complete history for one project, organized into tabs: - **Test runs** — every run with status, start time, duration, test counts, and browser badges; select two runs to compare. Runs with a shared failure signature roll up into **Failure clusters** here (see [AI diagnosis & clustering](./ai-diagnosis)). - **Flaky tests** — intermittent tests scored by a composite flakiness metric, with root-cause classification and impact ranking. See [Flaky tests & analytics](./flaky-tests#flaky-test-detection). - **Performance** — average/P90 duration trends and a slowest-tests table. See [Performance](./flaky-tests#performance). -- **Test cases** — every unique test with pass rate, result breakdown, average duration, and last-run date. +- **Test cases** — every unique test with status, executed-only pass rate, result breakdown, average duration, and last run; searchable, filterable by status and last-run age (stale cases hidden by default), paginated, with flat and per-spec tree views. - **Compare** — side-by-side delta between two runs (new failures, recovered, duration changes). - **Spec health** — a heatmap grouping test cases by spec file and coloring each by pass rate, so an unhealthy area of the suite jumps out. See [Spec health heatmap](./flaky-tests#spec-health-heatmap). - **Members** *(admins, when auth is enabled)* — grant or scope project access per user. See [Project access](./authentication#project-access). From 668e94a8966e79bd55b2e836744299193fbc99be Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 22:14:19 +0000 Subject: [PATCH 3/3] fix(app): make test-cases table full width and support choosing page size The desktop table had no explicit width, so on wide viewports it sized to its min-width/content and left dead space instead of filling the card. Page size was a hardcoded 50 with no way to change it. Add w-full alongside the existing min-width so the table always fills its container. Replace the fixed page size with a selectable 10/25/50/100 (default 25, synced to the URL like the other filters). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Co6VcMZLLwZWZ4FiUmR7fY --- .../project/ProjectTestCasesTable.vue | 55 ++++++++++++++----- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/application/app/components/project/ProjectTestCasesTable.vue b/application/app/components/project/ProjectTestCasesTable.vue index ed2fb87e..7e985f8d 100644 --- a/application/app/components/project/ProjectTestCasesTable.vue +++ b/application/app/components/project/ProjectTestCasesTable.vue @@ -19,8 +19,14 @@ const route = useRoute(); const router = useRouter(); const { treeView, setTreeView } = useTreeViewCookie('project-test-cases'); -const PAGE_SIZE = 50; const TREE_LIMIT = 1000; +const DEFAULT_PAGE_SIZE = 25; +const PAGE_SIZE_OPTIONS = [ + { label: '10 / page', value: 10 }, + { label: '25 / page', value: 25 }, + { label: '50 / page', value: 50 }, + { label: '100 / page', value: 100 }, +]; const AGE_OPTIONS = [ { label: 'Last 7 days', value: 7 }, { label: 'Last 30 days', value: 30 }, @@ -48,6 +54,8 @@ const statuses = ref(typeof init.status === 'string' ? init.status.spl const age = ref(typeof init.age === 'string' && init.age !== '' ? Math.max(0, Number(init.age) || 0) : defaultAge); const sort = ref(typeof init.sort === 'string' ? (init.sort as TestCasesSort) : 'lastRun'); const dir = ref<'asc' | 'desc'>(init.dir === 'asc' ? 'asc' : 'desc'); +const initialPageSize = Number(init.pageSize); +const pageSize = ref(PAGE_SIZE_OPTIONS.some((o) => o.value === initialPageSize) ? initialPageSize : DEFAULT_PAGE_SIZE); watch( searchInput, @@ -55,13 +63,13 @@ watch( q.value = value.trim(); }, 300), ); -watch([q, statuses, age, sort, dir, treeView], () => { +watch([q, statuses, age, sort, dir, pageSize, treeView], () => { page.value = 1; }); const query = computed(() => ({ - limit: treeView.value ? TREE_LIMIT : PAGE_SIZE, - offset: treeView.value ? 0 : (page.value - 1) * PAGE_SIZE, + limit: treeView.value ? TREE_LIMIT : pageSize.value, + offset: treeView.value ? 0 : (page.value - 1) * pageSize.value, ...(q.value ? { q: q.value } : {}), ...(statuses.value.length > 0 ? { status: statuses.value.join(',') } : {}), maxAgeDays: age.value, @@ -82,7 +90,7 @@ watch( ); if (props.syncQuery) { - watch([q, statuses, age, sort, dir, page], () => { + watch([q, statuses, age, sort, dir, page, pageSize], () => { router.replace({ query: { ...route.query, @@ -92,6 +100,7 @@ if (props.syncQuery) { sort: sort.value !== 'lastRun' ? sort.value : undefined, dir: dir.value !== 'desc' ? dir.value : undefined, page: page.value > 1 ? String(page.value) : undefined, + pageSize: pageSize.value !== DEFAULT_PAGE_SIZE ? String(pageSize.value) : undefined, }, }); }); @@ -146,8 +155,10 @@ const columns = computed[]>(() => [ const items = computed(() => data.value?.items ?? []); const total = computed(() => data.value?.total ?? 0); -const showingFrom = computed(() => (total.value === 0 ? 0 : (page.value - 1) * PAGE_SIZE + 1)); -const showingTo = computed(() => (treeView.value ? items.value.length : Math.min(page.value * PAGE_SIZE, total.value))); +const showingFrom = computed(() => (total.value === 0 ? 0 : (page.value - 1) * pageSize.value + 1)); +const showingTo = computed(() => + treeView.value ? items.value.length : Math.min(page.value * pageSize.value, total.value), +); const hasSearchOrStatusFilter = computed(() => q.value !== '' || statuses.value.length > 0); const hasAnyFilter = computed(() => hasSearchOrStatusFilter.value || age.value !== 0); const initialLoading = computed(() => status.value === 'pending' && !data.value); @@ -297,7 +308,7 @@ defineExpose({ refresh }); :data="items" :columns="columns" :ui="{ - base: 'table-fixed border-separate border-spacing-0 min-w-[56rem]', + base: 'w-full table-fixed border-separate border-spacing-0 min-w-[56rem]', thead: '[&>tr]:bg-elevated/50 [&>tr]:after:content-none', tbody: '[&>tr]:last:[&>td]:border-b-0 [&>tr]:hover:bg-gray-50 dark:[&>tr]:hover:bg-gray-900/50', th: 'first:rounded-l-lg last:rounded-r-lg border-y border-default first:border-l last:border-r', @@ -434,11 +445,29 @@ defineExpose({ refresh }); -
- Showing {{ showingFrom }}–{{ showingTo }} of {{ total }} - +
+
+ Showing {{ showingFrom }}–{{ showingTo }} of {{ total }} +
+ Rows per page + +
+
+