e2e: add medium-priority Playwright coverage (#44)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m29s

Co-authored-by: Shaun Campbell <shaun@campbellwireless.net>
Reviewed-on: #44
Co-authored-by: AI Agent <ai-agent@campbellwireless.net>
Co-committed-by: AI Agent <ai-agent@campbellwireless.net>
This commit was merged in pull request #44.
This commit is contained in:
2026-02-23 00:12:24 +00:00
committed by shaun
parent 224ca77a9d
commit f75465e7f1
6 changed files with 420 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
import { test, expect } from '@playwright/test';
import { TEST_USERS } from './setup/test-users.js';
import { loginAsLocalUser } from './helpers/auth.js';
import { addActivity, addPackingList, createTrip, uniqueSuffix } from './helpers/trip.js';
test.beforeEach(async ({ context }) => {
await context.clearCookies();
});
test('checklists and experiences persist after reload', async ({ page }) => {
const suffix = uniqueSuffix();
const tripName = `E2E Checklist ${suffix}`;
const listName = `Packing ${suffix}`;
const itemOne = `Passport ${suffix}`;
const itemTwo = `Sunscreen ${suffix}`;
const activityName = `Museum Visit ${suffix}`;
await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password);
await createTrip(page, { name: tripName, description: 'Checklist persistence coverage' });
await addPackingList(page, { name: listName, items: [itemOne, itemTwo] });
await addActivity(page, { name: activityName });
const toggleRequest = page.waitForResponse(
(response) =>
response.request().method() === 'POST' && response.url().includes('/toggleChecklistItem')
);
await page.getByRole('checkbox', { name: itemOne }).check();
await toggleRequest;
await expect(page.getByRole('checkbox', { name: itemOne })).toBeChecked();
await page.reload();
await expect(page.getByText(activityName, { exact: true })).toBeVisible();
await expect(page.getByRole('checkbox', { name: itemOne })).toBeChecked();
await expect(page.getByRole('checkbox', { name: itemTwo })).not.toBeChecked();
const listCard = page
.getByText(listName, { exact: true })
.locator('xpath=ancestor::div[contains(@class,"rounded-xl")]');
const items = listCard.locator('label');
await expect(items.nth(0)).toContainText(itemOne);
await expect(items.nth(1)).toContainText(itemTwo);
});

27
e2e/helpers/auth.ts Normal file
View File

@@ -0,0 +1,27 @@
import { expect, type Page } from '@playwright/test';
import { TEST_USERS } from '../setup/test-users.js';
const LOGIN_URL = '/trips/login';
const DASHBOARD_URL = '/trips/dashboard';
const PROFILE_URL = '/trips/profile';
export async function loginAsLocalUser(
page: Page,
username: string,
password: string
): Promise<void> {
await page.goto(LOGIN_URL);
await page.fill('input[name="identifier"]', username);
await page.fill('input[name="password"]', password);
await page.click('button[type="submit"]:has-text("Sign in locally")');
await page.waitForURL(`**${DASHBOARD_URL}`, { timeout: 15_000 });
}
export async function ensureSelfProfile(page: Page, email = TEST_USERS.regular.email): Promise<void> {
await page.goto(PROFILE_URL);
await page.fill('#first_name', 'E2E');
await page.fill('#last_name', 'User');
await page.fill('#email', email);
await page.getByRole('button', { name: 'Save profile' }).click();
await expect(page.getByRole('heading', { name: 'My Profile' })).toBeVisible();
}

159
e2e/helpers/trip.ts Normal file
View File

