Initial commit
This commit is contained in:
96
tests/e2e/package-lock.json
generated
Normal file
96
tests/e2e/package-lock.json
generated
Normal file
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"name": "randall-e2e",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "randall-e2e",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@types/node": "^22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
15
tests/e2e/package.json
Normal file
15
tests/e2e/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "randall-e2e",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:headed": "playwright test --headed",
|
||||
"test:ui": "playwright test --ui",
|
||||
"report": "playwright show-report"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@types/node": "^22.0.0"
|
||||
}
|
||||
}
|
||||
21
tests/e2e/playwright.config.ts
Normal file
21
tests/e2e/playwright.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: false,
|
||||
workers: 1, // tests share database state — never run in parallel
|
||||
retries: 0,
|
||||
reporter: [['list'], ['html', { open: 'never' }]],
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL ?? 'http://localhost',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
headless: !!process.env.CI,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
46
tests/e2e/tests/admin.spec.ts
Normal file
46
tests/e2e/tests/admin.spec.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { loginAsAdmin } from './helpers';
|
||||
|
||||
test.describe('Admin portal', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
});
|
||||
|
||||
test('admin user sees the Admin portal button in the header', async ({ page }) => {
|
||||
await expect(page.getByRole('button', { name: 'Admin portal' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('navigates to the admin portal', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Admin portal' }).click();
|
||||
await page.waitForURL('/admin');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Admin Portal' })).toBeVisible();
|
||||
await expect(page.getByText('Manage user accounts')).toBeVisible();
|
||||
});
|
||||
|
||||
test('admin portal lists the admin account under Approved accounts', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
|
||||
await expect(page.getByText('Approved accounts')).toBeVisible();
|
||||
// Target the name paragraph inside the admin's list item
|
||||
const adminRow = page.locator('li').filter({ hasText: 'admin@randall.local' });
|
||||
await expect(adminRow.getByRole('paragraph').filter({ hasText: /^Admin$/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test('admin account has the Admin badge and no Make admin button', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
|
||||
const adminRow = page.locator('li').filter({ hasText: 'admin@randall.local' });
|
||||
// The badge is a <span> with class bg-amber-100
|
||||
await expect(adminRow.locator('span').filter({ hasText: /^Admin$/ })).toBeVisible();
|
||||
await expect(adminRow.getByRole('button', { name: 'Make admin' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Back to planner link returns to the planner', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('button', { name: '← Back to planner' }).click();
|
||||
|
||||
await page.waitForURL('/');
|
||||
await expect(page.getByText('Reserve your workspace up to 2 weeks ahead')).toBeVisible();
|
||||
});
|
||||
});
|
||||
63
tests/e2e/tests/auth.spec.ts
Normal file
63
tests/e2e/tests/auth.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ADMIN_EMAIL, ADMIN_PASSWORD } from './helpers';
|
||||
|
||||
test.describe('Authentication', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('shows the sign-in form on initial load', async ({ page }) => {
|
||||
await expect(page.getByRole('heading', { name: 'Office Planner' })).toBeVisible();
|
||||
await expect(page.getByPlaceholder('jane@company.com')).toBeVisible();
|
||||
await expect(page.getByPlaceholder('••••••••')).toBeVisible();
|
||||
await expect(page.locator('button[type="submit"]')).toHaveText('Sign in');
|
||||
});
|
||||
|
||||
test('signs in with valid credentials and reaches the planner', async ({ page }) => {
|
||||
await page.getByPlaceholder('jane@company.com').fill(ADMIN_EMAIL);
|
||||
await page.getByPlaceholder('••••••••').fill(ADMIN_PASSWORD);
|
||||
await page.locator('button[type="submit"]').click();
|
||||
|
||||
await expect(page.getByText('Reserve your workspace up to 2 weeks ahead')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows an error for an unknown email', async ({ page }) => {
|
||||
await page.getByPlaceholder('jane@company.com').fill('nobody@example.com');
|
||||
await page.getByPlaceholder('••••••••').fill('anything');
|
||||
await page.locator('button[type="submit"]').click();
|
||||
|
||||
await expect(page.getByText(/invalid email or password/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows an error for a wrong password', async ({ page }) => {
|
||||
await page.getByPlaceholder('jane@company.com').fill(ADMIN_EMAIL);
|
||||
await page.getByPlaceholder('••••••••').fill('wrongpassword');
|
||||
await page.locator('button[type="submit"]').click();
|
||||
|
||||
await expect(page.getByText(/invalid email or password/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('registers a new account and shows pending approval message', async ({ page }) => {
|
||||
// Switch to register mode
|
||||
await page.locator('button').filter({ hasText: 'Create account' }).first().click();
|
||||
|
||||
await page.getByPlaceholder('Jane Smith').fill('Test User');
|
||||
await page.getByPlaceholder('jane@company.com').fill(`testuser+${Date.now()}@example.com`);
|
||||
await page.getByPlaceholder('••••••••').fill('Test@1234');
|
||||
await page.locator('button[type="submit"]').click();
|
||||
|
||||
await expect(page.getByText('Account pending approval')).toBeVisible();
|
||||
await expect(page.getByText(/administrator will review/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('signs out and returns to the sign-in form', async ({ page }) => {
|
||||
await page.getByPlaceholder('jane@company.com').fill(ADMIN_EMAIL);
|
||||
await page.getByPlaceholder('••••••••').fill(ADMIN_PASSWORD);
|
||||
await page.locator('button[type="submit"]').click();
|
||||
await page.waitForURL('/');
|
||||
|
||||
await page.getByRole('button', { name: 'Sign out' }).click();
|
||||
|
||||
await expect(page.locator('button[type="submit"]')).toHaveText('Sign in');
|
||||
});
|
||||
});
|
||||
25
tests/e2e/tests/helpers.ts
Normal file
25
tests/e2e/tests/helpers.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { type Page } from '@playwright/test';
|
||||
|
||||
export const ADMIN_EMAIL = 'admin@randall.local';
|
||||
export const ADMIN_PASSWORD = 'Admin@123';
|
||||
export const ADMIN_NAME = 'Admin';
|
||||
|
||||
export async function loginAs(page: Page, email: string, password: string) {
|
||||
await page.goto('/');
|
||||
await page.getByPlaceholder('jane@company.com').fill(email);
|
||||
await page.getByPlaceholder('••••••••').fill(password);
|
||||
await page.locator('button[type="submit"]').click();
|
||||
await page.waitForURL('/');
|
||||
await page.getByText('Reserve your workspace up to 2 weeks ahead').waitFor();
|
||||
}
|
||||
|
||||
export async function loginAsAdmin(page: Page) {
|
||||
await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
}
|
||||
|
||||
/** Returns today's date offset by `days` in yyyy-MM-dd format */
|
||||
export function offsetDate(days: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
100
tests/e2e/tests/planner.spec.ts
Normal file
100
tests/e2e/tests/planner.spec.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { loginAsAdmin, offsetDate } from './helpers';
|
||||
|
||||
test.describe('Planner', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
});
|
||||
|
||||
test('displays both pods with 8 desks each', async ({ page }) => {
|
||||
// Wait for floor plan to load — at least one free desk must be visible
|
||||
await expect(page.getByRole('button', { name: 'D1 Free' })).toBeVisible();
|
||||
|
||||
// All 16 desk buttons contain a desk label (D1–D16) in their text content
|
||||
const allDesks = page.getByRole('button').filter({ hasText: /D\d+/ });
|
||||
await expect(allDesks).toHaveCount(16);
|
||||
});
|
||||
|
||||
test('date picker is constrained to today and 14 days ahead', async ({ page }) => {
|
||||
const input = page.locator('input[type="date"]');
|
||||
const today = offsetDate(0);
|
||||
const max = offsetDate(14);
|
||||
|
||||
await expect(input).toHaveAttribute('min', today);
|
||||
await expect(input).toHaveAttribute('max', max);
|
||||
});
|
||||
|
||||
test('previous-day button is disabled when on today', async ({ page }) => {
|
||||
await expect(page.getByRole('button', { name: '←' })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('next-day button is disabled when on the maximum date', async ({ page }) => {
|
||||
const input = page.locator('input[type="date"]');
|
||||
await input.fill(offsetDate(14));
|
||||
await input.dispatchEvent('change');
|
||||
|
||||
await expect(page.getByRole('button', { name: '→' })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('reserves a desk and shows it as Mine', async ({ page }) => {
|
||||
const targetDate = offsetDate(7);
|
||||
const input = page.locator('input[type="date"]');
|
||||
await input.fill(targetDate);
|
||||
await input.dispatchEvent('change');
|
||||
|
||||
await page.getByRole('button', { name: 'D1 Free' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Reserve desk' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: /^D1\s+Mine/ })).toBeVisible();
|
||||
|
||||
// Clean up
|
||||
await page.getByRole('button', { name: /^D1\s+Mine/ }).click();
|
||||
await page.getByRole('button', { name: 'Cancel reservation' }).click();
|
||||
});
|
||||
|
||||
test('reserved desk appears in My reservations', async ({ page }) => {
|
||||
const targetDate = offsetDate(8);
|
||||
const input = page.locator('input[type="date"]');
|
||||
await input.fill(targetDate);
|
||||
await input.dispatchEvent('change');
|
||||
|
||||
await page.getByRole('button', { name: 'D2 Free' }).click();
|
||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'My reservations' })).toBeVisible();
|
||||
await expect(page.locator('section').filter({ hasText: 'My reservations' }).getByText('D2')).toBeVisible();
|
||||
|
||||
// Clean up
|
||||
await page.getByRole('button', { name: /^D2\s+Mine/ }).click();
|
||||
await page.getByRole('button', { name: 'Cancel reservation' }).click();
|
||||
});
|
||||
|
||||
test('cancels a reservation and desk returns to Free', async ({ page }) => {
|
||||
const targetDate = offsetDate(9);
|
||||
const input = page.locator('input[type="date"]');
|
||||
await input.fill(targetDate);
|
||||
await input.dispatchEvent('change');
|
||||
|
||||
// Reserve
|
||||
await page.getByRole('button', { name: 'D3 Free' }).click();
|
||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||
await expect(page.getByRole('button', { name: /^D3\s+Mine/ })).toBeVisible();
|
||||
|
||||
// Cancel
|
||||
await page.getByRole('button', { name: /^D3\s+Mine/ }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Cancel reservation' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Cancel reservation' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'D3 Free' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('dismisses the reservation modal when clicking Cancel', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'D4 Free' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Reserve desk' })).toBeVisible();
|
||||
|
||||
// Scope to the modal overlay to avoid matching Cancel buttons in My reservations
|
||||
await page.locator('.fixed.inset-0').getByRole('button', { name: 'Cancel' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Reserve desk' })).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
15
tests/e2e/tsconfig.json
Normal file
15
tests/e2e/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user