import { expect } from '@playwright/test'; import { test } from '../playwright/fixtures'; import { goToReportingDetailed, waitForDetailedReportingUpdate } from './utils/reporting'; import { createProjectViaApi, createClientViaApi, createTaskViaApi, createTimeEntryViaApi, createTimeEntryWithTagViaApi, createBareTimeEntryViaApi, } from './utils/api'; // Each test registers a new user and creates test data via API test.describe.configure({ timeout: 30000 }); // ────────────────────────────────────────────────── // Basic Detailed View Tests // ────────────────────────────────────────────────── test('test that detailed view shows time entries correctly', async ({ page, ctx }) => { const projectName = 'Detailed View Project ' + Math.floor(Math.random() * 10000); const project = await createProjectViaApi(ctx, { name: projectName }); await createTimeEntryViaApi(ctx, { description: `Entry for ${projectName}`, duration: '1h', projectId: project.id, }); // Go to detailed reporting view await goToReportingDetailed(page); // Verify the time entry is shown with all details await expect(page.getByText(projectName, { exact: true }).first()).toBeVisible(); await expect(page.locator('input[name="Duration"]').first()).toHaveValue('1:00:00'); await expect(page.getByText('Entry for ' + projectName, { exact: true }).first()).toBeVisible(); }); test('test that updating duration in detailed view works correctly', async ({ page, ctx }) => { const projectName = 'Duration Update Project ' + Math.floor(Math.random() * 10000); const initialDuration = '1h'; const updatedDuration = '2h 30min'; const project = await createProjectViaApi(ctx, { name: projectName }); await createTimeEntryViaApi(ctx, { description: `Entry for ${projectName}`, duration: initialDuration, projectId: project.id, }); // Go to detailed reporting view await goToReportingDetailed(page); // Find and update the duration const durationInput = page.locator('input[name="Duration"]').first(); await durationInput.click(); await durationInput.fill(updatedDuration); await Promise.all([ durationInput.press('Enter'), page.waitForResponse( (response) => response.url().includes('/time-entries') && response.status() === 200 ), ]); // Verify the new duration is displayed (reporting views promote to HH:MM:SS format) await expect(durationInput).toHaveValue('2:30:00'); }); // ────────────────────────────────────────────────── // Project Filter Tests // ────────────────────────────────────────────────── test('test that project multiselect filters work on detailed reporting page', async ({ page, ctx, }) => { const project1 = 'DetailProj1 ' + Math.floor(Math.random() * 10000); const project2 = 'DetailProj2 ' + Math.floor(Math.random() * 10000); const p1 = await createProjectViaApi(ctx, { name: project1 }); const p2 = await createProjectViaApi(ctx, { name: project2 }); await createTimeEntryViaApi(ctx, { description: `Entry for ${project1}`, duration: '1h', projectId: p1.id, }); await createTimeEntryViaApi(ctx, { description: `Entry for ${project2}`, duration: '2h', projectId: p2.id, }); await goToReportingDetailed(page); // Wait for initial data load await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible(); await expect(page.getByText(`Entry for ${project2}`).first()).toBeVisible(); // Open project multiselect and select project1 await page.getByRole('button', { name: 'Projects' }).first().click(); await page.getByRole('option').filter({ hasText: project1 }).click(); await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); // Verify only project1 entry is shown await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible(); await expect(page.getByText(`Entry for ${project2}`).first()).not.toBeVisible(); }); // ────────────────────────────────────────────────── // Client Filter Tests // ────────────────────────────────────────────────── test('test that client multiselect filters work on detailed reporting page', async ({ page, ctx, }) => { const client1 = 'DetailClient1 ' + Math.floor(Math.random() * 10000); const project1 = 'DetailClientProj1 ' + Math.floor(Math.random() * 10000); const project2 = 'DetailClientProj2 ' + Math.floor(Math.random() * 10000); const c1 = await createClientViaApi(ctx, { name: client1 }); const p1 = await createProjectViaApi(ctx, { name: project1, client_id: c1.id }); const p2 = await createProjectViaApi(ctx, { name: project2 }); await createTimeEntryViaApi(ctx, { description: `Entry for ${project1}`, duration: '1h', projectId: p1.id, }); await createTimeEntryViaApi(ctx, { description: `Entry for ${project2}`, duration: '2h', projectId: p2.id, }); await goToReportingDetailed(page); await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible(); await expect(page.getByText(`Entry for ${project2}`).first()).toBeVisible(); // Filter by client1 await page.getByRole('button', { name: 'Clients' }).first().click(); await page.getByRole('option').filter({ hasText: client1 }).click(); await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); // Only entries for project1 (with client1) should be visible await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible(); await expect(page.getByText(`Entry for ${project2}`).first()).not.toBeVisible(); }); // ────────────────────────────────────────────────── // Task Filter Tests // ────────────────────────────────────────────────── test('test that task multiselect dropdown filters reporting by task', async ({ page, ctx }) => { const projectName = 'TaskFilterProj ' + Math.floor(Math.random() * 10000); const task1 = 'TaskFilter1 ' + Math.floor(Math.random() * 10000); const task2 = 'TaskFilter2 ' + Math.floor(Math.random() * 10000); const project = await createProjectViaApi(ctx, { name: projectName }); const t1 = await createTaskViaApi(ctx, { name: task1, project_id: project.id }); const t2 = await createTaskViaApi(ctx, { name: task2, project_id: project.id }); await createTimeEntryViaApi(ctx, { description: `Entry for ${projectName} - ${task1}`, duration: '1h', projectId: project.id, taskId: t1.id, }); await createTimeEntryViaApi(ctx, { description: `Entry for ${projectName} - ${task2}`, duration: '2h', projectId: project.id, taskId: t2.id, }); // Use the detailed view to verify task filtering (shows individual entries) await goToReportingDetailed(page); await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible(); await expect(page.getByText(`Entry for ${projectName} - ${task2}`).first()).toBeVisible(); // Open task multiselect dropdown await page.getByRole('button', { name: 'Tasks' }).first().click(); // Verify both tasks appear await expect(page.getByRole('option').filter({ hasText: task1 })).toBeVisible(); await expect(page.getByRole('option').filter({ hasText: task2 })).toBeVisible(); // Select task1 await page.getByRole('option').filter({ hasText: task1 }).click(); await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); // Verify badge shows count of 1 await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('1')).toBeVisible(); // Verify only task1 entry is shown await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible(); await expect(page.getByText(`Entry for ${projectName} - ${task2}`).first()).not.toBeVisible(); }); test('test that selecting multiple tasks shows correct badge count', async ({ page, ctx }) => { const projectName = 'MultiTaskProj ' + Math.floor(Math.random() * 10000); const task1 = 'MultiTask1 ' + Math.floor(Math.random() * 10000); const task2 = 'MultiTask2 ' + Math.floor(Math.random() * 10000); const project = await createProjectViaApi(ctx, { name: projectName }); const t1 = await createTaskViaApi(ctx, { name: task1, project_id: project.id }); const t2 = await createTaskViaApi(ctx, { name: task2, project_id: project.id }); await createTimeEntryViaApi(ctx, { description: `Entry for ${projectName} - ${task1}`, duration: '1h', projectId: project.id, taskId: t1.id, }); await createTimeEntryViaApi(ctx, { description: `Entry for ${projectName} - ${task2}`, duration: '2h', projectId: project.id, taskId: t2.id, }); // Use the detailed view to verify task filtering await goToReportingDetailed(page); await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible(); await expect(page.getByText(`Entry for ${projectName} - ${task2}`).first()).toBeVisible(); // Select both tasks await page.getByRole('button', { name: 'Tasks' }).first().click(); await page.getByRole('option').filter({ hasText: task1 }).click(); await page.getByRole('option').filter({ hasText: task2 }).click(); await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); // Verify badge shows count of 2 await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('2')).toBeVisible(); // Verify both task entries are shown await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible(); await expect(page.getByText(`Entry for ${projectName} - ${task2}`).first()).toBeVisible(); }); test('test that deselecting a task removes the filter', async ({ page, ctx }) => { const projectName = 'TaskDeselectProj ' + Math.floor(Math.random() * 10000); const task1 = 'TaskDeselect1 ' + Math.floor(Math.random() * 10000); const project = await createProjectViaApi(ctx, { name: projectName }); const t1 = await createTaskViaApi(ctx, { name: task1, project_id: project.id }); await createTimeEntryViaApi(ctx, { description: `Entry for ${projectName} - ${task1}`, duration: '1h', projectId: project.id, taskId: t1.id, }); await goToReportingDetailed(page); await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible(); // Select task await page.getByRole('button', { name: 'Tasks' }).first().click(); await page.getByRole('option').filter({ hasText: task1 }).click(); await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('1')).toBeVisible(); // Deselect task await page.getByRole('button', { name: 'Tasks' }).first().click(); await page.getByRole('option').filter({ hasText: task1 }).click(); await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); await expect( page.getByRole('button', { name: 'Tasks' }).first().getByText(/^\d+$/) ).not.toBeVisible(); }); // ────────────────────────────────────────────────── // Member Filter Tests // ────────────────────────────────────────────────── test('test that member multiselect filters work on detailed reporting page', async ({ page, ctx, }) => { const projectName = 'DetailMemberProj ' + Math.floor(Math.random() * 10000); const project = await createProjectViaApi(ctx, { name: projectName }); await createTimeEntryViaApi(ctx, { description: `Entry for ${projectName}`, duration: '1h', projectId: project.id, }); await goToReportingDetailed(page); await expect(page.getByText(`Entry for ${projectName}`).first()).toBeVisible(); // Filter by the current member await page.getByRole('button', { name: 'Members' }).first().click(); await page.getByRole('option').filter({ hasText: 'John Doe' }).click(); await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); // Data should still be visible since all entries belong to this member await expect(page.getByText(`Entry for ${projectName}`).first()).toBeVisible(); // Verify badge shows count of 1 await expect( page.getByRole('button', { name: 'Members' }).first().getByText('1') ).toBeVisible(); }); // ────────────────────────────────────────────────── // Tag Filter Tests // ────────────────────────────────────────────────── test('test that tag filter works on detailed reporting page', async ({ page, ctx }) => { const tag1 = 'DetailTag1 ' + Math.floor(Math.random() * 10000); const tag2 = 'DetailTag2 ' + Math.floor(Math.random() * 10000); await createTimeEntryWithTagViaApi(ctx, tag1, '1h'); await createTimeEntryWithTagViaApi(ctx, tag2, '2h'); await goToReportingDetailed(page); await expect(page.getByText(`Entry with tag ${tag1}`).first()).toBeVisible(); await expect(page.getByText(`Entry with tag ${tag2}`).first()).toBeVisible(); // Filter by tag1 await page.getByRole('button', { name: 'Tags' }).click(); await page.getByRole('option').filter({ hasText: tag1 }).click(); await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); await expect(page.getByText(`Entry with tag ${tag1}`).first()).toBeVisible(); await expect(page.getByText(`Entry with tag ${tag2}`).first()).not.toBeVisible(); }); // ────────────────────────────────────────────────── // Billable Filter Tests // ────────────────────────────────────────────────── test('test that billable filter works on detailed reporting page', async ({ page, ctx }) => { const projectName = 'DetailBillProj ' + Math.floor(Math.random() * 10000); const project = await createProjectViaApi(ctx, { name: projectName }); await createTimeEntryViaApi(ctx, { description: `Entry for ${projectName}`, duration: '1h', projectId: project.id, }); await goToReportingDetailed(page); await expect(page.getByText(`Entry for ${projectName}`).first()).toBeVisible(); // Filter by billable only await page.getByRole('combobox').filter({ hasText: 'Billable' }).click(); await Promise.all([ page.getByRole('option', { name: 'Billable', exact: true }).click(), waitForDetailedReportingUpdate(page), ]); // Switch to Non Billable await page.getByRole('combobox').filter({ hasText: 'Billable' }).click(); await Promise.all([ page.getByRole('option', { name: 'Non Billable', exact: true }).click(), waitForDetailedReportingUpdate(page), ]); // Switch back to Both await page.getByRole('combobox').filter({ hasText: 'Non Billable' }).click(); await Promise.all([ page.getByRole('option', { name: 'Both' }).click(), waitForDetailedReportingUpdate(page), ]); }); // ────────────────────────────────────────────────── // Combined Filter Tests // ────────────────────────────────────────────────── test('test that combining project and task filters narrows results', async ({ page, ctx }) => { const projectName = 'CombinedProj ' + Math.floor(Math.random() * 10000); const otherProject = 'OtherCombProj ' + Math.floor(Math.random() * 10000); const task1 = 'CombinedTask1 ' + Math.floor(Math.random() * 10000); const p1 = await createProjectViaApi(ctx, { name: projectName }); const p2 = await createProjectViaApi(ctx, { name: otherProject }); const t1 = await createTaskViaApi(ctx, { name: task1, project_id: p1.id }); await createTimeEntryViaApi(ctx, { description: `Entry for ${projectName} - ${task1}`, duration: '1h', projectId: p1.id, taskId: t1.id, }); await createTimeEntryViaApi(ctx, { description: `Entry for ${otherProject}`, duration: '2h', projectId: p2.id, }); await goToReportingDetailed(page); await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible(); await expect(page.getByText(`Entry for ${otherProject}`).first()).toBeVisible(); // Filter by project await page.getByRole('button', { name: 'Projects' }).first().click(); await page.getByRole('option').filter({ hasText: projectName }).click(); await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); // Additionally filter by task await page.getByRole('button', { name: 'Tasks' }).first().click(); await page.getByRole('option').filter({ hasText: task1 }).click(); await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); // Verify both badges show count of 1 await expect( page.getByRole('button', { name: 'Projects' }).first().getByText('1') ).toBeVisible(); await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('1')).toBeVisible(); // Verify only the combined entry is shown await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible(); await expect(page.getByText(`Entry for ${otherProject}`).first()).not.toBeVisible(); }); test('test that combining client and member filters narrows results on detailed page', async ({ page, ctx, }) => { const client1 = 'CombClient ' + Math.floor(Math.random() * 10000); const project1 = 'CombClientProj ' + Math.floor(Math.random() * 10000); const project2 = 'CombNoClientProj ' + Math.floor(Math.random() * 10000); const c1 = await createClientViaApi(ctx, { name: client1 }); const p1 = await createProjectViaApi(ctx, { name: project1, client_id: c1.id }); const p2 = await createProjectViaApi(ctx, { name: project2 }); await createTimeEntryViaApi(ctx, { description: `Entry for ${project1}`, duration: '1h', projectId: p1.id, }); await createTimeEntryViaApi(ctx, { description: `Entry for ${project2}`, duration: '2h', projectId: p2.id, }); await goToReportingDetailed(page); await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible(); await expect(page.getByText(`Entry for ${project2}`).first()).toBeVisible(); // Filter by client await page.getByRole('button', { name: 'Clients' }).first().click(); await page.getByRole('option').filter({ hasText: client1 }).click(); await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); // Additionally filter by member await page.getByRole('button', { name: 'Members' }).first().click(); await page.getByRole('option').filter({ hasText: 'John Doe' }).click(); await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); // Only project1 entry should be visible (filtered by client + member) await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible(); await expect(page.getByText(`Entry for ${project2}`).first()).not.toBeVisible(); // Both badges should show count of 1 await expect( page.getByRole('button', { name: 'Clients' }).first().getByText('1') ).toBeVisible(); await expect( page.getByRole('button', { name: 'Members' }).first().getByText('1') ).toBeVisible(); }); test('test that combining tag and project filters narrows results', async ({ page, ctx }) => { const tag1 = 'CombTag ' + Math.floor(Math.random() * 10000); const project1 = 'CombTagProj ' + Math.floor(Math.random() * 10000); const p1 = await createProjectViaApi(ctx, { name: project1 }); // Create a time entry with a project (no tag) await createTimeEntryViaApi(ctx, { description: `Entry for ${project1}`, duration: '1h', projectId: p1.id, }); // Create a time entry with a tag (no specific project) await createTimeEntryWithTagViaApi(ctx, tag1, '2h'); await goToReportingDetailed(page); await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible(); await expect(page.getByText(`Entry with tag ${tag1}`).first()).toBeVisible(); // Filter by project await page.getByRole('button', { name: 'Projects' }).first().click(); await page.getByRole('option').filter({ hasText: project1 }).click(); await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); // Only the project entry should be visible (tagged entry has no project) await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible(); await expect(page.getByText(`Entry with tag ${tag1}`).first()).not.toBeVisible(); }); // ────────────────────────────────────────────────── // "No X" Filter Tests // ────────────────────────────────────────────────── test('test that "No Project" filter shows entries without a project', async ({ page, ctx }) => { const project1 = 'NoProj1 ' + Math.floor(Math.random() * 10000); const p1 = await createProjectViaApi(ctx, { name: project1 }); await createTimeEntryViaApi(ctx, { description: `Entry for ${project1}`, duration: '1h', projectId: p1.id, }); await createBareTimeEntryViaApi(ctx, 'Bare entry no project', '30min'); await goToReportingDetailed(page); await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible(); await expect(page.getByText('Bare entry no project').first()).toBeVisible(); // Open project dropdown and select "No Project" await page.getByRole('button', { name: 'Projects' }).first().click(); await page.getByRole('option').filter({ hasText: 'No Project' }).click(); await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); // Verify badge shows 1 await expect( page.getByRole('button', { name: 'Projects' }).first().getByText('1') ).toBeVisible(); // Only the bare entry (no project) should be visible await expect(page.getByText('Bare entry no project').first()).toBeVisible(); await expect(page.getByText(`Entry for ${project1}`).first()).not.toBeVisible(); }); test('test that "No Task" filter shows entries without a task', async ({ page, ctx }) => { const projectName = 'NoTaskProj ' + Math.floor(Math.random() * 10000); const task1 = 'NoTaskFilter1 ' + Math.floor(Math.random() * 10000); const project = await createProjectViaApi(ctx, { name: projectName }); const t1 = await createTaskViaApi(ctx, { name: task1, project_id: project.id }); await createTimeEntryViaApi(ctx, { description: `Entry for ${projectName} - ${task1}`, duration: '1h', projectId: project.id, taskId: t1.id, }); await createTimeEntryViaApi(ctx, { description: `Entry for ${projectName}`, duration: '30min', projectId: project.id, }); await goToReportingDetailed(page); await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible(); await expect(page.getByText(`Entry for ${projectName}`).first()).toBeVisible(); // Open task dropdown and select "No Task" await page.getByRole('button', { name: 'Tasks' }).first().click(); await page.getByRole('option').filter({ hasText: 'No Task' }).click(); await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('1')).toBeVisible(); // Only the entry without a task should be visible await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).not.toBeVisible(); }); test('test that "No Tag" filter shows entries without tags', async ({ page, ctx }) => { const tag1 = 'NoTagFilter1 ' + Math.floor(Math.random() * 10000); await createTimeEntryWithTagViaApi(ctx, tag1, '1h'); await createBareTimeEntryViaApi(ctx, 'Entry without any tag', '30min'); await goToReportingDetailed(page); await expect(page.getByText(`Entry with tag ${tag1}`).first()).toBeVisible(); await expect(page.getByText('Entry without any tag').first()).toBeVisible(); // Open tag dropdown and select "No Tag" await page.getByRole('button', { name: 'Tags' }).click(); await page.getByRole('option').filter({ hasText: 'No Tag' }).click(); await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); await expect(page.getByRole('button', { name: 'Tags' }).getByText('1')).toBeVisible(); await expect(page.getByText('Entry without any tag').first()).toBeVisible(); await expect(page.getByText(`Entry with tag ${tag1}`).first()).not.toBeVisible(); }); test('test that "No Client" filter shows entries without a client', async ({ page, ctx }) => { const client1 = 'NoClientFilter ' + Math.floor(Math.random() * 10000); const projectWithClient = 'NoClientProj1 ' + Math.floor(Math.random() * 10000); const projectNoClient = 'NoClientProj2 ' + Math.floor(Math.random() * 10000); const c1 = await createClientViaApi(ctx, { name: client1 }); const pWithClient = await createProjectViaApi(ctx, { name: projectWithClient, client_id: c1.id, }); const pNoClient = await createProjectViaApi(ctx, { name: projectNoClient }); await createTimeEntryViaApi(ctx, { description: `Entry for ${projectWithClient}`, duration: '1h', projectId: pWithClient.id, }); await createTimeEntryViaApi(ctx, { description: `Entry for ${projectNoClient}`, duration: '30min', projectId: pNoClient.id, }); await goToReportingDetailed(page); await expect(page.getByText(`Entry for ${projectWithClient}`).first()).toBeVisible(); await expect(page.getByText(`Entry for ${projectNoClient}`).first()).toBeVisible(); // Open client dropdown and select "No Client" await page.getByRole('button', { name: 'Clients' }).first().click(); await page.getByRole('option').filter({ hasText: 'No Client' }).click(); await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); await expect( page.getByRole('button', { name: 'Clients' }).first().getByText('1') ).toBeVisible(); await expect(page.getByText(`Entry for ${projectNoClient}`).first()).toBeVisible(); await expect(page.getByText(`Entry for ${projectWithClient}`).first()).not.toBeVisible(); }); test('test that combining "No Project" with a project ID shows both', async ({ page, ctx }) => { const project1 = 'CombNoProj ' + Math.floor(Math.random() * 10000); const p1 = await createProjectViaApi(ctx, { name: project1 }); await createTimeEntryViaApi(ctx, { description: `Entry for ${project1}`, duration: '1h', projectId: p1.id, }); await createBareTimeEntryViaApi(ctx, 'Bare combined entry', '30min'); await goToReportingDetailed(page); await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible(); await expect(page.getByText('Bare combined entry').first()).toBeVisible(); // Select both "No Project" and the specific project await page.getByRole('button', { name: 'Projects' }).first().click(); await page.getByRole('option').filter({ hasText: 'No Project' }).click(); await page.getByRole('option').filter({ hasText: project1 }).click(); await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); // Badge should show 2 await expect( page.getByRole('button', { name: 'Projects' }).first().getByText('2') ).toBeVisible(); // Both entries should be visible await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible(); await expect(page.getByText('Bare combined entry').first()).toBeVisible(); }); // ────────────────────────────────────────────────── // Keyboard Navigation Tests // ────────────────────────────────────────────────── test('test that keyboard navigation works in multiselect dropdown', async ({ page, ctx }) => { const project1 = 'KbNavProj1 ' + Math.floor(Math.random() * 10000); const project2 = 'KbNavProj2 ' + Math.floor(Math.random() * 10000); const p1 = await createProjectViaApi(ctx, { name: project1 }); const p2 = await createProjectViaApi(ctx, { name: project2 }); await createTimeEntryViaApi(ctx, { description: `Entry for ${project1}`, duration: '1h', projectId: p1.id, }); await createTimeEntryViaApi(ctx, { description: `Entry for ${project2}`, duration: '2h', projectId: p2.id, }); await goToReportingDetailed(page); await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible(); // Open project dropdown await page.getByRole('button', { name: 'Projects' }).first().click(); // The search input should be focused, first item ("No Project") highlighted await expect(page.getByPlaceholder('Search for a Project...')).toBeFocused(); // Press ArrowDown to move to first project, then Enter to select it await page.keyboard.press('ArrowDown'); await page.keyboard.press('ArrowDown'); await page.keyboard.press('Enter'); // Close dropdown and verify filter applied await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); // Badge should show 1 await expect( page.getByRole('button', { name: 'Projects' }).first().getByText('1') ).toBeVisible(); }); // ────────────────────────────────────────────────── // Pagination Tests // ────────────────────────────────────────────────── test.describe('Reporting Detailed Pagination', () => { test('test that detailed reporting paginates when there are more than 15 time entries', async ({ page, ctx, }) => { // The detailed report paginates server-side with a page limit of 15. // Create 17 time entries on a single project so we get exactly 2 pages. const seed = Math.floor(Math.random() * 100000); const projectName = `ReportPagProj ${seed}`; const project = await createProjectViaApi(ctx, { name: projectName }); const descriptions = Array.from( { length: 17 }, (_, i) => `ReportPagEntry ${String(i).padStart(2, '0')} ${seed}` ); await Promise.all( descriptions.map((description) => createTimeEntryViaApi(ctx, { description, duration: '30min', projectId: project.id, }) ) ); await goToReportingDetailed(page); await expect(page.getByText(descriptions[0]!).first()).toBeVisible({ timeout: 10000, }); // Pagination nav should be rendered. await expect(page.getByRole('button', { name: 'Next Page' })).toBeVisible(); // Collect which descriptions are currently visible on page 1. const visiblePage1 = new Set(); for (const description of descriptions) { if ((await page.getByText(description).count()) > 0) { visiblePage1.add(description); } } // The page limit is 15 → exactly 15 entries visible on page 1. expect(visiblePage1.size).toBe(15); // Go to page 2 and wait for the server fetch. await Promise.all([ page.getByRole('button', { name: 'Next Page' }).click(), waitForDetailedReportingUpdate(page), ]); const visiblePage2 = new Set(); for (const description of descriptions) { if ((await page.getByText(description).count()) > 0) { visiblePage2.add(description); } } // Page 2 should hold the remaining 2 entries, disjoint from page 1. expect(visiblePage2.size).toBe(2); for (const description of visiblePage2) { expect(visiblePage1.has(description)).toBe(false); } // Across both pages, all 17 entries should have been visible. expect(visiblePage1.size + visiblePage2.size).toBe(17); // Page 2 button is selected. await expect(page.getByRole('button', { name: 'Page 2' })).toHaveAttribute( 'aria-current', 'page' ); // Previous page returns to page 1. await Promise.all([ page.getByRole('button', { name: 'Previous Page' }).click(), waitForDetailedReportingUpdate(page), ]); expect((await page.getByText(descriptions[0]!).count()) > 0).toBe(true); }); test('test that reporting pagination is not shown when there are 15 or fewer time entries', async ({ page, ctx, }) => { const seed = Math.floor(Math.random() * 100000); const projectName = `FewEntriesProj ${seed}`; const project = await createProjectViaApi(ctx, { name: projectName }); await Promise.all( Array.from({ length: 5 }, (_, i) => createTimeEntryViaApi(ctx, { description: `FewEntries ${i} ${seed}`, duration: '30min', projectId: project.id, }) ) ); await goToReportingDetailed(page); await expect(page.getByText(`FewEntries 0 ${seed}`).first()).toBeVisible({ timeout: 10000, }); await expect(page.getByRole('button', { name: 'Next Page' })).toHaveCount(0); }); });