mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
146fe4a063
The guides of the map, bookings, costs, files, collab, lists, road trip and transports now describe the desktop as it is after the redesign: the bars with their icon buttons and filter popovers, the dialogs with the title in the head band and status and type as pills, the three booking views and the booking detail, the compact note cards, the Costs table. Two guides are new: the booking views (cards, list, timeline, the Trip and Day zoom and the detail) and the Costs table view. Every changed text is translated into every locale, and the Plan tab texts call the bookings tab by the label it shows, Book. Every picture of the thirteen trip screens is taken again against the new layout, on one picture day. Scripts follow the new markup; the Dawarich fixture uploads its recording again when the stored one belongs to another run, and the spare poll gets its vote from a member so its winner shows green. server-launch.mjs skips the server build with E2E_SERVER_PREBUILT, so several recordings on their own port pairs can share one dist.
617 lines
27 KiB
TypeScript
617 lines
27 KiB
TypeScript
import { test, expect, type Page, type Locator } from '@playwright/test'
|
||
import { captureGuide, captureHero, typeInto, beat, settle, VIEWPORT, type GuideScript } from './guide'
|
||
import { seededTrip, ensureFilesFixtures, filesPdfFixture, filesImageFixture, drawPdf } from './fixtures'
|
||
import { openTrip, modal, dialog } from './trip-shared'
|
||
import { ensureNextcloudFolder, NEXTCLOUD_CONNECTION, NEXTCLOUD_DOCUMENTS, NEXTCLOUD_FOLDER, resetDocSync, toggleAddon } from './external'
|
||
import { tripFilesContext, tripFilesGuides } from '../../src/help/contexts/tripFiles'
|
||
import type { HelpGuide } from '../../src/help/types'
|
||
|
||
/**
|
||
* Actions for the Files tab of a trip, keyed by the ids in
|
||
* `src/help/contexts/tripFiles.ts`. The seed creates no documents at all, so
|
||
* `ensureFilesFixtures` puts a small set of drawn ones in before the first
|
||
* picture; what a guide uploads, links, stars or throws away it undoes in
|
||
* `cleanup`, so the next one starts from that same set.
|
||
*/
|
||
|
||
const guide = (id: string): HelpGuide => {
|
||
const g = tripFilesGuides.find(x => x.id === id)
|
||
if (!g) throw new Error(`no registered guide "${id}"`)
|
||
return g
|
||
}
|
||
|
||
/** The fixture documents each guide acts on, by the name their row carries. */
|
||
const STARRED = 'JR-Pass-voucher.pdf'
|
||
const LINKED = 'LH716-boarding-pass.pdf'
|
||
const DOOMED = 'old-draft-itinerary.pdf'
|
||
const PHOTO = 'nishiki-market.jpg'
|
||
/** What the upload guide brings in; these two names exist nowhere else, so cleanup can take them out. */
|
||
const UPLOADED_PDF = 'kyoto-bus-pass.pdf'
|
||
const UPLOADED_PHOTO = 'arashiyama.jpg'
|
||
const UPLOADED = [UPLOADED_PDF, UPLOADED_PHOTO]
|
||
/** What the link guide ties the boarding pass to, spelled as the dialog spells them. */
|
||
const PLACE = 'teamLab Planets'
|
||
const FLIGHT = 'LH716 FRA → HND'
|
||
const NOTE = 'Seat 34K, boarding 12:20'
|
||
|
||
const openFiles = (page: Page) => openTrip(page, { tab: 'dateien' })
|
||
|
||
/**
|
||
* The file rows, in the order the list shows them. A row is the `.group` card
|
||
* whose last child holds the row's icon actions (`.file-actions`); matching on
|
||
* that keeps any other hover group on the trip page out of the count.
|
||
*/
|
||
const rows = (page: Page) => page.locator('div.group:has(> div.file-actions)')
|
||
const row = (page: Page, name: string) => rows(page).filter({ hasText: name }).first()
|
||
/** A row's actions are icons; each takes its accessible name from its tooltip. */
|
||
const action = (page: Page, name: string, label: string) =>
|
||
row(page, name).getByRole('button', { name: label, exact: true })
|
||
/** react-dropzone always sets `role="presentation"` on the box it wraps. */
|
||
const dropzone = (page: Page) => page.locator('div[role="presentation"]').filter({ hasText: 'Drop files here' }).first()
|
||
/** The filter row, found through the one tab that is always there. */
|
||
const tabRow = (page: Page) => page.getByRole('button', { name: /^All \d+$/ }).locator('xpath=..')
|
||
/** The star tab has no text: its accessible name is the count alone. */
|
||
const starTab = (page: Page) => tabRow(page).getByRole('button').filter({ has: page.locator('svg.lucide-star') }).first()
|
||
/**
|
||
* The number on a filter tab. Reading it rather than counting the fixture keeps
|
||
* the step honest about the one thing it is really claiming: that the tab shows
|
||
* exactly as many rows as its badge promises, and it survives another screen's
|
||
* fixture adding a file of its own.
|
||
*/
|
||
const tabCount = async (tab: Locator): Promise<number> => Number(((await tab.textContent()) ?? '').replace(/\D+/g, ''))
|
||
/**
|
||
* The trash icon at the right end of the bar, named by its tooltip. Trash and
|
||
* Empty Trash both contain "Trash", so it needs `exact`.
|
||
*/
|
||
const trashTab = (page: Page) => page.getByRole('button', { name: 'Trash', exact: true })
|
||
/**
|
||
* Assign File is a DialogShell: the panel is the `dialog`, and the eyebrow in
|
||
* its head band (Assign File, over the file's name) is what labels it.
|
||
*/
|
||
const assignDialog = (page: Page) => page.getByRole('dialog', { name: 'Assign File' })
|
||
const noteBox = (page: Page) => assignDialog(page).getByPlaceholder('Add a note...')
|
||
/**
|
||
* The lightbox, found by the one header button only it and the document preview
|
||
* carry, and they are never open at the same time. Its three direct children
|
||
* are the header, the picture and the strip of thumbnails.
|
||
*/
|
||
const lightbox = (page: Page) =>
|
||
page.locator('div[role="presentation"]').filter({ has: page.getByRole('button', { name: 'Open in new tab' }) }).first()
|
||
/**
|
||
* The preview for everything that is not a picture: a DialogShell panel with
|
||
* the file's name in the head band. For a PDF it holds the browser's embedded
|
||
* viewer, the one `<object>` only it renders; the markdown preview
|
||
* (`.collab-note-md`) has no step of its own.
|
||
*/
|
||
const pdfPreview = (page: Page) => page.getByRole('dialog').filter({ has: page.locator('object[type="application/pdf"]') })
|
||
const pdfViewer = (page: Page) => pdfPreview(page).locator('object[type="application/pdf"]')
|
||
/** The round × in a dialog's head band, named Close by its tooltip. */
|
||
const closeX = (dialog: Locator) => dialog.getByRole('button', { name: 'Close', exact: true }).first()
|
||
/**
|
||
* A DialogShell hands the focus back to whatever opened it when it closes,
|
||
* and a Tooltip opens on focus as well as on hover. The opener of the assign
|
||
* dialog and of document sync is an icon with a tooltip, so without this the
|
||
* after-picture shows "Assign" or "Document sync" floating under the pointer
|
||
* that has long since moved away.
|
||
*/
|
||
const dropFocus = (page: Page) => page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
|
||
/** The bar's way into document sync; there only while a store is on offer (see beforeAll). */
|
||
const syncButton = (page: Page) => page.getByRole('button', { name: 'Document sync', exact: true })
|
||
/** The head band's title; the trip's name stands under it as a line of its own. */
|
||
const syncTitle = (page: Page) => modal(page).getByRole('heading', { name: /^Document sync/ })
|
||
/**
|
||
* A store row in the dialog's sidebar. Its accessible name runs on into the
|
||
* subtitle (Files in a folder), so it is matched by its start.
|
||
*/
|
||
const storeRow = (page: Page, name: string) => modal(page).getByRole('button', { name: new RegExp(`^${name}\\b`) })
|
||
/**
|
||
* A box of the connection form, by the placeholder the store's field carries.
|
||
* The labels are no good as locators here: the hint under Username says
|
||
* "email address", so a lookup by the label Address finds two boxes.
|
||
*/
|
||
const formBox = (page: Page, placeholder: string) => dialog(page).getByPlaceholder(placeholder, { exact: true })
|
||
/** The folder picker's row for the folder the fixture put in the store; the Create button is not in a list. */
|
||
const folderRow = (page: Page) => dialog(page).locator('li button').filter({ hasText: NEXTCLOUD_FOLDER }).first()
|
||
/** The binding card, the one <article> in the dialog once a store is bound. */
|
||
const bindingCard = (page: Page) => dialog(page).locator('article').first()
|
||
/** Reads Syncing, disabled, while a run is going; the locator waits it out. */
|
||
const syncNow = (page: Page) => bindingCard(page).getByRole('button', { name: 'Sync now' })
|
||
/** A healthy binding is a green dot beside the store's name, named In sync by its tooltip. */
|
||
const inSync = (page: Page) => bindingCard(page).getByRole('img', { name: 'In sync' })
|
||
|
||
interface ApiFile {
|
||
id: number
|
||
original_name: string
|
||
starred?: number
|
||
}
|
||
|
||
async function listFiles(page: Page, trash = false): Promise<ApiFile[]> {
|
||
const { tripId } = seededTrip()
|
||
const res = await page.request.get(`/api/trips/${tripId}/files${trash ? '?trash=true' : ''}`)
|
||
const body = (await res.json()) as { files?: ApiFile[] }
|
||
return body.files ?? []
|
||
}
|
||
|
||
/** Take the guide's own uploads out of the trip for good: to the trash, then off the disk. */
|
||
async function removeFiles(page: Page, names: string[]): Promise<void> {
|
||
const { tripId } = seededTrip()
|
||
for (const file of (await listFiles(page)).filter(f => names.includes(f.original_name))) {
|
||
await page.request.delete(`/api/trips/${tripId}/files/${file.id}`)
|
||
}
|
||
for (const file of (await listFiles(page, true)).filter(f => names.includes(f.original_name))) {
|
||
await page.request.delete(`/api/trips/${tripId}/files/${file.id}/permanent`)
|
||
}
|
||
}
|
||
|
||
/** Put the note and every link the guide made back to nothing. */
|
||
async function unlinkFile(page: Page, name: string): Promise<void> {
|
||
const { tripId } = seededTrip()
|
||
const file = (await listFiles(page)).find(f => f.original_name === name)
|
||
if (!file) return
|
||
await page.request.put(`/api/trips/${tripId}/files/${file.id}`, {
|
||
data: { place_id: null, reservation_id: null, description: '' },
|
||
})
|
||
const res = await page.request.get(`/api/trips/${tripId}/files/${file.id}/links`)
|
||
const { links = [] } = (await res.json()) as { links?: { id: number }[] }
|
||
for (const link of links) {
|
||
await page.request.delete(`/api/trips/${tripId}/files/${file.id}/link/${link.id}`)
|
||
}
|
||
}
|
||
|
||
/** The star route toggles, so it is only called back when the file really is starred. */
|
||
async function unstarFile(page: Page, name: string): Promise<void> {
|
||
const { tripId } = seededTrip()
|
||
const file = (await listFiles(page)).find(f => f.original_name === name)
|
||
if (file?.starred) await page.request.patch(`/api/trips/${tripId}/files/${file.id}/star`)
|
||
}
|
||
|
||
/** Bring a file the guide threw away back out of the trash, if it is still in there. */
|
||
async function untrashFile(page: Page, name: string): Promise<void> {
|
||
const { tripId } = seededTrip()
|
||
const file = (await listFiles(page, true)).find(f => f.original_name === name)
|
||
if (file) await page.request.post(`/api/trips/${tripId}/files/${file.id}/restore`)
|
||
}
|
||
|
||
const only = (target: (p: Page) => Locator) => ({ target })
|
||
|
||
const SCRIPTS: Record<string, GuideScript> = {
|
||
'files-upload': {
|
||
guide: guide('files-upload'),
|
||
// Starts on the plan, because the first step is reaching the tab.
|
||
start: p => openTrip(p, { day: null }),
|
||
steps: [
|
||
{
|
||
target: p => p.getByRole('button', { name: 'Files', exact: true }),
|
||
act: async p => {
|
||
await p.getByRole('button', { name: 'Files', exact: true }).click()
|
||
await expect(p.getByRole('heading', { name: 'Files' })).toBeVisible({ timeout: 20_000 })
|
||
await expect(dropzone(p)).toBeVisible({ timeout: 20_000 })
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
target: dropzone,
|
||
// The hidden input takes the files directly: clicking the box would open
|
||
// the operating system's picker, which Playwright cannot answer.
|
||
act: async p => {
|
||
await dropzone(p).locator('input[type="file"]').setInputFiles([
|
||
filesPdfFixture('kyoto-bus-pass'),
|
||
await filesImageFixture('arashiyama'),
|
||
])
|
||
await expect(assignDialog(p)).toBeVisible({ timeout: 60_000 })
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
// The whole panel: the head band with the file's name, the note and
|
||
// the two lists are what the step's text walks through.
|
||
target: assignDialog,
|
||
act: async p => {
|
||
await closeX(assignDialog(p)).click()
|
||
await expect(assignDialog(p)).toHaveCount(0)
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
prepare: async p => {
|
||
await expect(row(p, UPLOADED_PHOTO)).toBeVisible({ timeout: 20_000 })
|
||
await settle(p)
|
||
},
|
||
target: p => row(p, UPLOADED_PHOTO),
|
||
},
|
||
],
|
||
cleanup: p => removeFiles(p, UPLOADED),
|
||
},
|
||
'files-link': {
|
||
guide: guide('files-link'),
|
||
start: openFiles,
|
||
steps: [
|
||
{
|
||
target: p => action(p, LINKED, 'Assign'),
|
||
act: async p => {
|
||
await action(p, LINKED, 'Assign').click()
|
||
await expect(assignDialog(p)).toBeVisible({ timeout: 20_000 })
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
prepare: async p => {
|
||
await typeInto(p, noteBox(p), NOTE)
|
||
},
|
||
// The label above the box belongs in the picture, so the step rings the
|
||
// block rather than the input.
|
||
target: p => noteBox(p).locator('xpath=..'),
|
||
// The note is written on blur, and Enter blurs; the row behind the
|
||
// dialog picks it up, which is the proof that it was saved.
|
||
act: async p => {
|
||
await noteBox(p).press('Enter')
|
||
await expect(row(p, LINKED).getByText(NOTE)).toBeVisible({ timeout: 20_000 })
|
||
},
|
||
},
|
||
{
|
||
// Exact: the bookings fixture names a booking after this place, and the
|
||
// dialog lists both under the same opening words.
|
||
target: p => assignDialog(p).getByRole('button', { name: PLACE, exact: true }),
|
||
act: async p => {
|
||
await assignDialog(p).getByRole('button', { name: PLACE, exact: true }).click()
|
||
await expect(assignDialog(p).getByRole('button', { name: PLACE, exact: true }).locator('svg.lucide-check')).toBeVisible({ timeout: 20_000 })
|
||
},
|
||
},
|
||
{
|
||
target: p => assignDialog(p).getByRole('button', { name: FLIGHT }),
|
||
act: async p => {
|
||
await assignDialog(p).getByRole('button', { name: FLIGHT }).click()
|
||
await expect(assignDialog(p).getByRole('button', { name: FLIGHT }).locator('svg.lucide-check')).toBeVisible({ timeout: 20_000 })
|
||
},
|
||
},
|
||
{
|
||
target: p => closeX(assignDialog(p)),
|
||
act: async p => {
|
||
await closeX(assignDialog(p)).click()
|
||
await expect(assignDialog(p)).toHaveCount(0)
|
||
await dropFocus(p)
|
||
// A badge carries the name alone now; what it links to is its tooltip.
|
||
await expect(row(p, LINKED).getByText(FLIGHT, { exact: true })).toBeVisible({ timeout: 20_000 })
|
||
await expect(row(p, LINKED).getByText(PLACE, { exact: true })).toBeVisible({ timeout: 20_000 })
|
||
await settle(p)
|
||
},
|
||
},
|
||
],
|
||
cleanup: p => unlinkFile(p, LINKED),
|
||
},
|
||
'files-star': {
|
||
guide: guide('files-star'),
|
||
start: openFiles,
|
||
steps: [
|
||
{
|
||
target: p => action(p, STARRED, 'Star'),
|
||
act: async p => {
|
||
await action(p, STARRED, 'Star').click()
|
||
await expect(action(p, STARRED, 'Unstar')).toBeVisible({ timeout: 20_000 })
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
// Sorting is the server's (starred first, then newest first), so the
|
||
// list is only right once the reload has come back.
|
||
prepare: async p => {
|
||
await expect(rows(p).first()).toContainText(STARRED, { timeout: 20_000 })
|
||
await settle(p)
|
||
},
|
||
target: p => rows(p).first(),
|
||
},
|
||
{
|
||
target: starTab,
|
||
act: async p => {
|
||
const starred = await tabCount(starTab(p))
|
||
await starTab(p).click()
|
||
await expect(rows(p)).toHaveCount(starred)
|
||
await settle(p)
|
||
},
|
||
},
|
||
],
|
||
cleanup: p => unstarFile(p, STARRED),
|
||
},
|
||
'files-filter': {
|
||
guide: guide('files-filter'),
|
||
start: openFiles,
|
||
steps: [
|
||
only(tabRow),
|
||
{
|
||
target: p => p.getByRole('button', { name: /^PDFs \d+$/ }),
|
||
act: async p => {
|
||
const pdfs = await tabCount(p.getByRole('button', { name: /^PDFs \d+$/ }))
|
||
await p.getByRole('button', { name: /^PDFs \d+$/ }).click()
|
||
await expect(rows(p)).toHaveCount(pdfs)
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
target: p => p.getByRole('button', { name: /^Collab Notes \d+$/ }),
|
||
act: async p => {
|
||
await p.getByRole('button', { name: /^Collab Notes \d+$/ }).click()
|
||
await expect(p.getByText('From Collab Notes')).toBeVisible({ timeout: 20_000 })
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
target: p => p.getByRole('button', { name: /^All \d+$/ }),
|
||
act: async p => {
|
||
const all = await tabCount(p.getByRole('button', { name: /^All \d+$/ }))
|
||
await p.getByRole('button', { name: /^All \d+$/ }).click()
|
||
await expect(rows(p)).toHaveCount(all)
|
||
await settle(p)
|
||
},
|
||
},
|
||
],
|
||
},
|
||
'files-preview': {
|
||
guide: guide('files-preview'),
|
||
start: openFiles,
|
||
steps: [
|
||
{
|
||
// Two buttons in the row carry the file's name: the thumbnail and the
|
||
// name itself. The reader is told about the name, which comes second.
|
||
target: p => row(p, PHOTO).getByRole('button', { name: PHOTO }).last(),
|
||
act: async p => {
|
||
await row(p, PHOTO).getByRole('button', { name: PHOTO }).last().click()
|
||
await expect(p.getByRole('img', { name: PHOTO })).toBeVisible({ timeout: 30_000 })
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
target: p => lightbox(p).locator('> div').last(),
|
||
// Which way the picture can move depends on where it sits in the strip,
|
||
// and the right chevron is there only while there is a next one.
|
||
act: async p => {
|
||
const forward = (await lightbox(p).locator('svg.lucide-chevron-right').count()) > 0
|
||
await p.keyboard.press(forward ? 'ArrowRight' : 'ArrowLeft')
|
||
await expect(p.getByRole('img', { name: PHOTO })).toHaveCount(0, { timeout: 20_000 })
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
// The header's button group, not the whole header bar: the bar runs the
|
||
// full width of the frame, so ringing it rings mostly empty space.
|
||
target: p => lightbox(p).getByRole('button', { name: 'Open in new tab' }).locator('xpath=..'),
|
||
// The group's own centre falls between its buttons, where the picture
|
||
// underneath takes the pointer, so hovering it never lands. The pointer
|
||
// goes on a button instead; the ring is still round the group.
|
||
hover: p => lightbox(p).getByRole('button', { name: 'Open in new tab' }).hover(),
|
||
act: async p => {
|
||
await p.keyboard.press('Escape')
|
||
await expect(lightbox(p)).toHaveCount(0, { timeout: 20_000 })
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
// A PDF this time rather than the markdown file: the browser's own
|
||
// viewer draws the page inside the card, which is what a reader sees
|
||
// for a ticket. The card gets its source only once the signed download
|
||
// link is minted (useFileManager.ts: getAuthUrl → setPreviewFileUrl),
|
||
// and no DOM event says when the viewer has painted, so the step waits
|
||
// for the file's own response and then a moment more, as the satellite
|
||
// step of the map guide does for its tiles.
|
||
prepare: async p => {
|
||
const fetched = p
|
||
.waitForResponse(r => r.url().includes('/download?token=') && r.ok(), { timeout: 30_000 })
|
||
.catch(() => null)
|
||
await row(p, LINKED).getByRole('button', { name: LINKED }).last().click()
|
||
await expect(pdfPreview(p)).toBeVisible({ timeout: 30_000 })
|
||
await expect(pdfViewer(p)).toHaveAttribute('data', /\/download\?token=.+#view=FitH$/, { timeout: 30_000 })
|
||
await fetched
|
||
// The fallback link inside the <object> is drawn only where the
|
||
// browser has no viewer; hidden, it proves the picture shows a page.
|
||
await expect(pdfPreview(p).getByRole('button', { name: 'Download PDF' })).toBeHidden()
|
||
await settle(p)
|
||
await p.waitForTimeout(1500)
|
||
},
|
||
target: pdfPreview,
|
||
act: async p => {
|
||
await closeX(pdfPreview(p)).click()
|
||
await expect(pdfPreview(p)).toHaveCount(0, { timeout: 20_000 })
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
target: p => action(p, STARRED, 'Download'),
|
||
// The button fetches the bytes and clicks a synthetic anchor, so the
|
||
// browser reports a real download.
|
||
act: async p => {
|
||
const download = p.waitForEvent('download', { timeout: 30_000 })
|
||
await action(p, STARRED, 'Download').click()
|
||
await (await download).delete()
|
||
await settle(p)
|
||
},
|
||
},
|
||
],
|
||
},
|
||
'files-trash': {
|
||
guide: guide('files-trash'),
|
||
start: openFiles,
|
||
steps: [
|
||
{
|
||
target: p => action(p, DOOMED, 'Delete'),
|
||
act: async p => {
|
||
await action(p, DOOMED, 'Delete').click()
|
||
await expect(row(p, DOOMED)).toHaveCount(0, { timeout: 20_000 })
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
target: trashTab,
|
||
act: async p => {
|
||
await trashTab(p).click()
|
||
await expect(p.getByRole('heading', { name: 'Trash' })).toBeVisible({ timeout: 20_000 })
|
||
await expect(row(p, DOOMED)).toBeVisible({ timeout: 20_000 })
|
||
await settle(p)
|
||
},
|
||
},
|
||
only(p => row(p, DOOMED)),
|
||
{
|
||
target: p => action(p, DOOMED, 'Restore'),
|
||
act: async p => {
|
||
await action(p, DOOMED, 'Restore').click()
|
||
await expect(row(p, DOOMED)).toHaveCount(0, { timeout: 20_000 })
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
// Empty Trash is there because the fixture leaves one document in the
|
||
// trash. It asks with the browser's own confirm, which Playwright
|
||
// dismisses, so clicking it would do nothing at all; the step shows the
|
||
// button and leaves the trash by the trash icon instead.
|
||
target: p => p.getByRole('button', { name: 'Empty Trash' }),
|
||
act: async p => {
|
||
await trashTab(p).click()
|
||
await expect(p.getByRole('heading', { name: 'Files' })).toBeVisible({ timeout: 20_000 })
|
||
await expect(row(p, DOOMED)).toBeVisible({ timeout: 20_000 })
|
||
await settle(p)
|
||
},
|
||
},
|
||
],
|
||
cleanup: p => untrashFile(p, DOOMED),
|
||
},
|
||
|
||
'files-sync': {
|
||
guide: guide('files-sync'),
|
||
// The store's address and app password come from media.env (external.ts).
|
||
// They are read inside the guide rather than at import, so a run without
|
||
// the file fails this one guide and not the whole screen.
|
||
start: async p => {
|
||
const { tripId } = seededTrip()
|
||
await resetDocSync(p.request, tripId)
|
||
await ensureNextcloudFolder(drawPdf)
|
||
await openFiles(p)
|
||
await expect(syncButton(p)).toBeVisible({ timeout: 20_000 })
|
||
},
|
||
steps: [
|
||
{
|
||
target: syncButton,
|
||
act: async p => {
|
||
await syncButton(p).click()
|
||
await expect(syncTitle(p)).toBeVisible({ timeout: 20_000 })
|
||
// The stores arrive after a spinner; the heading over them says the list is in.
|
||
await expect(modal(p).getByText('Connect a provider', { exact: true })).toBeVisible({ timeout: 20_000 })
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
target: p => storeRow(p, 'Nextcloud'),
|
||
act: async p => {
|
||
await storeRow(p, 'Nextcloud').click()
|
||
// The connection dialog stacks on top, so `modal` is now that one.
|
||
await expect(modal(p).getByRole('heading', { name: 'Nextcloud', exact: true })).toBeVisible({ timeout: 20_000 })
|
||
await expect(formBox(p, 'https://cloud.example.com')).toBeVisible()
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
prepare: async p => {
|
||
const store = NEXTCLOUD_CONNECTION()
|
||
await typeInto(p, formBox(p, 'https://cloud.example.com'), store.baseUrl)
|
||
await typeInto(p, formBox(p, 'username'), store.credentials.login_name)
|
||
await typeInto(p, formBox(p, 'app password'), store.credentials.app_password)
|
||
await typeInto(p, formBox(p, '/TREK'), store.credentials.base_path)
|
||
await beat(p, 300)
|
||
},
|
||
// The whole form, as the create-user step does: the labels and hints belong in the picture.
|
||
target: dialog,
|
||
},
|
||
{
|
||
target: p => dialog(p).getByRole('button', { name: 'Test connection' }),
|
||
act: async p => {
|
||
await dialog(p).getByRole('button', { name: 'Test connection' }).click()
|
||
await expect(
|
||
dialog(p).getByText(`Reached it, signed in as ${NEXTCLOUD_CONNECTION().credentials.login_name}`),
|
||
).toBeVisible({ timeout: 30_000 })
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
// Connect saves the credentials and opens the folder picker on top of
|
||
// the dialog; the picture is the picker, with the folder the reader is
|
||
// told to click, because that is the screen the step is about.
|
||
prepare: async p => {
|
||
await dialog(p).getByRole('button', { name: 'Connect', exact: true }).click()
|
||
await expect(modal(p).getByRole('heading', { name: 'Where should this trip live in Nextcloud?' })).toBeVisible({ timeout: 30_000 })
|
||
await expect(folderRow(p)).toBeVisible({ timeout: 30_000 })
|
||
await settle(p)
|
||
},
|
||
target: folderRow,
|
||
act: async p => {
|
||
await folderRow(p).click()
|
||
await expect(bindingCard(p)).toBeVisible({ timeout: 30_000 })
|
||
await settle(p)
|
||
},
|
||
},
|
||
{
|
||
// Binding starts a run of its own, which the button cannot join: a Sync
|
||
// now while it is going answers busy and moves nothing, and the card
|
||
// only learns of that first run from the ping it sends when it is
|
||
// through. So the click is repeated until the card reports In sync,
|
||
// which is a dot named by its tooltip once the state is good.
|
||
prepare: async p => {
|
||
await expect(async () => {
|
||
await syncNow(p).click({ timeout: 15_000 })
|
||
await expect(inSync(p)).toBeVisible({ timeout: 15_000 })
|
||
}).toPass({ timeout: 120_000, intervals: [3_000] })
|
||
await settle(p)
|
||
},
|
||
target: bindingCard,
|
||
// Closed with the × in the head band, as the step's text says.
|
||
act: async p => {
|
||
await closeX(dialog(p)).click()
|
||
await expect(modal(p)).toHaveCount(0, { timeout: 20_000 })
|
||
await dropFocus(p)
|
||
// The run's file:created events put the pulled documents at the top of the list.
|
||
for (const name of NEXTCLOUD_DOCUMENTS) await expect(row(p, name)).toBeVisible({ timeout: 30_000 })
|
||
await settle(p)
|
||
},
|
||
},
|
||
],
|
||
cleanup: async p => {
|
||
const { tripId } = seededTrip()
|
||
await resetDocSync(p.request, tripId)
|
||
await removeFiles(p, NEXTCLOUD_DOCUMENTS)
|
||
// The run pushed the trip's own documents into the folder; back to its two.
|
||
await ensureNextcloudFolder(drawPdf)
|
||
},
|
||
},
|
||
}
|
||
|
||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||
|
||
test.describe.configure({ mode: 'serial' })
|
||
|
||
test.beforeAll(async ({ request }) => {
|
||
await ensureFilesFixtures(request)
|
||
// A store on offer puts Document sync beside the trash icon for the trip's
|
||
// owner, which is how bullet 6 of the screen text describes the bar; on for
|
||
// the whole file, so the hero and all seven guides show the same bar.
|
||
await toggleAddon(request, 'nextcloud', true)
|
||
})
|
||
|
||
test.afterAll(async ({ request }) => {
|
||
// Instance-wide, not trip-scoped: every store ships off, and the other
|
||
// screens' pictures must not grow the button. Here rather than in the last
|
||
// guide's cleanup, so a failed run puts it back too (as trip-roadtrip does).
|
||
await toggleAddon(request, 'nextcloud', false)
|
||
})
|
||
|
||
test('every registered files guide has a script, and only those', async () => {
|
||
expect(Object.keys(SCRIPTS).sort()).toEqual([...tripFilesContext.guides].sort())
|
||
})
|
||
|
||
test('hero: trip-files', async ({ page }) => {
|
||
await page.setViewportSize(VIEWPORT)
|
||
await captureHero(page, tripFilesContext.id, openFiles)
|
||
})
|
||
|
||
for (const id of tripFilesContext.guides) {
|
||
test(`pictures: ${id}`, async ({ page }) => {
|
||
await page.setViewportSize(VIEWPORT)
|
||
await captureGuide(page, SCRIPTS[id])
|
||
})
|
||
}
|