Initial commit: TravelHub multi-tenant flight booking platform
Angular + Go + PostgreSQL sample with strict per-tenant isolation. - Backend: Go API (basic auth, tenant-scoped repository layer, pgx pool) - Frontend: Angular 22 SPA (search, checkout, bookings, dashboard) - Database: schema, seed data and demo bookings migrations - Tests: 311 across Go (integration), Angular (unit) and Playwright (e2e), plus a generated evidence report with PDF export - Security: whitebox VAPT report under security/ Secrets (.env, tokens, DB credentials) are git-ignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
/** The seeded demo accounts, from backend/migrations/002_seed.sql. */
|
||||
|
||||
export interface Account {
|
||||
tenant: string;
|
||||
tenantName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
fullName: string;
|
||||
role: 'admin' | 'agent';
|
||||
}
|
||||
|
||||
export const DEMO_PASSWORD = 'demo1234';
|
||||
|
||||
export const SKYLINE_AGENT: Account = {
|
||||
tenant: 'skyline',
|
||||
tenantName: 'Skyline Travel',
|
||||
email: 'agent@skyline.test',
|
||||
password: DEMO_PASSWORD,
|
||||
fullName: 'Marcus Reid',
|
||||
role: 'agent',
|
||||
};
|
||||
|
||||
export const SKYLINE_ADMIN: Account = {
|
||||
tenant: 'skyline',
|
||||
tenantName: 'Skyline Travel',
|
||||
email: 'admin@skyline.test',
|
||||
password: DEMO_PASSWORD,
|
||||
fullName: 'Ava Chen',
|
||||
role: 'admin',
|
||||
};
|
||||
|
||||
export const GLOBETROTTER_AGENT: Account = {
|
||||
tenant: 'globetrotter',
|
||||
tenantName: 'Globetrotter Tours',
|
||||
email: 'agent@globetrotter.test',
|
||||
password: DEMO_PASSWORD,
|
||||
fullName: 'Tom Okafor',
|
||||
role: 'agent',
|
||||
};
|
||||
|
||||
export const GLOBETROTTER_ADMIN: Account = {
|
||||
tenant: 'globetrotter',
|
||||
tenantName: 'Globetrotter Tours',
|
||||
email: 'admin@globetrotter.test',
|
||||
password: DEMO_PASSWORD,
|
||||
fullName: 'Priya Nair',
|
||||
role: 'admin',
|
||||
};
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Simulated backend for UI tests.
|
||||
*
|
||||
* The end-to-end specs drive the real stack, which is the right way to prove
|
||||
* the system works. But some conditions are impractical to stage for real:
|
||||
* a 500 from the database, a fifteen-second response, a seat sold by someone
|
||||
* else in the half second between opening checkout and confirming, an agency
|
||||
* with a hundred thousand flights, a passenger whose name is 200 characters
|
||||
* long.
|
||||
*
|
||||
* These helpers intercept `/api/**` in the browser and answer it directly, so
|
||||
* a test can put the UI in any of those states deterministically, in
|
||||
* milliseconds, without touching the database.
|
||||
*/
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
// --- canned data -----------------------------------------------------------
|
||||
|
||||
export const TENANTS = [
|
||||
{ slug: 'skyline', name: 'Skyline Travel' },
|
||||
{ slug: 'globetrotter', name: 'Globetrotter Tours' },
|
||||
];
|
||||
|
||||
export const SESSION = {
|
||||
user: {
|
||||
id: 'u-1',
|
||||
tenantId: 't-1',
|
||||
email: 'agent@skyline.test',
|
||||
fullName: 'Marcus Reid',
|
||||
role: 'agent',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
tenant: {
|
||||
id: 't-1',
|
||||
slug: 'skyline',
|
||||
name: 'Skyline Travel',
|
||||
status: 'active',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
export const AIRPORTS = [
|
||||
{ id: 'a-jfk', iataCode: 'JFK', name: 'John F. Kennedy International', city: 'New York', country: 'United States', timezone: 'America/New_York' },
|
||||
{ id: 'a-lhr', iataCode: 'LHR', name: 'Heathrow', city: 'London', country: 'United Kingdom', timezone: 'Europe/London' },
|
||||
];
|
||||
|
||||
export const AIRLINES = [{ id: 'al-aa', iataCode: 'AA', name: 'American Airlines' }];
|
||||
|
||||
export function makeFlight(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'f-1',
|
||||
flightNumber: 'AA100',
|
||||
airline: AIRLINES[0],
|
||||
origin: AIRPORTS[0],
|
||||
destination: AIRPORTS[1],
|
||||
departureTime: '2026-12-19T18:00:00Z',
|
||||
arrivalTime: '2026-12-20T01:00:00Z',
|
||||
durationMinutes: 420,
|
||||
aircraft: 'Boeing 787-9',
|
||||
cabinClass: 'economy',
|
||||
basePrice: 483.3,
|
||||
currency: 'USD',
|
||||
seatsTotal: 180,
|
||||
seatsAvailable: 165,
|
||||
status: 'scheduled',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeBooking(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'b-1',
|
||||
bookingReference: 'SIM001',
|
||||
flight: makeFlight(),
|
||||
passengers: [{ id: 'p-1', firstName: 'Dana', lastName: 'Whitfield' }],
|
||||
passengerCount: 1,
|
||||
totalAmount: 483.3,
|
||||
currency: 'USD',
|
||||
status: 'confirmed',
|
||||
contactEmail: 'ops@example.com',
|
||||
contactPhone: '',
|
||||
bookedBy: 'Marcus Reid',
|
||||
createdAt: '2026-09-12T13:10:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeDashboard(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
totalBookings: 6,
|
||||
confirmedBookings: 5,
|
||||
cancelledBookings: 1,
|
||||
totalRevenue: 7263.16,
|
||||
currency: 'USD',
|
||||
flightsInStock: 294,
|
||||
seatsSold: 12,
|
||||
recentBookings: [makeBooking()],
|
||||
topRoutes: [{ route: 'JFK to LHR', bookings: 3, revenue: 4000 }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function page$<T>(items: T[], total = items.length, limit = 10, offset = 0) {
|
||||
return { items, total, limit, offset };
|
||||
}
|
||||
|
||||
// --- scenario --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* How one endpoint should answer. A bare value is a 200 with that value as the
|
||||
* body; use `reply`, `fail` or `slow` for anything else.
|
||||
*
|
||||
* The envelope carries a symbol brand rather than being recognised by shape.
|
||||
* Domain objects here have a `status` field of their own -- a flight is
|
||||
* "scheduled", a booking is "confirmed" -- so duck-typing on `status` would
|
||||
* silently read a booking as an HTTP response.
|
||||
*/
|
||||
const ENVELOPE = Symbol('travelhub.reply');
|
||||
|
||||
export interface ReplyEnvelope {
|
||||
[ENVELOPE]: true;
|
||||
status: number;
|
||||
body: unknown;
|
||||
delayMs?: number;
|
||||
}
|
||||
|
||||
export type Reply = ReplyEnvelope | unknown;
|
||||
|
||||
/** An explicit response: status, body and optional latency. */
|
||||
export function reply(opts: { status?: number; body?: unknown; delayMs?: number }): ReplyEnvelope {
|
||||
return {
|
||||
[ENVELOPE]: true,
|
||||
status: opts.status ?? 200,
|
||||
body: opts.body ?? {},
|
||||
delayMs: opts.delayMs,
|
||||
};
|
||||
}
|
||||
|
||||
/** An error response, in the shape the Go API uses. */
|
||||
export function fail(status: number, error = 'simulated failure'): ReplyEnvelope {
|
||||
return reply({ status, body: { error } });
|
||||
}
|
||||
|
||||
/** A successful but slow response, for asserting on loading states. */
|
||||
export function slow(body: unknown, delayMs: number): ReplyEnvelope {
|
||||
return reply({ status: 200, body, delayMs });
|
||||
}
|
||||
|
||||
export interface Scenario {
|
||||
/** Latency applied to every endpoint that does not set its own. */
|
||||
delayMs?: number;
|
||||
/** Abort every API call, as though the network were gone. */
|
||||
offline?: boolean;
|
||||
|
||||
session?: Reply;
|
||||
tenants?: Reply;
|
||||
airports?: Reply;
|
||||
airlines?: Reply;
|
||||
flights?: Reply;
|
||||
flightById?: Reply;
|
||||
bookings?: Reply;
|
||||
bookingById?: Reply;
|
||||
createBooking?: Reply;
|
||||
cancelBooking?: Reply;
|
||||
dashboard?: Reply;
|
||||
}
|
||||
|
||||
function normalise(value: Reply): { status: number; body: unknown; delayMs?: number } {
|
||||
if (value !== null && typeof value === 'object' && ENVELOPE in value) {
|
||||
const envelope = value as ReplyEnvelope;
|
||||
return { status: envelope.status, body: envelope.body, delayMs: envelope.delayMs };
|
||||
}
|
||||
return { status: 200, body: value };
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Installs the simulated API. Anything the scenario does not mention falls
|
||||
* back to a sensible, healthy default, so a test only spells out the one
|
||||
* condition it is about.
|
||||
*/
|
||||
export async function simulateApi(page: Page, scenario: Scenario = {}): Promise<void> {
|
||||
const defaults: Scenario = {
|
||||
session: SESSION,
|
||||
tenants: TENANTS,
|
||||
airports: AIRPORTS,
|
||||
airlines: AIRLINES,
|
||||
flights: page$([makeFlight()]),
|
||||
flightById: makeFlight(),
|
||||
bookings: page$([makeBooking()]),
|
||||
bookingById: makeBooking(),
|
||||
createBooking: reply({ status: 201, body: makeBooking() }),
|
||||
cancelBooking: makeBooking({ status: 'cancelled' }),
|
||||
dashboard: makeDashboard(),
|
||||
};
|
||||
|
||||
const merged = { ...defaults, ...scenario };
|
||||
|
||||
await page.route('**/api/**', async (route) => {
|
||||
if (scenario.offline) {
|
||||
await route.abort('internetdisconnected');
|
||||
return;
|
||||
}
|
||||
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
const method = request.method();
|
||||
|
||||
let answer: Reply;
|
||||
if (path === '/api/health') {
|
||||
answer = { status: 'ok' };
|
||||
} else if (path === '/api/tenants') {
|
||||
answer = merged.tenants;
|
||||
} else if (path === '/api/session') {
|
||||
answer = merged.session;
|
||||
} else if (path === '/api/airports') {
|
||||
answer = merged.airports;
|
||||
} else if (path === '/api/airlines') {
|
||||
answer = merged.airlines;
|
||||
} else if (path === '/api/dashboard') {
|
||||
answer = merged.dashboard;
|
||||
} else if (path === '/api/flights') {
|
||||
answer = merged.flights;
|
||||
} else if (path.startsWith('/api/flights/')) {
|
||||
answer = merged.flightById;
|
||||
} else if (path.endsWith('/cancel')) {
|
||||
answer = merged.cancelBooking;
|
||||
} else if (path === '/api/bookings') {
|
||||
answer = method === 'POST' ? merged.createBooking : merged.bookings;
|
||||
} else if (path.startsWith('/api/bookings/')) {
|
||||
answer = merged.bookingById;
|
||||
} else {
|
||||
answer = fail(404, 'not found');
|
||||
}
|
||||
|
||||
const { status, body, delayMs } = normalise(answer);
|
||||
const wait = delayMs ?? merged.delayMs ?? 0;
|
||||
if (wait > 0) {
|
||||
await sleep(wait);
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts credentials in localStorage before the app boots, so a simulated test
|
||||
* lands straight on the screen under test instead of signing in first.
|
||||
*/
|
||||
export async function primeCredentials(page: Page, tenant = 'skyline'): Promise<void> {
|
||||
await page.addInitScript(
|
||||
([slug]) => {
|
||||
localStorage.setItem(
|
||||
'travelhub.credentials',
|
||||
JSON.stringify({ tenant: slug, basic: btoa('agent@skyline.test:demo1234') }),
|
||||
);
|
||||
},
|
||||
[tenant],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Evidence capture for passing tests.
|
||||
*
|
||||
* Playwright keeps screenshots and traces only when something fails, which is
|
||||
* the right default for debugging but useless as a record of what the suite
|
||||
* actually proved. This fixture lets a test mark the moments that matter and
|
||||
* record the data it observed, so a successful run produces a readable report
|
||||
* rather than a row of green ticks.
|
||||
*
|
||||
* await evidence.step('Search results for JFK to LHR', { total: 42, fare: 483.3 });
|
||||
*
|
||||
* Each call attaches a screenshot plus a JSON blob. `reporters/evidence.ts`
|
||||
* turns those attachments into the report.
|
||||
*/
|
||||
import { Page, TestInfo } from '@playwright/test';
|
||||
|
||||
/** Attachment name prefixes the reporter looks for. */
|
||||
export const SHOT_PREFIX = 'evidence';
|
||||
export const DATA_PREFIX = 'evidence-data';
|
||||
|
||||
export type Facts = Record<string, unknown>;
|
||||
|
||||
export class Evidence {
|
||||
private sequence = 0;
|
||||
|
||||
constructor(
|
||||
private readonly page: Page,
|
||||
private readonly info: TestInfo,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Records a named moment: a screenshot of the current page, and optionally
|
||||
* the data the test observed there.
|
||||
*/
|
||||
async step(name: string, facts?: Facts): Promise<void> {
|
||||
const seq = this.next();
|
||||
|
||||
const shot = await this.page.screenshot({ fullPage: false, animations: 'disabled' });
|
||||
await this.info.attach(`${SHOT_PREFIX}:${seq}:${name}`, {
|
||||
body: shot,
|
||||
contentType: 'image/png',
|
||||
});
|
||||
|
||||
if (facts) {
|
||||
await this.attachFacts(seq, name, facts);
|
||||
}
|
||||
}
|
||||
|
||||
/** Records data with no screenshot -- for values read across several pages. */
|
||||
async note(name: string, facts: Facts): Promise<void> {
|
||||
await this.attachFacts(this.next(), name, facts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the whole scrollable page rather than the viewport. Slower, so it
|
||||
* is reserved for the screens worth showing in full.
|
||||
*/
|
||||
async fullPage(name: string, facts?: Facts): Promise<void> {
|
||||
const seq = this.next();
|
||||
|
||||
const shot = await this.page.screenshot({ fullPage: true, animations: 'disabled' });
|
||||
await this.info.attach(`${SHOT_PREFIX}:${seq}:${name}`, {
|
||||
body: shot,
|
||||
contentType: 'image/png',
|
||||
});
|
||||
|
||||
if (facts) {
|
||||
await this.attachFacts(seq, name, facts);
|
||||
}
|
||||
}
|
||||
|
||||
private async attachFacts(seq: string, name: string, facts: Facts): Promise<void> {
|
||||
await this.info.attach(`${DATA_PREFIX}:${seq}:${name}`, {
|
||||
body: Buffer.from(JSON.stringify(facts, null, 2), 'utf8'),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
private next(): string {
|
||||
this.sequence += 1;
|
||||
return String(this.sequence).padStart(2, '0');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { expect, test as base } from '@playwright/test';
|
||||
|
||||
import { Account } from './accounts';
|
||||
import { Evidence } from './evidence';
|
||||
import { BookingDetailPage, BookingsPage } from '../pages/bookings.page';
|
||||
import { CheckoutPage } from '../pages/checkout.page';
|
||||
import { DashboardPage } from '../pages/dashboard.page';
|
||||
import { LoginPage } from '../pages/login.page';
|
||||
import { SearchPage } from '../pages/search.page';
|
||||
import { ShellPage } from '../pages/shell.page';
|
||||
|
||||
interface Fixtures {
|
||||
login: LoginPage;
|
||||
shell: ShellPage;
|
||||
search: SearchPage;
|
||||
checkout: CheckoutPage;
|
||||
bookings: BookingsPage;
|
||||
bookingDetail: BookingDetailPage;
|
||||
dashboard: DashboardPage;
|
||||
|
||||
/** Signs in and lands on the dashboard. */
|
||||
signInAs: (account: Account) => Promise<void>;
|
||||
|
||||
/** Records screenshots and observed data for the run report. */
|
||||
evidence: Evidence;
|
||||
|
||||
/** Console and uncaught errors seen during the test; asserted empty at the end. */
|
||||
consoleErrors: string[];
|
||||
}
|
||||
|
||||
export const test = base.extend<Fixtures>({
|
||||
login: async ({ page }, use) => use(new LoginPage(page)),
|
||||
shell: async ({ page }, use) => use(new ShellPage(page)),
|
||||
search: async ({ page }, use) => use(new SearchPage(page)),
|
||||
checkout: async ({ page }, use) => use(new CheckoutPage(page)),
|
||||
bookings: async ({ page }, use) => use(new BookingsPage(page)),
|
||||
bookingDetail: async ({ page }, use) => use(new BookingDetailPage(page)),
|
||||
dashboard: async ({ page }, use) => use(new DashboardPage(page)),
|
||||
|
||||
signInAs: async ({ login }, use) => {
|
||||
await use(async (account: Account) => {
|
||||
await login.signIn(account);
|
||||
});
|
||||
},
|
||||
|
||||
evidence: async ({ page }, use, testInfo) => {
|
||||
await use(new Evidence(page, testInfo));
|
||||
},
|
||||
|
||||
// Every test also asserts the browser console stayed quiet. A stack trace in
|
||||
// the console is a real defect even when the assertions happen to pass.
|
||||
consoleErrors: [
|
||||
async ({ page }, use) => {
|
||||
const errors: string[] = [];
|
||||
|
||||
page.on('console', (message) => {
|
||||
if (message.type() !== 'error') {
|
||||
return;
|
||||
}
|
||||
// The browser logs this for any non-2xx response, including the 401s
|
||||
// and 404s these tests deliberately provoke. It says nothing about
|
||||
// whether the application handled them, so it is not a defect signal.
|
||||
if (message.text().startsWith('Failed to load resource:')) {
|
||||
return;
|
||||
}
|
||||
errors.push(`console.error: ${message.text()}`);
|
||||
});
|
||||
page.on('pageerror', (error) => {
|
||||
errors.push(`uncaught: ${error.message}`);
|
||||
});
|
||||
|
||||
await use(errors);
|
||||
|
||||
expect(errors, 'the browser console should be clean').toEqual([]);
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
});
|
||||
|
||||
export { expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* A surname unique to this test run, so a booking can be found again in a list
|
||||
* that other tests and the seed data also write to.
|
||||
*/
|
||||
export function uniqueSurname(prefix = 'E2E'): string {
|
||||
return `${prefix}${Date.now().toString(36).toUpperCase()}`;
|
||||
}
|
||||
Generated
+93
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"name": "travelhub-e2e",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "travelhub-e2e",
|
||||
"version": "0.0.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"typescript": "~5.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz",
|
||||
"integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.7.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
|
||||
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "travelhub-e2e",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Coded UI tests for TravelHub, driven through a real browser.",
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:headed": "playwright test --headed",
|
||||
"test:ui": "playwright test --ui",
|
||||
"test:debug": "playwright test --debug",
|
||||
"report": "playwright show-report",
|
||||
"install-browsers": "playwright install chromium"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"typescript": "~5.7.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { expect, Locator, Page } from '@playwright/test';
|
||||
|
||||
/** The bookings list. */
|
||||
export class BookingsPage {
|
||||
readonly rows: Locator;
|
||||
readonly emptyState: Locator;
|
||||
readonly filterAll: Locator;
|
||||
readonly filterConfirmed: Locator;
|
||||
readonly filterCancelled: Locator;
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.rows = page.locator('table.data tbody tr');
|
||||
this.emptyState = page.locator('.empty');
|
||||
this.filterAll = page.locator('.filter', { hasText: 'All' });
|
||||
this.filterConfirmed = page.locator('.filter', { hasText: 'Confirmed' });
|
||||
this.filterCancelled = page.locator('.filter', { hasText: 'Cancelled' });
|
||||
}
|
||||
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto('/bookings');
|
||||
await this.settled();
|
||||
}
|
||||
|
||||
/** Waits for either a populated table or the empty state. */
|
||||
async settled(): Promise<void> {
|
||||
await expect(this.page.locator('.filters')).toBeVisible();
|
||||
await expect(this.page.locator('.skeleton')).toHaveCount(0);
|
||||
}
|
||||
|
||||
rowFor(reference: string): Locator {
|
||||
return this.rows.filter({ hasText: reference });
|
||||
}
|
||||
|
||||
async open(reference: string): Promise<void> {
|
||||
await this.rowFor(reference).getByRole('link', { name: 'View' }).click();
|
||||
await this.page.waitForURL(/\/bookings\/[0-9a-f-]+/);
|
||||
}
|
||||
|
||||
async filterBy(status: 'all' | 'confirmed' | 'cancelled'): Promise<void> {
|
||||
const button = {
|
||||
all: this.filterAll,
|
||||
confirmed: this.filterConfirmed,
|
||||
cancelled: this.filterCancelled,
|
||||
}[status];
|
||||
await button.click();
|
||||
await this.settled();
|
||||
}
|
||||
}
|
||||
|
||||
/** A single booking, with its cancel flow. */
|
||||
export class BookingDetailPage {
|
||||
readonly confirmationBanner: Locator;
|
||||
readonly reference: Locator;
|
||||
readonly statusBadge: Locator;
|
||||
readonly travellerRows: Locator;
|
||||
readonly totalPaid: Locator;
|
||||
readonly cancelButton: Locator;
|
||||
readonly confirmCancelButton: Locator;
|
||||
readonly keepButton: Locator;
|
||||
readonly cancelledNotice: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.confirmationBanner = page.locator('.confirmed-banner');
|
||||
this.reference = page.locator('.sum-list div', { hasText: 'Reference' }).locator('dd');
|
||||
this.statusBadge = page.locator('.head-actions .badge');
|
||||
this.travellerRows = page.locator('table.data tbody tr');
|
||||
this.totalPaid = page.locator('.total-row strong');
|
||||
this.cancelButton = page.getByRole('button', { name: 'Cancel booking' });
|
||||
this.confirmCancelButton = page.getByRole('button', { name: /Yes, cancel booking|Cancelling/ });
|
||||
this.keepButton = page.getByRole('button', { name: 'Keep it' });
|
||||
this.cancelledNotice = page.locator('.alert-info');
|
||||
}
|
||||
|
||||
async bookingReference(): Promise<string> {
|
||||
return (await this.reference.innerText()).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks through the two-step cancel confirmation.
|
||||
*
|
||||
* The caller must name the booking it means to void. Cancelling is
|
||||
* destructive and these tests share a database with seeded demo data, so the
|
||||
* page object refuses to act on whatever booking happens to be on screen
|
||||
* after a mis-step earlier in the test.
|
||||
*/
|
||||
async cancel(expectedReference: string): Promise<void> {
|
||||
await expect(
|
||||
this.reference,
|
||||
`refusing to cancel: expected booking ${expectedReference} on screen`,
|
||||
).toHaveText(expectedReference);
|
||||
|
||||
await this.cancelButton.click();
|
||||
await this.confirmCancelButton.click();
|
||||
await expect(this.cancelledNotice).toBeVisible();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { expect, Locator, Page } from '@playwright/test';
|
||||
|
||||
export interface Traveller {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
dateOfBirth?: string;
|
||||
passportNumber?: string;
|
||||
}
|
||||
|
||||
/** The booking form. */
|
||||
export class CheckoutPage {
|
||||
readonly travellerFieldsets: Locator;
|
||||
readonly addTraveller: Locator;
|
||||
readonly contactEmail: Locator;
|
||||
readonly contactPhone: Locator;
|
||||
readonly confirm: Locator;
|
||||
readonly total: Locator;
|
||||
readonly error: Locator;
|
||||
readonly seatsLeft: Locator;
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.travellerFieldsets = page.locator('.pax');
|
||||
this.addTraveller = page.getByRole('button', { name: '+ Add traveller' });
|
||||
this.contactEmail = page.locator('#contactEmail');
|
||||
this.contactPhone = page.locator('#contactPhone');
|
||||
this.confirm = page.getByRole('button', { name: /Confirm booking|Confirming/ });
|
||||
this.total = page.locator('.fare-row.total strong');
|
||||
this.error = page.locator('.alert-error');
|
||||
this.seatsLeft = page.locator('.sum-list div', { hasText: 'Seats left' }).locator('dd');
|
||||
}
|
||||
|
||||
async waitUntilReady(): Promise<void> {
|
||||
await expect(this.travellerFieldsets.first()).toBeVisible();
|
||||
}
|
||||
|
||||
async totalAmount(): Promise<number> {
|
||||
const text = await this.total.innerText();
|
||||
return Number(text.replace(/[^0-9.]/g, ''));
|
||||
}
|
||||
|
||||
/** Fills one traveller block, adding a row first if necessary. */
|
||||
async fillTraveller(index: number, traveller: Traveller): Promise<void> {
|
||||
// Wait for each added row to render before deciding whether another is
|
||||
// needed -- re-counting immediately races Angular and adds one too many.
|
||||
let count = await this.travellerFieldsets.count();
|
||||
while (count <= index) {
|
||||
await this.addTraveller.click();
|
||||
count += 1;
|
||||
await expect(this.travellerFieldsets).toHaveCount(count);
|
||||
}
|
||||
await this.page.locator(`#first-${index}`).fill(traveller.firstName);
|
||||
await this.page.locator(`#last-${index}`).fill(traveller.lastName);
|
||||
if (traveller.dateOfBirth) {
|
||||
await this.page.locator(`#dob-${index}`).fill(traveller.dateOfBirth);
|
||||
}
|
||||
if (traveller.passportNumber) {
|
||||
await this.page.locator(`#passport-${index}`).fill(traveller.passportNumber);
|
||||
}
|
||||
}
|
||||
|
||||
async fillTravellers(travellers: Traveller[]): Promise<void> {
|
||||
for (let i = 0; i < travellers.length; i++) {
|
||||
await this.fillTraveller(i, travellers[i]);
|
||||
}
|
||||
}
|
||||
|
||||
async removeTraveller(index: number): Promise<void> {
|
||||
await this.travellerFieldsets.nth(index).getByRole('button', { name: 'Remove' }).click();
|
||||
}
|
||||
|
||||
/** Confirms and waits for the confirmation screen. */
|
||||
async confirmBooking(): Promise<void> {
|
||||
await this.confirm.click();
|
||||
await this.page.waitForURL(/\/bookings\/[0-9a-f-]+/);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { expect, Locator, Page } from '@playwright/test';
|
||||
|
||||
/** The agency overview. */
|
||||
export class DashboardPage {
|
||||
readonly greeting: Locator;
|
||||
readonly tiles: Locator;
|
||||
readonly recentRows: Locator;
|
||||
readonly topRoutes: Locator;
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.greeting = page.locator('h1');
|
||||
this.tiles = page.locator('.tile');
|
||||
this.recentRows = page.locator('table.data tbody tr');
|
||||
this.topRoutes = page.locator('.route-row');
|
||||
}
|
||||
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto('/dashboard');
|
||||
await this.settled();
|
||||
}
|
||||
|
||||
async settled(): Promise<void> {
|
||||
await expect(this.page.locator('.skeleton')).toHaveCount(0);
|
||||
await expect(this.tiles.first()).toBeVisible();
|
||||
}
|
||||
|
||||
/** Reads one headline figure by its label, e.g. "Revenue" or "Seats sold". */
|
||||
async tileValue(label: string): Promise<number> {
|
||||
const tile = this.tiles.filter({ has: this.page.locator('.tile-label', { hasText: label }) });
|
||||
const text = await tile.locator('.tile-value').innerText();
|
||||
return Number(text.replace(/[^0-9.]/g, ''));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { expect, Locator, Page } from '@playwright/test';
|
||||
|
||||
import { Account } from '../fixtures/accounts';
|
||||
|
||||
/** The sign-in screen: agency picker, credentials, demo shortcuts. */
|
||||
export class LoginPage {
|
||||
readonly tenantSelect: Locator;
|
||||
readonly email: Locator;
|
||||
readonly password: Locator;
|
||||
readonly submit: Locator;
|
||||
readonly error: Locator;
|
||||
readonly demoChips: Locator;
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.tenantSelect = page.locator('#tenant');
|
||||
this.email = page.locator('#email');
|
||||
this.password = page.locator('#password');
|
||||
// Matched by role rather than label: the button relabels itself to
|
||||
// "Signing in…" while a request is in flight, and tests assert on that.
|
||||
this.submit = page.locator('form button[type="submit"]');
|
||||
this.error = page.locator('.alert-error');
|
||||
this.demoChips = page.locator('.demo-chip');
|
||||
}
|
||||
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto('/login');
|
||||
await expect(this.submit).toBeVisible();
|
||||
}
|
||||
|
||||
/** Fills the form from the demo shortcut for the given account. */
|
||||
async useDemoChip(account: Account): Promise<void> {
|
||||
await this.demoChips.filter({ hasText: account.email }).first().click();
|
||||
}
|
||||
|
||||
async fill(account: Account, password = account.password): Promise<void> {
|
||||
await this.tenantSelect.selectOption(account.tenant);
|
||||
await this.email.fill(account.email);
|
||||
await this.password.fill(password);
|
||||
}
|
||||
|
||||
/** Signs in and waits for the console to appear. */
|
||||
async signIn(account: Account): Promise<void> {
|
||||
await this.goto();
|
||||
await this.useDemoChip(account);
|
||||
await this.submit.click();
|
||||
await this.page.waitForURL('**/dashboard');
|
||||
}
|
||||
|
||||
/** Attempts a sign-in that is expected to fail, and returns the message. */
|
||||
async signInExpectingFailure(account: Account, password: string): Promise<string> {
|
||||
await this.fill(account, password);
|
||||
await this.submit.click();
|
||||
await expect(this.error).toBeVisible();
|
||||
return (await this.error.innerText()).trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { expect, Locator, Page } from '@playwright/test';
|
||||
|
||||
/** One flight result card. */
|
||||
export class FlightCard {
|
||||
constructor(readonly root: Locator) {}
|
||||
|
||||
get price(): Locator {
|
||||
return this.root.locator('.price');
|
||||
}
|
||||
|
||||
get seatsBadge(): Locator {
|
||||
return this.root.locator('.fare-tags .badge').last();
|
||||
}
|
||||
|
||||
get selectButton(): Locator {
|
||||
return this.root.getByRole('button', { name: 'Select' });
|
||||
}
|
||||
|
||||
async fare(): Promise<number> {
|
||||
const text = (await this.price.innerText()).replace(/[^0-9.]/g, '');
|
||||
return Number(text);
|
||||
}
|
||||
|
||||
async seatsLeft(): Promise<number> {
|
||||
const text = await this.seatsBadge.innerText();
|
||||
return Number(text.replace(/[^0-9]/g, ''));
|
||||
}
|
||||
|
||||
async flightNumber(): Promise<string> {
|
||||
return (await this.root.locator('.carrier-meta').innerText()).split('·')[0].trim();
|
||||
}
|
||||
|
||||
async durationMinutes(): Promise<number> {
|
||||
const text = await this.root.locator('.leg-duration').innerText();
|
||||
const match = /(?:(\d+)h\s*)?(\d+)m/.exec(text);
|
||||
if (!match) {
|
||||
throw new Error(`unparseable duration: ${text}`);
|
||||
}
|
||||
return Number(match[1] ?? 0) * 60 + Number(match[2]);
|
||||
}
|
||||
}
|
||||
|
||||
/** The flight search screen. */
|
||||
export class SearchPage {
|
||||
readonly origin: Locator;
|
||||
readonly destination: Locator;
|
||||
readonly date: Locator;
|
||||
readonly cabin: Locator;
|
||||
readonly travellers: Locator;
|
||||
readonly searchButton: Locator;
|
||||
readonly resetButton: Locator;
|
||||
readonly sort: Locator;
|
||||
readonly resultSummary: Locator;
|
||||
readonly results: Locator;
|
||||
readonly emptyState: Locator;
|
||||
readonly nextPage: Locator;
|
||||
readonly prevPage: Locator;
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.origin = page.locator('#origin');
|
||||
this.destination = page.locator('#destination');
|
||||
this.date = page.locator('#date');
|
||||
this.cabin = page.locator('#cabin');
|
||||
this.travellers = page.locator('#passengers');
|
||||
this.searchButton = page.getByRole('button', { name: 'Search', exact: true });
|
||||
this.resetButton = page.getByRole('button', { name: 'Reset' });
|
||||
this.sort = page.locator('.sort select');
|
||||
this.resultSummary = page.locator('.results-bar span').first();
|
||||
this.results = page.locator('.flight');
|
||||
this.emptyState = page.locator('.empty');
|
||||
this.nextPage = page.getByRole('button', { name: 'Next →' });
|
||||
this.prevPage = page.getByRole('button', { name: '← Previous' });
|
||||
}
|
||||
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto('/search');
|
||||
await this.settled();
|
||||
}
|
||||
|
||||
card(index = 0): FlightCard {
|
||||
return new FlightCard(this.results.nth(index));
|
||||
}
|
||||
|
||||
/** Waits for the result list to stop showing skeletons. */
|
||||
async settled(): Promise<void> {
|
||||
await expect(this.page.locator('.skeleton-card')).toHaveCount(0);
|
||||
await expect(this.resultSummary).not.toHaveText(/Searching/);
|
||||
}
|
||||
|
||||
async searchRoute(from: string, to: string): Promise<void> {
|
||||
await this.origin.selectOption(from);
|
||||
await this.destination.selectOption(to);
|
||||
await this.searchButton.click();
|
||||
await this.settled();
|
||||
}
|
||||
|
||||
async setTravellers(n: number): Promise<void> {
|
||||
await this.travellers.selectOption(String(n));
|
||||
await this.searchButton.click();
|
||||
await this.settled();
|
||||
}
|
||||
|
||||
async sortBy(value: 'price' | 'duration' | 'departure'): Promise<void> {
|
||||
await this.sort.selectOption(value);
|
||||
await this.settled();
|
||||
}
|
||||
|
||||
/** Reads the "Showing 1-10 of 42 flights" line. */
|
||||
async totalResults(): Promise<number> {
|
||||
const text = await this.resultSummary.innerText();
|
||||
const match = /of (\d+) flights/.exec(text);
|
||||
return match ? Number(match[1]) : 0;
|
||||
}
|
||||
|
||||
/** Finds the card for a given flight number on the current page. */
|
||||
cardForFlight(flightNumber: string): FlightCard {
|
||||
return new FlightCard(this.results.filter({ hasText: flightNumber }).first());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Locator, Page } from '@playwright/test';
|
||||
|
||||
/** The signed-in chrome: sidebar navigation and the account bar. */
|
||||
export class ShellPage {
|
||||
readonly agencyName: Locator;
|
||||
readonly agencySlug: Locator;
|
||||
readonly accountName: Locator;
|
||||
readonly accountMeta: Locator;
|
||||
readonly signOut: Locator;
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.agencyName = page.locator('.tenant-name');
|
||||
this.agencySlug = page.locator('.tenant-slug');
|
||||
this.accountName = page.locator('.account-name');
|
||||
this.accountMeta = page.locator('.account-meta');
|
||||
this.signOut = page.getByRole('button', { name: 'Sign out' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates by sidebar link. The href selector is deliberate: several screens
|
||||
* also carry a "Search flights" call-to-action button, and matching by role
|
||||
* and name alone would be ambiguous.
|
||||
*/
|
||||
async goToDashboard(): Promise<void> {
|
||||
await this.page.locator('.nav-link[href="/dashboard"]').click();
|
||||
await this.page.waitForURL('**/dashboard');
|
||||
}
|
||||
|
||||
async goToSearch(): Promise<void> {
|
||||
await this.page.locator('.nav-link[href="/search"]').click();
|
||||
await this.page.waitForURL('**/search');
|
||||
}
|
||||
|
||||
async goToBookings(): Promise<void> {
|
||||
await this.page.locator('.nav-link[href="/bookings"]').click();
|
||||
await this.page.waitForURL('**/bookings');
|
||||
}
|
||||
|
||||
async signOutAndWait(): Promise<void> {
|
||||
await this.signOut.click();
|
||||
await this.page.waitForURL('**/login');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const WEB = process.env.E2E_BASE_URL ?? 'http://localhost:4200';
|
||||
const API = process.env.E2E_API_URL ?? 'http://localhost:8080';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
outputDir: './test-results',
|
||||
|
||||
// These tests share one database, and several of them assert on seat counts
|
||||
// and booking totals. Running a single worker keeps those assertions
|
||||
// meaningful; the suite is small enough that the wall-clock cost is minor.
|
||||
workers: 1,
|
||||
fullyParallel: false,
|
||||
|
||||
// A stray test.only should fail the build rather than silently skip the suite.
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 10_000 },
|
||||
|
||||
// The evidence reporter turns the screenshots and data captured by passing
|
||||
// tests into a readable run report; the html one stays for debugging failures.
|
||||
reporter: process.env.CI
|
||||
? [['github'], ['html', { open: 'never' }], ['./reporters/evidence.ts'], ['list']]
|
||||
: [['html', { open: 'never' }], ['./reporters/evidence.ts'], ['list']],
|
||||
|
||||
use: {
|
||||
baseURL: WEB,
|
||||
trace: 'retain-on-failure',
|
||||
// 'on' rather than 'only-on-failure': the evidence report is a record of
|
||||
// what passed, so every test contributes at least a final screenshot.
|
||||
screenshot: 'on',
|
||||
video: 'retain-on-failure',
|
||||
actionTimeout: 15_000,
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 900 } },
|
||||
},
|
||||
// A narrow viewport, because the console is built to work on a phone too.
|
||||
// Only the read-mostly specs run here: the booking flows write to a shared
|
||||
// database, and running them twice would churn the demo data for no gain.
|
||||
{
|
||||
name: 'mobile-chrome',
|
||||
use: { ...devices['Pixel 7'] },
|
||||
testMatch: /(auth|responsive)\.spec\.ts/,
|
||||
},
|
||||
],
|
||||
|
||||
// Starts the Go API and the Angular dev server if they are not already up, so
|
||||
// `npm test` is the only command a developer or CI job needs.
|
||||
webServer: [
|
||||
{
|
||||
command: 'go run ./cmd/api',
|
||||
cwd: '../backend',
|
||||
url: `${API}/api/health`,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 180_000,
|
||||
stdout: 'ignore',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
{
|
||||
command: 'npm start',
|
||||
cwd: '../frontend',
|
||||
url: WEB,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 240_000,
|
||||
stdout: 'ignore',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Builds a run report from the evidence captured by passing tests.
|
||||
*
|
||||
* Playwright's own HTML report is a debugging tool: it shows what went wrong.
|
||||
* This one is the opposite -- a record of what the suite proved, with the
|
||||
* screenshots and the data each scenario observed, readable by somebody who
|
||||
* will never open a terminal.
|
||||
*
|
||||
* Output lands in `e2e/evidence-report/`:
|
||||
* index.html the report
|
||||
* report.json the same content, machine readable
|
||||
* report.md a text summary for pasting into a ticket or a PR
|
||||
* shots/ the screenshots
|
||||
*/
|
||||
import { copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join, relative, resolve, sep } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import type { FullConfig, FullResult, Reporter, Suite, TestCase, TestResult } from '@playwright/test/reporter';
|
||||
|
||||
import { DATA_PREFIX, SHOT_PREFIX } from '../fixtures/evidence';
|
||||
import { renderHtml } from './render-html';
|
||||
import {
|
||||
RunMeta,
|
||||
Scenario,
|
||||
Step,
|
||||
Summary,
|
||||
areaFor,
|
||||
formatValue,
|
||||
friendly,
|
||||
group,
|
||||
label,
|
||||
sentenceCase,
|
||||
} from './report-model';
|
||||
|
||||
const OUT_DIR = resolve(__dirname, '..', 'evidence-report');
|
||||
const SHOT_DIR = join(OUT_DIR, 'shots');
|
||||
|
||||
export default class EvidenceReporter implements Reporter {
|
||||
private readonly scenarios: Scenario[] = [];
|
||||
private startedAt = Date.now();
|
||||
private rootDir = '';
|
||||
private meta: RunMeta = { baseURL: 'unknown', projects: [], workers: 1 };
|
||||
|
||||
onBegin(config: FullConfig, _suite: Suite): void {
|
||||
this.startedAt = Date.now();
|
||||
this.rootDir = config.rootDir;
|
||||
|
||||
this.meta = {
|
||||
baseURL: String(config.projects[0]?.use?.baseURL ?? 'unknown'),
|
||||
projects: config.projects.map((project) => {
|
||||
const viewport = project.use?.viewport;
|
||||
const size = viewport ? `${viewport.width} x ${viewport.height}` : 'default size';
|
||||
return `${project.name} (${size})`;
|
||||
}),
|
||||
workers: config.workers,
|
||||
};
|
||||
|
||||
rmSync(OUT_DIR, { recursive: true, force: true });
|
||||
mkdirSync(SHOT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
onTestEnd(test: TestCase, result: TestResult): void {
|
||||
const path = test.titlePath().filter(Boolean);
|
||||
// titlePath is [project, file, ...describes, title]
|
||||
const project = path[0] ?? 'default';
|
||||
const file = relative(this.rootDir, test.location.file).split(sep).join('/');
|
||||
const title = path[path.length - 1] ?? test.title;
|
||||
const suite = path.slice(2, -1).join(' › ');
|
||||
|
||||
const steps = this.collectSteps(test, result);
|
||||
|
||||
this.scenarios.push({
|
||||
project,
|
||||
file,
|
||||
suite,
|
||||
title,
|
||||
status: result.status,
|
||||
expected: result.status === test.expectedStatus,
|
||||
durationMs: result.duration,
|
||||
steps,
|
||||
error: result.error?.message?.split('\n').slice(0, 4).join('\n'),
|
||||
});
|
||||
}
|
||||
|
||||
/** Pairs `evidence:NN:name` screenshots with `evidence-data:NN:name` blobs. */
|
||||
private collectSteps(test: TestCase, result: TestResult): Step[] {
|
||||
const bySeq = new Map<string, Step>();
|
||||
|
||||
for (const attachment of result.attachments) {
|
||||
const match = /^(evidence|evidence-data):(\d+):(.*)$/.exec(attachment.name);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const [, kind, seq, name] = match;
|
||||
|
||||
let step = bySeq.get(seq);
|
||||
if (!step) {
|
||||
step = { seq, name };
|
||||
bySeq.set(seq, step);
|
||||
}
|
||||
|
||||
if (kind === SHOT_PREFIX) {
|
||||
// An attachment made with `body` carries the bytes in memory; one made
|
||||
// with `path` points at a file on disk. Both shapes occur.
|
||||
const safe = `${slug(test.id)}-${seq}.png`;
|
||||
try {
|
||||
if (attachment.body) {
|
||||
writeFileSync(join(SHOT_DIR, safe), attachment.body);
|
||||
step.shot = `shots/${safe}`;
|
||||
} else if (attachment.path) {
|
||||
copyFileSync(attachment.path, join(SHOT_DIR, safe));
|
||||
step.shot = `shots/${safe}`;
|
||||
}
|
||||
} catch {
|
||||
// A screenshot that could not be saved should not sink the report.
|
||||
}
|
||||
}
|
||||
|
||||
if (kind === DATA_PREFIX) {
|
||||
const raw = attachment.body
|
||||
? attachment.body.toString('utf8')
|
||||
: attachment.path
|
||||
? safeRead(attachment.path)
|
||||
: '';
|
||||
if (raw) {
|
||||
try {
|
||||
step.facts = JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
step.facts = { raw };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const steps = [...bySeq.values()].sort((a, b) => a.seq.localeCompare(b.seq));
|
||||
if (steps.length > 0) {
|
||||
return steps;
|
||||
}
|
||||
|
||||
// Not every test is instrumented. Fall back to Playwright's own
|
||||
// end-of-test screenshot so the report still shows what the run looked
|
||||
// like, just without the narrated data.
|
||||
const auto = result.attachments.find(
|
||||
(a) => a.name === 'screenshot' && a.contentType === 'image/png',
|
||||
);
|
||||
if (!auto) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const safe = `${slug(test.id)}-final.png`;
|
||||
try {
|
||||
if (auto.body) {
|
||||
writeFileSync(join(SHOT_DIR, safe), auto.body);
|
||||
} else if (auto.path) {
|
||||
copyFileSync(auto.path, join(SHOT_DIR, safe));
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ seq: '01', name: 'Final state', shot: `shots/${safe}` }];
|
||||
}
|
||||
|
||||
async onEnd(result: FullResult): Promise<void> {
|
||||
const durationMs = Date.now() - this.startedAt;
|
||||
|
||||
const summary = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
outcome: result.status,
|
||||
durationMs,
|
||||
total: this.scenarios.length,
|
||||
passed: this.scenarios.filter((s) => s.status === 'passed').length,
|
||||
failed: this.scenarios.filter((s) => s.status === 'failed' || s.status === 'timedOut').length,
|
||||
skipped: this.scenarios.filter((s) => s.status === 'skipped').length,
|
||||
narrated: this.scenarios.filter((s) => s.steps.some((step) => step.facts)).length,
|
||||
screenshots: this.scenarios.reduce(
|
||||
(n, s) => n + s.steps.filter((step) => step.shot).length,
|
||||
0,
|
||||
),
|
||||
};
|
||||
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
writeFileSync(
|
||||
join(OUT_DIR, 'report.json'),
|
||||
JSON.stringify({ summary, scenarios: this.scenarios }, null, 2),
|
||||
'utf8',
|
||||
);
|
||||
writeFileSync(join(OUT_DIR, 'report.md'), renderMarkdown(summary, this.scenarios), 'utf8');
|
||||
writeFileSync(
|
||||
join(OUT_DIR, 'index.html'),
|
||||
renderHtml(summary, this.scenarios, this.meta),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// An absolute file:// URL, because a relative path is ambiguous: the suite
|
||||
// runs with its cwd in e2e/, but people launch it from the repo root.
|
||||
const file = join(OUT_DIR, 'index.html');
|
||||
const url = pathToFileURL(file).href;
|
||||
|
||||
console.log(
|
||||
[
|
||||
'',
|
||||
'Evidence report — what passed, with data and screenshots:',
|
||||
` ${url}`,
|
||||
` ${summary.passed}/${summary.total} passed · ${summary.screenshots} screenshots · ` +
|
||||
`${summary.narrated} scenarios with recorded data`,
|
||||
'',
|
||||
' Open it: npm run report',
|
||||
' Markdown: ' + pathToFileURL(join(OUT_DIR, 'report.md')).href,
|
||||
'',
|
||||
" (`npx playwright show-report` opens Playwright's own report, which is a",
|
||||
' different artifact — it is for debugging failures.)',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---------------------------------------------------------------
|
||||
|
||||
function slug(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9]+/g, '-').slice(0, 60);
|
||||
}
|
||||
|
||||
function safeRead(path: string): string {
|
||||
try {
|
||||
return readFileSync(path, 'utf8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// --- markdown --------------------------------------------------------------
|
||||
|
||||
function renderMarkdown(summary: Summary, scenarios: Scenario[]): string {
|
||||
const lines: string[] = [
|
||||
'# TravelHub end-to-end run report',
|
||||
'',
|
||||
`Generated ${summary.generatedAt} · ${summary.passed}/${summary.total} passed · ` +
|
||||
`${friendly(summary.durationMs)}`,
|
||||
'',
|
||||
];
|
||||
|
||||
for (const file of group(scenarios)) {
|
||||
lines.push(`## ${areaFor(file.name).title}`, '', `_${areaFor(file.name).blurb}_`, '');
|
||||
for (const scenario of file.items) {
|
||||
const mark = scenario.status === 'passed' ? '✅' : scenario.status === 'skipped' ? '⏭️' : '❌';
|
||||
const heading = sentenceCase(scenario.title);
|
||||
lines.push(`### ${mark} ${heading}`, '');
|
||||
lines.push(`*${scenario.suite ? scenario.suite + ' · ' : ''}${scenario.project} · ${friendly(scenario.durationMs)}*`, '');
|
||||
|
||||
if (scenario.steps.length === 0) {
|
||||
lines.push('_No evidence captured._', '');
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const step of scenario.steps) {
|
||||
lines.push(`**${step.seq}. ${step.name}**`, '');
|
||||
if (step.facts) {
|
||||
lines.push('| Field | Value |', '| ----- | ----- |');
|
||||
for (const [key, value] of Object.entries(step.facts)) {
|
||||
lines.push(`| ${label(key)} | ${formatValue(value)} |`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
if (step.shot) {
|
||||
lines.push(``, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
/**
|
||||
* Renders the run report as a single self-contained page.
|
||||
*
|
||||
* Written for someone who did not run the tests: a verdict first, then a
|
||||
* summary by capability, then the detail. Numbered sections so it can be cited
|
||||
* ("2.3.1 failed"), and a print stylesheet so it can be saved as a PDF.
|
||||
*/
|
||||
import {
|
||||
Area,
|
||||
RunMeta,
|
||||
Scenario,
|
||||
Step,
|
||||
Summary,
|
||||
areaFor,
|
||||
escapeHtml,
|
||||
formatValue,
|
||||
friendly,
|
||||
group,
|
||||
label,
|
||||
sentenceCase,
|
||||
statusClass,
|
||||
statusWord,
|
||||
} from './report-model';
|
||||
|
||||
interface AreaBlock {
|
||||
index: number;
|
||||
area: Area;
|
||||
items: Scenario[];
|
||||
passed: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export function renderHtml(summary: Summary, scenarios: Scenario[], meta: RunMeta): string {
|
||||
const areas: AreaBlock[] = group(scenarios).map((entry, index) => ({
|
||||
index: index + 1,
|
||||
area: areaFor(entry.name),
|
||||
items: entry.items,
|
||||
passed: entry.items.filter((s) => s.status === 'passed').length,
|
||||
failed: entry.items.filter((s) => s.status === 'failed' || s.status === 'timedOut').length,
|
||||
}));
|
||||
|
||||
const allPassed = summary.failed === 0 && summary.passed > 0;
|
||||
const stamp = new Date(summary.generatedAt).toLocaleString('en-GB', {
|
||||
dateStyle: 'full',
|
||||
timeStyle: 'short',
|
||||
});
|
||||
|
||||
const contents = areas
|
||||
.map(
|
||||
(a) =>
|
||||
`<li><a href="#area-${a.index}"><span class="num">2.${a.index}</span>${escapeHtml(
|
||||
a.area.title,
|
||||
)}</a><span class="dots"></span><span class="count">${a.items.length} tests</span></li>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
const summaryRows = areas
|
||||
.map((a) => {
|
||||
const rate = Math.round((a.passed / Math.max(a.items.length, 1)) * 100);
|
||||
return `
|
||||
<tr>
|
||||
<td class="num-cell">2.${a.index}</td>
|
||||
<td><a href="#area-${a.index}">${escapeHtml(a.area.title)}</a></td>
|
||||
<td class="figure">${a.items.length}</td>
|
||||
<td class="figure">${a.passed}</td>
|
||||
<td class="figure">${a.failed || '—'}</td>
|
||||
<td>
|
||||
<div class="bar" title="${rate}% passed"><span style="width:${rate}%"></span></div>
|
||||
</td>
|
||||
<td><span class="tag ${a.failed ? 'fail' : 'pass'}">${a.failed ? 'Failed' : 'Passed'}</span></td>
|
||||
</tr>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>TravelHub — Test Execution Report</title>
|
||||
<style>
|
||||
${STYLES}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="sheet">
|
||||
|
||||
<div class="cover">
|
||||
<div class="eyebrow">Test execution report</div>
|
||||
<h1>TravelHub</h1>
|
||||
<p class="when">Multi-tenant flight booking · end-to-end suite<br />${escapeHtml(stamp)}</p>
|
||||
|
||||
<div class="verdict ${allPassed ? 'ok' : 'bad'}">
|
||||
<span class="mark">${allPassed ? '✓' : '!'}</span>
|
||||
<span>
|
||||
<span class="headline">${
|
||||
allPassed ? 'All tests passed' : `${summary.failed} of ${summary.total} tests failed`
|
||||
}</span><br />
|
||||
<span class="sub">${summary.passed} of ${summary.total} scenarios verified in ${friendly(
|
||||
summary.durationMs,
|
||||
)}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl class="facts-grid">
|
||||
<div><dt>Application under test</dt><dd>${escapeHtml(meta.baseURL)}</dd></div>
|
||||
<div><dt>Browsers</dt><dd>${escapeHtml(meta.projects.join(', ') || 'chromium')}</dd></div>
|
||||
<div><dt>Scenarios executed</dt><dd>${summary.total}</dd></div>
|
||||
<div><dt>Duration</dt><dd>${friendly(summary.durationMs)}</dd></div>
|
||||
<div><dt>Screenshots captured</dt><dd>${summary.screenshots}</dd></div>
|
||||
<div><dt>Scenarios with recorded data</dt><dd>${summary.narrated}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div class="section-title"><span class="num">1</span><h2>Summary</h2></div>
|
||||
<p class="lede">
|
||||
Each area below is a capability of the product rather than a source file. Every
|
||||
scenario was executed against the running application and a real database, except
|
||||
where an area states that the backend was simulated.
|
||||
</p>
|
||||
|
||||
<table class="summary">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th><th>Area tested</th><th class="figure">Tests</th>
|
||||
<th class="figure">Passed</th><th class="figure">Failed</th>
|
||||
<th>Pass rate</th><th>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${summaryRows}</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td></td><td>Total</td>
|
||||
<td class="figure">${summary.total}</td>
|
||||
<td class="figure">${summary.passed}</td>
|
||||
<td class="figure">${summary.failed || '—'}</td>
|
||||
<td></td>
|
||||
<td><span class="tag ${allPassed ? 'pass' : 'fail'}">${allPassed ? 'Passed' : 'Failed'}</span></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<h3 class="sub-head">Contents</h3>
|
||||
<ol class="toc">${contents}</ol>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-title"><span class="num">2</span><h2>Detailed results</h2></div>
|
||||
<p class="lede">
|
||||
Scenarios shown with a data table carry the figures the test actually observed —
|
||||
fares, booking references, seat counts — next to a screenshot of the screen at that
|
||||
moment. The rest carry a screenshot of their final state.
|
||||
</p>
|
||||
|
||||
<div class="toolbar">
|
||||
<button id="all" aria-pressed="true">All scenarios</button>
|
||||
<button id="documented" aria-pressed="false">With recorded data only</button>
|
||||
<button id="expand">Expand all</button>
|
||||
<button id="collapse">Collapse all</button>
|
||||
<button id="pdf" title="Opens your browser's print dialog; choose "Save as PDF"">Save as PDF</button>
|
||||
<span class="legend">
|
||||
<span><i class="swatch pass"></i>Passed</span>
|
||||
<span><i class="swatch fail"></i>Failed</span>
|
||||
<span><i class="swatch skip"></i>Not run</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
${areas.map(renderArea).join('')}
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Generated automatically by the TravelHub end-to-end suite
|
||||
(<code>npm run test:e2e</code>) and reopened with <code>npm run report</code>.
|
||||
Screenshots are taken at the moment each step completed; click one to enlarge.
|
||||
Use your browser’s print function to save this report as a PDF.
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<dialog id="lightbox"><img alt="" /></dialog>
|
||||
|
||||
<script>
|
||||
${SCRIPT}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function renderArea(entry: AreaBlock): string {
|
||||
const scenarios = entry.items
|
||||
.map((scenario, i) => renderScenario(scenario, `2.${entry.index}.${i + 1}`))
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<div class="area" id="area-${entry.index}">
|
||||
<div class="area-head">
|
||||
<div class="section-title">
|
||||
<span class="num">2.${entry.index}</span>
|
||||
<h2 class="area-title">${escapeHtml(entry.area.title)}</h2>
|
||||
<span class="tag ${entry.failed ? 'fail' : 'pass'}">${entry.passed}/${entry.items.length} passed</span>
|
||||
</div>
|
||||
<p class="lede area-blurb">${escapeHtml(entry.area.blurb)}</p>
|
||||
</div>
|
||||
${scenarios}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderScenario(scenario: Scenario, ref: string): string {
|
||||
const hasFacts = scenario.steps.some((s) => s.facts);
|
||||
const steps =
|
||||
scenario.steps.length === 0
|
||||
? '<p class="no-evidence">No screenshot was captured for this scenario.</p>'
|
||||
: scenario.steps.map(renderStep).join('');
|
||||
|
||||
return `
|
||||
<details class="scenario" data-facts="${hasFacts ? '1' : '0'}"${hasFacts ? ' open' : ''}>
|
||||
<summary>
|
||||
<span class="ref">${ref}</span>
|
||||
<span class="headline">
|
||||
${scenario.suite ? `<span class="group">${escapeHtml(scenario.suite)}</span>` : ''}
|
||||
<span class="what">${escapeHtml(sentenceCase(scenario.title))}</span>
|
||||
</span>
|
||||
<span class="right">
|
||||
<span class="hide-narrow">${escapeHtml(scenario.project)}</span>
|
||||
<span>${friendly(scenario.durationMs)}</span>
|
||||
<span class="tag ${statusClass(scenario.status)}">${statusWord(scenario.status)}</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="body">
|
||||
${scenario.error ? `<pre class="error">${escapeHtml(scenario.error)}</pre>` : ''}
|
||||
${steps}
|
||||
</div>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
function renderStep(step: Step): string {
|
||||
const facts = step.facts
|
||||
? `<table class="facts">${Object.entries(step.facts)
|
||||
.map(
|
||||
([key, value]) =>
|
||||
`<tr><td>${escapeHtml(label(key))}</td><td>${escapeHtml(formatValue(value))}</td></tr>`,
|
||||
)
|
||||
.join('')}</table>`
|
||||
: '';
|
||||
|
||||
const shot = step.shot
|
||||
? `<figure>
|
||||
<img class="shot" loading="lazy" src="${escapeHtml(step.shot)}" alt="${escapeHtml(step.name)}" />
|
||||
<figcaption>Screen at this point<span class="hint"> · click to enlarge</span></figcaption>
|
||||
</figure>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="step">
|
||||
<h4><span class="n">${escapeHtml(step.seq)}</span>${escapeHtml(step.name)}</h4>
|
||||
${facts}
|
||||
${shot}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const STYLES = `
|
||||
:root {
|
||||
--ink:#111827; --body:#374151; --muted:#6b7280; --faint:#9ca3af;
|
||||
--line:#e5e7eb; --rule:#d1d5db; --bg:#f3f4f6; --card:#fff;
|
||||
--pass:#067a6e; --pass-bg:#e6f4f1; --fail:#b3243f; --fail-bg:#fdeaee;
|
||||
--skip:#6b7280; --skip-bg:#f3f4f6; --accent:#0b5cd6;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--body);
|
||||
font:15px/1.6 'Inter',-apple-system,'Segoe UI',Roboto,sans-serif}
|
||||
.sheet{max-width:1080px;margin:0 auto;background:var(--card);
|
||||
box-shadow:0 1px 3px rgba(0,0,0,.08)}
|
||||
h1,h2,h3,h4{color:var(--ink);margin:0;line-height:1.3}
|
||||
a{color:var(--accent)}
|
||||
|
||||
.cover{padding:44px 56px 36px;border-bottom:3px solid var(--ink)}
|
||||
.eyebrow{font-size:.72rem;letter-spacing:.16em;text-transform:uppercase;
|
||||
color:var(--faint);font-weight:650;margin-bottom:10px}
|
||||
.cover h1{font-size:2.05rem;letter-spacing:-.025em;margin-bottom:6px}
|
||||
.cover .when{color:var(--muted);font-size:.92rem;margin:0}
|
||||
|
||||
.verdict{display:flex;align-items:center;gap:18px;margin-top:28px;padding:20px 24px;
|
||||
border-radius:10px;border:1px solid}
|
||||
.verdict.ok{background:var(--pass-bg);border-color:#b9e0d8}
|
||||
.verdict.bad{background:var(--fail-bg);border-color:#f3c2cc}
|
||||
.verdict .mark{width:44px;height:44px;border-radius:50%;display:grid;place-items:center;
|
||||
font-size:1.3rem;color:#fff;flex-shrink:0}
|
||||
.verdict.ok .mark{background:var(--pass)}
|
||||
.verdict.bad .mark{background:var(--fail)}
|
||||
.verdict .headline{font-size:1.16rem;font-weight:650;color:var(--ink)}
|
||||
.verdict .sub{font-size:.9rem;color:var(--muted)}
|
||||
|
||||
.facts-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));
|
||||
border-top:1px solid var(--line);margin:28px 0 0}
|
||||
.facts-grid div{padding:14px 24px 14px 0;border-bottom:1px solid var(--line)}
|
||||
.facts-grid dt{font-size:.72rem;letter-spacing:.08em;text-transform:uppercase;
|
||||
color:var(--faint);font-weight:650;margin-bottom:3px}
|
||||
.facts-grid dd{margin:0;font-size:.92rem;color:var(--ink)}
|
||||
|
||||
section{padding:36px 56px}
|
||||
section + section{border-top:1px solid var(--line)}
|
||||
.section-title{display:flex;align-items:baseline;gap:12px;margin-bottom:6px;flex-wrap:wrap}
|
||||
.section-title .num{color:var(--faint);font-variant-numeric:tabular-nums;
|
||||
font-weight:650;font-size:1.05rem}
|
||||
.section-title h2{font-size:1.3rem;letter-spacing:-.015em}
|
||||
.area-title{font-size:1.12rem}
|
||||
.lede{color:var(--muted);font-size:.93rem;margin:0 0 22px;max-width:74ch}
|
||||
.area-blurb{margin:8px 0 0}
|
||||
.sub-head{margin-top:30px;font-size:.95rem}
|
||||
|
||||
.toc{list-style:none;padding:0;margin:10px 0 0;max-width:640px}
|
||||
.toc li{display:flex;align-items:baseline;gap:8px;padding:7px 0;
|
||||
border-bottom:1px dotted var(--rule)}
|
||||
.toc a{text-decoration:none;color:var(--ink)}
|
||||
.toc a:hover{color:var(--accent)}
|
||||
.toc .num{color:var(--faint);font-variant-numeric:tabular-nums;margin-right:10px}
|
||||
.toc .dots{flex:1}
|
||||
.toc .count{color:var(--faint);font-size:.85rem;font-variant-numeric:tabular-nums}
|
||||
|
||||
table.summary{width:100%;border-collapse:collapse;font-size:.92rem}
|
||||
table.summary th{text-align:left;font-size:.7rem;letter-spacing:.08em;text-transform:uppercase;
|
||||
color:var(--faint);font-weight:650;padding:0 12px 8px 0;
|
||||
border-bottom:2px solid var(--rule)}
|
||||
table.summary td{padding:11px 12px 11px 0;border-bottom:1px solid var(--line);
|
||||
vertical-align:middle}
|
||||
table.summary td a{text-decoration:none;font-weight:550}
|
||||
.num-cell{color:var(--faint);font-variant-numeric:tabular-nums;width:48px}
|
||||
.figure{font-variant-numeric:tabular-nums;text-align:right;width:70px}
|
||||
.bar{width:110px;height:7px;background:var(--line);border-radius:99px;overflow:hidden}
|
||||
.bar span{display:block;height:100%;background:var(--pass);border-radius:99px}
|
||||
.tag{display:inline-block;font-size:.68rem;font-weight:700;letter-spacing:.06em;
|
||||
text-transform:uppercase;padding:3px 9px;border-radius:4px;white-space:nowrap}
|
||||
.tag.pass{background:var(--pass-bg);color:var(--pass)}
|
||||
.tag.fail{background:var(--fail-bg);color:var(--fail)}
|
||||
.tag.skip{background:var(--skip-bg);color:var(--skip)}
|
||||
tfoot td{font-weight:650;color:var(--ink);border-bottom:none;padding-top:14px}
|
||||
|
||||
.area{padding-top:34px}
|
||||
.area:first-of-type{padding-top:0}
|
||||
.area-head{border-left:3px solid var(--accent);padding-left:16px;margin-bottom:20px}
|
||||
|
||||
.scenario{border:1px solid var(--line);border-radius:8px;margin-bottom:14px;
|
||||
overflow:hidden;background:var(--card)}
|
||||
.scenario > summary{list-style:none;cursor:pointer;padding:13px 16px;display:flex;
|
||||
gap:14px;align-items:flex-start}
|
||||
.scenario > summary::-webkit-details-marker{display:none}
|
||||
.scenario > summary:hover{background:#fafafa}
|
||||
.ref{color:var(--faint);font-variant-numeric:tabular-nums;font-size:.85rem;
|
||||
min-width:52px;flex-shrink:0;padding-top:2px}
|
||||
.headline{flex:1;min-width:0}
|
||||
.headline .what{font-weight:600;color:var(--ink)}
|
||||
.headline .group{display:block;font-size:.78rem;color:var(--muted);margin-bottom:1px}
|
||||
.right{display:flex;align-items:center;gap:10px;flex-shrink:0;
|
||||
font-size:.78rem;color:var(--faint)}
|
||||
|
||||
.body{border-top:1px solid var(--line);padding:0 16px 8px;background:#fcfcfd}
|
||||
.step{padding:18px 0;border-bottom:1px dashed var(--line)}
|
||||
.step:last-child{border-bottom:none}
|
||||
.step h4{font-size:.95rem;margin-bottom:10px;display:flex;gap:9px;align-items:baseline}
|
||||
.step h4 .n{color:var(--faint);font-variant-numeric:tabular-nums;font-weight:650;font-size:.8rem}
|
||||
table.facts{border-collapse:collapse;font-size:.86rem;margin:0 0 12px;min-width:340px}
|
||||
table.facts td{border:1px solid var(--line);padding:6px 11px}
|
||||
table.facts td:first-child{background:#f8fafc;color:var(--muted);white-space:nowrap;
|
||||
font-size:.8rem}
|
||||
table.facts td:last-child{font-variant-numeric:tabular-nums;color:var(--ink);font-weight:500}
|
||||
figure{margin:0}
|
||||
.shot{display:block;max-width:100%;border:1px solid var(--rule);border-radius:6px;
|
||||
cursor:zoom-in}
|
||||
figcaption{font-size:.75rem;color:var(--faint);margin-top:5px}
|
||||
.no-evidence{color:var(--faint);font-size:.86rem;padding:14px 0}
|
||||
.error{background:var(--fail-bg);color:#8c1030;padding:11px 14px;margin:12px 0;
|
||||
border-radius:6px;font-size:.83rem;white-space:pre-wrap;
|
||||
font-family:ui-monospace,monospace}
|
||||
|
||||
.toolbar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:20px}
|
||||
.toolbar button{border:1px solid var(--rule);background:#fff;border-radius:6px;
|
||||
padding:6px 13px;font:inherit;font-size:.85rem;cursor:pointer;color:var(--body)}
|
||||
.toolbar button:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.toolbar button[aria-pressed="true"]{background:var(--ink);border-color:var(--ink);color:#fff}
|
||||
.legend{display:flex;gap:16px;flex-wrap:wrap;font-size:.8rem;color:var(--muted);margin-left:auto}
|
||||
.legend span{display:inline-flex;align-items:center;gap:6px}
|
||||
.swatch{width:10px;height:10px;border-radius:2px;display:inline-block}
|
||||
.swatch.pass{background:var(--pass)}
|
||||
.swatch.fail{background:var(--fail)}
|
||||
.swatch.skip{background:var(--skip)}
|
||||
|
||||
footer{padding:26px 56px 44px;color:var(--faint);font-size:.82rem;
|
||||
border-top:1px solid var(--line)}
|
||||
footer code{background:var(--bg);padding:1px 5px;border-radius:3px}
|
||||
|
||||
dialog{border:none;border-radius:10px;padding:0;max-width:96vw;max-height:96vh}
|
||||
dialog::backdrop{background:rgba(17,24,39,.8)}
|
||||
dialog img{display:block;max-width:96vw;max-height:96vh}
|
||||
|
||||
@media (max-width:720px){
|
||||
.cover{padding:28px 20px 24px}
|
||||
section{padding:26px 20px}
|
||||
footer{padding:22px 20px 34px}
|
||||
.hide-narrow{display:none}
|
||||
.scenario > summary{flex-wrap:wrap}
|
||||
table.summary{font-size:.85rem}
|
||||
.bar{width:60px}
|
||||
}
|
||||
|
||||
@media print{
|
||||
body{background:#fff;font-size:11pt}
|
||||
.sheet{box-shadow:none;max-width:none}
|
||||
.toolbar,dialog{display:none !important}
|
||||
|
||||
/* The page margins come from the printer, so drop the screen gutters. */
|
||||
.cover{padding:0 0 22px}
|
||||
section{padding:22px 0}
|
||||
footer{padding:16px 0 0}
|
||||
|
||||
.area{break-before:page}
|
||||
.area:first-of-type{break-before:auto}
|
||||
.area-head{break-after:avoid}
|
||||
.scenario{break-inside:avoid;border-color:var(--rule)}
|
||||
.scenario > summary{background:#fff}
|
||||
.step{break-inside:avoid}
|
||||
h2,h3,h4{break-after:avoid}
|
||||
table.summary{break-inside:avoid}
|
||||
|
||||
/* A closed <details> prints nothing, which would silently drop most of the
|
||||
report. The beforeprint handler opens them; this is the belt and braces. */
|
||||
.scenario:not([open]) > .body{display:block}
|
||||
|
||||
.shot{max-height:150mm;object-fit:contain;object-position:left top}
|
||||
a,a:visited,table.summary td a,.toc a{color:var(--ink);text-decoration:none}
|
||||
.toc .count,.right{color:var(--muted)}
|
||||
.hint{display:none} /* nothing to click on paper */
|
||||
}`;
|
||||
|
||||
const SCRIPT = `
|
||||
const box = document.getElementById('lightbox');
|
||||
const img = box.querySelector('img');
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target instanceof HTMLImageElement && e.target.classList.contains('shot')) {
|
||||
img.src = e.target.src;
|
||||
box.showModal();
|
||||
} else if (e.target === box) {
|
||||
box.close();
|
||||
}
|
||||
});
|
||||
|
||||
const all = document.getElementById('all');
|
||||
const documented = document.getElementById('documented');
|
||||
function filter(onlyDocumented) {
|
||||
all.setAttribute('aria-pressed', String(!onlyDocumented));
|
||||
documented.setAttribute('aria-pressed', String(onlyDocumented));
|
||||
for (const el of document.querySelectorAll('.scenario')) {
|
||||
el.hidden = onlyDocumented && el.dataset.facts === '0';
|
||||
}
|
||||
for (const area of document.querySelectorAll('.area')) {
|
||||
area.hidden = ![...area.querySelectorAll('.scenario')].some((s) => !s.hidden);
|
||||
}
|
||||
}
|
||||
all.addEventListener('click', () => filter(false));
|
||||
documented.addEventListener('click', () => filter(true));
|
||||
document.getElementById('expand').addEventListener('click', () =>
|
||||
document.querySelectorAll('.scenario').forEach((d) => (d.open = true)));
|
||||
document.getElementById('collapse').addEventListener('click', () =>
|
||||
document.querySelectorAll('.scenario').forEach((d) => (d.open = false)));
|
||||
|
||||
document.getElementById('pdf').addEventListener('click', () => window.print());
|
||||
|
||||
// Printing a collapsed <details> prints only its summary, so open everything
|
||||
// first and put it back the way the reader had it afterwards.
|
||||
let restore = [];
|
||||
window.addEventListener('beforeprint', () => {
|
||||
restore = [...document.querySelectorAll('.scenario')].map((d) => [d, d.open]);
|
||||
for (const [d] of restore) {
|
||||
d.open = true;
|
||||
}
|
||||
for (const img of document.querySelectorAll('img.shot')) {
|
||||
img.loading = 'eager';
|
||||
}
|
||||
});
|
||||
window.addEventListener('afterprint', () => {
|
||||
for (const [d, was] of restore) {
|
||||
d.open = was;
|
||||
}
|
||||
});`;
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* The shape of a run report, and the vocabulary it is written in.
|
||||
*
|
||||
* Kept separate from both the collector and the renderers so neither depends on
|
||||
* the other: `evidence.ts` gathers, `render-html.ts` presents, and both agree
|
||||
* on what a scenario is by importing from here.
|
||||
*/
|
||||
import type { TestResult } from '@playwright/test/reporter';
|
||||
|
||||
/** One captured moment inside a scenario. */
|
||||
export interface Step {
|
||||
seq: string;
|
||||
name: string;
|
||||
shot?: string; // path relative to the report root
|
||||
facts?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** One test, as the report talks about it. */
|
||||
export interface Scenario {
|
||||
project: string;
|
||||
file: string;
|
||||
suite: string;
|
||||
title: string;
|
||||
status: TestResult['status'];
|
||||
expected: boolean;
|
||||
durationMs: number;
|
||||
steps: Step[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** How the run was configured, for the cover page. */
|
||||
export interface RunMeta {
|
||||
baseURL: string;
|
||||
projects: string[];
|
||||
workers: number;
|
||||
}
|
||||
|
||||
export interface Summary {
|
||||
generatedAt: string;
|
||||
outcome: string;
|
||||
durationMs: number;
|
||||
total: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
narrated: number;
|
||||
screenshots: number;
|
||||
}
|
||||
|
||||
// --- areas -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Spec filenames are an implementation detail. The report groups by what the
|
||||
* tests actually cover, so somebody who has never opened the codebase can read
|
||||
* it without a glossary.
|
||||
*/
|
||||
export interface Area {
|
||||
title: string;
|
||||
blurb: string;
|
||||
}
|
||||
|
||||
export const AREAS: Record<string, Area> = {
|
||||
'auth.spec.ts': {
|
||||
title: 'Signing in and sessions',
|
||||
blurb:
|
||||
'Agents sign in against their own agency. Credentials issued by one agency must not work at another, and a session should survive a page reload but not outlive a sign-out.',
|
||||
},
|
||||
'search.spec.ts': {
|
||||
title: 'Searching for flights',
|
||||
blurb:
|
||||
'Filtering the agency inventory by route, date, cabin and party size; ordering results by fare, journey time or departure; and paging through them.',
|
||||
},
|
||||
'booking.spec.ts': {
|
||||
title: 'Booking and cancelling',
|
||||
blurb:
|
||||
'Selling a ticket end to end: the fare carried into checkout, traveller details, confirmation with a booking reference, and cancellation returning the seats to inventory.',
|
||||
},
|
||||
'tenant-isolation.spec.ts': {
|
||||
title: 'Keeping agencies separate',
|
||||
blurb:
|
||||
'The central promise of the platform. Two agencies share one deployment and one database, and neither may see the other’s inventory, customers or revenue — not even by guessing a web address.',
|
||||
},
|
||||
'dashboard.spec.ts': {
|
||||
title: 'Agency dashboard',
|
||||
blurb:
|
||||
'Trading figures for the signed-in agency — revenue, seats sold, bookings and best-selling routes — and that they move correctly when a sale is made and when it is cancelled.',
|
||||
},
|
||||
'simulation.spec.ts': {
|
||||
title: 'Behaviour when things go wrong',
|
||||
blurb:
|
||||
'Here the backend is simulated so it can misbehave on demand: server errors, slow responses, a dropped network, an expired session, a seat sold mid-purchase, and awkward data such as very long names.',
|
||||
},
|
||||
'responsive.spec.ts': {
|
||||
title: 'Phones and small screens',
|
||||
blurb:
|
||||
'The console is used at a desk and on a phone. No screen may scroll sideways at phone width, and wide tables must scroll inside their own frame rather than stretching the page.',
|
||||
},
|
||||
};
|
||||
|
||||
/** Reading order, rather than alphabetical: a reader follows the user journey. */
|
||||
export const AREA_ORDER = [
|
||||
'auth.spec.ts',
|
||||
'search.spec.ts',
|
||||
'booking.spec.ts',
|
||||
'tenant-isolation.spec.ts',
|
||||
'dashboard.spec.ts',
|
||||
'simulation.spec.ts',
|
||||
'responsive.spec.ts',
|
||||
];
|
||||
|
||||
export function areaFor(file: string): Area {
|
||||
const name = file.split('/').pop() ?? file;
|
||||
return AREAS[name] ?? { title: name, blurb: '' };
|
||||
}
|
||||
|
||||
export function group(scenarios: Scenario[]): Array<{ name: string; items: Scenario[] }> {
|
||||
const map = new Map<string, Scenario[]>();
|
||||
for (const scenario of scenarios) {
|
||||
const list = map.get(scenario.file) ?? [];
|
||||
list.push(scenario);
|
||||
map.set(scenario.file, list);
|
||||
}
|
||||
|
||||
const rank = (file: string) => {
|
||||
const index = AREA_ORDER.indexOf(file.split('/').pop() ?? file);
|
||||
return index === -1 ? AREA_ORDER.length : index;
|
||||
};
|
||||
|
||||
return [...map.entries()]
|
||||
.sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
|
||||
.map(([name, items]) => ({ name, items }));
|
||||
}
|
||||
|
||||
// --- formatting ------------------------------------------------------------
|
||||
|
||||
export function escapeHtml(value: unknown): string {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export function formatValue(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '—';
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'Yes' : 'No';
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/** "1 min 30 s" reads better in a summary than "89.8 s". */
|
||||
export function friendly(ms: number): string {
|
||||
if (ms < 1000) {
|
||||
return `${ms} ms`;
|
||||
}
|
||||
const total = Math.round(ms / 1000);
|
||||
const minutes = Math.floor(total / 60);
|
||||
const secs = total % 60;
|
||||
return minutes === 0 ? `${secs} s` : `${minutes} min ${String(secs).padStart(2, '0')} s`;
|
||||
}
|
||||
|
||||
export function sentenceCase(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
|
||||
/** seatsAvailable -> "Seats available", so data tables read as English. */
|
||||
export function label(key: string): string {
|
||||
const spaced = key
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
return sentenceCase(spaced);
|
||||
}
|
||||
|
||||
export function statusWord(status: Scenario['status']): string {
|
||||
switch (status) {
|
||||
case 'passed':
|
||||
return 'Passed';
|
||||
case 'skipped':
|
||||
return 'Not run';
|
||||
case 'timedOut':
|
||||
return 'Timed out';
|
||||
default:
|
||||
return 'Failed';
|
||||
}
|
||||
}
|
||||
|
||||
export function statusClass(status: Scenario['status']): string {
|
||||
if (status === 'passed') {
|
||||
return 'pass';
|
||||
}
|
||||
return status === 'skipped' ? 'skip' : 'fail';
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Exports the evidence report to PDF, headlessly.
|
||||
*
|
||||
* Uses the Chromium that Playwright already installed, so there is no new
|
||||
* dependency and no need to open a browser by hand. The browser's own
|
||||
* File > Print also works and produces the same layout; this exists so the
|
||||
* export can be scripted, and so CI can attach a PDF to a build.
|
||||
*
|
||||
* node scripts/report-pdf.mjs everything
|
||||
* node scripts/report-pdf.mjs --summary summary plus the narrated scenarios only
|
||||
* node scripts/report-pdf.mjs --out x.pdf write somewhere else
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, statSync } from 'node:fs';
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
const E2E = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const REPORT_DIR = join(E2E, 'evidence-report');
|
||||
const SOURCE = join(REPORT_DIR, 'index.html');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const summaryOnly = args.includes('--summary');
|
||||
|
||||
const outFlag = args.indexOf('--out');
|
||||
const requested = outFlag !== -1 ? args[outFlag + 1] : undefined;
|
||||
const OUT = requested
|
||||
? isAbsolute(requested)
|
||||
? requested
|
||||
: resolve(process.cwd(), requested)
|
||||
: join(REPORT_DIR, summaryOnly ? 'TravelHub-test-report-summary.pdf' : 'TravelHub-test-report.pdf');
|
||||
|
||||
if (!existsSync(SOURCE)) {
|
||||
console.error(
|
||||
`No report found at ${SOURCE}\n` + 'Run the suite first: npm run test:e2e',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const browser = await chromium.launch();
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.goto(pathToFileURL(SOURCE).href, { waitUntil: 'load' });
|
||||
|
||||
const counts = await page.evaluate(async (opts) => {
|
||||
if (opts.summaryOnly) {
|
||||
// Drop scenarios that carry only a screenshot, then any area left empty.
|
||||
for (const el of document.querySelectorAll('.scenario')) {
|
||||
if (el.dataset.facts === '0') {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
for (const area of document.querySelectorAll('.area')) {
|
||||
if (!area.querySelector('.scenario')) {
|
||||
area.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A collapsed <details> would print as a one-line summary.
|
||||
for (const details of document.querySelectorAll('details')) {
|
||||
details.open = true;
|
||||
}
|
||||
|
||||
// Lazy images never load off-screen, and the PDF renderer does not scroll.
|
||||
// Only the screenshots: the lightbox holds an empty <img> that never loads.
|
||||
const images = [...document.querySelectorAll('img.shot')];
|
||||
for (const img of images) {
|
||||
img.loading = 'eager';
|
||||
}
|
||||
await Promise.all(
|
||||
images.map((img) =>
|
||||
img.complete
|
||||
? null
|
||||
: new Promise((done) => {
|
||||
img.addEventListener('load', done, { once: true });
|
||||
img.addEventListener('error', done, { once: true });
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
scenarios: document.querySelectorAll('.scenario').length,
|
||||
images: images.length,
|
||||
unloaded: images.filter((i) => i.naturalWidth === 0).length,
|
||||
};
|
||||
}, { summaryOnly });
|
||||
|
||||
if (counts.unloaded > 0) {
|
||||
console.warn(` warning: ${counts.unloaded} screenshot(s) failed to load`);
|
||||
}
|
||||
|
||||
await page.emulateMedia({ media: 'print' });
|
||||
|
||||
const foot =
|
||||
'<div style="width:100%;font-size:8pt;color:#9ca3af;padding:0 12mm;' +
|
||||
'font-family:Segoe UI,Helvetica,Arial,sans-serif;display:flex;justify-content:space-between">' +
|
||||
'<span>TravelHub — test execution report</span>' +
|
||||
'<span>Page <span class="pageNumber"></span> of <span class="totalPages"></span></span>' +
|
||||
'</div>';
|
||||
|
||||
mkdirSync(dirname(OUT), { recursive: true });
|
||||
|
||||
await page.pdf({
|
||||
path: OUT,
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
displayHeaderFooter: true,
|
||||
headerTemplate: '<div></div>', // suppress Chromium's default header
|
||||
footerTemplate: foot,
|
||||
margin: { top: '14mm', bottom: '16mm', left: '12mm', right: '12mm' },
|
||||
});
|
||||
|
||||
const mb = (statSync(OUT).size / 1024 / 1024).toFixed(1);
|
||||
console.log(
|
||||
`PDF written: ${OUT}\n` +
|
||||
` ${counts.scenarios} scenarios · ${counts.images} screenshots · ${mb} MB` +
|
||||
(summaryOnly ? ' (summary: narrated scenarios only)' : ''),
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { expect, test } from '../fixtures/test';
|
||||
import { GLOBETROTTER_AGENT, SKYLINE_ADMIN, SKYLINE_AGENT } from '../fixtures/accounts';
|
||||
|
||||
test.describe('Sign in', () => {
|
||||
test('offers every active agency in the picker', async ({ login }) => {
|
||||
await login.goto();
|
||||
|
||||
// One placeholder option plus the seeded agencies.
|
||||
await expect(login.tenantSelect.locator('option')).toHaveCount(3);
|
||||
await expect(login.tenantSelect.locator('option')).toContainText([
|
||||
'Select an agency',
|
||||
'Globetrotter Tours',
|
||||
'Skyline Travel',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a demo shortcut fills the whole form', async ({ login }) => {
|
||||
await login.goto();
|
||||
await login.useDemoChip(SKYLINE_AGENT);
|
||||
|
||||
await expect(login.tenantSelect).toHaveValue('skyline');
|
||||
await expect(login.email).toHaveValue(SKYLINE_AGENT.email);
|
||||
await expect(login.password).toHaveValue(SKYLINE_AGENT.password);
|
||||
});
|
||||
|
||||
test('signs an agent into their own agency', async ({ login, shell, page, evidence }) => {
|
||||
await login.goto();
|
||||
await evidence.step('Sign-in screen with the agency picker', {
|
||||
agenciesOffered: (await login.tenantSelect.locator('option').count()) - 1,
|
||||
demoShortcuts: await login.demoChips.count(),
|
||||
});
|
||||
|
||||
await login.signIn(SKYLINE_AGENT);
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await expect(shell.agencyName).toHaveText(SKYLINE_AGENT.tenantName);
|
||||
await expect(shell.accountName).toHaveText(SKYLINE_AGENT.fullName);
|
||||
await expect(shell.accountMeta).toContainText(SKYLINE_AGENT.email);
|
||||
|
||||
await evidence.step('Signed in to the agency console', {
|
||||
agency: SKYLINE_AGENT.tenantName,
|
||||
user: SKYLINE_AGENT.fullName,
|
||||
email: SKYLINE_AGENT.email,
|
||||
role: SKYLINE_AGENT.role,
|
||||
landedOn: '/dashboard',
|
||||
});
|
||||
});
|
||||
|
||||
test('signs an admin in and shows the admin role', async ({ login, shell }) => {
|
||||
await login.signIn(SKYLINE_ADMIN);
|
||||
|
||||
await expect(shell.accountName).toHaveText(SKYLINE_ADMIN.fullName);
|
||||
await expect(shell.accountMeta).toContainText('admin');
|
||||
});
|
||||
|
||||
test('rejects a wrong password', async ({ login }) => {
|
||||
await login.goto();
|
||||
|
||||
const message = await login.signInExpectingFailure(SKYLINE_AGENT, 'definitely-wrong');
|
||||
|
||||
expect(message).toContain('not valid for this agency');
|
||||
await expect(login.submit).toBeVisible();
|
||||
});
|
||||
|
||||
// The central multi-tenant rule, exercised through the UI.
|
||||
test('rejects valid credentials aimed at the wrong agency', async ({ login }) => {
|
||||
await login.goto();
|
||||
await login.tenantSelect.selectOption(GLOBETROTTER_AGENT.tenant);
|
||||
await login.email.fill(SKYLINE_AGENT.email);
|
||||
await login.password.fill(SKYLINE_AGENT.password);
|
||||
await login.submit.click();
|
||||
|
||||
await expect(login.error).toContainText('not valid for this agency');
|
||||
await expect(login.submit).toBeVisible();
|
||||
});
|
||||
|
||||
test('will not submit without an agency', async ({ login, page }) => {
|
||||
await login.goto();
|
||||
await login.email.fill(SKYLINE_AGENT.email);
|
||||
await login.password.fill(SKYLINE_AGENT.password);
|
||||
await login.submit.click();
|
||||
|
||||
await expect(page.locator('.field .error')).toContainText('choose an agency');
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
|
||||
test('rejects a malformed email before contacting the server', async ({ login, page }) => {
|
||||
await login.goto();
|
||||
await login.tenantSelect.selectOption(SKYLINE_AGENT.tenant);
|
||||
await login.email.fill('not-an-email');
|
||||
await login.password.fill('whatever');
|
||||
await login.submit.click();
|
||||
|
||||
await expect(page.locator('.field .error')).toContainText('valid email');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Session', () => {
|
||||
test('survives a page reload', async ({ login, shell, page }) => {
|
||||
await login.signIn(SKYLINE_AGENT);
|
||||
|
||||
await page.reload();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await expect(shell.agencyName).toHaveText(SKYLINE_AGENT.tenantName);
|
||||
});
|
||||
|
||||
test('sends an anonymous visitor to sign-in and back again', async ({ login, page }) => {
|
||||
await page.goto('/bookings');
|
||||
|
||||
// The guard should bounce to the sign-in screen and remember the target.
|
||||
await expect(page).toHaveURL(/\/login\?returnTo=%2Fbookings/);
|
||||
|
||||
await login.useDemoChip(SKYLINE_AGENT);
|
||||
await login.submit.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/bookings$/);
|
||||
});
|
||||
|
||||
test('signing out clears the session for good', async ({ login, shell, page }) => {
|
||||
await login.signIn(SKYLINE_AGENT);
|
||||
await shell.signOutAndWait();
|
||||
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
|
||||
// Going back must not resurrect the console.
|
||||
await page.goto('/dashboard');
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
|
||||
test('an already signed-in user is moved off the sign-in screen', async ({ login, page }) => {
|
||||
await login.signIn(SKYLINE_AGENT);
|
||||
|
||||
await page.goto('/login');
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
});
|
||||
|
||||
test('stale stored credentials are discarded', async ({ login, page }) => {
|
||||
await login.signIn(SKYLINE_AGENT);
|
||||
|
||||
// Corrupt the stored password the way a changed account would.
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem(
|
||||
'travelhub.credentials',
|
||||
JSON.stringify({ tenant: 'skyline', basic: btoa('agent@skyline.test:stale') }),
|
||||
);
|
||||
});
|
||||
await page.goto('/dashboard');
|
||||
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
const stored = await page.evaluate(() => localStorage.getItem('travelhub.credentials'));
|
||||
expect(stored).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import { expect, test, uniqueSurname } from '../fixtures/test';
|
||||
import { SKYLINE_AGENT } from '../fixtures/accounts';
|
||||
|
||||
test.beforeEach(async ({ signInAs, shell, search }) => {
|
||||
await signInAs(SKYLINE_AGENT);
|
||||
await shell.goToSearch();
|
||||
await search.settled();
|
||||
});
|
||||
|
||||
test.describe('Booking a flight', () => {
|
||||
test('carries the fare and traveller count into checkout', async ({ search, checkout, page }) => {
|
||||
await search.searchRoute('JFK', 'LHR');
|
||||
await search.setTravellers(2);
|
||||
|
||||
const fare = await search.card(0).fare();
|
||||
await search.card(0).selectButton.click();
|
||||
|
||||
await page.waitForURL(/\/book\//);
|
||||
await checkout.waitUntilReady();
|
||||
|
||||
await expect(checkout.travellerFieldsets).toHaveCount(2);
|
||||
expect(await checkout.totalAmount()).toBeCloseTo(fare * 2, 2);
|
||||
});
|
||||
|
||||
test('reprices as travellers are added and removed', async ({ search, checkout, page }) => {
|
||||
const fare = await search.card(0).fare();
|
||||
await search.card(0).selectButton.click();
|
||||
await page.waitForURL(/\/book\//);
|
||||
await checkout.waitUntilReady();
|
||||
|
||||
expect(await checkout.totalAmount()).toBeCloseTo(fare, 2);
|
||||
|
||||
await checkout.addTraveller.click();
|
||||
await expect(checkout.travellerFieldsets).toHaveCount(2);
|
||||
expect(await checkout.totalAmount()).toBeCloseTo(fare * 2, 2);
|
||||
|
||||
await checkout.removeTraveller(1);
|
||||
await expect(checkout.travellerFieldsets).toHaveCount(1);
|
||||
expect(await checkout.totalAmount()).toBeCloseTo(fare, 2);
|
||||
});
|
||||
|
||||
test('refuses to submit with missing traveller names', async ({ search, checkout, page }) => {
|
||||
await search.card(0).selectButton.click();
|
||||
await page.waitForURL(/\/book\//);
|
||||
await checkout.waitUntilReady();
|
||||
|
||||
await checkout.confirm.click();
|
||||
|
||||
await expect(checkout.error).toBeVisible();
|
||||
await expect(page.locator('.pax .error').first()).toContainText('Required');
|
||||
// Still on the form, nothing sold.
|
||||
await expect(page).toHaveURL(/\/book\//);
|
||||
});
|
||||
|
||||
test('books, confirms, lists and cancels, returning the seats', async ({
|
||||
search,
|
||||
checkout,
|
||||
bookings,
|
||||
bookingDetail,
|
||||
shell,
|
||||
page,
|
||||
evidence,
|
||||
}) => {
|
||||
const surname = uniqueSurname();
|
||||
|
||||
await search.searchRoute('JFK', 'LHR');
|
||||
const card = search.card(0);
|
||||
const flightNumber = await card.flightNumber();
|
||||
const fare = await card.fare();
|
||||
const seatsBefore = await card.seatsLeft();
|
||||
|
||||
await evidence.step('Search results: JFK to LHR', {
|
||||
agency: 'Skyline Travel',
|
||||
route: 'JFK to LHR',
|
||||
matchingFlights: await search.totalResults(),
|
||||
cheapestFlight: flightNumber,
|
||||
fare: `$${fare.toFixed(2)}`,
|
||||
seatsAvailable: seatsBefore,
|
||||
});
|
||||
|
||||
await card.selectButton.click();
|
||||
await page.waitForURL(/\/book\//);
|
||||
await checkout.waitUntilReady();
|
||||
|
||||
await checkout.fillTravellers([
|
||||
{ firstName: 'Dana', lastName: surname, dateOfBirth: '1988-04-12', passportNumber: 'X1234567' },
|
||||
{ firstName: 'Elias', lastName: surname },
|
||||
]);
|
||||
await checkout.contactPhone.fill('+1-555-0101');
|
||||
|
||||
const expectedTotal = await checkout.totalAmount();
|
||||
|
||||
await evidence.step('Checkout completed for two travellers', {
|
||||
travellers: `Dana ${surname}, Elias ${surname}`,
|
||||
farePerTraveller: `$${fare.toFixed(2)}`,
|
||||
total: `$${expectedTotal.toFixed(2)}`,
|
||||
contactPhone: '+1-555-0101',
|
||||
});
|
||||
|
||||
await checkout.confirmBooking();
|
||||
|
||||
// --- confirmation -------------------------------------------------------
|
||||
await expect(bookingDetail.confirmationBanner).toBeVisible();
|
||||
await expect(bookingDetail.confirmationBanner).toContainText('Booking confirmed');
|
||||
await expect(bookingDetail.statusBadge).toHaveText(/confirmed/i);
|
||||
await expect(bookingDetail.travellerRows).toHaveCount(2);
|
||||
await expect(bookingDetail.travellerRows.first()).toContainText(surname);
|
||||
|
||||
const reference = await bookingDetail.bookingReference();
|
||||
expect(reference).toMatch(/^[A-Z0-9]{6}$/);
|
||||
expect(Number((await bookingDetail.totalPaid.innerText()).replace(/[^0-9.]/g, ''))).toBeCloseTo(
|
||||
expectedTotal,
|
||||
2,
|
||||
);
|
||||
expect(expectedTotal).toBeCloseTo(fare * 2, 2);
|
||||
|
||||
await evidence.step('Booking confirmed', {
|
||||
bookingReference: reference,
|
||||
status: 'confirmed',
|
||||
travellers: 2,
|
||||
totalPaid: `$${expectedTotal.toFixed(2)}`,
|
||||
soldBy: SKYLINE_AGENT.fullName,
|
||||
});
|
||||
|
||||
// --- appears in the list ------------------------------------------------
|
||||
await shell.goToBookings();
|
||||
await expect(bookings.rowFor(reference)).toHaveCount(1);
|
||||
await expect(bookings.rowFor(reference)).toContainText(/confirmed/i);
|
||||
await expect(bookings.rowFor(reference)).toContainText(SKYLINE_AGENT.fullName);
|
||||
|
||||
await evidence.step('Booking appears in the agency list', {
|
||||
bookingReference: reference,
|
||||
visibleInList: true,
|
||||
});
|
||||
|
||||
// --- seats were taken out of inventory ----------------------------------
|
||||
await shell.goToSearch();
|
||||
await search.searchRoute('JFK', 'LHR');
|
||||
const seatsAfterSale = await search.cardForFlight(flightNumber).seatsLeft();
|
||||
expect(seatsAfterSale).toBe(seatsBefore - 2);
|
||||
|
||||
await evidence.step('Seats removed from inventory', {
|
||||
flight: flightNumber,
|
||||
seatsBefore,
|
||||
seatsAfterSale,
|
||||
difference: seatsBefore - seatsAfterSale,
|
||||
});
|
||||
|
||||
// --- cancel -------------------------------------------------------------
|
||||
await shell.goToBookings();
|
||||
await bookings.open(reference);
|
||||
await bookingDetail.cancel(reference);
|
||||
|
||||
await expect(bookingDetail.statusBadge).toHaveText(/cancelled/i);
|
||||
await expect(bookingDetail.cancelledNotice).toContainText('returned to inventory');
|
||||
await expect(bookingDetail.cancelButton).toHaveCount(0);
|
||||
|
||||
await evidence.step('Booking cancelled', {
|
||||
bookingReference: reference,
|
||||
status: 'cancelled',
|
||||
});
|
||||
|
||||
// --- seats came back ----------------------------------------------------
|
||||
await shell.goToSearch();
|
||||
await search.searchRoute('JFK', 'LHR');
|
||||
const seatsAfterCancel = await search.cardForFlight(flightNumber).seatsLeft();
|
||||
expect(seatsAfterCancel).toBe(seatsBefore);
|
||||
|
||||
await evidence.step('Seats returned to inventory', {
|
||||
flight: flightNumber,
|
||||
seatsBefore,
|
||||
seatsAfterSale,
|
||||
seatsAfterCancel,
|
||||
restored: seatsAfterCancel === seatsBefore,
|
||||
});
|
||||
});
|
||||
|
||||
test('a cancel can be backed out of', async ({ search, checkout, bookingDetail, page }) => {
|
||||
const surname = uniqueSurname('KEEP');
|
||||
|
||||
await search.card(0).selectButton.click();
|
||||
await page.waitForURL(/\/book\//);
|
||||
await checkout.waitUntilReady();
|
||||
await checkout.fillTravellers([{ firstName: 'Mina', lastName: surname }]);
|
||||
await checkout.confirmBooking();
|
||||
const reference = await bookingDetail.bookingReference();
|
||||
|
||||
await bookingDetail.cancelButton.click();
|
||||
await expect(bookingDetail.confirmCancelButton).toBeVisible();
|
||||
|
||||
await bookingDetail.keepButton.click();
|
||||
|
||||
await expect(bookingDetail.confirmCancelButton).toHaveCount(0);
|
||||
await expect(bookingDetail.statusBadge).toHaveText(/confirmed/i);
|
||||
|
||||
// Leave the data as we found it.
|
||||
await bookingDetail.cancel(reference);
|
||||
});
|
||||
|
||||
test('the bookings list filters by status', async ({
|
||||
search,
|
||||
checkout,
|
||||
bookings,
|
||||
bookingDetail,
|
||||
shell,
|
||||
page,
|
||||
}) => {
|
||||
const surname = uniqueSurname('FILTER');
|
||||
|
||||
await search.card(0).selectButton.click();
|
||||
await page.waitForURL(/\/book\//);
|
||||
await checkout.waitUntilReady();
|
||||
await checkout.fillTravellers([{ firstName: 'Otto', lastName: surname }]);
|
||||
await checkout.confirmBooking();
|
||||
const reference = await bookingDetail.bookingReference();
|
||||
|
||||
await shell.goToBookings();
|
||||
await bookings.filterBy('confirmed');
|
||||
await expect(bookings.rowFor(reference)).toHaveCount(1);
|
||||
|
||||
await bookings.filterBy('cancelled');
|
||||
await expect(bookings.rowFor(reference)).toHaveCount(0);
|
||||
|
||||
// Cancel it, then confirm it moves between the two filters.
|
||||
await bookings.filterBy('all');
|
||||
await bookings.open(reference);
|
||||
await bookingDetail.cancel(reference);
|
||||
|
||||
await shell.goToBookings();
|
||||
await bookings.filterBy('cancelled');
|
||||
await expect(bookings.rowFor(reference)).toHaveCount(1);
|
||||
|
||||
await bookings.filterBy('confirmed');
|
||||
await expect(bookings.rowFor(reference)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a booking opened directly shows no stale confirmation banner', async ({
|
||||
search,
|
||||
checkout,
|
||||
bookingDetail,
|
||||
bookings,
|
||||
shell,
|
||||
page,
|
||||
}) => {
|
||||
const surname = uniqueSurname('DIRECT');
|
||||
|
||||
await search.card(0).selectButton.click();
|
||||
await page.waitForURL(/\/book\//);
|
||||
await checkout.waitUntilReady();
|
||||
await checkout.fillTravellers([{ firstName: 'Rhea', lastName: surname }]);
|
||||
await checkout.confirmBooking();
|
||||
const reference = await bookingDetail.bookingReference();
|
||||
|
||||
// Re-entering from the list is not a fresh purchase.
|
||||
await shell.goToBookings();
|
||||
await bookings.open(reference);
|
||||
await expect(bookingDetail.confirmationBanner).toHaveCount(0);
|
||||
await expect(bookingDetail.statusBadge).toHaveText(/confirmed/i);
|
||||
|
||||
await bookingDetail.cancel(reference);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { expect, test, uniqueSurname } from '../fixtures/test';
|
||||
import { SKYLINE_AGENT } from '../fixtures/accounts';
|
||||
|
||||
test.beforeEach(async ({ signInAs }) => {
|
||||
await signInAs(SKYLINE_AGENT);
|
||||
});
|
||||
|
||||
test.describe('Dashboard', () => {
|
||||
test('greets the agent and names the agency', async ({ dashboard }) => {
|
||||
await dashboard.settled();
|
||||
|
||||
await expect(dashboard.greeting).toContainText('Marcus');
|
||||
await expect(dashboard.greeting.locator('..')).toContainText('Skyline Travel');
|
||||
});
|
||||
|
||||
test('shows the four headline figures', async ({ dashboard, page }) => {
|
||||
await dashboard.settled();
|
||||
|
||||
await expect(dashboard.tiles).toHaveCount(4);
|
||||
await expect(page.locator('.tile-label')).toContainText([
|
||||
'Revenue',
|
||||
'Bookings',
|
||||
'Seats sold',
|
||||
'Flights in stock',
|
||||
]);
|
||||
expect(await dashboard.tileValue('Flights in stock')).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('lists recent bookings and best routes from the seed data', async ({ dashboard }) => {
|
||||
await dashboard.settled();
|
||||
|
||||
expect(await dashboard.recentRows.count()).toBeGreaterThan(0);
|
||||
expect(await dashboard.topRoutes.count()).toBeGreaterThan(0);
|
||||
await expect(dashboard.topRoutes.first()).toContainText(' to ');
|
||||
});
|
||||
|
||||
// Delta-based, so the test does not care what the database already held.
|
||||
test('a new sale moves the figures, and cancelling moves them back', async ({
|
||||
dashboard,
|
||||
shell,
|
||||
search,
|
||||
checkout,
|
||||
bookings,
|
||||
bookingDetail,
|
||||
page,
|
||||
evidence,
|
||||
}) => {
|
||||
await dashboard.settled();
|
||||
const revenueBefore = await dashboard.tileValue('Revenue');
|
||||
const seatsBefore = await dashboard.tileValue('Seats sold');
|
||||
const bookingsBefore = await dashboard.tileValue('Bookings');
|
||||
|
||||
await evidence.step('Dashboard before the sale', {
|
||||
agency: 'Skyline Travel',
|
||||
revenue: `$${revenueBefore.toLocaleString()}`,
|
||||
seatsSold: seatsBefore,
|
||||
bookings: bookingsBefore,
|
||||
});
|
||||
|
||||
await shell.goToSearch();
|
||||
const fare = await search.card(0).fare();
|
||||
await search.card(0).selectButton.click();
|
||||
await page.waitForURL(/\/book\//);
|
||||
await checkout.waitUntilReady();
|
||||
|
||||
const surname = uniqueSurname('DASH');
|
||||
await checkout.fillTravellers([
|
||||
{ firstName: 'Ana', lastName: surname },
|
||||
{ firstName: 'Bo', lastName: surname },
|
||||
]);
|
||||
await checkout.confirmBooking();
|
||||
const reference = await bookingDetail.bookingReference();
|
||||
|
||||
await shell.goToDashboard();
|
||||
await dashboard.settled();
|
||||
|
||||
// Revenue is rounded to whole units in the tile, so allow a unit of slack.
|
||||
expect(await dashboard.tileValue('Revenue')).toBeGreaterThanOrEqual(
|
||||
Math.floor(revenueBefore + fare * 2) - 1,
|
||||
);
|
||||
const revenueAfter = await dashboard.tileValue('Revenue');
|
||||
expect(await dashboard.tileValue('Seats sold')).toBe(seatsBefore + 2);
|
||||
expect(await dashboard.tileValue('Bookings')).toBe(bookingsBefore + 1);
|
||||
await expect(dashboard.recentRows.first()).toContainText(reference);
|
||||
|
||||
await evidence.step('Dashboard after the sale', {
|
||||
bookingReference: reference,
|
||||
revenueBefore: `$${revenueBefore.toLocaleString()}`,
|
||||
revenueAfter: `$${revenueAfter.toLocaleString()}`,
|
||||
farePerTraveller: `$${fare.toFixed(2)}`,
|
||||
seatsSold: `${seatsBefore} to ${seatsBefore + 2}`,
|
||||
bookings: `${bookingsBefore} to ${bookingsBefore + 1}`,
|
||||
});
|
||||
|
||||
// --- cancel and re-check ------------------------------------------------
|
||||
await shell.goToBookings();
|
||||
await bookings.open(reference);
|
||||
await bookingDetail.cancel(reference);
|
||||
|
||||
await shell.goToDashboard();
|
||||
await dashboard.settled();
|
||||
|
||||
expect(await dashboard.tileValue('Seats sold')).toBe(seatsBefore);
|
||||
// The booking still exists; it is just no longer confirmed.
|
||||
expect(await dashboard.tileValue('Bookings')).toBe(bookingsBefore + 1);
|
||||
|
||||
await evidence.step('Dashboard after cancelling', {
|
||||
bookingReference: reference,
|
||||
seatsSold: `back to ${seatsBefore}`,
|
||||
bookings: `${bookingsBefore + 1} (the cancelled booking is still counted)`,
|
||||
revenue: `$${(await dashboard.tileValue('Revenue')).toLocaleString()}`,
|
||||
});
|
||||
});
|
||||
|
||||
test('a recent booking row opens the booking', async ({ dashboard, page }) => {
|
||||
await dashboard.settled();
|
||||
|
||||
await dashboard.recentRows.first().click();
|
||||
|
||||
await expect(page).toHaveURL(/\/bookings\/[0-9a-f-]+/);
|
||||
});
|
||||
|
||||
test('the new booking shortcut goes to search', async ({ dashboard, page }) => {
|
||||
await dashboard.settled();
|
||||
|
||||
await page.getByRole('link', { name: '+ New booking' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/search$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* The console is built to work at phone width. These checks guard the layout
|
||||
* rules that make that true -- chiefly that nothing forces a horizontal scroll.
|
||||
*/
|
||||
import { expect, test } from '../fixtures/test';
|
||||
import { SKYLINE_AGENT } from '../fixtures/accounts';
|
||||
|
||||
/** True when the document is wider than the viewport. */
|
||||
async function scrollsSideways(page: import('@playwright/test').Page): Promise<boolean> {
|
||||
return page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 1);
|
||||
}
|
||||
|
||||
test.describe('Layout', () => {
|
||||
test('the sign-in screen fits without sideways scrolling', async ({ page, login }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await login.goto();
|
||||
|
||||
expect(await scrollsSideways(page)).toBe(false);
|
||||
// The decorative panel is hidden rather than squeezed.
|
||||
await expect(page.locator('.hero')).toBeHidden();
|
||||
await expect(login.submit).toBeVisible();
|
||||
});
|
||||
|
||||
test('the console screens fit at phone width', async ({ page, signInAs, shell, search }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await signInAs(SKYLINE_AGENT);
|
||||
|
||||
expect(await scrollsSideways(page)).toBe(false);
|
||||
|
||||
await shell.goToSearch();
|
||||
await search.settled();
|
||||
expect(await scrollsSideways(page)).toBe(false);
|
||||
await expect(search.results.first()).toBeVisible();
|
||||
|
||||
await shell.goToBookings();
|
||||
expect(await scrollsSideways(page)).toBe(false);
|
||||
});
|
||||
|
||||
test('wide tables scroll inside their own container', async ({ page, signInAs, shell }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await signInAs(SKYLINE_AGENT);
|
||||
await shell.goToBookings();
|
||||
|
||||
const wrapper = page.locator('.table-wrap').first();
|
||||
await expect(wrapper).toBeVisible();
|
||||
|
||||
// The table may overflow; the page must not.
|
||||
expect(await scrollsSideways(page)).toBe(false);
|
||||
});
|
||||
|
||||
test('the sidebar collapses into a top bar on narrow screens', async ({ page, signInAs }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await signInAs(SKYLINE_AGENT);
|
||||
|
||||
const sidebar = page.locator('.sidebar');
|
||||
const content = page.locator('.content');
|
||||
|
||||
const sidebarBox = await sidebar.boundingBox();
|
||||
const contentBox = await content.boundingBox();
|
||||
|
||||
expect(sidebarBox).not.toBeNull();
|
||||
expect(contentBox).not.toBeNull();
|
||||
// Stacked, not side by side.
|
||||
expect(contentBox!.y).toBeGreaterThan(sidebarBox!.y);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { expect, test } from '../fixtures/test';
|
||||
import { SKYLINE_AGENT } from '../fixtures/accounts';
|
||||
|
||||
test.beforeEach(async ({ signInAs, shell, search }) => {
|
||||
await signInAs(SKYLINE_AGENT);
|
||||
await shell.goToSearch();
|
||||
await search.settled();
|
||||
});
|
||||
|
||||
test.describe('Flight search', () => {
|
||||
test('opens with the agency inventory already listed', async ({ search }) => {
|
||||
await expect(search.results.first()).toBeVisible();
|
||||
await expect(search.resultSummary).toContainText('flights');
|
||||
expect(await search.totalResults()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('offers only airports this agency flies', async ({ search }) => {
|
||||
const codes = await search.origin.locator('option').allInnerTexts();
|
||||
|
||||
expect(codes.join(' ')).toContain('JFK');
|
||||
expect(codes.join(' ')).toContain('LHR');
|
||||
expect(codes[0]).toContain('Any airport');
|
||||
});
|
||||
|
||||
test('narrows results to a route', async ({ search, evidence }) => {
|
||||
const before = await search.totalResults();
|
||||
await evidence.step('Full inventory before filtering', { flightsVisible: before });
|
||||
|
||||
await search.searchRoute('JFK', 'LHR');
|
||||
const after = await search.totalResults();
|
||||
|
||||
await evidence.step('Filtered to JFK to LHR', {
|
||||
route: 'JFK to LHR',
|
||||
flightsBefore: before,
|
||||
flightsAfter: after,
|
||||
cheapestFare: `$${(await search.card(0).fare()).toFixed(2)}`,
|
||||
firstFlight: await search.card(0).flightNumber(),
|
||||
});
|
||||
|
||||
expect(after).toBeGreaterThan(0);
|
||||
expect(after).toBeLessThan(before);
|
||||
|
||||
// Every visible card should be on the requested route.
|
||||
const cards = await search.results.all();
|
||||
for (const card of cards) {
|
||||
await expect(card.locator('.iata').first()).toHaveText('JFK');
|
||||
await expect(card.locator('.iata').last()).toHaveText('LHR');
|
||||
}
|
||||
});
|
||||
|
||||
test('a route with no inventory shows an empty state', async ({ search }) => {
|
||||
// Nothing in the seed data flies Heathrow to Heathrow.
|
||||
await search.searchRoute('LHR', 'LHR');
|
||||
|
||||
await expect(search.emptyState).toBeVisible();
|
||||
await expect(search.emptyState).toContainText('Nothing on this route yet');
|
||||
await expect(search.results).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('resets back to the full list', async ({ search }) => {
|
||||
await search.searchRoute('JFK', 'LHR');
|
||||
const narrowed = await search.totalResults();
|
||||
|
||||
await search.resetButton.click();
|
||||
await search.settled();
|
||||
|
||||
expect(await search.totalResults()).toBeGreaterThan(narrowed);
|
||||
await expect(search.origin).toHaveValue('');
|
||||
});
|
||||
|
||||
test('sorts by lowest fare', async ({ search, evidence }) => {
|
||||
await search.searchRoute('JFK', 'LHR');
|
||||
await search.sortBy('price');
|
||||
|
||||
const fares: number[] = [];
|
||||
for (let i = 0; i < (await search.results.count()); i++) {
|
||||
fares.push(await search.card(i).fare());
|
||||
}
|
||||
|
||||
expect(fares.length).toBeGreaterThan(1);
|
||||
expect([...fares]).toEqual([...fares].sort((a, b) => a - b));
|
||||
|
||||
await evidence.step('Results ordered by lowest fare', {
|
||||
sortedBy: 'price',
|
||||
faresOnPage: fares.map((f) => `$${f.toFixed(2)}`).join(', '),
|
||||
lowest: `$${fares[0].toFixed(2)}`,
|
||||
highest: `$${fares[fares.length - 1].toFixed(2)}`,
|
||||
ascending: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('sorts by shortest trip', async ({ search }) => {
|
||||
await search.sortBy('duration');
|
||||
|
||||
const durations: number[] = [];
|
||||
for (let i = 0; i < Math.min(5, await search.results.count()); i++) {
|
||||
durations.push(await search.card(i).durationMinutes());
|
||||
}
|
||||
|
||||
expect([...durations]).toEqual([...durations].sort((a, b) => a - b));
|
||||
});
|
||||
|
||||
test('filters by cabin', async ({ search }) => {
|
||||
await search.cabin.selectOption('business');
|
||||
await search.searchButton.click();
|
||||
await search.settled();
|
||||
|
||||
const count = await search.results.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
for (let i = 0; i < count; i++) {
|
||||
await expect(search.results.nth(i).locator('.fare-tags .badge').first()).toHaveText(
|
||||
/business/i,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('hides flights without room for the whole party', async ({ search }) => {
|
||||
await search.setTravellers(9);
|
||||
|
||||
const count = await search.results.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
expect(await search.card(i).seatsLeft()).toBeGreaterThanOrEqual(9);
|
||||
}
|
||||
});
|
||||
|
||||
test('pages through results without repeating them', async ({ search }) => {
|
||||
await expect(search.prevPage).toBeDisabled();
|
||||
|
||||
const firstPage = await search.results.first().innerText();
|
||||
|
||||
await search.nextPage.click();
|
||||
await search.settled();
|
||||
|
||||
await expect(search.prevPage).toBeEnabled();
|
||||
await expect(search.resultSummary).toContainText('Showing 11');
|
||||
expect(await search.results.first().innerText()).not.toBe(firstPage);
|
||||
|
||||
await search.prevPage.click();
|
||||
await search.settled();
|
||||
await expect(search.resultSummary).toContainText('Showing 1–10');
|
||||
});
|
||||
|
||||
test('a result card carries everything needed to choose', async ({ search }) => {
|
||||
const card = search.card(0);
|
||||
|
||||
await expect(card.root.locator('.carrier-name')).not.toBeEmpty();
|
||||
await expect(card.root.locator('.leg-duration')).toContainText(/\d+h|\d+m/);
|
||||
await expect(card.price).toContainText('$');
|
||||
await expect(card.seatsBadge).toContainText('left');
|
||||
await expect(card.root.locator('.departure-date')).not.toBeEmpty();
|
||||
await expect(card.selectButton).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* Simulated-condition UI tests.
|
||||
*
|
||||
* Everything here answers the question "what does the screen do when the world
|
||||
* misbehaves?" -- a failing API, a slow one, an absent one, an empty agency, a
|
||||
* seat sold out from under the form, a session that expired mid-session.
|
||||
*
|
||||
* The backend is intercepted rather than used, which makes these tests fast,
|
||||
* deterministic, and free of database side effects. They are the complement to
|
||||
* the real-stack specs, not a replacement: those prove the system works, these
|
||||
* prove the UI degrades well.
|
||||
*/
|
||||
import { expect, test } from '../fixtures/test';
|
||||
import {
|
||||
SESSION,
|
||||
fail,
|
||||
makeBooking,
|
||||
makeDashboard,
|
||||
makeFlight,
|
||||
page$,
|
||||
primeCredentials,
|
||||
simulateApi,
|
||||
slow,
|
||||
} from '../fixtures/api-mock';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await primeCredentials(page);
|
||||
});
|
||||
|
||||
// --- failing backend -------------------------------------------------------
|
||||
|
||||
test.describe('When the API fails', () => {
|
||||
test('search shows an error instead of an empty page', async ({ page, search }) => {
|
||||
await simulateApi(page, { flights: fail(500, 'boom') });
|
||||
|
||||
await page.goto('/search');
|
||||
|
||||
await expect(page.locator('.alert-error')).toContainText('Could not load flights');
|
||||
// The filter panel stays usable so the agent can retry.
|
||||
await expect(search.searchButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test('the dashboard explains itself rather than showing zeroes', async ({ page }) => {
|
||||
await simulateApi(page, { dashboard: fail(503, 'unavailable') });
|
||||
|
||||
await page.goto('/dashboard');
|
||||
|
||||
await expect(page.locator('.alert-error')).toContainText('Could not load dashboard');
|
||||
// Crucially, it does not render "$0 revenue" as though that were the truth.
|
||||
await expect(page.locator('.tile-value')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('the bookings list reports a failure', async ({ page }) => {
|
||||
await simulateApi(page, { bookings: fail(500) });
|
||||
|
||||
await page.goto('/bookings');
|
||||
|
||||
await expect(page.locator('.alert-error')).toContainText('Could not load bookings');
|
||||
});
|
||||
|
||||
test('a missing backend is named on the sign-in screen', async ({ page, login }) => {
|
||||
// Status 0 is what the browser reports when nothing answers at all.
|
||||
await simulateApi(page, { offline: true });
|
||||
|
||||
await page.goto('/login');
|
||||
|
||||
await expect(login.error).toContainText('Go backend');
|
||||
});
|
||||
|
||||
test('going offline mid-session surfaces a failure, not a hang', async ({ page, search }) => {
|
||||
await simulateApi(page);
|
||||
await page.goto('/search');
|
||||
await expect(search.results.first()).toBeVisible();
|
||||
|
||||
// The network drops, then the agent searches again.
|
||||
await page.unrouteAll({ behavior: 'ignoreErrors' });
|
||||
await simulateApi(page, { offline: true });
|
||||
await search.searchButton.click();
|
||||
|
||||
await expect(page.locator('.alert-error')).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
|
||||
// --- slow backend ----------------------------------------------------------
|
||||
|
||||
test.describe('When the API is slow', () => {
|
||||
test('search shows skeletons while it waits', async ({ page }) => {
|
||||
await simulateApi(page, { flights: slow(page$([makeFlight()]), 2000) });
|
||||
|
||||
await page.goto('/search');
|
||||
|
||||
// The loading state must appear rather than a blank panel.
|
||||
await expect(page.locator('.skeleton-card').first()).toBeVisible();
|
||||
await expect(page.locator('.results-bar')).toContainText('Searching');
|
||||
|
||||
await expect(page.locator('.flight').first()).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator('.skeleton-card')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('the sign-in button reports that it is working', async ({ page, login }) => {
|
||||
await simulateApi(page, { session: slow(SESSION, 1500) });
|
||||
await page.context().clearCookies();
|
||||
await page.addInitScript(() => localStorage.clear());
|
||||
|
||||
await page.goto('/login');
|
||||
await login.useDemoChip({
|
||||
tenant: 'skyline',
|
||||
tenantName: 'Skyline Travel',
|
||||
email: 'agent@skyline.test',
|
||||
password: 'demo1234',
|
||||
fullName: 'Marcus Reid',
|
||||
role: 'agent',
|
||||
});
|
||||
await login.submit.click();
|
||||
|
||||
await expect(login.submit).toContainText('Signing in');
|
||||
await expect(login.submit).toBeDisabled();
|
||||
});
|
||||
|
||||
test('the dashboard shows skeleton tiles before its figures', async ({ page }) => {
|
||||
await simulateApi(page, { dashboard: slow(makeDashboard(), 1500) });
|
||||
|
||||
await page.goto('/dashboard');
|
||||
|
||||
await expect(page.locator('.skeleton').first()).toBeVisible();
|
||||
await expect(page.locator('.tile-value').first()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
|
||||
// --- expired session -------------------------------------------------------
|
||||
|
||||
test.describe('When the session expires', () => {
|
||||
test('a 401 mid-session returns the agent to sign-in', async ({ page }) => {
|
||||
await simulateApi(page);
|
||||
await page.goto('/dashboard');
|
||||
await expect(page.locator('.tile-value').first()).toBeVisible();
|
||||
|
||||
// The account is disabled elsewhere; the next call is rejected.
|
||||
await page.unrouteAll({ behavior: 'ignoreErrors' });
|
||||
await simulateApi(page, {
|
||||
bookings: fail(401, 'invalid credentials for this tenant'),
|
||||
session: fail(401, 'invalid credentials for this tenant'),
|
||||
});
|
||||
|
||||
await page.locator('.nav-link[href="/bookings"]').click();
|
||||
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
// Stale credentials must not be left lying around.
|
||||
expect(await page.evaluate(() => localStorage.getItem('travelhub.credentials'))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// --- inventory and sales edge cases ----------------------------------------
|
||||
|
||||
test.describe('Edge cases in the data', () => {
|
||||
test('an agency with no inventory sees an empty state, not an error', async ({ page }) => {
|
||||
await simulateApi(page, { flights: page$([], 0) });
|
||||
|
||||
await page.goto('/search');
|
||||
|
||||
await expect(page.locator('.empty')).toContainText('Nothing on this route yet');
|
||||
await expect(page.locator('.alert-error')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a brand new agency sees zeroes without a broken dashboard', async ({ page }) => {
|
||||
await simulateApi(page, {
|
||||
dashboard: makeDashboard({
|
||||
totalBookings: 0,
|
||||
confirmedBookings: 0,
|
||||
cancelledBookings: 0,
|
||||
totalRevenue: 0,
|
||||
seatsSold: 0,
|
||||
recentBookings: [],
|
||||
// Go marshals an empty slice as null, which is the shape that breaks
|
||||
// a naive .length in a template.
|
||||
topRoutes: null,
|
||||
}),
|
||||
});
|
||||
|
||||
await page.goto('/dashboard');
|
||||
|
||||
await expect(page.locator('.tile-value').first()).toBeVisible();
|
||||
await expect(page.getByText('No bookings yet')).toBeVisible();
|
||||
await expect(page.getByText('Nothing sold yet')).toBeVisible();
|
||||
});
|
||||
|
||||
test('the last seat is flagged as urgent', async ({ page, search }) => {
|
||||
await simulateApi(page, {
|
||||
flights: page$([
|
||||
makeFlight({ id: 'f-scarce', flightNumber: 'AA1', seatsAvailable: 1 }),
|
||||
makeFlight({ id: 'f-roomy', flightNumber: 'AA2', seatsAvailable: 120 }),
|
||||
]),
|
||||
});
|
||||
|
||||
await page.goto('/search');
|
||||
|
||||
await expect(search.results.nth(0).locator('.badge-warn')).toContainText('1 left');
|
||||
await expect(search.results.nth(1).locator('.badge-warn')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a seat sold during checkout is explained clearly', async ({ page, checkout, search }) => {
|
||||
await simulateApi(page, {
|
||||
flights: page$([makeFlight({ seatsAvailable: 2 })]),
|
||||
flightById: makeFlight({ seatsAvailable: 2 }),
|
||||
// The seat goes while the form is open.
|
||||
createBooking: fail(409, 'not enough seats available on this flight'),
|
||||
});
|
||||
|
||||
await page.goto('/search');
|
||||
await search.card(0).selectButton.click();
|
||||
await page.waitForURL(/\/book\//);
|
||||
await checkout.waitUntilReady();
|
||||
await checkout.fillTraveller(0, { firstName: 'Dana', lastName: 'Whitfield' });
|
||||
|
||||
await checkout.confirm.click();
|
||||
|
||||
await expect(checkout.error).toContainText('just sold');
|
||||
// The agent keeps their typing, so they can pick another flight and retry.
|
||||
await expect(page.locator('#first-0')).toHaveValue('Dana');
|
||||
});
|
||||
|
||||
test('a party larger than the cabin is blocked before submitting', async ({
|
||||
page,
|
||||
checkout,
|
||||
search,
|
||||
}) => {
|
||||
await simulateApi(page, {
|
||||
flights: page$([makeFlight({ seatsAvailable: 1 })]),
|
||||
flightById: makeFlight({ seatsAvailable: 1 }),
|
||||
});
|
||||
|
||||
await page.goto('/search');
|
||||
await search.card(0).selectButton.click();
|
||||
await page.waitForURL(/\/book\//);
|
||||
await checkout.waitUntilReady();
|
||||
|
||||
await checkout.addTraveller.click();
|
||||
|
||||
await expect(page.locator('.alert-error')).toContainText('Only 1 seat');
|
||||
await expect(checkout.confirm).toBeDisabled();
|
||||
});
|
||||
|
||||
test('a huge result set still paginates sensibly', async ({ page, search }) => {
|
||||
const many = Array.from({ length: 10 }, (_, i) =>
|
||||
makeFlight({ id: `f-${i}`, flightNumber: `AA${100 + i}`, basePrice: 100 + i }),
|
||||
);
|
||||
await simulateApi(page, { flights: page$(many, 98_432) });
|
||||
|
||||
await page.goto('/search');
|
||||
|
||||
await expect(search.resultSummary).toContainText('98432');
|
||||
await expect(search.results).toHaveCount(10);
|
||||
await expect(search.nextPage).toBeEnabled();
|
||||
await expect(search.prevPage).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
// --- hostile content -------------------------------------------------------
|
||||
|
||||
test.describe('Awkward content', () => {
|
||||
test('very long names do not break the layout or escape the page', async ({ page }) => {
|
||||
const longName = 'Wolfeschlegelsteinhausenbergerdorff'.repeat(4);
|
||||
await simulateApi(page, {
|
||||
bookingById: makeBooking({
|
||||
passengers: [{ id: 'p-1', firstName: longName, lastName: longName }],
|
||||
contactEmail: `${'a'.repeat(120)}@example.com`,
|
||||
}),
|
||||
});
|
||||
|
||||
await page.goto('/bookings/b-1');
|
||||
|
||||
await expect(page.locator('table.data tbody tr').first()).toBeVisible();
|
||||
const overflows = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth > window.innerWidth + 1,
|
||||
);
|
||||
expect(overflows, 'a long passenger name must not widen the page').toBe(false);
|
||||
});
|
||||
|
||||
test('markup in data is rendered as text, never as HTML', async ({ page }) => {
|
||||
// Angular escapes interpolated values; this asserts nobody has reached for
|
||||
// innerHTML since.
|
||||
await simulateApi(page, {
|
||||
bookingById: makeBooking({
|
||||
passengers: [
|
||||
{ id: 'p-1', firstName: '<img src=x onerror=alert(1)>', lastName: '<b>bold</b>' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await page.goto('/bookings/b-1');
|
||||
|
||||
const row = page.locator('table.data tbody tr').first();
|
||||
await expect(row).toContainText('<b>bold</b>');
|
||||
await expect(row.locator('b')).toHaveCount(0);
|
||||
await expect(row.locator('img')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- keyboard only ---------------------------------------------------------
|
||||
|
||||
test.describe('Keyboard operation', () => {
|
||||
test('the sign-in form can be completed without a mouse', async ({ page, login }) => {
|
||||
await simulateApi(page);
|
||||
await page.addInitScript(() => localStorage.clear());
|
||||
|
||||
await page.goto('/login');
|
||||
|
||||
await login.tenantSelect.focus();
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(login.email).toBeFocused();
|
||||
await page.keyboard.type('agent@skyline.test');
|
||||
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(login.password).toBeFocused();
|
||||
await page.keyboard.type('demo1234');
|
||||
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(login.submit).toBeFocused();
|
||||
});
|
||||
|
||||
test('every interactive control on search is reachable by tab', async ({ page }) => {
|
||||
await simulateApi(page);
|
||||
await page.goto('/search');
|
||||
await expect(page.locator('.flight').first()).toBeVisible();
|
||||
|
||||
const reached = new Set<string>();
|
||||
for (let i = 0; i < 25; i++) {
|
||||
await page.keyboard.press('Tab');
|
||||
const id = await page.evaluate(() => {
|
||||
const el = document.activeElement as HTMLElement | null;
|
||||
return el ? (el.id || el.className || el.tagName) : '';
|
||||
});
|
||||
reached.add(id);
|
||||
}
|
||||
|
||||
// The filter panel and the first result's action are all tab-reachable.
|
||||
expect([...reached].join(' ')).toContain('origin');
|
||||
expect([...reached].join(' ')).toContain('destination');
|
||||
expect([...reached].join(' ')).toContain('btn');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* The point of the product: two agencies share one deployment and one database,
|
||||
* and neither can see the other's inventory, customers or revenue.
|
||||
*
|
||||
* These assertions are made through the UI, the way a customer would notice a
|
||||
* leak, rather than against the API.
|
||||
*/
|
||||
import { expect, test, uniqueSurname } from '../fixtures/test';
|
||||
import { GLOBETROTTER_AGENT, SKYLINE_AGENT } from '../fixtures/accounts';
|
||||
|
||||
test.describe('Tenant isolation', () => {
|
||||
test('the same route is priced per agency', async ({
|
||||
signInAs,
|
||||
shell,
|
||||
search,
|
||||
login,
|
||||
evidence,
|
||||
}) => {
|
||||
await signInAs(SKYLINE_AGENT);
|
||||
await shell.goToSearch();
|
||||
await search.searchRoute('JFK', 'LHR');
|
||||
await search.sortBy('price');
|
||||
const skylineFare = await search.card(0).fare();
|
||||
|
||||
await evidence.step('Skyline Travel: cheapest JFK to LHR', {
|
||||
agency: 'Skyline Travel',
|
||||
route: 'JFK to LHR',
|
||||
cheapestFare: `$${skylineFare.toFixed(2)}`,
|
||||
resultCount: await search.totalResults(),
|
||||
});
|
||||
|
||||
await shell.signOutAndWait();
|
||||
|
||||
await login.signIn(GLOBETROTTER_AGENT);
|
||||
await shell.goToSearch();
|
||||
await search.searchRoute('JFK', 'LHR');
|
||||
await search.sortBy('price');
|
||||
const globetrotterFare = await search.card(0).fare();
|
||||
|
||||
await evidence.step('Globetrotter Tours: same route, its own fare', {
|
||||
agency: 'Globetrotter Tours',
|
||||
route: 'JFK to LHR',
|
||||
cheapestFare: `$${globetrotterFare.toFixed(2)}`,
|
||||
resultCount: await search.totalResults(),
|
||||
});
|
||||
|
||||
await evidence.note('Per-tenant pricing confirmed', {
|
||||
skyline: `$${skylineFare.toFixed(2)}`,
|
||||
globetrotter: `$${globetrotterFare.toFixed(2)}`,
|
||||
difference: `$${(globetrotterFare - skylineFare).toFixed(2)}`,
|
||||
markup: `${(((globetrotterFare - skylineFare) / skylineFare) * 100).toFixed(1)}%`,
|
||||
});
|
||||
|
||||
expect(skylineFare).toBeGreaterThan(0);
|
||||
expect(globetrotterFare).toBeGreaterThan(0);
|
||||
// Globetrotter sells the same seats at a negotiated markup.
|
||||
expect(globetrotterFare).not.toBeCloseTo(skylineFare, 2);
|
||||
expect(globetrotterFare).toBeGreaterThan(skylineFare);
|
||||
});
|
||||
|
||||
test('the sidebar always names the agency you are working in', async ({
|
||||
signInAs,
|
||||
shell,
|
||||
login,
|
||||
}) => {
|
||||
await signInAs(SKYLINE_AGENT);
|
||||
await expect(shell.agencyName).toHaveText('Skyline Travel');
|
||||
await expect(shell.agencySlug).toHaveText('skyline');
|
||||
|
||||
await shell.signOutAndWait();
|
||||
await login.signIn(GLOBETROTTER_AGENT);
|
||||
|
||||
await expect(shell.agencyName).toHaveText('Globetrotter Tours');
|
||||
await expect(shell.agencySlug).toHaveText('globetrotter');
|
||||
});
|
||||
|
||||
test('one agency never sees another agency booking', async ({
|
||||
signInAs,
|
||||
login,
|
||||
shell,
|
||||
search,
|
||||
checkout,
|
||||
bookings,
|
||||
bookingDetail,
|
||||
page,
|
||||
evidence,
|
||||
}) => {
|
||||
const surname = uniqueSurname('ISO');
|
||||
|
||||
// --- Skyline sells a ticket --------------------------------------------
|
||||
await signInAs(SKYLINE_AGENT);
|
||||
await shell.goToSearch();
|
||||
await search.card(0).selectButton.click();
|
||||
await page.waitForURL(/\/book\//);
|
||||
await checkout.waitUntilReady();
|
||||
await checkout.fillTravellers([{ firstName: 'Iris', lastName: surname }]);
|
||||
await checkout.confirmBooking();
|
||||
|
||||
const reference = await bookingDetail.bookingReference();
|
||||
const bookingUrl = page.url();
|
||||
|
||||
await evidence.step('Skyline sells a ticket', {
|
||||
agency: 'Skyline Travel',
|
||||
bookingReference: reference,
|
||||
passenger: `Iris ${surname}`,
|
||||
bookingUrl,
|
||||
});
|
||||
|
||||
await shell.signOutAndWait();
|
||||
|
||||
// --- Globetrotter must not find it -------------------------------------
|
||||
await login.signIn(GLOBETROTTER_AGENT);
|
||||
await shell.goToBookings();
|
||||
|
||||
await expect(bookings.rowFor(reference)).toHaveCount(0);
|
||||
expect(await page.content()).not.toContain(surname);
|
||||
|
||||
await evidence.step('Globetrotter cannot see it in their bookings', {
|
||||
signedInAs: 'Globetrotter Tours',
|
||||
searchedFor: reference,
|
||||
rowsFound: 0,
|
||||
passengerNameOnPage: false,
|
||||
});
|
||||
|
||||
// Not even by deep-linking straight at the booking's own URL.
|
||||
await page.goto(bookingUrl);
|
||||
await expect(page.getByText('Booking not found')).toBeVisible();
|
||||
expect(await page.content()).not.toContain(surname);
|
||||
|
||||
await evidence.step('Deep link to the booking URL is refused', {
|
||||
signedInAs: 'Globetrotter Tours',
|
||||
requestedUrl: bookingUrl,
|
||||
result: 'Booking not found',
|
||||
dataLeaked: false,
|
||||
});
|
||||
|
||||
// --- clean up as the owning agency -------------------------------------
|
||||
await shell.signOutAndWait();
|
||||
await login.signIn(SKYLINE_AGENT);
|
||||
await shell.goToBookings();
|
||||
await bookings.open(reference);
|
||||
await bookingDetail.cancel(reference);
|
||||
});
|
||||
|
||||
test('revenue figures are per agency', async ({ signInAs, login, shell, dashboard }) => {
|
||||
await signInAs(SKYLINE_AGENT);
|
||||
await dashboard.settled();
|
||||
const skylineRevenue = await dashboard.tileValue('Revenue');
|
||||
const skylineBookings = await dashboard.tileValue('Bookings');
|
||||
|
||||
await shell.signOutAndWait();
|
||||
await login.signIn(GLOBETROTTER_AGENT);
|
||||
await dashboard.settled();
|
||||
const globetrotterRevenue = await dashboard.tileValue('Revenue');
|
||||
const globetrotterBookings = await dashboard.tileValue('Bookings');
|
||||
|
||||
expect(skylineRevenue).toBeGreaterThan(0);
|
||||
expect(globetrotterRevenue).toBeGreaterThan(0);
|
||||
// Separate books: the seeded agencies sold different volumes.
|
||||
expect(skylineRevenue).not.toBe(globetrotterRevenue);
|
||||
expect(skylineBookings).not.toBe(globetrotterBookings);
|
||||
});
|
||||
|
||||
test('a deep link to another agency flight is refused', async ({
|
||||
signInAs,
|
||||
login,
|
||||
shell,
|
||||
search,
|
||||
page,
|
||||
}) => {
|
||||
// Capture a flight id that belongs to Globetrotter.
|
||||
await signInAs(GLOBETROTTER_AGENT);
|
||||
await shell.goToSearch();
|
||||
await search.card(0).selectButton.click();
|
||||
await page.waitForURL(/\/book\//);
|
||||
const globetrotterBookUrl = page.url();
|
||||
|
||||
await page.goto('/dashboard');
|
||||
await shell.signOutAndWait();
|
||||
|
||||
// Skyline holds a valid id, and still cannot reach it.
|
||||
await login.signIn(SKYLINE_AGENT);
|
||||
await page.goto(globetrotterBookUrl);
|
||||
|
||||
await expect(page.getByText('Flight not available')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noImplicitOverride": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user