docs(help): new pictures for Collections, Vacay, Atlas and Journey
Every guide of the four screens is recorded again with the new dialogs. The guide scripts follow the new markup, and a review of the pictures fixed what they taught wrong: the Kyoto list holds Kyoto places with their photos, the invite and share steps show the picked user, the shared calendar step shows one shared with you, the Dawarich and wishlist steps frame the whole dialog, and the create journey guide links a trip with places.
@@ -42,10 +42,17 @@ const searchBox = (page: Page) => searchInput(page).locator('xpath=ancestor::div
|
||||
const countryHit = (page: Page, name: string) => searchBox(page).getByRole('button', { name: new RegExp(`^[A-Z]{2} ${name}$`) })
|
||||
/** A geocoded place in the dropdown, under the Places heading. */
|
||||
const placeHit = (page: Page, text: string) => searchBox(page).getByRole('button', { name: new RegExp(text) }).first()
|
||||
/** The country / region popup card. */
|
||||
const popup = (page: Page) => page.locator('div[role="presentation"] > div[role="presentation"]')
|
||||
/**
|
||||
* The country / region popup: the panel of the shared dialog frame, inside
|
||||
* the backdrop `modal` finds. The Dawarich dialog uses the same frame, but the
|
||||
* two are never open at once.
|
||||
*/
|
||||
const popup = (page: Page) => modal(page).getByRole('dialog')
|
||||
/** The glass panel at the bottom. */
|
||||
const panel = (page: Page) => page.getByRole('button', { name: 'Stats' }).locator('xpath=ancestor::div[2]')
|
||||
/** The continent counts in the Stats tab, and the streak with this year's trips right of them. */
|
||||
const continentStats = (page: Page) => panel(page).locator('div.flex.items-center.gap-4').filter({ hasText: 'Europe' }).first()
|
||||
const highlightStats = (page: Page) => panel(page).locator('div.flex.items-center.gap-5').first()
|
||||
/** The detail card the panel grows for a country picked in the search. */
|
||||
const detailCard = (page: Page, country: string) =>
|
||||
panel(page).locator('p.text-sm.font-bold').filter({ hasText: country }).locator('xpath=../..')
|
||||
@@ -84,13 +91,21 @@ async function pickCountry(page: Page, name: string): Promise<void> {
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/** Close the country popup by clicking beside it (there is no Escape). */
|
||||
/**
|
||||
* Close the country popup with the button in its head band. Not with Escape:
|
||||
* after a key press the browser draws its focus ring on the next popup, which
|
||||
* takes the focus as it opens, and that ring would be in the picture.
|
||||
*/
|
||||
async function closePopup(page: Page): Promise<void> {
|
||||
await page.mouse.click(40, VIEWPORT.height - 40)
|
||||
await popup(page).getByRole('button', { name: 'Close', exact: true }).click()
|
||||
await expect(popup(page)).toHaveCount(0)
|
||||
}
|
||||
|
||||
/** The next unset select in a scope: month and year both read “—” until picked. */
|
||||
/**
|
||||
* The next unset select of the Add place form in the Bucket List tab: month
|
||||
* and year both read a long dash until picked. The popup's bucket step labels
|
||||
* its two selects instead, so there they are found by Month and Year.
|
||||
*/
|
||||
const unsetSelect = (scope: Locator) => scope.getByRole('button', { name: '—', exact: true }).first()
|
||||
|
||||
/** Pick an option in one of the month / year selects. */
|
||||
@@ -136,6 +151,63 @@ async function clickMapCentre(page: Page): Promise<void> {
|
||||
await page.mouse.click(c.x, c.y)
|
||||
}
|
||||
|
||||
type Box = { x: number; y: number; width: number; height: number }
|
||||
|
||||
/**
|
||||
* An invisible box laid over several elements, for a step whose text names
|
||||
* things that have no parent of their own to ring. The runner rings whatever
|
||||
* box the target has, so this gives it one. It takes no pointer events, so the
|
||||
* hover and the click the step makes go to the app underneath, and a step
|
||||
* that uses it needs its own `hover` for the same reason.
|
||||
*/
|
||||
async function spanOver(page: Page, boxes: Box[]): Promise<void> {
|
||||
await page.evaluate(boxes => {
|
||||
document.getElementById('trek-help-span')?.remove()
|
||||
const left = Math.min(...boxes.map(b => b.x))
|
||||
const top = Math.min(...boxes.map(b => b.y))
|
||||
const right = Math.max(...boxes.map(b => b.x + b.width))
|
||||
const bottom = Math.max(...boxes.map(b => b.y + b.height))
|
||||
const el = document.createElement('div')
|
||||
el.id = 'trek-help-span'
|
||||
Object.assign(el.style, {
|
||||
position: 'fixed',
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${right - left}px`,
|
||||
height: `${bottom - top}px`,
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
document.body.appendChild(el)
|
||||
}, boxes)
|
||||
}
|
||||
const span = (page: Page) => page.locator('#trek-help-span')
|
||||
async function clearSpan(page: Page): Promise<void> {
|
||||
await page.evaluate(() => document.getElementById('trek-help-span')?.remove())
|
||||
}
|
||||
|
||||
async function boxOf(locator: Locator): Promise<Box> {
|
||||
const box = await locator.boundingBox()
|
||||
if (!box) throw new Error(`no box for ${locator}`)
|
||||
return box
|
||||
}
|
||||
|
||||
/**
|
||||
* The name the Atlas shows beside the pointer over a region. It is one fixed
|
||||
* div the map moves after the pointer, with no role or name of its own; the
|
||||
* country under the region name is how it is told apart.
|
||||
*/
|
||||
async function regionTooltipBox(page: Page, country: string): Promise<Box> {
|
||||
const box = await page.evaluate(country => {
|
||||
const tip = Array.from(document.querySelectorAll('div')).find(d =>
|
||||
d.style.position === 'fixed' && d.style.pointerEvents === 'none' && d.style.display === 'block' && (d.textContent ?? '').includes(country))
|
||||
if (!tip) return null
|
||||
const r = tip.getBoundingClientRect()
|
||||
return { x: r.x, y: r.y, width: r.width, height: r.height }
|
||||
}, country)
|
||||
if (!box) throw new Error(`no region tooltip naming ${country} on screen`)
|
||||
return box
|
||||
}
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'mark-country': {
|
||||
guide: guide('mark-country'),
|
||||
@@ -199,6 +271,13 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
act: async p => {
|
||||
await pickCountry(p, 'Japan')
|
||||
await expect(detailCard(p, 'Japan')).toBeVisible()
|
||||
// The step promises the card's flag, which is a Twemoji image from a
|
||||
// CDN. Waited for here so the result shows the flag rather than an
|
||||
// empty square; should the CDN be out of reach, the card falls back to
|
||||
// two letters and the image never loads, which fails this step.
|
||||
const flag = detailCard(p, 'Japan').locator('img[alt="JP"]')
|
||||
await expect(flag).toBeVisible({ timeout: 15_000 })
|
||||
await expect.poll(() => flag.evaluate(img => (img as HTMLImageElement).complete && (img as HTMLImageElement).naturalWidth > 0), { timeout: 15_000 }).toBe(true)
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -243,9 +322,18 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
target: centreRegion,
|
||||
// The text says hovering names the region. That name floats right of
|
||||
// the pointer and reaches past the region's own box, so a ring round
|
||||
// the region alone ran straight through it. The ring goes round both.
|
||||
prepare: async p => {
|
||||
await hoverMapCentre(p)
|
||||
await settle(p)
|
||||
await spanOver(p, [await boxOf(centreRegion(p)), await regionTooltipBox(p, 'Austria')])
|
||||
},
|
||||
target: span,
|
||||
hover: hoverMapCentre,
|
||||
act: async p => {
|
||||
await clearSpan(p)
|
||||
await clickMapCentre(p)
|
||||
await expect(popup(p).getByRole('button', { name: /Mark as visited/ })).toBeVisible()
|
||||
},
|
||||
@@ -314,9 +402,10 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await choose(p, unsetSelect(popup(p)), 'June')
|
||||
await choose(p, unsetSelect(popup(p)), String(year(1)))
|
||||
await choose(p, popup(p).getByLabel('Month', { exact: true }), 'June')
|
||||
await choose(p, popup(p).getByLabel('Year', { exact: true }), String(year(1)))
|
||||
},
|
||||
// The whole popup: the step names both selects and the button under them.
|
||||
target: p => popup(p),
|
||||
act: async p => {
|
||||
await popup(p).getByRole('button', { name: 'Add to bucket list', exact: true }).click()
|
||||
@@ -342,7 +431,9 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
act: async p => { await p.getByRole('button', { name: 'Add place' }).click() },
|
||||
},
|
||||
{
|
||||
target: p => p.getByPlaceholder('Name (country, city, place...)'),
|
||||
// The row, not the field alone: the text has the reader press the
|
||||
// search button beside it as well.
|
||||
target: p => p.getByPlaceholder('Name (country, city, place...)').locator('xpath=..'),
|
||||
act: async p => {
|
||||
const input = p.getByPlaceholder('Name (country, city, place...)')
|
||||
await typeInto(p, input, 'Petra')
|
||||
@@ -359,7 +450,10 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
await choose(p, unsetSelect(panel(p)), 'Sep')
|
||||
await choose(p, unsetSelect(panel(p)), String(year(1)))
|
||||
},
|
||||
target: p => p.getByRole('button', { name: 'Add', exact: true }),
|
||||
// The whole form: framed on the small Add button alone, the picture lost
|
||||
// the name and the month, and looked like Add on an empty form.
|
||||
target: p => p.getByRole('button', { name: 'Add', exact: true }).locator('xpath=../..'),
|
||||
hover: p => p.getByRole('button', { name: 'Add', exact: true }).hover(),
|
||||
act: async p => {
|
||||
await p.getByRole('button', { name: 'Add', exact: true }).click()
|
||||
await expect(panel(p).getByText('Petra').first()).toBeVisible()
|
||||
@@ -374,7 +468,17 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
start: openAtlas,
|
||||
steps: [
|
||||
{ target: p => panel(p).locator('.rounded-xl').filter({ hasText: 'Countries' }).first() },
|
||||
{ target: p => panel(p).locator('.flex.items-center.gap-4').first() },
|
||||
{
|
||||
// The text covers the continents, the streak and this year's trips.
|
||||
// Those are two groups side by side with no box of their own around
|
||||
// both, so the ring gets one laid over them.
|
||||
prepare: async p => {
|
||||
await spanOver(p, [await boxOf(continentStats(p)), await boxOf(highlightStats(p))])
|
||||
},
|
||||
target: span,
|
||||
hover: p => continentStats(p).hover(),
|
||||
act: clearSpan,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -394,7 +498,11 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Look for countries' }),
|
||||
// The pitch with its button rather than the button alone: a frame
|
||||
// centred on the button cut off the dialog's head band, and with it the
|
||||
// name of the service this dialog asks.
|
||||
target: p => modal(p).getByRole('button', { name: 'Look for countries' }).locator('xpath=..'),
|
||||
hover: p => modal(p).getByRole('button', { name: 'Look for countries' }).hover(),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Look for countries' }).click()
|
||||
// A year in thirteen 30-day reads, one after another, each of which
|
||||
@@ -408,11 +516,18 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: /^Add \d+ countries$/ }),
|
||||
// The whole dialog: the list step 2 describes (flag, cities, ticked
|
||||
// rows) is only ever pictured here, and a frame centred on the button
|
||||
// cut the rows in half. The button sits in the footer, in the ring too.
|
||||
target: p => modal(p).getByRole('dialog'),
|
||||
hover: p => modal(p).getByRole('button', { name: /^Add \d+ countries$/ }).hover(),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: /^Add \d+ countries$/ }).click()
|
||||
await expect(p.getByText(/\d+ countries added/)).toBeVisible({ timeout: 20_000 })
|
||||
await p.keyboard.press('Escape')
|
||||
// Closed with the button in its head band, not Escape: the focus goes
|
||||
// back to the Countries tile, and after a key press the browser draws
|
||||
// its focus ring there, into the result picture.
|
||||
await modal(p).getByRole('button', { name: 'Close', exact: true }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
// The Atlas re-reads itself after a write; the colours land with the answer.
|
||||
await expect.poll(async () => (await visitedCodes(p)).size, { timeout: 20_000 }).toBeGreaterThan(visitedBefore.size)
|
||||
@@ -445,7 +560,10 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Check wishlist' }),
|
||||
// As in the countries guide: the pitch with its button, so the head band
|
||||
// stays in the frame.
|
||||
target: p => modal(p).getByRole('button', { name: 'Check wishlist' }).locator('xpath=..'),
|
||||
hover: p => modal(p).getByRole('button', { name: 'Check wishlist' }).hover(),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Check wishlist' }).click()
|
||||
// One read per wish with coordinates (Fushimi Inari, Wadi Rum, Petra),
|
||||
@@ -456,7 +574,10 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: /^Tick off \d+$/ }),
|
||||
// The whole dialog, as in the countries guide: the rows with distance,
|
||||
// stay and day and the note with the 250 m rule are what step 2 quotes.
|
||||
target: p => modal(p).getByRole('dialog'),
|
||||
hover: p => modal(p).getByRole('button', { name: /^Tick off \d+$/ }).hover(),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: /^Tick off \d+$/ }).click()
|
||||
await expect(p.getByText(/\d+ wishes ticked off/)).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||||
import { test, expect, type Page, type Locator, type Request } from '@playwright/test'
|
||||
import path from 'node:path'
|
||||
import { clearNotices } from '../screenshots/shot'
|
||||
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, OUT_DIR, type GuideScript } from './guide'
|
||||
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, OUT_DIR, PICTURE_DAY, type GuideScript } from './guide'
|
||||
import { ensureExtraTrips } from './fixtures'
|
||||
import { collectionsGuides, collectionsContext } from '../../src/help/contexts/collections'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
@@ -9,13 +9,34 @@ import type { HelpGuide } from '../../src/help/types'
|
||||
/**
|
||||
* Actions for the Collections guides, keyed by the ids in
|
||||
* `src/help/contexts/collections.ts`. The seed leaves one list, "Kyoto
|
||||
* shortlist", with four of the Japan trip's places on it; the guides add a
|
||||
* second list, more places, a label, a member and a file round trip, in the
|
||||
* order the context lists them, and leave it all in place.
|
||||
* shortlist", with the Japan trip's three Kyoto places on it; the guides add a
|
||||
* second list, a Kyoto place found by search, three more Kyoto temples taken
|
||||
* from the trip, a label, a member and a file round trip, in the order the
|
||||
* context lists them, and leave the list side of it in place. Whatever they put
|
||||
* into a trip comes out again in that guide's cleanup.
|
||||
*/
|
||||
|
||||
const KYOTO = 'Kyoto shortlist'
|
||||
const LISBON = 'Lisbon coffee'
|
||||
const JAPAN = 'Autumn in Japan'
|
||||
|
||||
/**
|
||||
* Kyoto places the import guide puts into the Japan trip first. The seed's trip
|
||||
* holds Tokyo and the three Kyoto places the list already has, so an import from
|
||||
* it could only add Tokyo to a list called "Kyoto shortlist", and every later
|
||||
* picture would carry that. None of these is on a day, which is also what the
|
||||
* import's third step says starts out selected.
|
||||
*/
|
||||
const KYOTO_EXTRAS = [
|
||||
{ name: 'Kinkaku-ji Temple', lat: 35.0394, lng: 135.7292, address: '1 Kinkakujicho, Kita Ward, Kyoto',
|
||||
description: 'The Golden Pavilion, mirrored in its pond.' },
|
||||
{ name: 'Tenryu-ji Temple', lat: 35.0158, lng: 135.6737, address: '68 Sagatenryuji Susukinobabacho, Ukyo Ward, Kyoto',
|
||||
description: 'Zen garden next to the bamboo grove.' },
|
||||
{ name: 'Kiyomizu-dera Temple', lat: 34.9949, lng: 135.785, address: '1-294 Kiyomizu, Higashiyama Ward, Kyoto',
|
||||
description: 'The wooden stage over the maples.' },
|
||||
]
|
||||
/** The two places in Arashiyama, which the labels guide groups under that name. */
|
||||
const ARASHIYAMA_PLACES = ['Arashiyama Bamboo Grove', 'Tenryu-ji Temple']
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = collectionsGuides.find(x => x.id === id)
|
||||
@@ -28,28 +49,215 @@ const listRow = (page: Page, name: string) => rail(page).locator('button.col-row
|
||||
const hero = (page: Page) => page.locator('.col-hero')
|
||||
const filterBar = (page: Page) => page.locator('.col-filterbar')
|
||||
const modal = (page: Page) => page.locator('.trek-modal-backdrop').last()
|
||||
/** The card inside the backdrop, for framing a whole dialog. */
|
||||
const modalCard = (page: Page) => modal(page).locator('> [role="presentation"]').first()
|
||||
/** The rounded panel inside the backdrop, for framing a whole dialog. */
|
||||
const modalCard = (page: Page) => modal(page).locator('> [role="dialog"]').first()
|
||||
/** A trip's tile on the first step of the import from a trip. */
|
||||
const importTrip = (page: Page, title: string) => modal(page).getByRole('button').filter({ hasText: title })
|
||||
/**
|
||||
* The trip tiles on the first step of that import, and the rows of a trip's
|
||||
* places on its second step: each is the dialog's only staggered block while it
|
||||
* is on screen.
|
||||
*/
|
||||
const importStagger = (page: Page) => modal(page).locator('.trek-stagger')
|
||||
const placeRow = (page: Page, name: string) => page.locator('.col-lrow').filter({ hasText: name }).first()
|
||||
const placeRows = (page: Page) => page.locator('.col-lrow')
|
||||
const selectionBar = (page: Page) => page.locator('.col-selbar')
|
||||
/** The detail sheet beside the list. */
|
||||
const detail = (page: Page) => page.locator('.col-detail').first()
|
||||
/** The map beside the list, with its floating controls. */
|
||||
const mapShell = (page: Page) => page.locator('.col-map-shell')
|
||||
/** The map's search pill: the magnifier and the field together. */
|
||||
const mapSearch = (page: Page) => mapShell(page).locator('.col-map-search').first()
|
||||
|
||||
// ── Trip data over the API ────────────────────────────────────────────────────
|
||||
|
||||
async function tripIdOf(page: Page, title: string): Promise<number> {
|
||||
const res = await page.request.get('/api/trips')
|
||||
const body = (await res.json()) as { trips?: { id: number; title: string }[] } | { id: number; title: string }[]
|
||||
const trips = Array.isArray(body) ? body : (body.trips ?? [])
|
||||
const trip = trips.find(t => t.title === title)
|
||||
if (!trip) throw new Error(`trip "${title}" is missing`)
|
||||
return trip.id
|
||||
}
|
||||
|
||||
async function tripPlaces(page: Page, tripId: number): Promise<{ id: number; name: string }[]> {
|
||||
const res = await page.request.get(`/api/trips/${tripId}/places`)
|
||||
if (!res.ok()) throw new Error(`GET places of trip ${tripId}: ${res.status()}`)
|
||||
const body = (await res.json()) as { places?: { id: number; name: string }[] } | { id: number; name: string }[]
|
||||
return Array.isArray(body) ? body : (body.places ?? [])
|
||||
}
|
||||
|
||||
async function deleteTripPlaces(page: Page, tripId: number, ids: number[]): Promise<void> {
|
||||
for (const id of ids) {
|
||||
const res = await page.request.delete(`/api/trips/${tripId}/places/${id}`)
|
||||
if (!res.ok() && res.status() !== 404) throw new Error(`DELETE place ${id} of trip ${tripId}: ${res.status()}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** The places the import guide put into the Japan trip, removed again in its cleanup. */
|
||||
let addedToTrip: number[] = []
|
||||
/** The Japan trip's places before the copy guide ran; everything else is the copy's. */
|
||||
let placesBeforeCopy: Set<number> | null = null
|
||||
|
||||
// ── Waiting for the page to look finished ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Take focus off whatever holds it. A focused field or a button that got focus
|
||||
* back from a closed dialog draws an outline in the accent, which in a picture
|
||||
* reads as a second step ring.
|
||||
*/
|
||||
async function blurFocus(page: Page): Promise<void> {
|
||||
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur?.())
|
||||
}
|
||||
|
||||
/**
|
||||
* Let a Leaflet camera move that is under way arrive.
|
||||
*
|
||||
* Leaflet times a pan from `+new Date()`, and the browser's clock is pinned to
|
||||
* the picture day (guide.ts), so a pan started for a picture never gets past its
|
||||
* first frame: the map stays where it was and the place the text says it pans
|
||||
* to is not in view. Moving the fixed time on for a moment lets the running
|
||||
* animation see that its time is up and finish, exactly where it would have
|
||||
* ended; the clock goes back to the picture day straight after.
|
||||
*/
|
||||
async function finishMapMotion(page: Page): Promise<void> {
|
||||
await page.clock.setFixedTime(new Date(PICTURE_DAY.getTime() + 10_000))
|
||||
await page.evaluate(() => new Promise(res => requestAnimationFrame(() => requestAnimationFrame(res))))
|
||||
await page.clock.setFixedTime(PICTURE_DAY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the map has stopped moving and shows its pins in the part of it
|
||||
* that is on screen: every pin, or with `every: false` at least one, for a map
|
||||
* that has just panned to one place and may have left others behind its edge.
|
||||
* Nothing to wait for where the list has no map.
|
||||
*/
|
||||
async function mapReady(page: Page, { every = true } = {}): Promise<void> {
|
||||
if (!(await mapShell(page).count())) return
|
||||
await finishMapMotion(page)
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
mapShell(page).evaluate((el, every) => {
|
||||
if (el.querySelector('.leaflet-zoom-anim, .leaflet-pan-anim, .leaflet-cluster-anim')) return 'moving'
|
||||
const map = el.getBoundingClientRect()
|
||||
const box = {
|
||||
left: Math.max(map.left, 0),
|
||||
top: Math.max(map.top, 0),
|
||||
right: Math.min(map.right, window.innerWidth),
|
||||
bottom: Math.min(map.bottom, window.innerHeight),
|
||||
}
|
||||
const icons = Array.from(el.querySelectorAll('.leaflet-marker-icon')).map(i => i.getBoundingClientRect())
|
||||
const inside = icons.filter(r => r.left >= box.left && r.right <= box.right && r.top >= box.top && r.bottom <= box.bottom).length
|
||||
if (!inside) return 'no pins in view'
|
||||
return every && inside < icons.length ? `${icons.length - inside} of ${icons.length} pins out of view` : 'ready'
|
||||
}, every, { timeout: 2_000 }), // short: a map on its way off the page is asked again, not waited for
|
||||
{ message: 'the map shows the pins of the list', timeout: 15_000 },
|
||||
)
|
||||
.toBe('ready')
|
||||
}
|
||||
|
||||
/**
|
||||
* Frame the map on the list that is open.
|
||||
*
|
||||
* The map opens on a camera worked out for the whole window, not for the pane it
|
||||
* sits in (MapView's opening frame), so on this page it starts about one zoom
|
||||
* level too close and every pin is off its edges. It frames the pane properly
|
||||
* whenever the number of places on it changes, so the search pill is used to
|
||||
* empty it and fill it again, which is what a reader who searches and clears the
|
||||
* search sees. The pill ends empty and the page as it was.
|
||||
*/
|
||||
async function frameMap(page: Page): Promise<void> {
|
||||
if (!(await mapShell(page).count())) return
|
||||
const field = mapSearch(page).locator('input')
|
||||
const rows = await placeRows(page).count()
|
||||
await field.fill('zzz')
|
||||
await expect(placeRows(page)).toHaveCount(0)
|
||||
await field.fill('')
|
||||
await expect(placeRows(page)).toHaveCount(rows)
|
||||
await blurFocus(page)
|
||||
await mapReady(page)
|
||||
}
|
||||
|
||||
/** Photo requests of each page still in flight: the lookups and the images they point at. */
|
||||
const photoRequests = new WeakMap<Page, Set<Request>>()
|
||||
|
||||
function trackPhotos(page: Page): void {
|
||||
if (photoRequests.has(page)) return
|
||||
const pending = new Set<Request>()
|
||||
photoRequests.set(page, pending)
|
||||
const isPhoto = (r: Request) => r.url().includes('/api/maps/place-photo/')
|
||||
page.on('request', r => { if (isPhoto(r)) pending.add(r) })
|
||||
page.on('requestfinished', r => pending.delete(r))
|
||||
page.on('requestfailed', r => pending.delete(r))
|
||||
// A cached photo whose file has gone answers with an empty 204, and the avatar
|
||||
// falls back to its pin. Say so in the log, because that is not the app.
|
||||
page.on('response', r => {
|
||||
if (isPhoto(r) && r.url().endsWith('/bytes') && r.status() === 204) console.warn(`[collections] empty place photo: ${r.url()}`)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the place photos. Each avatar looks its photo up first and loads the
|
||||
* image after that, so the lookups can be quiet for a moment while an image is
|
||||
* still to come; quiet has to hold for a while before it counts. Bounded, so a
|
||||
* lookup that hangs costs time, not the run.
|
||||
*/
|
||||
async function photosReady(page: Page): Promise<void> {
|
||||
const pending = photoRequests.get(page)
|
||||
if (pending) {
|
||||
const until = Date.now() + 30_000
|
||||
let quietSince = 0
|
||||
while (Date.now() < until) {
|
||||
if (pending.size) quietSince = 0
|
||||
else if (!quietSince) quietSince = Date.now()
|
||||
else if (Date.now() - quietSince > 800) break
|
||||
await page.waitForTimeout(100)
|
||||
}
|
||||
if (pending.size) console.warn(`[collections] place photos still loading: ${[...pending].map(r => r.url()).join(', ')}`)
|
||||
}
|
||||
await page
|
||||
.waitForFunction(() => Array.from(document.querySelectorAll<HTMLImageElement>('.col-lrow img')).every(img => img.complete), undefined, { timeout: 10_000 })
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
/** Map framed, pins drawn, photos in, nothing focused: a page ready for its picture. */
|
||||
async function pageReady(page: Page, { reframe = false, every = true } = {}): Promise<void> {
|
||||
if (reframe) await frameMap(page)
|
||||
else await mapReady(page, { every })
|
||||
await photosReady(page)
|
||||
await blurFocus(page)
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
async function openCollections(page: Page): Promise<void> {
|
||||
trackPhotos(page)
|
||||
await page.goto('/collections')
|
||||
await clearNotices(page)
|
||||
await dismissReleaseNotice(page)
|
||||
await expect(rail(page)).toBeVisible({ timeout: 20_000 })
|
||||
await expect(listRow(page, KYOTO)).toBeVisible({ timeout: 20_000 })
|
||||
await settle(page)
|
||||
// All saved, which this lands on, always has places, and the map comes with them.
|
||||
await expect(placeRows(page).first()).toBeVisible({ timeout: 20_000 })
|
||||
await pageReady(page, { reframe: true })
|
||||
}
|
||||
|
||||
async function openList(page: Page, name: string): Promise<void> {
|
||||
await openCollections(page)
|
||||
await listRow(page, name).click()
|
||||
await expect(hero(page)).toContainText(name)
|
||||
// Until its places are in, the page can still show the map of the list before.
|
||||
await settle(page)
|
||||
await expect(page.locator('.col-loading')).toHaveCount(0)
|
||||
await pageReady(page, { reframe: true })
|
||||
}
|
||||
|
||||
/** Turn on select mode and tick the named places. */
|
||||
async function selectPlaces(page: Page, names: string[]): Promise<void> {
|
||||
await filterBar(page).getByRole('button', { name: 'Select', exact: true }).click()
|
||||
await expect(selectionBar(page)).toBeVisible()
|
||||
for (const name of names) await placeRow(page, name).click()
|
||||
await expect(selectionBar(page)).toContainText(`${names.length} selected`)
|
||||
}
|
||||
|
||||
/** Pick an option in a CustomSelect that has a search box. */
|
||||
@@ -61,6 +269,10 @@ async function pickSearchable(page: Page, trigger: Locator, text: string): Promi
|
||||
await search.locator('xpath=../..').getByRole('button', { name: text, exact: true }).first().click()
|
||||
}
|
||||
|
||||
/** The place the add guide finds by search: not on the list yet, with a real street address. */
|
||||
const SEARCH_FOR = 'Nijo Castle'
|
||||
const FOUND = /Nij[oō]/
|
||||
|
||||
const SCRIPTS: Record<string, GuideScript> = {
|
||||
'create-list': {
|
||||
guide: guide('create-list'),
|
||||
@@ -81,6 +293,9 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
await modal(p).getByRole('button', { name: '#f97316' }).click()
|
||||
await typeInto(p, modal(p).getByPlaceholder('Add a description…'), 'Pastéis, miradouros and the best flat white in Alfama.')
|
||||
await beat(p, 400)
|
||||
// The description keeps focus after typing, and its focus border sits
|
||||
// right next to the ring on Create in the next picture.
|
||||
await blurFocus(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -89,7 +304,10 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
await modal(p).getByRole('button', { name: 'Create' }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await expect(hero(p)).toContainText(LISBON)
|
||||
await settle(p)
|
||||
// The new list is empty: its two ways to fill it replace the list and the map.
|
||||
await expect(p.locator('.col-cta').first()).toBeVisible()
|
||||
await expect(mapShell(p)).toHaveCount(0)
|
||||
await pageReady(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -108,15 +326,19 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByPlaceholder('Search for a place…'),
|
||||
// The whole pill, magnifier included, not the bare input inside it.
|
||||
target: p => modal(p).getByPlaceholder('Search for a place…').locator('xpath=..'),
|
||||
act: async p => {
|
||||
const search = modal(p).getByPlaceholder('Search for a place…')
|
||||
await typeInto(p, search, 'Fushimi Inari')
|
||||
await typeInto(p, search, SEARCH_FOR)
|
||||
await modal(p).getByRole('button', { name: 'Search', exact: true }).click()
|
||||
const hit = modal(p).locator('button').filter({ hasText: /Fushimi Inari/ }).first()
|
||||
// The OpenStreetMap hit, whose address runs from the street to the
|
||||
// country; the index can answer the same name with only a ward.
|
||||
const hit = modal(p).locator('button').filter({ hasText: FOUND }).filter({ hasText: /Kyoto.*Japan/ }).first()
|
||||
await expect(hit).toBeVisible({ timeout: 20_000 })
|
||||
await hit.click()
|
||||
await expect(modal(p).getByPlaceholder('Name', { exact: true })).toHaveValue(/Fushimi/)
|
||||
await expect(modal(p).getByPlaceholder('Name', { exact: true })).toHaveValue(FOUND)
|
||||
await blurFocus(p)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
@@ -124,12 +346,12 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
target: p => modal(p).getByRole('button', { name: 'Add', exact: true }),
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: 'Add', exact: true }).click()
|
||||
await expect(placeRow(p, 'Fushimi Inari Shrine')).toBeVisible({ timeout: 15_000 })
|
||||
await expect(placeRow(p, 'Nij')).toBeVisible({ timeout: 15_000 })
|
||||
// The dialog stays open for the next place; the guide is done with it.
|
||||
await beat(p, 600)
|
||||
await modal(p).getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
await pageReady(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -137,33 +359,52 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
|
||||
'import-from-trip': {
|
||||
guide: guide('import-from-trip'),
|
||||
start: p => openList(p, KYOTO),
|
||||
start: async p => {
|
||||
const tripId = await tripIdOf(p, JAPAN)
|
||||
const have = new Set((await tripPlaces(p, tripId)).map(x => x.name))
|
||||
addedToTrip = []
|
||||
for (const place of KYOTO_EXTRAS) {
|
||||
if (have.has(place.name)) continue
|
||||
const res = await p.request.post(`/api/trips/${tripId}/places`, { data: place })
|
||||
if (!res.ok()) throw new Error(`could not add "${place.name}" to ${JAPAN}: ${res.status()} ${await res.text()}`)
|
||||
const body = (await res.json()) as { place?: { id: number }; id?: number }
|
||||
const id = body.place?.id ?? body.id
|
||||
if (id != null) addedToTrip.push(id)
|
||||
}
|
||||
await openList(p, KYOTO)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => filterBar(p).getByRole('button', { name: 'Import from a trip' }),
|
||||
act: async p => {
|
||||
await filterBar(p).getByRole('button', { name: 'Import from a trip' }).click()
|
||||
await expect(modal(p)).toBeVisible()
|
||||
await expect(modal(p).locator('button.col-imp-trip').first()).toBeVisible({ timeout: 15_000 })
|
||||
await expect(importTrip(p, JAPAN)).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).locator('button.col-imp-trip').filter({ hasText: 'Autumn in Japan' }),
|
||||
// All the trip tiles, not the one: a tile in the left column puts the
|
||||
// frame's centre there, and the dialog's right half falls off the picture.
|
||||
target: importStagger,
|
||||
act: async p => {
|
||||
await modal(p).locator('button.col-imp-trip').filter({ hasText: 'Autumn in Japan' }).click()
|
||||
await expect(modal(p).locator('button.col-imp-row').first()).toBeVisible({ timeout: 15_000 })
|
||||
await importTrip(p, JAPAN).click()
|
||||
await expect(modal(p).getByPlaceholder('Search places…')).toBeVisible()
|
||||
await expect(importStagger(p).getByRole('button').first()).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).locator('.col-imp-list'),
|
||||
// The whole dialog: the rows with their toolbar are taller than the
|
||||
// smallest frame, so a frame centred on them cuts the head band off.
|
||||
target: modalCard,
|
||||
act: async p => {
|
||||
// Make sure something is ticked: the first two rows that are not already on the list.
|
||||
const rows = modal(p).locator('button.col-imp-row:not(.dup)')
|
||||
for (let i = 0; i < Math.min(2, await rows.count()); i++) {
|
||||
if ((await rows.nth(i).getAttribute('aria-pressed')) !== 'true') await rows.nth(i).click()
|
||||
// Nothing to tick: the three temples no day holds are what the dialog
|
||||
// selects by itself, which is what the step's text says.
|
||||
for (const place of KYOTO_EXTRAS) {
|
||||
await expect(importStagger(p).getByRole('button', { name: new RegExp(place.name) })).toHaveAttribute('aria-pressed', 'true')
|
||||
}
|
||||
await expect(modal(p).getByRole('button', { name: `Import ${KYOTO_EXTRAS.length}` })).toBeVisible()
|
||||
await beat(p, 300)
|
||||
},
|
||||
},
|
||||
@@ -172,12 +413,19 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
act: async p => {
|
||||
await modal(p).getByRole('button', { name: /^Import \d+$/ }).click()
|
||||
await expect(modal(p).getByText('Import finished')).toBeVisible({ timeout: 15_000 })
|
||||
await modal(p).getByRole('button', { name: 'Close' }).click()
|
||||
// The head band has a Close of its own; the one meant here ends the dialog in its footer.
|
||||
await modal(p).locator('footer').getByRole('button', { name: 'Close' }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
for (const place of KYOTO_EXTRAS) await expect(placeRow(p, place.name)).toBeVisible()
|
||||
await pageReady(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
// The list keeps its copies; the trip goes back to what the seed made of it.
|
||||
await deleteTripPlaces(p, await tripIdOf(p, JAPAN), addedToTrip)
|
||||
addedToTrip = []
|
||||
},
|
||||
},
|
||||
|
||||
'place-status': {
|
||||
@@ -197,7 +445,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
act: async p => {
|
||||
await placeRows(p).first().getByRole('button', { name: 'Want to go' }).click()
|
||||
await expect(placeRows(p).first().getByRole('button', { name: 'Visited' })).toBeVisible()
|
||||
await settle(p)
|
||||
await pageReady(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -212,7 +460,9 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
act: async p => {
|
||||
await placeRows(p).nth(1).click()
|
||||
await expect(detail(p)).toBeVisible()
|
||||
await settle(p)
|
||||
// The map pans to the place; let that pan arrive before the next picture.
|
||||
// Places at the far side of the list can end up past the map's edge.
|
||||
await pageReady(p, { every: false })
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -242,16 +492,14 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
await beat(p, 400)
|
||||
await p.keyboard.press('Escape')
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
// Escape hands focus back to the label control, outlined.
|
||||
await blurFocus(p)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await filterBar(p).getByRole('button', { name: 'Select', exact: true }).click()
|
||||
await expect(selectionBar(p)).toBeVisible()
|
||||
await placeRows(p).nth(0).click()
|
||||
await placeRows(p).nth(1).click()
|
||||
},
|
||||
// The two places that are in Arashiyama, so the label means what it says.
|
||||
prepare: p => selectPlaces(p, ARASHIYAMA_PLACES),
|
||||
target: p => selectionBar(p).getByRole('button', { name: 'Assign label' }),
|
||||
act: async p => {
|
||||
await selectionBar(p).getByRole('button', { name: 'Assign label' }).click()
|
||||
@@ -261,6 +509,8 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await filterBar(p).getByRole('button', { name: 'Select', exact: true }).click()
|
||||
await expect(selectionBar(p)).toHaveCount(0)
|
||||
for (const name of ARASHIYAMA_PLACES) await expect(placeRow(p, name).locator('.col-lrow-label')).toContainText('Arashiyama')
|
||||
await blurFocus(p)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
@@ -268,7 +518,8 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
target: p => p.locator('.col-labelfilter').first(),
|
||||
act: async p => {
|
||||
await p.locator('.col-labelfilter').first().getByRole('button', { name: /Arashiyama/ }).click()
|
||||
await settle(p)
|
||||
await expect(placeRows(p)).toHaveCount(ARASHIYAMA_PLACES.length)
|
||||
await pageReady(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -303,8 +554,12 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => { await selectionBar(p).getByRole('button', { name: 'Select all' }).click() },
|
||||
prepare: async p => {
|
||||
await selectionBar(p).getByRole('button', { name: 'Select all' }).click()
|
||||
await blurFocus(p)
|
||||
},
|
||||
target: selectionBar,
|
||||
act: pageReady,
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
@@ -315,15 +570,17 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
|
||||
'copy-to-trip': {
|
||||
guide: guide('copy-to-trip'),
|
||||
start: p => openList(p, KYOTO),
|
||||
start: async p => {
|
||||
placesBeforeCopy = new Set((await tripPlaces(p, await tripIdOf(p, JAPAN))).map(x => x.id))
|
||||
await openList(p, KYOTO)
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
target: p => filterBar(p).getByRole('button', { name: 'Select', exact: true }),
|
||||
act: async p => {
|
||||
await filterBar(p).getByRole('button', { name: 'Select', exact: true }).click()
|
||||
await expect(selectionBar(p)).toBeVisible()
|
||||
await placeRows(p).nth(0).click()
|
||||
await placeRows(p).nth(2).click()
|
||||
// Two places the Japan trip does not have yet: the one found by search
|
||||
// and a temple whose trip copy the import guide took out again.
|
||||
await selectPlaces(p, ['Nij', 'Kinkaku-ji Temple'])
|
||||
await beat(p, 300)
|
||||
},
|
||||
},
|
||||
@@ -336,16 +593,23 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).locator('button').filter({ hasText: 'Norway Road Trip' }),
|
||||
target: p => modal(p).locator('button').filter({ hasText: JAPAN }),
|
||||
act: async p => {
|
||||
await modal(p).locator('button').filter({ hasText: 'Norway Road Trip' }).click()
|
||||
await modal(p).locator('button').filter({ hasText: JAPAN }).click()
|
||||
await expect(modal(p)).toHaveCount(0, { timeout: 15_000 })
|
||||
await settle(p)
|
||||
await pageReady(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
cleanup: async p => {
|
||||
if (await selectionBar(p).count()) await filterBar(p).getByRole('button', { name: 'Select', exact: true }).click()
|
||||
// The copies leave the trip again, so the trip screens keep the seed's trip.
|
||||
if (placesBeforeCopy) {
|
||||
const tripId = await tripIdOf(p, JAPAN)
|
||||
const before = placesBeforeCopy
|
||||
await deleteTripPlaces(p, tripId, (await tripPlaces(p, tripId)).filter(x => !before.has(x.id)).map(x => x.id))
|
||||
placesBeforeCopy = null
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -362,7 +626,9 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => modal(p).getByRole('button', { name: 'Select a user' }).locator('xpath=../..'),
|
||||
// The invite row: the user picker, the role picker and Send invite. Each
|
||||
// picker sits in a wrapper that sizes it, so the row is three levels up.
|
||||
target: p => modal(p).getByRole('button', { name: 'Select a user' }).locator('xpath=../../..'),
|
||||
act: async p => {
|
||||
await pickSearchable(p, modal(p).getByRole('button', { name: 'Select a user' }), 'mira')
|
||||
await beat(p, 300)
|
||||
@@ -376,7 +642,8 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
await beat(p, 500)
|
||||
await p.keyboard.press('Escape')
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
// Escape gives focus back to Share, and its outline would read as a ring.
|
||||
await pageReady(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -438,7 +705,8 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
await modal(p).getByRole('button', { name: /^Import$/ }).click()
|
||||
await expect(modal(p)).toHaveCount(0, { timeout: 15_000 })
|
||||
await expect(hero(p)).toContainText('Kyoto shortlist (from file)')
|
||||
await settle(p)
|
||||
// A new list mounts a new map, which opens on the window's camera.
|
||||
await pageReady(p, { reframe: true })
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -461,10 +729,10 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
act: async p => {
|
||||
const desc = modal(p).getByPlaceholder('Add a description…')
|
||||
await desc.fill('')
|
||||
await typeInto(p, desc, 'Coffee first, then the miradouros. Weekend of 16 October.')
|
||||
await typeInto(p, desc, 'Coffee first, then the miradouros. For the long weekend in Lisbon.')
|
||||
await modal(p).getByRole('button', { name: 'Save' }).click()
|
||||
await expect(modal(p)).toHaveCount(0)
|
||||
await settle(p)
|
||||
await pageReady(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -479,13 +747,14 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
act: async p => {
|
||||
await rail(p).locator('button.col-row-btn').filter({ hasText: 'All saved' }).click()
|
||||
await expect(hero(p)).toContainText('All saved')
|
||||
await settle(p)
|
||||
await pageReady(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.getByPlaceholder('Search places').first(),
|
||||
// The whole pill, magnifier included, not the bare input inside it.
|
||||
target: mapSearch,
|
||||
act: async p => {
|
||||
await typeInto(p, p.getByPlaceholder('Search places').first(), 'temple')
|
||||
await typeInto(p, mapSearch(p).locator('input'), 'temple')
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
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, coverFixture, JOURNEY_TITLE, SECOND_JOURNEY, SPARE_TRIP } from './fixtures'
|
||||
import { ensureExtraTrips, ensureJourneyFixtures, filesImageFixture, JOURNEY_TITLE, SECOND_JOURNEY, SPARE_TRIP } from './fixtures'
|
||||
import { journeyGuides, journeyContext, journalGuides, journalContext, studioGuides, studioContext } from '../../src/help/contexts/journey'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
|
||||
@@ -10,6 +11,12 @@ import type { HelpGuide } from '../../src/help/types'
|
||||
* `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]
|
||||
@@ -24,7 +31,16 @@ 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 })
|
||||
@@ -44,34 +60,55 @@ async function openJournal(page: Page): Promise<void> {
|
||||
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/ })
|
||||
/** Full-screen dialogs of this screen (create, settings, add trip, invite) all sit at z-200. */
|
||||
const anyDialog = (page: Page) => page.locator('div.fixed.inset-0.z-\\[200\\]')
|
||||
/**
|
||||
* 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()
|
||||
const dialogCard = (page: Page) => dialog(page).locator('> div').first()
|
||||
/** The entry editor. */
|
||||
const editor = (page: Page) => page.locator('div.fixed.inset-0.z-\\[9999\\]')
|
||||
/** 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 ⋯ menu button on its cover. */
|
||||
/** 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()
|
||||
// Two builds of the button: on a photo cover (rounded-[10px]) and on a plain card (w-7 h-7 rounded-md).
|
||||
const entryMenu = (page: Page, title: string) => entryCard(page, title).locator('button[class*="rounded-[10px]"], button.w-7.rounded-md').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(dialog(page).getByText('Journey Settings')).toBeVisible()
|
||||
await expect(page.getByRole('dialog', { name: 'Journey Settings' })).toBeVisible()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/** Close whatever dialogs are stacked: Escape first, then the backdrop, until none is left. */
|
||||
/**
|
||||
* 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> {
|
||||
await page.keyboard.press('Escape')
|
||||
for (let i = 0; i < 3 && (await anyDialog(page).count()); i++) {
|
||||
await page.mouse.click(20, VIEWPORT.height / 2)
|
||||
await dialogCard(page).getByRole('button', { name: 'Close', exact: true }).first().click()
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
await expect(anyDialog(page)).toHaveCount(0)
|
||||
@@ -84,6 +121,229 @@ async function openEditor(page: Page): Promise<void> {
|
||||
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}"`)
|
||||
}
|
||||
|
||||
// ── Studio ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const rail = (page: Page) => page.locator('.st-rail')
|
||||
@@ -91,7 +351,8 @@ const railButton = (page: Page, name: string) => rail(page).getByRole('button',
|
||||
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()
|
||||
const exportDialog = (page: Page) => page.locator('.st-ex')
|
||||
/** 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:"]')
|
||||
@@ -132,7 +393,17 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
// ── /journey ────────────────────────────────────────────────────────────
|
||||
'create-journey': {
|
||||
guide: guide('create-journey'),
|
||||
start: openJourneys,
|
||||
// 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,
|
||||
@@ -145,7 +416,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
{
|
||||
target: dialogCard,
|
||||
act: async p => {
|
||||
await typeInto(p, dialog(p).locator('input').first(), 'Lisbon weekend')
|
||||
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".
|
||||
@@ -159,6 +430,9 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
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)
|
||||
},
|
||||
@@ -189,16 +463,23 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
// ── /journey/:id ────────────────────────────────────────────────────────
|
||||
'add-entry': {
|
||||
guide: guide('add-entry'),
|
||||
start: openJournal,
|
||||
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,
|
||||
},
|
||||
{
|
||||
target: p => editor(p).getByPlaceholder('Write your story...'),
|
||||
// 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...'), 'Night market on the river')
|
||||
await typeInto(p, editor(p).getByPlaceholder('Give this moment a name...'), NEW_ENTRY)
|
||||
await typeInto(
|
||||
p,
|
||||
editor(p).getByPlaceholder('Write your story...'),
|
||||
@@ -208,7 +489,9 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => editor(p).getByRole('button', { name: 'Amazing' }).locator('xpath=../..'),
|
||||
// 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()
|
||||
@@ -220,42 +503,57 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
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, 'Night market on the river')).toBeVisible()
|
||||
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: openJournal,
|
||||
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, 'Barefoot through the light'),
|
||||
act: async p => {
|
||||
await entryMenu(p, 'Barefoot through the light').click()
|
||||
await p.getByRole('button', { name: 'Edit', exact: true }).first().click()
|
||||
await expect(editor(p)).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => entryMenu(p, ASAKUSA),
|
||||
act: p => editEntry(p, ASAKUSA),
|
||||
},
|
||||
{
|
||||
target: p => editor(p).getByRole('button', { name: 'Upload photos' }),
|
||||
act: async p => {
|
||||
const file = await coverFixture()
|
||||
await editor(p).locator('input[type="file"][multiple]').setInputFiles(file)
|
||||
await expect(editor(p).locator('img').first()).toBeVisible({ timeout: 15_000 })
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => { await editor(p).locator('img').first().hover() },
|
||||
target: p => editor(p).getByRole('button', { name: 'Save', exact: true }),
|
||||
// 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, 'Barefoot through the light').locator('img').first()).toBeVisible({ timeout: 15_000 })
|
||||
await expect(entryCard(p, ASAKUSA).locator('img').first()).toBeVisible({ timeout: 15_000 })
|
||||
await blurFocus(p)
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
@@ -289,6 +587,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
await dialog(p).getByRole('button', { name: 'Bring back dismissed suggestions' }).click()
|
||||
await beat(p, 500)
|
||||
await closeDialog(p)
|
||||
await blurFocus(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -308,29 +607,31 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
{
|
||||
target: p => editor(p).getByText('Date', { exact: true }).locator('xpath=..'),
|
||||
act: async p => {
|
||||
await editor(p).getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(editor(p)).toHaveCount(0)
|
||||
},
|
||||
act: cancelEditor,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'pros-cons': {
|
||||
guide: guide('pros-cons'),
|
||||
start: openJournal,
|
||||
start: async p => {
|
||||
await clearVerdict(p, ASAKUSA)
|
||||
await openJournal(p)
|
||||
},
|
||||
cleanup: p => clearVerdict(p, ASAKUSA),
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await entryMenu(p, 'First morning in Asakusa').click()
|
||||
await p.getByRole('button', { name: 'Edit', exact: true }).first().click()
|
||||
await expect(editor(p)).toBeVisible()
|
||||
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)
|
||||
},
|
||||
@@ -340,12 +641,17 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
act: async p => {
|
||||
await editor(p).getByRole('button', { name: 'Save', exact: true }).click()
|
||||
await expect(editor(p)).toHaveCount(0, { timeout: 15_000 })
|
||||
// The card folds a long entry, so the saved verdict is checked at the source.
|
||||
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 === 'First morning in Asakusa')?.pros_cons?.pros ?? []
|
||||
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)
|
||||
},
|
||||
},
|
||||
@@ -357,7 +663,8 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
start: openJournal,
|
||||
steps: [
|
||||
{
|
||||
target: p => p.getByPlaceholder('Search this journey'),
|
||||
// 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)
|
||||
@@ -403,16 +710,27 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
act: openSettings,
|
||||
},
|
||||
{
|
||||
prepare: async p => { await dialog(p).getByText('Entry fields').scrollIntoViewIfNeeded() },
|
||||
target: p => dialog(p).getByText('Entry fields').locator('xpath=..'),
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// 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: openJournal,
|
||||
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,
|
||||
@@ -432,16 +750,32 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
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: openJournal,
|
||||
start: async p => {
|
||||
await deleteShareLink(p)
|
||||
await openJournal(p)
|
||||
},
|
||||
cleanup: deleteShareLink,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
@@ -467,13 +801,18 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
|
||||
contributors: {
|
||||
guide: guide('contributors'),
|
||||
start: openJournal,
|
||||
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=..'),
|
||||
},
|
||||
{
|
||||
@@ -488,9 +827,16 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => dialog(p).getByRole('button', { name: 'Invite', exact: true }),
|
||||
// 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)
|
||||
@@ -528,9 +874,21 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
act: openSettings,
|
||||
},
|
||||
{
|
||||
prepare: async p => { await dialog(p).getByRole('button', { name: 'Archive Journey' }).scrollIntoViewIfNeeded() },
|
||||
target: p => dialog(p).getByRole('button', { name: 'Archive Journey' }).locator('xpath=..'),
|
||||
act: closeDialog,
|
||||
// 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)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -736,12 +1094,21 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
target: exportDialog,
|
||||
// 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 => {
|
||||
await exportDialog(p).getByRole('button', { name: 'Close' }).click()
|
||||
// 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)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||||
import { clearNotices } from '../screenshots/shot'
|
||||
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript } from './guide'
|
||||
import { test, expect, request as apiRequest, type APIRequestContext, type Page, type Locator } from '@playwright/test'
|
||||
import { captureGuide, captureHero, dismissReleaseNotice, beat, typeInto, settle, VIEWPORT, type GuideScript, type StepAction } from './guide'
|
||||
import { E2E_BASE_URL } from '../../playwright.config'
|
||||
import { monthName, year } from '../dates'
|
||||
import { vacayGuides, vacayContext } from '../../src/help/contexts/vacay'
|
||||
import type { HelpGuide } from '../../src/help/types'
|
||||
@@ -16,6 +16,8 @@ import type { HelpGuide } from '../../src/help/types'
|
||||
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'
|
||||
|
||||
const guide = (id: string): HelpGuide => {
|
||||
const g = vacayGuides.find(x => x.id === id)
|
||||
@@ -23,10 +25,33 @@ const guide = (id: string): HelpGuide => {
|
||||
return g
|
||||
}
|
||||
|
||||
/**
|
||||
* What clearNotices in `screenshots/shot.ts` clears, less its loop over every
|
||||
* button whose name contains "next". On this page that loop found the year
|
||||
* card's "Add next year" and clicked it six times on every visit, so the plan
|
||||
* grew by six years per guide and the Year card filled up with chips past
|
||||
* 2100. A tour's own button is called just "Next", so that one is still stepped
|
||||
* through.
|
||||
*/
|
||||
async function clearVacayNotices(page: Page): Promise<void> {
|
||||
const next = page.getByRole('button', { name: 'Next', exact: true })
|
||||
for (let i = 0; i < 6 && (await next.isVisible().catch(() => false)); i++) {
|
||||
if (!(await next.isEnabled().catch(() => false))) break
|
||||
await next.click().catch(() => {})
|
||||
}
|
||||
for (const label of ['Dismiss', 'OK']) {
|
||||
const btn = page.getByRole('button', { name: label, exact: true })
|
||||
for (let i = 0; i < 4 && (await btn.isVisible().catch(() => false)); i++) {
|
||||
await btn.click().catch(() => {})
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function openVacay(page: Page): Promise<void> {
|
||||
await page.goto('/vacay')
|
||||
await clearNotices(page)
|
||||
await dismissReleaseNotice(page)
|
||||
await clearVacayNotices(page)
|
||||
await expect(toolbar(page)).toBeVisible()
|
||||
await expect(monthCard(page, NEXT_MONTH)).toBeVisible()
|
||||
}
|
||||
@@ -43,9 +68,52 @@ const sideCard = (page: Page, label: string) => page.locator('.vg-card').filter(
|
||||
const settings = (page: Page) => page.locator('.trek-modal-backdrop')
|
||||
/** 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 =>
|
||||
settings(page).getByText(label, { exact: true }).locator('xpath=../../..').getByRole('button').first()
|
||||
const toggleRow = (page: Page, label: string): Locator => settingRow(page, label).getByRole('button').first()
|
||||
/** The form Add calendar opens: colour, label, country, region and its Add button. */
|
||||
const addCalendarForm = (page: Page): Locator =>
|
||||
settings(page).getByPlaceholder('Label (optional)').last().locator('xpath=../..')
|
||||
/** The Days tile of the Entitlement card while it is being edited. */
|
||||
const daysTile = (page: Page): Locator => sideCard(page, 'Entitlement').locator('input').locator('xpath=..')
|
||||
/** A year chip in the Year card. */
|
||||
const yearChip = (page: Page, y: number): Locator =>
|
||||
sideCard(page, 'Year').locator('[role="button"]').filter({ hasText: String(y) }).first()
|
||||
/** A row of a sidebar card (a person, an invite, a share) by the name in it. */
|
||||
const cardRow = (page: Page, card: string, name: string): Locator =>
|
||||
sideCard(page, card).locator('.group').filter({ hasText: name }).first()
|
||||
|
||||
/** Nothing hovered: for a picture where the pointer would reveal a button the step is not about. */
|
||||
const parkPointer = async (page: Page): Promise<void> => { await page.mouse.move(VIEWPORT.width - 4, VIEWPORT.height - 4) }
|
||||
|
||||
/**
|
||||
* Back to the top of the page. A step target further down scrolls the page a
|
||||
* little, and a result picture taken there hides the top row of months under
|
||||
* the header, unlike the hero and the other results.
|
||||
*/
|
||||
async function toTop(page: Page): Promise<void> {
|
||||
// On a desktop the body is what scrolls (index.css hides the overflow of
|
||||
// html), so the window's own scroll position is already 0.
|
||||
await page.evaluate(() => {
|
||||
for (const el of [document.body, document.documentElement]) el.scrollTo({ top: 0, left: 0, behavior: 'instant' })
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'instant' })
|
||||
})
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a settings switch on, and leave it alone when it is on already. Block
|
||||
* Weekends and Carry Over start out on, and a plain click switched them off,
|
||||
* so the next picture showed the opposite of what the step had just said. The
|
||||
* switch has no aria state; its filled track is the only sign it is on.
|
||||
*/
|
||||
async function switchOn(page: Page, label: string): Promise<void> {
|
||||
const sw = toggleRow(page, label)
|
||||
if (!(await sw.evaluate(el => el.classList.contains('bg-content')))) await sw.click()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
async function openSettings(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Settings' }).click()
|
||||
@@ -56,9 +124,107 @@ async function openSettings(page: Page): Promise<void> {
|
||||
async function closeSettings(page: Page): Promise<void> {
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(settings(page)).toBeHidden()
|
||||
// The dialog hands the focus back to the Settings button, and after a key
|
||||
// press that draws a thick focus outline, which in the next picture reads
|
||||
// like a step ring.
|
||||
await page.evaluate(() => { (document.activeElement as HTMLElement | null)?.blur() })
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Click Add calendar and fill the form the way the step describes, short of
|
||||
* adding it, so the picture shows the fields filled in.
|
||||
*/
|
||||
async function fillCalendarForm(page: Page, addButton: Locator, label: string): Promise<void> {
|
||||
await addButton.click()
|
||||
await typeInto(page, settings(page).getByPlaceholder('Label (optional)').last(), label)
|
||||
await pick(page, settings(page).getByRole('button', { name: 'Select country' }), 'Germany')
|
||||
// The region list loads after the country is picked; give it a moment to show up.
|
||||
const region = settings(page).getByRole('button', { name: 'Select region (required)' })
|
||||
await region.waitFor({ state: 'visible', timeout: 8_000 }).catch(() => {})
|
||||
if (await region.isVisible().catch(() => false)) await pick(page, region, 'Berlin')
|
||||
await expect(addCalendarForm(page).getByRole('button', { name: 'Add', exact: true })).toBeEnabled({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
async function submitCalendarForm(page: Page): Promise<void> {
|
||||
await addCalendarForm(page).getByRole('button', { name: 'Add', exact: true }).click()
|
||||
// The form closes once the calendar is saved and turns into its row.
|
||||
await expect(settings(page).getByRole('button', { name: 'Add', exact: true })).toBeHidden({ timeout: 10_000 })
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* "Close Settings", the last step of the holiday guides. The ring goes round
|
||||
* the head band rather than the close button alone: the 960 px dialog is as
|
||||
* wide as the smallest frame, so a frame centred on the button leaves out the
|
||||
* band's title and the calendar the guide just added, and the band is still
|
||||
* what the step names. The pointer rests on the button, so its Close tooltip
|
||||
* marks where to click inside the ring.
|
||||
*/
|
||||
const settingsCloseStep: StepAction = {
|
||||
target: p => settings(p).locator('header').first(),
|
||||
hover: async p => { await settings(p).getByRole('button', { name: 'Close' }).hover() },
|
||||
act: closeSettings,
|
||||
}
|
||||
|
||||
// ── A calendar shared with the admin ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The seeded member whose calendar the share-calendar guide shows as shared
|
||||
* with the admin. Not jonas: the admin shares with jonas in the same guide,
|
||||
* and two rows named jonas would hide which one is incoming.
|
||||
*/
|
||||
const SHARER = { username: 'mira', email: 'mira@example.com', password: 'DemoSeed12345!' }
|
||||
|
||||
/**
|
||||
* A request context acting as the member. Its own context with a bearer
|
||||
* token: a login on the page's context would put the member's session cookie
|
||||
* on the admin's requests.
|
||||
*/
|
||||
async function asMember(member: typeof SHARER): Promise<APIRequestContext> {
|
||||
const anon = await apiRequest.newContext({ baseURL: E2E_BASE_URL, storageState: undefined })
|
||||
const login = await anon.post('/api/auth/login', { data: { email: member.email, password: member.password } })
|
||||
const token = login.ok() ? ((await login.json()) as { token?: string }).token : undefined
|
||||
await anon.dispose()
|
||||
if (!token) throw new Error(`could not log in as ${member.username}: ${login.status()}`)
|
||||
return apiRequest.newContext({
|
||||
baseURL: E2E_BASE_URL,
|
||||
storageState: undefined,
|
||||
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
}
|
||||
|
||||
/** The member shares her calendar with the admin, as she would from her own Vacay. */
|
||||
async function shareWithAdmin(page: Page): Promise<void> {
|
||||
const me = await page.request.get('/api/auth/me')
|
||||
const adminId = ((await me.json()) as { user: { id: number } }).user.id
|
||||
const member = await asMember(SHARER)
|
||||
try {
|
||||
const res = await member.post('/api/addons/vacay/shares', { data: { user_id: adminId } })
|
||||
// A retried guide finds the share from its first attempt still there.
|
||||
if (!res.ok() && !(await res.text()).includes('Already shared')) {
|
||||
throw new Error(`could not share ${SHARER.username}'s calendar with the admin: ${res.status()}`)
|
||||
}
|
||||
} finally {
|
||||
await member.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/** Take back what shareWithAdmin did: the share and the notification it sent the admin. */
|
||||
async function unshareWithAdmin(page: Page): Promise<void> {
|
||||
const shares = (await (await page.request.get('/api/addons/vacay/shares')).json()) as { incoming: { id: number; username: string }[] }
|
||||
for (const s of shares.incoming.filter(x => x.username === SHARER.username)) {
|
||||
const res = await page.request.delete(`/api/addons/vacay/shares/${s.id}`)
|
||||
if (!res.ok()) throw new Error(`could not remove ${SHARER.username}'s share: ${res.status()}`)
|
||||
}
|
||||
const inbox = (await (await page.request.get('/api/notifications/in-app?limit=50')).json()) as {
|
||||
notifications: { id: number; title_key: string; sender_username: string | null }[]
|
||||
}
|
||||
for (const n of inbox.notifications.filter(x => x.title_key === 'notif.vacay_share.title' && x.sender_username === SHARER.username)) {
|
||||
await page.request.delete(`/api/notifications/in-app/${n.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -91,7 +257,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
await beat(p, 600)
|
||||
// Leave the day logged for the after-picture and the guides that follow.
|
||||
await day(p, NEXT_MONTH, 14).click()
|
||||
await settle(p)
|
||||
await toTop(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -111,7 +277,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
{
|
||||
target: p => p.getByRole('button', { name: 'Half day' }),
|
||||
act: async p => { await p.getByRole('button', { name: 'Half day' }).click() },
|
||||
act: async p => { await p.getByRole('button', { name: 'Half day' }).click(); await toTop(p) },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -130,6 +296,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
await day(p, NEXT_MONTH, 16).click()
|
||||
await settle(p)
|
||||
await p.getByRole('button', { name: 'Comp / Flex' }).click()
|
||||
await toTop(p)
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -144,13 +311,21 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
act: async p => { await sideCard(p, 'Entitlement').locator('[role="button"]').filter({ hasText: 'Days' }).first().click() },
|
||||
},
|
||||
{
|
||||
target: p => sideCard(p, 'Entitlement').locator('input'),
|
||||
act: async p => {
|
||||
// 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, '30')
|
||||
await input.press('Enter')
|
||||
await settle(p)
|
||||
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)
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -171,13 +346,23 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
{
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => { await sideCard(p, 'Year').locator('[role="button"]').filter({ hasText: String(YEAR + 1) }).first().hover() },
|
||||
target: p => sideCard(p, 'Year').locator('[role="button"]').filter({ hasText: String(YEAR + 1) }).first().getByRole('button', { name: 'Remove year' }),
|
||||
// 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 => {
|
||||
@@ -192,9 +377,12 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
start: openVacay,
|
||||
steps: [
|
||||
{
|
||||
// On by default, so the step only shows where the switch lives.
|
||||
// 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 => toggleRow(p, 'Company Holidays'),
|
||||
target: p => settingRow(p, 'Company Holidays'),
|
||||
act: closeSettings,
|
||||
},
|
||||
{
|
||||
@@ -210,6 +398,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
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)
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -220,29 +409,19 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
start: openVacay,
|
||||
steps: [
|
||||
{
|
||||
// The whole row, as in company-holidays step 1: name and switch together.
|
||||
prepare: openSettings,
|
||||
target: p => toggleRow(p, 'Public Holidays'),
|
||||
target: p => settingRow(p, 'Public Holidays'),
|
||||
act: async p => { await toggleRow(p, 'Public Holidays').click(); await settle(p) },
|
||||
},
|
||||
{
|
||||
target: p => settings(p).getByRole('button', { name: 'Add calendar' }).first(),
|
||||
act: async p => {
|
||||
await settings(p).getByRole('button', { name: 'Add calendar' }).first().click()
|
||||
await typeInto(p, settings(p).getByPlaceholder('Label (optional)').last(), 'Berlin')
|
||||
await pick(p, settings(p).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(p).getByRole('button', { name: 'Select region (required)' })
|
||||
await region.waitFor({ state: 'visible', timeout: 8_000 }).catch(() => {})
|
||||
if (await region.isVisible().catch(() => false)) await pick(p, region, 'Berlin')
|
||||
await expect(settings(p).getByRole('button', { name: 'Add', exact: true })).toBeEnabled({ timeout: 10_000 })
|
||||
await settings(p).getByRole('button', { name: 'Add', exact: true }).click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => settings(p).getByRole('button', { name: 'Close' }),
|
||||
act: async p => { await closeSettings(p); await settle(p) },
|
||||
// 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,
|
||||
],
|
||||
},
|
||||
|
||||
@@ -252,28 +431,16 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
steps: [
|
||||
{
|
||||
prepare: openSettings,
|
||||
target: p => toggleRow(p, 'School Holidays'),
|
||||
target: p => settingRow(p, 'School Holidays'),
|
||||
act: async p => { await toggleRow(p, 'School Holidays').click(); await settle(p) },
|
||||
},
|
||||
{
|
||||
target: p => settings(p).getByRole('button', { name: 'Add calendar' }).last(),
|
||||
act: async p => {
|
||||
await settings(p).getByRole('button', { name: 'Add calendar' }).last().click()
|
||||
await typeInto(p, settings(p).getByPlaceholder('Label (optional)').last(), 'Schools Berlin')
|
||||
await pick(p, settings(p).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(p).getByRole('button', { name: 'Select region (required)' })
|
||||
await region.waitFor({ state: 'visible', timeout: 8_000 }).catch(() => {})
|
||||
if (await region.isVisible().catch(() => false)) await pick(p, region, 'Berlin')
|
||||
await expect(settings(p).getByRole('button', { name: 'Add', exact: true })).toBeEnabled({ timeout: 10_000 })
|
||||
await settings(p).getByRole('button', { name: 'Add', exact: true }).click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => settings(p).getByRole('button', { name: 'Close' }),
|
||||
act: async p => { await closeSettings(p); await settle(p) },
|
||||
// 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,
|
||||
],
|
||||
},
|
||||
|
||||
@@ -287,7 +454,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
{
|
||||
target: p => toggleRow(p, 'Block Weekends'),
|
||||
act: async p => { await toggleRow(p, 'Block Weekends').click(); await settle(p) },
|
||||
act: async p => { await switchOn(p, 'Block Weekends') },
|
||||
},
|
||||
{
|
||||
target: p => settings(p).getByText('Week starts on', { exact: true }).locator('xpath=../../..'),
|
||||
@@ -305,7 +472,8 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
target: p => settings(p).getByText('Vacation year', { exact: true }).locator('xpath=../../..'),
|
||||
},
|
||||
{
|
||||
target: p => settings(p).getByText('Vacation year', { exact: true }).locator('xpath=../../..').getByRole('button').first(),
|
||||
// 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,
|
||||
},
|
||||
],
|
||||
@@ -321,7 +489,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
{
|
||||
target: p => toggleRow(p, 'Carry Over'),
|
||||
act: async p => { await toggleRow(p, 'Carry Over').click(); await settle(p); await closeSettings(p) },
|
||||
act: async p => { await switchOn(p, 'Carry Over'); await closeSettings(p) },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -335,16 +503,25 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
act: async p => { await sideCard(p, 'Persons').getByRole('button').first().click(); await settle(p) },
|
||||
},
|
||||
{
|
||||
target: p => dialog(p).getByRole('button', { name: 'Select user' }),
|
||||
act: async 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)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => sideCard(p, 'Persons').getByText('mira', { exact: false }).first(),
|
||||
// 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -358,18 +535,34 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
act: async p => { await sideCard(p, 'Shared Calendars').getByTitle('Share calendar').click(); await settle(p) },
|
||||
},
|
||||
{
|
||||
target: p => dialog(p).getByRole('button', { name: 'Select user' }),
|
||||
act: async 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', SHARER.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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 195 KiB |