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 { 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 { 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 => { 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 { // 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 { 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 { await page.getByRole('button', { name: 'Settings' }).click() await expect(settings(page)).toBeVisible() await settle(page) } async function closeSettings(page: Page): Promise { 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 { 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 { 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 { 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() let christmasEveBefore: number | null = null const SCRIPTS: Record = { '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]) }) }