Initial commit

This commit is contained in:
Aaro Koivukangas
2026-10-11 03:26:15 +03:00
commit b6af96cf6d
49 changed files with 9627 additions and 0 deletions
+100
View File
@@ -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;
});
+191
View File
@@ -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
View File
@@ -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 };
+256
View File
@@ -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;
});
});
+33
View File
@@ -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);
});
});
+26
View File
@@ -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;
});
});
+60
View File
@@ -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([]);
});
});
+112
View File
@@ -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;
});
});