mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
1607 lines
66 KiB
TypeScript
1607 lines
66 KiB
TypeScript
import { test, expect, type APIResponse, type Locator, type Page } from '@playwright/test'
|
||
import { readFileSync } from 'node:fs'
|
||
import { clearNotices } from '../screenshots/shot'
|
||
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||
import { ensureExtraTrips, ensureJourneyFixtures, filesImageFixture, SECOND_JOURNEY, SPARE_TRIP } from './fixtures'
|
||
import { connectJournalDawarich, releaseJournalDawarich, STAY_DAY, writtenEntryIds } from './fixtures-journey'
|
||
import { journeyGuides, journeyContext, journalGuides, journalContext, studioGuides, studioContext } from '../../src/help/contexts/journey'
|
||
import type { HelpGuide } from '../../src/help/types'
|
||
|
||
/**
|
||
* Actions for the two Journey screens, keyed by the ids in
|
||
* `src/help/contexts/journey.ts`: the list at `/journey` and the open journal
|
||
* at `/journey/:id`. The fixtures write three entries into the seeded
|
||
* "Autumn in Japan" journey and leave the rest of its timeline as suggestions.
|
||
*
|
||
* A guide that writes something (an entry, photos, a verdict, a share link, a
|
||
* contributor, a linked trip) takes it away again in its cleanup, so every
|
||
* guide starts from the journal the overview picture shows. Each one also
|
||
* clears the same things at its start, which makes a retry after a failed run
|
||
* begin from the seed as well.
|
||
*/
|
||
|
||
const ALL = [...journeyGuides, ...journalGuides, ...studioGuides]
|
||
|
||
const guide = (id: string): HelpGuide => {
|
||
const g = ALL.find(x => x.id === id)
|
||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||
return g
|
||
}
|
||
|
||
let journeyId = 0
|
||
|
||
async function openJourneys(page: Page): Promise<void> {
|
||
await page.goto('/journey')
|
||
// clearNotices pages through a notice by clicking any button whose name holds
|
||
// "next", and the second journey's card is such a button ("Norway, next
|
||
// summer"): it opened that journey instead. While it runs the cards are
|
||
// hidden from role lookups with aria-hidden. Not inert: Playwright still
|
||
// finds an inert button and then waits forever for it to take the click.
|
||
await expect(journeyCard(page, SECOND_JOURNEY.title)).toBeVisible({ timeout: 20_000 })
|
||
const cards = page.locator('.vg-card')
|
||
await cards.evaluateAll(els => els.forEach(el => el.setAttribute('aria-hidden', 'true')))
|
||
await clearNotices(page)
|
||
await cards.evaluateAll(els => els.forEach(el => el.removeAttribute('aria-hidden')))
|
||
await dismissReleaseNotice(page)
|
||
await expect(createCard(page)).toBeVisible({ timeout: 20_000 })
|
||
await expect(journeyCard(page, SECOND_JOURNEY.title)).toBeVisible({ timeout: 20_000 })
|
||
await settle(page)
|
||
}
|
||
|
||
async function openJournal(page: Page): Promise<void> {
|
||
await page.goto(`/journey/${journeyId}`)
|
||
await clearNotices(page)
|
||
await dismissReleaseNotice(page)
|
||
await expect(page.getByRole('button', { name: 'Add Entry' }).first()).toBeVisible({ timeout: 20_000 })
|
||
await expect(entryCard(page, 'First morning in Asakusa')).toBeVisible({ timeout: 20_000 })
|
||
await settle(page)
|
||
await page.waitForTimeout(600) // the map tiles
|
||
}
|
||
|
||
const journeyCard = (page: Page, title: string) => page.locator('.vg-card').filter({ hasText: title }).first()
|
||
/** The + card at the end of the grid. */
|
||
const createCard = (page: Page) => page.getByRole('button', { name: /Create a new Journey/ })
|
||
/**
|
||
* The dialogs of both screens (create, settings, link a trip, invite, the entry
|
||
* editor) are the shared DialogShell: a dimmed backdrop holding the panel. They
|
||
* portal onto the body in the order they open, so the last backdrop is the one
|
||
* on top. ConfirmDialog draws its own backdrop without this class, so a
|
||
* question asked over a dialog is not counted as one.
|
||
*/
|
||
const anyDialog = (page: Page) => page.locator('div.trek-modal-backdrop')
|
||
const dialog = (page: Page) => anyDialog(page).last()
|
||
/** The rounded panel of the top dialog: head band, body and footer. */
|
||
const dialogCard = (page: Page) => dialog(page).getByRole('dialog')
|
||
/** The entry editor, named by the eyebrow over the title it has you type. */
|
||
const editor = (page: Page) => page.getByRole('dialog', { name: /^(new|edit) entry$/i })
|
||
/** The editor's photo tiles, saved ones first and then the files waiting for Save. */
|
||
const photoTiles = (page: Page) => editor(page).locator('.group.h-20.w-20')
|
||
|
||
/** A written entry's card in the timeline, and the More options button that opens its menu. */
|
||
const entryCard = (page: Page, title: string) => page.locator('div[class*="rounded-[20px]"]').filter({ hasText: title }).first()
|
||
// The shared MoreButton, in both builds of the card (over a photo cover and on a plain one).
|
||
const entryMenu = (page: Page, title: string) => entryCard(page, title).getByRole('button', { name: 'More options' })
|
||
/**
|
||
* An item of the menu the More options button opens. The shared ContextMenu
|
||
* portals onto the end of the body, so the last button of that name is its own.
|
||
*/
|
||
const menuItem = (page: Page, name: string) => page.getByRole('button', { name, exact: true }).last()
|
||
/** A suggestion card: the one kind of card that carries a dismiss button. */
|
||
const suggestion = (page: Page) =>
|
||
page.locator('[role="button"]').filter({ has: page.getByRole('button', { name: 'Dismiss this suggestion' }) }).first()
|
||
/** The suggestion a trip place became, by the place's name. */
|
||
const namedSuggestion = (page: Page, name: string) =>
|
||
page.locator('[role="button"]').filter({ has: page.getByRole('button', { name: 'Dismiss this suggestion' }) }).filter({ hasText: name }).first()
|
||
const settingsButton = (page: Page) => page.getByRole('button', { name: 'Journey Settings' }).first()
|
||
const mapPanel = (page: Page) => page.locator('aside').filter({ has: page.locator('.leaflet-container, .maplibregl-map, canvas') }).first()
|
||
|
||
async function openSettings(page: Page): Promise<void> {
|
||
await settingsButton(page).click()
|
||
await expect(page.getByRole('dialog', { name: 'Journey Settings' })).toBeVisible()
|
||
await settle(page)
|
||
}
|
||
|
||
/**
|
||
* Close whatever dialogs are stacked, the top one first, with the X in its
|
||
* head band. The mouse rather than Escape: a dialog hands the focus back to
|
||
* the button that opened it, and after a key press that focus counts as
|
||
* keyboard focus and brings up the button's tooltip in the next picture.
|
||
*/
|
||
async function closeDialog(page: Page): Promise<void> {
|
||
for (let i = 0; i < 3 && (await anyDialog(page).count()); i++) {
|
||
await dialogCard(page).getByRole('button', { name: 'Close', exact: true }).first().click()
|
||
await page.waitForTimeout(300)
|
||
}
|
||
await expect(anyDialog(page)).toHaveCount(0)
|
||
await settle(page)
|
||
}
|
||
|
||
async function openEditor(page: Page): Promise<void> {
|
||
await page.getByRole('button', { name: 'Add Entry' }).first().click()
|
||
await expect(editor(page)).toBeVisible()
|
||
await settle(page)
|
||
}
|
||
|
||
/** Open a written entry in the editor through the menu on its card. */
|
||
async function editEntry(page: Page, title: string): Promise<void> {
|
||
await entryMenu(page, title).click()
|
||
await menuItem(page, 'Edit').click()
|
||
await expect(editor(page)).toBeVisible()
|
||
await settle(page)
|
||
}
|
||
|
||
/**
|
||
* Leave the editor without saving. One that holds an unsaved change asks
|
||
* first, in a ConfirmDialog, and the guide means to throw the change away.
|
||
*/
|
||
async function cancelEditor(page: Page): Promise<void> {
|
||
await editor(page).getByRole('button', { name: 'Cancel', exact: true }).click()
|
||
const discard = page.getByRole('button', { name: 'Discard', exact: true })
|
||
await expect.poll(async () => (await editor(page).count()) === 0 || (await discard.isVisible())).toBe(true)
|
||
if (await discard.isVisible()) await discard.click()
|
||
await expect(editor(page)).toHaveCount(0)
|
||
await settle(page)
|
||
}
|
||
|
||
/**
|
||
* Take the focus off whatever holds it before an after-picture. A closed dialog
|
||
* hands the focus back to the button that opened it, and after typing that
|
||
* counts as keyboard focus: the button then shows its focus ring or tooltip in
|
||
* a picture that is about something else.
|
||
*/
|
||
async function blurFocus(page: Page): Promise<void> {
|
||
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
|
||
}
|
||
|
||
/**
|
||
* Some steps are about a few controls that have no element of their own around
|
||
* them: the three choices of the export dialog sit in two sections, Delete and
|
||
* Archive Journey share the footer with Cancel and Save. The ring is drawn
|
||
* round one element's box, so a transparent box is laid over exactly the
|
||
* union of those controls for the picture and removed again after it. It
|
||
* takes no pointer events, so the measuring and the hovering go through it.
|
||
*/
|
||
const GROUP_MARK = 'trek-help-group'
|
||
const groupMark = (page: Page) => page.locator(`#${GROUP_MARK}`)
|
||
|
||
async function markGroup(page: Page, items: Locator): Promise<void> {
|
||
const boxes = await items.evaluateAll(els => els.map(el => {
|
||
const r = el.getBoundingClientRect()
|
||
return { left: r.left, top: r.top, right: r.right, bottom: r.bottom }
|
||
}))
|
||
if (!boxes.length) throw new Error('markGroup: nothing to mark')
|
||
const box = {
|
||
left: Math.min(...boxes.map(b => b.left)),
|
||
top: Math.min(...boxes.map(b => b.top)),
|
||
right: Math.max(...boxes.map(b => b.right)),
|
||
bottom: Math.max(...boxes.map(b => b.bottom)),
|
||
}
|
||
await page.evaluate(({ id, box }) => {
|
||
document.getElementById(id)?.remove()
|
||
const el = document.createElement('div')
|
||
el.id = id
|
||
Object.assign(el.style, {
|
||
position: 'fixed',
|
||
left: `${box.left}px`,
|
||
top: `${box.top}px`,
|
||
width: `${box.right - box.left}px`,
|
||
height: `${box.bottom - box.top}px`,
|
||
pointerEvents: 'none',
|
||
})
|
||
document.body.appendChild(el)
|
||
}, { id: GROUP_MARK, box })
|
||
}
|
||
|
||
async function unmarkGroup(page: Page): Promise<void> {
|
||
await page.evaluate(id => document.getElementById(id)?.remove(), GROUP_MARK)
|
||
}
|
||
|
||
// ── Data a guide brings along ─────────────────────────────────────────────────
|
||
|
||
/**
|
||
* The seed has one trip with places, and a journey already holds it. The
|
||
* guides that show a trip's places arriving as suggestions therefore put a few
|
||
* real stops on a trip of their own first, each on a day, because only a place
|
||
* on a day becomes a suggestion. `day` is the index of that day in the trip.
|
||
*/
|
||
interface TripStop { name: string; lat: number; lng: number; address: string; day: number }
|
||
|
||
/** Five stops for the Lisbon weekend (three days), ticked when a journey is created. */
|
||
const LISBON_STOPS: TripStop[] = [
|
||
{ name: 'Castelo de São Jorge', lat: 38.7139, lng: -9.1334, address: 'R. de Santa Cruz do Castelo, 1100-129 Lisboa, Portugal', day: 0 },
|
||
{ name: 'Miradouro da Senhora do Monte', lat: 38.7192, lng: -9.1326, address: 'Largo Monte, 1170-107 Lisboa, Portugal', day: 0 },
|
||
{ name: 'Mosteiro dos Jerónimos', lat: 38.6979, lng: -9.2068, address: 'Praça do Império, 1400-206 Lisboa, Portugal', day: 1 },
|
||
{ name: 'Pastéis de Belém', lat: 38.6975, lng: -9.2032, address: 'R. de Belém 84-92, 1300-085 Lisboa, Portugal', day: 1 },
|
||
{ name: 'Palácio Nacional da Pena', lat: 38.7876, lng: -9.3906, address: 'Estrada da Pena, 2710-609 Sintra, Portugal', day: 2 },
|
||
]
|
||
const LISBON_TRIP = 'Weekend in Lisbon'
|
||
const NEW_JOURNEY = 'Lisbon weekend'
|
||
|
||
/** Four stations of the Alps by rail trip (eight days), for the guide that links it. */
|
||
const ALPS_STOPS: TripStop[] = [
|
||
{ name: 'Zürich HB', lat: 47.378, lng: 8.5402, address: 'Bahnhofplatz, 8001 Zürich, Switzerland', day: 0 },
|
||
{ name: 'Alp Grüm', lat: 46.3736, lng: 10.0467, address: '7710 Alp Grüm, Switzerland', day: 2 },
|
||
{ name: 'Tirano', lat: 46.2163, lng: 10.1686, address: 'Piazza Stazione, 23037 Tirano SO, Italy', day: 3 },
|
||
{ name: 'Venezia Santa Lucia', lat: 45.441, lng: 12.3208, address: 'Fondamenta Santa Lucia, 30121 Venezia VE, Italy', day: 6 },
|
||
]
|
||
|
||
const NEW_ENTRY = 'Night market on the river'
|
||
const ASAKUSA = 'First morning in Asakusa'
|
||
|
||
/** The body of an API answer, or a loud failure naming what was asked. */
|
||
async function answer<T>(res: APIResponse, what: string): Promise<T> {
|
||
if (!res.ok()) throw new Error(`${what}: ${res.status()} ${await res.text()}`)
|
||
return (await res.json()) as T
|
||
}
|
||
|
||
/** The list inside an answer that comes either bare or wrapped in `{ key: [...] }`. */
|
||
function listIn<T>(body: unknown, key: string): T[] {
|
||
if (Array.isArray(body)) return body as T[]
|
||
return ((body as Record<string, T[]>)[key] ?? []) as T[]
|
||
}
|
||
|
||
async function tripId(page: Page, title: string): Promise<number> {
|
||
const body = await answer<unknown>(await page.request.get('/api/trips'), 'list trips')
|
||
// Exact title: a copy another screen's guide made is "Weekend in Lisbon" plus something.
|
||
const trip = listIn<{ id: number; title: string }>(body, 'trips').find(t => t.title === title)
|
||
if (!trip) throw new Error(`fixture trip "${title}" is missing`)
|
||
return trip.id
|
||
}
|
||
|
||
async function tripPlaces(page: Page, trip: number): Promise<{ id: number; name: string }[]> {
|
||
return listIn<{ id: number; name: string }>(await answer<unknown>(await page.request.get(`/api/trips/${trip}/places`), 'list places'), 'places')
|
||
}
|
||
|
||
/** Put the stops on the trip, each on its day; those already there are left alone. */
|
||
async function addTripStops(page: Page, title: string, stops: TripStop[]): Promise<void> {
|
||
const trip = await tripId(page, title)
|
||
const have = await tripPlaces(page, trip)
|
||
const days = listIn<{ id: number; day_number?: number; date?: string }>(
|
||
await answer<unknown>(await page.request.get(`/api/trips/${trip}/days`), 'list days'), 'days',
|
||
).sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0) || String(a.date).localeCompare(String(b.date)))
|
||
for (const { day, ...place } of stops) {
|
||
if (have.some(p => p.name === place.name)) continue
|
||
const { place: created } = await answer<{ place: { id: number } }>(
|
||
await page.request.post(`/api/trips/${trip}/places`, { data: place }), `add "${place.name}"`,
|
||
)
|
||
const dayId = days[day]?.id
|
||
if (!dayId) throw new Error(`trip "${title}" has no day ${day + 1}`)
|
||
await answer(await page.request.post(`/api/trips/${trip}/days/${dayId}/assignments`, { data: { place_id: created.id } }), `put "${place.name}" on day ${day + 1}`)
|
||
}
|
||
}
|
||
|
||
/** Take the stops off the trip again, by name, so only what the guide added goes. */
|
||
async function removeTripStops(page: Page, title: string, stops: TripStop[]): Promise<void> {
|
||
const trip = await tripId(page, title)
|
||
for (const place of await tripPlaces(page, trip)) {
|
||
if (!stops.some(s => s.name === place.name)) continue
|
||
await answer(await page.request.delete(`/api/trips/${trip}/places/${place.id}`), `remove "${place.name}"`)
|
||
}
|
||
}
|
||
|
||
async function deleteJourneysNamed(page: Page, title: string): Promise<void> {
|
||
const { journeys } = await answer<{ journeys: { id: number; title: string }[] }>(await page.request.get('/api/journeys'), 'list journeys')
|
||
for (const j of journeys.filter(x => x.title === title)) {
|
||
await answer(await page.request.delete(`/api/journeys/${j.id}`), `delete journey "${title}"`)
|
||
}
|
||
}
|
||
|
||
interface JournalEntry { id: number; title: string | null; photos?: { id: number }[] }
|
||
|
||
async function journalEntries(page: Page): Promise<JournalEntry[]> {
|
||
const { entries } = await answer<{ entries: JournalEntry[] }>(await page.request.get(`/api/journeys/${journeyId}/entries`), 'list entries')
|
||
return entries
|
||
}
|
||
|
||
async function journalEntry(page: Page, title: string): Promise<JournalEntry> {
|
||
const entry = (await journalEntries(page)).find(e => e.title === title)
|
||
if (!entry) throw new Error(`entry "${title}" is missing`)
|
||
return entry
|
||
}
|
||
|
||
async function deleteEntriesNamed(page: Page, title: string): Promise<void> {
|
||
for (const entry of (await journalEntries(page)).filter(e => e.title === title)) {
|
||
await answer(await page.request.delete(`/api/journeys/entries/${entry.id}`), `delete entry "${title}"`)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Leave an entry with the photo the fixtures gave it and nothing else. That
|
||
* photo was uploaded first, so it has the lowest id; it goes back to the front
|
||
* as well, in case a guide made another one the cover.
|
||
*/
|
||
async function resetEntryPhotos(page: Page, title: string): Promise<void> {
|
||
const photos = [...((await journalEntry(page, title)).photos ?? [])].sort((a, b) => a.id - b.id)
|
||
for (const photo of photos.slice(1)) {
|
||
await answer(await page.request.delete(`/api/journeys/photos/${photo.id}`), `delete a photo of "${title}"`)
|
||
}
|
||
if (photos[0]) await answer(await page.request.patch(`/api/journeys/photos/${photos[0].id}`, { data: { sort_order: 0 } }), `put the first photo of "${title}" back in front`)
|
||
}
|
||
|
||
/** The fixtures give this entry no verdict; null is what an entry without one holds. */
|
||
async function clearVerdict(page: Page, title: string): Promise<void> {
|
||
const entry = await journalEntry(page, title)
|
||
await answer(await page.request.patch(`/api/journeys/entries/${entry.id}`, { data: { pros_cons: null } }), `clear the verdict of "${title}"`)
|
||
}
|
||
|
||
async function removeContributor(page: Page, username: string): Promise<void> {
|
||
const { contributors } = await answer<{ contributors: { user_id: number; username: string; role: string }[] }>(
|
||
await page.request.get(`/api/journeys/${journeyId}`), 'read the journey',
|
||
)
|
||
for (const c of contributors.filter(x => x.username === username && x.role !== 'owner')) {
|
||
await answer(await page.request.delete(`/api/journeys/${journeyId}/contributors/${c.user_id}`), `remove ${username}`)
|
||
}
|
||
}
|
||
|
||
async function deleteShareLink(page: Page): Promise<void> {
|
||
// Answered with success whether or not a link exists.
|
||
await answer(await page.request.delete(`/api/journeys/${journeyId}/share-link`), 'delete the share link')
|
||
}
|
||
|
||
async function unlinkTrip(page: Page, title: string): Promise<void> {
|
||
const trip = await tripId(page, title)
|
||
const res = await page.request.delete(`/api/journeys/${journeyId}/trips/${trip}`)
|
||
// The owner is answered with success whether or not the trip was linked.
|
||
await answer(res, `unlink "${title}"`)
|
||
}
|
||
|
||
/** The fixtures leave every entry published; null is not a value the column takes, false is. */
|
||
async function clearDraft(page: Page, title: string): Promise<void> {
|
||
const entry = await journalEntry(page, title)
|
||
await answer(await page.request.patch(`/api/journeys/entries/${entry.id}`, { data: { is_draft: false } }), `publish "${title}" again`)
|
||
}
|
||
|
||
async function setJourney(page: Page, data: Record<string, boolean>): Promise<void> {
|
||
await answer(await page.request.patch(`/api/journeys/${journeyId}`, { data }), `update the journey with ${JSON.stringify(data)}`)
|
||
}
|
||
|
||
// ── The map and a recorded day ────────────────────────────────────────────────
|
||
|
||
/** The frosted switches at the top of the map: Plan, Recorded, Entries, Photos and the key. */
|
||
const layerBar = (page: Page) => page.getByRole('group', { name: 'Map layers' })
|
||
|
||
/**
|
||
* A hollow plan pin inside the map's frame. The map keeps the pins it drew
|
||
* outside the view in the DOM as well, and the plan pins carry no class of
|
||
* their own, only their 22 × 28 drawing, so the one in view is marked for the
|
||
* step and found by the mark.
|
||
*/
|
||
const PIN_MARK = 'data-help-plan-pin'
|
||
/** A pin of either map engine: Leaflet's, or the vector map's. */
|
||
const MARKERS = ':is(.leaflet-marker-icon, .maplibregl-marker, .mapboxgl-marker)'
|
||
const planPin = (page: Page) => page.locator(`[${PIN_MARK}]`)
|
||
|
||
/**
|
||
* The plan pins lie below the entry pins on purpose, and in the Japan journey
|
||
* most planned places carry an entry on the same spot, so a pin is only taken
|
||
* when its own centre is what a click there would hit. The layer is also drawn
|
||
* anew on every moveend, which drops a mark set while the first fit still
|
||
* moves the map, so the pin must be found at the same place twice in a row.
|
||
*/
|
||
async function markPlanPin(page: Page): Promise<void> {
|
||
// Zoomed out over the whole trip, the plan pins gather into discs and the one
|
||
// left alone sits under an entry pin; each zoom step pulls them apart.
|
||
for (let zoom = 0; zoom < 2; zoom++) {
|
||
if (await findPlanPin(page, 6_000).then(() => true, () => false)) return
|
||
// The map's own + in its corner; it carries no name, only the sign.
|
||
await mapPanel(page).getByRole('button', { name: '+', exact: true }).click()
|
||
await page.waitForTimeout(800)
|
||
}
|
||
await findPlanPin(page, 20_000)
|
||
}
|
||
|
||
/** Mark a plan pin that lies well inside the frame and that nothing covers, once the map holds still. */
|
||
async function findPlanPin(page: Page, timeout: number): Promise<void> {
|
||
let last = ''
|
||
await expect
|
||
.poll(
|
||
async () => {
|
||
const found = await mapPanel(page).evaluate((aside, { attr, markers }) => {
|
||
for (const old of Array.from(document.querySelectorAll(`[${attr}]`))) old.removeAttribute(attr)
|
||
const frame = aside.getBoundingClientRect()
|
||
const pins = Array.from(aside.querySelectorAll(markers)).filter(el => el.querySelector('svg[viewBox="0 0 22 28"]'))
|
||
for (const el of pins) {
|
||
const r = el.getBoundingClientRect()
|
||
// Clear of the layer bar at the top and the edges, so the card it opens has room.
|
||
if (!(r.left > frame.left + 80 && r.right < frame.right - 80 && r.top > frame.top + 140 && r.bottom < frame.bottom - 80)) continue
|
||
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
|
||
if (!hit || !el.contains(hit)) continue
|
||
el.setAttribute(attr, '')
|
||
return `${Math.round(r.left)}:${Math.round(r.top)}`
|
||
}
|
||
return ''
|
||
}, { attr: PIN_MARK, markers: MARKERS })
|
||
const steady = found !== '' && found === last
|
||
last = found
|
||
return steady
|
||
},
|
||
{ timeout, intervals: [400], message: 'no plan pin inside the journey map that an entry pin does not cover' },
|
||
)
|
||
.toBe(true)
|
||
}
|
||
|
||
/** One day of the timeline, by the date it carries, and its header row. */
|
||
const dayBlock = (page: Page, date = STAY_DAY) => page.locator(`[data-day="${date}"]`)
|
||
const dayHeader = (page: Page, date = STAY_DAY) => dayBlock(page, date).locator(':scope > div').first()
|
||
/** The folded row of the day's stays and the button that opens it. */
|
||
const stayRowToggle = (page: Page) => dayBlock(page).getByRole('button', { name: /stays? from Dawarich/ })
|
||
/** The dialog a recorded day opens in: the shared DialogShell, titled with the day. */
|
||
const dayDialog = (page: Page, button: RegExp | string) =>
|
||
page.getByRole('dialog').filter({ has: page.getByRole('button', { name: button }) }).last()
|
||
const recordingDialog = (page: Page) => dayDialog(page, 'Keep route')
|
||
const SUBMIT = /^Take over \d+ drafts?$/
|
||
const importDialog = (page: Page) => dayDialog(page, SUBMIT)
|
||
|
||
/** The entries a recorded-day guide found, so its cleanup deletes only what it wrote. */
|
||
let entriesBefore = new Set<number>()
|
||
|
||
/** Dawarich connected with the stays waiting, then the journal scrolled to the stay day. */
|
||
async function openRecordedDay(page: Page): Promise<void> {
|
||
await connectJournalDawarich(page.request, journeyId)
|
||
entriesBefore = await writtenEntryIds(page.request, journeyId)
|
||
await openJournal(page)
|
||
await expect(dayBlock(page)).toBeVisible({ timeout: 20_000 })
|
||
await expect(dayBlock(page).getByTestId('journey-day-recording')).toBeVisible({ timeout: 20_000 })
|
||
await dayHeader(page).evaluate(el => el.scrollIntoView({ block: 'start' }))
|
||
await settle(page)
|
||
}
|
||
|
||
const releaseRecordedDay = (page: Page) => releaseJournalDawarich(page.request, journeyId, entriesBefore)
|
||
|
||
// ── Studio ────────────────────────────────────────────────────────────────────
|
||
|
||
const rail = (page: Page) => page.locator('.st-rail')
|
||
const railButton = (page: Page, name: string) => rail(page).getByRole('tab', { name, exact: true })
|
||
const sidePanel = (page: Page) => page.locator('.st-panel.st-side')
|
||
const inspector = (page: Page) => page.locator('.st-inspector')
|
||
const studioMenu = (page: Page) => page.locator('.st-menu').last()
|
||
/** The export dialog: the shared DialogShell over Studio, titled Export. */
|
||
const exportDialog = (page: Page) => page.getByRole('dialog', { name: 'Export' })
|
||
const thumbs = (page: Page) => page.locator('.st-thumb-row')
|
||
/** The hit targets over the spread, one per element. */
|
||
const sheetElements = (page: Page) => page.locator('.st-sheet > div[style*="left:"]')
|
||
|
||
async function studioSection(page: Page, name: string): Promise<void> {
|
||
await railButton(page, name).click()
|
||
await expect(sidePanel(page)).toBeVisible()
|
||
await settle(page)
|
||
}
|
||
|
||
async function relayoutBook(page: Page): Promise<void> {
|
||
await page.getByRole('button', { name: 'Auto layout' }).click()
|
||
await studioMenu(page).getByText('The whole book').click()
|
||
await expect.poll(async () => thumbs(page).count(), { timeout: 30_000 }).toBeGreaterThan(6)
|
||
await settle(page)
|
||
await page.waitForTimeout(800)
|
||
}
|
||
|
||
/**
|
||
* Studio over the journal. A journey with no book yet opens on an empty one,
|
||
* so the first visit lays the book out; every guide then finds pages to work
|
||
* on, and the overview picture shows a book rather than blank covers.
|
||
*/
|
||
async function openStudio(page: Page): Promise<void> {
|
||
await page.goto(`/journey/${journeyId}/studio`)
|
||
await clearNotices(page)
|
||
await dismissReleaseNotice(page)
|
||
await expect(page.locator('.st-bar')).toBeVisible({ timeout: 30_000 })
|
||
await expect(rail(page)).toBeVisible({ timeout: 30_000 })
|
||
await studioSection(page, 'Pages')
|
||
// An untouched book is cover, first page, one empty spread, last page and back: five thumbnails.
|
||
if ((await thumbs(page).count()) < 7) await relayoutBook(page)
|
||
await settle(page)
|
||
await page.waitForTimeout(800)
|
||
}
|
||
|
||
const SCRIPTS: Record<string, GuideScript> = {
|
||
// ── /journey ────────────────────────────────────────────────────────────
|
||
'create-journey': {
|
||
guide: guide('create-journey'),
|
||
// The ticked trip needs places on its days, or the counter reads 0 and the
|
||
// new journal opens empty, which is the opposite of what the result says.
|
||
start: async p => {
|
||
await deleteJourneysNamed(p, NEW_JOURNEY)
|
||
await addTripStops(p, LISBON_TRIP, LISBON_STOPS)
|
||
await openJourneys(p)
|
||
},
|
||
cleanup: async p => {
|
||
await deleteJourneysNamed(p, NEW_JOURNEY)
|
||
await removeTripStops(p, LISBON_TRIP, LISBON_STOPS)
|
||
},
|
||
steps: [
|
||
{
|
||
target: createCard,
|
||
act: async p => {
|
||
await createCard(p).click()
|
||
await expect(dialog(p)).toBeVisible()
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
target: dialogCard,
|
||
act: async p => {
|
||
await typeInto(p, dialog(p).locator('input').first(), NEW_JOURNEY)
|
||
await typeInto(p, dialog(p).locator('input').nth(1), 'Two days of tiles and custard tarts')
|
||
// By accessible name, not by a substring: a copy of the trip made by
|
||
// another screen's guide is also "Weekend in Lisbon something".
|
||
await dialog(p).getByRole('checkbox', { name: /^Weekend in Lisbon \d+ days/ }).click()
|
||
await beat(p, 400)
|
||
},
|
||
},
|
||
{
|
||
target: p => dialog(p).getByRole('button', { name: 'Create Journey' }),
|
||
act: async p => {
|
||
await dialog(p).getByRole('button', { name: 'Create Journey' }).click()
|
||
await expect(p).toHaveURL(/\/journey\/\d+/, { timeout: 15_000 })
|
||
await expect(p.getByRole('button', { name: 'Add Entry' }).first()).toBeVisible({ timeout: 20_000 })
|
||
// The trip's places, now suggestions on their days, are what the result is about.
|
||
await expect(namedSuggestion(p, LISBON_STOPS[0].name)).toBeVisible({ timeout: 20_000 })
|
||
await blurFocus(p)
|
||
await settle(p)
|
||
await p.waitForTimeout(600)
|
||
},
|
||
},
|
||
],
|
||
},
|
||
|
||
'open-journey': {
|
||
guide: guide('open-journey'),
|
||
start: openJourneys,
|
||
steps: [
|
||
{
|
||
target: p => journeyCard(p, SECOND_JOURNEY.title),
|
||
},
|
||
],
|
||
},
|
||
|
||
'continue-writing': {
|
||
guide: guide('continue-writing'),
|
||
start: openJourneys,
|
||
steps: [
|
||
{
|
||
target: p => p.getByText('Continue writing').first(),
|
||
},
|
||
],
|
||
},
|
||
|
||
// ── /journey/:id ────────────────────────────────────────────────────────
|
||
'add-entry': {
|
||
guide: guide('add-entry'),
|
||
start: async p => {
|
||
await deleteEntriesNamed(p, NEW_ENTRY)
|
||
await openJournal(p)
|
||
},
|
||
cleanup: p => deleteEntriesNamed(p, NEW_ENTRY),
|
||
steps: [
|
||
{
|
||
target: p => p.getByRole('button', { name: 'Add Entry' }).first(),
|
||
act: openEditor,
|
||
},
|
||
{
|
||
// The name is typed into the head band. Ringing the band keeps the story box and
|
||
// its Markdown toolbar in the frame below it; ringing the story box cuts the band
|
||
// off, and a ring round the name field alone runs through the eyebrow above it.
|
||
target: p => editor(p).locator('header'),
|
||
act: async p => {
|
||
await typeInto(p, editor(p).getByPlaceholder('Give this moment a name...'), NEW_ENTRY)
|
||
await typeInto(
|
||
p,
|
||
editor(p).getByPlaceholder('Write your story...'),
|
||
'Lanterns along the Kamo, grilled sweetfish on sticks and a jazz trio nobody had booked. We stayed until the last stall packed up.',
|
||
)
|
||
await beat(p, 400)
|
||
},
|
||
},
|
||
{
|
||
// The editor's right column: date, location, mood and weather, which is
|
||
// everything the step names (the verdict sits at its top).
|
||
target: p => editor(p).getByRole('group', { name: 'Mood' }).locator('xpath=../..'),
|
||
act: async p => {
|
||
await editor(p).getByRole('button', { name: 'Amazing' }).click()
|
||
await editor(p).getByRole('button', { name: 'Partly cloudy' }).click()
|
||
await beat(p, 400)
|
||
},
|
||
},
|
||
{
|
||
target: p => editor(p).getByRole('button', { name: 'Save', exact: true }),
|
||
act: async p => {
|
||
await editor(p).getByRole('button', { name: 'Save', exact: true }).click()
|
||
await expect(editor(p)).toHaveCount(0, { timeout: 15_000 })
|
||
await expect(entryCard(p, NEW_ENTRY)).toBeVisible()
|
||
// It lands on today, the last day, far below the top of the timeline
|
||
// where the journal stays after the editor closes.
|
||
await entryCard(p, NEW_ENTRY).evaluate(el => el.scrollIntoView({ block: 'center' }))
|
||
await blurFocus(p)
|
||
await settle(p)
|
||
},
|
||
},
|
||
],
|
||
},
|
||
|
||
// Make 1st is offered on a saved photo that is not first yet; files just
|
||
// picked wait for Save and have none. So the entry is one that already holds
|
||
// two photos (the fixtures' one and a second put there at the start), the
|
||
// upload adds a third, and step 3 shows the second with Make 1st on it.
|
||
'entry-photos': {
|
||
guide: guide('entry-photos'),
|
||
start: async p => {
|
||
await resetEntryPhotos(p, ASAKUSA)
|
||
const entry = await journalEntry(p, ASAKUSA)
|
||
const grove = await filesImageFixture('arashiyama')
|
||
await answer(await p.request.post(`/api/journeys/entries/${entry.id}/photos`, {
|
||
multipart: { photos: { name: 'morning-light.jpg', mimeType: 'image/jpeg', buffer: readFileSync(grove) } },
|
||
}), `add a second photo to "${ASAKUSA}"`)
|
||
await openJournal(p)
|
||
},
|
||
cleanup: p => resetEntryPhotos(p, ASAKUSA),
|
||
steps: [
|
||
{
|
||
target: p => entryMenu(p, ASAKUSA),
|
||
act: p => editEntry(p, ASAKUSA),
|
||
},
|
||
{
|
||
target: p => editor(p).getByRole('button', { name: 'Upload photos' }),
|
||
act: async p => {
|
||
const before = await photoTiles(p).count()
|
||
await editor(p).locator('input[type="file"][multiple]').setInputFiles(await filesImageFixture('jr-pass-map'))
|
||
await expect(photoTiles(p)).toHaveCount(before + 1, { timeout: 15_000 })
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
// The second saved photo; the target is hovered for the picture, which shows its Make 1st.
|
||
target: p => photoTiles(p).nth(1),
|
||
act: async p => {
|
||
await photoTiles(p).nth(1).getByRole('button', { name: 'Make 1st' }).click()
|
||
await beat(p, 400)
|
||
await editor(p).getByRole('button', { name: 'Save', exact: true }).click()
|
||
await expect(editor(p)).toHaveCount(0, { timeout: 30_000 })
|
||
await expect(entryCard(p, ASAKUSA).locator('img').first()).toBeVisible({ timeout: 15_000 })
|
||
await blurFocus(p)
|
||
await settle(p)
|
||
},
|
||
},
|
||
],
|
||
},
|
||
|
||
'entry-draft': {
|
||
guide: guide('entry-draft'),
|
||
start: async p => {
|
||
await clearDraft(p, ASAKUSA)
|
||
await openJournal(p)
|
||
},
|
||
cleanup: p => clearDraft(p, ASAKUSA),
|
||
steps: [
|
||
{
|
||
prepare: async p => {
|
||
await editEntry(p, ASAKUSA)
|
||
await editor(p).getByRole('button', { name: 'Draft', exact: true }).scrollIntoViewIfNeeded()
|
||
await settle(p)
|
||
},
|
||
// The row with the label and its hint, the switch at its end.
|
||
target: p => editor(p).getByRole('button', { name: 'Draft', exact: true }).locator('xpath=..'),
|
||
hover: p => editor(p).getByRole('button', { name: 'Draft', exact: true }).hover(),
|
||
act: async p => {
|
||
const toggle = editor(p).getByRole('button', { name: 'Draft', exact: true })
|
||
await toggle.click()
|
||
await expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||
await beat(p, 400)
|
||
},
|
||
},
|
||
{
|
||
target: p => editor(p).getByRole('button', { name: 'Save', exact: true }),
|
||
act: async p => {
|
||
await editor(p).getByRole('button', { name: 'Save', exact: true }).click()
|
||
await expect(editor(p)).toHaveCount(0, { timeout: 15_000 })
|
||
const badge = entryCard(p, ASAKUSA).getByText('Draft', { exact: true })
|
||
await expect(badge).toBeVisible({ timeout: 15_000 })
|
||
await entryCard(p, ASAKUSA).evaluate(el => el.scrollIntoView({ block: 'center' }))
|
||
await blurFocus(p)
|
||
await settle(p)
|
||
},
|
||
},
|
||
],
|
||
},
|
||
|
||
suggestions: {
|
||
guide: guide('suggestions'),
|
||
start: openJournal,
|
||
steps: [
|
||
{
|
||
target: suggestion,
|
||
},
|
||
{
|
||
target: p => suggestion(p).getByRole('button', { name: 'Dismiss this suggestion' }),
|
||
act: async p => {
|
||
const before = await p.getByRole('button', { name: 'Dismiss this suggestion' }).count()
|
||
await suggestion(p).getByRole('button', { name: 'Dismiss this suggestion' }).click()
|
||
// A place kept across two days is two suggestions, so one click can take two away.
|
||
await expect.poll(() => p.getByRole('button', { name: 'Dismiss this suggestion' }).count()).toBeLessThan(before)
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
prepare: async p => {
|
||
await openSettings(p)
|
||
await dialog(p).getByText('Bring back dismissed suggestions').scrollIntoViewIfNeeded()
|
||
},
|
||
target: p => dialog(p).getByText('Bring back dismissed suggestions').locator('xpath=../..'),
|
||
act: async p => {
|
||
await dialog(p).getByRole('button', { name: 'Bring back dismissed suggestions' }).click()
|
||
await beat(p, 500)
|
||
await closeDialog(p)
|
||
await blurFocus(p)
|
||
},
|
||
},
|
||
],
|
||
},
|
||
|
||
'add-on-day': {
|
||
guide: guide('add-on-day'),
|
||
start: openJournal,
|
||
steps: [
|
||
{
|
||
target: p => p.getByRole('button', { name: 'Add an entry on this day' }).nth(1),
|
||
act: async p => {
|
||
await p.getByRole('button', { name: 'Add an entry on this day' }).nth(1).click()
|
||
await expect(editor(p)).toBeVisible()
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
target: p => editor(p).getByText('Date', { exact: true }).locator('xpath=..'),
|
||
act: cancelEditor,
|
||
},
|
||
],
|
||
},
|
||
|
||
'pros-cons': {
|
||
guide: guide('pros-cons'),
|
||
start: async p => {
|
||
await clearVerdict(p, ASAKUSA)
|
||
await openJournal(p)
|
||
},
|
||
cleanup: p => clearVerdict(p, ASAKUSA),
|
||
steps: [
|
||
{
|
||
prepare: async p => {
|
||
await editEntry(p, ASAKUSA)
|
||
await editor(p).getByText('Pros & Cons').scrollIntoViewIfNeeded()
|
||
await settle(p)
|
||
},
|
||
target: p => editor(p).getByText('Pros & Cons').locator('xpath=../..'),
|
||
act: async p => {
|
||
// Two pros through Add another, as the step says, and one con: two short lists.
|
||
await typeInto(p, editor(p).getByPlaceholder('Something great...').last(), 'Empty courtyard at seven')
|
||
await editor(p).getByRole('button', { name: 'Add another' }).first().click()
|
||
await typeInto(p, editor(p).getByPlaceholder('Something great...').last(), 'Soba as the counter opened')
|
||
await typeInto(p, editor(p).getByPlaceholder('Not so great...').last(), 'Coffee only after nine')
|
||
await beat(p, 400)
|
||
},
|
||
},
|
||
{
|
||
target: p => editor(p).getByRole('button', { name: 'Save', exact: true }),
|
||
act: async p => {
|
||
await editor(p).getByRole('button', { name: 'Save', exact: true }).click()
|
||
await expect(editor(p)).toHaveCount(0, { timeout: 15_000 })
|
||
await expect.poll(async () => {
|
||
const res = await p.request.get(`/api/journeys/${journeyId}/entries`)
|
||
const { entries } = (await res.json()) as { entries: { title: string | null; pros_cons?: { pros?: string[] } | null }[] }
|
||
return entries.find(e => e.title === ASAKUSA)?.pros_cons?.pros ?? []
|
||
}).toContain('Empty courtyard at seven')
|
||
// The card folds the verdict behind Show more; the result is about the verdict.
|
||
await entryCard(p, ASAKUSA).getByRole('button', { name: 'Show more' }).click()
|
||
const verdict = entryCard(p, ASAKUSA).getByText('Empty courtyard at seven')
|
||
await expect(verdict).toBeVisible()
|
||
await verdict.evaluate(el => el.scrollIntoView({ block: 'center' }))
|
||
await blurFocus(p)
|
||
await settle(p)
|
||
},
|
||
},
|
||
],
|
||
},
|
||
|
||
'search-journey': {
|
||
guide: guide('search-journey'),
|
||
start: openJournal,
|
||
steps: [
|
||
{
|
||
// The pill round the field, which carries the magnifier and the border.
|
||
target: p => p.getByPlaceholder('Search this journey').locator('xpath=..'),
|
||
act: async p => {
|
||
await typeInto(p, p.getByPlaceholder('Search this journey'), 'crossing')
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
// The day count between the two round buttons at the foot of the timeline.
|
||
prepare: async p => {
|
||
await p.getByPlaceholder('Search this journey').fill('')
|
||
await settle(p)
|
||
},
|
||
target: p => p.getByRole('button', { name: 'Jump to a day' }),
|
||
act: async p => {
|
||
await p.getByRole('button', { name: 'Jump to a day' }).click()
|
||
await expect(p.getByRole('menu')).toBeVisible()
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
// The first day opened onto its entries, so the picture shows both levels.
|
||
prepare: async p => {
|
||
await p.getByRole('menu').getByRole('button', { name: 'Expand' }).first().click()
|
||
await settle(p)
|
||
},
|
||
target: p => p.getByRole('menu'),
|
||
hover: p => p.getByRole('menu').getByRole('menuitem').nth(1).hover(),
|
||
act: async p => {
|
||
await p.getByRole('menu').getByRole('menuitem').nth(1).click()
|
||
await expect(p.getByRole('menu')).toHaveCount(0)
|
||
await p.waitForTimeout(800) // the smooth scroll
|
||
await settle(p)
|
||
},
|
||
},
|
||
],
|
||
},
|
||
|
||
'gallery-map': {
|
||
guide: guide('gallery-map'),
|
||
start: openJournal,
|
||
steps: [
|
||
{
|
||
target: p => p.getByRole('button', { name: 'Gallery' }).first(),
|
||
act: async p => {
|
||
await p.getByRole('button', { name: 'Gallery' }).first().click()
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
// Upload and, with Immich or Synology connected, a button per library, at the right of the toolbar.
|
||
target: p => p.getByRole('button', { name: 'Upload', exact: true }).first(),
|
||
},
|
||
{
|
||
prepare: async p => {
|
||
await p.getByRole('button', { name: 'Timeline', exact: true }).first().click()
|
||
await settle(p)
|
||
await p.waitForTimeout(600) // the map tiles
|
||
},
|
||
target: mapPanel,
|
||
},
|
||
],
|
||
},
|
||
|
||
'journey-map-layers': {
|
||
guide: guide('journey-map-layers'),
|
||
start: openJournal,
|
||
steps: [
|
||
{
|
||
target: layerBar,
|
||
// Plan off and on again: the switch is remembered in this browser, and the next guide finds it on.
|
||
act: async p => {
|
||
const plan = p.getByTestId('journey-map-layer-plan')
|
||
await plan.click()
|
||
await expect(plan).toHaveAttribute('aria-pressed', 'false')
|
||
await beat(p, 600)
|
||
await plan.click()
|
||
await expect(plan).toHaveAttribute('aria-pressed', 'true')
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
prepare: async p => {
|
||
await p.getByTestId('journey-map-key-button').click()
|
||
await expect(p.getByTestId('journey-map-key')).toBeVisible()
|
||
await settle(p)
|
||
},
|
||
target: p => p.getByTestId('journey-map-key'),
|
||
hover: p => p.getByTestId('journey-map-key-button').hover(),
|
||
act: async p => {
|
||
await p.getByTestId('journey-map-key-button').click()
|
||
await expect(p.getByTestId('journey-map-key')).toHaveCount(0)
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
prepare: markPlanPin,
|
||
target: planPin,
|
||
act: async p => {
|
||
await planPin(p).click()
|
||
await expect(mapPanel(p).getByText('Planned', { exact: true }).first()).toBeVisible({ timeout: 10_000 })
|
||
await settle(p)
|
||
},
|
||
},
|
||
],
|
||
},
|
||
|
||
// ── A recorded day (Dawarich) ───────────────────────────────────────────
|
||
'journey-keep-route': {
|
||
guide: guide('journey-keep-route'),
|
||
start: openRecordedDay,
|
||
cleanup: releaseRecordedDay,
|
||
steps: [
|
||
{
|
||
target: p => dayBlock(p).getByTestId('journey-day-recording'),
|
||
act: async p => {
|
||
await dayBlock(p).getByTestId('journey-day-recording').click()
|
||
// The dialog reads the day from Dawarich first; the button waits for the preview.
|
||
await expect(recordingDialog(p).getByRole('button', { name: 'Keep route' })).toBeEnabled({ timeout: 60_000 })
|
||
await settle(p)
|
||
await p.waitForTimeout(600) // the sketch's tiles
|
||
},
|
||
},
|
||
{
|
||
target: recordingDialog,
|
||
},
|
||
{
|
||
target: p => recordingDialog(p).getByRole('button', { name: 'Keep route' }),
|
||
act: async p => {
|
||
await recordingDialog(p).getByRole('button', { name: 'Keep route' }).click()
|
||
// Saved, the dialog closes; should it stay, its button reads Already kept and the X closes it.
|
||
const kept = p.getByRole('button', { name: 'Already kept' })
|
||
await expect.poll(async () => (await p.getByRole('button', { name: 'Keep route' }).count()) === 0 || (await kept.count()) > 0, { timeout: 30_000 }).toBe(true)
|
||
if (await anyDialog(p).count()) await closeDialog(p)
|
||
// A kept day shows its distance in the header instead of the Dawarich mark.
|
||
await expect(dayBlock(p).getByTestId('journey-day-recording')).toContainText(/\d/, { timeout: 15_000 })
|
||
await dayHeader(p).evaluate(el => el.scrollIntoView({ block: 'center' }))
|
||
await blurFocus(p)
|
||
await settle(p)
|
||
await p.waitForTimeout(600) // the map draws the route
|
||
},
|
||
},
|
||
],
|
||
},
|
||
|
||
'journey-day-stays': {
|
||
guide: guide('journey-day-stays'),
|
||
start: async p => {
|
||
await openRecordedDay(p)
|
||
await expect(stayRowToggle(p)).toBeVisible({ timeout: 20_000 })
|
||
await stayRowToggle(p).evaluate(el => el.scrollIntoView({ block: 'center' }))
|
||
await settle(p)
|
||
},
|
||
cleanup: releaseRecordedDay,
|
||
steps: [
|
||
{
|
||
target: stayRowToggle,
|
||
act: async p => {
|
||
await stayRowToggle(p).click()
|
||
await expect(stayRowToggle(p)).toHaveAttribute('aria-expanded', 'true')
|
||
await expect(dayBlock(p).getByRole('button', { name: 'Write a journal entry' }).first()).toBeVisible()
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
target: p => dayBlock(p).getByRole('button', { name: 'Write a journal entry' }).first(),
|
||
act: async p => {
|
||
const before = (await writtenEntryIds(p.request, journeyId)).size
|
||
await dayBlock(p).getByRole('button', { name: 'Write a journal entry' }).first().click()
|
||
await expect.poll(async () => (await writtenEntryIds(p.request, journeyId)).size, { timeout: 15_000 }).toBeGreaterThan(before)
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
target: p => dayBlock(p).getByRole('button', { name: 'Not a place I visited' }).first(),
|
||
act: async p => {
|
||
const rows = dayBlock(p).getByRole('button', { name: 'Not a place I visited' })
|
||
const before = await rows.count()
|
||
await rows.first().click()
|
||
await expect.poll(() => rows.count(), { timeout: 15_000 }).toBeLessThan(before)
|
||
await blurFocus(p)
|
||
await settle(p)
|
||
},
|
||
},
|
||
],
|
||
},
|
||
|
||
// Nothing is written: Take over turns the stays into drafts for good, and a
|
||
// stay taken over is never offered again, so a second run would find none.
|
||
'journey-take-over-day': {
|
||
guide: guide('journey-take-over-day'),
|
||
start: async p => {
|
||
await openRecordedDay(p)
|
||
await expect(stayRowToggle(p)).toBeVisible({ timeout: 20_000 })
|
||
await stayRowToggle(p).evaluate(el => el.scrollIntoView({ block: 'center' }))
|
||
await settle(p)
|
||
},
|
||
cleanup: releaseRecordedDay,
|
||
steps: [
|
||
{
|
||
target: p => dayBlock(p).getByRole('button', { name: 'Take over day', exact: true }),
|
||
act: async p => {
|
||
await dayBlock(p).getByRole('button', { name: 'Take over day', exact: true }).click()
|
||
await expect(importDialog(p).locator('[data-stay-id]').first()).toBeVisible({ timeout: 60_000 })
|
||
await settle(p)
|
||
await p.waitForTimeout(600) // the day's map
|
||
},
|
||
},
|
||
{
|
||
prepare: async p => {
|
||
await importDialog(p).locator('[data-stay-id]').first().scrollIntoViewIfNeeded()
|
||
await markGroup(p, importDialog(p).locator('[data-stay-id]'))
|
||
},
|
||
target: groupMark,
|
||
hover: p => importDialog(p).locator('[data-stay-id] input').first().hover(),
|
||
act: async p => {
|
||
await unmarkGroup(p)
|
||
await typeInto(p, importDialog(p).locator('[data-stay-id] input').first(), 'Morning on the bridge')
|
||
await beat(p, 400)
|
||
},
|
||
},
|
||
{
|
||
target: p => importDialog(p).getByRole('group', { name: 'Stays' }),
|
||
act: async p => {
|
||
await importDialog(p).getByRole('group', { name: 'Stays' }).getByRole('button', { name: 'Day report' }).click()
|
||
await expect(importDialog(p).getByRole('button', { name: /^Take over 1 draft$/ })).toBeVisible()
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
prepare: async p => {
|
||
await importDialog(p).getByRole('button', { name: SUBMIT }).scrollIntoViewIfNeeded()
|
||
await settle(p)
|
||
},
|
||
target: p => importDialog(p).getByRole('button', { name: SUBMIT }),
|
||
act: async p => {
|
||
await importDialog(p).getByRole('button', { name: 'Cancel', exact: true }).click()
|
||
await expect(importDialog(p)).toHaveCount(0)
|
||
await settle(p)
|
||
},
|
||
},
|
||
],
|
||
},
|
||
|
||
'journey-auto-prepare': {
|
||
guide: guide('journey-auto-prepare'),
|
||
start: async p => {
|
||
await connectJournalDawarich(p.request, journeyId)
|
||
entriesBefore = await writtenEntryIds(p.request, journeyId)
|
||
await openJournal(p)
|
||
},
|
||
cleanup: releaseRecordedDay,
|
||
steps: [
|
||
{ target: settingsButton, act: openSettings },
|
||
{
|
||
prepare: async p => {
|
||
await dialog(p).getByText('Recorded days', { exact: true }).scrollIntoViewIfNeeded()
|
||
await settle(p)
|
||
},
|
||
// The label sits in a row of its own, so the section with its switch is two levels up.
|
||
target: p => dialog(p).getByText('Recorded days', { exact: true }).locator('xpath=../..'),
|
||
hover: p => dialog(p).getByRole('button', { name: 'Prepare recorded days automatically' }).hover(),
|
||
act: async p => {
|
||
const toggle = dialog(p).getByRole('button', { name: 'Prepare recorded days automatically' })
|
||
await toggle.click()
|
||
await expect(toggle).toHaveAttribute('aria-pressed', 'true', { timeout: 15_000 })
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
// Switching on fetches at once; the line says when, with Fetch now beside it.
|
||
prepare: async p => {
|
||
await expect(dialog(p).getByTestId('journey-prep-status')).toBeVisible({ timeout: 30_000 })
|
||
// Fetch now shows only while a linked trip has days left to prepare; the fetch it starts first has to end.
|
||
await expect(dialog(p).getByRole('button', { name: 'Fetching…' })).toHaveCount(0, { timeout: 60_000 })
|
||
await settle(p)
|
||
},
|
||
target: p => dialog(p).getByTestId('journey-prep-status'),
|
||
act: closeDialog,
|
||
},
|
||
],
|
||
},
|
||
|
||
'photo-location': {
|
||
guide: guide('photo-location'),
|
||
start: async p => {
|
||
await setJourney(p, { photo_location: false })
|
||
await openJournal(p)
|
||
},
|
||
cleanup: p => setJourney(p, { photo_location: false }),
|
||
steps: [
|
||
{ target: settingsButton, act: openSettings },
|
||
{
|
||
prepare: async p => {
|
||
await dialog(p).getByRole('button', { name: 'Place entries from their photos' }).scrollIntoViewIfNeeded()
|
||
await settle(p)
|
||
},
|
||
// The row with the label, its hint and the switch.
|
||
target: p => dialog(p).getByRole('button', { name: 'Place entries from their photos' }).locator('xpath=..'),
|
||
hover: p => dialog(p).getByRole('button', { name: 'Place entries from their photos' }).hover(),
|
||
act: async p => {
|
||
const toggle = dialog(p).getByRole('button', { name: 'Place entries from their photos' })
|
||
await toggle.click()
|
||
await expect(toggle).toHaveAttribute('aria-pressed', 'true', { timeout: 15_000 })
|
||
await beat(p, 400)
|
||
await closeDialog(p)
|
||
},
|
||
},
|
||
],
|
||
},
|
||
|
||
'entry-fields': {
|
||
guide: guide('entry-fields'),
|
||
start: openJournal,
|
||
steps: [
|
||
{
|
||
target: settingsButton,
|
||
act: openSettings,
|
||
},
|
||
{
|
||
prepare: async p => { await dialog(p).getByText('Entry fields', { exact: true }).scrollIntoViewIfNeeded() },
|
||
// The label sits in a row of its own, so the section with its switches is two levels up.
|
||
target: p => dialog(p).getByText('Entry fields', { exact: true }).locator('xpath=../..'),
|
||
act: closeDialog,
|
||
},
|
||
],
|
||
},
|
||
|
||
'journey-status': {
|
||
guide: guide('journey-status'),
|
||
start: openJournal,
|
||
steps: [
|
||
{ target: settingsButton, act: openSettings },
|
||
{
|
||
prepare: async p => {
|
||
const status = dialog(p).getByRole('group', { name: 'Status' })
|
||
await status.scrollIntoViewIfNeeded()
|
||
await status.getByRole('button', { name: 'Completed' }).click()
|
||
await expect(status.getByRole('button', { name: 'Completed' })).toHaveAttribute('aria-pressed', 'true')
|
||
await settle(p)
|
||
},
|
||
target: p => dialog(p).getByRole('group', { name: 'Status' }).locator('xpath=..'),
|
||
},
|
||
],
|
||
cleanup: async p => {
|
||
const status = dialog(p).getByRole('group', { name: 'Status' })
|
||
if (await status.count()) {
|
||
await status.getByRole('button', { name: 'Automatic' }).click()
|
||
await settle(p)
|
||
}
|
||
},
|
||
},
|
||
// The spare trip gets stations on its days first: a trip without places adds
|
||
// nothing to the journal, and the result would show no change at all.
|
||
'link-trip': {
|
||
guide: guide('link-trip'),
|
||
start: async p => {
|
||
await unlinkTrip(p, SPARE_TRIP.title)
|
||
await addTripStops(p, SPARE_TRIP.title, ALPS_STOPS)
|
||
await openJournal(p)
|
||
},
|
||
cleanup: async p => {
|
||
await unlinkTrip(p, SPARE_TRIP.title)
|
||
await removeTripStops(p, SPARE_TRIP.title, ALPS_STOPS)
|
||
},
|
||
steps: [
|
||
{
|
||
target: settingsButton,
|
||
act: openSettings,
|
||
},
|
||
{
|
||
prepare: async p => { await dialog(p).getByRole('button', { name: 'Add Trip' }).scrollIntoViewIfNeeded() },
|
||
target: p => dialog(p).getByRole('button', { name: 'Add Trip' }),
|
||
act: async p => {
|
||
await dialog(p).getByRole('button', { name: 'Add Trip' }).click()
|
||
await expect(dialog(p).getByPlaceholder('Trip name or destination...')).toBeVisible()
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
target: p => dialog(p).getByText(SPARE_TRIP.title).locator('xpath=../..'),
|
||
act: async p => {
|
||
await dialog(p).getByText(SPARE_TRIP.title).locator('xpath=../..').getByRole('button', { name: 'Link' }).click()
|
||
await beat(p, 600)
|
||
// Linking closes the picker and the settings under it once the server has
|
||
// answered; waiting for that keeps the X from racing the request.
|
||
await expect(p.getByPlaceholder('Trip name or destination...')).toHaveCount(0, { timeout: 15_000 })
|
||
if (await anyDialog(p).count()) await closeDialog(p)
|
||
// Its days come months after the running trip, at the foot of the
|
||
// timeline, so the result is taken there, on the first new suggestion.
|
||
const first = namedSuggestion(p, ALPS_STOPS[0].name)
|
||
await expect(first).toBeVisible({ timeout: 15_000 })
|
||
await first.evaluate(el => el.scrollIntoView({ block: 'center' }))
|
||
await blurFocus(p)
|
||
await settle(p)
|
||
await p.waitForTimeout(600) // the map follows the scroll
|
||
},
|
||
},
|
||
],
|
||
},
|
||
|
||
// The link is deleted again afterwards: left in place, it shows up in every
|
||
// later picture of the settings, archive-journey's among them.
|
||
'share-public': {
|
||
guide: guide('share-public'),
|
||
start: async p => {
|
||
await deleteShareLink(p)
|
||
await openJournal(p)
|
||
},
|
||
cleanup: deleteShareLink,
|
||
steps: [
|
||
{
|
||
prepare: async p => {
|
||
await openSettings(p)
|
||
await dialog(p).getByText('Public Share').scrollIntoViewIfNeeded()
|
||
},
|
||
target: p => dialog(p).getByText('Public Share').locator('xpath=..'),
|
||
},
|
||
{
|
||
target: p => dialog(p).getByRole('button', { name: 'Create share link' }),
|
||
act: async p => {
|
||
await dialog(p).getByRole('button', { name: 'Create share link' }).click()
|
||
await expect(dialog(p).getByRole('button', { name: 'Copy' })).toBeVisible({ timeout: 15_000 })
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
target: p => dialog(p).getByText('Public Share').locator('xpath=..'),
|
||
act: closeDialog,
|
||
},
|
||
],
|
||
},
|
||
|
||
contributors: {
|
||
guide: guide('contributors'),
|
||
start: async p => {
|
||
await removeContributor(p, 'jonas')
|
||
await openJournal(p)
|
||
},
|
||
cleanup: p => removeContributor(p, 'jonas'),
|
||
steps: [
|
||
{
|
||
prepare: async p => {
|
||
await openSettings(p)
|
||
await dialog(p).getByRole('button', { name: 'Invite Contributor' }).scrollIntoViewIfNeeded()
|
||
},
|
||
// The button is the last thing in the Contributors section, so its parent is the whole section.
|
||
target: p => dialog(p).getByRole('button', { name: 'Invite Contributor' }).locator('xpath=..'),
|
||
},
|
||
{
|
||
target: p => dialog(p).getByRole('button', { name: 'Invite Contributor' }),
|
||
act: async p => {
|
||
await dialog(p).getByRole('button', { name: 'Invite Contributor' }).click()
|
||
const search = p.getByPlaceholder('Username or email...')
|
||
await expect(search).toBeVisible()
|
||
await typeInto(p, search, 'jonas')
|
||
await dialog(p).locator('button').filter({ hasText: 'jonas' }).first().click()
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
// The role switch at the left of the footer, with Invite beside it in the
|
||
// frame. Not the footer itself: it spans the panel edge to edge, and a ring
|
||
// round it runs past the panel's rounded corners.
|
||
target: p => dialog(p).getByRole('group', { name: 'Role' }),
|
||
hover: p => dialog(p).getByRole('group', { name: 'Role' }).getByRole('button', { name: 'Editor' }).hover(),
|
||
act: async p => {
|
||
await dialog(p).getByRole('group', { name: 'Role' }).getByRole('button', { name: 'Editor' }).click()
|
||
await dialog(p).getByRole('button', { name: 'Invite', exact: true }).click()
|
||
// The invite lists jonas as well, so the settings are checked only once it has closed.
|
||
await expect(p.getByPlaceholder('Username or email...')).toHaveCount(0, { timeout: 15_000 })
|
||
await expect(dialog(p).getByText('jonas')).toBeVisible({ timeout: 15_000 })
|
||
await beat(p, 500)
|
||
await closeDialog(p)
|
||
},
|
||
},
|
||
],
|
||
},
|
||
|
||
studio: {
|
||
guide: guide('studio'),
|
||
start: openJournal,
|
||
steps: [
|
||
{
|
||
target: p => p.getByRole('button', { name: 'Studio', exact: true }).first(),
|
||
act: async p => {
|
||
await p.getByRole('button', { name: 'Studio', exact: true }).first().click()
|
||
await expect(p).toHaveURL(/\/studio/, { timeout: 15_000 })
|
||
await expect(p.locator('.st-bar')).toBeVisible({ timeout: 30_000 })
|
||
await settle(p)
|
||
await p.waitForTimeout(1200)
|
||
},
|
||
},
|
||
{
|
||
target: p => p.locator('.st-head-close'),
|
||
},
|
||
],
|
||
},
|
||
|
||
'archive-journey': {
|
||
guide: guide('archive-journey'),
|
||
start: openJournal,
|
||
steps: [
|
||
{
|
||
target: settingsButton,
|
||
act: openSettings,
|
||
},
|
||
{
|
||
// Delete and Archive Journey, at the left of the footer. The footer as a
|
||
// whole spans the panel edge to edge and its ring would run past the
|
||
// panel's corners, so the two are marked as a group.
|
||
prepare: p => markGroup(p, dialogCard(p).locator('footer').getByRole('button', { name: /^(Delete|Archive Journey|Restore Journey)$/ })),
|
||
target: groupMark,
|
||
// The pointer waits in the empty middle of the footer: on either button it
|
||
// brings up a tooltip right where the step's number goes.
|
||
hover: async p => {
|
||
const footer = await dialogCard(p).locator('footer').boundingBox()
|
||
if (footer) await p.mouse.move(footer.x + footer.width / 2, footer.y + footer.height / 2)
|
||
},
|
||
act: async p => {
|
||
await unmarkGroup(p)
|
||
await closeDialog(p)
|
||
},
|
||
},
|
||
],
|
||
},
|
||
|
||
// ── /journey/:id/studio ─────────────────────────────────────────────────
|
||
'studio-auto-layout': {
|
||
guide: guide('studio-auto-layout'),
|
||
start: openStudio,
|
||
steps: [
|
||
{
|
||
target: p => p.getByRole('button', { name: 'Auto layout' }),
|
||
act: async p => {
|
||
await p.getByRole('button', { name: 'Auto layout' }).click()
|
||
await expect(studioMenu(p)).toBeVisible()
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
target: studioMenu,
|
||
act: async p => {
|
||
await studioMenu(p).getByText('The whole book').click()
|
||
await expect.poll(async () => thumbs(p).count(), { timeout: 30_000 }).toBeGreaterThan(6)
|
||
await settle(p)
|
||
await p.waitForTimeout(800)
|
||
},
|
||
},
|
||
{
|
||
target: p => p.getByRole('button', { name: 'Undo' }),
|
||
},
|
||
],
|
||
},
|
||
|
||
'studio-pages': {
|
||
guide: guide('studio-pages'),
|
||
start: openStudio,
|
||
steps: [
|
||
{
|
||
target: p => railButton(p, 'Pages'),
|
||
act: async p => { await studioSection(p, 'Pages') },
|
||
},
|
||
{
|
||
target: p => sidePanel(p).getByRole('button', { name: 'Add spread' }),
|
||
act: async p => {
|
||
const before = await thumbs(p).count()
|
||
await sidePanel(p).getByRole('button', { name: 'Add spread' }).click()
|
||
await expect(thumbs(p)).toHaveCount(before + 1)
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
prepare: async p => {
|
||
await thumbs(p).nth(2).scrollIntoViewIfNeeded()
|
||
await thumbs(p).nth(2).hover()
|
||
},
|
||
target: p => thumbs(p).nth(2),
|
||
},
|
||
],
|
||
},
|
||
|
||
'studio-layouts': {
|
||
guide: guide('studio-layouts'),
|
||
start: openStudio,
|
||
steps: [
|
||
{
|
||
prepare: async p => {
|
||
await studioSection(p, 'Pages')
|
||
await thumbs(p).nth(2).locator('.st-thumb').click()
|
||
await settle(p)
|
||
},
|
||
target: p => railButton(p, 'Layouts'),
|
||
act: async p => { await studioSection(p, 'Layouts') },
|
||
},
|
||
{
|
||
target: p => sidePanel(p).locator('.st-tile, .st-thumb').nth(3),
|
||
act: async p => {
|
||
await sidePanel(p).locator('.st-tile, .st-thumb').nth(3).click()
|
||
await settle(p)
|
||
await p.waitForTimeout(600)
|
||
},
|
||
},
|
||
],
|
||
},
|
||
|
||
'studio-content': {
|
||
guide: guide('studio-content'),
|
||
start: openStudio,
|
||
steps: [
|
||
{
|
||
target: p => railButton(p, 'Content'),
|
||
act: async p => { await studioSection(p, 'Content') },
|
||
},
|
||
{
|
||
target: p => sidePanel(p).locator('.st-photo-grid').first(),
|
||
act: async p => {
|
||
const add = sidePanel(p).locator('button[title="Add to this page"]').first()
|
||
await add.hover()
|
||
await add.click()
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
prepare: async p => {
|
||
await sidePanel(p).getByRole('tab', { name: /Entries/ }).click()
|
||
await expect(sidePanel(p).locator('.st-entry').first()).toBeVisible()
|
||
await settle(p)
|
||
},
|
||
target: p => sidePanel(p).locator('.st-entry').first(),
|
||
act: async p => {
|
||
await sidePanel(p).locator('.st-entry').first().getByRole('button', { name: 'Title', exact: true }).click()
|
||
await settle(p)
|
||
},
|
||
},
|
||
],
|
||
},
|
||
|
||
'studio-elements': {
|
||
guide: guide('studio-elements'),
|
||
start: openStudio,
|
||
steps: [
|
||
{
|
||
target: p => railButton(p, 'Elements'),
|
||
act: async p => { await studioSection(p, 'Elements') },
|
||
},
|
||
{
|
||
target: sidePanel,
|
||
},
|
||
],
|
||
},
|
||
|
||
'studio-travel': {
|
||
guide: guide('studio-travel'),
|
||
start: openStudio,
|
||
steps: [
|
||
{
|
||
target: p => railButton(p, 'Travel'),
|
||
act: async p => { await studioSection(p, 'Travel') },
|
||
},
|
||
{
|
||
target: sidePanel,
|
||
},
|
||
],
|
||
},
|
||
|
||
'studio-properties': {
|
||
guide: guide('studio-properties'),
|
||
start: openStudio,
|
||
steps: [
|
||
{
|
||
prepare: async p => {
|
||
await studioSection(p, 'Pages')
|
||
await thumbs(p).nth(2).locator('.st-thumb').click()
|
||
await settle(p)
|
||
await expect(sheetElements(p).first()).toBeVisible()
|
||
},
|
||
target: p => sheetElements(p).first(),
|
||
act: async p => {
|
||
await sheetElements(p).first().click()
|
||
await expect(inspector(p).getByRole('button', { name: 'Delete' })).toBeVisible()
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
target: inspector,
|
||
},
|
||
{
|
||
target: p => inspector(p).getByRole('button', { name: 'Delete' }).locator('xpath=..'),
|
||
},
|
||
],
|
||
},
|
||
|
||
'studio-format': {
|
||
guide: guide('studio-format'),
|
||
start: openStudio,
|
||
steps: [
|
||
{
|
||
target: p => p.getByRole('button', { name: 'Page format' }),
|
||
act: async p => {
|
||
await p.getByRole('button', { name: 'Page format' }).click()
|
||
await expect(studioMenu(p)).toBeVisible()
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
target: studioMenu,
|
||
act: async p => {
|
||
await p.keyboard.press('Escape')
|
||
if (await studioMenu(p).isVisible()) await p.mouse.click(VIEWPORT.width / 2, VIEWPORT.height / 2)
|
||
},
|
||
},
|
||
],
|
||
},
|
||
|
||
'studio-export': {
|
||
guide: guide('studio-export'),
|
||
start: openStudio,
|
||
steps: [
|
||
{
|
||
target: p => p.getByRole('button', { name: 'Export' }),
|
||
act: async p => {
|
||
await p.getByRole('button', { name: 'Export' }).click()
|
||
await expect(exportDialog(p)).toBeVisible()
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
// Single pages, Spreads and Crop marks: three pressable rows in two
|
||
// sections, marked as one group with the sections' labels, so the
|
||
// step's number sits above LAYOUT instead of on it. The pointer rests
|
||
// on the chosen row, which has no hover look, so no row looks picked
|
||
// that is not.
|
||
prepare: p => markGroup(p, exportDialog(p).locator('section')),
|
||
target: groupMark,
|
||
hover: p => exportDialog(p).getByRole('button', { name: /^Single pages/ }).hover(),
|
||
act: unmarkGroup,
|
||
},
|
||
{
|
||
target: p => exportDialog(p).getByRole('button', { name: 'Print view' }),
|
||
act: async p => {
|
||
// Print view would open the browser's print window; the X closes the dialog instead.
|
||
await exportDialog(p).getByRole('button', { name: 'Close', exact: true }).click()
|
||
await expect(exportDialog(p)).toHaveCount(0)
|
||
},
|
||
},
|
||
],
|
||
},
|
||
|
||
'studio-spread-file': {
|
||
guide: guide('studio-spread-file'),
|
||
start: openStudio,
|
||
steps: [
|
||
{
|
||
target: p => p.getByRole('button', { name: 'Download this spread' }),
|
||
},
|
||
{
|
||
prepare: async p => { await studioSection(p, 'Pages') },
|
||
target: p => sidePanel(p).getByRole('button', { name: 'Import', exact: true }),
|
||
},
|
||
],
|
||
},
|
||
}
|
||
|
||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||
|
||
test.describe.configure({ mode: 'serial' })
|
||
|
||
test.beforeAll(async ({ request }) => {
|
||
await ensureExtraTrips(request)
|
||
journeyId = await ensureJourneyFixtures(request)
|
||
})
|
||
|
||
test('every registered journey guide has a script, and only those', async () => {
|
||
expect(Object.keys(SCRIPTS).sort()).toEqual([...journeyContext.guides, ...journalContext.guides, ...studioContext.guides].sort())
|
||
})
|
||
|
||
test('hero: journey', async ({ page }) => {
|
||
await page.setViewportSize(VIEWPORT)
|
||
await captureHero(page, journeyContext.id, openJourneys)
|
||
})
|
||
|
||
test('hero: journey-detail', async ({ page }) => {
|
||
await page.setViewportSize(VIEWPORT)
|
||
await captureHero(page, journalContext.id, openJournal)
|
||
})
|
||
|
||
test('hero: journey-studio', async ({ page }) => {
|
||
await page.setViewportSize(VIEWPORT)
|
||
await captureHero(page, studioContext.id, openStudio)
|
||
})
|
||
|
||
for (const id of [...journeyContext.guides, ...journalContext.guides, ...studioContext.guides]) {
|
||
test(`pictures: ${id}`, async ({ page }) => {
|
||
await page.setViewportSize(VIEWPORT)
|
||
await captureGuide(page, SCRIPTS[id])
|
||
})
|
||
}
|