| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510 |
- import { expect, type Locator, type Page, test } from '@playwright/test'
- import {
- deleteTechnicians,
- insertTechnician,
- jobAssignment,
- ownerOrganizationId,
- paymentsFor,
- } from '../../support/db'
- import { fillSettled, settle } from '../../support/hydration'
- import { makePdf, TINY_PNG } from '../../support/pdf'
- import { setInvoiceLock } from '../../support/settings'
- import {
- addPart,
- jobIdOf,
- newWorkOrder,
- partRows,
- saveWorkOrder,
- seededVehicleUrl,
- shareLink,
- } from '../../support/work-order'
- /**
- * What the work order page does, beyond laying the fields out.
- * `modern-layout.spec.ts` covers the one-copy-of-each-field rule; this file
- * drives the things that exist
- * only here or behave differently here: the job facts and the header's type,
- * the concern story (condition, cause, correction, confirm) and the question
- * before completing a job with a concern nobody confirmed, the files card,
- * the technician list, the notes, and a locked invoice that still takes a
- * status, a payment and an internal note.
- *
- * The jobs are made first, each with a part: `/service/new` hands back an
- * untouched draft younger than five seconds instead of making a new one.
- */
- test.describe.configure({ mode: 'serial' })
- const stamp = Date.now()
- let vehicleUrl = ''
- let factsJob = ''
- let filesJob = ''
- let scheduleJob = ''
- test.beforeAll(async ({ browser }) => {
- const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
- vehicleUrl = await seededVehicleUrl(page)
- factsJob = await jobWithPart(page, `E2E modern facts ${stamp}`)
- filesJob = await jobWithPart(page, `E2E modern files ${stamp}`)
- scheduleJob = await jobWithPart(page, `E2E modern schedule ${stamp}`)
- await page.close()
- })
- async function jobWithPart(page: Page, title: string): Promise<string> {
- const url = await newWorkOrder(page, vehicleUrl, title)
- await addPart(page, { name: `${title} part`, quantity: 1, unitPrice: 500 })
- await saveWorkOrder(page)
- return url
- }
- /** Opens a job on the overhauled page and waits for it to be usable. */
- async function openModern(page: Page, url: string): Promise<void> {
- await page.goto(url)
- await settle(page)
- await expect(page.getByTestId('service-layout-modern')).toBeVisible()
- }
- /**
- * Clicks low in a notes box, well below the first line, and types. The box is
- * taller than what is written in it, and a click in that empty space has to
- * put the cursor in the text the way a textarea does.
- */
- async function typeInNotes(page: Page, card: Locator, text: string): Promise<void> {
- const area = card.getByTestId('rich-text-area')
- const editor = card.locator('.ProseMirror')
- await expect(async () => {
- const box = await area.boundingBox()
- if (!box) throw new Error('notes box not on screen')
- await area.click({ position: { x: box.width / 2, y: box.height - 12 } })
- await expect(editor).toBeFocused({ timeout: 2_000 })
- }).toPass({ timeout: 30_000 })
- await page.keyboard.type(text)
- await expect(editor).toContainText(text)
- }
- function stepper(page: Page): Locator {
- return page.getByTestId('status-stepper')
- }
- async function expectStatus(page: Page, name: RegExp): Promise<void> {
- await expect(stepper(page).getByRole('button', { name })).toHaveAttribute('aria-current', 'step')
- }
- test.describe('the overhauled work order page', () => {
- test('keeps the type, set in the header, and the mileage, set on the facts card', async ({
- page,
- }) => {
- await openModern(page, factsJob)
- const facts = page.getByTestId('job-facts')
- await expect(facts).toContainText('Camry')
- await expect(facts.getByRole('link', { name: 'Open customer' })).toBeVisible()
- const type = page.getByTestId('service-type')
- await expect(async () => {
- await type.click()
- await expect(page.getByRole('option', { name: 'Repair' })).toBeVisible({ timeout: 2_000 })
- }).toPass({ timeout: 30_000 })
- await page.getByRole('option', { name: 'Repair' }).click()
- await fillSettled(page.locator('#mileage'), '123456')
- await saveWorkOrder(page)
- await page.reload()
- await settle(page)
- await expect(page.getByTestId('service-type')).toContainText('Repair')
- await expect(page.locator('#mileage')).toHaveValue('123456')
- })
- test('shows the whole customer and vehicle under "More info", with the VIN one click from the clipboard', async ({
- page,
- context,
- baseURL,
- }) => {
- await context.grantPermissions(['clipboard-read', 'clipboard-write'], {
- origin: baseURL ?? 'http://127.0.0.1:3100',
- })
- await openModern(page, factsJob)
- const facts = page.getByTestId('job-facts')
- const details = facts.getByTestId('vehicle-details')
- await expect(details).toHaveCount(0)
- await expect(async () => {
- await facts.getByRole('button', { name: 'More info' }).click()
- await expect(details).toBeVisible({ timeout: 2_000 })
- }).toPass({ timeout: 30_000 })
- await expect(details.getByTestId('vehicle-detail-vehicle')).toContainText('Camry')
- // The same switch opens the customer's half: every customer has a "since".
- await expect(facts.getByTestId('customer-details')).toContainText('Customer since')
- // The reason for the list: the VIN, copied without leaving the job.
- const vinRow = details.getByTestId('vehicle-detail-vin')
- const vin = (await vinRow.locator('dd span').first().innerText()).trim()
- expect(vin).toMatch(/^[A-HJ-NPR-Z0-9]{17}$/)
- await vinRow.getByRole('button', { name: 'Copy VIN' }).click()
- await expect(vinRow.getByRole('button', { name: 'Copied' })).toBeVisible()
- expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(vin)
- // The plate on the card copies too; the vehicle's name is the link.
- const plate = facts.getByTestId('copy-plate')
- const plateText = (await plate.innerText()).trim()
- await plate.click()
- await expect(plate).toContainText('Copied')
- expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(plateText)
- await expect(plate).toHaveText(plateText)
- // Left open, it opens again next time; closed, it stays closed.
- await page.reload()
- await settle(page)
- await expect(page.getByTestId('vehicle-details')).toBeVisible()
- await page.getByTestId('job-facts').getByRole('button', { name: 'Less info' }).click()
- await expect(page.getByTestId('vehicle-details')).toHaveCount(0)
- await expect(page.getByTestId('customer-details')).toHaveCount(0)
- })
- test('writes in both notes from a click anywhere in the box, and saves them', async ({
- page,
- }) => {
- await openModern(page, factsJob)
- const internal = `Pads measured 2 mm on the left ${stamp}`
- const customer = `Front pads replaced, discs within limits ${stamp}`
- await typeInNotes(page, page.getByTestId('notes-internal'), internal)
- await typeInNotes(page, page.getByTestId('notes-customer'), customer)
- await saveWorkOrder(page)
- await page.reload()
- await settle(page)
- await expect(page.getByTestId('notes-internal').locator('.ProseMirror')).toContainText(internal)
- await expect(page.getByTestId('notes-customer').locator('.ProseMirror')).toContainText(customer)
- })
- test('tells a concern as condition, cause, correction and confirmation', async ({ page }) => {
- await openModern(page, factsJob)
- const concerns = page.getByTestId('customer-concerns')
- const story = concerns.getByTestId('concern-story').first()
- const words = {
- condition: `Squeal from the front left when braking ${stamp}`,
- cause: 'Inner pad worn to the backing plate',
- correction: 'Replaced front pads and cleaned the slides',
- confirm: 'Test drive, no noise',
- }
- await fillSettled(
- story.getByPlaceholder('What the customer reported, or what you observed'),
- words.condition
- )
- await story.getByTestId('concern-step-cause').click()
- await story.getByPlaceholder('What testing showed the root cause to be').fill(words.cause)
- await story.getByTestId('concern-step-correction').click()
- await story.getByPlaceholder('What was done to fix it').fill(words.correction)
- await story.getByTestId('concern-step-confirm').click()
- await story
- .getByPlaceholder('How the fix was checked: test drive, re-scan, symptom gone')
- .fill(words.confirm)
- await story.getByTestId('concern-confirmed').click()
- await expect(story).toContainText('Stamped with your name when you save')
- await saveWorkOrder(page)
- await page.reload()
- await settle(page)
- const saved = page.getByTestId('customer-concerns').getByTestId('concern-story').first()
- await expect(saved.getByTestId('concern-step-condition')).toContainText(words.condition)
- await expect(saved.getByTestId('concern-step-cause')).toContainText(words.cause)
- await expect(saved.getByTestId('concern-step-correction')).toContainText(words.correction)
- await expect(saved.getByTestId('concern-step-confirm')).toContainText(words.confirm)
- await saved.getByTestId('concern-step-confirm').click()
- // The stamp is the server's: who ticked it, and when.
- await expect(saved).toContainText('Confirmed by')
- await expect(page.getByTestId('concerns-confirmed')).toHaveText('1 of 1 confirmed')
- })
- test('asks before completing a job with a concern nobody confirmed', async ({ page }) => {
- await openModern(page, factsJob)
- const concerns = page.getByTestId('customer-concerns')
- const stories = concerns.getByTestId('concern-story')
- const add = concerns.getByRole('button', { name: 'Add another' })
- // A blank row may already be waiting; otherwise ask for one.
- if (await add.isEnabled()) {
- const before = await stories.count()
- await expect(async () => {
- await add.click()
- await expect(stories).toHaveCount(before + 1, { timeout: 2_000 })
- }).toPass({ timeout: 30_000 })
- }
- const open = `Aircon smells musty ${stamp}`
- await stories
- .last()
- .getByPlaceholder('What the customer reported, or what you observed')
- .fill(open)
- await saveWorkOrder(page)
- const question = page.getByRole('alertdialog', { name: 'Complete with unconfirmed concerns?' })
- await stepper(page)
- .getByRole('button', { name: /Completed/ })
- .click()
- await expect(question).toContainText(open)
- await question.getByRole('button', { name: 'Cancel' }).click()
- await expect(question).toBeHidden()
- await expect(stepper(page).getByRole('button', { name: /Completed/ })).not.toHaveAttribute(
- 'aria-current',
- 'step'
- )
- await stepper(page)
- .getByRole('button', { name: /Completed/ })
- .click()
- await question.getByRole('button', { name: 'Complete anyway' }).click()
- await expectStatus(page, /Completed/)
- await saveWorkOrder(page)
- await page.reload()
- await settle(page)
- await expectStatus(page, /Completed/)
- })
- test('files photos and video on the job, shown to the customer until hidden', async ({
- page,
- }) => {
- await openModern(page, filesJob)
- const files = page.getByTestId('files-media')
- const tiles = files.getByTestId('media-tile')
- // A photo: shown to the customer from the moment it lands, then hidden.
- await files.locator('input[type="file"]').setInputFiles({
- name: `e2e-photo-${stamp}.png`,
- mimeType: 'image/png',
- buffer: TINY_PNG,
- })
- await expect(tiles).toHaveCount(1)
- await expect(tiles.first()).toContainText('Customer can see')
- await expect(async () => {
- await tiles.first().getByRole('button', { name: 'Hide from the customer' }).click()
- await expect(tiles.first()).not.toContainText('Customer can see', { timeout: 2_000 })
- }).toPass({ timeout: 30_000 })
- // Read back: the switch turns before the write lands.
- await expect(async () => {
- await page.reload()
- await settle(page)
- await expect(page.getByTestId('media-tile').first()).not.toContainText('Customer can see', {
- timeout: 2_000,
- })
- }).toPass({ timeout: 30_000 })
- // A video goes to the customer too, as on the classic page: the shared
- // link plays it. And it plays here, not in a new tab.
- const clip = `e2e-clip-${stamp}.mp4`
- await files.getByRole('tab', { name: /^Video/ }).click()
- await files.locator('input[type="file"]').setInputFiles({
- name: clip,
- mimeType: 'video/mp4',
- // The server keeps the bytes as they came when it cannot re-encode them.
- buffer: Buffer.from(`not really a video ${stamp}`),
- })
- await expect(tiles).toHaveCount(1)
- await expect(tiles.first()).toContainText('Customer can see')
- await tiles.first().getByRole('button', { name: clip }).click()
- const player = page.getByRole('dialog', { name: clip })
- await expect(player.locator('video')).toHaveAttribute('src', /\/api\/protected\/files\//)
- await page.keyboard.press('Escape')
- await expect(player).toBeHidden()
- })
- test('lands a link to an old tab on that tab of the files card', async ({ page }) => {
- await page.goto(`${filesJob}?tab=documents`)
- await settle(page)
- const files = page.getByTestId('files-media')
- await expect(files.getByRole('tab', { name: /^Documents/ })).toHaveAttribute(
- 'aria-selected',
- 'true'
- )
- await expect(files).toBeInViewport()
- })
- test("takes photos on a phone that scanned the desk's code, and shows them at the desk as they land", async ({
- page,
- browser,
- }) => {
- await openModern(page, factsJob)
- const files = page.getByTestId('files-media')
- const before = await files.getByTestId('media-tile').count()
- const dialog = page.getByTestId('photo-handoff-dialog')
- await expect(async () => {
- await files.getByTestId('photo-handoff-open').click()
- await expect(dialog).toBeVisible({ timeout: 2_000 })
- }).toPass({ timeout: 30_000 })
- const link = dialog.getByTestId('photo-handoff-link')
- await expect(link).toHaveValue(/\/p\/ph1\./)
- const url = await link.inputValue()
- // The phone: another browser, signed in to nothing.
- const phone = await browser.newContext({
- storageState: { cookies: [], origins: [] },
- viewport: { width: 390, height: 844 },
- })
- const phonePage = await phone.newPage()
- await phonePage.goto(url)
- await settle(phonePage)
- await expect(phonePage.getByRole('heading', { name: 'Add photos and documents' })).toBeVisible()
- await expect(phonePage.getByRole('button', { name: 'Take photo' })).toBeVisible()
- // The workshop's own page, as far as the customer holding the phone can tell.
- await expect(phonePage.getByTestId('photo-handoff-workshop')).not.toBeEmpty()
- await phonePage.getByTestId('photo-handoff-library').setInputFiles({
- name: `e2e-phone-${stamp}.png`,
- mimeType: 'image/png',
- buffer: TINY_PNG,
- })
- await expect(phonePage.getByTestId('photo-handoff-added')).toHaveText('1 file added')
- // And a customer's paperwork: a PDF goes on the job as a document.
- await phonePage.getByTestId('photo-handoff-document').setInputFiles({
- name: `e2e-registration-${stamp}.pdf`,
- mimeType: 'application/pdf',
- buffer: await makePdf([`Registration ${stamp}`]),
- })
- await expect(phonePage.getByTestId('photo-handoff-added')).toHaveText('2 files added')
- // A link somebody changed opens nothing.
- await phonePage.goto(url.replace(/.$/, (last) => (last === 'A' ? 'B' : 'A')))
- await expect(phonePage.getByTestId('photo-handoff-problem')).toContainText(
- 'This link does not work'
- )
- await phone.close()
- // At the desk the photo arrives without anybody reloading.
- await expect(dialog.getByTestId('photo-handoff-received')).toHaveText('2 files received', {
- timeout: 15_000,
- })
- await dialog.getByRole('button', { name: 'Done' }).click()
- await expect(files.getByTestId('media-tile')).toHaveCount(before + 1)
- await files.getByRole('tab', { name: /^Documents/ }).click()
- // A document's name is its caption until somebody writes one.
- await expect(files.getByPlaceholder(`e2e-registration-${stamp}.pdf`)).toHaveCount(1)
- })
- test.describe('the technician list', () => {
- let technicianId = ''
- const technicianName = `E2E Mechanic ${stamp}`
- test.beforeAll(async () => {
- technicianId = await insertTechnician(await ownerOrganizationId(), technicianName)
- })
- test.afterAll(async () => {
- await deleteTechnicians([technicianId])
- })
- test('puts a technician on the job with one click', async ({ page }) => {
- await openModern(page, scheduleJob)
- const list = page.getByRole('radiogroup', { name: 'Technician' })
- const technician = list.getByRole('radio', { name: new RegExp(technicianName) })
- await expect(async () => {
- await technician.click()
- await expect(technician).toHaveAttribute('aria-checked', 'true', { timeout: 2_000 })
- }).toPass({ timeout: 30_000 })
- await expect
- .poll(async () => (await jobAssignment(jobIdOf(scheduleJob))).technicianId)
- .toBe(technicianId)
- await page.reload()
- await settle(page)
- await expect(
- page
- .getByRole('radiogroup', { name: 'Technician' })
- .getByRole('radio', { name: new RegExp(technicianName) })
- ).toHaveAttribute('aria-checked', 'true')
- // The way to the workshop's defaults is here, as on the classic picker.
- await expect(page.getByRole('link', { name: 'Set Defaults' })).toHaveAttribute(
- 'href',
- '/settings/workshop'
- )
- })
- })
- test.describe('on a locked invoice', () => {
- let lockedJob = ''
- test.beforeAll(async ({ browser }) => {
- const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
- // On first, so the share below is a send the lock counts.
- await setInvoiceLock(page, { enabled: true, trigger: 'sent' })
- lockedJob = await jobWithPart(page, `E2E modern locked ${stamp}`)
- await shareLink(page)
- await page.close()
- })
- test.afterAll(async ({ browser }) => {
- const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
- await setInvoiceLock(page, { enabled: false, trigger: 'sent' })
- await page.close()
- })
- test('freezes what is owed and nothing else', async ({ page }) => {
- await openModern(page, lockedJob)
- await expect(
- page.getByText('This invoice is locked because it has been sent', { exact: true })
- ).toBeVisible()
- // Frozen: the lines, the mileage, and the notes the invoice prints.
- await expect(partRows(page).first()).toBeDisabled()
- await expect(page.locator('#mileage')).toBeDisabled()
- // Still readable: the vehicle's details open on a locked invoice too.
- await page.getByTestId('job-facts').getByRole('button', { name: 'More info' }).click()
- await expect(page.getByTestId('vehicle-details')).toBeVisible()
- await expect(page.getByTestId('notes-customer').locator('.ProseMirror')).toHaveAttribute(
- 'contenteditable',
- 'false'
- )
- // Still moving: the status, saved on its own. "Waiting Parts" is filed
- // under "In Progress", in the menu behind that stage's arrow, and the
- // stage then reads "In Progress: Waiting Parts".
- await expect(async () => {
- await page.getByTestId('status-menu-in-progress').click()
- await expect(page.getByRole('menuitem', { name: 'Waiting Parts' })).toBeVisible({
- timeout: 2_000,
- })
- }).toPass({ timeout: 30_000 })
- await page.getByRole('menuitem', { name: 'Waiting Parts' }).click()
- await expectStatus(page, /Waiting Parts/)
- // Still written: the internal notes, saved on their own.
- const note = `Customer will collect on Friday ${stamp}`
- await typeInNotes(page, page.getByTestId('notes-internal'), note)
- await expect(page.getByText('Saved', { exact: true })).toBeVisible()
- // Still paid: the bar's button opens a form that works, and Enter in
- // the amount records the payment rather than saving the job.
- const bar = page.getByTestId('money-bar')
- await bar.getByRole('button', { name: 'Take payment' }).click()
- const amount = page.locator('#paymentAmount')
- await expect(amount).toBeEnabled()
- await expect(amount).toBeFocused()
- await amount.fill('100')
- await amount.press('Enter')
- await expect(page.getByText('Payment recorded', { exact: true })).toBeVisible()
- // Recording a payment offers to tell the customer; not today.
- const notify = page.getByRole('dialog', { name: /^Notify / })
- await expect(notify).toBeVisible()
- await page.keyboard.press('Escape')
- await expect(notify).toBeHidden()
- await expect(bar.getByTestId('money-paid')).toContainText('100')
- await expect.poll(async () => (await paymentsFor(jobIdOf(lockedJob))).length).toBe(1)
- expect((await paymentsFor(jobIdOf(lockedJob)))[0].amount).toBe(100)
- await page.reload()
- await settle(page)
- await expectStatus(page, /Waiting Parts/)
- await expect(page.getByTestId('notes-internal').locator('.ProseMirror')).toContainText(note)
- const activity = page
- .getByRole('heading', { name: 'Activity', exact: true })
- .locator('xpath=ancestor::*[.//ol][1]')
- await expect(activity).toContainText('Invoice link shared')
- await expect(activity).toContainText('Payment of')
- })
- })
- })
|