Files
TREK/client/e2e/help/vacay.guide.ts
T
Maurice 31859651d7 feat(help): rewrite the help center for v5 with a Tours section, new pictures in every language, and fix the tour planner bugs it surfaced
Every help screen checked against the v5 redesign. Tours is documented for
the first time, as a screen of its own with twelve screens under it, and
notifications get help of their own. All pictures are taken again on a
realistic demo trip, with short loops where a still cannot show the motion.
The English is plain and every language is written naturally rather than
word for word.

The switcher shows two levels at most, a screen's overview lists its
sub-screens as cards above its guides, each wiki page is linked once, and
the help sidebar is wider.

Fixes found along the way: Save to Collection keeps a tour's route, a track
dropped on the places column opens the Tours import, tour Share on desktop,
the unsaved tour draft lives on the account, a route click no longer adds
an end point, Trip places leaves the trip's tours out, Escape closes a
stage's day menu, confirms before destructive deletes, DWD warnings sharing
one id kept apart, a booking's date moves with it, and smaller fixes across
notifications, bookings, the day plan, stays, Atlas, Collections, Vacay and
the admin screens.
2026-10-22 22:29:35 +02:00

761 lines
31 KiB
TypeScript

import { test, expect, type Page, type Locator } from '@playwright/test'
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript, type StepAction } from './guide'
import { monthName, monthsAhead, year } from '../dates'
import {
PARTNER, ensureFused, ensureSeparate, clearPartnerDays, requestFusionFromPartner, clearFusionRequest,
shareWithAdmin, unshareWithAdmin, ownColor, setOwnColor, ensurePublicCalendar, calendarColors,
restoreCalendarColors, companyHolidayFraction, setCompanyHoliday,
} from './fixtures-vacay'
import { vacayGuides, vacayContext } from '../../src/help/contexts/vacay'
import type { HelpGuide } from '../../src/help/types'
/**
* Actions for the Vacay guides, keyed by the ids in `src/help/contexts/vacay.ts`.
* They run in the order the context lists them and build on each other: the
* year the calendar shows gets a few logged days, company holidays and a
* public-holiday calendar as the run goes on, which is what a lived-in plan
* looks like anyway. The guides about planning together fuse the seeded
* member mira into the plan through the API (`fixtures-vacay.ts`) and split
* it again in their cleanup, so every other guide finds a plan of one.
*/
const YEAR = year()
/** The month after the picture day's: the first one the planner draws after the current. */
const NEXT_MONTH = monthName(1)
/** What the entitlement guide sets: not the seed's 30, so the change shows. */
const ENTITLEMENT = '28'
/**
* Weekdays of the next month from the 10th on. A fixed day number fell on a
* weekend in some months, and Block Weekends (on by default) ignores the
* click there, so the picture showed an empty cell under a step that says it
* fills.
*/
const WORKDAYS: number[] = Array.from({ length: 19 }, (_, i) => i + 10).filter(d => {
const weekday = new Date(`${monthsAhead(1, d)}T12:00:00Z`).getUTCDay()
return weekday !== 0 && weekday !== 6
})
const [LOG_DAY, HALF_DAY, COMP_DAY, , PARTNER_DAY] = WORKDAYS
/** The `YYYY-MM-DD` of a day number in the next month. */
const nextMonthDate = (n: number): string => monthsAhead(1, n)
/** The day the company-holiday guides mark, whole and then half. */
const CHRISTMAS_EVE = `${YEAR}-12-24`
const guide = (id: string): HelpGuide => {
const g = vacayGuides.find(x => x.id === id)
if (!g) throw new Error(`no registered guide "${id}"`)
return g
}
/**
* What clearNotices in `screenshots/shot.ts` clears, less its loop over every
* button whose name contains "next". On this page that loop found the year
* card's "Add next year" and clicked it six times on every visit, so the plan
* grew by six years per guide and the Year card filled up with chips past
* 2100. A tour's own button is called just "Next", so that one is still stepped
* through.
*/
async function clearVacayNotices(page: Page): Promise<void> {
const next = page.getByRole('button', { name: 'Next', exact: true })
for (let i = 0; i < 6 && (await next.isVisible().catch(() => false)); i++) {
if (!(await next.isEnabled().catch(() => false))) break
await next.click().catch(() => {})
}
for (const label of ['Dismiss', 'OK']) {
const btn = page.getByRole('button', { name: label, exact: true })
for (let i = 0; i < 4 && (await btn.isVisible().catch(() => false)); i++) {
await btn.click().catch(() => {})
await page.waitForTimeout(300)
}
}
}
async function openVacay(page: Page): Promise<void> {
await page.goto('/vacay')
await dismissReleaseNotice(page)
await clearVacayNotices(page)
await expect(toolbar(page)).toBeVisible()
await expect(monthCard(page, NEXT_MONTH)).toBeVisible()
}
/** The floating mode toolbar under the grid. */
const toolbar = (page: Page) => page.locator('.vg-card.rounded-full').first()
/** A month card by its (English) heading. */
const monthCard = (page: Page, month: string) => page.locator('.vg-card').filter({ hasText: month }).first()
/** A day cell inside a month card. */
const day = (page: Page, month: string, n: number) => monthCard(page, month).getByRole('button', { name: String(n), exact: true })
/** A sidebar card by its label (the DOM text; CSS uppercases it). */
const sideCard = (page: Page, label: string) => page.locator('.vg-card').filter({ hasText: label }).first()
/** The settings dialog. */
const settings = (page: Page) => page.locator('.trek-modal-backdrop')
/** The window Dissolve opens before it splits the plan; it stacks on top of Settings. */
const dissolveConfirm = (page: Page) =>
page.locator('.trek-modal-enter').filter({ hasText: 'Separate calendars again' }).filter({ has: page.getByRole('button', { name: 'Cancel' }) }).last()
/** The invite and share forms: small dialogs portalled to the body. */
const dialog = (page: Page) => page.locator('.trek-backdrop-enter').last()
/** A settings row by its label: icon, label, hint and, for a toggle, its switch. */
const settingRow = (page: Page, label: string): Locator =>
settings(page).getByText(label, { exact: true }).locator('xpath=../../..')
/** The switch of a settings row, found through the row's label. */
const toggleRow = (page: Page, label: string): Locator => settingRow(page, label).getByRole('button').first()
/** The form Add calendar opens: colour, label, country, region and its Add button. */
const addCalendarForm = (page: Page): Locator =>
settings(page).getByPlaceholder('Label (optional)').last().locator('xpath=../..')
/** The Days tile of the Entitlement card while it is being edited. */
const daysTile = (page: Page): Locator => sideCard(page, 'Entitlement').locator('input').locator('xpath=..')
/** A year chip in the Year card. */
const yearChip = (page: Page, y: number): Locator =>
sideCard(page, 'Year').locator('[role="button"]').filter({ hasText: String(y) }).first()
/** A row of a sidebar card (a person, an invite, a share) by the name in it. */
const cardRow = (page: Page, card: string, name: string): Locator =>
sideCard(page, card).locator('.group').filter({ hasText: name }).first()
/** Nothing hovered: for a picture where the pointer would reveal a button the step is not about. */
const parkPointer = async (page: Page): Promise<void> => { await page.mouse.move(VIEWPORT.width - 4, VIEWPORT.height - 4) }
/**
* Back to the top of the page. A step target further down scrolls the page a
* little, and a result picture taken there hides the top row of months under
* the header, unlike the hero and the other results.
*/
async function toTop(page: Page): Promise<void> {
// On a desktop the body is what scrolls (index.css hides the overflow of
// html), so the window's own scroll position is already 0.
await page.evaluate(() => {
for (const el of [document.body, document.documentElement]) el.scrollTo({ top: 0, left: 0, behavior: 'instant' })
window.scrollTo({ top: 0, left: 0, behavior: 'instant' })
})
await settle(page)
}
/**
* Turn a settings switch on, and leave it alone when it is on already. Block
* Weekends and Carry Over start out on, and a plain click switched them off,
* so the next picture showed the opposite of what the step had just said. The
* switch has no aria state; its filled track is the only sign it is on.
*/
async function switchOn(page: Page, label: string): Promise<void> {
const sw = toggleRow(page, label)
if (!(await sw.evaluate(el => el.classList.contains('bg-content')))) await sw.click()
await settle(page)
}
async function openSettings(page: Page): Promise<void> {
await page.getByRole('button', { name: 'Settings' }).click()
await expect(settings(page)).toBeVisible()
await settle(page)
}
async function closeSettings(page: Page): Promise<void> {
await page.keyboard.press('Escape')
await expect(settings(page)).toBeHidden()
// The dialog hands the focus back to the Settings button, and after a key
// press that draws a thick focus outline, which in the next picture reads
// like a step ring.
await page.evaluate(() => { (document.activeElement as HTMLElement | null)?.blur() })
await settle(page)
}
/**
* Click Add calendar and fill the form the way the step describes, short of
* adding it, so the picture shows the fields filled in.
*/
async function fillCalendarForm(page: Page, addButton: Locator, label: string): Promise<void> {
await addButton.click()
await typeInto(page, settings(page).getByPlaceholder('Label (optional)').last(), label)
await pick(page, settings(page).getByRole('button', { name: 'Select country' }), 'Germany')
// The region list loads after the country is picked; give it a moment to show up.
const region = settings(page).getByRole('button', { name: 'Select region (required)' })
await region.waitFor({ state: 'visible', timeout: 8_000 }).catch(() => {})
if (await region.isVisible().catch(() => false)) await pick(page, region, 'Berlin')
await expect(addCalendarForm(page).getByRole('button', { name: 'Add', exact: true })).toBeEnabled({ timeout: 10_000 })
}
async function submitCalendarForm(page: Page): Promise<void> {
await addCalendarForm(page).getByRole('button', { name: 'Add', exact: true }).click()
// The form closes once the calendar is saved and turns into its row.
await expect(settings(page).getByRole('button', { name: 'Add', exact: true })).toBeHidden({ timeout: 10_000 })
await settle(page)
}
/**
* "Close Settings", the last step of the holiday guides. The ring goes round
* the head band rather than the close button alone: the 960 px dialog is as
* wide as the smallest frame, so a frame centred on the button leaves out the
* band's title and the calendar the guide just added, and the band is still
* what the step names. The pointer rests on the button, so its Close tooltip
* marks where to click inside the ring.
*/
const settingsCloseStep: StepAction = {
target: p => settings(p).locator('header').first(),
hover: async p => { await settings(p).getByRole('button', { name: 'Close' }).hover() },
act: closeSettings,
}
/**
* Pick an option in a CustomSelect: open it, filter, click the match inside
* the open menu. Scoped to the menu on purpose: a calendar row already set to
* "Germany" renders a trigger button of that very name, which a page-wide
* lookup would hit first.
*/
async function pick(page: Page, trigger: Locator, text: string): Promise<void> {
await trigger.click()
// The menu's filter field; its placeholder is the app's "Search".
const search = page.getByPlaceholder('Search', { exact: true }).last()
await search.waitFor({ state: 'visible', timeout: 5_000 })
await typeInto(page, search, text)
const menu = search.locator('xpath=../..')
await menu.getByRole('button', { name: text, exact: true }).first().click()
}
/** A row of the Persons card (a fused person or a pending invite) by its name. */
const personRow = (page: Page, name: string): Locator => cardRow(page, 'Persons', name)
/** Your own row in Persons, the one with the "you" badge. */
const ownPersonRow = (page: Page): Locator =>
sideCard(page, 'Persons').locator('.group').filter({ has: page.getByText('you', { exact: true }) }).first()
/** The first holiday calendar row in Settings: colour square, label, country, region and bin. */
const calendarRow = (page: Page): Locator => settings(page).getByPlaceholder('Label (optional)').first().locator('xpath=../..')
/** The Fusion Request dialog, which opens by itself while an invite waits for an answer. */
const fusionDialog = (page: Page): Locator => page.getByRole('dialog', { name: 'Fusion Request' })
/** What a guide has to put back, read in its start and used in its cleanup. */
let savedColor = ''
let savedCalendarColors = new Map<number, string>()
let christmasEveBefore: number | null = null
const SCRIPTS: Record<string, GuideScript> = {
'log-day': {
guide: guide('log-day'),
start: openVacay,
steps: [
{ target: p => toolbar(p).getByRole('button').first() },
{
target: p => day(p, NEXT_MONTH, LOG_DAY),
act: async p => { await day(p, NEXT_MONTH, LOG_DAY).click(); await beat(p, 600) },
},
{
target: p => day(p, NEXT_MONTH, LOG_DAY),
act: async p => {
await day(p, NEXT_MONTH, LOG_DAY).click()
await beat(p, 600)
// Leave the day logged for the after-picture and the guides that follow.
await day(p, NEXT_MONTH, LOG_DAY).click()
await toTop(p)
},
},
],
},
'half-day': {
guide: guide('half-day'),
start: openVacay,
steps: [
{
target: p => p.getByRole('button', { name: 'Half day' }),
act: async p => { await p.getByRole('button', { name: 'Half day' }).click() },
},
{
target: p => day(p, NEXT_MONTH, HALF_DAY),
act: async p => { await day(p, NEXT_MONTH, HALF_DAY).click(); await settle(p) },
},
{
target: p => p.getByRole('button', { name: 'Half day' }),
act: async p => { await p.getByRole('button', { name: 'Half day' }).click(); await toTop(p) },
},
],
},
'comp-day': {
guide: guide('comp-day'),
start: openVacay,
steps: [
{
target: p => p.getByRole('button', { name: 'Comp / Flex' }),
act: async p => { await p.getByRole('button', { name: 'Comp / Flex' }).click() },
},
{
target: p => day(p, NEXT_MONTH, COMP_DAY),
act: async p => {
await day(p, NEXT_MONTH, COMP_DAY).click()
await settle(p)
await p.getByRole('button', { name: 'Comp / Flex' }).click()
await toTop(p)
},
},
],
},
entitlement: {
guide: guide('entitlement'),
start: openVacay,
steps: [
{
target: p => sideCard(p, 'Entitlement').locator('[role="button"]').filter({ hasText: 'Days' }).first(),
act: async p => { await sideCard(p, 'Entitlement').locator('[role="button"]').filter({ hasText: 'Days' }).first().click() },
},
{
// The seed's entitlement is 30, so typing 30 changed nothing and the
// result showed the Left of before. A different number is typed first,
// so the picture shows it in the field and the result recalculates Left.
prepare: async p => {
const input = sideCard(p, 'Entitlement').locator('input')
await input.fill('')
await typeInto(p, input, ENTITLEMENT)
},
// The whole tile, so the ring and its number sit round the Days label
// and the field rather than on the label.
target: daysTile,
act: async p => {
await sideCard(p, 'Entitlement').locator('input').press('Enter')
await expect(sideCard(p, 'Entitlement').locator('input')).toHaveCount(0)
await toTop(p)
},
},
],
},
years: {
guide: guide('years'),
start: openVacay,
steps: [
{
target: p => p.getByTitle('Add next year'),
act: async p => {
await p.getByTitle('Add next year').click()
await expect(sideCard(p, 'Year').locator('[role="button"]').filter({ hasText: String(YEAR + 1) })).toBeVisible()
await settle(p)
},
},
{
target: p => sideCard(p, 'Year').locator('.grid'),
act: async p => {
// Over to the new year and back. Staying put would leave the Entitlement
// card on the numbers of the year just added, which Add next year loads
// under the heading of the year still selected; switching reloads them.
await sideCard(p, 'Year').locator('[role="button"]').filter({ hasText: String(YEAR + 1) }).first().click()
await settle(p)
await sideCard(p, 'Year').locator('[role="button"]').filter({ hasText: String(YEAR) }).first().click()
await settle(p)
},
},
{
// The chip with its minus showing, rather than the minus alone: the step
// says to hover the chip, and a ring round the tiny minus put its number
// on top of the year it belongs to.
prepare: async p => { await yearChip(p, YEAR + 1).hover() },
target: p => yearChip(p, YEAR + 1),
// With the ring on the chip, this is what still fails the guide when the minus is gone.
act: async p => { await expect(yearChip(p, YEAR + 1).getByRole('button', { name: 'Remove year' })).toBeVisible() },
},
],
cleanup: async p => {
// The extra year was for the pictures only; the guides that follow expect the seed's single year.
const res = await p.request.delete(`/api/addons/vacay/years/${YEAR + 1}`)
if (!res.ok()) throw new Error(`could not remove year ${YEAR + 1}: ${res.status()}`)
},
},
'company-holidays': {
guide: guide('company-holidays'),
start: openVacay,
steps: [
{
// On by default, so the step only shows where the switch lives. The ring
// takes the whole row, name and switch, which is what "check that it is
// on" reads off, and it pulls the frame over the dialog instead of the
// page beside it.
prepare: openSettings,
target: p => settingRow(p, 'Company Holidays'),
act: closeSettings,
},
{
target: p => p.getByRole('button', { name: 'Company Holiday' }),
act: async p => { await p.getByRole('button', { name: 'Company Holiday' }).click() },
},
{
target: p => day(p, 'December', 24),
act: async p => {
await day(p, 'December', 24).click()
await beat(p, 400)
await day(p, 'December', 31).click()
await settle(p)
// Back to logging vacation, so the next guide starts where a reader would.
await toolbar(p).getByRole('button').first().click()
await toTop(p)
},
},
],
},
'half-company-holiday': {
guide: guide('half-company-holiday'),
// A full run finds Christmas Eve a whole company holiday from the guide
// before; the click with Half day on then converts it in place.
start: async p => {
christmasEveBefore = await companyHolidayFraction(p, YEAR, CHRISTMAS_EVE)
await openVacay(p)
},
steps: [
{
target: p => p.getByRole('button', { name: 'Company Holiday' }),
act: async p => { await p.getByRole('button', { name: 'Company Holiday' }).click() },
},
{
target: p => p.getByRole('button', { name: 'Half day' }),
act: async p => {
await p.getByRole('button', { name: 'Half day' }).click()
await expect(p.getByRole('button', { name: 'Half day' })).toHaveAttribute('aria-pressed', 'true')
},
},
{
target: p => day(p, 'December', 24),
act: async p => {
await day(p, 'December', 24).click()
await settle(p)
// Both switches back, so the next guide starts on plain vacation. The page
// stays where it is: the half-filled cell is what the result shows.
await p.getByRole('button', { name: 'Half day' }).click()
await toolbar(p).getByRole('button').first().click()
await settle(p)
},
},
],
cleanup: async p => { await setCompanyHoliday(p, YEAR, CHRISTMAS_EVE, christmasEveBefore) },
},
'public-holidays': {
guide: guide('public-holidays'),
start: openVacay,
steps: [
{
// The whole row, as in company-holidays step 1: name and switch together.
prepare: openSettings,
target: p => settingRow(p, 'Public Holidays'),
// switchOn rather than a click: a guide run on its own may find it on already.
act: async p => { await switchOn(p, 'Public Holidays') },
},
{
// The step is about the country and the region, so the picture shows
// the form filled in rather than the button that opens it empty.
prepare: p => fillCalendarForm(p, settings(p).getByRole('button', { name: 'Add calendar' }).first(), 'Berlin'),
target: addCalendarForm,
act: submitCalendarForm,
},
settingsCloseStep,
],
},
'school-holidays': {
guide: guide('school-holidays'),
start: openVacay,
steps: [
{
prepare: openSettings,
target: p => settingRow(p, 'School Holidays'),
act: async p => { await switchOn(p, 'School Holidays') },
},
{
// The last Add calendar: the public-holiday one above it is the first.
prepare: p => fillCalendarForm(p, settings(p).getByRole('button', { name: 'Add calendar' }).last(), 'Schools Berlin'),
target: addCalendarForm,
act: submitCalendarForm,
},
settingsCloseStep,
],
},
'edit-holiday-calendar': {
guide: guide('edit-holiday-calendar'),
start: async p => {
await ensurePublicCalendar(p)
savedCalendarColors = await calendarColors(p)
await openVacay(p)
},
steps: [
{
prepare: openSettings,
target: calendarRow,
hover: parkPointer,
},
{
// The swatches open before the picture, so it shows what the click offers.
prepare: async p => {
await calendarRow(p).getByTitle('Color').click()
await expect(calendarRow(p).locator('div[style*="display: grid"] > button').first()).toBeVisible()
},
target: p => calendarRow(p).getByTitle('Color'),
act: async p => {
await calendarRow(p).locator('div[style*="display: grid"] > button').nth(1).click()
await settle(p)
},
},
{
// The bin is the row's last button, after the colour square and the two lists.
target: p => calendarRow(p).locator('button').last(),
act: closeSettings,
},
],
cleanup: async p => { await restoreCalendarColors(p, savedCalendarColors) },
},
weekends: {
guide: guide('weekends'),
start: openVacay,
steps: [
{
target: p => p.getByRole('button', { name: 'Settings' }),
act: openSettings,
},
{
target: p => toggleRow(p, 'Block Weekends'),
act: async p => { await switchOn(p, 'Block Weekends') },
},
{
target: p => settings(p).getByText('Week starts on', { exact: true }).locator('xpath=../../..'),
act: closeSettings,
},
],
},
'leave-year': {
guide: guide('leave-year'),
start: openVacay,
steps: [
{
prepare: openSettings,
target: p => settings(p).getByText('Vacation year', { exact: true }).locator('xpath=../../..'),
},
{
// The step names all three choices, so the ring goes round the row that holds them.
target: p => settings(p).getByText('Vacation year', { exact: true }).locator('xpath=../../..').getByRole('button').first().locator('xpath=..'),
act: closeSettings,
},
],
},
'carry-over': {
guide: guide('carry-over'),
start: openVacay,
steps: [
{
target: p => p.getByRole('button', { name: 'Settings' }),
act: openSettings,
},
{
target: p => toggleRow(p, 'Carry Over'),
act: async p => { await switchOn(p, 'Carry Over'); await closeSettings(p) },
},
],
},
'person-color': {
guide: guide('person-color'),
start: async p => {
savedColor = await ownColor(p)
await openVacay(p)
},
steps: [
{
target: p => ownPersonRow(p).getByTitle('Change color'),
act: async p => { await ownPersonRow(p).getByTitle('Change color').click(); await settle(p) },
},
{
target: p => dialog(p).getByRole('group', { name: 'Color' }),
act: async p => {
// One that is not the current colour, or the pick would change nothing.
await dialog(p).getByRole('group', { name: 'Color' }).getByRole('button', { pressed: false }).nth(4).click()
await expect(p.getByRole('dialog', { name: 'Change color' })).toBeHidden()
await toTop(p)
},
},
],
cleanup: async p => { await setOwnColor(p, savedColor) },
},
invite: {
guide: guide('invite'),
start: openVacay,
steps: [
{
target: p => sideCard(p, 'Persons').getByRole('button').first(),
act: async p => { await sideCard(p, 'Persons').getByRole('button').first().click(); await settle(p) },
},
{
// Picked before the picture, so it shows mira chosen and Send Invite
// ready, which is the button the step ends on; the dialog closes on send,
// so no later picture shows the choice.
prepare: async p => {
await pick(p, dialog(p).getByRole('button', { name: 'Select user' }), 'mira (mira@example.com)')
await expect(dialog(p).getByRole('button', { name: 'Send Invite' })).toBeEnabled()
},
target: p => dialog(p).getByRole('button', { name: 'Send Invite' }),
act: async p => {
await dialog(p).getByRole('button', { name: 'Send Invite' }).click()
await expect(dialog(p)).toBeHidden({ timeout: 15_000 })
await settle(p)
},
},
{
// The whole row, with the pending chip the step is about. The pointer
// stays off it, or the row's Cancel button shows as well.
target: p => cardRow(p, 'Persons', 'mira'),
hover: parkPointer,
},
],
},
'answer-fusion-request': {
guide: guide('answer-fusion-request'),
start: async p => {
await requestFusionFromPartner(p)
await openVacay(p)
await expect(fusionDialog(p)).toBeVisible()
},
steps: [
{ target: p => fusionDialog(p).locator('header').first(), hover: parkPointer },
{ target: p => fusionDialog(p).locator('ul').first(), hover: parkPointer },
{
target: p => fusionDialog(p).locator('footer'),
hover: async p => { await fusionDialog(p).getByRole('button', { name: 'Accept & Fuse' }).hover() },
// Declined, so the admin's plan stays as the guides that follow expect it.
act: async p => {
await fusionDialog(p).getByRole('button', { name: 'Decline' }).click()
await expect(fusionDialog(p)).toBeHidden()
await settle(p)
},
},
],
cleanup: clearFusionRequest,
},
'log-for-partner': {
guide: guide('log-for-partner'),
start: async p => {
await ensureFused(p)
await openVacay(p)
await expect(personRow(p, PARTNER.username)).toBeVisible()
},
steps: [
{
target: p => personRow(p, PARTNER.username),
act: async p => {
await personRow(p, PARTNER.username).click()
await expect(toolbar(p).getByRole('button').first()).toContainText(PARTNER.username)
},
},
{ target: p => toolbar(p).getByRole('button').first() },
{
target: p => day(p, NEXT_MONTH, PARTNER_DAY),
act: async p => {
await day(p, NEXT_MONTH, PARTNER_DAY).click()
await settle(p)
await ownPersonRow(p).click()
await toTop(p)
},
},
],
cleanup: async p => {
await clearPartnerDays(p, YEAR, [nextMonthDate(PARTNER_DAY)])
await ensureSeparate(p)
},
},
'dissolve-fusion': {
guide: guide('dissolve-fusion'),
start: async p => {
await ensureFused(p)
await openVacay(p)
await expect(personRow(p, PARTNER.username)).toBeVisible()
},
steps: [
{
target: p => p.getByRole('button', { name: 'Settings' }),
act: openSettings,
},
{
// The whole red section: its name, the people in the plan and the button.
target: p => settings(p).getByText('Dissolve Fusion', { exact: true }).locator('xpath=ancestor::div[contains(@class, "rounded-xl")][1]'),
hover: async p => { await settings(p).getByRole('button', { name: 'Dissolve', exact: true }).hover() },
act: async p => {
await settings(p).getByRole('button', { name: 'Dissolve', exact: true }).click()
await expect(dissolveConfirm(p)).toBeVisible()
await settle(p)
},
},
{
// The window asks first; its Dissolve splits the plan.
target: dissolveConfirm,
hover: async p => { await dissolveConfirm(p).getByRole('button', { name: 'Dissolve', exact: true }).hover() },
act: async p => {
await dissolveConfirm(p).getByRole('button', { name: 'Dissolve', exact: true }).click()
await expect(settings(p)).toBeHidden()
await expect(personRow(p, PARTNER.username)).toBeHidden()
await p.evaluate(() => { (document.activeElement as HTMLElement | null)?.blur() })
await toTop(p)
},
},
],
// Already separate after the last step; this covers a run that failed before it.
cleanup: ensureSeparate,
},
'share-calendar': {
guide: guide('share-calendar'),
start: openVacay,
steps: [
{
target: p => sideCard(p, 'Shared Calendars').getByTitle('Share calendar'),
act: async p => { await sideCard(p, 'Shared Calendars').getByTitle('Share calendar').click(); await settle(p) },
},
{
// As in the invite guide: jonas picked and Share ready in the picture.
prepare: async p => {
await pick(p, dialog(p).getByRole('button', { name: 'Select user' }), 'jonas')
await expect(dialog(p).getByRole('button', { name: 'Share', exact: true })).toBeEnabled()
},
target: p => dialog(p).getByRole('button', { name: 'Share', exact: true }),
act: async p => {
await dialog(p).getByRole('button', { name: 'Share', exact: true }).click()
await expect(dialog(p)).toBeHidden({ timeout: 15_000 })
await settle(p)
},
},
{
// The step also names calendars shared with you, and the seed has none,
// so mira shares hers with the admin first. The page learns of it over
// the socket; a reload is the fallback should that message not come.
prepare: async p => {
await shareWithAdmin(p)
const incoming = cardRow(p, 'Shared Calendars', PARTNER.username)
if (!(await incoming.waitFor({ state: 'visible', timeout: 8_000 }).then(() => true, () => false))) await openVacay(p)
await expect(incoming).toBeVisible()
},
target: p => sideCard(p, 'Shared Calendars'),
// On the row the admin shares, so its Stop sharing button shows.
hover: async p => { await cardRow(p, 'Shared Calendars', 'jonas').hover() },
},
],
cleanup: unshareWithAdmin,
},
}
// ── Run ───────────────────────────────────────────────────────────────────────
test.describe.configure({ mode: 'serial' })
test('every registered vacay guide has a script, and only those', async () => {
expect(Object.keys(SCRIPTS).sort()).toEqual(vacayContext.guides.slice().sort())
})
test('hero: vacay', async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureHero(page, vacayContext.id, openVacay)
})
for (const id of vacayContext.guides) {
test(`pictures: ${id}`, async ({ page }) => {
await page.setViewportSize(VIEWPORT)
await captureGuide(page, SCRIPTS[id])
})
}