import { expect } from '@playwright/test'; import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc.js'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { test } from '../playwright/fixtures'; dayjs.extend(utc); import { createProjectViaApi, createClientViaApi, createTaskViaApi, createTimeEntryViaApi, createTimeEntryWithTagViaApi, createBareTimeEntryViaApi, createBillableProjectViaApi, createTimeEntryWithBillableStatusViaApi, createTagViaApi, createReportViaApi, } from './utils/api'; import { goToReporting, goToReportingShared, waitForReportingUpdate, saveAsSharedReport, } from './utils/reporting'; // Each test registers a new user and creates test data via API test.describe.configure({ timeout: 30000 }); // Date picker button name patterns for different date formats const DATE_PICKER_BUTTON_PATTERN = /^Pick a date$|^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/; // ────────────────────────────────────────────────── // Shared Report Lifecycle Tests // ────────────────────────────────────────────────── test('test that saving a report creates a shared report and its shareable link shows correct data', async ({ page, ctx, }) => { const projectName = 'SharedProject ' + Math.floor(Math.random() * 10000); const reportName = 'SharedReport ' + 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 goToReporting(page); await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible(); const { shareableLink } = await saveAsSharedReport(page, reportName); // Verify report appears on shared tab await goToReportingShared(page); await expect(page.getByTestId('report_table')).toBeVisible(); await expect(page.getByText(reportName)).toBeVisible(); await expect(page.getByText('Public', { exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: 'Copy URL' })).toBeVisible(); // Navigate to shareable link and verify report data await page.goto(shareableLink); await expect(page.getByText('Reporting')).toBeVisible(); await expect(page.getByText(projectName)).toBeVisible(); await expect(page.getByText('Total')).toBeVisible(); }); test('test that shared report with invalid secret shows no data', async ({ page }) => { await page.goto(PLAYWRIGHT_BASE_URL + '/shared-report#invalid-secret-value'); await expect(page.getByText('No time entries found').first()).toBeVisible(); }); test('test that a shared report can be edited to toggle public/private and then deleted', async ({ page, ctx, }) => { const projectName = 'EditDelProject ' + Math.floor(Math.random() * 10000); const reportName = 'EditDelReport ' + 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 goToReporting(page); await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible(); await saveAsSharedReport(page, reportName); await goToReportingShared(page); await expect(page.getByText(reportName)).toBeVisible(); await expect(page.getByText('Public', { exact: true })).toBeVisible(); // Click more options and edit await page .getByRole('button', { name: new RegExp('Actions for Project ' + reportName) }) .click(); await page.getByRole('menuitem', { name: /^Edit Report/ }).click(); // Uncheck public and save await page.getByLabel('Public').click(); await Promise.all([ page.waitForResponse( (response) => response.url().includes('/reports/') && response.request().method() === 'PUT' && response.status() === 200 ), page.getByRole('button', { name: 'Update Report' }).click(), ]); // Verify status changed to private await expect(page.getByText('Private')).toBeVisible(); await expect(page.getByText('--')).toBeVisible(); // Delete the report await page .getByRole('button', { name: new RegExp('Actions for Project ' + reportName) }) .click(); await Promise.all([ page.waitForResponse( (response) => response.url().includes('/reports/') && response.request().method() === 'DELETE' && response.status() === 204 ), page.getByRole('menuitem', { name: /^Delete Report/ }).click(), ]); await expect(page.getByText('No shared reports found')).toBeVisible(); }); // ────────────────────────────────────────────────── // Shared Report Filter Tests // ────────────────────────────────────────────────── test('test that shared report respects project filter', async ({ page, ctx }) => { const projectA = 'FilterProjA ' + Math.floor(Math.random() * 10000); const projectB = 'FilterProjB ' + Math.floor(Math.random() * 10000); const reportName = 'FilterProjReport ' + Math.floor(Math.random() * 10000); const projA = await createProjectViaApi(ctx, { name: projectA }); const projB = await createProjectViaApi(ctx, { name: projectB }); await createTimeEntryViaApi(ctx, { description: `Entry for ${projectA}`, duration: '1h', projectId: projA.id, }); await createTimeEntryViaApi(ctx, { description: `Entry for ${projectB}`, duration: '2h', projectId: projB.id, }); await goToReporting(page); await expect(page.getByTestId('reporting_view').getByText(projectA)).toBeVisible(); // Filter by project A await page.getByRole('button', { name: 'Projects' }).first().click(); await Promise.all([ page.getByRole('option').filter({ hasText: projectA }).click(), waitForReportingUpdate(page), ]); await page.keyboard.press('Escape'); const { shareableLink } = await saveAsSharedReport(page, reportName); // View the shared report await page.goto(shareableLink); await expect(page.getByText('Reporting')).toBeVisible(); await expect(page.getByText(projectA)).toBeVisible(); await expect(page.getByText(projectB)).not.toBeVisible(); }); test('test that shared report with No Project filter shows entries without a project', async ({ page, ctx, }) => { const projectName = 'NoProjFilter ' + Math.floor(Math.random() * 10000); const reportName = 'NoProjReport ' + 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 createBareTimeEntryViaApi(ctx, 'Bare entry no project', '2h'); await goToReporting(page); await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible(); // Filter by "No Project" await page.getByRole('button', { name: 'Projects' }).first().click(); await Promise.all([ page.getByRole('option').filter({ hasText: 'No Project' }).click(), waitForReportingUpdate(page), ]); await page.keyboard.press('Escape'); const { shareableLink } = await saveAsSharedReport(page, reportName); // View the shared report await page.goto(shareableLink); await expect(page.getByText('Reporting')).toBeVisible(); // The "No Project" group should show, but the project name should not appear as a group await expect(page.getByText('Total')).toBeVisible(); await expect(page.getByText(projectName)).not.toBeVisible(); }); test('test that shared report with No Task filter shows entries without a task', async ({ page, ctx, }) => { const projectName = 'NoTaskProj ' + Math.floor(Math.random() * 10000); const taskName = 'NoTaskFilter ' + Math.floor(Math.random() * 10000); const reportName = 'NoTaskReport ' + Math.floor(Math.random() * 10000); const project = await createProjectViaApi(ctx, { name: projectName }); const task = await createTaskViaApi(ctx, { name: taskName, project_id: project.id }); await createTimeEntryViaApi(ctx, { description: `Entry for ${projectName} - ${taskName}`, duration: '1h', projectId: project.id, taskId: task.id, }); await createTimeEntryViaApi(ctx, { description: `Entry for ${projectName}`, duration: '2h', projectId: project.id, }); await goToReporting(page); await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible(); // Filter by "No Task" await page.getByRole('button', { name: 'Tasks' }).first().click(); await Promise.all([ page.getByRole('option').filter({ hasText: 'No Task' }).click(), waitForReportingUpdate(page), ]); await page.keyboard.press('Escape'); const { shareableLink } = await saveAsSharedReport(page, reportName); // View the shared report await page.goto(shareableLink); await expect(page.getByText('Reporting')).toBeVisible(); await expect(page.getByText('Total')).toBeVisible(); }); test('test that shared report respects task filter', async ({ page, ctx }) => { const projectName = 'TaskFilterProj ' + Math.floor(Math.random() * 10000); const taskA = 'TaskA ' + Math.floor(Math.random() * 10000); const taskB = 'TaskB ' + Math.floor(Math.random() * 10000); const reportName = 'TaskFilterReport ' + Math.floor(Math.random() * 10000); const project = await createProjectViaApi(ctx, { name: projectName }); const task = await createTaskViaApi(ctx, { name: taskA, project_id: project.id }); await createTaskViaApi(ctx, { name: taskB, project_id: project.id }); await createTimeEntryViaApi(ctx, { description: `Entry for ${taskA}`, duration: '1h', projectId: project.id, taskId: task.id, }); await createTimeEntryViaApi(ctx, { description: `Entry for ${projectName} no task`, duration: '2h', projectId: project.id, }); await goToReporting(page); await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible(); // Filter by task A await page.getByRole('button', { name: 'Tasks' }).first().click(); await Promise.all([ page.getByRole('option').filter({ hasText: taskA }).click(), waitForReportingUpdate(page), ]); await page.keyboard.press('Escape'); const { shareableLink } = await saveAsSharedReport(page, reportName); // View the shared report await page.goto(shareableLink); await expect(page.getByText('Reporting')).toBeVisible(); await expect(page.getByText('Total')).toBeVisible(); await expect(page.getByText('1:00:00').first()).toBeVisible(); await expect(page.getByText('3:00:00')).not.toBeVisible(); }); test('test that shared report respects client filter', async ({ page, ctx }) => { const clientA = 'ClientA ' + Math.floor(Math.random() * 10000); const clientB = 'ClientB ' + Math.floor(Math.random() * 10000); const projectA = 'ClientFilterProjA ' + Math.floor(Math.random() * 10000); const projectB = 'ClientFilterProjB ' + Math.floor(Math.random() * 10000); const reportName = 'ClientFilterReport ' + Math.floor(Math.random() * 10000); const cliA = await createClientViaApi(ctx, { name: clientA }); const cliB = await createClientViaApi(ctx, { name: clientB }); const projA = await createProjectViaApi(ctx, { name: projectA, client_id: cliA.id }); const projB = await createProjectViaApi(ctx, { name: projectB, client_id: cliB.id }); await createTimeEntryViaApi(ctx, { description: `Entry for ${clientA}`, duration: '1h', projectId: projA.id, }); await createTimeEntryViaApi(ctx, { description: `Entry for ${clientB}`, duration: '2h', projectId: projB.id, }); await goToReporting(page); await expect(page.getByTestId('reporting_view').getByText(projectA)).toBeVisible(); // Filter by client A await page.getByRole('button', { name: 'Clients' }).first().click(); await Promise.all([ page.getByRole('option').filter({ hasText: clientA }).click(), waitForReportingUpdate(page), ]); await page.keyboard.press('Escape'); const { shareableLink } = await saveAsSharedReport(page, reportName); // View the shared report await page.goto(shareableLink); await expect(page.getByText('Reporting')).toBeVisible(); await expect(page.getByText(projectA)).toBeVisible(); await expect(page.getByText(projectB)).not.toBeVisible(); }); test('test that shared report respects tag filter', async ({ page, ctx }) => { const tagA = 'TagA ' + Math.floor(Math.random() * 10000); const tagB = 'TagB ' + Math.floor(Math.random() * 10000); const reportName = 'TagFilterReport ' + Math.floor(Math.random() * 10000); const tagObjA = await createTagViaApi(ctx, { name: tagA }); await createTagViaApi(ctx, { name: tagB }); await createTimeEntryViaApi(ctx, { description: `Entry with ${tagA}`, duration: '1h', tags: [tagObjA.id], }); await createBareTimeEntryViaApi(ctx, 'Entry no tags', '2h'); await goToReporting(page); await expect(page.getByTestId('reporting_view').getByText('Total')).toBeVisible(); // Filter by tag A await page.getByRole('button', { name: 'Tags' }).first().click(); await Promise.all([ page.getByRole('option').filter({ hasText: tagA }).click(), waitForReportingUpdate(page), ]); await page.keyboard.press('Escape'); const { shareableLink } = await saveAsSharedReport(page, reportName); // View the shared report await page.goto(shareableLink); await expect(page.getByText('Reporting')).toBeVisible(); await expect(page.getByText('Total')).toBeVisible(); await expect(page.getByText('1:00:00').first()).toBeVisible(); await expect(page.getByText('3:00:00')).not.toBeVisible(); }); test('test that shared report respects member filter', async ({ page, ctx }) => { const projectName = 'MemberFilterProj ' + Math.floor(Math.random() * 10000); const reportName = 'MemberFilterReport ' + 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 goToReporting(page); await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible(); // Filter by current member (John Doe) await page.getByRole('button', { name: 'Members' }).first().click(); await Promise.all([ page.getByRole('option').filter({ hasText: 'John Doe' }).click(), waitForReportingUpdate(page), ]); await page.keyboard.press('Escape'); const { shareableLink } = await saveAsSharedReport(page, reportName); // View the shared report — should still show data since all entries belong to this member await page.goto(shareableLink); await expect(page.getByText('Reporting')).toBeVisible(); await expect(page.getByText(projectName)).toBeVisible(); await expect(page.getByText('Total')).toBeVisible(); }); test('test that shared report with billable filter only shows billable entries', async ({ page, ctx, }) => { const reportName = 'BillableFilterReport ' + Math.floor(Math.random() * 10000); // Create one billable (1h) and one non-billable (2h) entry await createTimeEntryWithBillableStatusViaApi(ctx, true, '1h'); await createTimeEntryWithBillableStatusViaApi(ctx, false, '2h'); await goToReporting(page); await expect(page.getByTestId('reporting_view').getByText('Total')).toBeVisible(); // Filter by billable only await page.getByRole('combobox').filter({ hasText: 'Billable' }).click(); await Promise.all([ page.getByRole('option', { name: 'Billable', exact: true }).click(), waitForReportingUpdate(page), ]); // Verify only 1h shows before saving await expect(page.getByTestId('reporting_view').getByText('1:00:00').first()).toBeVisible(); const { shareableLink } = await saveAsSharedReport(page, reportName); // Navigate to the shared report await page.goto(shareableLink); await expect(page.getByText('Reporting')).toBeVisible(); await expect(page.getByText('Total')).toBeVisible(); // Shared report should only show the 1h billable entry, not the 2h non-billable await expect(page.getByText('1:00:00').first()).toBeVisible(); await expect(page.getByText('3:00:00')).not.toBeVisible(); }); // ────────────────────────────────────────────────── // Report Date Picker Tests // ────────────────────────────────────────────────── test('test that creating a report with an expiration date works', async ({ page, ctx }) => { const projectName = 'DatePickerProj ' + Math.floor(Math.random() * 10000); const reportName = 'DatePickerReport ' + 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 goToReporting(page); await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible(); // Open the save report modal await page.getByRole('button', { name: 'Save Report' }).click(); await page.getByLabel('Name').fill(reportName); // The "Public" checkbox should be checked by default, showing the date picker const datePicker = page .getByRole('dialog') .getByRole('button', { name: DATE_PICKER_BUTTON_PATTERN }); await expect(datePicker).toBeVisible(); await datePicker.click(); // Select a date in the next month const calendarGrid = page.getByRole('gridcell').first(); await expect(calendarGrid).toBeVisible({ timeout: 5000 }); await page.getByRole('button', { name: /Next/i }).click(); await page.getByRole('gridcell').filter({ hasText: /^15$/ }).first().click(); // Wait for the calendar to close await expect(calendarGrid).not.toBeVisible(); // Create the report and verify it includes the public_until date const [response] = await Promise.all([ page.waitForResponse( (response) => response.url().includes('/reports') && response.request().method() === 'POST' && response.status() === 201 ), page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click(), ]); const responseBody = await response.json(); expect(responseBody.data.public_until).toBeTruthy(); }); test('test that editing a report to make it public with expiration date works', async ({ page, ctx, }) => { const projectName = 'EditDateProj ' + Math.floor(Math.random() * 10000); const reportName = 'EditDateReport ' + 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 goToReporting(page); await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible(); // Open the save report modal and create a private report await page.getByRole('button', { name: 'Save Report' }).click(); await page.getByLabel('Name').fill(reportName); // Uncheck "Public" to create a private report await page.getByLabel('Public').click(); await Promise.all([ page.waitForResponse( (response) => response.url().includes('/reports') && response.request().method() === 'POST' && response.status() === 201 ), page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click(), ]); // Go to shared reports and edit await goToReportingShared(page); await expect(page.getByText(reportName)).toBeVisible(); await expect(page.getByText('Private')).toBeVisible(); // Click more options and edit await page .getByRole('button', { name: new RegExp('Actions for Project ' + reportName) }) .click(); await page.getByRole('menuitem', { name: /^Edit Report/ }).click(); // Check "Public" to make it public - this should show the date picker await page.getByLabel('Public').click(); // The date picker should now be visible const datePicker = page .getByRole('dialog') .getByRole('button', { name: DATE_PICKER_BUTTON_PATTERN }); await expect(datePicker).toBeVisible(); await datePicker.click(); // Select a date in the next month const calendarGrid = page.getByRole('gridcell').first(); await expect(calendarGrid).toBeVisible({ timeout: 5000 }); await page.getByRole('button', { name: /Next/i }).click(); await page.getByRole('gridcell').filter({ hasText: /^20$/ }).first().click(); // Wait for the calendar to close await expect(calendarGrid).not.toBeVisible(); // Update the report and verify it includes the public_until date const [response] = await Promise.all([ page.waitForResponse( (response) => response.url().includes('/reports/') && response.request().method() === 'PUT' && response.status() === 200 ), page.getByRole('button', { name: 'Update Report' }).click(), ]); const responseBody = await response.json(); expect(responseBody.data.public_until).toBeTruthy(); expect(responseBody.data.is_public).toBe(true); }); test('test that shared report with No Client filter shows entries without a client', async ({ page, ctx, }) => { const clientName = 'NoClientCli ' + Math.floor(Math.random() * 10000); const projectName = 'NoClientProj ' + Math.floor(Math.random() * 10000); const reportName = 'NoClientReport ' + Math.floor(Math.random() * 10000); const client = await createClientViaApi(ctx, { name: clientName }); const project = await createProjectViaApi(ctx, { name: projectName, client_id: client.id }); await createTimeEntryViaApi(ctx, { description: `Entry for ${projectName}`, duration: '1h', projectId: project.id, }); await createBareTimeEntryViaApi(ctx, 'Entry without client', '2h'); await goToReporting(page); await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible(); // Filter by "No Client" await page.getByRole('button', { name: 'Clients' }).first().click(); await Promise.all([ page.getByRole('option').filter({ hasText: 'No Client' }).click(), waitForReportingUpdate(page), ]); await page.keyboard.press('Escape'); const { shareableLink } = await saveAsSharedReport(page, reportName); // View the shared report await page.goto(shareableLink); await expect(page.getByText('Reporting')).toBeVisible(); await expect(page.getByText('Total')).toBeVisible(); await expect(page.getByText(projectName)).not.toBeVisible(); }); test('test that shared report with No Tag filter shows entries without tags', async ({ page, ctx, }) => { const tagName = 'NoTagFilter ' + Math.floor(Math.random() * 10000); const reportName = 'NoTagReport ' + Math.floor(Math.random() * 10000); await createTimeEntryWithTagViaApi(ctx, tagName, '1h'); await createBareTimeEntryViaApi(ctx, 'Entry without tags', '2h'); await goToReporting(page); await expect(page.getByText('Total')).toBeVisible(); // Filter by "No Tag" await page.getByRole('button', { name: 'Tags' }).first().click(); await Promise.all([ page.getByRole('option').filter({ hasText: 'No Tag' }).click(), waitForReportingUpdate(page), ]); await page.keyboard.press('Escape'); const { shareableLink } = await saveAsSharedReport(page, reportName); // View the shared report await page.goto(shareableLink); await expect(page.getByText('Reporting')).toBeVisible(); await expect(page.getByText('Total')).toBeVisible(); }); test('test that creating a report with empty name shows validation error', async ({ page, ctx, }) => { const projectName = 'EmptyNameProj ' + 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 goToReporting(page); await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible(); // Open the save report modal await page.getByRole('button', { name: 'Save Report' }).click(); // Leave name empty and try to create await page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click(); // Should show validation error await expect(page.getByText('The name field is required')).toBeVisible(); }); test('test that updating report name works', async ({ page, ctx }) => { const projectName = 'UpdateNameProj ' + Math.floor(Math.random() * 10000); const reportName = 'OriginalName ' + Math.floor(Math.random() * 10000); const newReportName = 'UpdatedName ' + 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 goToReporting(page); await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible(); await saveAsSharedReport(page, reportName); await goToReportingShared(page); await expect(page.getByText(reportName)).toBeVisible(); // Click more options and edit await page .getByRole('button', { name: new RegExp('Actions for Project ' + reportName) }) .click(); await page.getByRole('menuitem', { name: /^Edit Report/ }).click(); // Update the name await page.getByLabel('Name', { exact: true }).fill(newReportName); await Promise.all([ page.waitForResponse( (response) => response.url().includes('/reports/') && response.request().method() === 'PUT' && response.status() === 200 ), page.getByRole('button', { name: 'Update Report' }).click(), ]); // Verify the name was updated in the table await expect(page.getByText(newReportName)).toBeVisible(); await expect(page.getByText(reportName)).not.toBeVisible(); }); test('test that updating expiration date on already-public report works', async ({ page, ctx }) => { const projectName = 'UpdateExpDateProj ' + Math.floor(Math.random() * 10000); const reportName = 'UpdateExpDateReport ' + 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 goToReporting(page); await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible(); // Create a public report (already public by default) await saveAsSharedReport(page, reportName); // Go to shared reports and edit await goToReportingShared(page); await expect(page.getByText(reportName)).toBeVisible(); // Click more options and edit await page .getByRole('button', { name: new RegExp('Actions for Project ' + reportName) }) .click(); await page.getByRole('menuitem', { name: /^Edit Report/ }).click(); // The date picker should be visible (report is already public) const datePicker = page .getByRole('dialog') .getByRole('button', { name: DATE_PICKER_BUTTON_PATTERN }); await expect(datePicker).toBeVisible(); await datePicker.click(); // Select the 25th of next month const calendarGrid = page.getByRole('gridcell').first(); await expect(calendarGrid).toBeVisible({ timeout: 5000 }); await page.getByRole('button', { name: /Next/i }).click(); await page.getByRole('gridcell').filter({ hasText: /^25$/ }).first().click(); // Wait for the calendar to close await expect(calendarGrid).not.toBeVisible(); // Update the report and verify it includes the correct public_until date const [response] = await Promise.all([ page.waitForResponse( (response) => response.url().includes('/reports/') && response.request().method() === 'PUT' && response.status() === 200 ), page.getByRole('button', { name: 'Update Report' }).click(), ]); const responseBody = await response.json(); expect(responseBody.data.public_until).toBeTruthy(); // Verify the date is the 25th of a future month const returnedDate = new Date(responseBody.data.public_until); expect(returnedDate.getUTCDate()).toBe(25); // The returned date should be in the future const now = new Date(); expect(returnedDate.getTime()).toBeGreaterThan(now.getTime()); }); test('test that clearing the expiration date on a report works', async ({ page, ctx }) => { const reportName = 'ClearExpReport ' + Math.floor(Math.random() * 10000); // Create a public report with an expiration date via API await createReportViaApi(ctx, { name: reportName, is_public: true, public_until: dayjs().add(1, 'month').utc().format('YYYY-MM-DDTHH:mm:ss[Z]'), }); // Go to shared reports and edit the report await goToReportingShared(page); await expect(page.getByText(reportName)).toBeVisible(); await page .getByRole('button', { name: new RegExp('Actions for Project ' + reportName) }) .click(); await page.getByRole('menuitem', { name: /^Edit Report/ }).click(); await expect(page.getByRole('dialog')).toBeVisible(); // The date picker should show a date (not "Pick a date") await expect( page.getByRole('dialog').getByRole('button', { name: 'Pick a date' }) ).not.toBeVisible(); // Click the clear button (X icon) to remove the expiration date const clearButton = page .getByRole('dialog') .locator('[role="button"]') .filter({ has: page.locator('svg.lucide-x') }); await expect(clearButton).toBeVisible(); await clearButton.click(); // The date picker should now show "Pick a date" await expect( page.getByRole('dialog').getByRole('button', { name: 'Pick a date' }) ).toBeVisible(); // The clear button should no longer be visible await expect(clearButton).not.toBeVisible(); // Update the report and verify public_until is null const [updateResponse] = await Promise.all([ page.waitForResponse( (response) => response.url().includes('/reports/') && response.request().method() === 'PUT' && response.status() === 200 ), page.getByRole('button', { name: 'Update Report' }).click(), ]); const updateBody = await updateResponse.json(); expect(updateBody.data.public_until).toBeNull(); }); test('test that date picker clear button is not visible when no date is set', async ({ page, ctx, }) => { const reportName = 'NoClearReport ' + Math.floor(Math.random() * 10000); // Create a public report without an expiration date via API await createReportViaApi(ctx, { name: reportName, is_public: true, public_until: null, }); // Go to shared reports and edit the report await goToReportingShared(page); await expect(page.getByText(reportName)).toBeVisible(); await page .getByRole('button', { name: new RegExp('Actions for Project ' + reportName) }) .click(); await page.getByRole('menuitem', { name: /^Edit Report/ }).click(); await expect(page.getByRole('dialog')).toBeVisible(); // The date picker should show "Pick a date" await expect( page.getByRole('dialog').getByRole('button', { name: 'Pick a date' }) ).toBeVisible(); // The clear button should NOT be visible const clearButton = page .getByRole('dialog') .locator('[role="button"]') .filter({ has: page.locator('svg.lucide-x') }); await expect(clearButton).not.toBeVisible(); }); // ────────────────────────────────────────────────── // Shared Report Cost Column Tests // ────────────────────────────────────────────────── test('test that shared report displays cost column correctly aligned with data rows', async ({ page, ctx, }) => { const projectName = 'BillableProj ' + Math.floor(Math.random() * 10000); const reportName = 'BillableReport ' + Math.floor(Math.random() * 10000); const project = await createBillableProjectViaApi(ctx, { name: projectName, billable_rate: 10000, // 100.00 per hour }); await createTimeEntryViaApi(ctx, { description: `Entry for ${projectName}`, duration: '1h', projectId: project.id, billable: true, }); await goToReporting(page); await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible(); const { shareableLink } = await saveAsSharedReport(page, reportName); // Navigate to the shared report await page.goto(shareableLink); await expect(page.getByText('Reporting')).toBeVisible(); await expect(page.getByText(projectName)).toBeVisible(); // Verify the table header has all three columns await expect(page.getByText('Name', { exact: true })).toBeVisible(); await expect(page.getByText('Duration', { exact: true })).toBeVisible(); await expect(page.getByText('Cost', { exact: true })).toBeVisible(); // Verify the Total row displays both duration and cost await expect(page.getByText('Total')).toBeVisible(); // The data rows should render cost values (not just header + duration) // With 1h at 100/h the cost should be displayed somewhere in the table // If showCost is not passed to ReportingRow, only the header "Cost" and // the Total row cost will render, but individual row costs will be missing const table = page.locator('[style*="grid-template-columns"]'); // Count elements containing the cost value - header "Cost" + project row cost + total row cost = 3 // If broken (showCost not passed), the project row won't render its cost cell await expect(table.getByText(/100/).first()).toBeVisible(); // Verify the cost value appears at least twice in the table // (once for the data row, once for the total) beyond just the header const costValues = table.getByText(/100/); await expect(costValues).toHaveCount(2); });