mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
0ed0d98c32
Place details, Day details, Map, Transports, Bookings, Lists, Costs, Files, Collab and Road Trip: 77 guides over 360 steps, each one a task a reader can follow, with the Playwright script that will photograph it. The fixtures they need go in beside ensureDaysFixtures, and every screen is a child of the trip, so the panel folds them under it. The capture now pins the browser's clock to the day the seeded trip ends. A picture has to look the same whoever takes it and whenever: a day later the trip is over, My Trips moves it out of the boarding pass, What's Next has nothing left to list, and a forecast for a day in the past is not a forecast. Only `Date` is pinned, so timers keep running. trip-days is green end to end after its locators were checked against the running app; its route guides read day 6, whose two stops sit next to each other, because day 1's are separated by the flight and have no leg of their own.
569 lines
23 KiB
TypeScript
569 lines
23 KiB
TypeScript
import { test, expect, type Locator, type Page } from '@playwright/test'
|
|
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
|
import { seededTrip, ensureCostsFixtures } from './fixtures'
|
|
import { openTrip, modal, dialog } from './trip-shared'
|
|
import { tripCostsContext, tripCostsGuides } from '../../src/help/contexts/tripCosts'
|
|
import type { HelpGuide } from '../../src/help/types'
|
|
|
|
/**
|
|
* Actions for the Costs tab of a trip, keyed by the ids in
|
|
* `src/help/contexts/tripCosts.ts`. They run on the seeded "Autumn in Japan",
|
|
* which keeps its books in JPY with one expense in EUR and one transfer already
|
|
* recorded, so the frozen rate and the settle-up column both have something
|
|
* real to show. What a guide writes it takes away again in `cleanup`, the
|
|
* settlements included, because the next guide reads the same balances.
|
|
*/
|
|
|
|
const guide = (id: string): HelpGuide => {
|
|
const g = tripCostsGuides.find(x => x.id === id)
|
|
if (!g) throw new Error(`no registered guide "${id}"`)
|
|
return g
|
|
}
|
|
|
|
/** The expense nobody has paid for; `ensureCostsFixtures` puts it there once, for every guide. */
|
|
const UNPAID = 'Airport transfer (Narita Express)'
|
|
/** The seeded expense in euros, the one carrying a frozen rate. */
|
|
const SEEDED_EUR = 'Flights FRA → HND'
|
|
/** The seeded flight booking, on the Transports tab because its type is flight. */
|
|
const BOOKING = 'LH716 FRA → HND'
|
|
const DINNER = 'Kaiseki dinner in Gion'
|
|
const UPGRADE = 'Shinkansen seat upgrade'
|
|
const LUNCH = { name: 'Nishiki Market lunch', category: 'food', total_price: 4800, currency: 'JPY', expense_date: '2026-09-17' }
|
|
|
|
/** The Costs tab, waited out: its panel is lazy and its figures come from the server. */
|
|
const openCosts = async (p: Page): Promise<void> => {
|
|
await openTrip(p, { tab: 'finanzplan' })
|
|
await expect(p.getByRole('button', { name: 'Add expense' }).first()).toBeVisible({ timeout: 30_000 })
|
|
await expect(p.locator('.exp-row').first()).toBeVisible({ timeout: 30_000 })
|
|
await settle(p)
|
|
}
|
|
|
|
/** A row of the ledger by the expense's name. */
|
|
const expRow = (page: Page, name: string) => page.locator('.exp-row').filter({ hasText: name }).first()
|
|
/**
|
|
* The pencil/bin pill beside a row. It is a sibling of `.exp-row`, not a child,
|
|
* so a locator scoped to the row itself would never find it.
|
|
*/
|
|
const rowActions = (page: Page, name: string) => expRow(page, name).locator('xpath=..').locator('.exp-actions')
|
|
/** A recorded transfer in the ledger, told apart by who paid it. */
|
|
const payRow = (page: Page, who: string) =>
|
|
page.locator('.exp-row').filter({ hasText: 'Payment' }).filter({ hasText: who }).first()
|
|
/** A block of the expense editor, found by its own label. */
|
|
const panel = (page: Page, label: string) =>
|
|
modal(page).getByText(label, { exact: true }).locator('xpath=ancestor::div[contains(@class,"rounded-2xl")][1]')
|
|
/** A card of the right-hand column, by the label it opens with. */
|
|
const card = (page: Page, label: RegExp) =>
|
|
page.locator('.costs-grid > div:nth-child(2) > div').filter({ hasText: label }).first()
|
|
/** CustomSelect drops its list into a fixed portal on the body, outside the modal. */
|
|
const dropdown = (page: Page) => page.locator('div[style*="z-index: 99999"]').last()
|
|
|
|
const amounts = (page: Page) => modal(page).locator('input[inputmode="decimal"]')
|
|
const nameBox = (page: Page) => modal(page).getByPlaceholder('e.g. Dinner, souvenirs, gas…')
|
|
/** The header button; the editor's own save carries the same words, so it is scoped to the modal. */
|
|
const addExpense = (page: Page) => page.getByRole('button', { name: 'Add expense' }).first()
|
|
const saveExpense = (page: Page) => modal(page).getByRole('button', { name: 'Add expense' })
|
|
const settleButtons = (page: Page) => card(page, /^Settle up · /).getByRole('button', { name: 'Settle' })
|
|
/**
|
|
* One suggested transfer in the Settle up card, by the title its pair of avatars
|
|
* carries ("jonas → you"); the row itself is that pair's parent. The card lists
|
|
* the flows in whatever order the settlement endpoint returns them, so taking
|
|
* the first Settle button would settle whichever transfer happened to come
|
|
* first, and the ledger steps below look for a named one.
|
|
*/
|
|
const settleRow = (page: Page, from: string) =>
|
|
card(page, /^Settle up · /).locator(`div[title^="${from} "]`).locator('xpath=..')
|
|
|
|
const only = (target: (p: Page) => Locator) => ({ target })
|
|
|
|
const closeModal = async (page: Page): Promise<void> => {
|
|
if (await modal(page).isVisible().catch(() => false)) {
|
|
await page.keyboard.press('Escape')
|
|
await expect(modal(page)).toHaveCount(0)
|
|
}
|
|
}
|
|
|
|
async function deleteExpenses(page: Page, ...names: string[]): Promise<void> {
|
|
const { tripId } = seededTrip()
|
|
const res = await page.request.get(`/api/trips/${tripId}/budget`)
|
|
const { items } = (await res.json()) as { items: { id: number; name: string }[] }
|
|
for (const item of items.filter(i => names.includes(i.name))) {
|
|
await page.request.delete(`/api/trips/${tripId}/budget/${item.id}`)
|
|
}
|
|
}
|
|
|
|
async function createExpense(
|
|
page: Page,
|
|
item: { name: string; category: string; total_price: number; currency: string; expense_date: string },
|
|
): Promise<void> {
|
|
const { tripId, memberIds } = seededTrip()
|
|
const created = await page.request.post(`/api/trips/${tripId}/budget`, {
|
|
data: { ...item, payers: [{ user_id: 1, amount: item.total_price }], member_ids: [1, ...memberIds] },
|
|
})
|
|
if (!created.ok()) throw new Error(`could not create "${item.name}": ${created.status()} ${await created.text()}`)
|
|
}
|
|
|
|
async function settlementIds(page: Page): Promise<number[]> {
|
|
const { tripId } = seededTrip()
|
|
const res = await page.request.get(`/api/trips/${tripId}/budget/settlements`)
|
|
const { settlements } = (await res.json()) as { settlements: { id: number }[] }
|
|
return settlements.map(s => s.id)
|
|
}
|
|
|
|
/** The transfers that were there before the settle-up guide ran; everything else it made, it deletes. */
|
|
let transfersBefore: number[] = []
|
|
|
|
/** Open the expense editor's currency list, search for a code and take it. */
|
|
async function pickCurrency(page: Page, code: string): Promise<void> {
|
|
await modal(page).getByRole('button', { name: /^JPY/ }).first().click()
|
|
await beat(page, 300)
|
|
await dropdown(page).locator('input').first().fill(code)
|
|
await dropdown(page).getByRole('button', { name: new RegExp(`^${code}`) }).click()
|
|
await expect(modal(page).getByRole('button', { name: new RegExp(`^${code}`) }).first()).toBeVisible()
|
|
await settle(page)
|
|
}
|
|
|
|
const SCRIPTS: Record<string, GuideScript> = {
|
|
'add-expense': {
|
|
guide: guide('add-expense'),
|
|
start: openCosts,
|
|
steps: [
|
|
{
|
|
target: addExpense,
|
|
act: async p => {
|
|
await addExpense(p).click()
|
|
await expect(nameBox(p)).toBeVisible()
|
|
await settle(p)
|
|
},
|
|
},
|
|
{
|
|
prepare: async p => {
|
|
await typeInto(p, nameBox(p), DINNER)
|
|
await amounts(p).first().fill('18600')
|
|
await expect(saveExpense(p)).toBeEnabled()
|
|
await settle(p)
|
|
},
|
|
// The left block carries the name, the amount, the currency and the day.
|
|
target: p => panel(p, 'What was it for?'),
|
|
},
|
|
// The two-column row inside that block, so the step about them is not the
|
|
// same picture as the step about the name.
|
|
only(p => modal(p).getByText('Currency', { exact: true }).locator('xpath=../..')),
|
|
{
|
|
prepare: async p => {
|
|
await modal(p).getByRole('button', { name: 'Food & drink' }).click()
|
|
// The chosen pill is marked by its border alone, not by a class.
|
|
await expect(modal(p).getByRole('button', { name: 'Food & drink' })).toHaveAttribute(
|
|
'style',
|
|
/border-color: var\(--text-primary\)/,
|
|
)
|
|
await settle(p)
|
|
},
|
|
target: p => panel(p, 'Category'),
|
|
},
|
|
only(p => panel(p, 'Who paid?')),
|
|
{
|
|
target: saveExpense,
|
|
act: async p => {
|
|
await saveExpense(p).click()
|
|
await expect(modal(p)).toHaveCount(0)
|
|
await expect(expRow(p, DINNER)).toBeVisible({ timeout: 15_000 })
|
|
await settle(p)
|
|
},
|
|
},
|
|
],
|
|
cleanup: p => deleteExpenses(p, DINNER),
|
|
},
|
|
|
|
'expense-payers': {
|
|
guide: guide('expense-payers'),
|
|
start: openCosts,
|
|
steps: [
|
|
{
|
|
prepare: async p => {
|
|
await rowActions(p, 'Ryokan in Hakone').getByRole('button', { name: 'Edit' }).click()
|
|
await expect(panel(p, 'Who paid?')).toBeVisible()
|
|
await settle(p)
|
|
},
|
|
target: p => panel(p, 'Who paid?'),
|
|
},
|
|
{
|
|
prepare: async p => {
|
|
await panel(p, 'Who paid?').getByRole('button').filter({ hasText: 'You' }).first().click()
|
|
await beat(p, 300)
|
|
await expect(p.getByRole('button', { name: 'No one paid yet' })).toBeVisible()
|
|
},
|
|
target: p => p.getByRole('button', { name: 'No one paid yet' }),
|
|
// The list closes on a second click of its trigger, not on Escape; nothing is picked.
|
|
act: async p => {
|
|
await panel(p, 'Who paid?').getByRole('button').filter({ hasText: 'You' }).first().click()
|
|
await expect(p.getByRole('button', { name: 'No one paid yet' })).toHaveCount(0)
|
|
await settle(p)
|
|
},
|
|
},
|
|
{
|
|
prepare: async p => {
|
|
await modal(p).getByRole('button', { name: 'Multiple people paid' }).click()
|
|
await expect(modal(p).getByTestId('payer-toggle')).toHaveCount(3)
|
|
await modal(p).getByTestId('payer-toggle').nth(1).click()
|
|
await expect(modal(p).getByTestId('payer-amount')).toHaveCount(2)
|
|
await settle(p)
|
|
},
|
|
target: p => panel(p, 'Who paid?'),
|
|
act: async p => {
|
|
await modal(p).getByRole('button', { name: 'One person paid' }).click()
|
|
await closeModal(p)
|
|
await settle(p)
|
|
},
|
|
},
|
|
{
|
|
target: p => expRow(p, UNPAID),
|
|
act: async p => {
|
|
await expect(expRow(p, UNPAID).getByText('Unfinished')).toBeVisible()
|
|
},
|
|
},
|
|
],
|
|
},
|
|
|
|
'split-expense': {
|
|
guide: guide('split-expense'),
|
|
start: async p => {
|
|
await createExpense(p, LUNCH)
|
|
await openCosts(p)
|
|
},
|
|
steps: [
|
|
{
|
|
prepare: async p => {
|
|
await rowActions(p, LUNCH.name).getByRole('button', { name: 'Edit' }).click()
|
|
await expect(panel(p, 'Split')).toBeVisible()
|
|
await panel(p, 'Split').getByRole('button', { name: /jonas/ }).click()
|
|
await expect(modal(p).getByText('Excluded')).toBeVisible()
|
|
await settle(p)
|
|
},
|
|
target: p => panel(p, 'Split'),
|
|
act: async p => {
|
|
await panel(p, 'Split').getByRole('button', { name: /jonas/ }).click()
|
|
await expect(modal(p).getByText('Excluded')).toHaveCount(0)
|
|
await settle(p)
|
|
},
|
|
},
|
|
only(p => modal(p).getByText(/^Split \d+ ways · /)),
|
|
{
|
|
prepare: async p => {
|
|
await modal(p).getByRole('button', { name: 'Custom', exact: true }).click()
|
|
await amounts(p).nth(1).fill('1000')
|
|
await expect(modal(p).getByText(/^Sum of splits: /)).toBeVisible()
|
|
await settle(p)
|
|
},
|
|
target: p => modal(p).getByText(/^Sum of splits: /),
|
|
act: async p => {
|
|
await amounts(p).nth(2).fill('1900')
|
|
await amounts(p).nth(3).fill('1900')
|
|
await expect(modal(p).getByText('Split matches total')).toBeVisible()
|
|
await settle(p)
|
|
},
|
|
},
|
|
{
|
|
prepare: async p => {
|
|
await modal(p).getByRole('button', { name: 'Ticket', exact: true }).click()
|
|
await modal(p).getByRole('button', { name: 'Add item' }).click()
|
|
await typeInto(p, modal(p).getByPlaceholder('Item name').first(), 'Tamagoyaki')
|
|
await amounts(p).nth(1).fill('1200')
|
|
await modal(p).getByRole('button', { name: 'Add item' }).click()
|
|
await typeInto(p, modal(p).getByPlaceholder('Item name').nth(1), 'Soy milk donuts')
|
|
await amounts(p).nth(2).fill('900')
|
|
// The second line is shared by two of the three travelers.
|
|
await modal(p)
|
|
.getByPlaceholder('Item name')
|
|
.nth(1)
|
|
.locator('xpath=ancestor::div[2]')
|
|
.getByRole('button', { name: /jonas/ })
|
|
.click()
|
|
await expect(amounts(p).first()).toHaveValue('2100.00')
|
|
await settle(p)
|
|
},
|
|
target: p => modal(p).getByPlaceholder('Item name').first().locator('xpath=ancestor::div[2]'),
|
|
},
|
|
{
|
|
target: p => modal(p).getByText('Individual shares').locator('xpath=..'),
|
|
act: async p => {
|
|
await modal(p).getByRole('button', { name: 'Save', exact: true }).click()
|
|
await expect(modal(p)).toHaveCount(0)
|
|
await settle(p)
|
|
},
|
|
},
|
|
],
|
|
cleanup: p => deleteExpenses(p, LUNCH.name),
|
|
},
|
|
|
|
'expense-currency': {
|
|
guide: guide('expense-currency'),
|
|
start: openCosts,
|
|
steps: [
|
|
{
|
|
prepare: async p => {
|
|
await addExpense(p).click()
|
|
await expect(nameBox(p)).toBeVisible()
|
|
await typeInto(p, nameBox(p), UPGRADE)
|
|
await amounts(p).first().fill('48')
|
|
// The editor opens on Food & drink; a seat upgrade filed under it would
|
|
// read wrong on the ledger row this guide's last picture is of.
|
|
await modal(p).getByRole('button', { name: 'Transport', exact: true }).click()
|
|
await settle(p)
|
|
},
|
|
target: p => panel(p, 'What was it for?'),
|
|
},
|
|
{
|
|
prepare: async p => {
|
|
await modal(p).getByRole('button', { name: /^JPY/ }).first().click()
|
|
await beat(p, 300)
|
|
await dropdown(p).locator('input').first().fill('EUR')
|
|
await expect(dropdown(p).getByRole('button', { name: /^EUR/ })).toBeVisible()
|
|
},
|
|
target: p => modal(p).getByRole('button', { name: /^JPY/ }).first(),
|
|
act: async p => {
|
|
await dropdown(p).getByRole('button', { name: /^EUR/ }).click()
|
|
await expect(modal(p).getByRole('button', { name: /^EUR/ }).first()).toBeVisible()
|
|
await expect(modal(p).getByText('live rate')).toBeVisible({ timeout: 20_000 })
|
|
await settle(p)
|
|
},
|
|
},
|
|
{
|
|
target: p => modal(p).getByText('live rate').locator('xpath=ancestor::div[1]'),
|
|
act: async p => {
|
|
// Both sides have to be there: an offline run would show the amount unconverted.
|
|
await expect(modal(p).getByText('live rate').locator('xpath=ancestor::div[1]')).toHaveText(/€[\s\S]*[¥¥]/)
|
|
},
|
|
},
|
|
{
|
|
target: saveExpense,
|
|
act: async p => {
|
|
await saveExpense(p).click()
|
|
await expect(modal(p)).toHaveCount(0)
|
|
await expect(expRow(p, UPGRADE)).toBeVisible({ timeout: 15_000 })
|
|
await settle(p)
|
|
},
|
|
},
|
|
{
|
|
target: p => expRow(p, UPGRADE),
|
|
act: async p => {
|
|
await expect(expRow(p, UPGRADE)).toHaveText(/€[\s\S]*→[\s\S]*[¥¥]/)
|
|
await expect(expRow(p, SEEDED_EUR)).toHaveText(/€[\s\S]*→[\s\S]*[¥¥]/)
|
|
},
|
|
},
|
|
],
|
|
cleanup: p => deleteExpenses(p, UPGRADE),
|
|
},
|
|
|
|
'filter-costs': {
|
|
guide: guide('filter-costs'),
|
|
start: openCosts,
|
|
steps: [
|
|
{
|
|
prepare: async p => {
|
|
await typeInto(p, p.getByPlaceholder('Search expenses…'), 'pass')
|
|
await expect(expRow(p, 'JR Pass (14 days)')).toBeVisible()
|
|
await settle(p)
|
|
},
|
|
target: p => p.getByPlaceholder('Search expenses…').locator('xpath=..'),
|
|
act: async p => {
|
|
await p.getByPlaceholder('Search expenses…').fill('')
|
|
await settle(p)
|
|
},
|
|
},
|
|
{
|
|
prepare: async p => {
|
|
await p.getByRole('button', { name: 'All categories' }).first().click()
|
|
await beat(p, 300)
|
|
await expect(dropdown(p).getByRole('button', { name: 'Transport', exact: true })).toBeVisible()
|
|
},
|
|
target: p => p.getByRole('button', { name: 'All categories' }).first(),
|
|
act: async p => {
|
|
await dropdown(p).getByRole('button', { name: 'Transport', exact: true }).click()
|
|
await expect(expRow(p, 'Ryokan in Hakone')).toHaveCount(0)
|
|
await p.getByRole('button', { name: 'Transport', exact: true }).first().click()
|
|
await beat(p, 300)
|
|
await dropdown(p).getByRole('button', { name: 'All categories' }).click()
|
|
await expect(expRow(p, 'Ryokan in Hakone')).toBeVisible()
|
|
await settle(p)
|
|
},
|
|
},
|
|
{
|
|
prepare: async p => {
|
|
await p.getByRole('button', { name: 'All days' }).first().click()
|
|
await beat(p, 300)
|
|
await dropdown(p).getByRole('button', { name: 'Sat, Sep 12' }).click()
|
|
await expect(p.getByText(/^Saturday, September 12$/)).toBeVisible()
|
|
await settle(p)
|
|
},
|
|
target: p => p.getByText(/^Saturday, September 12$/).locator('xpath=../..'),
|
|
act: async p => {
|
|
await p.getByRole('button', { name: 'Sat, Sep 12' }).first().click()
|
|
await beat(p, 300)
|
|
await dropdown(p).getByRole('button', { name: 'All days' }).click()
|
|
await expect(p.getByText(/^Saturday, September 12$/)).toHaveCount(0)
|
|
await settle(p)
|
|
},
|
|
},
|
|
{
|
|
prepare: async p => {
|
|
await p.getByRole('button', { name: 'Paid by me' }).click()
|
|
await settle(p)
|
|
},
|
|
target: p => p.getByRole('button', { name: 'All', exact: true }).locator('xpath=..'),
|
|
act: async p => {
|
|
await p.getByRole('button', { name: 'All', exact: true }).click()
|
|
await settle(p)
|
|
},
|
|
},
|
|
// Clicking it would start a download; the button is what the step is about.
|
|
only(p => p.getByRole('button', { name: 'Export CSV' })),
|
|
],
|
|
},
|
|
|
|
'settle-up': {
|
|
guide: guide('settle-up'),
|
|
start: async p => {
|
|
await openCosts(p)
|
|
transfersBefore = await settlementIds(p)
|
|
},
|
|
steps: [
|
|
only(p => card(p, /^Settle up · /)),
|
|
{
|
|
target: p => settleRow(p, 'jonas').getByRole('button', { name: 'Settle' }),
|
|
act: async p => {
|
|
await settleRow(p, 'jonas').getByRole('button', { name: 'Settle' }).click()
|
|
await expect(settleButtons(p)).toHaveCount(1, { timeout: 15_000 })
|
|
await settle(p)
|
|
},
|
|
},
|
|
only(p => payRow(p, 'jonas')),
|
|
{
|
|
target: p => payRow(p, 'jonas').locator('xpath=..').locator('.exp-actions'),
|
|
act: async p => {
|
|
await payRow(p, 'jonas').locator('xpath=..').getByRole('button', { name: 'Undo' }).click()
|
|
await expect(settleButtons(p)).toHaveCount(2, { timeout: 15_000 })
|
|
await settle(p)
|
|
},
|
|
},
|
|
{
|
|
prepare: async p => {
|
|
await p.getByRole('button', { name: 'Add payment' }).first().click()
|
|
await expect(modal(p).getByRole('button', { name: 'Add payment' })).toBeVisible()
|
|
await modal(p).locator('input[inputmode="decimal"]').first().fill('5000')
|
|
await settle(p)
|
|
},
|
|
target: dialog,
|
|
act: async p => {
|
|
await modal(p).getByRole('button', { name: 'Add payment' }).click()
|
|
await expect(modal(p)).toHaveCount(0)
|
|
await settle(p)
|
|
},
|
|
},
|
|
// Ringed and left alone: one click would record every open transfer and
|
|
// flatten the balances every later picture is taken against.
|
|
only(p => p.getByRole('button', { name: 'Settle up' })),
|
|
],
|
|
cleanup: async p => {
|
|
const { tripId } = seededTrip()
|
|
for (const id of await settlementIds(p)) {
|
|
if (!transfersBefore.includes(id)) await p.request.delete(`/api/trips/${tripId}/budget/settlements/${id}`)
|
|
}
|
|
},
|
|
},
|
|
|
|
'final-budget': {
|
|
guide: guide('final-budget'),
|
|
start: openCosts,
|
|
steps: [
|
|
only(p => card(p, /^Balances/)),
|
|
only(p => card(p, /^Final budget/)),
|
|
{
|
|
prepare: async p => {
|
|
await card(p, /^Final budget/).getByRole('button').first().click()
|
|
await expect(p.getByText('Expenses paid').first()).toBeVisible()
|
|
await settle(p)
|
|
},
|
|
target: p => card(p, /^Final budget/).locator('[aria-expanded="true"]').locator('xpath=following-sibling::div[1]'),
|
|
},
|
|
{
|
|
// The second "Expenses paid" is the heading of the list the figure is made of.
|
|
target: p => p.getByText('Expenses paid').nth(1).locator('xpath=..'),
|
|
act: async p => {
|
|
await card(p, /^Final budget/).getByRole('button').first().click()
|
|
await expect(card(p, /^Final budget/).locator('[aria-expanded="true"]')).toHaveCount(0)
|
|
},
|
|
},
|
|
],
|
|
},
|
|
|
|
'expense-from-booking': {
|
|
guide: guide('expense-from-booking'),
|
|
start: async p => {
|
|
await openTrip(p, { tab: 'transports' })
|
|
await expect(p.getByRole('button', { name: 'Edit' }).first()).toBeVisible({ timeout: 30_000 })
|
|
await settle(p)
|
|
},
|
|
steps: [
|
|
{
|
|
target: p => p.getByRole('button', { name: 'Edit' }).first(),
|
|
act: async p => {
|
|
await p.getByRole('button', { name: 'Edit' }).first().click()
|
|
await expect(modal(p).getByText('Saves the booking, then opens the Costs editor.')).toBeVisible()
|
|
await settle(p)
|
|
},
|
|
},
|
|
only(p => modal(p).getByText('Saves the booking, then opens the Costs editor.').locator('xpath=..')),
|
|
{
|
|
target: p => modal(p).getByRole('button', { name: 'Create expense' }),
|
|
act: async p => {
|
|
await modal(p).getByRole('button', { name: 'Create expense' }).click()
|
|
await expect(nameBox(p)).toHaveValue(BOOKING, { timeout: 20_000 })
|
|
await settle(p)
|
|
},
|
|
},
|
|
{
|
|
prepare: async p => {
|
|
await amounts(p).first().fill('890')
|
|
await pickCurrency(p, 'EUR')
|
|
},
|
|
target: saveExpense,
|
|
act: async p => {
|
|
await saveExpense(p).click()
|
|
await expect(modal(p)).toHaveCount(0)
|
|
await p.getByRole('button', { name: 'Edit' }).first().click()
|
|
await expect(modal(p).getByText('Linked expense')).toBeVisible({ timeout: 20_000 })
|
|
await settle(p)
|
|
},
|
|
},
|
|
],
|
|
cleanup: async p => {
|
|
await closeModal(p)
|
|
await deleteExpenses(p, BOOKING)
|
|
},
|
|
},
|
|
}
|
|
|
|
// ── Run ───────────────────────────────────────────────────────────────────────
|
|
|
|
test.describe.configure({ mode: 'serial' })
|
|
|
|
test.beforeAll(async ({ request }) => {
|
|
await ensureCostsFixtures(request)
|
|
})
|
|
|
|
test('every registered costs guide has a script, and only those', async () => {
|
|
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripCostsContext.guides].sort())
|
|
})
|
|
|
|
test('hero: trip-costs', async ({ page }) => {
|
|
await page.setViewportSize(VIEWPORT)
|
|
await captureHero(page, tripCostsContext.id, openCosts)
|
|
})
|
|
|
|
for (const id of tripCostsContext.guides) {
|
|
test(`pictures: ${id}`, async ({ page }) => {
|
|
await page.setViewportSize(VIEWPORT)
|
|
await captureGuide(page, SCRIPTS[id])
|
|
})
|
|
}
|