mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-05-07 20:32:26 +00:00
Expand e2e test coverage migrate to API-based data setup
This commit is contained in:
+189
-2
@@ -1,5 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import { getPasswordResetUrl } from './utils/mailpit';
|
||||
|
||||
async function registerNewUser(page, email, password) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
|
||||
@@ -35,14 +36,200 @@ test('can register and delete account', async ({ page }) => {
|
||||
await registerNewUser(page, email, password);
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||
await page.getByRole('button', { name: 'Delete Account' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await page.getByPlaceholder('Password').fill(password);
|
||||
await page.getByRole('button', { name: 'Delete Account' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Delete Account' }).click();
|
||||
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/login');
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByLabel('Password').fill(password);
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
await expect(page.getByRole('paragraph')).toContainText(
|
||||
await expect(page.getByRole('alert')).toContainText(
|
||||
'These credentials do not match our records.'
|
||||
);
|
||||
});
|
||||
|
||||
test('shows error for invalid email on forgot password', async ({ page }) => {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
||||
|
||||
// Request password reset with non-existent email
|
||||
await page.getByLabel('Email').fill('nonexistent@example.com');
|
||||
await page.getByRole('button', { name: 'Email Password Reset Link' }).click();
|
||||
|
||||
// Should show error message
|
||||
await expect(page.getByText("We can't find a user with that email address.")).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows browser validation for invalid email format on forgot password', async ({ page }) => {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
||||
|
||||
// Request password reset with invalid email format
|
||||
const emailInput = page.getByLabel('Email');
|
||||
await emailInput.fill('notanemail');
|
||||
|
||||
// Check for browser validation - the input should be invalid
|
||||
const isInvalid = await emailInput.evaluate((el: HTMLInputElement) => !el.validity.valid);
|
||||
expect(isInvalid).toBe(true);
|
||||
});
|
||||
|
||||
test('shows browser validation for empty email on forgot password', async ({ page }) => {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
||||
|
||||
// The email input is required, so it should be invalid when empty
|
||||
const emailInput = page.getByLabel('Email');
|
||||
|
||||
// Check for browser validation - the input should be invalid because it's required and empty
|
||||
const isInvalid = await emailInput.evaluate((el: HTMLInputElement) => el.validity.valueMissing);
|
||||
expect(isInvalid).toBe(true);
|
||||
});
|
||||
|
||||
test('can reset password via email link', async ({ page, request }) => {
|
||||
// First register a new user
|
||||
const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;
|
||||
const originalPassword = 'suchagreatpassword123';
|
||||
const newPassword = 'mynewsecurepassword456';
|
||||
await registerNewUser(page, email, originalPassword);
|
||||
|
||||
// Log out
|
||||
await page.getByTestId('current_user_button').click();
|
||||
await page.getByText('Log Out').click();
|
||||
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
||||
|
||||
// Request password reset
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByRole('button', { name: 'Email Password Reset Link' }).click();
|
||||
await expect(page.getByText('We have emailed your password reset link.')).toBeVisible();
|
||||
|
||||
// Get password reset URL from email
|
||||
const resetUrl = await getPasswordResetUrl(request, email);
|
||||
|
||||
// Navigate to reset page
|
||||
await page.goto(resetUrl);
|
||||
|
||||
// Fill in new password
|
||||
await page.getByLabel('Password', { exact: true }).fill(newPassword);
|
||||
await page.getByLabel('Confirm Password').fill(newPassword);
|
||||
await page.getByRole('button', { name: 'Reset Password' }).click();
|
||||
|
||||
// Should redirect to login page after successful reset
|
||||
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
||||
|
||||
// Try logging in with new password
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByLabel('Password').fill(newPassword);
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
await expect(page.getByTestId('dashboard_view')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows validation error for password mismatch on reset', async ({ page, request }) => {
|
||||
// First register a new user
|
||||
const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;
|
||||
const originalPassword = 'suchagreatpassword123';
|
||||
await registerNewUser(page, email, originalPassword);
|
||||
|
||||
// Log out
|
||||
await page.getByTestId('current_user_button').click();
|
||||
await page.getByText('Log Out').click();
|
||||
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
||||
|
||||
// Request password reset
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByRole('button', { name: 'Email Password Reset Link' }).click();
|
||||
await expect(page.getByText('We have emailed your password reset link.')).toBeVisible();
|
||||
|
||||
// Get password reset URL from email
|
||||
const resetUrl = await getPasswordResetUrl(request, email);
|
||||
|
||||
// Navigate to reset page
|
||||
await page.goto(resetUrl);
|
||||
|
||||
// Fill in mismatched passwords
|
||||
await page.getByLabel('Password', { exact: true }).fill('newpassword123');
|
||||
await page.getByLabel('Confirm Password').fill('differentpassword456');
|
||||
await page.getByRole('button', { name: 'Reset Password' }).click();
|
||||
|
||||
// Should show validation error
|
||||
await expect(page.getByText('The password field confirmation does not match.')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows validation error for short password on reset', async ({ page, request }) => {
|
||||
// First register a new user
|
||||
const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;
|
||||
const originalPassword = 'suchagreatpassword123';
|
||||
await registerNewUser(page, email, originalPassword);
|
||||
|
||||
// Log out
|
||||
await page.getByTestId('current_user_button').click();
|
||||
await page.getByText('Log Out').click();
|
||||
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
||||
|
||||
// Request password reset
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByRole('button', { name: 'Email Password Reset Link' }).click();
|
||||
await expect(page.getByText('We have emailed your password reset link.')).toBeVisible();
|
||||
|
||||
// Get password reset URL from email
|
||||
const resetUrl = await getPasswordResetUrl(request, email);
|
||||
|
||||
// Navigate to reset page
|
||||
await page.goto(resetUrl);
|
||||
|
||||
// Fill in short password
|
||||
await page.getByLabel('Password', { exact: true }).fill('short');
|
||||
await page.getByLabel('Confirm Password').fill('short');
|
||||
await page.getByRole('button', { name: 'Reset Password' }).click();
|
||||
|
||||
// Should show validation error about minimum length
|
||||
await expect(page.getByText('must be at least')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows error for invalid login credentials', async ({ page }) => {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/login');
|
||||
await page.getByLabel('Email').fill('nonexistent@example.com');
|
||||
await page.getByLabel('Password').fill('wrongpassword123');
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText('These credentials do not match our records.')
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows error when registering with existing email', async ({ page }) => {
|
||||
const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;
|
||||
const password = 'suchagreatpassword123';
|
||||
|
||||
// Register first user
|
||||
await registerNewUser(page, email, password);
|
||||
|
||||
// Log out
|
||||
await page.getByTestId('current_user_button').click();
|
||||
await page.getByText('Log Out').click();
|
||||
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
||||
|
||||
// Try to register with the same email
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
|
||||
await page.getByLabel('Name').fill('Another User');
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(password);
|
||||
await page.getByLabel('Confirm Password').fill(password);
|
||||
await page.getByLabel('I agree to the Terms of').click();
|
||||
await page.getByRole('button', { name: 'Register' }).click();
|
||||
|
||||
// Should show error about email already taken
|
||||
await expect(page.getByText('The resource already exists.')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows validation error for weak password on registration', async ({ page }) => {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
|
||||
await page.getByLabel('Name').fill('Weak Password User');
|
||||
await page.getByLabel('Email').fill(`weak+${Math.round(Math.random() * 10000)}@test.com`);
|
||||
await page.getByLabel('Password', { exact: true }).fill('short');
|
||||
await page.getByLabel('Confirm Password').fill('short');
|
||||
await page.getByLabel('I agree to the Terms of').click();
|
||||
await page.getByRole('button', { name: 'Register' }).click();
|
||||
|
||||
await expect(page.getByText('must be at least')).toBeVisible();
|
||||
});
|
||||
|
||||
+110
-8
@@ -2,7 +2,11 @@ import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import { test } from '../playwright/fixtures';
|
||||
import { expect } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { createProject, createBillableProject, createBareTimeEntry } from './utils/reporting';
|
||||
import {
|
||||
createBillableProjectViaApi,
|
||||
createProjectViaApi,
|
||||
createBareTimeEntryViaApi,
|
||||
} from './utils/api';
|
||||
|
||||
async function goToCalendar(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
|
||||
@@ -17,11 +21,12 @@ async function goToCalendar(page: Page) {
|
||||
|
||||
test('test that changing project in calendar edit modal from non-billable to billable updates billable status', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const billableProjectName = 'Billable Cal Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
|
||||
await createBillableProject(page, billableProjectName);
|
||||
await createBareTimeEntry(page, 'Test billable calendar', '1h');
|
||||
await createBillableProjectViaApi(ctx, { name: billableProjectName });
|
||||
await createBareTimeEntryViaApi(ctx, 'Test billable calendar', '1h');
|
||||
|
||||
await goToCalendar(page);
|
||||
|
||||
@@ -59,14 +64,15 @@ test('test that changing project in calendar edit modal from non-billable to bil
|
||||
|
||||
test('test that changing project in calendar edit modal from billable to non-billable updates billable status', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const billableProjectName = 'Billable Cal Rev Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
const nonBillableProjectName =
|
||||
'NonBillable Cal Rev Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
|
||||
await createBillableProject(page, billableProjectName);
|
||||
await createProject(page, nonBillableProjectName);
|
||||
await createBareTimeEntry(page, 'Test billable cal reverse', '1h');
|
||||
await createBillableProjectViaApi(ctx, { name: billableProjectName });
|
||||
await createProjectViaApi(ctx, { name: nonBillableProjectName });
|
||||
await createBareTimeEntryViaApi(ctx, 'Test billable cal reverse', '1h');
|
||||
|
||||
await goToCalendar(page);
|
||||
|
||||
@@ -112,12 +118,13 @@ test('test that changing project in calendar edit modal from billable to non-bil
|
||||
|
||||
test('test that opening calendar edit modal for a time entry with manually overridden billable status preserves that status', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const billableProjectName =
|
||||
'Billable Cal Persist Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
|
||||
await createBillableProject(page, billableProjectName);
|
||||
await createBareTimeEntry(page, 'Test cal persist override', '1h');
|
||||
await createBillableProjectViaApi(ctx, { name: billableProjectName });
|
||||
await createBareTimeEntryViaApi(ctx, 'Test cal persist override', '1h');
|
||||
|
||||
await goToCalendar(page);
|
||||
|
||||
@@ -186,3 +193,98 @@ test('test that opening calendar edit modal for a time entry with manually overr
|
||||
const responseBody = await updateResponse.json();
|
||||
expect(responseBody.data.billable).toBe(false);
|
||||
});
|
||||
|
||||
test('test that calendar page loads and displays time entries', async ({ page, ctx }) => {
|
||||
await createBareTimeEntryViaApi(ctx, 'Calendar display test', '1h');
|
||||
|
||||
await goToCalendar(page);
|
||||
|
||||
// Calendar container should be visible
|
||||
await expect(page.locator('.fc')).toBeVisible();
|
||||
|
||||
// The time entry should appear as a calendar event
|
||||
await expect(
|
||||
page.locator('.fc-event').filter({ hasText: 'Calendar display test' }).first()
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that calendar navigation buttons work', async ({ page }) => {
|
||||
await goToCalendar(page);
|
||||
await expect(page.locator('.fc')).toBeVisible();
|
||||
|
||||
// Click the "next" button to navigate forward
|
||||
await page.locator('button.fc-next-button').click();
|
||||
await expect(page.locator('.fc')).toBeVisible();
|
||||
|
||||
// Click the "prev" button to navigate back
|
||||
await page.locator('button.fc-prev-button').click();
|
||||
await expect(page.locator('.fc')).toBeVisible();
|
||||
|
||||
// Navigate forward first so "today" button becomes enabled, then click it
|
||||
await page.locator('button.fc-next-button').click();
|
||||
await page.locator('button.fc-today-button').click();
|
||||
await expect(page.locator('.fc')).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that editing time entry description via calendar modal works', async ({ page, ctx }) => {
|
||||
const originalDescription = 'Edit me in calendar ' + Math.floor(1 + Math.random() * 10000);
|
||||
const updatedDescription = 'Updated in calendar ' + Math.floor(1 + Math.random() * 10000);
|
||||
await createBareTimeEntryViaApi(ctx, originalDescription, '1h');
|
||||
|
||||
await goToCalendar(page);
|
||||
|
||||
// Click on the time entry event
|
||||
await page.locator('.fc-event').filter({ hasText: originalDescription }).first().click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Update the description (edit modal uses placeholder, not data-testid)
|
||||
const descriptionInput = page.getByRole('dialog').getByPlaceholder('What did you work on?');
|
||||
await descriptionInput.fill(updatedDescription);
|
||||
|
||||
// Save and verify
|
||||
const [editResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
),
|
||||
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
||||
]);
|
||||
const editBody = await editResponse.json();
|
||||
expect(editBody.data.description).toBe(updatedDescription);
|
||||
|
||||
// Verify the updated description is shown in the calendar UI
|
||||
await expect(
|
||||
page.locator('.fc-event').filter({ hasText: updatedDescription }).first()
|
||||
).toBeVisible();
|
||||
// Verify the old description is no longer shown
|
||||
await expect(
|
||||
page.locator('.fc-event').filter({ hasText: originalDescription })
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('test that deleting time entry from calendar modal works', async ({ page, ctx }) => {
|
||||
const description = 'Delete me from calendar ' + Math.floor(1 + Math.random() * 10000);
|
||||
await createBareTimeEntryViaApi(ctx, description, '1h');
|
||||
|
||||
await goToCalendar(page);
|
||||
|
||||
// Click on the time entry event
|
||||
await page.locator('.fc-event').filter({ hasText: description }).first().click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Click the delete button
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/') &&
|
||||
response.request().method() === 'DELETE' &&
|
||||
response.status() === 204
|
||||
),
|
||||
page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(),
|
||||
]);
|
||||
|
||||
// Verify the event is removed from the calendar
|
||||
await expect(page.locator('.fc-event').filter({ hasText: description })).not.toBeVisible();
|
||||
});
|
||||
|
||||
+61
-9
@@ -2,15 +2,16 @@ import { expect } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import { test } from '../playwright/fixtures';
|
||||
import { createClientViaApi } from './utils/api';
|
||||
|
||||
async function goToProjectsOverview(page: Page) {
|
||||
async function goToClientsOverview(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/clients');
|
||||
}
|
||||
|
||||
// Create new project via modal
|
||||
// Create new client via modal
|
||||
test('test that creating and deleting a new client via the modal works', async ({ page }) => {
|
||||
const newClientName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
await goToProjectsOverview(page);
|
||||
await goToClientsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Client' }).click();
|
||||
await page.getByPlaceholder('Client Name').fill(newClientName);
|
||||
await Promise.all([
|
||||
@@ -42,13 +43,11 @@ test('test that creating and deleting a new client via the modal works', async (
|
||||
await expect(page.getByTestId('client_table')).not.toContainText(newClientName);
|
||||
});
|
||||
|
||||
test('test that archiving and unarchiving clients works', async ({ page }) => {
|
||||
test('test that archiving and unarchiving clients works', async ({ page, ctx }) => {
|
||||
const newClientName = 'New Client ' + Math.floor(1 + Math.random() * 10000);
|
||||
await goToProjectsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Client' }).click();
|
||||
await page.getByLabel('Client Name').fill(newClientName);
|
||||
await createClientViaApi(ctx, { name: newClientName });
|
||||
|
||||
await page.getByRole('button', { name: 'Create Client' }).click();
|
||||
await goToClientsOverview(page);
|
||||
await expect(page.getByText(newClientName)).toBeVisible();
|
||||
|
||||
await page.getByRole('row').first().getByRole('button').click();
|
||||
@@ -72,4 +71,57 @@ test('test that archiving and unarchiving clients works', async ({ page }) => {
|
||||
]);
|
||||
});
|
||||
|
||||
// TODO: Add Name Update Test
|
||||
test('test that editing a client name works', async ({ page, ctx }) => {
|
||||
const originalName = 'Original Client ' + Math.floor(1 + Math.random() * 10000);
|
||||
const updatedName = 'Updated Client ' + Math.floor(1 + Math.random() * 10000);
|
||||
await createClientViaApi(ctx, { name: originalName });
|
||||
|
||||
await goToClientsOverview(page);
|
||||
await expect(page.getByText(originalName)).toBeVisible();
|
||||
|
||||
// Open edit modal via actions menu
|
||||
const moreButton = page.locator("[aria-label='Actions for Client " + originalName + "']");
|
||||
await moreButton.click();
|
||||
await page.getByTestId('client_edit').click();
|
||||
|
||||
// Update the client name
|
||||
await page.getByPlaceholder('Client Name').fill(updatedName);
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Update Client' }).click(),
|
||||
page.waitForResponse(
|
||||
async (response) =>
|
||||
response.url().includes('/clients') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
),
|
||||
]);
|
||||
|
||||
// Verify updated name is shown and old name is gone
|
||||
await expect(page.getByTestId('client_table')).toContainText(updatedName);
|
||||
await expect(page.getByTestId('client_table')).not.toContainText(originalName);
|
||||
});
|
||||
|
||||
test('test that deleting a client via actions menu works', async ({ page, ctx }) => {
|
||||
const clientName = 'DeleteMe Client ' + Math.floor(1 + Math.random() * 10000);
|
||||
|
||||
await createClientViaApi(ctx, { name: clientName });
|
||||
|
||||
await goToClientsOverview(page);
|
||||
await expect(page.getByTestId('client_table')).toContainText(clientName);
|
||||
|
||||
const moreButton = page.locator("[aria-label='Actions for Client " + clientName + "']");
|
||||
await moreButton.click();
|
||||
const deleteButton = page.locator("[aria-label='Delete Client " + clientName + "']");
|
||||
|
||||
await Promise.all([
|
||||
deleteButton.click(),
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/clients') &&
|
||||
response.request().method() === 'DELETE' &&
|
||||
response.status() === 204
|
||||
),
|
||||
]);
|
||||
|
||||
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
|
||||
});
|
||||
|
||||
+11
-14
@@ -20,8 +20,8 @@ async function closeCommandPalette(page: Page) {
|
||||
|
||||
async function searchInCommandPalette(page: Page, query: string) {
|
||||
await page.locator('[role="dialog"] input').fill(query);
|
||||
// Wait for search to filter and API calls to settle
|
||||
await page.waitForTimeout(500);
|
||||
// Wait for search debounce to settle (command palette uses a debounced search)
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
async function selectCommand(page: Page, name: string) {
|
||||
@@ -306,26 +306,23 @@ test.describe('Command Palette', () => {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
await page.getByPlaceholder('The next big thing').fill(projectName);
|
||||
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
// Wait for project to be created and page to update
|
||||
await expect(page.getByText(projectName)).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Now go to dashboard and search for the project
|
||||
await goToDashboard(page);
|
||||
// Search from the projects page where the query cache now has the new project
|
||||
await openCommandPalette(page);
|
||||
await searchInCommandPalette(page, projectName);
|
||||
|
||||
// Wait for entity search to return results, then use keyboard to select
|
||||
await page.waitForTimeout(1500);
|
||||
// Wait for entity search to return results
|
||||
const projectOption = page.getByRole('option').filter({ hasText: projectName });
|
||||
await expect(projectOption).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Use keyboard to navigate down (past any matching commands) and select
|
||||
// The project should appear in search results
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
// Should navigate somewhere (either project page or remain on dashboard)
|
||||
// If entity search found the project, it will navigate to project page
|
||||
await page.waitForTimeout(500);
|
||||
// Select the project from search results
|
||||
await projectOption.click();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+233
-45
@@ -4,13 +4,11 @@
|
||||
import { expect, test } from '../playwright/fixtures';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import type { Page } from '@playwright/test';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import { inviteAndAcceptMember } from './utils/members';
|
||||
import { createPlaceholderMemberViaImportApi } from './utils/api';
|
||||
|
||||
// Tests that invite + accept members need more time
|
||||
test.describe.configure({ timeout: 60000 });
|
||||
test.describe.configure({ timeout: 45000 });
|
||||
|
||||
async function goToMembersPage(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
|
||||
@@ -104,46 +102,11 @@ test('test that organization billable rate can be updated with all existing time
|
||||
]);
|
||||
});
|
||||
|
||||
async function createPlaceholderMemberViaImport(page: Page, placeholderName: string) {
|
||||
const placeholderEmail = `placeholder+${Math.floor(Math.random() * 100000)}@solidtime-import.test`;
|
||||
const csvContent = [
|
||||
'User,Email,Client,Project,Task,Description,Billable,Start date,Start time,End date,End time,Tags',
|
||||
`${placeholderName},${placeholderEmail},,,,Imported entry,No,2024-01-01,09:00:00,2024-01-01,10:00:00,`,
|
||||
].join('\n');
|
||||
|
||||
// Write CSV to a temp file for upload
|
||||
const tmpDir = os.tmpdir();
|
||||
const tmpFile = path.join(tmpDir, `import-${Date.now()}.csv`);
|
||||
fs.writeFileSync(tmpFile, csvContent);
|
||||
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/import');
|
||||
|
||||
// Select "Toggl Time Entries" import type
|
||||
await page.locator('select#importType').selectOption({ label: 'Toggl Time Entries' });
|
||||
|
||||
// Upload the CSV file
|
||||
await page.locator('input[type="file"]').setInputFiles(tmpFile);
|
||||
|
||||
// Click Import and wait for success
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Import Data' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes('/import') && response.status() === 200
|
||||
),
|
||||
]);
|
||||
|
||||
// Close the result modal
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
// Clean up temp file
|
||||
fs.unlinkSync(tmpFile);
|
||||
}
|
||||
|
||||
test('test that changing role of placeholder member is rejected', async ({ page }) => {
|
||||
test('test that changing role of placeholder member is rejected', async ({ page, ctx }) => {
|
||||
const placeholderName = 'RoleChange ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create a placeholder member via import
|
||||
await createPlaceholderMemberViaImport(page, placeholderName);
|
||||
await createPlaceholderMemberViaImportApi(ctx, placeholderName);
|
||||
|
||||
// Go to members page and verify placeholder exists with role "Placeholder"
|
||||
await goToMembersPage(page);
|
||||
@@ -226,11 +189,11 @@ test('test that changing member role updates the role in the member table', asyn
|
||||
await expect(memberRow.getByText('Manager', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that merging a placeholder member works', async ({ page }) => {
|
||||
test('test that merging a placeholder member works', async ({ page, ctx }) => {
|
||||
const placeholderName = 'Merge Target ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create a placeholder member via import
|
||||
await createPlaceholderMemberViaImport(page, placeholderName);
|
||||
await createPlaceholderMemberViaImportApi(ctx, placeholderName);
|
||||
|
||||
// Go to members page
|
||||
await goToMembersPage(page);
|
||||
@@ -265,9 +228,234 @@ test('test that merging a placeholder member works', async ({ page }) => {
|
||||
),
|
||||
]);
|
||||
|
||||
// Wait for dialog to close after successful merge
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||
// Wait for merge dialog to close after successful merge
|
||||
await expect(page.getByRole('dialog').filter({ hasText: 'Merge Member' })).not.toBeVisible();
|
||||
|
||||
// Verify placeholder member is no longer in the members table
|
||||
await expect(page.getByRole('main').getByText(placeholderName)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('test that deleting a placeholder member works', async ({ page, ctx }) => {
|
||||
const placeholderName = 'Delete Target ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create a placeholder member via import
|
||||
await createPlaceholderMemberViaImportApi(ctx, placeholderName);
|
||||
|
||||
// Go to members page
|
||||
await goToMembersPage(page);
|
||||
const memberRow = page.getByRole('row').filter({ hasText: placeholderName });
|
||||
await expect(memberRow).toBeVisible();
|
||||
|
||||
// Open actions menu and click Delete
|
||||
await memberRow.getByRole('button').click();
|
||||
await page.getByRole('menuitem').getByText('Delete').click();
|
||||
|
||||
// Verify delete modal is shown
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Delete Member' })).toBeVisible();
|
||||
|
||||
// Try to delete without checking the confirmation checkbox
|
||||
await page.getByRole('button', { name: 'Delete Member' }).click();
|
||||
|
||||
// Should show validation error
|
||||
await expect(
|
||||
page.getByText('You must confirm that you understand the consequences of this action')
|
||||
).toBeVisible();
|
||||
|
||||
// Check the confirmation checkbox
|
||||
await page.getByRole('checkbox').click();
|
||||
|
||||
// Click Delete Member button and wait for API response
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Delete Member' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/members/') &&
|
||||
response.request().method() === 'DELETE' &&
|
||||
response.ok()
|
||||
),
|
||||
]);
|
||||
|
||||
// Verify modal is closed
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||
|
||||
// Verify member is removed from the table
|
||||
await expect(page.getByRole('main').getByText(placeholderName)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('test that member delete modal can be cancelled', async ({ page, ctx }) => {
|
||||
const placeholderName = 'Delete Cancel ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create a placeholder member via import
|
||||
await createPlaceholderMemberViaImportApi(ctx, placeholderName);
|
||||
|
||||
// Go to members page
|
||||
await goToMembersPage(page);
|
||||
const memberRow = page.getByRole('row').filter({ hasText: placeholderName });
|
||||
await expect(memberRow).toBeVisible();
|
||||
|
||||
// Open actions menu and click Delete
|
||||
await memberRow.getByRole('button').click();
|
||||
await page.getByRole('menuitem').getByText('Delete').click();
|
||||
|
||||
// Verify delete modal is shown
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Set up listener to verify no DELETE request is sent
|
||||
let deleteRequestSent = false;
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('/members/') && request.method() === 'DELETE') {
|
||||
deleteRequestSent = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Click Cancel
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
// Verify modal is closed
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||
|
||||
// Verify member is still in the table
|
||||
await expect(memberRow).toBeVisible();
|
||||
|
||||
// Verify no DELETE request was sent
|
||||
expect(deleteRequestSent).toBe(false);
|
||||
});
|
||||
|
||||
test('test that organization owner cannot be deleted', async ({ page }) => {
|
||||
await goToMembersPage(page);
|
||||
|
||||
// Find the owner row (John Doe with Owner role)
|
||||
const ownerRow = page.getByRole('row').filter({ hasText: 'Owner' });
|
||||
await expect(ownerRow).toBeVisible();
|
||||
|
||||
// Open the actions menu for the owner
|
||||
await ownerRow.getByRole('button').click();
|
||||
|
||||
// Click Delete
|
||||
await page.getByRole('menuitem').getByText('Delete').click();
|
||||
|
||||
// Verify delete modal is shown
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Check the confirmation checkbox
|
||||
await page.getByRole('checkbox').click();
|
||||
|
||||
// Try to delete - should fail with 400 error
|
||||
const responsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/members/') && response.request().method() === 'DELETE'
|
||||
);
|
||||
await page.getByRole('button', { name: 'Delete Member' }).click();
|
||||
const response = await responsePromise;
|
||||
|
||||
// Verify the API returned an error status
|
||||
expect(response.status()).toBe(400);
|
||||
|
||||
// Close the modal by pressing Escape
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Refresh and verify the owner is still there
|
||||
await goToMembersPage(page);
|
||||
await expect(page.getByRole('row').filter({ hasText: 'Owner' })).toBeVisible();
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// Invitations Tab Tests
|
||||
// =============================================
|
||||
|
||||
test('test that invitation shows in invitations tab and can be revoked', async ({ page }) => {
|
||||
const inviteEmail = `invite+${Math.floor(Math.random() * 100000)}@pending.test`;
|
||||
|
||||
await goToMembersPage(page);
|
||||
await openInviteMemberModal(page);
|
||||
|
||||
await page.getByPlaceholder('Member Email').fill(inviteEmail);
|
||||
await page.getByRole('button', { name: 'Employee' }).click();
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/invitations') &&
|
||||
response.request().method() === 'POST' &&
|
||||
response.status() === 204
|
||||
),
|
||||
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
|
||||
]);
|
||||
|
||||
// Wait for modal to close
|
||||
await expect(page.getByPlaceholder('Member Email')).not.toBeVisible();
|
||||
|
||||
// Switch to Invitations tab and verify the invitation is visible
|
||||
await page.getByText('Invitations', { exact: true }).click();
|
||||
await expect(page.getByText(inviteEmail)).toBeVisible();
|
||||
|
||||
// Find and click the actions menu for this invitation
|
||||
const invitationRow = page.locator('tr, [role="row"]').filter({ hasText: inviteEmail });
|
||||
await invitationRow.getByRole('button').click();
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/invitations/') &&
|
||||
response.request().method() === 'DELETE' &&
|
||||
response.status() === 204
|
||||
),
|
||||
page.getByRole('menuitem').getByText('Delete').click(),
|
||||
]);
|
||||
|
||||
// Verify invitation is removed
|
||||
await expect(page.getByText(inviteEmail)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('test that invitation can be resent', async ({ page }) => {
|
||||
const inviteEmail = `resend+${Math.floor(Math.random() * 100000)}@invite.test`;
|
||||
|
||||
await goToMembersPage(page);
|
||||
await openInviteMemberModal(page);
|
||||
|
||||
await page.getByPlaceholder('Member Email').fill(inviteEmail);
|
||||
await page.getByRole('button', { name: 'Employee' }).click();
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/invitations') &&
|
||||
response.request().method() === 'POST' &&
|
||||
response.status() === 204
|
||||
),
|
||||
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
|
||||
]);
|
||||
|
||||
// Wait for modal to close
|
||||
await expect(page.getByPlaceholder('Member Email')).not.toBeVisible();
|
||||
|
||||
// Switch to Invitations tab
|
||||
await page.getByText('Invitations', { exact: true }).click();
|
||||
await expect(page.getByText(inviteEmail)).toBeVisible();
|
||||
|
||||
// Find and click the actions menu, then resend
|
||||
const invitationRow = page.locator('tr, [role="row"]').filter({ hasText: inviteEmail });
|
||||
await invitationRow.getByRole('button').click();
|
||||
// Wait for dropdown menu to appear
|
||||
await expect(page.getByRole('menuitem').getByText('Resend Invitation')).toBeVisible();
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/resend') && response.request().method() === 'POST'
|
||||
),
|
||||
page.getByRole('menuitem').getByText('Resend Invitation').click(),
|
||||
]);
|
||||
});
|
||||
|
||||
test('test that accepted invitation disappears from invitations tab', async ({ page, browser }) => {
|
||||
const memberId = Math.round(Math.random() * 100000);
|
||||
const memberEmail = `accepted+${memberId}@invite.test`;
|
||||
|
||||
// Invite and accept the member
|
||||
await inviteAndAcceptMember(page, browser, 'Accepted Member', memberEmail, 'Employee');
|
||||
|
||||
// Go to members page and switch to Invitations tab
|
||||
await goToMembersPage(page);
|
||||
await page.getByRole('tab', { name: 'Invitations' }).click();
|
||||
|
||||
// The accepted invitation should not be visible
|
||||
await expect(page.getByText(memberEmail)).not.toBeVisible();
|
||||
});
|
||||
|
||||
+136
-1
@@ -228,4 +228,139 @@ test('test that format settings are reflected in the dashboard', async ({ page }
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// TODO: Test 12-hour clock format
|
||||
test('test that organization time entry settings can be toggled', async ({ page }) => {
|
||||
await goToOrganizationSettings(page);
|
||||
|
||||
const preventOverlappingCheckbox = page.getByLabel(
|
||||
'Prevent overlapping time entries (new entries only)'
|
||||
);
|
||||
const manageTasksCheckbox = page.getByLabel('Allow Employees to manage tasks');
|
||||
|
||||
// Get current states and toggle both
|
||||
const wasOverlappingChecked = await preventOverlappingCheckbox.isChecked();
|
||||
const wasManageTasksChecked = await manageTasksCheckbox.isChecked();
|
||||
|
||||
if (wasOverlappingChecked) {
|
||||
await preventOverlappingCheckbox.uncheck();
|
||||
} else {
|
||||
await preventOverlappingCheckbox.check();
|
||||
}
|
||||
|
||||
if (wasManageTasksChecked) {
|
||||
await manageTasksCheckbox.uncheck();
|
||||
} else {
|
||||
await manageTasksCheckbox.check();
|
||||
}
|
||||
|
||||
// Save
|
||||
const settingsForm = page.locator('form').filter({ hasText: 'Prevent overlapping' });
|
||||
await Promise.all([
|
||||
settingsForm.getByRole('button', { name: 'Save' }).click(),
|
||||
page.waitForResponse(
|
||||
async (response) =>
|
||||
response.url().includes('/organizations/') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200 &&
|
||||
(await response.json()).data.prevent_overlapping_time_entries ===
|
||||
!wasOverlappingChecked
|
||||
),
|
||||
]);
|
||||
|
||||
// Reload and verify both settings persisted
|
||||
await page.reload();
|
||||
await expect(preventOverlappingCheckbox).toBeChecked({ checked: !wasOverlappingChecked });
|
||||
await expect(manageTasksCheckbox).toBeChecked({ checked: !wasManageTasksChecked });
|
||||
|
||||
// Toggle both back to restore original state
|
||||
if (!wasOverlappingChecked) {
|
||||
await preventOverlappingCheckbox.uncheck();
|
||||
} else {
|
||||
await preventOverlappingCheckbox.check();
|
||||
}
|
||||
|
||||
if (!wasManageTasksChecked) {
|
||||
await manageTasksCheckbox.uncheck();
|
||||
} else {
|
||||
await manageTasksCheckbox.check();
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
settingsForm.getByRole('button', { name: 'Save' }).click(),
|
||||
page.waitForResponse(
|
||||
async (response) =>
|
||||
response.url().includes('/organizations/') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200 &&
|
||||
(await response.json()).data.prevent_overlapping_time_entries ===
|
||||
wasOverlappingChecked
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
test('test that 12-hour clock format can be set', async ({ page }) => {
|
||||
await goToOrganizationSettings(page);
|
||||
|
||||
await page.getByLabel('Time Format').click();
|
||||
await page.getByRole('option', { name: '12-hour clock' }).click();
|
||||
await Promise.all([
|
||||
page
|
||||
.locator('form')
|
||||
.filter({ hasText: 'Time Format' })
|
||||
.getByRole('button', { name: 'Save' })
|
||||
.click(),
|
||||
page.waitForResponse(
|
||||
async (response) =>
|
||||
response.url().includes('/organizations/') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200 &&
|
||||
(await response.json()).data.time_format === '12-hours'
|
||||
),
|
||||
]);
|
||||
|
||||
// Reload and verify it persisted
|
||||
await page.reload();
|
||||
await expect(page.getByLabel('Time Format')).toContainText('12-hour clock');
|
||||
|
||||
// Reset back to 24-hour
|
||||
await page.getByLabel('Time Format').click();
|
||||
await page.getByRole('option', { name: '24-hour clock' }).click();
|
||||
await Promise.all([
|
||||
page
|
||||
.locator('form')
|
||||
.filter({ hasText: 'Time Format' })
|
||||
.getByRole('button', { name: 'Save' })
|
||||
.click(),
|
||||
page.waitForResponse(
|
||||
async (response) =>
|
||||
response.url().includes('/organizations/') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200 &&
|
||||
(await response.json()).data.time_format === '24-hours'
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
test('test that format settings persist after page reload', async ({ page }) => {
|
||||
await goToOrganizationSettings(page);
|
||||
|
||||
// Set a specific date format
|
||||
await page.getByLabel('Date Format').click();
|
||||
await page.getByRole('option', { name: 'DD/MM/YYYY' }).click();
|
||||
await Promise.all([
|
||||
page
|
||||
.locator('form')
|
||||
.filter({ hasText: 'Date Format' })
|
||||
.getByRole('button', { name: 'Save' })
|
||||
.click(),
|
||||
page.waitForResponse(
|
||||
async (response) =>
|
||||
response.url().includes('/organizations/') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
),
|
||||
]);
|
||||
|
||||
// Reload and verify it persisted
|
||||
await page.reload();
|
||||
await expect(page.getByLabel('Date Format')).toContainText('DD/MM/YYYY');
|
||||
});
|
||||
|
||||
+257
-1
@@ -1,5 +1,10 @@
|
||||
import { test, expect } from '../playwright/fixtures';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
async function goToProfilePage(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||
}
|
||||
|
||||
test('test that user name can be updated', async ({ page }) => {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||
@@ -68,3 +73,254 @@ test('test that user can revoke an API key', async ({ page }) => {
|
||||
await expect(page.locator('body')).toContainText('NEW API KEY');
|
||||
await expect(page.locator('body')).toContainText('Revoked');
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// Update Password Form Tests
|
||||
// =============================================
|
||||
|
||||
test('test that password mismatch shows error', async ({ page }) => {
|
||||
await goToProfilePage(page);
|
||||
|
||||
// Fill in with mismatched passwords
|
||||
await page.getByLabel('Current Password').fill(TEST_USER_PASSWORD);
|
||||
await page.getByLabel('New Password').fill('newSecurePassword456');
|
||||
await page.getByLabel('Confirm Password').fill('differentPassword789');
|
||||
|
||||
// Find the form containing the Confirm Password field and click its Save button
|
||||
const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form');
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/user/password') && response.request().method() === 'PUT'
|
||||
),
|
||||
passwordForm.getByRole('button', { name: 'Save' }).click(),
|
||||
]);
|
||||
|
||||
// Verify error message about password confirmation
|
||||
await expect(page.getByText('confirmation does not match')).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that short password shows validation error', async ({ page }) => {
|
||||
await goToProfilePage(page);
|
||||
|
||||
// Fill in with a too short password
|
||||
await page.getByLabel('Current Password').fill(TEST_USER_PASSWORD);
|
||||
await page.getByLabel('New Password').fill('short');
|
||||
await page.getByLabel('Confirm Password').fill('short');
|
||||
|
||||
// Find the form containing the Confirm Password field and click its Save button
|
||||
const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form');
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/user/password') && response.request().method() === 'PUT'
|
||||
),
|
||||
passwordForm.getByRole('button', { name: 'Save' }).click(),
|
||||
]);
|
||||
|
||||
// Verify error message about password length
|
||||
await expect(page.getByText('must be at least')).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that incorrect current password shows validation error', async ({ page }) => {
|
||||
await goToProfilePage(page);
|
||||
|
||||
// Fill in with wrong current password
|
||||
await page.getByLabel('Current Password').fill('wrongCurrentPassword123');
|
||||
await page.getByLabel('New Password').fill('newSecurePassword456');
|
||||
await page.getByLabel('Confirm Password').fill('newSecurePassword456');
|
||||
|
||||
// Find the form containing the Confirm Password field and click its Save button
|
||||
const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form');
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/user/password') && response.request().method() === 'PUT'
|
||||
),
|
||||
passwordForm.getByRole('button', { name: 'Save' }).click(),
|
||||
]);
|
||||
|
||||
// Verify error message about incorrect password
|
||||
await expect(page.getByText('does not match')).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that password can be updated successfully', async ({ page }) => {
|
||||
await goToProfilePage(page);
|
||||
const newPassword = 'newSecurePassword456';
|
||||
|
||||
// Change password to new password
|
||||
await page.getByLabel('Current Password').fill(TEST_USER_PASSWORD);
|
||||
await page.getByLabel('New Password').fill(newPassword);
|
||||
await page.getByLabel('Confirm Password').fill(newPassword);
|
||||
|
||||
const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form');
|
||||
const responsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/user/password') && response.request().method() === 'PUT'
|
||||
);
|
||||
await passwordForm.getByRole('button', { name: 'Save' }).click();
|
||||
const response = await responsePromise;
|
||||
|
||||
// Verify successful response (303 is Inertia redirect on success, means password was updated)
|
||||
expect(response.status()).toBe(303);
|
||||
|
||||
// Verify no error messages are displayed
|
||||
await expect(page.getByText('does not match')).not.toBeVisible();
|
||||
await expect(page.getByText('must be at least')).not.toBeVisible();
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// Theme Selection Tests
|
||||
// =============================================
|
||||
|
||||
test('test that theme can be changed to dark and light', async ({ page }) => {
|
||||
await goToProfilePage(page);
|
||||
|
||||
// The theme select is a Reka UI combobox (button), not a native <select>
|
||||
const themeSelect = page.locator('button[role="combobox"]');
|
||||
|
||||
// Change theme to dark
|
||||
await themeSelect.click();
|
||||
await page.getByRole('option', { name: 'Dark' }).click();
|
||||
|
||||
// Verify the html element has 'dark' class
|
||||
await expect(page.locator('html')).toHaveClass(/dark/);
|
||||
|
||||
// Change theme to light
|
||||
await themeSelect.click();
|
||||
await page.getByRole('option', { name: 'Light' }).click();
|
||||
|
||||
// Verify the html element has 'light' class and no 'dark' class
|
||||
await expect(page.locator('html')).toHaveClass(/light/);
|
||||
await expect(page.locator('html')).not.toHaveClass(/dark/);
|
||||
|
||||
// Verify localStorage persists the setting
|
||||
const storedTheme = await page.evaluate(() => localStorage.getItem('theme'));
|
||||
expect(storedTheme).toContain('light');
|
||||
|
||||
// Reload and verify the theme persists
|
||||
await page.reload();
|
||||
await expect(page.locator('html')).toHaveClass(/light/);
|
||||
|
||||
// Reset to system
|
||||
await page.locator('button[role="combobox"]').click();
|
||||
await page.getByRole('option', { name: 'System' }).click();
|
||||
await expect(page.getByText('System default:')).toBeVisible();
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// Two Factor Authentication Tests
|
||||
// =============================================
|
||||
|
||||
test('test that password confirmation modal can be cancelled without sending API request', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToProfilePage(page);
|
||||
|
||||
// Find the Enable button in the 2FA section
|
||||
const enableButton = page
|
||||
.getByText('You have not enabled two factor authentication.')
|
||||
.locator('..')
|
||||
.getByRole('button', { name: 'Enable' });
|
||||
await enableButton.click();
|
||||
|
||||
// Verify password confirmation modal appears
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Set up listener to verify no POST request is sent to confirm-password
|
||||
let confirmPasswordRequestSent = false;
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('/user/confirm-password') && request.method() === 'POST') {
|
||||
confirmPasswordRequestSent = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Click Cancel
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
// Verify modal is closed
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||
|
||||
// Verify no confirm-password request was sent
|
||||
expect(confirmPasswordRequestSent).toBe(false);
|
||||
});
|
||||
|
||||
test('test that password confirmation modal shows error for incorrect password', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToProfilePage(page);
|
||||
|
||||
// Find the Enable button in the 2FA section
|
||||
const enableButton = page
|
||||
.getByText('You have not enabled two factor authentication.')
|
||||
.locator('..')
|
||||
.getByRole('button', { name: 'Enable' });
|
||||
await enableButton.click();
|
||||
|
||||
// Verify password confirmation modal appears
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Enter incorrect password and confirm
|
||||
await page.getByPlaceholder('Password').fill('wrongpassword123');
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click();
|
||||
|
||||
// Should show error message (wait longer for API response)
|
||||
await expect(page.getByRole('dialog').getByText('incorrect')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('test that 2FA can be enabled with correct password', async ({ page }) => {
|
||||
await goToProfilePage(page);
|
||||
|
||||
// Verify 2FA is not enabled
|
||||
await expect(page.getByText('You have not enabled two factor authentication.')).toBeVisible();
|
||||
|
||||
// Find the Enable button in the 2FA section
|
||||
const enableButton = page
|
||||
.getByText('You have not enabled two factor authentication.')
|
||||
.locator('..')
|
||||
.getByRole('button', { name: 'Enable' });
|
||||
await enableButton.click();
|
||||
|
||||
// Verify password confirmation modal appears
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Enter correct password and confirm
|
||||
await page.getByPlaceholder('Password').fill(TEST_USER_PASSWORD);
|
||||
await Promise.all([
|
||||
page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/user/two-factor-authentication') &&
|
||||
response.request().method() === 'POST'
|
||||
),
|
||||
]);
|
||||
|
||||
// Verify QR code is shown
|
||||
await expect(page.getByRole('heading', { name: 'Finish enabling two factor' })).toBeVisible();
|
||||
await expect(page.getByText('Setup Key:')).toBeVisible();
|
||||
await expect(page.getByLabel('Code')).toBeVisible();
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// Logout Other Browser Sessions Tests
|
||||
// =============================================
|
||||
|
||||
test('test that logout other browser sessions works with correct password', async ({ page }) => {
|
||||
await goToProfilePage(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Log Out Other Browser Sessions' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder('Password').fill(TEST_USER_PASSWORD);
|
||||
await Promise.all([
|
||||
page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: 'Log Out Other Browser Sessions' })
|
||||
.click(),
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/user/other-browser-sessions') &&
|
||||
response.request().method() === 'DELETE'
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
+205
-24
@@ -3,38 +3,25 @@ import type { Page } from '@playwright/test';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import { test } from '../playwright/fixtures';
|
||||
import { formatCentsWithOrganizationDefaults } from './utils/money';
|
||||
import { createProjectViaApi, createProjectMemberViaApi, type TestContext } from './utils/api';
|
||||
|
||||
async function goToProjectsOverview(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
||||
async function createProjectWithMemberViaApi(ctx: TestContext, page: Page, projectName: string) {
|
||||
const project = await createProjectViaApi(ctx, { name: projectName });
|
||||
await createProjectMemberViaApi(ctx, project.id, { member_id: ctx.memberId });
|
||||
|
||||
// Navigate to the project detail page
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + project.id);
|
||||
await expect(page.getByTestId('project_member_table').getByRole('row').first()).toBeVisible();
|
||||
return project;
|
||||
}
|
||||
|
||||
test('test that updating project member billable rate works for existing time entries', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
const newBillableRate = Math.round(Math.random() * 10000);
|
||||
await goToProjectsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
await page.getByLabel('Project Name').fill(newProjectName);
|
||||
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Create Project' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/projects') &&
|
||||
response.request().method() === 'POST' &&
|
||||
response.status() === 201
|
||||
),
|
||||
]);
|
||||
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.getByText(newProjectName).click();
|
||||
await page.getByRole('button', { name: 'Add Member' }).click();
|
||||
|
||||
await expect(page.getByText('Add Project Member').first()).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Select a member...' }).click();
|
||||
await page.getByRole('option').first().click();
|
||||
await page.getByRole('button', { name: 'Add Project Member' }).click();
|
||||
await createProjectWithMemberViaApi(ctx, page, newProjectName);
|
||||
|
||||
await page
|
||||
.getByTestId('project_member_table')
|
||||
@@ -69,3 +56,197 @@ test('test that updating project member billable rate works for existing time en
|
||||
.getByText(formatCentsWithOrganizationDefaults(newBillableRate * 100))
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that project member edit modal can be cancelled without sending API request', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const projectName = 'Cancel Test ' + Math.floor(1 + Math.random() * 10000);
|
||||
|
||||
await createProjectWithMemberViaApi(ctx, page, projectName);
|
||||
|
||||
// Open the edit modal
|
||||
await page
|
||||
.getByTestId('project_member_table')
|
||||
.getByRole('row')
|
||||
.first()
|
||||
.getByRole('button')
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: 'Edit Project Member' }).click();
|
||||
|
||||
// Verify the modal is open and shows the member name
|
||||
await expect(page.getByRole('heading', { name: 'Edit Project Member' })).toBeVisible();
|
||||
await expect(page.getByRole('dialog').getByText('John Doe')).toBeVisible();
|
||||
|
||||
// Enter a new billable rate
|
||||
await page.getByLabel('Billable Rate').fill('999');
|
||||
|
||||
// Set up listener to verify no PUT request is sent
|
||||
let putRequestSent = false;
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('/project-members/') && request.method() === 'PUT') {
|
||||
putRequestSent = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Click Cancel
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
// Verify the modal is closed
|
||||
await expect(page.getByRole('heading', { name: 'Edit Project Member' })).not.toBeVisible();
|
||||
|
||||
// Verify no PUT request was sent
|
||||
expect(putRequestSent).toBe(false);
|
||||
});
|
||||
|
||||
test('test that project member update without billable rate change skips confirmation and completes', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const projectName = 'No Change ' + Math.floor(1 + Math.random() * 10000);
|
||||
|
||||
await createProjectWithMemberViaApi(ctx, page, projectName);
|
||||
|
||||
// Open the edit modal
|
||||
await page
|
||||
.getByTestId('project_member_table')
|
||||
.getByRole('row')
|
||||
.first()
|
||||
.getByRole('button')
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: 'Edit Project Member' }).click();
|
||||
|
||||
// Click Update without changing anything - no confirmation modal since rate didn't change
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/project-members/') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
),
|
||||
page.getByRole('button', { name: 'Update Project Member' }).click(),
|
||||
]);
|
||||
|
||||
// Verify the edit modal is closed (confirmation modal was skipped)
|
||||
await expect(page.getByRole('heading', { name: 'Edit Project Member' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('test that billable rate confirmation modal can be cancelled without sending API request', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const projectName = 'Rate Cancel ' + Math.floor(1 + Math.random() * 10000);
|
||||
const newBillableRate = Math.round(Math.random() * 10000);
|
||||
|
||||
await createProjectWithMemberViaApi(ctx, page, projectName);
|
||||
|
||||
// Open the edit modal
|
||||
await page
|
||||
.getByTestId('project_member_table')
|
||||
.getByRole('row')
|
||||
.first()
|
||||
.getByRole('button')
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: 'Edit Project Member' }).click();
|
||||
|
||||
// Change the billable rate
|
||||
await page.getByLabel('Billable Rate').fill(newBillableRate.toString());
|
||||
|
||||
// Set up listener to verify no PUT request is sent
|
||||
let putRequestSent = false;
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('/project-members/') && request.method() === 'PUT') {
|
||||
putRequestSent = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Click Update - this should show the confirmation modal
|
||||
await page.getByRole('button', { name: 'Update Project Member' }).click();
|
||||
|
||||
// Verify the confirmation modal is shown
|
||||
await expect(page.getByText('update all existing time entries')).toBeVisible();
|
||||
|
||||
// Click Cancel to close the confirmation modal without updating
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
// Verify the confirmation modal is closed but edit modal is still open
|
||||
await expect(page.getByText('update all existing time entries')).not.toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Edit Project Member' })).toBeVisible();
|
||||
|
||||
// Close the edit modal
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
// Verify the edit modal is closed
|
||||
await expect(page.getByRole('heading', { name: 'Edit Project Member' })).not.toBeVisible();
|
||||
|
||||
// Verify no PUT request was sent
|
||||
expect(putRequestSent).toBe(false);
|
||||
});
|
||||
|
||||
test('test that clearing billable rate reverts to project default', async ({ page, ctx }) => {
|
||||
const projectName = 'Revert Default ' + Math.floor(1 + Math.random() * 10000);
|
||||
const customRate = Math.round(100 + Math.random() * 10000);
|
||||
|
||||
await createProjectWithMemberViaApi(ctx, page, projectName);
|
||||
|
||||
// Verify the billable rate shows "--" (project default) initially
|
||||
await expect(
|
||||
page.getByTestId('project_member_table').getByRole('row').first().getByText('--')
|
||||
).toBeVisible();
|
||||
|
||||
// Set a custom billable rate
|
||||
await page
|
||||
.getByTestId('project_member_table')
|
||||
.getByRole('row')
|
||||
.first()
|
||||
.getByRole('button')
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: 'Edit Project Member' }).click();
|
||||
await page.getByLabel('Billable Rate').fill(customRate.toString());
|
||||
await page.getByRole('button', { name: 'Update Project Member' }).click();
|
||||
|
||||
// Confirm the billable rate update
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Yes, update existing time' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/project-members/') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
),
|
||||
]);
|
||||
|
||||
// Verify the custom rate is shown in the table (not "--")
|
||||
await expect(
|
||||
page.getByTestId('project_member_table').getByRole('row').first().getByText('--')
|
||||
).not.toBeVisible();
|
||||
|
||||
// Now clear the billable rate to revert to project default
|
||||
await page
|
||||
.getByTestId('project_member_table')
|
||||
.getByRole('row')
|
||||
.first()
|
||||
.getByRole('button')
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: 'Edit Project Member' }).click();
|
||||
|
||||
// Set billable rate to 0 to revert to project default
|
||||
await page.getByLabel('Billable Rate').fill('0');
|
||||
await page.getByRole('button', { name: 'Update Project Member' }).click();
|
||||
|
||||
// Confirm the billable rate update
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Yes, update existing time' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/project-members/') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
),
|
||||
]);
|
||||
|
||||
// Verify the billable rate shows "--" again (project default)
|
||||
await expect(
|
||||
page.getByTestId('project_member_table').getByRole('row').first().getByText('--')
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
+58
-82
@@ -4,6 +4,7 @@ import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import { test } from '../playwright/fixtures';
|
||||
import { formatCentsWithOrganizationDefaults } from './utils/money';
|
||||
import type { CurrencyFormat } from '../resources/js/packages/ui/src/utils/money';
|
||||
import { createProjectViaApi, createTaskViaApi } from './utils/api';
|
||||
|
||||
async function goToProjectsOverview(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
||||
@@ -70,24 +71,13 @@ async function removeStatusFilter(page: Page) {
|
||||
await statusBadge.locator('button').last().click();
|
||||
}
|
||||
|
||||
test('test that archiving and unarchiving projects works', async ({ page }) => {
|
||||
test('test that archiving and unarchiving projects works', async ({ page, ctx }) => {
|
||||
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
await createProjectViaApi(ctx, { name: newProjectName });
|
||||
|
||||
await goToProjectsOverview(page);
|
||||
await clearProjectTableState(page);
|
||||
await page.reload();
|
||||
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
await page.getByLabel('Project Name').fill(newProjectName);
|
||||
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Create Project' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/projects') &&
|
||||
response.request().method() === 'POST' &&
|
||||
response.status() === 201
|
||||
),
|
||||
]);
|
||||
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Archive the project
|
||||
@@ -119,22 +109,12 @@ test('test that archiving and unarchiving projects works', async ({ page }) => {
|
||||
await expect(page.getByText(newProjectName)).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that updating billable rate works with existing time entries', async ({ page }) => {
|
||||
test('test that updating billable rate works with existing time entries', async ({ page, ctx }) => {
|
||||
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
const newBillableRate = Math.round(Math.random() * 10000);
|
||||
await goToProjectsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
await page.getByLabel('Project Name').fill(newProjectName);
|
||||
await createProjectViaApi(ctx, { name: newProjectName });
|
||||
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Create Project' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/projects') &&
|
||||
response.request().method() === 'POST' &&
|
||||
response.status() === 201
|
||||
),
|
||||
]);
|
||||
await goToProjectsOverview(page);
|
||||
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.getByRole('row').first().getByRole('button').click();
|
||||
@@ -200,15 +180,14 @@ test('test that sorting projects by name works', async ({ page }) => {
|
||||
const nameHeader = page.getByText('Name').first();
|
||||
await nameHeader.click();
|
||||
|
||||
// Wait for sort to apply
|
||||
await page.waitForTimeout(100);
|
||||
// Wait for sort indicator to appear
|
||||
await expect(nameHeader.locator('svg')).toBeVisible();
|
||||
|
||||
// Click again to sort descending
|
||||
await nameHeader.click();
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// Verify the sort indicator is showing descending
|
||||
await expect(page.locator('svg').first()).toBeVisible();
|
||||
// Verify the sort indicator is still visible (showing descending)
|
||||
await expect(nameHeader.locator('svg')).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that sorting projects by status works', async ({ page }) => {
|
||||
@@ -223,32 +202,18 @@ test('test that sorting projects by status works', async ({ page }) => {
|
||||
const statusHeader = page.getByText('Status').first();
|
||||
await statusHeader.click();
|
||||
|
||||
// Wait for sort to apply
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// Sort indicator should be visible
|
||||
await expect(statusHeader.locator('svg')).toBeVisible();
|
||||
});
|
||||
|
||||
// Filter tests
|
||||
test('test that filtering projects by status works', async ({ page }) => {
|
||||
test('test that filtering projects by status works', async ({ page, ctx }) => {
|
||||
const newProjectName = 'Filter Test Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
await createProjectViaApi(ctx, { name: newProjectName });
|
||||
|
||||
await goToProjectsOverview(page);
|
||||
await clearProjectTableState(page);
|
||||
await page.reload();
|
||||
|
||||
// Create a new project
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
await page.getByLabel('Project Name').fill(newProjectName);
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Create Project' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/projects') &&
|
||||
response.request().method() === 'POST' &&
|
||||
response.status() === 201
|
||||
),
|
||||
]);
|
||||
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Archive the project
|
||||
@@ -287,9 +252,6 @@ test('test that filter state persists after page reload', async ({ page }) => {
|
||||
// Verify the filter badge is visible
|
||||
await expect(page.getByTestId('status-filter-badge')).toBeVisible();
|
||||
|
||||
// Wait for the state to be saved
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// Reload the page
|
||||
await page.reload();
|
||||
|
||||
@@ -305,11 +267,9 @@ test('test that sort state persists after page reload', async ({ page }) => {
|
||||
// Click on Name header twice to sort descending
|
||||
const nameHeader = page.getByText('Name').first();
|
||||
await nameHeader.click();
|
||||
await expect(nameHeader.locator('svg')).toBeVisible();
|
||||
await nameHeader.click();
|
||||
|
||||
// Wait for the state to be saved
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// Reload the page
|
||||
await page.reload();
|
||||
|
||||
@@ -319,22 +279,13 @@ test('test that sort state persists after page reload', async ({ page }) => {
|
||||
|
||||
test('test that custom billable rate is displayed correctly on project detail page', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const newProjectName = 'Billable Rate Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
const newBillableRate = Math.round(10 + Math.random() * 1000);
|
||||
await goToProjectsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
await page.getByLabel('Project Name').fill(newProjectName);
|
||||
await createProjectViaApi(ctx, { name: newProjectName });
|
||||
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Create Project' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/projects') &&
|
||||
response.request().method() === 'POST' &&
|
||||
response.status() === 201
|
||||
),
|
||||
]);
|
||||
await goToProjectsOverview(page);
|
||||
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Edit the project to set a custom billable rate
|
||||
@@ -451,22 +402,11 @@ test('test that creating a project with estimated time using comma decimal notat
|
||||
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
|
||||
});
|
||||
|
||||
test('test that updating estimated time on existing project works', async ({ page }) => {
|
||||
test('test that updating estimated time on existing project works', async ({ page, ctx }) => {
|
||||
const newProjectName = 'Update Estimated Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
await goToProjectsOverview(page);
|
||||
await createProjectViaApi(ctx, { name: newProjectName });
|
||||
|
||||
// Create a project first
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
await page.getByLabel('Project Name').fill(newProjectName);
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Create Project' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/projects') &&
|
||||
response.request().method() === 'POST' &&
|
||||
response.status() === 201
|
||||
),
|
||||
]);
|
||||
await goToProjectsOverview(page);
|
||||
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Edit the project to add estimated time
|
||||
@@ -525,4 +465,40 @@ test('test that estimated time input displays formatted value after blur', async
|
||||
|
||||
// Edit Project Member Billable Rate
|
||||
|
||||
// Edit Task Name
|
||||
test('test that editing a task name on the project detail page works', async ({ page, ctx }) => {
|
||||
const projectName = 'Task Edit Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
const originalTaskName = 'Original Task ' + Math.floor(1 + Math.random() * 10000);
|
||||
const updatedTaskName = 'Updated Task ' + Math.floor(1 + Math.random() * 10000);
|
||||
const project = await createProjectViaApi(ctx, { name: projectName });
|
||||
await createTaskViaApi(ctx, { name: originalTaskName, project_id: project.id });
|
||||
|
||||
// Navigate to the project detail page
|
||||
await goToProjectsOverview(page);
|
||||
await expect(page.getByText(projectName)).toBeVisible({ timeout: 10000 });
|
||||
await page.getByText(projectName).first().click();
|
||||
await page.waitForURL(/\/projects\/[a-f0-9-]+/);
|
||||
|
||||
// Verify task is visible
|
||||
await expect(page.getByTestId('task_table')).toContainText(originalTaskName);
|
||||
|
||||
// Open edit modal via actions menu
|
||||
const moreButton = page.locator("[aria-label='Actions for Task " + originalTaskName + "']");
|
||||
await moreButton.click();
|
||||
await page.getByTestId('task_edit').click();
|
||||
|
||||
// Update the task name
|
||||
await page.locator('#taskName').fill(updatedTaskName);
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Update Task' }).click(),
|
||||
page.waitForResponse(
|
||||
async (response) =>
|
||||
response.url().includes('/tasks') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
),
|
||||
]);
|
||||
|
||||
// Verify updated name is shown and old name is gone
|
||||
await expect(page.getByTestId('task_table')).toContainText(updatedTaskName);
|
||||
await expect(page.getByTestId('task_table')).not.toContainText(originalTaskName);
|
||||
});
|
||||
|
||||
+276
-155
@@ -1,47 +1,52 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { test } from '../playwright/fixtures';
|
||||
import { goToReportingDetailed, waitForDetailedReportingUpdate } from './utils/reporting';
|
||||
import {
|
||||
goToReportingDetailed,
|
||||
createProject,
|
||||
createClient,
|
||||
createProjectWithClient,
|
||||
createTask,
|
||||
createTimeEntryWithProject,
|
||||
createTimeEntryWithProjectAndTask,
|
||||
createTimeEntryWithTag,
|
||||
createBareTimeEntry,
|
||||
waitForDetailedReportingUpdate,
|
||||
} from './utils/reporting';
|
||||
createProjectViaApi,
|
||||
createClientViaApi,
|
||||
createTaskViaApi,
|
||||
createTimeEntryViaApi,
|
||||
createTimeEntryWithTagViaApi,
|
||||
createBareTimeEntryViaApi,
|
||||
} from './utils/api';
|
||||
|
||||
// Each test registers a new user and creates test data, which needs more time
|
||||
test.describe.configure({ timeout: 60000 });
|
||||
// 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 }) => {
|
||||
test('test that detailed view shows time entries correctly', async ({ page, ctx }) => {
|
||||
const projectName = 'Detailed View Project ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
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 })).toBeVisible();
|
||||
await expect(page.locator('input[name="Duration"]')).toHaveValue('1h 00min');
|
||||
await expect(page.getByText('Entry for ' + projectName, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(projectName, { exact: true }).first()).toBeVisible();
|
||||
await expect(page.locator('input[name="Duration"]').first()).toHaveValue('1h 00min');
|
||||
await expect(page.getByText('Entry for ' + projectName, { exact: true }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that updating duration in detailed view works correctly', async ({ page }) => {
|
||||
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';
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, initialDuration);
|
||||
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);
|
||||
@@ -65,20 +70,31 @@ test('test that updating duration in detailed view works correctly', async ({ pa
|
||||
// Project Filter Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that project multiselect filters work on detailed reporting page', async ({ page }) => {
|
||||
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);
|
||||
|
||||
await createProject(page, project1);
|
||||
await createProject(page, project2);
|
||||
await createTimeEntryWithProject(page, project1, '1h');
|
||||
await createTimeEntryWithProject(page, project2, '2h');
|
||||
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}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${project2}`)).toBeVisible();
|
||||
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();
|
||||
@@ -87,29 +103,40 @@ test('test that project multiselect filters work on detailed reporting page', as
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||
|
||||
// Verify only project1 entry is shown
|
||||
await expect(page.getByText(`Entry for ${project1}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${project2}`)).not.toBeVisible();
|
||||
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 }) => {
|
||||
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);
|
||||
|
||||
await createClient(page, client1);
|
||||
await createProjectWithClient(page, project1, client1);
|
||||
await createProject(page, project2);
|
||||
await createTimeEntryWithProject(page, project1, '1h');
|
||||
await createTimeEntryWithProject(page, project2, '2h');
|
||||
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}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${project2}`)).toBeVisible();
|
||||
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();
|
||||
@@ -118,30 +145,40 @@ test('test that client multiselect filters work on detailed reporting page', asy
|
||||
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}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${project2}`)).not.toBeVisible();
|
||||
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 }) => {
|
||||
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);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTask(page, projectName, task1);
|
||||
await createTask(page, projectName, task2);
|
||||
await createTimeEntryWithProjectAndTask(page, projectName, task1, '1h');
|
||||
await createTimeEntryWithProjectAndTask(page, projectName, task2, '2h');
|
||||
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}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${projectName} - ${task2}`)).toBeVisible();
|
||||
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();
|
||||
@@ -159,26 +196,36 @@ test('test that task multiselect dropdown filters reporting by task', async ({ p
|
||||
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}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${projectName} - ${task2}`)).not.toBeVisible();
|
||||
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 }) => {
|
||||
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);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTask(page, projectName, task1);
|
||||
await createTask(page, projectName, task2);
|
||||
await createTimeEntryWithProjectAndTask(page, projectName, task1, '1h');
|
||||
await createTimeEntryWithProjectAndTask(page, projectName, task2, '2h');
|
||||
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}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${projectName} - ${task2}`)).toBeVisible();
|
||||
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();
|
||||
@@ -191,20 +238,25 @@ test('test that selecting multiple tasks shows correct badge count', async ({ pa
|
||||
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}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${projectName} - ${task2}`)).toBeVisible();
|
||||
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 }) => {
|
||||
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);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTask(page, projectName, task1);
|
||||
await createTimeEntryWithProjectAndTask(page, projectName, task1, '1h');
|
||||
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}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible();
|
||||
|
||||
// Select task
|
||||
await page.getByRole('button', { name: 'Tasks' }).first().click();
|
||||
@@ -227,14 +279,21 @@ test('test that deselecting a task removes the filter', async ({ page }) => {
|
||||
// Member Filter Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that member multiselect filters work on detailed reporting page', async ({ page }) => {
|
||||
test('test that member multiselect filters work on detailed reporting page', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const projectName = 'DetailMemberProj ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
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}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${projectName}`).first()).toBeVisible();
|
||||
|
||||
// Filter by the current member
|
||||
await page.getByRole('button', { name: 'Members' }).first().click();
|
||||
@@ -243,7 +302,7 @@ test('test that member multiselect filters work on detailed reporting page', asy
|
||||
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}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${projectName}`).first()).toBeVisible();
|
||||
|
||||
// Verify badge shows count of 1
|
||||
await expect(
|
||||
@@ -255,17 +314,17 @@ test('test that member multiselect filters work on detailed reporting page', asy
|
||||
// Tag Filter Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that tag filter works on detailed reporting page', async ({ page }) => {
|
||||
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 createTimeEntryWithTag(page, tag1, '1h');
|
||||
await createTimeEntryWithTag(page, tag2, '2h');
|
||||
await createTimeEntryWithTagViaApi(ctx, tag1, '1h');
|
||||
await createTimeEntryWithTagViaApi(ctx, tag2, '2h');
|
||||
|
||||
await goToReportingDetailed(page);
|
||||
|
||||
await expect(page.getByText(`Entry with tag ${tag1}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry with tag ${tag2}`)).toBeVisible();
|
||||
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();
|
||||
@@ -273,22 +332,26 @@ test('test that tag filter works on detailed reporting page', async ({ page }) =
|
||||
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||
|
||||
await expect(page.getByText(`Entry with tag ${tag1}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry with tag ${tag2}`)).not.toBeVisible();
|
||||
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 }) => {
|
||||
test('test that billable filter works on detailed reporting page', async ({ page, ctx }) => {
|
||||
const projectName = 'DetailBillProj ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
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}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${projectName}`).first()).toBeVisible();
|
||||
|
||||
// Filter by billable only
|
||||
await page.getByRole('combobox').filter({ hasText: 'Billable' }).click();
|
||||
@@ -316,21 +379,30 @@ test('test that billable filter works on detailed reporting page', async ({ page
|
||||
// Combined Filter Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that combining project and task filters narrows results', async ({ page }) => {
|
||||
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);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createProject(page, otherProject);
|
||||
await createTask(page, projectName, task1);
|
||||
await createTimeEntryWithProjectAndTask(page, projectName, task1, '1h');
|
||||
await createTimeEntryWithProject(page, otherProject, '2h');
|
||||
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}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${otherProject}`)).toBeVisible();
|
||||
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();
|
||||
@@ -349,27 +421,36 @@ test('test that combining project and task filters narrows results', async ({ pa
|
||||
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}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${otherProject}`)).not.toBeVisible();
|
||||
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);
|
||||
|
||||
await createClient(page, client1);
|
||||
await createProjectWithClient(page, project1, client1);
|
||||
await createProject(page, project2);
|
||||
await createTimeEntryWithProject(page, project1, '1h');
|
||||
await createTimeEntryWithProject(page, project2, '2h');
|
||||
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}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${project2}`)).toBeVisible();
|
||||
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();
|
||||
@@ -382,8 +463,8 @@ test('test that combining client and member filters narrows results on detailed
|
||||
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}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${project2}`)).not.toBeVisible();
|
||||
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(
|
||||
@@ -394,22 +475,26 @@ test('test that combining client and member filters narrows results on detailed
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that combining tag and project filters narrows results', async ({ page }) => {
|
||||
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);
|
||||
|
||||
await createProject(page, project1);
|
||||
const p1 = await createProjectViaApi(ctx, { name: project1 });
|
||||
|
||||
// Create a time entry with a project (no tag)
|
||||
await createTimeEntryWithProject(page, project1, '1h');
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
description: `Entry for ${project1}`,
|
||||
duration: '1h',
|
||||
projectId: p1.id,
|
||||
});
|
||||
|
||||
// Create a time entry with a tag (no specific project)
|
||||
await createTimeEntryWithTag(page, tag1, '2h');
|
||||
await createTimeEntryWithTagViaApi(ctx, tag1, '2h');
|
||||
|
||||
await goToReportingDetailed(page);
|
||||
|
||||
await expect(page.getByText(`Entry for ${project1}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry with tag ${tag1}`)).toBeVisible();
|
||||
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();
|
||||
@@ -417,25 +502,29 @@ test('test that combining tag and project filters narrows results', async ({ pag
|
||||
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}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry with tag ${tag1}`)).not.toBeVisible();
|
||||
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 }) => {
|
||||
test('test that "No Project" filter shows entries without a project', async ({ page, ctx }) => {
|
||||
const project1 = 'NoProj1 ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, project1);
|
||||
await createTimeEntryWithProject(page, project1, '1h');
|
||||
await createBareTimeEntry(page, 'Bare entry no project', '30min');
|
||||
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}`)).toBeVisible();
|
||||
await expect(page.getByText('Bare entry no project')).toBeVisible();
|
||||
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();
|
||||
@@ -449,22 +538,31 @@ test('test that "No Project" filter shows entries without a project', async ({ p
|
||||
).toBeVisible();
|
||||
|
||||
// Only the bare entry (no project) should be visible
|
||||
await expect(page.getByText('Bare entry no project')).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${project1}`)).not.toBeVisible();
|
||||
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 }) => {
|
||||
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);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTask(page, projectName, task1);
|
||||
await createTimeEntryWithProjectAndTask(page, projectName, task1, '1h');
|
||||
await createTimeEntryWithProject(page, projectName, '30min');
|
||||
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}`)).toBeVisible();
|
||||
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"
|
||||
@@ -476,19 +574,19 @@ test('test that "No Task" filter shows entries without a task', async ({ 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}`)).not.toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('test that "No Tag" filter shows entries without tags', async ({ page }) => {
|
||||
test('test that "No Tag" filter shows entries without tags', async ({ page, ctx }) => {
|
||||
const tag1 = 'NoTagFilter1 ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createTimeEntryWithTag(page, tag1, '1h');
|
||||
await createBareTimeEntry(page, 'Entry without any tag', '30min');
|
||||
await createTimeEntryWithTagViaApi(ctx, tag1, '1h');
|
||||
await createBareTimeEntryViaApi(ctx, 'Entry without any tag', '30min');
|
||||
|
||||
await goToReportingDetailed(page);
|
||||
|
||||
await expect(page.getByText(`Entry with tag ${tag1}`)).toBeVisible();
|
||||
await expect(page.getByText('Entry without any tag')).toBeVisible();
|
||||
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();
|
||||
@@ -498,25 +596,36 @@ test('test that "No Tag" filter shows entries without tags', async ({ page }) =>
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Tags' }).getByText('1')).toBeVisible();
|
||||
|
||||
await expect(page.getByText('Entry without any tag')).toBeVisible();
|
||||
await expect(page.getByText(`Entry with tag ${tag1}`)).not.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 }) => {
|
||||
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);
|
||||
|
||||
await createClient(page, client1);
|
||||
await createProjectWithClient(page, projectWithClient, client1);
|
||||
await createProject(page, projectNoClient);
|
||||
await createTimeEntryWithProject(page, projectWithClient, '1h');
|
||||
await createTimeEntryWithProject(page, projectNoClient, '30min');
|
||||
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}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${projectNoClient}`)).toBeVisible();
|
||||
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();
|
||||
@@ -528,21 +637,25 @@ test('test that "No Client" filter shows entries without a client', async ({ pag
|
||||
page.getByRole('button', { name: 'Clients' }).first().getByText('1')
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByText(`Entry for ${projectNoClient}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${projectWithClient}`)).not.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 }) => {
|
||||
test('test that combining "No Project" with a project ID shows both', async ({ page, ctx }) => {
|
||||
const project1 = 'CombNoProj ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, project1);
|
||||
await createTimeEntryWithProject(page, project1, '1h');
|
||||
await createBareTimeEntry(page, 'Bare combined entry', '30min');
|
||||
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}`)).toBeVisible();
|
||||
await expect(page.getByText('Bare combined entry')).toBeVisible();
|
||||
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();
|
||||
@@ -557,25 +670,33 @@ test('test that combining "No Project" with a project ID shows both', async ({ p
|
||||
).toBeVisible();
|
||||
|
||||
// Both entries should be visible
|
||||
await expect(page.getByText(`Entry for ${project1}`)).toBeVisible();
|
||||
await expect(page.getByText('Bare combined entry')).toBeVisible();
|
||||
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 }) => {
|
||||
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);
|
||||
|
||||
await createProject(page, project1);
|
||||
await createProject(page, project2);
|
||||
await createTimeEntryWithProject(page, project1, '1h');
|
||||
await createTimeEntryWithProject(page, project2, '2h');
|
||||
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}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();
|
||||
|
||||
// Open project dropdown
|
||||
await page.getByRole('button', { name: 'Projects' }).first().click();
|
||||
|
||||
+356
-236
@@ -1,77 +1,18 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import { test } from '../playwright/fixtures';
|
||||
import { goToReporting, waitForReportingUpdate } from './utils/reporting';
|
||||
import {
|
||||
goToReporting,
|
||||
createProject,
|
||||
createClient,
|
||||
createProjectWithClient,
|
||||
createTask,
|
||||
createTimeEntryWithProject,
|
||||
createTimeEntryWithProjectAndTask,
|
||||
createTimeEntryWithTag,
|
||||
createTimeEntryWithBillableStatus,
|
||||
waitForReportingUpdate,
|
||||
} from './utils/reporting';
|
||||
createProjectViaApi,
|
||||
createClientViaApi,
|
||||
createTaskViaApi,
|
||||
createTimeEntryViaApi,
|
||||
createTimeEntryWithTagViaApi,
|
||||
createTimeEntryWithBillableStatusViaApi,
|
||||
} from './utils/api';
|
||||
|
||||
// Each test registers a new user and creates test data, which needs more time
|
||||
test.describe.configure({ timeout: 60000 });
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// No-op Dropdown Close Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that opening and closing a filter dropdown without changes does not trigger an API request', async ({
|
||||
page,
|
||||
}) => {
|
||||
const projectName = 'NoOpDropdown ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
|
||||
// Wait for initial reporting data to fully load and all network activity to settle
|
||||
await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Set up a request counter for aggregate API calls
|
||||
let aggregateRequestCount = 0;
|
||||
page.on('response', (response) => {
|
||||
if (
|
||||
response.url().includes('/time-entries/aggregate') &&
|
||||
!response.url().includes('/export') &&
|
||||
response.status() === 200
|
||||
) {
|
||||
aggregateRequestCount++;
|
||||
}
|
||||
});
|
||||
|
||||
// Open project dropdown, change nothing, close it
|
||||
await page.getByRole('button', { name: 'Projects' }).first().click();
|
||||
await expect(page.getByPlaceholder('Search for a Project...')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Open member dropdown, change nothing, close it
|
||||
await page.getByRole('button', { name: 'Members' }).first().click();
|
||||
await expect(page.getByPlaceholder('Search for a Member...')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Open client dropdown, change nothing, close it
|
||||
await page.getByRole('button', { name: 'Clients' }).first().click();
|
||||
await expect(page.getByPlaceholder('Search for a Client...')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Wait for all network activity to settle before asserting no requests were made
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// No aggregate API requests should have been made
|
||||
expect(aggregateRequestCount).toBe(0);
|
||||
|
||||
// Verify the report data is still intact (no flash/reload)
|
||||
await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();
|
||||
});
|
||||
// Each test registers a new user and creates test data via API
|
||||
test.describe.configure({ timeout: 30000 });
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Project Multiselect Dropdown Tests
|
||||
@@ -79,14 +20,23 @@ test('test that opening and closing a filter dropdown without changes does not t
|
||||
|
||||
test('test that project multiselect dropdown shows projects and filters reporting', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const project1 = 'ProjFilter1 ' + Math.floor(Math.random() * 10000);
|
||||
const project2 = 'ProjFilter2 ' + Math.floor(Math.random() * 10000);
|
||||
const project1Name = 'ProjFilter1 ' + Math.floor(Math.random() * 10000);
|
||||
const project2Name = 'ProjFilter2 ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, project1);
|
||||
await createProject(page, project2);
|
||||
await createTimeEntryWithProject(page, project1, '1h');
|
||||
await createTimeEntryWithProject(page, project2, '2h');
|
||||
const project1 = await createProjectViaApi(ctx, { name: project1Name });
|
||||
const project2 = await createProjectViaApi(ctx, { name: project2Name });
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
description: `Entry for ${project1Name}`,
|
||||
duration: '1h',
|
||||
projectId: project1.id,
|
||||
});
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
description: `Entry for ${project2Name}`,
|
||||
duration: '2h',
|
||||
projectId: project2.id,
|
||||
});
|
||||
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
@@ -95,14 +45,15 @@ test('test that project multiselect dropdown shows projects and filters reportin
|
||||
await page.getByRole('button', { name: 'Projects' }).first().click();
|
||||
|
||||
// Verify both projects appear as options
|
||||
await expect(page.getByRole('option').filter({ hasText: project1 })).toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: project2 })).toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: project1Name })).toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: project2Name })).toBeVisible();
|
||||
|
||||
// Select project1
|
||||
await page.getByRole('option').filter({ hasText: project1 }).click();
|
||||
|
||||
// Close dropdown and wait for report update
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]);
|
||||
// Select project1 and wait for report update
|
||||
await Promise.all([
|
||||
page.getByRole('option').filter({ hasText: project1Name }).click(),
|
||||
waitForReportingUpdate(page),
|
||||
]);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Verify filter badge shows count of 1
|
||||
await expect(
|
||||
@@ -110,17 +61,21 @@ test('test that project multiselect dropdown shows projects and filters reportin
|
||||
).toBeVisible();
|
||||
|
||||
// Verify only project1 data is shown
|
||||
await expect(page.getByTestId('reporting_view').getByText(project1)).toBeVisible();
|
||||
await expect(page.getByTestId('reporting_view').getByText(project2)).not.toBeVisible();
|
||||
await expect(page.getByTestId('reporting_view').getByText(project1Name)).toBeVisible();
|
||||
await expect(page.getByTestId('reporting_view').getByText(project2Name)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('test that project multiselect search filters the option list', async ({ page }) => {
|
||||
const project1 = 'SearchableAlpha ' + Math.floor(Math.random() * 10000);
|
||||
const project2 = 'SearchableBeta ' + Math.floor(Math.random() * 10000);
|
||||
test('test that project multiselect search filters the option list', async ({ page, ctx }) => {
|
||||
const project1Name = 'SearchableAlpha ' + Math.floor(Math.random() * 10000);
|
||||
const project2Name = 'SearchableBeta ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, project1);
|
||||
await createProject(page, project2);
|
||||
await createTimeEntryWithProject(page, project1, '1h');
|
||||
const project1 = await createProjectViaApi(ctx, { name: project1Name });
|
||||
await createProjectViaApi(ctx, { name: project2Name });
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
description: `Entry for ${project1Name}`,
|
||||
duration: '1h',
|
||||
projectId: project1.id,
|
||||
});
|
||||
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
@@ -132,30 +87,40 @@ test('test that project multiselect search filters the option list', async ({ pa
|
||||
await page.getByPlaceholder('Search for a Project...').fill('Alpha');
|
||||
|
||||
// Verify only matching project is visible
|
||||
await expect(page.getByRole('option').filter({ hasText: project1 })).toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: project2 })).not.toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: project1Name })).toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: project2Name })).not.toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('test that selecting multiple projects shows correct badge count', async ({ page }) => {
|
||||
const project1 = 'MultiProj1 ' + Math.floor(Math.random() * 10000);
|
||||
const project2 = 'MultiProj2 ' + Math.floor(Math.random() * 10000);
|
||||
test('test that selecting multiple projects shows correct badge count', async ({ page, ctx }) => {
|
||||
const project1Name = 'MultiProj1 ' + Math.floor(Math.random() * 10000);
|
||||
const project2Name = 'MultiProj2 ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, project1);
|
||||
await createProject(page, project2);
|
||||
await createTimeEntryWithProject(page, project1, '1h');
|
||||
await createTimeEntryWithProject(page, project2, '2h');
|
||||
const project1 = await createProjectViaApi(ctx, { name: project1Name });
|
||||
const project2 = await createProjectViaApi(ctx, { name: project2Name });
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
description: `Entry for ${project1Name}`,
|
||||
duration: '1h',
|
||||
projectId: project1.id,
|
||||
});
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
description: `Entry for ${project2Name}`,
|
||||
duration: '2h',
|
||||
projectId: project2.id,
|
||||
});
|
||||
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
|
||||
// Open project dropdown and select both
|
||||
await page.getByRole('button', { name: 'Projects' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: project1 }).click();
|
||||
await page.getByRole('option').filter({ hasText: project2 }).click();
|
||||
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]);
|
||||
await page.getByRole('option').filter({ hasText: project1Name }).click();
|
||||
await Promise.all([
|
||||
page.getByRole('option').filter({ hasText: project2Name }).click(),
|
||||
waitForReportingUpdate(page),
|
||||
]);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Verify filter badge shows count of 2
|
||||
await expect(
|
||||
@@ -163,23 +128,30 @@ test('test that selecting multiple projects shows correct badge count', async ({
|
||||
).toBeVisible();
|
||||
|
||||
// Verify both projects are shown in the report
|
||||
await expect(page.getByTestId('reporting_view').getByText(project1)).toBeVisible();
|
||||
await expect(page.getByTestId('reporting_view').getByText(project2)).toBeVisible();
|
||||
await expect(page.getByTestId('reporting_view').getByText(project1Name)).toBeVisible();
|
||||
await expect(page.getByTestId('reporting_view').getByText(project2Name)).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that deselecting a project removes the filter', async ({ page }) => {
|
||||
const project1 = 'DeselectProj ' + Math.floor(Math.random() * 10000);
|
||||
test('test that deselecting a project removes the filter', async ({ page, ctx }) => {
|
||||
const project1Name = 'DeselectProj ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, project1);
|
||||
await createTimeEntryWithProject(page, project1, '1h');
|
||||
const project1 = await createProjectViaApi(ctx, { name: project1Name });
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
description: `Entry for ${project1Name}`,
|
||||
duration: '1h',
|
||||
projectId: project1.id,
|
||||
});
|
||||
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
|
||||
// Select project
|
||||
await page.getByRole('button', { name: 'Projects' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: project1 }).click();
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]);
|
||||
await Promise.all([
|
||||
page.getByRole('option').filter({ hasText: project1Name }).click(),
|
||||
waitForReportingUpdate(page),
|
||||
]);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Verify badge count is 1
|
||||
await expect(
|
||||
@@ -188,7 +160,10 @@ test('test that deselecting a project removes the filter', async ({ page }) => {
|
||||
|
||||
// Deselect project
|
||||
await page.getByRole('button', { name: 'Projects' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: project1 }).click();
|
||||
await Promise.all([
|
||||
page.getByRole('option').filter({ hasText: project1Name }).click(),
|
||||
waitForReportingUpdate(page),
|
||||
]);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Verify badge count is gone (no count displayed when 0)
|
||||
@@ -201,18 +176,32 @@ test('test that deselecting a project removes the filter', async ({ page }) => {
|
||||
// Client Multiselect Dropdown Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that client multiselect dropdown filters reporting by client', async ({ page }) => {
|
||||
const client1 = 'ClientFilter1 ' + Math.floor(Math.random() * 10000);
|
||||
const client2 = 'ClientFilter2 ' + Math.floor(Math.random() * 10000);
|
||||
const project1 = 'ClientProj1 ' + Math.floor(Math.random() * 10000);
|
||||
const project2 = 'ClientProj2 ' + Math.floor(Math.random() * 10000);
|
||||
test('test that client multiselect dropdown filters reporting by client', async ({ page, ctx }) => {
|
||||
const client1Name = 'ClientFilter1 ' + Math.floor(Math.random() * 10000);
|
||||
const client2Name = 'ClientFilter2 ' + Math.floor(Math.random() * 10000);
|
||||
const project1Name = 'ClientProj1 ' + Math.floor(Math.random() * 10000);
|
||||
const project2Name = 'ClientProj2 ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createClient(page, client1);
|
||||
await createClient(page, client2);
|
||||
await createProjectWithClient(page, project1, client1);
|
||||
await createProjectWithClient(page, project2, client2);
|
||||
await createTimeEntryWithProject(page, project1, '1h');
|
||||
await createTimeEntryWithProject(page, project2, '2h');
|
||||
const client1 = await createClientViaApi(ctx, { name: client1Name });
|
||||
const client2 = await createClientViaApi(ctx, { name: client2Name });
|
||||
const project1 = await createProjectViaApi(ctx, {
|
||||
name: project1Name,
|
||||
client_id: client1.id,
|
||||
});
|
||||
const project2 = await createProjectViaApi(ctx, {
|
||||
name: project2Name,
|
||||
client_id: client2.id,
|
||||
});
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
description: `Entry for ${project1Name}`,
|
||||
duration: '1h',
|
||||
projectId: project1.id,
|
||||
});
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
description: `Entry for ${project2Name}`,
|
||||
duration: '2h',
|
||||
projectId: project2.id,
|
||||
});
|
||||
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
@@ -221,13 +210,15 @@ test('test that client multiselect dropdown filters reporting by client', async
|
||||
await page.getByRole('button', { name: 'Clients' }).first().click();
|
||||
|
||||
// Verify both clients appear
|
||||
await expect(page.getByRole('option').filter({ hasText: client1 })).toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: client2 })).toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: client1Name })).toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: client2Name })).toBeVisible();
|
||||
|
||||
// Select client1
|
||||
await page.getByRole('option').filter({ hasText: client1 }).click();
|
||||
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]);
|
||||
await Promise.all([
|
||||
page.getByRole('option').filter({ hasText: client1Name }).click(),
|
||||
waitForReportingUpdate(page),
|
||||
]);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Verify badge shows count of 1
|
||||
await expect(
|
||||
@@ -235,16 +226,16 @@ test('test that client multiselect dropdown filters reporting by client', async
|
||||
).toBeVisible();
|
||||
|
||||
// Verify only project1 (belonging to client1) is shown
|
||||
await expect(page.getByTestId('reporting_view').getByText(project1)).toBeVisible();
|
||||
await expect(page.getByTestId('reporting_view').getByText(project2)).not.toBeVisible();
|
||||
await expect(page.getByTestId('reporting_view').getByText(project1Name)).toBeVisible();
|
||||
await expect(page.getByTestId('reporting_view').getByText(project2Name)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('test that client multiselect search filters the option list', async ({ page }) => {
|
||||
const client1 = 'ClientSearchAlpha ' + Math.floor(Math.random() * 10000);
|
||||
const client2 = 'ClientSearchBeta ' + Math.floor(Math.random() * 10000);
|
||||
test('test that client multiselect search filters the option list', async ({ page, ctx }) => {
|
||||
const client1Name = 'ClientSearchAlpha ' + Math.floor(Math.random() * 10000);
|
||||
const client2Name = 'ClientSearchBeta ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createClient(page, client1);
|
||||
await createClient(page, client2);
|
||||
await createClientViaApi(ctx, { name: client1Name });
|
||||
await createClientViaApi(ctx, { name: client2Name });
|
||||
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
@@ -254,27 +245,37 @@ test('test that client multiselect search filters the option list', async ({ pag
|
||||
// Search for "Alpha"
|
||||
await page.getByPlaceholder('Search for a Client...').fill('Alpha');
|
||||
|
||||
await expect(page.getByRole('option').filter({ hasText: client1 })).toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: client2 })).not.toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: client1Name })).toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: client2Name })).not.toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('test that deselecting a client removes the filter', async ({ page }) => {
|
||||
const client1 = 'ClientDeselect ' + Math.floor(Math.random() * 10000);
|
||||
const project1 = 'ClientDeselectProj ' + Math.floor(Math.random() * 10000);
|
||||
test('test that deselecting a client removes the filter', async ({ page, ctx }) => {
|
||||
const client1Name = 'ClientDeselect ' + Math.floor(Math.random() * 10000);
|
||||
const project1Name = 'ClientDeselectProj ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createClient(page, client1);
|
||||
await createProjectWithClient(page, project1, client1);
|
||||
await createTimeEntryWithProject(page, project1, '1h');
|
||||
const client1 = await createClientViaApi(ctx, { name: client1Name });
|
||||
const project1 = await createProjectViaApi(ctx, {
|
||||
name: project1Name,
|
||||
client_id: client1.id,
|
||||
});
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
description: `Entry for ${project1Name}`,
|
||||
duration: '1h',
|
||||
projectId: project1.id,
|
||||
});
|
||||
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
|
||||
// Select client
|
||||
await page.getByRole('button', { name: 'Clients' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: client1 }).click();
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]);
|
||||
await Promise.all([
|
||||
page.getByRole('option').filter({ hasText: client1Name }).click(),
|
||||
waitForReportingUpdate(page),
|
||||
]);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Clients' }).first().getByText('1')
|
||||
@@ -282,7 +283,10 @@ test('test that deselecting a client removes the filter', async ({ page }) => {
|
||||
|
||||
// Deselect client
|
||||
await page.getByRole('button', { name: 'Clients' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: client1 }).click();
|
||||
await Promise.all([
|
||||
page.getByRole('option').filter({ hasText: client1Name }).click(),
|
||||
waitForReportingUpdate(page),
|
||||
]);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(
|
||||
@@ -294,39 +298,55 @@ test('test that deselecting a client removes the filter', async ({ page }) => {
|
||||
// Task Multiselect Dropdown Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that task filtering works in reporting', async ({ page }) => {
|
||||
test('test that task filtering works in reporting', async ({ page, ctx }) => {
|
||||
const projectName = 'Task Filter Proj ' + Math.floor(Math.random() * 10000);
|
||||
const task1 = 'Task Filter A ' + Math.floor(Math.random() * 10000);
|
||||
const task2 = 'Task Filter B ' + Math.floor(Math.random() * 10000);
|
||||
const task1Name = 'Task Filter A ' + Math.floor(Math.random() * 10000);
|
||||
const task2Name = 'Task Filter B ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '30min');
|
||||
await createTask(page, projectName, task1);
|
||||
await createTask(page, projectName, task2);
|
||||
await createTimeEntryWithProjectAndTask(page, projectName, task1, '1h');
|
||||
await createTimeEntryWithProjectAndTask(page, projectName, task2, '2h');
|
||||
const project = await createProjectViaApi(ctx, { name: projectName });
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
description: `Entry for ${projectName}`,
|
||||
duration: '30min',
|
||||
projectId: project.id,
|
||||
});
|
||||
const task1 = await createTaskViaApi(ctx, { name: task1Name, project_id: project.id });
|
||||
const task2 = await createTaskViaApi(ctx, { name: task2Name, project_id: project.id });
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
description: `Entry for ${projectName} - ${task1Name}`,
|
||||
duration: '1h',
|
||||
projectId: project.id,
|
||||
taskId: task1.id,
|
||||
});
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
description: `Entry for ${projectName} - ${task2Name}`,
|
||||
duration: '2h',
|
||||
projectId: project.id,
|
||||
taskId: task2.id,
|
||||
});
|
||||
|
||||
// Go to reporting and group by task to see individual tasks
|
||||
await goToReporting(page);
|
||||
|
||||
// Filter by task1
|
||||
await page.getByRole('button', { name: 'Tasks' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: task1 }).click();
|
||||
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]);
|
||||
await Promise.all([
|
||||
page.getByRole('option').filter({ hasText: task1Name }).click(),
|
||||
waitForReportingUpdate(page),
|
||||
]);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Verify the report only shows 1h (task1's duration)
|
||||
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that task multiselect search filters the option list', async ({ page }) => {
|
||||
test('test that task multiselect search filters the option list', async ({ page, ctx }) => {
|
||||
const projectName = 'TaskSearchProj ' + Math.floor(Math.random() * 10000);
|
||||
const task1 = 'TaskSearchAlpha ' + Math.floor(Math.random() * 10000);
|
||||
const task2 = 'TaskSearchBeta ' + Math.floor(Math.random() * 10000);
|
||||
const task1Name = 'TaskSearchAlpha ' + Math.floor(Math.random() * 10000);
|
||||
const task2Name = 'TaskSearchBeta ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTask(page, projectName, task1);
|
||||
await createTask(page, projectName, task2);
|
||||
const project = await createProjectViaApi(ctx, { name: projectName });
|
||||
await createTaskViaApi(ctx, { name: task1Name, project_id: project.id });
|
||||
await createTaskViaApi(ctx, { name: task2Name, project_id: project.id });
|
||||
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
@@ -335,8 +355,8 @@ test('test that task multiselect search filters the option list', async ({ page
|
||||
|
||||
await page.getByPlaceholder('Search for a Task...').fill('Alpha');
|
||||
|
||||
await expect(page.getByRole('option').filter({ hasText: task1 })).toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: task2 })).not.toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: task1Name })).toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: task2Name })).not.toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
@@ -347,11 +367,16 @@ test('test that task multiselect search filters the option list', async ({ page
|
||||
|
||||
test('test that member multiselect dropdown shows current member and filters reporting', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const projectName = 'MemberFilterProj ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
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.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
@@ -363,9 +388,11 @@ test('test that member multiselect dropdown shows current member and filters rep
|
||||
await expect(page.getByRole('option').filter({ hasText: 'John Doe' })).toBeVisible();
|
||||
|
||||
// Select the member
|
||||
await page.getByRole('option').filter({ hasText: 'John Doe' }).click();
|
||||
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]);
|
||||
await Promise.all([
|
||||
page.getByRole('option').filter({ hasText: 'John Doe' }).click(),
|
||||
waitForReportingUpdate(page),
|
||||
]);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Verify badge shows count of 1
|
||||
await expect(
|
||||
@@ -393,19 +420,26 @@ test('test that member multiselect search filters the option list', async ({ pag
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('test that deselecting a member removes the filter', async ({ page }) => {
|
||||
test('test that deselecting a member removes the filter', async ({ page, ctx }) => {
|
||||
const projectName = 'MemberDeselectProj ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
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.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
|
||||
// Select 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'), waitForReportingUpdate(page)]);
|
||||
await Promise.all([
|
||||
page.getByRole('option').filter({ hasText: 'John Doe' }).click(),
|
||||
waitForReportingUpdate(page),
|
||||
]);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Members' }).first().getByText('1')
|
||||
@@ -413,7 +447,10 @@ test('test that deselecting a member removes the filter', async ({ page }) => {
|
||||
|
||||
// Deselect member
|
||||
await page.getByRole('button', { name: 'Members' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: 'John Doe' }).click();
|
||||
await Promise.all([
|
||||
page.getByRole('option').filter({ hasText: 'John Doe' }).click(),
|
||||
waitForReportingUpdate(page),
|
||||
]);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Verify badge count is gone
|
||||
@@ -426,33 +463,32 @@ test('test that deselecting a member removes the filter', async ({ page }) => {
|
||||
// Tag Dropdown Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that tag filtering works in reporting', async ({ page }) => {
|
||||
const tag1 = 'Test Tag 1 ' + Math.floor(Math.random() * 10000);
|
||||
const tag2 = 'Test Tag 2 ' + Math.floor(Math.random() * 10000);
|
||||
test('test that tag filtering works in reporting', async ({ page, ctx }) => {
|
||||
const tag1Name = 'Test Tag 1 ' + Math.floor(Math.random() * 10000);
|
||||
const tag2Name = 'Test Tag 2 ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create time entries with different tags
|
||||
await createTimeEntryWithTag(page, tag1, '1h');
|
||||
await createTimeEntryWithTag(page, tag2, '2h');
|
||||
await createTimeEntryWithTagViaApi(ctx, tag1Name, '1h');
|
||||
await createTimeEntryWithTagViaApi(ctx, tag2Name, '2h');
|
||||
|
||||
// Go to reporting and filter by tag1
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Tags' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
await page.getByText(tag1).click();
|
||||
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]);
|
||||
await Promise.all([page.getByText(tag1Name).click(), waitForReportingUpdate(page)]);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Verify only time entries with tag1 are shown
|
||||
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that tag dropdown search filters the option list', async ({ page }) => {
|
||||
const tag1 = 'TagSearchAlpha ' + Math.floor(Math.random() * 10000);
|
||||
const tag2 = 'TagSearchBeta ' + Math.floor(Math.random() * 10000);
|
||||
test('test that tag dropdown search filters the option list', async ({ page, ctx }) => {
|
||||
const tag1Name = 'TagSearchAlpha ' + Math.floor(Math.random() * 10000);
|
||||
const tag2Name = 'TagSearchBeta ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createTimeEntryWithTag(page, tag1, '1h');
|
||||
await createTimeEntryWithTag(page, tag2, '2h');
|
||||
await createTimeEntryWithTagViaApi(ctx, tag1Name, '1h');
|
||||
await createTimeEntryWithTagViaApi(ctx, tag2Name, '2h');
|
||||
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
@@ -461,62 +497,74 @@ test('test that tag dropdown search filters the option list', async ({ page }) =
|
||||
|
||||
await page.getByPlaceholder('Search for a Tag...').fill('Alpha');
|
||||
|
||||
await expect(page.getByRole('option').filter({ hasText: tag1 })).toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: tag2 })).not.toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: tag1Name })).toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: tag2Name })).not.toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('test that selecting multiple tags shows correct badge count', async ({ page }) => {
|
||||
const tag1 = 'MultiTag1 ' + Math.floor(Math.random() * 10000);
|
||||
const tag2 = 'MultiTag2 ' + Math.floor(Math.random() * 10000);
|
||||
test('test that selecting multiple tags shows correct badge count', async ({ page, ctx }) => {
|
||||
const tag1Name = 'MultiTag1 ' + Math.floor(Math.random() * 10000);
|
||||
const tag2Name = 'MultiTag2 ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createTimeEntryWithTag(page, tag1, '1h');
|
||||
await createTimeEntryWithTag(page, tag2, '2h');
|
||||
await createTimeEntryWithTagViaApi(ctx, tag1Name, '1h');
|
||||
await createTimeEntryWithTagViaApi(ctx, tag2Name, '2h');
|
||||
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
|
||||
// Select both tags
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
await page.getByRole('option').filter({ hasText: tag1 }).click();
|
||||
await page.getByRole('option').filter({ hasText: tag2 }).click();
|
||||
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]);
|
||||
await page.getByRole('option').filter({ hasText: tag1Name }).click();
|
||||
await Promise.all([
|
||||
page.getByRole('option').filter({ hasText: tag2Name }).click(),
|
||||
waitForReportingUpdate(page),
|
||||
]);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Verify badge shows count of 2
|
||||
await expect(page.getByRole('button', { name: 'Tags' }).getByText('2')).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that deselecting a tag removes the filter', async ({ page }) => {
|
||||
const tag1 = 'TagDeselect ' + Math.floor(Math.random() * 10000);
|
||||
test('test that deselecting a tag removes the filter', async ({ page, ctx }) => {
|
||||
const tag1Name = 'TagDeselect ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createTimeEntryWithTag(page, tag1, '1h');
|
||||
await createTimeEntryWithTagViaApi(ctx, tag1Name, '1h');
|
||||
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
|
||||
// Select tag
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
await page.getByRole('option').filter({ hasText: tag1 }).click();
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]);
|
||||
await Promise.all([
|
||||
page.getByRole('option').filter({ hasText: tag1Name }).click(),
|
||||
waitForReportingUpdate(page),
|
||||
]);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Tags' }).getByText('1')).toBeVisible();
|
||||
|
||||
// Deselect tag
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
await page.getByRole('option').filter({ hasText: tag1 }).click();
|
||||
await Promise.all([
|
||||
page.getByRole('option').filter({ hasText: tag1Name }).click(),
|
||||
waitForReportingUpdate(page),
|
||||
]);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Tags' }).getByText(/^\d+$/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('test that creating a tag inline from the reporting filter works', async ({ page }) => {
|
||||
test('test that creating a tag inline from the reporting filter works', async ({ page, ctx }) => {
|
||||
const projectName = 'TagCreateProj ' + Math.floor(Math.random() * 10000);
|
||||
const newTag = 'InlineTag ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
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.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
@@ -541,10 +589,10 @@ test('test that creating a tag inline from the reporting filter works', async ({
|
||||
// Billable Select Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that billable status filtering works in reporting', async ({ page }) => {
|
||||
test('test that billable status filtering works in reporting', async ({ page, ctx }) => {
|
||||
// Create billable and non-billable time entries
|
||||
await createTimeEntryWithBillableStatus(page, true, '1h');
|
||||
await createTimeEntryWithBillableStatus(page, false, '2h');
|
||||
await createTimeEntryWithBillableStatusViaApi(ctx, true, '1h');
|
||||
await createTimeEntryWithBillableStatusViaApi(ctx, false, '2h');
|
||||
|
||||
// Go to reporting and filter by billable
|
||||
await goToReporting(page);
|
||||
@@ -591,11 +639,15 @@ test('test that billable filter can switch between all three states', async ({ p
|
||||
// Rounding Controls Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that rounding can be enabled', async ({ page }) => {
|
||||
test('test that rounding can be enabled', async ({ page, ctx }) => {
|
||||
const projectName = 'RoundingProj ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h 7min');
|
||||
const project = await createProjectViaApi(ctx, { name: projectName });
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
description: `Entry for ${projectName}`,
|
||||
duration: '1h 7min',
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
@@ -621,10 +673,15 @@ test('test that rounding can be enabled', async ({ page }) => {
|
||||
// Export Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that export dropdown shows all format options', async ({ page }) => {
|
||||
test('test that export dropdown shows all format options', async ({ page, ctx }) => {
|
||||
const projectName = 'Export Test ' + Math.floor(Math.random() * 10000);
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
|
||||
const project = await createProjectViaApi(ctx, { name: projectName });
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
description: `Entry for ${projectName}`,
|
||||
duration: '1h',
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
// Go to reporting page
|
||||
await goToReporting(page);
|
||||
@@ -640,10 +697,15 @@ test('test that export dropdown shows all format options', async ({ page }) => {
|
||||
await expect(page.getByRole('menuitem', { name: /Export as ODS/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that CSV export triggers download', async ({ page }) => {
|
||||
test('test that CSV export triggers download', async ({ page, ctx }) => {
|
||||
const projectName = 'CSV Export ' + Math.floor(Math.random() * 10000);
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
|
||||
const project = await createProjectViaApi(ctx, { name: projectName });
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
description: `Entry for ${projectName}`,
|
||||
duration: '1h',
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
// Go to reporting page
|
||||
await goToReporting(page);
|
||||
@@ -666,22 +728,21 @@ test('test that CSV export triggers download', async ({ page }) => {
|
||||
|
||||
// Verify the export success modal appeared
|
||||
await expect(page.getByText('Export Successful!')).toBeVisible();
|
||||
|
||||
// Verify the download URL is accessible and returns CSV content
|
||||
const downloadResponse = await page.request.get(responseBody.download_url);
|
||||
expect(downloadResponse.ok()).toBeTruthy();
|
||||
const contentType = downloadResponse.headers()['content-type'];
|
||||
expect(contentType).toContain('csv');
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Group By Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that group by select changes report grouping', async ({ page }) => {
|
||||
test('test that group by select changes report grouping', async ({ page, ctx }) => {
|
||||
const projectName = 'GroupBy Test ' + Math.floor(Math.random() * 10000);
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
|
||||
const project = await createProjectViaApi(ctx, { name: projectName });
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
description: `Entry for ${projectName}`,
|
||||
duration: '1h',
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
// Go to reporting page
|
||||
await goToReporting(page);
|
||||
@@ -693,12 +754,12 @@ test('test that group by select changes report grouping', async ({ page }) => {
|
||||
// Click the first group by select to change grouping
|
||||
await groupBySelects.filter({ hasText: 'Project' }).first().click();
|
||||
|
||||
// Select "Members" option and wait for the table query to update (has sub_group param)
|
||||
// Select "Members" option and wait for the table query to update with group=user
|
||||
const [aggregateResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') &&
|
||||
response.url().includes('sub_group') &&
|
||||
response.url().includes('group=user') &&
|
||||
response.status() === 200
|
||||
),
|
||||
page.getByRole('option', { name: 'Members' }).click(),
|
||||
@@ -714,10 +775,16 @@ test('test that group by select changes report grouping', async ({ page }) => {
|
||||
|
||||
test('test that setting group by to current sub group triggers sub group fallback', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const projectName = 'Fallback Test ' + Math.floor(Math.random() * 10000);
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
|
||||
const project = await createProjectViaApi(ctx, { name: projectName });
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
description: `Entry for ${projectName}`,
|
||||
duration: '1h',
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
// Go to reporting page
|
||||
await goToReporting(page);
|
||||
@@ -734,7 +801,7 @@ test('test that setting group by to current sub group triggers sub group fallbac
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') &&
|
||||
response.url().includes('sub_group') &&
|
||||
response.url().includes('group=task') &&
|
||||
response.status() === 200
|
||||
),
|
||||
page.getByRole('option', { name: 'Tasks' }).click(),
|
||||
@@ -751,3 +818,56 @@ test('test that setting group by to current sub group triggers sub group fallbac
|
||||
// The sub group should have fallen back to a different value (not "Tasks")
|
||||
await expect(groupBySelects.filter({ hasText: 'Members' }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Export Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that CSV export can be triggered from the reporting page', async ({ page, ctx }) => {
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
description: 'CSV export test',
|
||||
duration: '1h',
|
||||
});
|
||||
|
||||
await goToReporting(page);
|
||||
await waitForReportingUpdate(page);
|
||||
|
||||
// Open export dropdown
|
||||
await page.getByRole('button', { name: 'Export' }).click();
|
||||
|
||||
// Click CSV export and wait for the API response
|
||||
const [exportResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate/export') &&
|
||||
response.status() === 200
|
||||
),
|
||||
page.getByRole('menuitem', { name: 'Export as CSV' }).click(),
|
||||
]);
|
||||
|
||||
// Verify the API returned a download URL
|
||||
const responseBody = await exportResponse.json();
|
||||
expect(responseBody.download_url).toBeTruthy();
|
||||
|
||||
// Verify the export success modal appeared
|
||||
await expect(page.getByText('Export Successful!')).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that export dropdown shows all export options', async ({ page, ctx }) => {
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
description: 'Export options test',
|
||||
duration: '1h',
|
||||
});
|
||||
|
||||
await goToReporting(page);
|
||||
await waitForReportingUpdate(page);
|
||||
|
||||
// Open export dropdown
|
||||
await page.getByRole('button', { name: 'Export' }).click();
|
||||
|
||||
// Verify all export options are visible
|
||||
await expect(page.getByRole('menuitem', { name: 'Export as PDF' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Export as Excel' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Export as CSV' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Export as ODS' })).toBeVisible();
|
||||
});
|
||||
|
||||
+185
-51
@@ -1,23 +1,23 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import { test } from '../playwright/fixtures';
|
||||
import {
|
||||
createProjectViaApi,
|
||||
createClientViaApi,
|
||||
createTaskViaApi,
|
||||
createTimeEntryViaApi,
|
||||
createTimeEntryWithTagViaApi,
|
||||
createBareTimeEntryViaApi,
|
||||
} from './utils/api';
|
||||
import {
|
||||
goToReporting,
|
||||
goToReportingShared,
|
||||
createProject,
|
||||
createClient,
|
||||
createProjectWithClient,
|
||||
createTask,
|
||||
createTimeEntryWithProject,
|
||||
createTimeEntryWithProjectAndTask,
|
||||
createTimeEntryWithTag,
|
||||
createBareTimeEntry,
|
||||
waitForReportingUpdate,
|
||||
saveAsSharedReport,
|
||||
} from './utils/reporting';
|
||||
|
||||
// Each test registers a new user and creates test data, which needs more time
|
||||
test.describe.configure({ timeout: 60000 });
|
||||
// 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 =
|
||||
@@ -29,12 +29,17 @@ const DATE_PICKER_BUTTON_PATTERN =
|
||||
|
||||
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);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
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();
|
||||
@@ -62,12 +67,17 @@ test('test that shared report with invalid secret shows no data', async ({ page
|
||||
|
||||
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);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
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();
|
||||
@@ -121,24 +131,34 @@ test('test that a shared report can be edited to toggle public/private and then
|
||||
// Shared Report Filter Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that shared report respects project filter', async ({ page }) => {
|
||||
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);
|
||||
|
||||
await createProject(page, projectA);
|
||||
await createProject(page, projectB);
|
||||
await createTimeEntryWithProject(page, projectA, '1h');
|
||||
await createTimeEntryWithProject(page, projectB, '2h');
|
||||
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 page.getByRole('option').filter({ hasText: projectA }).click();
|
||||
await Promise.all([
|
||||
page.getByRole('option').filter({ hasText: projectA }).click(),
|
||||
waitForReportingUpdate(page),
|
||||
]);
|
||||
await page.keyboard.press('Escape');
|
||||
await waitForReportingUpdate(page);
|
||||
|
||||
const { shareableLink } = await saveAsSharedReport(page, reportName);
|
||||
|
||||
@@ -151,22 +171,29 @@ test('test that shared report respects project filter', async ({ page }) => {
|
||||
|
||||
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);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
await createBareTimeEntry(page, 'Bare entry no project', '2h');
|
||||
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 page.getByRole('option').filter({ hasText: 'No Project' }).click();
|
||||
await Promise.all([
|
||||
page.getByRole('option').filter({ hasText: 'No Project' }).click(),
|
||||
waitForReportingUpdate(page),
|
||||
]);
|
||||
await page.keyboard.press('Escape');
|
||||
await waitForReportingUpdate(page);
|
||||
|
||||
const { shareableLink } = await saveAsSharedReport(page, reportName);
|
||||
|
||||
@@ -180,24 +207,36 @@ test('test that shared report with No Project filter shows entries without a pro
|
||||
|
||||
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);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTask(page, projectName, taskName);
|
||||
await createTimeEntryWithProjectAndTask(page, projectName, taskName, '1h');
|
||||
await createTimeEntryWithProject(page, projectName, '2h');
|
||||
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 page.getByRole('option').filter({ hasText: 'No Task' }).click();
|
||||
await Promise.all([
|
||||
page.getByRole('option').filter({ hasText: 'No Task' }).click(),
|
||||
waitForReportingUpdate(page),
|
||||
]);
|
||||
await page.keyboard.press('Escape');
|
||||
await waitForReportingUpdate(page);
|
||||
|
||||
const { shareableLink } = await saveAsSharedReport(page, reportName);
|
||||
|
||||
@@ -211,12 +250,16 @@ test('test that shared report with No Task filter shows entries without a task',
|
||||
// Report Date Picker Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that creating a report with an expiration date works', async ({ page }) => {
|
||||
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);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
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();
|
||||
@@ -257,12 +300,17 @@ test('test that creating a report with an expiration date works', async ({ page
|
||||
|
||||
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);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
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();
|
||||
@@ -331,24 +379,31 @@ test('test that editing a report to make it public with expiration date works',
|
||||
|
||||
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);
|
||||
|
||||
await createClient(page, clientName);
|
||||
await createProjectWithClient(page, projectName, clientName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
await createBareTimeEntry(page, 'Entry without client', '2h');
|
||||
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 page.getByRole('option').filter({ hasText: 'No Client' }).click();
|
||||
await Promise.all([
|
||||
page.getByRole('option').filter({ hasText: 'No Client' }).click(),
|
||||
waitForReportingUpdate(page),
|
||||
]);
|
||||
await page.keyboard.press('Escape');
|
||||
await waitForReportingUpdate(page);
|
||||
|
||||
const { shareableLink } = await saveAsSharedReport(page, reportName);
|
||||
|
||||
@@ -359,21 +414,26 @@ test('test that shared report with No Client filter shows entries without a clie
|
||||
await expect(page.getByText(projectName)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('test that shared report with No Tag filter shows entries without tags', async ({ page }) => {
|
||||
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 createTimeEntryWithTag(page, tagName, '1h');
|
||||
await createBareTimeEntry(page, 'Entry without tags', '2h');
|
||||
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 page.getByRole('option').filter({ hasText: 'No Tag' }).click();
|
||||
await Promise.all([
|
||||
page.getByRole('option').filter({ hasText: 'No Tag' }).click(),
|
||||
waitForReportingUpdate(page),
|
||||
]);
|
||||
await page.keyboard.press('Escape');
|
||||
await waitForReportingUpdate(page);
|
||||
|
||||
const { shareableLink } = await saveAsSharedReport(page, reportName);
|
||||
|
||||
@@ -383,12 +443,86 @@ test('test that shared report with No Tag filter shows entries without tags', as
|
||||
await expect(page.getByText('Total')).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that updating expiration date on already-public report works', async ({ page }) => {
|
||||
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);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
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();
|
||||
|
||||
+49
-2
@@ -2,13 +2,13 @@ import { expect } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import { test } from '../playwright/fixtures';
|
||||
import { createTagViaApi } from './utils/api';
|
||||
|
||||
async function goToTagsOverview(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/tags');
|
||||
}
|
||||
|
||||
// Create new project via modal
|
||||
test('test that creating and deleting a new client via the modal works', async ({ page }) => {
|
||||
test('test that creating and deleting a new tag via the modal works', async ({ page }) => {
|
||||
const newTagName = 'New Tag ' + Math.floor(1 + Math.random() * 10000);
|
||||
await goToTagsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Tag' }).click();
|
||||
@@ -41,3 +41,50 @@ test('test that creating and deleting a new client via the modal works', async (
|
||||
]);
|
||||
await expect(page.getByTestId('tag_table')).not.toContainText(newTagName);
|
||||
});
|
||||
|
||||
test('test that editing a tag name works', async ({ page, ctx }) => {
|
||||
const originalTagName = 'Original Tag ' + Math.floor(1 + Math.random() * 10000);
|
||||
const updatedTagName = 'Updated Tag ' + Math.floor(1 + Math.random() * 10000);
|
||||
|
||||
await createTagViaApi(ctx, { name: originalTagName });
|
||||
|
||||
await goToTagsOverview(page);
|
||||
await expect(page.getByTestId('tag_table')).toContainText(originalTagName);
|
||||
|
||||
// Open actions menu and click Edit
|
||||
const moreButton = page.locator("[aria-label='Actions for Tag " + originalTagName + "']");
|
||||
await moreButton.click();
|
||||
await page.getByRole('menuitem').getByText('Edit').click();
|
||||
|
||||
// Update the tag name in the edit modal
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await page.getByPlaceholder('Tag Name').fill(updatedTagName);
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/tags/') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
),
|
||||
page.getByRole('button', { name: 'Update Tag' }).click(),
|
||||
]);
|
||||
|
||||
// Verify the table shows the updated name
|
||||
await expect(page.getByTestId('tag_table')).toContainText(updatedTagName);
|
||||
await expect(page.getByTestId('tag_table')).not.toContainText(originalTagName);
|
||||
});
|
||||
|
||||
test('test that multiple tags can be created via API and displayed in the table', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const tagName1 = 'TagA ' + Math.floor(1 + Math.random() * 10000);
|
||||
const tagName2 = 'TagB ' + Math.floor(1 + Math.random() * 10000);
|
||||
|
||||
await createTagViaApi(ctx, { name: tagName1 });
|
||||
await createTagViaApi(ctx, { name: tagName2 });
|
||||
|
||||
await goToTagsOverview(page);
|
||||
await expect(page.getByTestId('tag_table')).toContainText(tagName1);
|
||||
await expect(page.getByTestId('tag_table')).toContainText(tagName2);
|
||||
});
|
||||
|
||||
+78
-25
@@ -2,13 +2,13 @@ import { expect } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import { test } from '../playwright/fixtures';
|
||||
import { createProjectViaApi, createTaskViaApi, createClientViaApi } from './utils/api';
|
||||
|
||||
async function goToProjectsOverview(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
||||
}
|
||||
|
||||
// Create new project via modal
|
||||
test('test that creating and deleting a new tag in a new project works', async ({ page }) => {
|
||||
test('test that creating and deleting a new task in a new project works', async ({ page }) => {
|
||||
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
await goToProjectsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
@@ -28,11 +28,9 @@ test('test that creating and deleting a new tag in a new project works', async (
|
||||
]);
|
||||
|
||||
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
|
||||
|
||||
await page.getByText(newProjectName).click();
|
||||
|
||||
const newTaskName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
|
||||
const newTaskName = 'New Task ' + Math.floor(1 + Math.random() * 10000);
|
||||
await page.getByRole('button', { name: 'Create Task' }).click();
|
||||
await page.getByPlaceholder('Task Name').fill(newTaskName);
|
||||
|
||||
@@ -84,23 +82,14 @@ test('test that creating and deleting a new tag in a new project works', async (
|
||||
await expect(page.getByTestId('project_table')).not.toContainText(newProjectName);
|
||||
});
|
||||
|
||||
test('test that archiving and unarchiving tasks works', async ({ page }) => {
|
||||
test('test that archiving and unarchiving tasks works', async ({ page, ctx }) => {
|
||||
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
const newTaskName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
const newTaskName = 'New Task ' + Math.floor(1 + Math.random() * 10000);
|
||||
|
||||
await goToProjectsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
await page.getByLabel('Project Name').fill(newProjectName);
|
||||
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
await expect(page.getByText(newProjectName)).toBeVisible();
|
||||
|
||||
await page.getByText(newProjectName).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Create Task' }).click();
|
||||
await page.getByPlaceholder('Task Name').fill(newTaskName);
|
||||
await page.getByRole('button', { name: 'Create Task' }).click();
|
||||
const project = await createProjectViaApi(ctx, { name: newProjectName });
|
||||
await createTaskViaApi(ctx, { name: newTaskName, project_id: project.id });
|
||||
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + project.id);
|
||||
await expect(page.getByRole('table')).toContainText(newTaskName);
|
||||
|
||||
await page.getByRole('row').first().getByRole('button').click();
|
||||
@@ -124,14 +113,78 @@ test('test that archiving and unarchiving tasks works', async ({ page }) => {
|
||||
]);
|
||||
});
|
||||
|
||||
// Create new project with new Client
|
||||
test('test that editing a task name works', async ({ page, ctx }) => {
|
||||
const projectName = 'TaskEdit Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
const originalTaskName = 'Original Task ' + Math.floor(1 + Math.random() * 10000);
|
||||
const updatedTaskName = 'Updated Task ' + Math.floor(1 + Math.random() * 10000);
|
||||
|
||||
// Create new project with existing Client
|
||||
const project = await createProjectViaApi(ctx, { name: projectName });
|
||||
await createTaskViaApi(ctx, { name: originalTaskName, project_id: project.id });
|
||||
|
||||
// Delete project via More Options
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + project.id);
|
||||
await expect(page.getByTestId('task_table')).toContainText(originalTaskName);
|
||||
|
||||
// Test that project task count is displayed correctly
|
||||
// Open actions menu and click Edit
|
||||
const moreButton = page.locator("[aria-label='Actions for Task " + originalTaskName + "']");
|
||||
await moreButton.click();
|
||||
await page.getByRole('menuitem').getByText('Edit').click();
|
||||
|
||||
// Test that active / archive / all filter works (once implemented)
|
||||
// Update the task name
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await page.getByPlaceholder('Task Name').fill(updatedTaskName);
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Update Task' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/tasks') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
),
|
||||
]);
|
||||
|
||||
// Test update task name
|
||||
await expect(page.getByTestId('task_table')).toContainText(updatedTaskName);
|
||||
await expect(page.getByTestId('task_table')).not.toContainText(originalTaskName);
|
||||
});
|
||||
|
||||
test('test that creating a project with an existing client works', async ({ page, ctx }) => {
|
||||
const clientName = 'Existing Client ' + Math.floor(1 + Math.random() * 10000);
|
||||
const projectName = 'Project With Client ' + Math.floor(1 + Math.random() * 10000);
|
||||
|
||||
await createClientViaApi(ctx, { name: clientName });
|
||||
|
||||
await goToProjectsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
await page.getByLabel('Project Name').fill(projectName);
|
||||
|
||||
// Select the existing client
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'No Client' }).click();
|
||||
await page.getByRole('option', { name: clientName }).click();
|
||||
|
||||
await Promise.all([
|
||||
page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click(),
|
||||
page.waitForResponse(
|
||||
async (response) =>
|
||||
response.url().includes('/projects') &&
|
||||
response.request().method() === 'POST' &&
|
||||
response.status() === 201 &&
|
||||
(await response.json()).data.client_id !== null
|
||||
),
|
||||
]);
|
||||
|
||||
await expect(page.getByTestId('project_table')).toContainText(projectName);
|
||||
await expect(page.getByTestId('project_table')).toContainText(clientName);
|
||||
});
|
||||
|
||||
test('test that multiple tasks are displayed on project detail page', async ({ page, ctx }) => {
|
||||
const projectName = 'TaskCount Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
const taskName1 = 'CountTask A ' + Math.floor(1 + Math.random() * 10000);
|
||||
const taskName2 = 'CountTask B ' + Math.floor(1 + Math.random() * 10000);
|
||||
|
||||
const project = await createProjectViaApi(ctx, { name: projectName });
|
||||
await createTaskViaApi(ctx, { name: taskName1, project_id: project.id });
|
||||
await createTaskViaApi(ctx, { name: taskName2, project_id: project.id });
|
||||
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + project.id);
|
||||
await expect(page.getByText(taskName1)).toBeVisible();
|
||||
await expect(page.getByText(taskName2)).toBeVisible();
|
||||
});
|
||||
|
||||
+174
-73
@@ -9,7 +9,11 @@ import {
|
||||
startOrStopTimerWithButton,
|
||||
stoppedTimeEntryResponse,
|
||||
} from './utils/currentTimeEntry';
|
||||
import { createProject, createBillableProject, createBareTimeEntry } from './utils/reporting';
|
||||
import {
|
||||
createProjectViaApi,
|
||||
createBillableProjectViaApi,
|
||||
createBareTimeEntryViaApi,
|
||||
} from './utils/api';
|
||||
|
||||
// Date picker button name patterns for different date formats
|
||||
// Matches: "Pick a date", "YYYY-MM-DD", "DD/MM/YYYY", "DD.MM.YYYY", "MM/DD/YYYY", "DD-MM-YYYY", "MM-DD-YYYY"
|
||||
@@ -48,7 +52,8 @@ async function createEmptyTimeEntry(page: Page) {
|
||||
startOrStopTimerWithButton(page),
|
||||
assertThatTimerHasStarted(page),
|
||||
]);
|
||||
await page.waitForTimeout(1500);
|
||||
// Wait for the timer to accumulate some duration so the stopped entry has duration > 0
|
||||
await expect(page.getByTestId('time_entry_time')).not.toHaveValue('00:00:00');
|
||||
await Promise.all([
|
||||
stoppedTimeEntryResponse(page),
|
||||
startOrStopTimerWithButton(page),
|
||||
@@ -68,8 +73,6 @@ test('test that starting and stopping an empty time entry shows a new time entry
|
||||
(response) => response.url().includes('/time-entries') && response.status() === 200
|
||||
),
|
||||
]);
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// check that there are not testid time_entry_row elements on the page
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
const initialTimeEntryCount = await timeEntryRows.count();
|
||||
@@ -81,7 +84,7 @@ test('test that starting and stopping an empty time entry shows a new time entry
|
||||
// Test that description update works
|
||||
|
||||
async function assertThatTimeEntryRowIsStopped(newTimeEntry: Locator) {
|
||||
await expect(newTimeEntry.getByTestId('timer_button')).toHaveClass(/bg-quaternary/);
|
||||
await expect(newTimeEntry.getByTestId('timer_button').first()).toHaveClass(/bg-quaternary/);
|
||||
}
|
||||
|
||||
test('test that updating a description of a time entry in the overview works on blur', async ({
|
||||
@@ -94,7 +97,7 @@ test('test that updating a description of a time entry in the overview works on
|
||||
await assertThatTimeEntryRowIsStopped(newTimeEntry);
|
||||
|
||||
const newDescription = Math.floor(Math.random() * 1000000).toString();
|
||||
const descriptionElement = newTimeEntry.getByTestId('time_entry_description');
|
||||
const descriptionElement = newTimeEntry.getByTestId('time_entry_description').first();
|
||||
await descriptionElement.fill(newDescription);
|
||||
await Promise.all([
|
||||
descriptionElement.press('Tab'),
|
||||
@@ -126,7 +129,7 @@ test('test that updating a description of a time entry in the overview works on
|
||||
const newTimeEntry = timeEntryRows.first();
|
||||
await assertThatTimeEntryRowIsStopped(newTimeEntry);
|
||||
const newDescription = Math.floor(Math.random() * 1000000).toString();
|
||||
const descriptionElement = newTimeEntry.getByTestId('time_entry_description');
|
||||
const descriptionElement = newTimeEntry.getByTestId('time_entry_description').first();
|
||||
await descriptionElement.fill(newDescription);
|
||||
await Promise.all([
|
||||
descriptionElement.press('Enter'),
|
||||
@@ -157,7 +160,7 @@ test('test that adding a new tag to an existing time entry works', async ({ page
|
||||
await assertThatTimeEntryRowIsStopped(newTimeEntry);
|
||||
const newTagName = Math.floor(Math.random() * 1000000).toString();
|
||||
|
||||
await newTimeEntry.getByTestId('time_entry_tag_dropdown').click();
|
||||
await newTimeEntry.getByTestId('time_entry_tag_dropdown').first().click();
|
||||
await page.getByText('Create new tag').click();
|
||||
await page.getByPlaceholder('Tag Name').fill(newTagName);
|
||||
|
||||
@@ -184,7 +187,7 @@ test('test that adding a new tag to an existing time entry works', async ({ page
|
||||
);
|
||||
});
|
||||
|
||||
await expect(newTimeEntry.getByText(newTagName)).toBeVisible();
|
||||
await expect(newTimeEntry.getByText(newTagName).first()).toBeVisible();
|
||||
});
|
||||
|
||||
// Test that Start / End Time Update Works
|
||||
@@ -197,8 +200,8 @@ test('test that updating a the start of an existing time entry in the overview w
|
||||
|
||||
const newTimeEntry = timeEntryRows.first();
|
||||
await assertThatTimeEntryRowIsStopped(newTimeEntry);
|
||||
await page.waitForTimeout(1500);
|
||||
const timeEntryRangeElement = newTimeEntry.getByTestId('time_entry_range_selector');
|
||||
await expect(timeEntryRangeElement).toBeVisible();
|
||||
await timeEntryRangeElement.click();
|
||||
await page.getByTestId('time_entry_range_start').first().fill('1');
|
||||
await Promise.all([
|
||||
@@ -223,8 +226,8 @@ test('test that updating a the duration in the overview works on blur', async ({
|
||||
|
||||
const newTimeEntry = timeEntryRows.first();
|
||||
await assertThatTimeEntryRowIsStopped(newTimeEntry);
|
||||
await page.waitForTimeout(1500);
|
||||
const timeEntryDurationInput = newTimeEntry.locator('input[name="Duration"]');
|
||||
const timeEntryDurationInput = newTimeEntry.locator('input[name="Duration"]').first();
|
||||
await expect(timeEntryDurationInput).toBeEditable();
|
||||
await timeEntryDurationInput.fill('20min');
|
||||
|
||||
await Promise.all([
|
||||
@@ -251,7 +254,7 @@ test('test that starting a time entry from the overview works', async ({ page })
|
||||
await createEmptyTimeEntry(page);
|
||||
|
||||
const newTimeEntry = timeEntryRows.first();
|
||||
const startButton = newTimeEntry.getByTestId('timer_button');
|
||||
const startButton = newTimeEntry.getByTestId('timer_button').first();
|
||||
await expect(startButton).toHaveClass(/bg-quaternary/);
|
||||
|
||||
await Promise.all([
|
||||
@@ -269,7 +272,8 @@ test('test that starting a time entry from the overview works', async ({ page })
|
||||
|
||||
await assertThatTimerHasStarted(page);
|
||||
|
||||
await page.waitForTimeout(1500);
|
||||
// Wait for the timer to accumulate some duration
|
||||
await expect(page.getByTestId('time_entry_time')).not.toHaveValue('00:00:00');
|
||||
await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
return (
|
||||
@@ -281,8 +285,8 @@ test('test that starting a time entry from the overview works', async ({ page })
|
||||
);
|
||||
}),
|
||||
startOrStopTimerWithButton(page),
|
||||
assertThatTimerIsStopped(page),
|
||||
]);
|
||||
await assertThatTimerIsStopped(page);
|
||||
});
|
||||
|
||||
test('test that deleting a time entry from the overview works', async ({ page }) => {
|
||||
@@ -310,7 +314,6 @@ test.skip('test that load more works when the end of page is reached', async ({
|
||||
),
|
||||
]);
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
await Promise.all([
|
||||
page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)),
|
||||
page.waitForResponse(async (response) => {
|
||||
@@ -341,8 +344,9 @@ test.skip('test that load more works when the end of page is reached', async ({
|
||||
|
||||
test('test that updating the start date of a time entry via the edit modal works', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
await createBareTimeEntry(page, 'Date edit test', '1h');
|
||||
await createBareTimeEntryViaApi(ctx, 'Date edit test', '1h');
|
||||
await goToTimeOverview(page);
|
||||
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
@@ -449,8 +453,9 @@ test('test that setting a date in the create modal works', async ({ page }) => {
|
||||
|
||||
test('test that updating the date via the time entry row range selector works', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
await createBareTimeEntry(page, 'Date range test', '1h');
|
||||
await createBareTimeEntryViaApi(ctx, 'Date range test', '1h');
|
||||
await goToTimeOverview(page);
|
||||
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
@@ -500,8 +505,9 @@ test('test that updating the date via the time entry row range selector works',
|
||||
|
||||
test('test that updating the end date via the time entry row range selector works', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
await createBareTimeEntry(page, 'End date range test', '1h');
|
||||
await createBareTimeEntryViaApi(ctx, 'End date range test', '1h');
|
||||
await goToTimeOverview(page);
|
||||
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
@@ -550,7 +556,7 @@ test('test that updating the end date via the time entry row range selector work
|
||||
expect(getMonthFromTimestamp(updateBody.data.end)).toBe(expectedMonth);
|
||||
});
|
||||
|
||||
test('test that date picker displays date in organization date format', async ({ page }) => {
|
||||
test('test that date picker displays date in organization date format', async ({ page, ctx }) => {
|
||||
// First change the organization date format to DD/MM/YYYY
|
||||
await goToOrganizationSettings(page);
|
||||
await page.getByLabel('Date Format').click();
|
||||
@@ -571,7 +577,7 @@ test('test that date picker displays date in organization date format', async ({
|
||||
]);
|
||||
|
||||
// Create a time entry and open the edit modal
|
||||
await createBareTimeEntry(page, 'Date format test', '1h');
|
||||
await createBareTimeEntryViaApi(ctx, 'Date format test', '1h');
|
||||
await goToTimeOverview(page);
|
||||
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
@@ -696,12 +702,13 @@ test('test that mass update billable status works', async ({ page }) => {
|
||||
|
||||
test('test that resetting project selection in mass update modal does not update project', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const projectName = 'Mass Update Reset Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
await createProject(page, projectName);
|
||||
await createProjectViaApi(ctx, { name: projectName });
|
||||
|
||||
// Create a time entry with the project assigned
|
||||
await createBareTimeEntry(page, 'Mass update reset test', '1h');
|
||||
await createBareTimeEntryViaApi(ctx, 'Mass update reset test', '1h');
|
||||
await goToTimeOverview(page);
|
||||
|
||||
// Assign project to the time entry
|
||||
@@ -809,78 +816,84 @@ test('test that setting billable status via the create modal works', async ({ pa
|
||||
|
||||
test('test that changing project on a time entry row from non-billable to billable updates billable status', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const billableProjectName = 'Billable Row Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
const nonBillableProjectName =
|
||||
'NonBillable Row Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
|
||||
await createProject(page, nonBillableProjectName);
|
||||
await createBillableProject(page, billableProjectName);
|
||||
await createBareTimeEntry(page, 'Test billable row', '1h');
|
||||
await createProjectViaApi(ctx, { name: nonBillableProjectName });
|
||||
await createBillableProjectViaApi(ctx, { name: billableProjectName });
|
||||
await createBareTimeEntryViaApi(ctx, 'Test billable row', '1h');
|
||||
|
||||
await goToTimeOverview(page);
|
||||
const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first();
|
||||
|
||||
// Assign the non-billable project first
|
||||
await timeEntryRow.getByRole('button', { name: 'No Project' }).click();
|
||||
await page.getByRole('option', { name: nonBillableProjectName }).click();
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
);
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
),
|
||||
page.getByRole('option', { name: nonBillableProjectName }).click(),
|
||||
]);
|
||||
|
||||
// Now switch to the billable project
|
||||
await timeEntryRow.getByRole('button', { name: nonBillableProjectName }).click();
|
||||
await page.getByRole('option', { name: billableProjectName }).click();
|
||||
|
||||
const updateResponse = await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
);
|
||||
const [updateResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
),
|
||||
page.getByRole('option', { name: billableProjectName }).click(),
|
||||
]);
|
||||
const responseBody = await updateResponse.json();
|
||||
expect(responseBody.data.billable).toBe(true);
|
||||
});
|
||||
|
||||
test('test that changing project on a time entry row from billable to non-billable updates billable status', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const billableProjectName = 'Billable Row Rev Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
const nonBillableProjectName =
|
||||
'NonBillable Row Rev Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
|
||||
await createBillableProject(page, billableProjectName);
|
||||
await createProject(page, nonBillableProjectName);
|
||||
await createBareTimeEntry(page, 'Test billable row reverse', '1h');
|
||||
await createBillableProjectViaApi(ctx, { name: billableProjectName });
|
||||
await createProjectViaApi(ctx, { name: nonBillableProjectName });
|
||||
await createBareTimeEntryViaApi(ctx, 'Test billable row reverse', '1h');
|
||||
|
||||
await goToTimeOverview(page);
|
||||
const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first();
|
||||
|
||||
// Assign the billable project first
|
||||
await timeEntryRow.getByRole('button', { name: 'No Project' }).click();
|
||||
await page.getByRole('option', { name: billableProjectName }).click();
|
||||
const firstResponse = await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
);
|
||||
const [firstResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
),
|
||||
page.getByRole('option', { name: billableProjectName }).click(),
|
||||
]);
|
||||
const firstBody = await firstResponse.json();
|
||||
expect(firstBody.data.billable).toBe(true);
|
||||
|
||||
// Now switch to the non-billable project
|
||||
await timeEntryRow.getByRole('button', { name: billableProjectName }).click();
|
||||
await page.getByRole('option', { name: nonBillableProjectName }).click();
|
||||
|
||||
const updateResponse = await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
);
|
||||
const [updateResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
),
|
||||
page.getByRole('option', { name: nonBillableProjectName }).click(),
|
||||
]);
|
||||
const responseBody = await updateResponse.json();
|
||||
expect(responseBody.data.billable).toBe(false);
|
||||
});
|
||||
@@ -956,9 +969,9 @@ test('test that decimal duration input works in create modal', async ({ page })
|
||||
expect(createBody.data.duration).toBe(5400);
|
||||
});
|
||||
|
||||
test('test that project selection works in create modal', async ({ page }) => {
|
||||
test('test that project selection works in create modal', async ({ page, ctx }) => {
|
||||
const projectName = 'Create Modal Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
await createProject(page, projectName);
|
||||
await createProjectViaApi(ctx, { name: projectName });
|
||||
|
||||
await goToTimeOverview(page);
|
||||
|
||||
@@ -1135,11 +1148,11 @@ test('test that end time picker works in create modal', async ({ page }) => {
|
||||
|
||||
test('test that changing project in edit modal from non-billable to billable updates billable status', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const billableProjectName = 'Billable Modal Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
|
||||
await createBillableProject(page, billableProjectName);
|
||||
await createBareTimeEntry(page, 'Test billable modal', '1h');
|
||||
await createBillableProjectViaApi(ctx, { name: billableProjectName });
|
||||
await createBareTimeEntryViaApi(ctx, 'Test billable modal', '1h');
|
||||
|
||||
await goToTimeOverview(page);
|
||||
const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first();
|
||||
@@ -1179,11 +1192,11 @@ test('test that changing project in edit modal from non-billable to billable upd
|
||||
|
||||
test('test that opening edit modal for a time entry with manually overridden billable status preserves that status', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const billableProjectName = 'Billable Persist Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
|
||||
await createBillableProject(page, billableProjectName);
|
||||
await createBareTimeEntry(page, 'Test persist billable override', '1h');
|
||||
await createBillableProjectViaApi(ctx, { name: billableProjectName });
|
||||
await createBareTimeEntryViaApi(ctx, 'Test persist billable override', '1h');
|
||||
|
||||
await goToTimeOverview(page);
|
||||
const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first();
|
||||
@@ -1249,15 +1262,15 @@ test('test that opening edit modal for a time entry with manually overridden bil
|
||||
|
||||
test('test that changing project in edit modal from billable to non-billable updates billable status', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const billableProjectName =
|
||||
'Billable Modal Rev Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
const nonBillableProjectName =
|
||||
'NonBillable Modal Rev Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
|
||||
await createBillableProject(page, billableProjectName);
|
||||
await createProject(page, nonBillableProjectName);
|
||||
await createBareTimeEntry(page, 'Test billable modal reverse', '1h');
|
||||
await createBillableProjectViaApi(ctx, { name: billableProjectName });
|
||||
await createProjectViaApi(ctx, { name: nonBillableProjectName });
|
||||
await createBareTimeEntryViaApi(ctx, 'Test billable modal reverse', '1h');
|
||||
|
||||
await goToTimeOverview(page);
|
||||
const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first();
|
||||
@@ -1298,3 +1311,91 @@ test('test that changing project in edit modal from billable to non-billable upd
|
||||
const responseBody = await updateResponse.json();
|
||||
expect(responseBody.data.billable).toBe(false);
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// Mass Delete Tests
|
||||
// =============================================
|
||||
|
||||
test('test that mass deleting time entries works', async ({ page, ctx }) => {
|
||||
const description = 'Mass delete ' + Math.floor(1 + Math.random() * 10000);
|
||||
await createBareTimeEntryViaApi(ctx, description, '30min');
|
||||
|
||||
await goToTimeOverview(page);
|
||||
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
await expect(timeEntryRows.first()).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Select all time entries using the checkbox
|
||||
await page.getByLabel('Select All').click();
|
||||
await expect(page.getByText('selected')).toBeVisible();
|
||||
|
||||
// Verify the time entry is visible before deleting
|
||||
const entryRow = timeEntryRows.filter({ hasText: description });
|
||||
await expect(entryRow).toBeVisible();
|
||||
|
||||
// Click delete button in mass action bar (no confirmation dialog)
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') && response.request().method() === 'DELETE'
|
||||
),
|
||||
page.getByRole('button', { name: 'Delete' }).click(),
|
||||
]);
|
||||
|
||||
// Verify the time entry is no longer visible
|
||||
await expect(entryRow).not.toBeVisible();
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// Delete Single Time Entry Test
|
||||
// =============================================
|
||||
|
||||
test('test that deleting a single time entry via actions menu works', async ({ page, ctx }) => {
|
||||
const description = 'Delete single entry ' + Math.floor(1 + Math.random() * 10000);
|
||||
await createBareTimeEntryViaApi(ctx, description, '1h');
|
||||
|
||||
await goToTimeOverview(page);
|
||||
|
||||
const timeEntryRow = page
|
||||
.locator('[data-testid="time_entry_row"]')
|
||||
.filter({ hasText: description });
|
||||
await expect(timeEntryRow).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Open actions menu and click Delete
|
||||
await timeEntryRow.getByRole('button', { name: 'Actions for the time entry' }).first().click();
|
||||
await expect(page.getByTestId('time_entry_delete')).toBeVisible();
|
||||
// The dropdown delete uses the bulk delete endpoint (DELETE /time-entries?ids=...)
|
||||
// which returns 200 with a JSON body, not the single endpoint returning 204
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.request().method() === 'DELETE' &&
|
||||
response.status() === 200
|
||||
),
|
||||
page.getByTestId('time_entry_delete').click(),
|
||||
]);
|
||||
|
||||
// Verify the time entry is no longer visible
|
||||
await expect(timeEntryRow).not.toBeVisible();
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// Multiple Time Entries Test
|
||||
// =============================================
|
||||
|
||||
test('test that time entries page loads multiple entries created via API', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await createBareTimeEntryViaApi(ctx, `Batch entry ${i + 1}`, '30min');
|
||||
}
|
||||
|
||||
await goToTimeOverview(page);
|
||||
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
await expect(timeEntryRows.first()).toBeVisible();
|
||||
const count = await timeEntryRows.count();
|
||||
expect(count).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
|
||||
+86
-35
@@ -21,11 +21,8 @@ test('test that starting and stopping a timer without description and project wo
|
||||
page,
|
||||
}) => {
|
||||
await goToDashboard(page);
|
||||
await Promise.all([
|
||||
newTimeEntryResponse(page),
|
||||
startOrStopTimerWithButton(page),
|
||||
assertThatTimerHasStarted(page),
|
||||
]);
|
||||
await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
||||
await assertThatTimerHasStarted(page);
|
||||
await page.waitForTimeout(1500);
|
||||
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
||||
await assertThatTimerIsStopped(page);
|
||||
@@ -33,8 +30,8 @@ test('test that starting and stopping a timer without description and project wo
|
||||
|
||||
test('test that starting and stopping a timer with a description works', async ({ page }) => {
|
||||
await goToDashboard(page);
|
||||
// TODO: Fix flakyness by disabling description input field until timer is loaded
|
||||
await page.waitForTimeout(500);
|
||||
// Wait for the description input to be editable before filling
|
||||
await expect(page.getByTestId('time_entry_description')).toBeEditable();
|
||||
await page.getByTestId('time_entry_description').fill('New Time Entry Description');
|
||||
await Promise.all([
|
||||
newTimeEntryResponse(page, {
|
||||
@@ -60,13 +57,12 @@ test('test that starting the time entry starts the live timer and that it keeps
|
||||
|
||||
await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
||||
await assertThatTimerHasStarted(page);
|
||||
await page.waitForTimeout(500);
|
||||
const beforeTimerValue = await page.getByTestId('time_entry_time').inputValue();
|
||||
await page.waitForTimeout(2000);
|
||||
const afterWaitTimeValue = await page.getByTestId('time_entry_time').inputValue();
|
||||
expect(afterWaitTimeValue).not.toEqual(beforeTimerValue);
|
||||
await page.reload();
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page.getByTestId('time_entry_time')).toBeVisible();
|
||||
|
||||
const afterReloadTimerValue = await page.getByTestId('time_entry_time').inputValue();
|
||||
await page.waitForTimeout(2000);
|
||||
@@ -79,7 +75,7 @@ test('test that starting and updating the description while running works', asyn
|
||||
|
||||
await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
||||
await assertThatTimerHasStarted(page);
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page.getByTestId('time_entry_description')).toBeEditable();
|
||||
await page.getByTestId('time_entry_description').fill('New Time Entry Description');
|
||||
|
||||
await Promise.all([
|
||||
@@ -89,7 +85,6 @@ test('test that starting and updating the description while running works', asyn
|
||||
}),
|
||||
page.getByTestId('time_entry_description').press('Tab'),
|
||||
]);
|
||||
await page.waitForTimeout(500);
|
||||
await Promise.all([
|
||||
stoppedTimeEntryResponse(page, {
|
||||
description: 'New Time Entry Description',
|
||||
@@ -106,7 +101,7 @@ test('test that starting and updating the time while running works', async ({ pa
|
||||
await startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await assertThatTimerHasStarted(page);
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page.getByTestId('time_entry_time')).toBeEditable();
|
||||
await page.getByTestId('time_entry_time').fill('20min');
|
||||
|
||||
await Promise.all([
|
||||
@@ -130,7 +125,6 @@ test('test that starting and updating the time while running works', async ({ pa
|
||||
]);
|
||||
|
||||
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20/);
|
||||
await page.waitForTimeout(500);
|
||||
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
||||
await assertThatTimerIsStopped(page);
|
||||
});
|
||||
@@ -146,9 +140,7 @@ test('test that entering a human readable time starts the timer on blur', async
|
||||
await assertThatTimerHasStarted(page);
|
||||
|
||||
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
||||
await page.locator(
|
||||
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
|
||||
);
|
||||
await assertThatTimerIsStopped(page);
|
||||
});
|
||||
|
||||
test('test that entering a number in the time range starts the timer on blur', async ({ page }) => {
|
||||
@@ -162,9 +154,7 @@ test('test that entering a number in the time range starts the timer on blur', a
|
||||
await assertThatTimerHasStarted(page);
|
||||
|
||||
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
||||
await page.locator(
|
||||
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
|
||||
);
|
||||
await assertThatTimerIsStopped(page);
|
||||
});
|
||||
|
||||
test('test that entering a value with the format hh:mm in the time range starts the timer on blur', async ({
|
||||
@@ -180,9 +170,7 @@ test('test that entering a value with the format hh:mm in the time range starts
|
||||
await assertThatTimerHasStarted(page);
|
||||
|
||||
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
||||
await page.locator(
|
||||
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
|
||||
);
|
||||
await assertThatTimerIsStopped(page);
|
||||
});
|
||||
|
||||
test('test that entering a random value in the time range does not start the timer on blur', async ({
|
||||
@@ -190,10 +178,8 @@ test('test that entering a random value in the time range does not start the tim
|
||||
}) => {
|
||||
await goToDashboard(page);
|
||||
await page.getByTestId('time_entry_time').fill('asdasdasd');
|
||||
(await page.getByTestId('time_entry_time').press('Tab'),
|
||||
await page.locator(
|
||||
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
|
||||
));
|
||||
await page.getByTestId('time_entry_time').press('Tab');
|
||||
await assertThatTimerIsStopped(page);
|
||||
});
|
||||
|
||||
test('test that entering a time starts the timer on enter', async ({ page }) => {
|
||||
@@ -248,7 +234,7 @@ test('test that adding a new tag when the timer is running', async ({ page }) =>
|
||||
await page.getByTestId('tag_dropdown').click();
|
||||
await expect(page.getByRole('option', { name: newTagName })).toBeVisible();
|
||||
await page.getByTestId('tag_dropdown_search').press('Escape');
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page.getByTestId('tag_dropdown_search')).not.toBeVisible();
|
||||
|
||||
await Promise.all([
|
||||
stoppedTimeEntryResponse(page, { tags: [tagId] }),
|
||||
@@ -314,18 +300,83 @@ test('test that setting an end time with a different date via the timetracker ra
|
||||
expect(updateBody.data.end).toBeTruthy();
|
||||
});
|
||||
|
||||
// test that search is working
|
||||
test('test that timer starts on enter with description', async ({ page }) => {
|
||||
await goToDashboard(page);
|
||||
await expect(page.getByTestId('time_entry_description')).toBeEditable();
|
||||
await page.getByTestId('time_entry_description').fill('Start on Enter');
|
||||
|
||||
// test that adding a tag and project and starting the timer afterwards works and sets the project and tag correctly
|
||||
await Promise.all([
|
||||
newTimeEntryResponse(page, { description: 'Start on Enter' }),
|
||||
page.getByTestId('time_entry_description').press('Enter'),
|
||||
]);
|
||||
await assertThatTimerHasStarted(page);
|
||||
|
||||
// test that changing the project works
|
||||
await Promise.all([
|
||||
stoppedTimeEntryResponse(page, { description: 'Start on Enter' }),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await assertThatTimerIsStopped(page);
|
||||
});
|
||||
|
||||
// test that sidebar timetracker starts and stops timer
|
||||
test('test that timer started on dashboard is visible on time page', async ({ page }) => {
|
||||
await goToDashboard(page);
|
||||
|
||||
// test that sidebar timetracker changes state when tmer on dashboard is started
|
||||
// Start timer on dashboard
|
||||
await expect(page.getByTestId('time_entry_description')).toBeEditable();
|
||||
await page.getByTestId('time_entry_description').fill('Sync test');
|
||||
await Promise.all([
|
||||
newTimeEntryResponse(page, { description: 'Sync test' }),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await assertThatTimerHasStarted(page);
|
||||
|
||||
// test billable toggle
|
||||
// Navigate to time page
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
|
||||
// TODO: Test that project can be created in the time tracker row
|
||||
// Timer should still be running (the timer button should be red/active)
|
||||
await expect(
|
||||
page.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]')
|
||||
).toHaveClass(/bg-red-400\/80/);
|
||||
|
||||
// Add Test that time tracker starts on enter with description
|
||||
// Stop the timer
|
||||
await Promise.all([
|
||||
stoppedTimeEntryResponse(page, { description: 'Sync test' }),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await assertThatTimerIsStopped(page);
|
||||
});
|
||||
|
||||
test('test that adding a project and tag before starting timer works', async ({ page }) => {
|
||||
const newTagName = 'TimerTag ' + Math.floor(Math.random() * 10000);
|
||||
await goToDashboard(page);
|
||||
|
||||
// Create and select a tag first
|
||||
await page.getByTestId('tag_dropdown').click();
|
||||
await page.getByText('Create new tag').click();
|
||||
await page.getByPlaceholder('Tag Name').fill(newTagName);
|
||||
|
||||
const [tagCreateResponse] = await Promise.all([
|
||||
newTagResponse(page, { name: newTagName }),
|
||||
page.getByRole('button', { name: 'Create Tag' }).click(),
|
||||
]);
|
||||
const tagId = (await tagCreateResponse.json()).data.id;
|
||||
|
||||
// Wait for tags query refetch (tag is auto-selected after creation)
|
||||
await page.waitForResponse(
|
||||
(response) => response.url().includes('/tags') && response.status() === 200
|
||||
);
|
||||
|
||||
// Fill description and start
|
||||
await page.getByTestId('time_entry_description').fill('Entry with tag');
|
||||
await Promise.all([
|
||||
newTimeEntryResponse(page, { description: 'Entry with tag', tags: [tagId] }),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await assertThatTimerHasStarted(page);
|
||||
|
||||
await Promise.all([
|
||||
stoppedTimeEntryResponse(page, { description: 'Entry with tag', tags: [tagId] }),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await assertThatTimerIsStopped(page);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import type { APIRequestContext, Page } from '@playwright/test';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../../playwright/config';
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Types
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
export interface TestContext {
|
||||
request: APIRequestContext;
|
||||
orgId: string;
|
||||
memberId: string;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Auth helpers
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
async function getApiHeaders(page: Page): Promise<Record<string, string>> {
|
||||
const cookies = await page.context().cookies();
|
||||
const xsrfCookie = cookies.find((c) => c.name === 'XSRF-TOKEN');
|
||||
return {
|
||||
Accept: 'application/json',
|
||||
...(xsrfCookie ? { 'X-XSRF-TOKEN': decodeURIComponent(xsrfCookie.value) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Context setup
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
export async function setupTestContext(page: Page): Promise<TestContext> {
|
||||
const request = page.request;
|
||||
const headers = await getApiHeaders(page);
|
||||
const orgId = await getOrganizationId(request, headers);
|
||||
const memberId = await getCurrentMemberId(request, orgId, headers);
|
||||
return { request: createAuthenticatedRequest(request, headers), orgId, memberId };
|
||||
}
|
||||
|
||||
function createAuthenticatedRequest(
|
||||
request: APIRequestContext,
|
||||
headers: Record<string, string>
|
||||
): APIRequestContext {
|
||||
// Wrap the request to always include auth headers
|
||||
return new Proxy(request, {
|
||||
get(target, prop) {
|
||||
if (
|
||||
prop === 'get' ||
|
||||
prop === 'post' ||
|
||||
prop === 'put' ||
|
||||
prop === 'delete' ||
|
||||
prop === 'patch'
|
||||
) {
|
||||
return (url: string, options?: Record<string, unknown>) => {
|
||||
return target[prop as 'get'](url, {
|
||||
...options,
|
||||
headers: {
|
||||
...headers,
|
||||
...((options?.headers as Record<string, string>) || {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
return target[prop as keyof APIRequestContext];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function getOrganizationId(
|
||||
request: APIRequestContext,
|
||||
headers: Record<string, string>
|
||||
): Promise<string> {
|
||||
const response = await request.get(`${PLAYWRIGHT_BASE_URL}/api/v1/users/me/memberships`, {
|
||||
headers,
|
||||
});
|
||||
expect(response.status()).toBe(200);
|
||||
const body = await response.json();
|
||||
return body.data[0].organization.id;
|
||||
}
|
||||
|
||||
async function getCurrentMemberId(
|
||||
request: APIRequestContext,
|
||||
orgId: string,
|
||||
headers: Record<string, string>
|
||||
): Promise<string> {
|
||||
const response = await request.get(
|
||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${orgId}/members`,
|
||||
{ headers }
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
const body = await response.json();
|
||||
return body.data[0].id;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Duration parsing
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
function parseDurationToSeconds(duration: string): number {
|
||||
let totalSeconds = 0;
|
||||
|
||||
// Match patterns like "1h", "30min", "2h 30min", "1h 7min"
|
||||
const hourMatch = duration.match(/(\d+)\s*h/);
|
||||
const minMatch = duration.match(/(\d+)\s*min/);
|
||||
|
||||
if (hourMatch) {
|
||||
totalSeconds += parseInt(hourMatch[1], 10) * 3600;
|
||||
}
|
||||
if (minMatch) {
|
||||
totalSeconds += parseInt(minMatch[1], 10) * 60;
|
||||
}
|
||||
|
||||
// If no h/min pattern matched, try plain number as minutes
|
||||
if (!hourMatch && !minMatch) {
|
||||
const plainNumber = parseInt(duration, 10);
|
||||
if (!isNaN(plainNumber)) {
|
||||
totalSeconds = plainNumber * 60;
|
||||
}
|
||||
}
|
||||
|
||||
return totalSeconds;
|
||||
}
|
||||
|
||||
function createTimestamps(duration: string): { start: string; end: string } {
|
||||
const durationSeconds = parseDurationToSeconds(duration);
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 9, 0, 0);
|
||||
const end = new Date(start.getTime() + durationSeconds * 1000);
|
||||
|
||||
return {
|
||||
start: formatTimestamp(start),
|
||||
end: formatTimestamp(end),
|
||||
};
|
||||
}
|
||||
|
||||
function formatTimestamp(date: Date): string {
|
||||
return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
}
|
||||
|
||||
function randomColor(): string {
|
||||
const colors = [
|
||||
'#ef5350',
|
||||
'#ab47bc',
|
||||
'#5c6bc0',
|
||||
'#29b6f6',
|
||||
'#26a69a',
|
||||
'#9ccc65',
|
||||
'#ffa726',
|
||||
'#8d6e63',
|
||||
];
|
||||
return colors[Math.floor(Math.random() * colors.length)];
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Entity creation
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
export async function createProjectViaApi(
|
||||
ctx: TestContext,
|
||||
data: {
|
||||
name: string;
|
||||
color?: string;
|
||||
is_billable?: boolean;
|
||||
billable_rate?: number | null;
|
||||
client_id?: string | null;
|
||||
estimated_time?: number | null;
|
||||
}
|
||||
) {
|
||||
const response = await ctx.request.post(
|
||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/projects`,
|
||||
{
|
||||
data: {
|
||||
name: data.name,
|
||||
color: data.color ?? randomColor(),
|
||||
is_billable: data.is_billable ?? false,
|
||||
billable_rate: data.billable_rate ?? null,
|
||||
client_id: data.client_id ?? null,
|
||||
estimated_time: data.estimated_time ?? null,
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(response.status()).toBe(201);
|
||||
const body = await response.json();
|
||||
return body.data as { id: string; name: string; color: string; is_billable: boolean };
|
||||
}
|
||||
|
||||
export async function createBillableProjectViaApi(
|
||||
ctx: TestContext,
|
||||
data: { name: string; billable_rate?: number | null }
|
||||
) {
|
||||
return createProjectViaApi(ctx, {
|
||||
name: data.name,
|
||||
is_billable: true,
|
||||
billable_rate: data.billable_rate ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createClientViaApi(ctx: TestContext, data: { name: string }) {
|
||||
const response = await ctx.request.post(
|
||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/clients`,
|
||||
{ data: { name: data.name } }
|
||||
);
|
||||
expect(response.status()).toBe(201);
|
||||
const body = await response.json();
|
||||
return body.data as { id: string; name: string };
|
||||
}
|
||||
|
||||
export async function createProjectWithClientViaApi(
|
||||
ctx: TestContext,
|
||||
projectName: string,
|
||||
clientName: string
|
||||
) {
|
||||
const client = await createClientViaApi(ctx, { name: clientName });
|
||||
const project = await createProjectViaApi(ctx, {
|
||||
name: projectName,
|
||||
client_id: client.id,
|
||||
});
|
||||
return { project, client };
|
||||
}
|
||||
|
||||
export async function createTaskViaApi(
|
||||
ctx: TestContext,
|
||||
data: { name: string; project_id: string }
|
||||
) {
|
||||
const response = await ctx.request.post(
|
||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/tasks`,
|
||||
{
|
||||
data: {
|
||||
name: data.name,
|
||||
project_id: data.project_id,
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(response.status()).toBe(201);
|
||||
const body = await response.json();
|
||||
return body.data as { id: string; name: string; project_id: string };
|
||||
}
|
||||
|
||||
export async function createTagViaApi(ctx: TestContext, data: { name: string }) {
|
||||
const response = await ctx.request.post(
|
||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/tags`,
|
||||
{ data: { name: data.name } }
|
||||
);
|
||||
expect(response.status()).toBe(201);
|
||||
const body = await response.json();
|
||||
return body.data as { id: string; name: string };
|
||||
}
|
||||
|
||||
export async function createTimeEntryViaApi(
|
||||
ctx: TestContext,
|
||||
data: {
|
||||
description?: string;
|
||||
duration: string;
|
||||
projectId?: string | null;
|
||||
taskId?: string | null;
|
||||
tags?: string[];
|
||||
billable?: boolean;
|
||||
}
|
||||
) {
|
||||
const { start, end } = createTimestamps(data.duration);
|
||||
const response = await ctx.request.post(
|
||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/time-entries`,
|
||||
{
|
||||
data: {
|
||||
member_id: ctx.memberId,
|
||||
start,
|
||||
end,
|
||||
description: data.description ?? '',
|
||||
project_id: data.projectId ?? null,
|
||||
task_id: data.taskId ?? null,
|
||||
tags: data.tags ?? [],
|
||||
billable: data.billable ?? false,
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(response.status()).toBe(201);
|
||||
const body = await response.json();
|
||||
return body.data as { id: string; start: string; end: string; description: string };
|
||||
}
|
||||
|
||||
export async function createProjectMemberViaApi(
|
||||
ctx: TestContext,
|
||||
projectId: string,
|
||||
data: { member_id: string; billable_rate?: number | null }
|
||||
) {
|
||||
const response = await ctx.request.post(
|
||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/projects/${projectId}/project-members`,
|
||||
{
|
||||
data: {
|
||||
member_id: data.member_id,
|
||||
billable_rate: data.billable_rate ?? null,
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(response.status()).toBe(201);
|
||||
const body = await response.json();
|
||||
return body.data as { id: string; billable_rate: number | null };
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Composite helpers (matching existing UI helper signatures)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
export async function createTimeEntryWithProjectViaApi(
|
||||
ctx: TestContext,
|
||||
projectName: string,
|
||||
duration: string
|
||||
) {
|
||||
const project = await createProjectViaApi(ctx, { name: projectName });
|
||||
const entry = await createTimeEntryViaApi(ctx, {
|
||||
description: `Entry for ${projectName}`,
|
||||
duration,
|
||||
projectId: project.id,
|
||||
});
|
||||
return { project, entry };
|
||||
}
|
||||
|
||||
export async function createTimeEntryWithProjectAndTaskViaApi(
|
||||
ctx: TestContext,
|
||||
projectId: string,
|
||||
taskName: string,
|
||||
projectName: string,
|
||||
duration: string
|
||||
) {
|
||||
const task = await createTaskViaApi(ctx, { name: taskName, project_id: projectId });
|
||||
const entry = await createTimeEntryViaApi(ctx, {
|
||||
description: `Entry for ${projectName} - ${taskName}`,
|
||||
duration,
|
||||
projectId,
|
||||
taskId: task.id,
|
||||
});
|
||||
return { task, entry };
|
||||
}
|
||||
|
||||
export async function createTimeEntryWithTagViaApi(
|
||||
ctx: TestContext,
|
||||
tagName: string,
|
||||
duration: string
|
||||
) {
|
||||
const tag = await createTagViaApi(ctx, { name: tagName });
|
||||
const entry = await createTimeEntryViaApi(ctx, {
|
||||
description: `Entry with tag ${tagName}`,
|
||||
duration,
|
||||
tags: [tag.id],
|
||||
});
|
||||
return { tag, entry };
|
||||
}
|
||||
|
||||
export async function createBareTimeEntryViaApi(
|
||||
ctx: TestContext,
|
||||
description: string,
|
||||
duration: string
|
||||
) {
|
||||
return createTimeEntryViaApi(ctx, { description, duration });
|
||||
}
|
||||
|
||||
export async function createTimeEntryWithBillableStatusViaApi(
|
||||
ctx: TestContext,
|
||||
isBillable: boolean,
|
||||
duration: string
|
||||
) {
|
||||
return createTimeEntryViaApi(ctx, {
|
||||
description: `Time entry ${isBillable ? 'billable' : 'non-billable'}`,
|
||||
duration,
|
||||
billable: isBillable,
|
||||
});
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Import helper (for placeholder member creation)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
export async function createPlaceholderMemberViaImportApi(
|
||||
ctx: TestContext,
|
||||
placeholderName: string
|
||||
) {
|
||||
const placeholderEmail = `placeholder+${Math.floor(Math.random() * 100000)}@solidtime-import.test`;
|
||||
const csvContent = [
|
||||
'User,Email,Client,Project,Task,Description,Billable,Start date,Start time,End date,End time,Tags',
|
||||
`${placeholderName},${placeholderEmail},,,,Imported entry,No,2024-01-01,09:00:00,2024-01-01,10:00:00,`,
|
||||
].join('\n');
|
||||
|
||||
const base64Data = Buffer.from(csvContent).toString('base64');
|
||||
|
||||
const response = await ctx.request.post(
|
||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/import`,
|
||||
{
|
||||
data: {
|
||||
type: 'toggl_time_entries',
|
||||
data: base64Data,
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Organization settings helpers
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
export async function updateOrganizationSettingViaApi(
|
||||
ctx: TestContext,
|
||||
settings: Record<string, unknown>
|
||||
) {
|
||||
const response = await ctx.request.put(
|
||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}`,
|
||||
{ data: settings }
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
const body = await response.json();
|
||||
return body.data;
|
||||
}
|
||||
|
||||
export async function updateOrganizationCurrencyViaWeb(
|
||||
ctx: TestContext,
|
||||
currency: string,
|
||||
name: string = 'Test Organization'
|
||||
) {
|
||||
const response = await ctx.request.put(
|
||||
`${PLAYWRIGHT_BASE_URL}/teams/${ctx.orgId}`,
|
||||
{ data: { name, currency } }
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Bulk helpers
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
export async function createMultipleTimeEntriesViaApi(
|
||||
ctx: TestContext,
|
||||
count: number,
|
||||
data: { description?: string; duration?: string } = {}
|
||||
) {
|
||||
const entries = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const entry = await createTimeEntryViaApi(ctx, {
|
||||
description: data.description ?? `Bulk entry ${i + 1}`,
|
||||
duration: data.duration ?? '30min',
|
||||
});
|
||||
entries.push(entry);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Invitation helpers
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
export async function getInvitationsViaApi(ctx: TestContext) {
|
||||
const response = await ctx.request.get(
|
||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/invitations`
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
const body = await response.json();
|
||||
return body.data as Array<{ id: string; email: string; role: string }>;
|
||||
}
|
||||
@@ -6,9 +6,9 @@ export async function startOrStopTimerWithButton(page: Page) {
|
||||
}
|
||||
|
||||
export async function assertThatTimerHasStarted(page: Page) {
|
||||
await page.locator(
|
||||
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-red-400/80'
|
||||
);
|
||||
await expect(
|
||||
page.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]')
|
||||
).toHaveClass(/bg-red-400\/80/);
|
||||
}
|
||||
|
||||
export function newTimeEntryResponse(
|
||||
|
||||
@@ -51,3 +51,31 @@ export async function getInvitationAcceptUrl(
|
||||
|
||||
return acceptUrlMatch![1].replace(/&/g, '&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the password reset URL from a Mailpit email sent to the given address.
|
||||
* Retries a few times to allow for email delivery delay.
|
||||
*/
|
||||
export async function getPasswordResetUrl(
|
||||
request: APIRequestContext,
|
||||
recipientEmail: string
|
||||
): Promise<string> {
|
||||
let searchResult: { messages: Array<{ ID: string }> } = { messages: [] };
|
||||
|
||||
// Retry up to 5 times with 500ms delay to allow for email delivery
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
searchResult = await searchEmails(
|
||||
request,
|
||||
`to:${encodeURIComponent(recipientEmail)} subject:"Reset Password"`
|
||||
);
|
||||
if (searchResult.messages.length > 0) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
expect(searchResult.messages.length).toBeGreaterThan(0);
|
||||
|
||||
const message = await getMessage(request, searchResult.messages[0].ID);
|
||||
const resetUrlMatch = message.HTML.match(/href="([^"]*reset-password[^"]*)"/);
|
||||
expect(resetUrlMatch).toBeTruthy();
|
||||
|
||||
return resetUrlMatch![1].replace(/&/g, '&');
|
||||
}
|
||||
Generated
+2
-2
@@ -25,7 +25,7 @@
|
||||
"@tanstack/vue-table": "^8.21.2",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.3.0",
|
||||
"@vueuse/core": "^14.0.0",
|
||||
"@vueuse/core": "^14.2.0",
|
||||
"@vueuse/integrations": "^14.0.0",
|
||||
"@zodios/core": "^10.9.6",
|
||||
"chroma-js": "3.1.2",
|
||||
@@ -38,7 +38,7 @@
|
||||
"parse-duration": "^2.0.1",
|
||||
"pinia": "^3.0.0",
|
||||
"radix-vue": "^1.9.6",
|
||||
"reka-ui": "^2.7.0",
|
||||
"reka-ui": "^2.8.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vue-echarts": "^8.0.0",
|
||||
|
||||
+3
-3
@@ -45,7 +45,6 @@
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.6.0",
|
||||
"@floating-ui/vue": "^1.0.6",
|
||||
"@zodios/core": "^10.9.6",
|
||||
"@fullcalendar/core": "^6.1.18",
|
||||
"@fullcalendar/daygrid": "^6.1.18",
|
||||
"@fullcalendar/interaction": "^6.1.18",
|
||||
@@ -60,8 +59,9 @@
|
||||
"@tanstack/vue-table": "^8.21.2",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.3.0",
|
||||
"@vueuse/core": "^14.0.0",
|
||||
"@vueuse/core": "^14.2.0",
|
||||
"@vueuse/integrations": "^14.0.0",
|
||||
"@zodios/core": "^10.9.6",
|
||||
"chroma-js": "3.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -72,7 +72,7 @@
|
||||
"parse-duration": "^2.0.1",
|
||||
"pinia": "^3.0.0",
|
||||
"radix-vue": "^1.9.6",
|
||||
"reka-ui": "^2.7.0",
|
||||
"reka-ui": "^2.8.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vue-echarts": "^8.0.0",
|
||||
|
||||
+11
-31
@@ -17,8 +17,8 @@ export default defineConfig({
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1,
|
||||
/* Run tests in parallel */
|
||||
workers: 4,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: process.env.CI ? 'line' : 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
@@ -39,35 +39,15 @@ export default defineConfig({
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
|
||||
// {
|
||||
// name: 'webkit',
|
||||
// use: { ...devices['Desktop Safari'] },
|
||||
// },
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: { ...devices['Pixel 5'] },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: { ...devices['iPhone 12'] },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||
// },
|
||||
// Firefox only in CI to keep local runs fast
|
||||
...(process.env.CI
|
||||
? [
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export const PLAYWRIGHT_BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://solidtime.test';
|
||||
export const MAILPIT_BASE_URL = process.env.MAILPIT_BASE_URL ?? 'http://mailpit:8025';
|
||||
export const TEST_USER_PASSWORD = 'amazingpassword123';
|
||||
|
||||
+73
-17
@@ -1,27 +1,83 @@
|
||||
import { test as baseTest } from '@playwright/test';
|
||||
import { PLAYWRIGHT_BASE_URL } from './config';
|
||||
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from './config';
|
||||
import { type TestContext, setupTestContext } from '../e2e/utils/api';
|
||||
|
||||
export * from '@playwright/test';
|
||||
export const test = baseTest.extend<object, { workerStorageState: string }>({
|
||||
// Use the same storage state for all tests in this worker.
|
||||
export type { TestContext };
|
||||
|
||||
/**
|
||||
* API-based authentication fixture - creates a new user via HTTP requests instead of UI interactions.
|
||||
* This is ~10-25x faster than UI-based authentication (~100-200ms vs ~3-5s).
|
||||
*
|
||||
* Uses page.context().request() to ensure cookies are shared between the API request and page.
|
||||
*/
|
||||
export const test = baseTest.extend<{ ctx: TestContext }, { workerStorageState: string }>({
|
||||
page: async ({ page }, use) => {
|
||||
// Perform authentication steps. Replace these actions with your own.
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
|
||||
await page.getByLabel('Name').fill('John Doe');
|
||||
await page.getByLabel('Email').fill(`john+${Math.round(Math.random() * 1000000)}@doe.com`);
|
||||
await page.getByLabel('Password', { exact: true }).fill('amazingpassword123');
|
||||
await page.getByLabel('Confirm Password').fill('amazingpassword123');
|
||||
await page.getByLabel('I agree to the Terms of').click();
|
||||
await page.getByRole('button', { name: 'Register' }).click();
|
||||
// Generate unique email for this test
|
||||
const email = `john+${Date.now()}_${Math.floor(Math.random() * 10000)}@doe.com`;
|
||||
const password = TEST_USER_PASSWORD;
|
||||
const name = 'John Doe';
|
||||
|
||||
// Wait until the page receives the cookies.
|
||||
//
|
||||
// Sometimes login flow sets cookies in the process of several redirects.
|
||||
// Wait for the final URL to ensure that the cookies are actually set.
|
||||
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||
// Use page.context().request() so cookies are automatically shared with the page
|
||||
const request = page.context().request;
|
||||
|
||||
// End of authentication steps.
|
||||
// Step 1: Visit the register page to get CSRF token and initial session
|
||||
const csrfResponse = await request.get(`${PLAYWRIGHT_BASE_URL}/register`, {
|
||||
maxRedirects: 0,
|
||||
});
|
||||
|
||||
// Extract XSRF-TOKEN from cookies
|
||||
const cookies = csrfResponse.headers()['set-cookie'];
|
||||
let xsrfToken = '';
|
||||
if (cookies) {
|
||||
const xsrfMatch = cookies.match(/XSRF-TOKEN=([^;]+)/);
|
||||
if (xsrfMatch) {
|
||||
xsrfToken = decodeURIComponent(xsrfMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Register via API (Laravel Fortify web routes)
|
||||
const registerResponse = await request.post(`${PLAYWRIGHT_BASE_URL}/register`, {
|
||||
headers: {
|
||||
'X-XSRF-TOKEN': xsrfToken,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'text/html',
|
||||
},
|
||||
form: {
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
password_confirmation: password,
|
||||
terms: 'on',
|
||||
},
|
||||
maxRedirects: 0,
|
||||
});
|
||||
|
||||
// Check if registration was successful (should redirect to dashboard)
|
||||
if (registerResponse.status() !== 302) {
|
||||
console.error('API registration failed, falling back to UI-based registration');
|
||||
|
||||
// Fall back to UI-based registration
|
||||
await page.goto(`${PLAYWRIGHT_BASE_URL}/register`);
|
||||
await page.getByLabel('Name').fill(name);
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(password);
|
||||
await page.getByLabel('Confirm Password').fill(password);
|
||||
await page.getByLabel('I agree to the Terms of').click();
|
||||
await page.getByRole('button', { name: 'Register' }).click();
|
||||
await page.waitForURL(`${PLAYWRIGHT_BASE_URL}/dashboard`);
|
||||
} else {
|
||||
// Registration succeeded - cookies are already set in the context from the request
|
||||
// Just navigate to dashboard to verify
|
||||
await page.goto(`${PLAYWRIGHT_BASE_URL}/dashboard`);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
}
|
||||
|
||||
await use(page);
|
||||
},
|
||||
|
||||
ctx: async ({ page }, use) => {
|
||||
const ctx = await setupTestContext(page);
|
||||
await use(ctx);
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user