mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
279 lines
12 KiB
TypeScript
279 lines
12 KiB
TypeScript
import { expect, test } from '../playwright/fixtures';
|
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
|
import type { Page } from '@playwright/test';
|
|
import {
|
|
assertThatTimerHasStarted,
|
|
assertThatTimerIsStopped,
|
|
newTimeEntryResponse,
|
|
startOrStopTimerWithButton,
|
|
stoppedTimeEntryResponse,
|
|
} from './utils/currentTimeEntry';
|
|
import { createTimeEntryViaApi, updateOrganizationSettingViaApi } from './utils/api';
|
|
|
|
async function goToDashboard(page: Page) {
|
|
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
|
}
|
|
|
|
function visibleBreakButton(page: Page) {
|
|
return page.getByRole('button', { name: 'Take a break' }).locator('visible=true').first();
|
|
}
|
|
|
|
// Breaks are disabled by default for new organizations, so enable them for the break flows.
|
|
// The tests that assert the disabled behaviour turn them back off explicitly.
|
|
test.beforeEach(async ({ ctx }) => {
|
|
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
|
|
});
|
|
|
|
test('test that switching to a break stops the work timer and starts a break entry', async ({
|
|
page,
|
|
}) => {
|
|
await goToDashboard(page);
|
|
await expect(page.getByTestId('time_entry_description')).toBeEditable();
|
|
await page.getByTestId('time_entry_description').fill('Work before break');
|
|
await Promise.all([
|
|
newTimeEntryResponse(page, { description: 'Work before break', type: 'work' }),
|
|
page.getByTestId('time_entry_description').press('Enter'),
|
|
]);
|
|
await assertThatTimerHasStarted(page);
|
|
await page.waitForTimeout(1500);
|
|
|
|
// Switch to break: stops the work entry and starts a break entry
|
|
await Promise.all([
|
|
newTimeEntryResponse(page, { description: '', type: 'break' }),
|
|
visibleBreakButton(page).click(),
|
|
]);
|
|
await expect(page.getByText('On break')).toBeVisible();
|
|
|
|
// The break bar offers a one-click resume that stops the break and restores
|
|
// the interrupted work context
|
|
await page.waitForTimeout(1500);
|
|
const resumeButton = page.getByRole('button', { name: 'Resume "Work before break"' });
|
|
await expect(resumeButton).toBeVisible();
|
|
await Promise.all([
|
|
stoppedTimeEntryResponse(page, { type: 'break' }),
|
|
newTimeEntryResponse(page, { description: 'Work before break', type: 'work' }),
|
|
resumeButton.click(),
|
|
]);
|
|
await assertThatTimerHasStarted(page);
|
|
await expect(page.getByTestId('time_entry_description')).toHaveValue('Work before break');
|
|
|
|
// Cleanup: stop the running entry
|
|
await Promise.all([
|
|
stoppedTimeEntryResponse(page, { description: 'Work before break', type: 'work' }),
|
|
startOrStopTimerWithButton(page),
|
|
]);
|
|
await assertThatTimerIsStopped(page);
|
|
});
|
|
|
|
test('test that stopping a break returns to an idle tracker where a fresh entry starts normally', async ({
|
|
page,
|
|
}) => {
|
|
await goToDashboard(page);
|
|
await expect(page.getByTestId('time_entry_description')).toBeEditable();
|
|
await page.getByTestId('time_entry_description').fill('Work before break');
|
|
await Promise.all([
|
|
newTimeEntryResponse(page, { description: 'Work before break', type: 'work' }),
|
|
page.getByTestId('time_entry_description').press('Enter'),
|
|
]);
|
|
await assertThatTimerHasStarted(page);
|
|
await page.waitForTimeout(1500);
|
|
|
|
// Switch to a break
|
|
await Promise.all([
|
|
newTimeEntryResponse(page, { description: '', type: 'break' }),
|
|
visibleBreakButton(page).click(),
|
|
]);
|
|
await expect(page.getByText('On break')).toBeVisible();
|
|
|
|
// Stopping the break just ends it — no modal, the tracker returns to the
|
|
// empty idle input with focus so typing starts a fresh entry
|
|
await page.waitForTimeout(1500);
|
|
await Promise.all([
|
|
stoppedTimeEntryResponse(page, { type: 'break' }),
|
|
startOrStopTimerWithButton(page),
|
|
]);
|
|
await assertThatTimerIsStopped(page);
|
|
await expect(page.getByTestId('time_entry_description')).toHaveValue('');
|
|
await expect(page.getByTestId('time_entry_description')).toBeFocused();
|
|
|
|
// A fresh entry is the normal start flow: type + Enter
|
|
await page.getByTestId('time_entry_description').fill('Fresh after break');
|
|
await Promise.all([
|
|
newTimeEntryResponse(page, { description: 'Fresh after break', type: 'work' }),
|
|
page.getByTestId('time_entry_description').press('Enter'),
|
|
]);
|
|
await assertThatTimerHasStarted(page);
|
|
|
|
// Cleanup: stop the running entry
|
|
await Promise.all([
|
|
stoppedTimeEntryResponse(page, { description: 'Fresh after break', type: 'work' }),
|
|
startOrStopTimerWithButton(page),
|
|
]);
|
|
await assertThatTimerIsStopped(page);
|
|
});
|
|
|
|
test('test that the more options dropdown can start a break directly', async ({ page }) => {
|
|
await goToDashboard(page);
|
|
await expect(page.getByTestId('time_entry_description')).toBeEditable();
|
|
|
|
// Start a break straight from the more options dropdown (no create modal)
|
|
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
|
await Promise.all([
|
|
newTimeEntryResponse(page, { description: '', type: 'break' }),
|
|
page.getByRole('menuitem', { name: 'Start Break' }).click(),
|
|
]);
|
|
await expect(page.getByText('On break')).toBeVisible();
|
|
|
|
// Without interrupted work there is nothing to resume, so no resume button is offered
|
|
await expect(page.getByRole('button', { name: /^Resume/ })).toHaveCount(0);
|
|
|
|
// Cleanup: stop the break
|
|
await page.waitForTimeout(1500);
|
|
await Promise.all([
|
|
stoppedTimeEntryResponse(page, { type: 'break' }),
|
|
startOrStopTimerWithButton(page),
|
|
]);
|
|
await assertThatTimerIsStopped(page);
|
|
});
|
|
|
|
test('test that disabling breaks hides every break-creation entry point', async ({ page, ctx }) => {
|
|
// Breaks disabled for the organization (delivered to the client via the organization endpoint)
|
|
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: false });
|
|
await createTimeEntryViaApi(ctx, { duration: '1h', description: 'Regular work' });
|
|
|
|
// Calendar: the empty-slot context menu offers "Create Time Entry" but no "Add Break",
|
|
// and the edit modal drops the work-time/break type selector
|
|
await page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
|
|
await expect(page.locator('.fc')).toBeVisible();
|
|
const event = page.locator('.fc-event').filter({ hasText: 'Regular work' }).first();
|
|
await event.scrollIntoViewIfNeeded();
|
|
await expect(event).toBeVisible();
|
|
|
|
const box = await event.boundingBox();
|
|
expect(box).not.toBeNull();
|
|
await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height + 40, { button: 'right' });
|
|
await expect(page.getByRole('menu')).toBeVisible();
|
|
await expect(page.getByRole('menuitem', { name: 'Create Time Entry' })).toBeVisible();
|
|
await expect(page.getByRole('menuitem', { name: 'Add Break' })).toHaveCount(0);
|
|
await page.keyboard.press('Escape');
|
|
|
|
await event.click({ button: 'right' });
|
|
await expect(page.getByRole('menu')).toBeVisible();
|
|
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
|
await expect(page.getByRole('dialog')).toBeVisible();
|
|
await expect(
|
|
page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Work time' })
|
|
).toHaveCount(0);
|
|
await page.keyboard.press('Escape');
|
|
|
|
// Timesheet: no break row is shown
|
|
await page.goto(PLAYWRIGHT_BASE_URL + '/timesheet');
|
|
await expect(page.getByRole('button', { name: 'Add row' }).first()).toBeVisible();
|
|
await expect(page.getByText('Break', { exact: true })).toHaveCount(0);
|
|
|
|
// Dashboard tracker: no "Start Break" in the more options dropdown
|
|
await goToDashboard(page);
|
|
await expect(page.getByTestId('time_entry_description')).toBeEditable();
|
|
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
|
await expect(page.getByRole('menuitem', { name: 'Switch to simple mode' })).toBeVisible();
|
|
await expect(page.getByRole('menuitem', { name: 'Start Break' })).toHaveCount(0);
|
|
});
|
|
|
|
// The employee fixture registers a second user and accepts an invitation via Mailpit,
|
|
// which does not fit into the default per-test timeout.
|
|
test.describe('Org-level breaks setting', () => {
|
|
test.describe.configure({ timeout: 60000 });
|
|
|
|
test('test that the org-level breaks setting is respected for employees', async ({
|
|
ctx,
|
|
employee,
|
|
}) => {
|
|
const employeePage = employee.page;
|
|
|
|
// Breaks enabled (via beforeEach): the employee sees "Start Break" in the more options dropdown
|
|
await employeePage.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
|
await expect(employeePage.getByTestId('dashboard_view')).toBeVisible();
|
|
await employeePage.getByRole('button', { name: 'Time entry actions' }).click();
|
|
await expect(
|
|
employeePage.getByRole('menuitem', { name: 'Switch to simple mode' })
|
|
).toBeVisible();
|
|
await expect(employeePage.getByRole('menuitem', { name: 'Start Break' })).toBeVisible();
|
|
await employeePage.keyboard.press('Escape');
|
|
|
|
// The owner disables breaks for the whole organization
|
|
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: false });
|
|
|
|
// The employee reloads: "Start Break" is gone from the dropdown
|
|
await employeePage.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
|
await expect(employeePage.getByTestId('dashboard_view')).toBeVisible();
|
|
await employeePage.getByRole('button', { name: 'Time entry actions' }).click();
|
|
await expect(
|
|
employeePage.getByRole('menuitem', { name: 'Switch to simple mode' })
|
|
).toBeVisible();
|
|
await expect(employeePage.getByRole('menuitem', { name: 'Start Break' })).toHaveCount(0);
|
|
await employeePage.keyboard.press('Escape');
|
|
|
|
// With an active timer the break (coffee) button is not shown either
|
|
await employeePage.getByTestId('time_entry_description').fill('Employee work');
|
|
await Promise.all([
|
|
newTimeEntryResponse(employeePage, { description: 'Employee work', type: 'work' }),
|
|
employeePage.getByTestId('time_entry_description').press('Enter'),
|
|
]);
|
|
await assertThatTimerHasStarted(employeePage);
|
|
await expect(employeePage.getByRole('button', { name: 'Take a break' })).toHaveCount(0);
|
|
|
|
// Cleanup: stop the running entry
|
|
await Promise.all([
|
|
stoppedTimeEntryResponse(employeePage, { description: 'Employee work', type: 'work' }),
|
|
startOrStopTimerWithButton(employeePage),
|
|
]);
|
|
await assertThatTimerIsStopped(employeePage);
|
|
});
|
|
});
|
|
|
|
test('test that mass update warns about selected breaks and reports skipped entries instead of success', async ({
|
|
page,
|
|
ctx,
|
|
}) => {
|
|
// One work entry and one break: a billable mass update applies to the work
|
|
// entry but the server skips the break entirely — the UI must say so.
|
|
await createTimeEntryViaApi(ctx, { duration: '1h', description: 'Mass update work entry' });
|
|
await createTimeEntryViaApi(ctx, { duration: '30min', type: 'break' });
|
|
|
|
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
|
await expect(page.locator('[data-testid="time_entry_row"]')).toHaveCount(2);
|
|
await page.getByLabel('Select All').click();
|
|
await expect(page.getByText('2 selected')).toBeVisible();
|
|
await page.getByRole('button', { name: 'Edit' }).click();
|
|
await expect(page.getByRole('dialog')).toBeVisible();
|
|
|
|
// No warning while the changeset is compatible with breaks
|
|
await expect(page.getByTestId('mass_update_break_warning')).not.toBeVisible();
|
|
|
|
// Making the entries billable is break-incompatible → warning appears
|
|
await page
|
|
.getByRole('dialog')
|
|
.getByRole('combobox')
|
|
.filter({ hasText: 'Set billable status' })
|
|
.click();
|
|
await page.getByRole('option', { name: 'Billable', exact: true }).click();
|
|
await expect(page.getByTestId('mass_update_break_warning')).toBeVisible();
|
|
await expect(page.getByTestId('mass_update_break_warning')).toContainText('skipped entirely');
|
|
|
|
// Submit: the work entry updates, the break is skipped, and the toast
|
|
// reports the skip instead of claiming success for all entries
|
|
const [massUpdateResponse] = await Promise.all([
|
|
page.waitForResponse(
|
|
(response) =>
|
|
response.url().includes('/time-entries') &&
|
|
response.request().method() === 'PATCH' &&
|
|
response.status() === 200
|
|
),
|
|
page.getByRole('button', { name: 'Update Time Entries' }).click(),
|
|
]);
|
|
const massUpdateBody = await massUpdateResponse.json();
|
|
expect(massUpdateBody.success.length).toBe(1);
|
|
expect(massUpdateBody.error.length).toBe(1);
|
|
await expect(page.getByText('1 of 2 time entries was skipped')).toBeVisible();
|
|
});
|