mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
146fe4a063
The guides of the map, bookings, costs, files, collab, lists, road trip and transports now describe the desktop as it is after the redesign: the bars with their icon buttons and filter popovers, the dialogs with the title in the head band and status and type as pills, the three booking views and the booking detail, the compact note cards, the Costs table. Two guides are new: the booking views (cards, list, timeline, the Trip and Day zoom and the detail) and the Costs table view. Every changed text is translated into every locale, and the Plan tab texts call the bookings tab by the label it shows, Book. Every picture of the thirteen trip screens is taken again against the new layout, on one picture day. Scripts follow the new markup; the Dawarich fixture uploads its recording again when the stored one belongs to another run, and the spare poll gets its vote from a member so its winner shows green. server-launch.mjs skips the server build with E2E_SERVER_PREBUILT, so several recordings on their own port pairs can share one dist.
497 lines
24 KiB
TypeScript
497 lines
24 KiB
TypeScript
import { test, expect, type Locator, type Page } from '@playwright/test'
|
|
import { captureGuide, captureHero, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
|
import { ordinal, short } from '../dates'
|
|
import { SEED_CHAT } from '../screenshots/seed'
|
|
import { seededTrip, ensureCollabFixtures, COLLAB_SPARE_POLL, WHATS_NEXT_STOPS } from './fixtures'
|
|
import { openTrip } from './trip-shared'
|
|
import { tripCollabContext, tripCollabGuides } from '../../src/help/contexts/tripCollab'
|
|
import type { HelpGuide } from '../../src/help/types'
|
|
|
|
/**
|
|
* Actions for the Collab tab of a trip, keyed by the ids in
|
|
* `src/help/contexts/tripCollab.ts`. They run on the seeded "Autumn in Japan",
|
|
* whose collab addon already holds three notes, two polls and a seven message
|
|
* conversation; `ensureCollabFixtures` adds the shared links, a throwaway poll
|
|
* for the one guide that closes a poll for good, and the two upcoming stops
|
|
* What's Next needs. What a guide writes it takes away again in `cleanup`, with
|
|
* one deliberate exception: a deleted chat message cannot be undeleted, which is
|
|
* why `trip-chat` is the last guide of the screen.
|
|
*/
|
|
|
|
const guide = (id: string): HelpGuide => {
|
|
const g = tripCollabGuides.find(x => x.id === id)
|
|
if (!g) throw new Error(`no registered guide "${id}"`)
|
|
return g
|
|
}
|
|
|
|
const NOTE = {
|
|
title: 'Luggage forwarding',
|
|
body: 'Takkyubin from the hotel front desk, **before 11:00**, and the bags are in Kyoto the next afternoon.\n\n- One bag per person\n- About 2000 JPY a bag',
|
|
category: 'Transport',
|
|
website: 'https://www.global-yamato.com/en/hc/',
|
|
}
|
|
/** An .example host never resolves, so the chip shows its own glyph offline too. */
|
|
const LINK = { title: 'Hakone ryokan booking', url: 'https://www.hakone-ryokan.example/booking' }
|
|
const POLL = {
|
|
// Plain text on purpose: a bold run in the question would wrap the paragraph
|
|
// in one more element and move the card away from `pollCard`'s ancestor depth.
|
|
question: 'Which evening do we keep for the kaiseki dinner?',
|
|
options: [`${short(-5).split(',')[0]} ${ordinal(-5)}, after Fushimi Inari`, `${short(-4).split(',')[0]} ${ordinal(-4)}, after Arashiyama`, 'Either, decide on the day'],
|
|
}
|
|
/** The seeded poll nobody has voted on yet. */
|
|
const SEEDED_POLL = 'Ryokan or city hotel in Hakone?'
|
|
const CHAT = {
|
|
mine: 'Ryokan check-in is 15:00, so let us aim for the 13:10 train out of Kyoto.',
|
|
/** Typed before the picker opens, so the picture shows the emoji being added to it. */
|
|
emoji: 'Two weeks to go ',
|
|
flag: '🎌',
|
|
theirs: 'Drop bags. I want to be at Senso-ji before the crowds.',
|
|
answer: 'Agreed, bags at the hotel first, then Asakusa.',
|
|
reactTo: SEED_CHAT.teamlab,
|
|
reaction: '👍',
|
|
}
|
|
|
|
// ── Locators ─────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* One of the five cards of the tab, found from the name in its head band. The
|
|
* head band is `CollabPanelHead`, whose name is an h3 set in small capitals, so
|
|
* the match ignores case; the card is the nearest rounded ancestor (the cards
|
|
* inside a panel, notes and polls, are never an ancestor of that heading).
|
|
*/
|
|
const panel = (page: Page, name: RegExp) =>
|
|
page.getByRole('heading', { name }).locator('xpath=ancestor::div[contains(@class,"rounded-2xl")][1]')
|
|
const notesPanel = (page: Page) => panel(page, /^Notes$/i)
|
|
const pollsPanel = (page: Page) => panel(page, /^Polls$/i)
|
|
const linksPanel = (page: Page) => panel(page, /^Links$/i)
|
|
const nextPanel = (page: Page) => panel(page, /^What.s Next$/i)
|
|
|
|
const chatBox = (page: Page) => page.getByPlaceholder('Type a message...')
|
|
/** The row that holds the emoji button, the image button, the box and the arrow. */
|
|
const composer = (page: Page) => chatBox(page).locator('xpath=..')
|
|
/**
|
|
* The quoted message above the box while a reply is being written. With no reply
|
|
* pending the same position is the composer row itself, which is what the step
|
|
* after the send asserts against.
|
|
*/
|
|
const replyQuote = (page: Page) => chatBox(page).locator('xpath=../..').locator('> div').first()
|
|
/** The send button and the emoji button carry no text and no label at all. */
|
|
const sendButton = (page: Page) => page.locator('button:has(svg.lucide-arrow-up)')
|
|
const emojiButton = (page: Page) => page.locator('button:has(svg.lucide-smile)')
|
|
/** The emoji picker is a portal; its three category tabs are the sure thing in it. */
|
|
const emojiPicker = (page: Page) => page.getByRole('button', { name: 'Travel', exact: true }).locator('xpath=../..')
|
|
/** The right-click menu of eight reactions: a 4x2 grid in a portal of its own. */
|
|
const reactionMenu = (page: Page) => page.locator('div[style*="repeat(4"]').last()
|
|
/** One message, by the relative wrapper that also carries its Reply and Delete. */
|
|
const bubble = (page: Page, text: string) =>
|
|
page.getByText(text).first().locator('xpath=ancestor::div[contains(@style,"position: relative")][1]')
|
|
/**
|
|
* Put the pointer on a message's Reply or Delete. The row holding them is
|
|
* `pointer-events: none` until its own bubble is hovered, and it sits at
|
|
* `top: -14px`, outside that bubble's box: hovering the button alone would land
|
|
* on whatever is behind it. `captureGuide` scrolls the target to the middle of
|
|
* the frame between `prepare` and the shot, and that scroll can take the bubble
|
|
* out from under the pointer, so the hover is made again here, bubble first.
|
|
*/
|
|
const hoverMessageAction = (text: string, name: 'Reply' | 'Delete') => async (page: Page): Promise<void> => {
|
|
await page.getByText(text).first().hover()
|
|
await beat(page, 300)
|
|
await bubble(page, text).getByRole('button', { name }).hover()
|
|
}
|
|
|
|
/**
|
|
* The three editors are DialogShell panels (`role="dialog"`), told apart by a
|
|
* field only each of them has. The whole panel is the dialog: the head band
|
|
* with the title typed into it, the body, and the footer with the buttons.
|
|
*/
|
|
const noteDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.getByPlaceholder('Write something...') })
|
|
const pollDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.getByPlaceholder('What should we do?') })
|
|
const linkDialog = (page: Page) => page.getByRole('dialog').filter({ has: page.locator('#collab-link-url') })
|
|
/** A field of an editor with the eyebrow label over it: the box's parent. */
|
|
const field = (box: Locator) => box.locator('xpath=..')
|
|
|
|
/** A note card is an article named by the note's title. */
|
|
const noteCard = (page: Page, title: string) => page.getByRole('article', { name: title, exact: true })
|
|
/** A poll card is an article too, without a name: found from its question. */
|
|
const pollCard = (page: Page, question: string) =>
|
|
page.getByRole('article').filter({ has: page.getByText(question, { exact: true }) })
|
|
const linkChip = (page: Page, title: string) => page.locator('.collab-link-chip').filter({ hasText: title })
|
|
/** A What's Next row, from the place name in it: name → details → row. */
|
|
const nextRow = (page: Page, place: string) =>
|
|
nextPanel(page).getByText(place, { exact: true }).locator('xpath=../..')
|
|
|
|
// ── Start and cleanup ────────────────────────────────────────────────────────
|
|
|
|
const start = async (page: Page): Promise<void> => {
|
|
await openTrip(page, { tab: 'collab' })
|
|
// The Collab panel is a lazy chunk: everything below needs it mounted.
|
|
await expect(page.getByRole('button', { name: 'New Poll', exact: true })).toBeVisible({ timeout: 30_000 })
|
|
await settle(page)
|
|
}
|
|
|
|
const collab = (): string => `/api/trips/${seededTrip().tripId}/collab`
|
|
|
|
async function listOf<T>(page: Page, path: string, key: string): Promise<T[]> {
|
|
const res = await page.request.get(`${collab()}/${path}`)
|
|
const body = (await res.json()) as Record<string, T[] | undefined>
|
|
return body[key] ?? []
|
|
}
|
|
|
|
async function deleteNote(page: Page, title: string): Promise<void> {
|
|
const notes = await listOf<{ id: number; title: string }>(page, 'notes', 'notes')
|
|
for (const note of notes.filter(n => n.title === title)) {
|
|
await page.request.delete(`${collab()}/notes/${note.id}`)
|
|
}
|
|
}
|
|
|
|
async function deleteLink(page: Page, title: string): Promise<void> {
|
|
const links = await listOf<{ id: number; title: string }>(page, 'links', 'links')
|
|
for (const link of links.filter(l => l.title === title)) {
|
|
await page.request.delete(`${collab()}/links/${link.id}`)
|
|
}
|
|
}
|
|
|
|
async function deletePoll(page: Page, question: string): Promise<void> {
|
|
const polls = await listOf<{ id: number; question: string }>(page, 'polls', 'polls')
|
|
for (const poll of polls.filter(p => p.question === question)) {
|
|
await page.request.delete(`${collab()}/polls/${poll.id}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Voting for an option you already chose deletes the row, so one call puts the
|
|
* seeded, unvoted poll back the way the next guide expects it.
|
|
*/
|
|
async function resetVote(page: Page, question: string, optionIndex: number): Promise<void> {
|
|
const polls = await listOf<{ id: number; question: string }>(page, 'polls', 'polls')
|
|
const poll = polls.find(p => p.question === question)
|
|
if (poll) await page.request.post(`${collab()}/polls/${poll.id}/vote`, { data: { option_index: optionIndex } })
|
|
}
|
|
|
|
const only = (target: (page: Page) => Locator) => ({ target })
|
|
|
|
// ── Scripts ──────────────────────────────────────────────────────────────────
|
|
|
|
const SCRIPTS: Record<string, GuideScript> = {
|
|
'write-note': {
|
|
guide: guide('write-note'),
|
|
start,
|
|
steps: [
|
|
{
|
|
target: page => notesPanel(page).getByRole('button', { name: 'New Note', exact: true }),
|
|
act: async page => {
|
|
await notesPanel(page).getByRole('button', { name: 'New Note', exact: true }).click()
|
|
await expect(noteDialog(page)).toBeVisible()
|
|
await settle(page)
|
|
},
|
|
},
|
|
{
|
|
// The title is typed into the dialog's head band, under the New Note eyebrow.
|
|
prepare: page => typeInto(page, noteDialog(page).getByRole('textbox', { name: 'Note title' }), NOTE.title),
|
|
target: page => noteDialog(page).getByRole('textbox', { name: 'Note title' }),
|
|
},
|
|
{
|
|
prepare: page => typeInto(page, noteDialog(page).getByPlaceholder('Write something...'), NOTE.body),
|
|
// The Content eyebrow and the box under it.
|
|
target: page => field(noteDialog(page).getByPlaceholder('Write something...')),
|
|
},
|
|
{
|
|
// Picked before the shot, so the pill and the head band already wear its colour.
|
|
prepare: async page => {
|
|
await noteDialog(page).getByRole('button', { name: NOTE.category, exact: true }).click()
|
|
await expect(noteDialog(page).getByRole('button', { name: NOTE.category, exact: true })).toHaveAttribute('aria-pressed', 'true')
|
|
await beat(page, 300)
|
|
},
|
|
// The Category eyebrow and the row of pills: pill, row, field.
|
|
target: page => noteDialog(page).getByRole('button', { name: NOTE.category, exact: true }).locator('xpath=../..'),
|
|
},
|
|
{
|
|
prepare: page => typeInto(page, noteDialog(page).getByPlaceholder('https://...'), NOTE.website),
|
|
target: page => field(noteDialog(page).getByPlaceholder('https://...')),
|
|
},
|
|
{
|
|
target: page => noteDialog(page).getByRole('button', { name: 'Create', exact: true }),
|
|
act: async page => {
|
|
await noteDialog(page).getByRole('button', { name: 'Create', exact: true }).click()
|
|
await expect(noteDialog(page)).toHaveCount(0, { timeout: 15_000 })
|
|
await expect(noteCard(page, NOTE.title)).toBeVisible({ timeout: 15_000 })
|
|
await settle(page)
|
|
},
|
|
},
|
|
],
|
|
cleanup: page => deleteNote(page, NOTE.title),
|
|
},
|
|
'shared-links': {
|
|
guide: guide('shared-links'),
|
|
start,
|
|
steps: [
|
|
{
|
|
target: page => linksPanel(page).getByRole('button', { name: 'Add link', exact: true }),
|
|
act: async page => {
|
|
await linksPanel(page).getByRole('button', { name: 'Add link', exact: true }).click()
|
|
await expect(linkDialog(page)).toBeVisible()
|
|
await settle(page)
|
|
},
|
|
},
|
|
{
|
|
prepare: async page => {
|
|
// The title sits in the head band, the address in the Link field under it.
|
|
await typeInto(page, linkDialog(page).getByRole('textbox', { name: 'Link title' }), LINK.title)
|
|
await typeInto(page, linkDialog(page).locator('#collab-link-url'), LINK.url)
|
|
await beat(page, 300)
|
|
},
|
|
target: linkDialog,
|
|
act: async page => {
|
|
await linkDialog(page).getByRole('button', { name: 'Save link', exact: true }).click()
|
|
await expect(linkDialog(page)).toHaveCount(0, { timeout: 15_000 })
|
|
await expect(linkChip(page, LINK.title)).toBeVisible({ timeout: 15_000 })
|
|
await settle(page)
|
|
},
|
|
},
|
|
{
|
|
prepare: async page => { await linkChip(page, LINK.title).hover() },
|
|
target: page => linkChip(page, LINK.title).locator('.collab-link-chip__main'),
|
|
},
|
|
{
|
|
target: page => linkChip(page, LINK.title).locator('.collab-link-chip__actions'),
|
|
act: async page => {
|
|
// Exact: an accessible name is matched as a substring, and "Unpin link"
|
|
// holds "Pin link", so the loose form would find both buttons.
|
|
await linkChip(page, LINK.title).getByRole('button', { name: 'Pin link', exact: true }).click()
|
|
await expect(linkChip(page, LINK.title)).toHaveClass(/collab-link-chip--pinned/)
|
|
await expect(page.locator('.collab-link-grid__cells > div').first()).toContainText(LINK.title)
|
|
await settle(page)
|
|
},
|
|
},
|
|
],
|
|
cleanup: page => deleteLink(page, LINK.title),
|
|
},
|
|
'create-poll': {
|
|
guide: guide('create-poll'),
|
|
start,
|
|
steps: [
|
|
{
|
|
target: page => pollsPanel(page).getByRole('button', { name: 'New Poll', exact: true }),
|
|
act: async page => {
|
|
await pollsPanel(page).getByRole('button', { name: 'New Poll', exact: true }).click()
|
|
await expect(pollDialog(page)).toBeVisible()
|
|
await settle(page)
|
|
},
|
|
},
|
|
{
|
|
prepare: page => typeInto(page, pollDialog(page).getByPlaceholder('What should we do?'), POLL.question),
|
|
// The Question eyebrow, the box and the Markdown supported hint under it.
|
|
target: page => field(pollDialog(page).getByPlaceholder('What should we do?')),
|
|
},
|
|
{
|
|
prepare: async page => {
|
|
await typeInto(page, pollDialog(page).getByPlaceholder('Option 1'), POLL.options[0])
|
|
await typeInto(page, pollDialog(page).getByPlaceholder('Option 2'), POLL.options[1])
|
|
},
|
|
// The Options field: box, its row, the column of rows, the field with its eyebrow.
|
|
target: page => pollDialog(page).getByPlaceholder('Option 1').locator('xpath=ancestor::div[3]'),
|
|
},
|
|
{
|
|
// The label drops the string's own "+"; the button draws a plus icon instead.
|
|
target: page => pollDialog(page).getByRole('button', { name: 'Add option', exact: true }),
|
|
act: async page => {
|
|
await pollDialog(page).getByRole('button', { name: 'Add option', exact: true }).click()
|
|
await typeInto(page, pollDialog(page).getByPlaceholder('Option 3'), POLL.options[2])
|
|
await beat(page, 300)
|
|
},
|
|
},
|
|
// The switch alone is 36 px wide; ring the row it sits in, which is its label.
|
|
only(page => pollDialog(page).locator('label').filter({ hasText: 'Multiple choice' })),
|
|
{
|
|
target: page => pollDialog(page).getByRole('button', { name: 'Create Poll', exact: true }),
|
|
act: async page => {
|
|
await pollDialog(page).getByRole('button', { name: 'Create Poll', exact: true }).click()
|
|
await expect(pollDialog(page)).toHaveCount(0, { timeout: 15_000 })
|
|
await expect(pollCard(page, POLL.question)).toBeVisible({ timeout: 15_000 })
|
|
await settle(page)
|
|
},
|
|
},
|
|
],
|
|
cleanup: page => deletePoll(page, POLL.question),
|
|
},
|
|
'vote-poll': {
|
|
guide: guide('vote-poll'),
|
|
start,
|
|
steps: [
|
|
{
|
|
target: page => pollCard(page, SEEDED_POLL).getByRole('button', { name: 'City hotel' }),
|
|
act: async page => {
|
|
await pollCard(page, SEEDED_POLL).getByRole('button', { name: 'City hotel' }).click()
|
|
await expect(pollCard(page, SEEDED_POLL).getByRole('button', { name: /City hotel.*100%/ })).toBeVisible({ timeout: 15_000 })
|
|
await settle(page)
|
|
},
|
|
},
|
|
// Both bars and both percentages, so the step about reading them has them.
|
|
only(page => pollCard(page, SEEDED_POLL).locator('> div').last()),
|
|
{
|
|
target: page => pollCard(page, SEEDED_POLL).getByRole('button', { name: /^Ryokan with onsen/ }),
|
|
act: async page => {
|
|
await pollCard(page, SEEDED_POLL).getByRole('button', { name: /^Ryokan with onsen/ }).click()
|
|
await expect(pollCard(page, SEEDED_POLL).getByRole('button', { name: /Ryokan with onsen.*100%/ })).toBeVisible({ timeout: 15_000 })
|
|
await expect(pollCard(page, SEEDED_POLL).getByRole('button', { name: /City hotel.*0%/ })).toBeVisible()
|
|
await settle(page)
|
|
},
|
|
},
|
|
// The count is a chip under the question, in the card's head band: "1 vote" / "3 votes".
|
|
only(page => pollCard(page, SEEDED_POLL).getByText(/^\d+ votes?$/)),
|
|
],
|
|
cleanup: page => resetVote(page, SEEDED_POLL, 0),
|
|
},
|
|
'close-poll': {
|
|
guide: guide('close-poll'),
|
|
start,
|
|
steps: [
|
|
{
|
|
// The lock and the bin are round buttons at the right of the card's head band, named by their tooltips.
|
|
target: page => pollCard(page, COLLAB_SPARE_POLL.question).getByRole('button', { name: 'Close', exact: true }),
|
|
act: async page => {
|
|
await pollCard(page, COLLAB_SPARE_POLL.question).getByRole('button', { name: 'Close', exact: true }).click()
|
|
await expect(pollCard(page, COLLAB_SPARE_POLL.question).getByText('Closed', { exact: true })).toBeVisible({ timeout: 15_000 })
|
|
await settle(page)
|
|
},
|
|
},
|
|
only(page => pollCard(page, COLLAB_SPARE_POLL.question)),
|
|
{
|
|
target: page => pollCard(page, COLLAB_SPARE_POLL.question).getByRole('button', { name: 'Delete', exact: true }),
|
|
act: async page => {
|
|
await pollCard(page, COLLAB_SPARE_POLL.question).getByRole('button', { name: 'Delete', exact: true }).click()
|
|
await expect(pollsPanel(page).getByText(COLLAB_SPARE_POLL.question)).toHaveCount(0, { timeout: 15_000 })
|
|
await settle(page)
|
|
},
|
|
},
|
|
],
|
|
// The last step normally already removed it; this is for a run that stopped short.
|
|
cleanup: page => deletePoll(page, COLLAB_SPARE_POLL.question),
|
|
},
|
|
'whats-next': {
|
|
guide: guide('whats-next'),
|
|
start,
|
|
steps: [
|
|
only(nextPanel),
|
|
// The time column is the first child of a row; the name, the address and the chips are its last.
|
|
only(page => nextRow(page, WHATS_NEXT_STOPS.timed).locator('> div').first()),
|
|
only(page => nextRow(page, WHATS_NEXT_STOPS.timed).locator('> div').last()),
|
|
],
|
|
// Read-only panel: the guide writes nothing.
|
|
},
|
|
'trip-chat': {
|
|
guide: guide('trip-chat'),
|
|
start,
|
|
steps: [
|
|
{
|
|
prepare: page => typeInto(page, chatBox(page), CHAT.mine),
|
|
target: composer,
|
|
act: async page => {
|
|
await chatBox(page).press('Enter')
|
|
await expect(page.getByText(CHAT.mine)).toBeVisible({ timeout: 15_000 })
|
|
await settle(page)
|
|
},
|
|
},
|
|
{
|
|
prepare: async page => {
|
|
await typeInto(page, chatBox(page), CHAT.emoji)
|
|
await emojiButton(page).click()
|
|
await expect(emojiPicker(page)).toBeVisible()
|
|
await beat(page, 300)
|
|
},
|
|
target: emojiPicker,
|
|
act: async page => {
|
|
await emojiPicker(page).getByRole('button', { name: 'Travel', exact: true }).click()
|
|
await emojiPicker(page).getByRole('button', { name: CHAT.flag }).click()
|
|
await expect(chatBox(page)).toHaveValue(`${CHAT.emoji}${CHAT.flag}`)
|
|
await sendButton(page).click()
|
|
await expect(page.getByText(`${CHAT.emoji}${CHAT.flag}`)).toBeVisible({ timeout: 15_000 })
|
|
await settle(page)
|
|
},
|
|
},
|
|
{
|
|
// The hover row is pointer-events:none until the bubble under it is hovered,
|
|
// so the bubble has to be hovered before the button can be reached at all.
|
|
prepare: async page => {
|
|
await page.getByText(CHAT.theirs).first().hover()
|
|
await beat(page, 300)
|
|
},
|
|
target: page => bubble(page, CHAT.theirs).getByRole('button', { name: 'Reply' }),
|
|
hover: hoverMessageAction(CHAT.theirs, 'Reply'),
|
|
act: async page => {
|
|
await bubble(page, CHAT.theirs).getByRole('button', { name: 'Reply' }).click()
|
|
await expect(replyQuote(page)).toContainText(CHAT.theirs)
|
|
await settle(page)
|
|
},
|
|
},
|
|
{
|
|
prepare: page => typeInto(page, chatBox(page), CHAT.answer),
|
|
target: replyQuote,
|
|
act: async page => {
|
|
await chatBox(page).press('Enter')
|
|
await expect(page.getByText(CHAT.answer)).toBeVisible({ timeout: 15_000 })
|
|
await expect(replyQuote(page)).not.toContainText(CHAT.theirs)
|
|
await settle(page)
|
|
},
|
|
},
|
|
{
|
|
prepare: async page => {
|
|
await page.getByText(CHAT.reactTo).first().click({ button: 'right' })
|
|
await expect(reactionMenu(page)).toBeVisible()
|
|
await beat(page, 300)
|
|
},
|
|
target: reactionMenu,
|
|
act: async page => {
|
|
await reactionMenu(page).getByRole('button', { name: CHAT.reaction }).click()
|
|
await expect(
|
|
bubble(page, CHAT.reactTo).locator('xpath=..').getByRole('button', { name: CHAT.reaction }),
|
|
).toBeVisible({ timeout: 15_000 })
|
|
await settle(page)
|
|
},
|
|
},
|
|
{
|
|
prepare: async page => {
|
|
await page.getByText(CHAT.mine).first().hover()
|
|
await beat(page, 300)
|
|
},
|
|
target: page => bubble(page, CHAT.mine).getByRole('button', { name: 'Delete' }),
|
|
hover: hoverMessageAction(CHAT.mine, 'Delete'),
|
|
act: async page => {
|
|
await bubble(page, CHAT.mine).getByRole('button', { name: 'Delete' }).click()
|
|
// The bubble shrinks for 400 ms before the request goes out.
|
|
await page.waitForTimeout(1200)
|
|
await expect(page.getByText(/deleted a message/)).toBeVisible({ timeout: 15_000 })
|
|
await settle(page)
|
|
},
|
|
},
|
|
],
|
|
// No cleanup, deliberately: deleting a message is a soft delete and there is
|
|
// no undelete over the API, so undoing anything here would only add more
|
|
// tombstones. This is why trip-chat is the last guide of the screen.
|
|
},
|
|
}
|
|
|
|
// ── Run ───────────────────────────────────────────────────────────────────────
|
|
|
|
test.describe.configure({ mode: 'serial' })
|
|
|
|
test.beforeAll(async ({ request }) => {
|
|
await ensureCollabFixtures(request)
|
|
})
|
|
|
|
test('every registered collab guide has a script, and only those', async () => {
|
|
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripCollabContext.guides].sort())
|
|
})
|
|
|
|
test('hero: trip-collab', async ({ page }) => {
|
|
await page.setViewportSize(VIEWPORT)
|
|
await captureHero(page, tripCollabContext.id, start)
|
|
})
|
|
|
|
for (const id of tripCollabContext.guides) {
|
|
test(`pictures: ${id}`, async ({ page }) => {
|
|
await page.setViewportSize(VIEWPORT)
|
|
await captureGuide(page, SCRIPTS[id])
|
|
})
|
|
}
|