test(time-tracking): add end-to-end coverage

This commit is contained in:
kolaente 2026-06-08 15:16:48 +02:00 committed by kolaente
parent 2d334e56c7
commit 4390af4773
2 changed files with 354 additions and 0 deletions

View File

@ -0,0 +1,323 @@
import {test, expect} from '../../support/fixtures'
import type {Page, Locator} from '@playwright/test'
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
import {TimeEntryFactory} from '../../factories/time_entry'
import {LicenseFactory} from '../../factories/license'
import {UserFactory} from '../../factories/user'
import {UserProjectFactory} from '../../factories/users_project'
// Pick a project in the form's project picker. Waits for the project store to
// hydrate (the sidebar shows it) before searching so the result is there.
async function selectProject(page: Page, form: Locator, title: string) {
await expect(page.locator('.menu-container').getByText(title)).toBeVisible()
const input = form.locator('.multiselect').first().locator('input')
await input.click()
// pressSequentially (not fill) so the multiselect's @keyup search fires.
await input.pressSequentially(title, {delay: 30})
await form.locator('.search-result-button').filter({hasText: title}).first().click()
}
// Pick a task in the form's task picker (the second multiselect, after project).
async function selectTask(form: Locator, title: string) {
const input = form.locator('.multiselect').nth(1).locator('input')
await input.click()
await input.pressSequentially(title, {delay: 30})
await form.locator('.search-result-button').filter({hasText: title}).first().click()
}
// Open the time-tracking section on a task detail page.
async function openTaskTimeTracking(page: Page, taskId: number): Promise<Locator> {
await page.goto(`/tasks/${taskId}`)
await page.locator('[data-cy="taskTrackTimeAction"]').click()
const section = page.locator('.task-time-tracking')
await expect(section).toBeVisible()
return section
}
test.describe('Time tracking', () => {
test.describe('with the feature licensed', () => {
test.beforeEach(async () => {
await LicenseFactory.enable(['time_tracking'])
})
test.afterEach(async () => {
await LicenseFactory.disable()
})
test('shows the page and the sidebar entry', async ({authenticatedPage: page}) => {
await page.goto('/')
await expect(page.locator('.menu-container').getByRole('link', {name: 'Time tracking'})).toBeVisible()
await page.goto('/time-tracking')
await expect(page.locator('[data-cy="addTimeEntry"]')).toBeVisible()
})
test('logs a manual time entry', async ({authenticatedPage: page}) => {
await ProjectFactory.create(1, {title: 'E2E tracked project'}, false)
await page.goto('/time-tracking')
await page.locator('[data-cy="addTimeEntry"]').click()
const form = page.locator('[data-cy="timeEntryForm"]')
await expect(form).toBeVisible()
await selectProject(page, form, 'E2E tracked project')
// Smart-fill populates both from and to, so the entry is complete.
await form.locator('[data-cy="smartFill"]').click()
await form.locator('[data-cy="saveTimeEntry"]').click()
await expect(page.locator('[data-cy="timeEntry"]').filter({hasText: 'E2E tracked project'})).toBeVisible()
})
test('saving with an empty To logs a completed entry, not a running timer', async ({authenticatedPage: page}) => {
await ProjectFactory.create(1, {title: 'E2E save project'}, false)
await page.goto('/time-tracking')
await page.locator('[data-cy="addTimeEntry"]').click()
const form = page.locator('[data-cy="timeEntryForm"]')
await selectProject(page, form, 'E2E save project')
// No smart-fill: leave "To" empty, then Save.
await form.locator('[data-cy="saveTimeEntry"]').click()
// The entry is completed (no open-ended "…") and no timer started.
const entries = page.locator('[data-cy="timeEntry"]')
await expect(entries).toHaveCount(1)
await expect(entries.first()).not.toContainText('…')
await expect(page.locator('[data-cy="timerBadge"]')).not.toBeVisible()
})
test('switching from a task to a project logs against the project', async ({authenticatedPage: page}) => {
await ProjectFactory.create(1, {id: 1, title: 'XOR project'}, false)
await TaskFactory.create(1, {id: 1, title: 'XOR task', project_id: 1}, false)
await page.goto('/time-tracking')
await page.locator('[data-cy="addTimeEntry"]').click()
const form = page.locator('[data-cy="timeEntryForm"]')
// Pick a task, then change your mind to a project — the task must be cleared.
await selectTask(form, 'XOR task')
await selectProject(page, form, 'XOR project')
await form.locator('[data-cy="smartFill"]').click()
await form.locator('[data-cy="saveTimeEntry"]').click()
const entry = page.locator('[data-cy="timeEntry"]').first()
await expect(entry).toContainText('XOR project')
await expect(entry).not.toContainText('XOR task')
})
test('starts a timer and stopping it updates the same entry in the list', async ({authenticatedPage: page}) => {
await ProjectFactory.create(1, {title: 'E2E timer project'}, false)
await page.goto('/time-tracking')
await page.locator('[data-cy="addTimeEntry"]').click()
const form = page.locator('[data-cy="timeEntryForm"]')
await selectProject(page, form, 'E2E timer project')
await form.locator('[data-cy="startTimer"]').click()
const badge = page.locator('[data-cy="timerBadge"]')
await expect(badge).toBeVisible()
// The running entry is in the list with an open-ended time range.
const entries = page.locator('[data-cy="timeEntry"]')
await expect(entries).toHaveCount(1)
await expect(entries.first()).toContainText('…')
await badge.locator('[data-cy="stopTimer"]').click()
await expect(badge).not.toBeVisible()
// The same entry is updated in place — end time set, no longer open-ended.
await expect(entries).toHaveCount(1)
await expect(entries.first()).not.toContainText('…')
})
test('does not show another user\'s readable running timer in the header', async ({
authenticatedPage: page,
currentUser,
}) => {
const [timerOwner] = await UserFactory.create(1, {id: currentUser.id + 100}, false)
const [sharedProject] = await ProjectFactory.create(1, {
id: 1001,
title: 'Shared active timer project',
owner_id: timerOwner.id,
}, false)
await UserProjectFactory.create(1, {
project_id: sharedProject.id,
user_id: currentUser.id,
permission: 0,
}, false)
await TimeEntryFactory.create(1, {
project_id: sharedProject.id,
user_id: timerOwner.id,
end_time: null,
comment: 'other user running timer',
}, false)
const activeTimerHydrated = page.waitForResponse(response =>
response.request().method() === 'GET' &&
response.url().includes('/api/v2/time-entries') &&
response.url().includes('per_page=1'),
)
await page.goto('/time-tracking')
await activeTimerHydrated
await expect(page.locator('[data-cy="timeEntry"]').filter({hasText: 'other user running timer'})).toBeVisible()
await expect(page.locator('[data-cy="timerBadge"]')).not.toBeVisible()
})
test('hides edit/delete on entries owned by another user', async ({authenticatedPage: page, currentUser}) => {
const [other] = await UserFactory.create(1, {id: currentUser.id + 100}, false)
const [shared] = await ProjectFactory.create(1, {id: 2001, title: 'Shared log project', owner_id: other.id}, false)
await UserProjectFactory.create(1, {project_id: shared.id, user_id: currentUser.id, permission: 0}, false)
await TimeEntryFactory.create(1, {id: 10, project_id: shared.id, user_id: other.id, comment: 'theirs'}, false)
await TimeEntryFactory.create(1, {id: 11, project_id: shared.id, user_id: currentUser.id, comment: 'mine'}, false)
await page.goto('/time-tracking')
const theirs = page.locator('[data-cy="timeEntry"]').filter({hasText: 'theirs'})
const mine = page.locator('[data-cy="timeEntry"]').filter({hasText: 'mine'})
await expect(theirs).toBeVisible()
await expect(mine).toBeVisible()
// The current user keeps the controls on their own entry, but not the other's.
await expect(mine.locator('[data-cy="editTimeEntry"]')).toBeVisible()
await expect(theirs.locator('[data-cy="editTimeEntry"]')).toHaveCount(0)
await expect(theirs.locator('[data-cy="deleteTimeEntry"]')).toHaveCount(0)
})
test('task detail: logs an entry and toggles the form with the + button', async ({authenticatedPage: page}) => {
await ProjectFactory.create(1, {title: 'P'}, false)
await TaskFactory.create(1, {title: 'Tracked task', project_id: 1}, false)
const section = await openTaskTimeTracking(page, 1)
const form = section.locator('[data-cy="timeEntryForm"]')
// No entries yet → the form is shown implicitly.
await expect(form).toBeVisible()
await form.locator('[data-cy="smartFill"]').click()
await form.locator('[data-cy="saveTimeEntry"]').click()
await expect(section.locator('[data-cy="timeEntry"]')).toHaveCount(1)
// With an entry, the form collapses behind the + button.
await expect(form).not.toBeVisible()
const addButton = section.locator('[data-cy="addTaskTimeEntry"]')
await expect(addButton).toBeVisible()
await addButton.click()
await expect(form).toBeVisible()
})
test('task detail: stopping a timer updates the entry in the list', async ({authenticatedPage: page}) => {
await ProjectFactory.create(1, {title: 'P'}, false)
await TaskFactory.create(1, {title: 'Timed task', project_id: 1}, false)
const section = await openTaskTimeTracking(page, 1)
await section.locator('[data-cy="timeEntryForm"] [data-cy="startTimer"]').click()
const badge = page.locator('[data-cy="timerBadge"]')
await expect(badge).toBeVisible()
const entries = section.locator('[data-cy="timeEntry"]')
await expect(entries).toHaveCount(1)
await expect(entries.first()).toContainText('…')
await badge.locator('[data-cy="stopTimer"]').click()
await expect(badge).not.toBeVisible()
await expect(entries).toHaveCount(1)
await expect(entries.first()).not.toContainText('…')
})
test('edits an entry from the list', async ({authenticatedPage: page}) => {
await ProjectFactory.create(1, {id: 1, title: 'Edit project'}, false)
await TimeEntryFactory.create(1, {id: 1, project_id: 1, comment: 'original comment'}, false)
await page.goto('/time-tracking')
const entries = page.locator('[data-cy="timeEntry"]')
await expect(entries).toHaveCount(1)
await expect(entries.first()).toContainText('original comment')
await entries.first().locator('[data-cy="editTimeEntry"]').click()
const form = page.locator('[data-cy="timeEntryForm"]')
const comment = form.locator('[data-cy="timeEntryComment"]')
await expect(comment).toHaveValue('original comment')
await comment.fill('edited comment')
await form.locator('[data-cy="updateTimeEntry"]').click()
await expect(entries).toHaveCount(1)
await expect(entries.first()).toContainText('edited comment')
await expect(entries.first()).not.toContainText('original comment')
})
test('deletes an entry from the list', async ({authenticatedPage: page}) => {
await ProjectFactory.create(1, {id: 1, title: 'Delete project'}, false)
await TimeEntryFactory.create(1, {id: 1, project_id: 1, comment: 'to be deleted'}, false)
await page.goto('/time-tracking')
const entries = page.locator('[data-cy="timeEntry"]')
await expect(entries).toHaveCount(1)
await entries.first().locator('[data-cy="deleteTimeEntry"]').click()
await expect(entries).toHaveCount(0)
})
test('filters by project, reflected in the url and restored on reload', async ({authenticatedPage: page}) => {
await ProjectFactory.create(1, {id: 1, title: 'Alpha'}, false)
await ProjectFactory.create(1, {id: 2, title: 'Beta'}, false)
await TimeEntryFactory.create(1, {id: 1, project_id: 1, comment: 'alpha entry'}, false)
await TimeEntryFactory.create(1, {id: 2, project_id: 2, comment: 'beta entry'}, false)
await page.goto('/time-tracking')
const entries = page.locator('[data-cy="timeEntry"]')
await expect(entries).toHaveCount(2)
// Narrow to project Alpha in the filter modal.
await page.locator('[data-cy="openTimeTrackingFilters"]').click()
const dialog = page.locator('dialog[open]')
const projectInput = dialog.locator('.multiselect').first().locator('input')
await projectInput.click()
await projectInput.pressSequentially('Alpha', {delay: 30})
await dialog.locator('.search-result-button').filter({hasText: 'Alpha'}).first().click()
// The filter is written to the url.
await expect(page).toHaveURL(/[?&]project=1\b/)
// ...and survives a reload (restored from the url): only Alpha's entry.
await page.reload()
await expect(entries).toHaveCount(1)
await expect(entries.first()).toContainText('Alpha')
await expect(page).toHaveURL(/[?&]project=1\b/)
})
test('clearing the date range does not crash the page', async ({authenticatedPage: page}) => {
await page.goto('/time-tracking')
// The default range surfaces as "Today" in the toolbar label.
await expect(page.locator('.time-tracking__range')).toHaveText('Today')
await page.locator('[data-cy="openTimeTrackingFilters"]').click()
// Open the range popup (its trigger is the first button in the picker) and clear via Custom.
await page.locator('dialog[open] .datepicker-with-range-container').getByRole('button').first().click()
await page.getByRole('button', {name: 'Custom', exact: true}).click()
// rangeLabel must not call getFullYear on a null date — the page stays alive.
await expect(page.locator('.time-tracking__range')).toHaveText('Select a range')
await expect(page.locator('[data-cy="addTimeEntry"]')).toBeVisible()
})
})
test.describe('without the feature licensed', () => {
test.beforeEach(async () => {
await LicenseFactory.disable()
})
test('hides the sidebar entry and blocks the route', async ({authenticatedPage: page}) => {
await page.goto('/')
await expect(page.locator('.menu-container').getByRole('link', {name: 'Time tracking'})).toHaveCount(0)
await page.goto('/time-tracking')
await expect(page.locator('[data-cy="addTimeEntry"]')).not.toBeVisible()
})
})
})

View File

@ -0,0 +1,31 @@
import {Factory} from '../support/factory'
// Local "YYYY-MM-DD HH:MM:SS" (the format the DB fixtures use), not ISO-with-Z.
// start_time is filtered with datemath day windows that resolve to local time,
// and the comparison is lexical — a UTC-stamped value falls outside "today"
// near midnight.
function sqlDateTime(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
export class TimeEntryFactory extends Factory {
static table = 'time_entries'
static factory() {
const now = sqlDateTime(new Date())
return {
id: '{increment}',
user_id: 1,
task_id: 0,
project_id: 0,
// Completed by default (end set), within today so the default filter shows it.
start_time: now,
end_time: now,
comment: '',
created: now,
updated: now,
}
}
}