@@ -0,0 +1,159 @@
import { expect, type Page } from '@playwright/test';
const NEW_TRIP_URL = '/trips/trips/new';
function escapeRegex(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function uniqueSuffix(): string {
return `${Date.now()}-${Math.floor(Math.random() * 1000)}`;
}
export async function createTrip(
page: Page,
values: { name: string; startDate?: string; description?: string }
): Promise<{ tripUrl: string; tripId: string }> {
await page.goto(NEW_TRIP_URL);
await expect(page.getByRole('heading', { name: 'Plan New Trip' })).toBeVisible();
if (values.startDate) {
await page.getByLabel('Start date').fill(values.startDate);
} else {
await page.getByRole('checkbox', { name: "I don't know yet" }).first().check();
}
if (values.description) {
await page.getByLabel('Description').fill(values.description);
}
await page.locator('input[name="name"]').fill(values.name);
await page.getByRole('button', { name: 'Save' }).click();
await expect(page).toHaveURL(/\/trips\/trips\/(?!new$)[^/?#]+$/, { timeout: 15_000 });
const tripUrl = page.url();
const tripId = tripUrl.split('/').pop() ?? '';
return { tripUrl, tripId };
}
export async function openAddToTripMenuItem(page: Page, label: string): Promise<void> {
const addToTripButton = page.getByRole('button', { name: /^Add to trip$/i });
if (await addToTripButton.isVisible().catch(() => false)) {
await addToTripButton.click();
await page.getByRole('button', { name: label, exact: true }).click();
return;
}
// In the welcome state, button names include label + subtitle.
await page
.getByRole('button', { name: new RegExp(`^${escapeRegex(label)}\\b`, 'i') })
.first()
.click();
}
export async function openAddTraveller(page: Page): Promise<void> {
const existingDialog = page.getByRole('dialog', { name: 'Add traveller' });
if (await existingDialog.isVisible().catch(() => false)) return;
const addToTripButton = page.getByRole('button', { name: /^Add to trip$/i });
if (await addToTripButton.isVisible().catch(() => false)) {
await addToTripButton.click();
await page.getByRole('button', { name: 'Travellers', exact: true }).click();
return;
}
await page.getByRole('button', { name: /^Who's travelling\?/ }).click();
}
export async function addTraveller(
page: Page,
values: { firstName: string; lastName: string; email?: string }
): Promise<void> {
await openAddTraveller(page);
const dialog = page.getByRole('dialog', { name: 'Add traveller' });
await expect(dialog).toBeVisible();
const addNewButton = dialog.getByRole('button', { name: 'Add someone new' });
if (await addNewButton.isVisible().catch(() => false)) {
await addNewButton.click();
}
await dialog.getByLabel('First name', { exact: false }).fill(values.firstName);
await dialog.getByLabel('Last name', { exact: false }).fill(values.lastName);
if (values.email) {
await dialog.getByLabel(/Email/).fill(values.email);
}
await dialog.getByRole('button', { name: 'Add traveller' }).click();
await expect(dialog).toBeHidden();
}
export async function addFlight(
page: Page,
values: {
departureDate: string;
airlineCode: string;
flightNumber: string;
departureAirport: string;
arrivalAirport: string;
}
): Promise<void> {
await openAddToTripMenuItem(page, 'Transportation');
const dialog = page.getByRole('dialog', { name: 'Add transportation' });
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /Flight/ }).click();
const form = dialog.locator('form');
await expect(form.locator('input[name="segments[0][departure_date]"]')).toBeVisible();
await form.locator('input[name="segments[0][departure_date]"]').fill(values.departureDate);
await form.getByPlaceholder('Search airline or enter code').fill(values.airlineCode);
await form.locator('input[name="segments[0][flight_number]"]').fill(values.flightNumber);
await form.getByPlaceholder('Code or search').nth(0).fill(values.departureAirport);
await form.getByPlaceholder('Code or search').nth(1).fill(values.arrivalAirport);
await form.getByRole('button', { name: 'Add transportation' }).click();
await expect(dialog).toBeHidden();
}
export async function addLodging(
page: Page,
values: { name: string; guestNames?: string[] }
): Promise<void> {
await openAddToTripMenuItem(page, 'Lodgings');
const dialog = page.getByRole('dialog', { name: 'Add lodging' });
await expect(dialog).toBeVisible();
await dialog.getByLabel('Name', { exact: false }).fill(values.name);
for (const guestName of values.guestNames ?? []) {
await dialog.getByRole('checkbox', { name: guestName }).check();
}
await dialog.getByRole('button', { name: 'Add lodging' }).click();
await expect(dialog).toBeHidden();
}
export async function addPackingList(
page: Page,
values: { name: string; items: string[] }
): Promise<void> {
await openAddToTripMenuItem(page, 'Packing List');
const dialog = page
.locator('[role="dialog"]')
.filter({ has: page.getByRole('heading', { name: /Packing list/i }) })
.first();
await expect(dialog).toBeVisible();
await dialog.getByLabel('Name', { exact: false }).fill(values.name);
const itemInputs = dialog.getByPlaceholder('Item');
await itemInputs.first().fill(values.items[0]);
for (const item of values.items.slice(1)) {
await dialog.getByRole('button', { name: 'Add item' }).click();
await itemInputs.last().fill(item);
}
await dialog.getByRole('button', { name: 'Add', exact: true }).click();
await expect(dialog).toBeHidden();
}
export async function addActivity(page: Page, values: { name: string }): Promise<void> {
await openAddToTripMenuItem(page, 'Attractions & Activities');
const dialog = page
.locator('[role="dialog"]')
.filter({ has: page.getByRole('heading', { name: /Attraction & activity/i }) })
.first();
await expect(dialog).toBeVisible();
await dialog.getByLabel('Name', { exact: false }).fill(values.name);
await dialog.getByRole('button', { name: 'Add' }).click();
await expect(dialog).toBeHidden();
}

View File

@@ -0,0 +1,79 @@
import { test, expect } from '@playwright/test';
import { TEST_USERS } from './setup/test-users.js';
import { ensureSelfProfile, loginAsLocalUser } from './helpers/auth.js';
import {
addTraveller,
createTrip,
openAddToTripMenuItem,
openAddTraveller,
uniqueSuffix
} from './helpers/trip.js';
test.beforeEach(async ({ context }) => {
await context.clearCookies();
});
test('lodging guest selection and access boundaries are enforced', async ({ page }) => {
const suffix = uniqueSuffix();
const tripName = `E2E Lodging ${suffix}`;
const guestFirst = 'Jordan';
const guestLast = 'Guest';
const guestEmail = `jordan.${suffix}@test.local`;
const lodgingName = `E2E Lodge ${suffix}`;
await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password);
await ensureSelfProfile(page);
const { tripUrl } = await createTrip(page, {
name: tripName,
description: 'Lodging guest coverage'
});
const selfName = 'E2E User';
await openAddTraveller(page);
const travellerDialog = page.getByRole('dialog', { name: 'Add traveller' });
await expect(travellerDialog).toBeVisible();
const selfButton = travellerDialog.getByRole('button', { name: /You/ }).first();
if (await selfButton.isVisible().catch(() => false)) {
await selfButton.click();
await expect(travellerDialog).toBeHidden();
} else {
const selfNameButton = travellerDialog.getByRole('button', { name: selfName }).first();
if (await selfNameButton.isVisible().catch(() => false)) {
await selfNameButton.click();
await expect(travellerDialog).toBeHidden();
} else {
await travellerDialog.getByRole('button', { name: 'Close' }).click();
await expect(travellerDialog).toBeHidden();
}
}
await addTraveller(page, {
firstName: guestFirst,
lastName: guestLast,
email: guestEmail
});
await openAddToTripMenuItem(page, 'Lodgings');
const lodgingDialog = page.getByRole('dialog', { name: 'Add lodging' });
await expect(lodgingDialog).toBeVisible();
const guestName = `${guestFirst} ${guestLast}`;
await expect(lodgingDialog.getByRole('checkbox', { name: guestName })).toBeVisible();
await lodgingDialog.getByLabel('Name', { exact: false }).fill(lodgingName);
await lodgingDialog.getByText(guestName, { exact: true }).click();
await lodgingDialog.getByRole('button', { name: 'Add lodging' }).click();
await expect(lodgingDialog).toBeHidden();
await expect(page.getByText(lodgingName, { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Edit lodging' }).click();
const editDialog = page.getByRole('dialog', { name: 'Edit lodging' });
await expect(editDialog).toBeVisible();
await expect(editDialog.getByRole('checkbox', { name: guestName })).toBeChecked();
await editDialog.getByRole('button', { name: 'Close' }).click();
await page.context().clearCookies();
await loginAsLocalUser(page, TEST_USERS.admin.username, TEST_USERS.admin.password);
await page.goto(tripUrl);
await expect(page.getByText(/Trip not found|Not found/i)).toBeVisible();
});

View File

@@ -0,0 +1,60 @@
import { test, expect } from '@playwright/test';
import { TEST_USERS } from './setup/test-users.js';
import { loginAsLocalUser } from './helpers/auth.js';
import { createTrip, openAddToTripMenuItem, uniqueSuffix } from './helpers/trip.js';
test.beforeEach(async ({ context }) => {
await context.clearCookies();
});
test('admin can import a package tour and attach it to a trip', async ({ page }) => {
if (!process.env.GADVENTURES_API_KEY) {
test.skip(true, 'G Adventures API key not set for deterministic import');
}
const suffix = uniqueSuffix();
const operatorName = 'G Adventures';
await loginAsLocalUser(page, TEST_USERS.admin.username, TEST_USERS.admin.password);
await page.goto('/trips/admin/tour-operators');
await expect(page.getByRole('heading', { name: 'Tour Operators' })).toBeVisible();
await page.getByRole('button', { name: 'Add operator' }).click();
await page.getByPlaceholder('Search or type operator name').fill(operatorName);
await page.getByRole('button', { name: 'Add operator' }).last().click();
await expect(page.getByText(operatorName, { exact: true })).toBeVisible();
const operatorCard = page
.getByText(operatorName, { exact: true })
.locator('xpath=ancestor::div[contains(@class,"rounded-xl")]');
await operatorCard.getByRole('link', { name: 'Tours' }).click();
await expect(page.getByRole('heading', { name: operatorName })).toBeVisible();
await page.getByRole('button', { name: 'Import' }).click();
const importDialog = page.getByRole('dialog', { name: 'Import tours' });
await expect(importDialog).toBeVisible();
await importDialog.getByPlaceholder('Search tours...').fill('a');
const results = importDialog.locator('ul button');
await expect(results.first()).toBeVisible();
const tourTitle = (await results.first().innerText()).trim();
await results.first().click();
await importDialog.getByRole('button', { name: 'Import tour' }).click();
await expect(importDialog).toBeHidden();
await expect(page.getByRole('link', { name: tourTitle })).toBeVisible();
await page.context().clearCookies();
await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password);
await createTrip(page, { name: `E2E Tour Trip ${suffix}` });
await openAddToTripMenuItem(page, 'Package Tours');
const addDialog = page.getByRole('dialog', { name: 'Add package tour' });
await expect(addDialog).toBeVisible();
await addDialog.getByPlaceholder('Search or type operator name').fill(operatorName);
await addDialog.getByRole('button', { name: operatorName }).click();
await expect(addDialog.locator('select')).toContainText(tourTitle);
await addDialog.locator('select').first().selectOption({ label: tourTitle });
await addDialog.getByRole('button', { name: 'Add tour' }).click();
await expect(addDialog).toBeHidden();
await expect(page.getByText(tourTitle, { exact: true })).toBeVisible();
});

View File

@@ -0,0 +1,51 @@
import { test, expect } from '@playwright/test';
import { TEST_USERS } from './setup/test-users.js';
import { loginAsLocalUser } from './helpers/auth.js';
import { addFlight, createTrip, uniqueSuffix } from './helpers/trip.js';
function formatDate(offsetDays: number): string {
const date = new Date();
date.setDate(date.getDate() + offsetDays);
return date.toISOString().slice(0, 10);
}
test.beforeEach(async ({ context }) => {
await context.clearCookies();
});
test('transportation lifecycle updates the trip view', async ({ page }) => {
const suffix = uniqueSuffix();
const tripName = `E2E Transport ${suffix}`;
const departureDate = formatDate(30);
await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password);
await createTrip(page, {
name: tripName,
startDate: departureDate,
description: 'Transportation lifecycle coverage'
});
await addFlight(page, {
departureDate,
airlineCode: 'UA',
flightNumber: '1001',
departureAirport: 'SFO',
arrivalAirport: 'LAX'
});
await expect(page.getByRole('heading', { name: 'Transportation' })).toBeVisible();
await expect(page.getByText(/UA\s*1001/)).toBeVisible();
await page.getByRole('button', { name: 'Edit transportation' }).click();
const editDialog = page.getByRole('dialog', { name: 'Edit transportation' });
await expect(editDialog).toBeVisible();
await editDialog.locator('input[name="segments[0][flight_number]"]').fill('1002');
await editDialog.getByRole('button', { name: 'Save changes' }).click();
await expect(editDialog).toBeHidden();
await expect(page.getByText(/UA\s*1002/)).toBeVisible();
await expect(page.getByText(/UA\s*1001/)).toHaveCount(0);
await page.getByRole('button', { name: 'Remove transportation' }).click();
await expect(page.getByRole('heading', { name: 'Transportation' })).toHaveCount(0);
});