mirror of
https://github.com/iritmaximus/workh.git
synced 2026-10-12 19:13:33 +03:00
Initial commit
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import {
|
||||
baseData,
|
||||
dayInput,
|
||||
expect,
|
||||
fetchData,
|
||||
MONDAY,
|
||||
newClient,
|
||||
registerViaApi,
|
||||
saved,
|
||||
seed,
|
||||
test,
|
||||
} from './fixtures';
|
||||
|
||||
async function openDelete(page: Page) {
|
||||
await page.goto('/');
|
||||
await page.getByText('Account', { exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Delete account…' }).click();
|
||||
}
|
||||
|
||||
test.describe('deleting an account', () => {
|
||||
test('cancel leaves everything in place', async ({ page, user }) => {
|
||||
await openDelete(page);
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Delete account…' })).toBeVisible();
|
||||
expect((await page.request.get('/api/session')).status()).toBe(200);
|
||||
void user;
|
||||
});
|
||||
|
||||
test('the delete button stays disabled until a password is typed', async ({ page, user }) => {
|
||||
await openDelete(page);
|
||||
await expect(page.getByRole('button', { name: 'Delete my account' })).toBeDisabled();
|
||||
void user;
|
||||
});
|
||||
|
||||
test('a wrong password deletes nothing', async ({ page, user }) => {
|
||||
await openDelete(page);
|
||||
await page.getByLabel('Enter your password to confirm').fill('wrong password');
|
||||
await page.getByRole('button', { name: 'Delete my account' }).click();
|
||||
await expect(page.getByRole('alert')).toHaveText('Wrong password');
|
||||
expect((await page.request.get('/api/session')).status()).toBe(200);
|
||||
void user;
|
||||
});
|
||||
|
||||
test('deletes the login and the hours; the name can be reused from scratch', async ({ page, user }) => {
|
||||
await seed(page.request, baseData({ entries: { [MONDAY]: { mode: 'total', minutes: 435 } } }));
|
||||
await openDelete(page);
|
||||
await page.getByLabel('Enter your password to confirm').fill(user.password);
|
||||
await page.getByRole('button', { name: 'Delete my account' }).click();
|
||||
await expect(page.getByText('Your account and all its data were deleted.')).toBeVisible();
|
||||
|
||||
// The old password no longer logs in.
|
||||
await page.getByLabel('Username').fill(user.username);
|
||||
await page.getByLabel('Password').fill(user.password);
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
await expect(page.getByRole('alert')).toHaveText('Wrong username or password');
|
||||
|
||||
// Re-registering the name gives an empty account, not the old hours.
|
||||
await registerViaApi(page.request, user);
|
||||
expect((await fetchData(page.request)).entries).toEqual({});
|
||||
});
|
||||
|
||||
test('another device logged into the deleted account is logged out', async ({ page, browser, user }) => {
|
||||
const other = await newClient(browser);
|
||||
await other.page.request.post('/api/login', { data: user });
|
||||
await other.page.goto('/');
|
||||
await expect(other.page.locator('.app-header')).toContainText(user.username);
|
||||
|
||||
await openDelete(page);
|
||||
await page.getByLabel('Enter your password to confirm').fill(user.password);
|
||||
await page.getByRole('button', { name: 'Delete my account' }).click();
|
||||
await expect(page.getByText('Your account and all its data were deleted.')).toBeVisible();
|
||||
|
||||
// The other device notices on its next save…
|
||||
await dayInput(other.page, MONDAY).fill('7:15');
|
||||
await expect(other.page.getByText(/session expired/i)).toBeVisible();
|
||||
// …and nothing was written for the deleted account.
|
||||
await registerViaApi(page.request, user);
|
||||
expect((await fetchData(page.request)).entries).toEqual({});
|
||||
await other.context.close();
|
||||
});
|
||||
|
||||
test('a change typed just before deleting is not saved afterwards', async ({ page, user }) => {
|
||||
await openDelete(page);
|
||||
await dayInput(page, MONDAY).fill('7:15');
|
||||
await page.getByLabel('Enter your password to confirm').fill(user.password);
|
||||
await page.getByRole('button', { name: 'Delete my account' }).click();
|
||||
await expect(page.getByText('Your account and all its data were deleted.')).toBeVisible();
|
||||
await page.waitForTimeout(800);
|
||||
await registerViaApi(page.request, user);
|
||||
expect((await fetchData(page.request)).entries).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
test('saving shows progress and then "Saved ✓"', async ({ page, user }) => {
|
||||
await page.goto('/');
|
||||
await dayInput(page, MONDAY).fill('7:15');
|
||||
await saved(page);
|
||||
void user;
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
baseData,
|
||||
dayInput,
|
||||
expect,
|
||||
fetchData,
|
||||
MONDAY,
|
||||
newClient,
|
||||
PASSWORD,
|
||||
registerViaApi,
|
||||
track,
|
||||
saved,
|
||||
seed,
|
||||
test,
|
||||
uniqueName,
|
||||
} from './fixtures';
|
||||
|
||||
test.describe('sign up', () => {
|
||||
test('creates an account through the form and lands in an empty tracker', async ({ page }) => {
|
||||
const username = uniqueName();
|
||||
track({ username, password: PASSWORD });
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: 'Create an account' }).click();
|
||||
await page.getByLabel('Username').fill(username);
|
||||
await page.getByLabel('Password', { exact: true }).fill(PASSWORD);
|
||||
await page.getByLabel('Repeat password').fill(PASSWORD);
|
||||
await page.getByRole('button', { name: 'Create account' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Week 41/ })).toBeVisible();
|
||||
await expect(page.locator('.app-header')).toContainText(username);
|
||||
await expect(page.locator('.week-total')).toContainText('0 h 00 min');
|
||||
});
|
||||
|
||||
test('stores mixed-case usernames lowercase, and both spellings log in', async ({ page }) => {
|
||||
const username = uniqueName('Mixed');
|
||||
await registerViaApi(page.request, { username, password: PASSWORD });
|
||||
await page.request.post('/api/logout', { data: {} });
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByLabel('Username').fill(username.toUpperCase());
|
||||
await page.getByLabel('Password').fill(PASSWORD);
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
await expect(page.locator('.app-header')).toContainText(username.toLowerCase());
|
||||
});
|
||||
|
||||
test('refuses a taken username without logging into the existing account', async ({ page, user }) => {
|
||||
await page.request.post('/api/logout', { data: {} });
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: 'Create an account' }).click();
|
||||
await page.getByLabel('Username').fill(user.username);
|
||||
await page.getByLabel('Password', { exact: true }).fill('some other password');
|
||||
await page.getByLabel('Repeat password').fill('some other password');
|
||||
await page.getByRole('button', { name: 'Create account' }).click();
|
||||
await expect(page.getByRole('alert')).toHaveText('That username is taken');
|
||||
expect((await page.request.get('/api/session')).status()).toBe(401);
|
||||
});
|
||||
|
||||
test('blocks a username with spaces in the browser before it reaches the server', async ({ page }) => {
|
||||
const registerCalls: string[] = [];
|
||||
page.on('request', (r) => r.url().endsWith('/api/register') && registerCalls.push(r.url()));
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: 'Create an account' }).click();
|
||||
await page.getByLabel('Username').fill('has space');
|
||||
await page.getByLabel('Password', { exact: true }).fill(PASSWORD);
|
||||
await page.getByLabel('Repeat password').fill(PASSWORD);
|
||||
await page.getByRole('button', { name: 'Create account' }).click();
|
||||
|
||||
expect(await page.getByLabel('Username').evaluate((el: HTMLInputElement) => el.validity.patternMismatch)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(registerCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test('a password typo in the repeat field is caught', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: 'Create an account' }).click();
|
||||
await page.getByLabel('Username').fill(uniqueName());
|
||||
await page.getByLabel('Password', { exact: true }).fill(PASSWORD);
|
||||
await page.getByLabel('Repeat password').fill(PASSWORD + 'x');
|
||||
await page.getByRole('button', { name: 'Create account' }).click();
|
||||
await expect(page.getByRole('alert')).toHaveText('Passwords do not match');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('log in / log out', () => {
|
||||
test('a session survives a reload; logging out survives a reload too', async ({ page, user }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.app-header')).toContainText(user.username);
|
||||
await page.reload();
|
||||
await expect(page.locator('.app-header')).toContainText(user.username);
|
||||
|
||||
await page.getByRole('button', { name: 'Log out' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
|
||||
expect((await page.request.get('/api/data')).status()).toBe(401);
|
||||
});
|
||||
|
||||
test('a wrong password shows an error and clears only the password field', async ({ page, user }) => {
|
||||
await page.request.post('/api/logout', { data: {} });
|
||||
await page.goto('/');
|
||||
await page.getByLabel('Username').fill(user.username);
|
||||
await page.getByLabel('Password').fill('not my password');
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
await expect(page.getByRole('alert')).toHaveText('Wrong username or password');
|
||||
await expect(page.getByLabel('Username')).toHaveValue(user.username);
|
||||
await expect(page.getByLabel('Password')).toHaveValue('');
|
||||
});
|
||||
|
||||
test('ten wrong passwords lock the client out, even with the right one', async ({ page, user }) => {
|
||||
await page.request.post('/api/logout', { data: {} });
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await page.request.post('/api/login', { data: { username: user.username, password: 'wrong guess' } });
|
||||
}
|
||||
await page.goto('/');
|
||||
await page.getByLabel('Username').fill(user.username);
|
||||
await page.getByLabel('Password').fill(user.password);
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
await expect(page.getByRole('alert')).toContainText('Too many failed logins');
|
||||
});
|
||||
|
||||
test('logging out right after typing still saves the change', async ({ page, user }) => {
|
||||
await page.goto('/');
|
||||
await dayInput(page, MONDAY).fill('7:15');
|
||||
// Within the 500 ms save debounce:
|
||||
await page.getByRole('button', { name: 'Log out' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
|
||||
|
||||
await page.request.post('/api/login', { data: user });
|
||||
expect((await fetchData(page.request)).entries[MONDAY]).toEqual({ mode: 'total', minutes: 435 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('expired session', () => {
|
||||
test('asks to log in again and then saves the change made meanwhile', async ({ page, context, user }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { name: /Week 41/ })).toBeVisible();
|
||||
await context.clearCookies(); // the session is gone, the page doesn't know yet
|
||||
|
||||
await dayInput(page, MONDAY).fill('6:00');
|
||||
await expect(page.getByText(/session expired/i)).toBeVisible();
|
||||
await page.getByLabel('Username').fill(user.username);
|
||||
await page.getByLabel('Password').fill(user.password);
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Week 41/ })).toBeVisible();
|
||||
await saved(page);
|
||||
expect((await fetchData(page.request)).entries[MONDAY]).toEqual({ mode: 'total', minutes: 360 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('several users and devices', () => {
|
||||
test('users never see each other’s hours', async ({ browser }) => {
|
||||
const alice = await newClient(browser);
|
||||
const bob = await newClient(browser);
|
||||
await registerViaApi(alice.page.request, { username: uniqueName('alice'), password: PASSWORD });
|
||||
await registerViaApi(bob.page.request, { username: uniqueName('bob'), password: PASSWORD });
|
||||
await seed(alice.page.request, baseData({ entries: { [MONDAY]: { mode: 'total', minutes: 600 } } }));
|
||||
|
||||
await bob.page.goto('/');
|
||||
await expect(dayInput(bob.page, MONDAY)).toHaveValue('');
|
||||
await expect(bob.page.locator('.week-total')).toContainText('0 h 00 min');
|
||||
|
||||
await alice.page.goto('/');
|
||||
await expect(dayInput(alice.page, MONDAY)).toHaveValue('10:00');
|
||||
await alice.context.close();
|
||||
await bob.context.close();
|
||||
});
|
||||
|
||||
// Question: what happens with two devices open at once (phone + laptop)? Each save sends
|
||||
// the whole document, so a device with an old copy must not wipe the other's changes.
|
||||
test('two tabs editing different days keep both changes', async ({ page, context, user }) => {
|
||||
const tabA = page;
|
||||
const tabB = await context.newPage();
|
||||
await tabB.clock.setFixedTime(new Date('2026-10-08T12:00:00'));
|
||||
await tabA.goto('/');
|
||||
await tabB.goto('/');
|
||||
await expect(dayInput(tabB, MONDAY)).toBeVisible();
|
||||
|
||||
await dayInput(tabA, MONDAY).fill('8:00');
|
||||
await saved(tabA);
|
||||
await dayInput(tabB, '2026-10-06').fill('7:00'); // tab B still has the old copy
|
||||
await saved(tabB);
|
||||
|
||||
const data = await fetchData(page.request);
|
||||
expect(data.entries[MONDAY]).toEqual({ mode: 'total', minutes: 480 });
|
||||
expect(data.entries['2026-10-06']).toEqual({ mode: 'total', minutes: 420 });
|
||||
// Tab B now also shows tab A's Monday.
|
||||
await expect(dayInput(tabB, MONDAY)).toHaveValue('8:00');
|
||||
void user;
|
||||
});
|
||||
});
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
import { test as base, expect, type APIRequestContext, type Browser, type Page } from '@playwright/test';
|
||||
import type { Data } from '../shared/types';
|
||||
|
||||
/** Thursday of ISO week 41 (Mon 5 Oct – Sun 11 Oct 2026), midday local time. */
|
||||
export const NOW = new Date('2026-10-08T12:00:00');
|
||||
export const MONDAY = '2026-10-05';
|
||||
export const PASSWORD = 'correct horse battery';
|
||||
|
||||
export interface Creds {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
let counter = 0;
|
||||
/** Unique, valid username (3–32 chars, lowercase). Parallel workers never collide. */
|
||||
export function uniqueName(prefix = 'u'): string {
|
||||
return `${prefix}${process.pid.toString(36)}${(counter++).toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A made-up client address. The server trusts X-Forwarded-For from loopback (like it trusts
|
||||
* Traefik in the cluster), so each test is a separate client for the per-IP rate limits.
|
||||
*/
|
||||
export function randomIp(): string {
|
||||
const b = () => Math.floor(Math.random() * 254) + 1;
|
||||
return `10.${b()}.${b()}.${b()}`;
|
||||
}
|
||||
|
||||
// Accounts created by the running test; deleted afterwards so the servers never reach
|
||||
// their account limit (MAX_USERS), however often the suite runs.
|
||||
const created: { creds: Creds; baseURL: string }[] = [];
|
||||
|
||||
/** Remember an account created through the UI so it gets cleaned up too. */
|
||||
export function track(creds: Creds, baseURL = 'http://localhost:3100') {
|
||||
created.push({ creds, baseURL });
|
||||
}
|
||||
|
||||
export async function registerViaApi(request: APIRequestContext, creds: Creds) {
|
||||
const r = await request.post('/api/register', { data: creds });
|
||||
expect(r.status(), await r.text()).toBe(201);
|
||||
track(creds, new URL(r.url()).origin);
|
||||
}
|
||||
|
||||
// Plain fetch, not a Playwright request context: this runs during teardown, outside the
|
||||
// test's trace recording.
|
||||
async function deleteCreated() {
|
||||
for (const { creds, baseURL } of created.splice(0)) {
|
||||
const headers = { 'Content-Type': 'application/json', 'X-Forwarded-For': randomIp() };
|
||||
const login = await fetch(`${baseURL}/api/login`, { method: 'POST', headers, body: JSON.stringify(creds) });
|
||||
if (!login.ok) continue; // already deleted by the test, or never finished signing up
|
||||
const cookie = login.headers.get('set-cookie')!.split(';')[0];
|
||||
await fetch(`${baseURL}/api/account`, {
|
||||
method: 'DELETE',
|
||||
headers: { ...headers, Cookie: cookie },
|
||||
body: JSON.stringify({ password: creds.password }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace the logged-in user's data — test setup without clicking through every field. */
|
||||
export async function seed(request: APIRequestContext, data: Data) {
|
||||
const etag = (await request.get('/api/data')).headers()['etag'];
|
||||
const r = await request.put('/api/data', { data, headers: { 'If-Match': etag } });
|
||||
expect(r.status(), await r.text()).toBe(200);
|
||||
}
|
||||
|
||||
export function baseData(overrides: Partial<Data> = {}): Data {
|
||||
return {
|
||||
settings: { fullTimeWeekMinutes: 2175, workdays: [1, 2, 3, 4, 5], trackingStart: MONDAY, openingBalanceMinutes: 0 },
|
||||
workloadPeriods: [{ from: '2026-01-01', percent: 100 }],
|
||||
entries: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** New browser context = a different device/person, with its own IP and frozen clock. */
|
||||
export async function newClient(browser: Browser, baseURL = 'http://localhost:3100') {
|
||||
const context = await browser.newContext({ baseURL, extraHTTPHeaders: { 'X-Forwarded-For': randomIp() } });
|
||||
const page = await context.newPage();
|
||||
await page.clock.setFixedTime(NOW);
|
||||
return { context, page };
|
||||
}
|
||||
|
||||
// ---------- Page helpers ----------
|
||||
|
||||
export const dayInput = (page: Page, date: string) => page.getByLabel(`Hours worked on ${date}`);
|
||||
export const dayRow = (page: Page, date: string) =>
|
||||
page.locator('.day').filter({ has: page.getByRole('group', { name: `Input mode for ${date}` }) });
|
||||
export const weekFooter = (page: Page) => page.locator('.week-total');
|
||||
export const balanceCard = (page: Page) => page.locator('section.balance');
|
||||
export const saved = (page: Page) => expect(page.getByText('Saved ✓')).toBeVisible();
|
||||
|
||||
export async function fetchData(request: APIRequestContext): Promise<Data> {
|
||||
const r = await request.get('/api/data');
|
||||
expect(r.status()).toBe(200);
|
||||
return (await r.json()) as Data;
|
||||
}
|
||||
|
||||
// ---------- Fixtures ----------
|
||||
|
||||
export const test = base.extend<{ user: Creds; cleanup: void }>({
|
||||
cleanup: [
|
||||
async ({}, use) => {
|
||||
await use();
|
||||
await deleteCreated();
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
extraHTTPHeaders: async ({}, use) => {
|
||||
await use({ 'X-Forwarded-For': randomIp() });
|
||||
},
|
||||
page: async ({ page }, use) => {
|
||||
await page.clock.setFixedTime(NOW);
|
||||
await use(page);
|
||||
},
|
||||
/** A freshly registered user whose session cookie is already in the page's context. */
|
||||
user: async ({ page }, use) => {
|
||||
const creds = { username: uniqueName(), password: PASSWORD };
|
||||
await registerViaApi(page.request, creds);
|
||||
await use(creds);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
@@ -0,0 +1,256 @@
|
||||
import {
|
||||
balanceCard,
|
||||
baseData,
|
||||
dayInput,
|
||||
dayRow,
|
||||
expect,
|
||||
fetchData,
|
||||
MONDAY,
|
||||
saved,
|
||||
seed,
|
||||
test,
|
||||
weekFooter,
|
||||
} from './fixtures';
|
||||
|
||||
const TUE = '2026-10-06';
|
||||
const WED = '2026-10-07';
|
||||
const THU = '2026-10-08'; // "today"
|
||||
const FRI = '2026-10-09';
|
||||
const WEEKDAYS = [MONDAY, TUE, WED, THU, FRI];
|
||||
|
||||
test.describe('entering total hours', () => {
|
||||
test('a full 7:15 week is exactly on target, and survives a reload', async ({ page, user }) => {
|
||||
await page.goto('/');
|
||||
for (const d of WEEKDAYS) await dayInput(page, d).fill('7:15');
|
||||
await expect(weekFooter(page)).toContainText('Worked36 h 15 min');
|
||||
await expect(weekFooter(page)).toContainText('Target36 h 15 min');
|
||||
await expect(weekFooter(page)).toContainText('0 h 00 min — on target');
|
||||
await saved(page);
|
||||
|
||||
await page.reload();
|
||||
for (const d of WEEKDAYS) await expect(dayInput(page, d)).toHaveValue('7:15');
|
||||
void user;
|
||||
});
|
||||
|
||||
for (const [typed, minutes] of [
|
||||
['7.5', 450],
|
||||
['7,5', 450],
|
||||
['7:30', 450],
|
||||
['7h30', 450],
|
||||
['7h 30min', 450],
|
||||
['450m', 450],
|
||||
['8', 480],
|
||||
] as const) {
|
||||
test(`understands "${typed}"`, async ({ page, user }) => {
|
||||
await page.goto('/');
|
||||
const input = dayInput(page, MONDAY);
|
||||
await input.fill(typed);
|
||||
await input.blur();
|
||||
await expect(input).toHaveValue(`${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}`);
|
||||
await saved(page);
|
||||
expect((await fetchData(page.request)).entries[MONDAY]).toEqual({ mode: 'total', minutes });
|
||||
void user;
|
||||
});
|
||||
}
|
||||
|
||||
// Question: a single day can't be negative or longer than a day. Before this test the app
|
||||
// accepted "-2" (subtracting hours) and "30" for one day.
|
||||
for (const typed of ['-2', '25', '30:00', 'abc', '7:75']) {
|
||||
test(`rejects "${typed}" for a day and saves nothing`, async ({ page, user }) => {
|
||||
await page.goto('/');
|
||||
const input = dayInput(page, MONDAY);
|
||||
await input.fill(typed);
|
||||
await expect(input).toHaveAttribute('aria-invalid', 'true');
|
||||
await expect(weekFooter(page)).toContainText('Worked0 h 00 min');
|
||||
await page.waitForTimeout(800); // longer than the save debounce
|
||||
expect((await fetchData(page.request)).entries[MONDAY]).toBeUndefined();
|
||||
void user;
|
||||
});
|
||||
}
|
||||
|
||||
test('clearing a day removes it', async ({ page, user }) => {
|
||||
await seed(page.request, baseData({ entries: { [MONDAY]: { mode: 'total', minutes: 435 } } }));
|
||||
await page.goto('/');
|
||||
await dayInput(page, MONDAY).fill('');
|
||||
await saved(page);
|
||||
expect((await fetchData(page.request)).entries[MONDAY]).toBeUndefined();
|
||||
void user;
|
||||
});
|
||||
|
||||
// Question: the save waits 500 ms after typing. Is a change lost if the tab is closed sooner?
|
||||
test('a change is saved even when the page is reloaded immediately', async ({ page, user }) => {
|
||||
await page.goto('/');
|
||||
await dayInput(page, MONDAY).fill('6:45');
|
||||
await page.reload();
|
||||
await expect(dayInput(page, MONDAY)).toHaveValue('6:45');
|
||||
void user;
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('start–end ranges', () => {
|
||||
async function setRange(
|
||||
page: import('@playwright/test').Page,
|
||||
date: string,
|
||||
start: string,
|
||||
end: string,
|
||||
br?: string,
|
||||
) {
|
||||
const row = dayRow(page, date);
|
||||
await row.getByRole('button', { name: 'Start–End' }).click();
|
||||
await row.getByLabel('Start').fill(start);
|
||||
await row.getByLabel('End').fill(end);
|
||||
if (br !== undefined) await row.getByRole('spinbutton').fill(br);
|
||||
return row;
|
||||
}
|
||||
|
||||
test('subtracts the default 30 min break', async ({ page, user }) => {
|
||||
await page.goto('/');
|
||||
const row = await setRange(page, MONDAY, '08:00', '16:00');
|
||||
await expect(row.locator('.day-figures')).toContainText('7:30');
|
||||
await saved(page);
|
||||
expect((await fetchData(page.request)).entries[MONDAY]).toEqual({
|
||||
mode: 'range',
|
||||
start: '08:00',
|
||||
end: '16:00',
|
||||
breakMin: 30,
|
||||
});
|
||||
void user;
|
||||
});
|
||||
|
||||
test('handles a shift past midnight', async ({ page, user }) => {
|
||||
await page.goto('/');
|
||||
const row = await setRange(page, MONDAY, '22:00', '06:00', '0');
|
||||
await expect(row.locator('.day-figures')).toContainText('8:00');
|
||||
void user;
|
||||
});
|
||||
|
||||
test('a break longer than the shift gives zero, not negative hours', async ({ page, user }) => {
|
||||
await page.goto('/');
|
||||
const row = await setRange(page, MONDAY, '09:00', '10:00', '90');
|
||||
await expect(row.locator('.day-figures')).toContainText('0:00');
|
||||
void user;
|
||||
});
|
||||
|
||||
test('only a start time counts as nothing yet', async ({ page, user }) => {
|
||||
await page.goto('/');
|
||||
const row = dayRow(page, MONDAY);
|
||||
await row.getByRole('button', { name: 'Start–End' }).click();
|
||||
await row.getByLabel('Start').fill('08:00');
|
||||
await expect(row.locator('.day-figures')).toContainText('0:00');
|
||||
void user;
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('off days', () => {
|
||||
test('an off day drops out of the target and keeps its note', async ({ page, user }) => {
|
||||
await page.goto('/');
|
||||
const row = dayRow(page, WED);
|
||||
await row.getByRole('button', { name: 'Off' }).click();
|
||||
await row.getByPlaceholder('Vacation, sick, holiday…').fill('Vacation');
|
||||
await expect(weekFooter(page)).toContainText('Target29 h 00 min');
|
||||
await saved(page);
|
||||
await page.reload();
|
||||
await expect(dayRow(page, WED).getByPlaceholder('Vacation, sick, holiday…')).toHaveValue('Vacation');
|
||||
void user;
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('week view and balance', () => {
|
||||
test('the week number and dates are right, including around New Year', async ({ page, user }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { level: 2, name: /Week/ })).toHaveText('Week 41 5 Oct – 11 Oct 2026');
|
||||
const next = page.getByRole('button', { name: 'Next week' });
|
||||
for (let i = 0; i < 12; i++) await next.click();
|
||||
// 2026 has 53 ISO weeks; this one contains 1 Jan 2027.
|
||||
await expect(page.getByRole('heading', { level: 2, name: /Week/ })).toHaveText('Week 53 28 Dec – 3 Jan 2027');
|
||||
await next.click();
|
||||
await expect(page.getByRole('heading', { level: 2, name: /Week/ })).toHaveText('Week 1 4 Jan – 10 Jan 2027');
|
||||
await page.getByRole('button', { name: 'This week' }).click();
|
||||
await expect(page.getByRole('heading', { level: 2, name: /Week 41/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'This week' })).toHaveCount(0);
|
||||
void user;
|
||||
});
|
||||
|
||||
test('today is highlighted', async ({ page, user }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.day.today')).toHaveCount(1);
|
||||
await expect(page.locator('.day.today')).toContainText('Thu');
|
||||
void user;
|
||||
});
|
||||
|
||||
test('the balance counts up to today only; future days in the week do not count yet', async ({ page, user }) => {
|
||||
await seed(
|
||||
page.request,
|
||||
baseData({
|
||||
entries: {
|
||||
[MONDAY]: { mode: 'total', minutes: 435 },
|
||||
[TUE]: { mode: 'total', minutes: 435 },
|
||||
[WED]: { mode: 'total', minutes: 435 },
|
||||
[THU]: { mode: 'total', minutes: 495 }, // +1 h today
|
||||
[FRI]: { mode: 'total', minutes: 600 }, // tomorrow, already logged
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.goto('/');
|
||||
await expect(balanceCard(page)).toContainText('+1 h 00 min');
|
||||
// The week view, by contrast, shows the whole week.
|
||||
await expect(weekFooter(page)).toContainText('+3 h 45 min over');
|
||||
void user;
|
||||
});
|
||||
|
||||
test('hours before the tracking start date are ignored by the balance', async ({ page, user }) => {
|
||||
await seed(
|
||||
page.request,
|
||||
baseData({
|
||||
settings: { ...baseData().settings, trackingStart: WED },
|
||||
entries: { [MONDAY]: { mode: 'total', minutes: 600 }, [WED]: { mode: 'total', minutes: 435 } },
|
||||
}),
|
||||
);
|
||||
await page.goto('/');
|
||||
// Wed logged 7:15, Thu (today) nothing -> −7:15. Monday's 10 h must not count.
|
||||
await expect(balanceCard(page)).toContainText('−7 h 15 min');
|
||||
void user;
|
||||
});
|
||||
|
||||
test('the history adds up week by week and links to each week', async ({ page, user }) => {
|
||||
await seed(
|
||||
page.request,
|
||||
baseData({
|
||||
settings: { ...baseData().settings, trackingStart: '2026-09-28' },
|
||||
entries: Object.fromEntries(
|
||||
['2026-09-28', '2026-09-29', '2026-09-30', '2026-10-01', '2026-10-02'].map((d) => [
|
||||
d,
|
||||
{ mode: 'total' as const, minutes: 495 },
|
||||
]),
|
||||
),
|
||||
}),
|
||||
);
|
||||
await page.goto('/');
|
||||
const rows = page.locator('table tbody tr');
|
||||
await expect(rows).toHaveCount(2);
|
||||
// Newest first. Week 40: +5 h. Week 41 Mon–Thu: nothing logged -> −29 h.
|
||||
await expect(rows.nth(1)).toContainText('+5 h 00 min');
|
||||
await expect(rows.nth(0)).toContainText('−29 h 00 min');
|
||||
await expect(rows.nth(0)).toContainText('−24 h 00 min');
|
||||
await expect(balanceCard(page)).toContainText('−24 h 00 min');
|
||||
|
||||
await rows.nth(1).click();
|
||||
await expect(page.getByRole('heading', { level: 2, name: /Week 40/ })).toBeVisible();
|
||||
await expect(dayInput(page, '2026-09-28')).toHaveValue('8:15');
|
||||
void user;
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('time zones', () => {
|
||||
// Question: "today" comes from the browser clock. Just after midnight on a Monday, is it
|
||||
// already the new week — in a time zone far from the server's?
|
||||
test.use({ timezoneId: 'Pacific/Auckland' });
|
||||
|
||||
test('just after midnight on Monday is already the new week', async ({ page, user }) => {
|
||||
await page.clock.setFixedTime(new Date('2026-10-12T00:30:00+13:00'));
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { level: 2, name: /Week/ })).toHaveText('Week 42 12 Oct – 18 Oct 2026');
|
||||
await expect(page.locator('.day.today')).toContainText('Mon');
|
||||
void user;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { expect, PASSWORD, test, track, uniqueName } from './fixtures';
|
||||
|
||||
// This server runs with REGISTRATION_CODE=let-me-in-please.
|
||||
test.use({ baseURL: 'http://localhost:3101' });
|
||||
|
||||
test.describe('invite-only sign-up', () => {
|
||||
async function fillSignUp(page: import('@playwright/test').Page, code: string) {
|
||||
const username = uniqueName();
|
||||
track({ username, password: PASSWORD }, 'http://localhost:3101');
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: 'Create an account' }).click();
|
||||
await page.getByLabel('Username').fill(username);
|
||||
await page.getByLabel('Password', { exact: true }).fill(PASSWORD);
|
||||
await page.getByLabel('Repeat password').fill(PASSWORD);
|
||||
await page.getByLabel('Invite code').fill(code);
|
||||
await page.getByRole('button', { name: 'Create account' }).click();
|
||||
}
|
||||
|
||||
test('a wrong code is refused', async ({ page }) => {
|
||||
await fillSignUp(page, 'guess-the-code');
|
||||
await expect(page.getByRole('alert')).toHaveText('Invalid invite code');
|
||||
});
|
||||
|
||||
test('the right code creates the account', async ({ page }) => {
|
||||
await fillSignUp(page, 'let-me-in-please');
|
||||
await expect(page.getByRole('heading', { name: /Week 41/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test('the API refuses sign-up without the code too', async ({ page }) => {
|
||||
const r = await page.request.post('/api/register', { data: { username: uniqueName(), password: PASSWORD } });
|
||||
expect(r.status()).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { dayInput, expect, MONDAY, saved, test } from './fixtures';
|
||||
|
||||
async function expectNoSideways(page: Page) {
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
|
||||
expect(overflow).toBeLessThanOrEqual(0);
|
||||
}
|
||||
|
||||
test.describe('on a phone @mobile', () => {
|
||||
test('the login screen fits the width', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
|
||||
await expectNoSideways(page);
|
||||
});
|
||||
|
||||
test('the tracker fits the width and hours can be entered', async ({ page, user }) => {
|
||||
await page.goto('/');
|
||||
await expectNoSideways(page);
|
||||
await dayInput(page, MONDAY).tap();
|
||||
await dayInput(page, MONDAY).fill('7:15');
|
||||
await saved(page);
|
||||
await page.getByText('Settings', { exact: true }).tap();
|
||||
await expectNoSideways(page);
|
||||
void user;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { request as apiRequest } from '@playwright/test';
|
||||
import { baseData, dayRow, expect, MONDAY, seed, test } from './fixtures';
|
||||
|
||||
test.describe('session cookie', () => {
|
||||
test('is HttpOnly, Secure and SameSite=Strict, and invisible to page scripts', async ({ page, context, user }) => {
|
||||
await page.goto('/');
|
||||
const cookie = (await context.cookies()).find((c) => c.name === 'workh_session');
|
||||
expect(cookie).toMatchObject({ httpOnly: true, secure: true, sameSite: 'Strict' });
|
||||
// Expires in ~30 days (a persistent cookie, not a browser-session one).
|
||||
const days = (cookie!.expires * 1000 - Date.now()) / 86_400_000;
|
||||
expect(days).toBeGreaterThan(29);
|
||||
expect(days).toBeLessThan(31);
|
||||
expect(await page.evaluate(() => document.cookie)).not.toContain('workh_session');
|
||||
void user;
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('API without a session', () => {
|
||||
test('refuses every private endpoint', async ({ baseURL }) => {
|
||||
const anon = await apiRequest.newContext({ baseURL });
|
||||
expect((await anon.get('/api/data')).status()).toBe(401);
|
||||
expect((await anon.put('/api/data', { data: baseData() })).status()).toBe(401);
|
||||
expect((await anon.get('/api/session')).status()).toBe(401);
|
||||
expect((await anon.delete('/api/account', { data: { password: 'x' } })).status()).toBe(401);
|
||||
await anon.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('content', () => {
|
||||
test('HTML typed into a note is shown as text, never run', async ({ page, user }) => {
|
||||
const payload = '<img src=x onerror="window.xssRan=1">';
|
||||
await seed(page.request, baseData({ entries: { [MONDAY]: { mode: 'off', note: payload } } }));
|
||||
await page.goto('/');
|
||||
await expect(dayRow(page, MONDAY).getByPlaceholder('Vacation, sick, holiday…')).toHaveValue(payload);
|
||||
expect(await page.evaluate(() => (window as unknown as { xssRan?: number }).xssRan)).toBeUndefined();
|
||||
void user;
|
||||
});
|
||||
|
||||
test('the page runs without console errors or CSP violations', async ({ page, user }) => {
|
||||
const problems: string[] = [];
|
||||
page.on('console', (m) => m.type() === 'error' && problems.push(m.text()));
|
||||
page.on('pageerror', (e) => problems.push(e.message));
|
||||
await page.goto('/');
|
||||
await page.getByText('Settings', { exact: true }).click();
|
||||
await page.getByText('Account', { exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Next week' }).click();
|
||||
await expect(page.getByRole('heading', { level: 2, name: /Week 42/ })).toBeVisible();
|
||||
expect(problems).toEqual([]);
|
||||
void user;
|
||||
});
|
||||
|
||||
test('the login page also loads cleanly under the CSP', async ({ page }) => {
|
||||
const problems: string[] = [];
|
||||
page.on('console', (m) => m.type() === 'error' && !m.text().includes('401') && problems.push(m.text()));
|
||||
page.on('pageerror', (e) => problems.push(e.message));
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
|
||||
expect(problems).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { balanceCard, baseData, dayRow, expect, fetchData, MONDAY, saved, seed, test, weekFooter } from './fixtures';
|
||||
|
||||
async function openSettings(page: Page) {
|
||||
await page.getByText('Settings', { exact: true }).click();
|
||||
}
|
||||
|
||||
test.describe('workload periods', () => {
|
||||
test('a 50% period from Wednesday halves the target from that day on', async ({ page, user }) => {
|
||||
await page.goto('/');
|
||||
await openSettings(page);
|
||||
await page.getByRole('button', { name: '+ Add period' }).click(); // starts today at 50%
|
||||
const periods = page.locator('.periods li');
|
||||
await expect(periods).toHaveCount(2);
|
||||
await periods.nth(1).locator('input[type=date]').fill('2026-10-07');
|
||||
|
||||
// Mon, Tue 7:15; Wed–Fri 3:37.5
|
||||
await expect(dayRow(page, MONDAY).locator('.day-figures')).toContainText('/ 7:15');
|
||||
await expect(dayRow(page, '2026-10-07').locator('.day-figures')).toContainText('/ 3:38');
|
||||
await expect(weekFooter(page)).toContainText('Target25 h 23 min'); // 14:30 + 10:52.5, rounded
|
||||
await saved(page);
|
||||
// A new account's first period starts the Monday it was created (server clock).
|
||||
expect((await fetchData(page.request)).workloadPeriods).toEqual([
|
||||
{ from: expect.any(String), percent: 100 },
|
||||
{ from: '2026-10-07', percent: 50 },
|
||||
]);
|
||||
void user;
|
||||
});
|
||||
|
||||
test('25% gives 9:04 a week and the period list shows it', async ({ page, user }) => {
|
||||
await seed(page.request, baseData({ workloadPeriods: [{ from: '2026-01-01', percent: 25 }] }));
|
||||
await page.goto('/');
|
||||
await expect(weekFooter(page)).toContainText('Target9 h 04 min');
|
||||
await openSettings(page);
|
||||
await expect(page.locator('.periods li')).toContainText('= 9:04 h/week');
|
||||
void user;
|
||||
});
|
||||
|
||||
test('percentages outside 0–100 are clamped', async ({ page, user }) => {
|
||||
await page.goto('/');
|
||||
await openSettings(page);
|
||||
const pct = page.locator('.periods li input[type=number]');
|
||||
await pct.fill('250');
|
||||
await expect(pct).toHaveValue('100');
|
||||
await pct.fill('-5');
|
||||
await expect(pct).toHaveValue('0');
|
||||
void user;
|
||||
});
|
||||
|
||||
test('with every period removed the target falls back to 100%', async ({ page, user }) => {
|
||||
await page.goto('/');
|
||||
await openSettings(page);
|
||||
await page.locator('.periods li').getByRole('button', { name: 'Remove' }).click();
|
||||
await expect(page.locator('.periods li')).toHaveCount(0);
|
||||
await expect(weekFooter(page)).toContainText('Target36 h 15 min');
|
||||
void user;
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('other settings', () => {
|
||||
test('a different full-time week changes every daily target', async ({ page, user }) => {
|
||||
await page.goto('/');
|
||||
await openSettings(page);
|
||||
const input = page.locator('.settings-grid label').filter({ hasText: 'Full-time week' }).locator('input');
|
||||
await input.fill('37:30');
|
||||
await expect(weekFooter(page)).toContainText('Target37 h 30 min');
|
||||
await expect(dayRow(page, MONDAY).locator('.day-figures')).toContainText('/ 7:30');
|
||||
void user;
|
||||
});
|
||||
|
||||
test('a four-day week spreads the same hours over four days', async ({ page, user }) => {
|
||||
await page.goto('/');
|
||||
await openSettings(page);
|
||||
await page.getByRole('checkbox', { name: 'Fri' }).uncheck();
|
||||
await expect(weekFooter(page)).toContainText('Target36 h 15 min');
|
||||
await expect(dayRow(page, MONDAY).locator('.day-figures')).toContainText('/ 9:04');
|
||||
await expect(dayRow(page, '2026-10-09').locator('.day-figures')).toContainText('/ 0:00');
|
||||
void user;
|
||||
});
|
||||
|
||||
test('the last working day cannot be unchecked', async ({ page, user }) => {
|
||||
await seed(page.request, baseData({ settings: { ...baseData().settings, workdays: [1] } }));
|
||||
await page.goto('/');
|
||||
await openSettings(page);
|
||||
await page.getByRole('checkbox', { name: 'Mon' }).click();
|
||||
await expect(page.getByRole('checkbox', { name: 'Mon' })).toBeChecked();
|
||||
void user;
|
||||
});
|
||||
|
||||
test('a negative opening balance carries into the balance', async ({ page, user }) => {
|
||||
await page.goto('/');
|
||||
await openSettings(page);
|
||||
await page.getByPlaceholder('e.g. -2:30').fill('-2:30');
|
||||
// Mon–Thu nothing logged (−29 h) plus −2:30
|
||||
await expect(balanceCard(page)).toContainText('−31 h 30 min');
|
||||
void user;
|
||||
});
|
||||
|
||||
test('moving the tracking start changes what the balance counts', async ({ page, user }) => {
|
||||
await page.goto('/');
|
||||
await openSettings(page);
|
||||
await page
|
||||
.locator('.settings-grid label')
|
||||
.filter({ hasText: 'Tracking start' })
|
||||
.locator('input')
|
||||
.fill('2026-10-08');
|
||||
await expect(balanceCard(page)).toContainText('−7 h 15 min'); // only today
|
||||
await saved(page);
|
||||
expect((await fetchData(page.request)).settings.trackingStart).toBe('2026-10-08');
|
||||
void user;
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user