| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182 |
- import { expect, type Locator, type Page, test } from '@playwright/test'
- import { settle } from '../../support/hydration'
- import {
- addLabor,
- addPart,
- laborRows,
- newWorkOrder,
- partRowOf,
- partRows,
- saveWorkOrder,
- seededVehicleUrl,
- shareLink,
- totalsRow,
- } from '../../support/work-order'
- /**
- * A job from the moment it is written up to the moment it is finished: parts
- * and labour added, priced, corrected, a line removed, the status walked
- * through to completed, and the customer's copy checked at the end.
- *
- * This is the path every workshop walks several times a day, so it is a
- * single serial story rather than independent tests: each step edits the job
- * the step before it left.
- */
- test.describe.configure({ mode: 'serial' })
- const stamp = Date.now()
- const TITLE = `E2E lifecycle ${stamp}`
- const PART = `E2E oil filter ${stamp}`
- const SECOND_PART = `E2E air filter ${stamp}`
- /** A part name written over three lines, the way a counter hand describes one. */
- const MULTILINE_PART = `E2E timing belt kit ${stamp}\nGates K015603XS\nincludes tensioner and idler`
- const MULTILINE_LABOR = 'Replace timing belt\nand water pump\nrefill coolant'
- let vehicleUrl = ''
- let jobUrl = ''
- /** The status stepper in the page header: one button per stage. */
- function stage(page: Page, name: string): Locator {
- return page.getByTestId('status-stepper').getByRole('button', { name: new RegExp(name) })
- }
- /** Moves the job to a stage. A click before hydration does nothing, so it is repeated. */
- async function setStatus(page: Page, name: string): Promise<void> {
- await expect(async () => {
- await stage(page, name).click()
- await expect(stage(page, name)).toHaveAttribute('aria-current', 'step', { timeout: 2_000 })
- }).toPass({ timeout: 30_000 })
- }
- test.beforeAll(async ({ browser }) => {
- const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
- vehicleUrl = await seededVehicleUrl(page)
- await page.close()
- })
- test.describe('a work order from intake to completion', () => {
- test('a job is opened on the vehicle and saved with one part', async ({ page }) => {
- jobUrl = await newWorkOrder(page, vehicleUrl, TITLE)
- await addPart(page, { name: PART, quantity: 2, unitPrice: 120 })
- await saveWorkOrder(page)
- await page.reload()
- await expect(page.locator('input[name="title"]')).toHaveValue(TITLE)
- await expect(totalsRow(page, 'Parts')).toContainText('$240.00')
- })
- test('labour is added and the totals follow', async ({ page }) => {
- await page.goto(jobUrl)
- await addLabor(page, { description: 'Change the oil and filter', hours: 1, rate: 800 })
- await saveWorkOrder(page)
- await expect(totalsRow(page, 'Labor')).toContainText('$800.00')
- await expect(totalsRow(page, 'Subtotal')).toContainText('$1,040.00')
- })
- test('a price is corrected and the totals follow it', async ({ page }) => {
- await page.goto(jobUrl)
- const quantity = partRowOf(partRows(page).first()).locator('input[type="number"]').first()
- await expect(quantity).toHaveValue('2')
- // A field can carry the typed value while React's state is still empty,
- // and the editor saves its state, not the DOM. The totals moving is the
- // only proof the change was taken.
- await expect(async () => {
- await quantity.fill('3')
- await expect(totalsRow(page, 'Parts')).toContainText('$360.00', { timeout: 2_000 })
- }).toPass({ timeout: 30_000 })
- await saveWorkOrder(page)
- await page.reload()
- await expect(totalsRow(page, 'Parts')).toContainText('$360.00')
- await expect(totalsRow(page, 'Subtotal')).toContainText('$1,160.00')
- })
- test('a second part is added and then removed again', async ({ page }) => {
- await page.goto(jobUrl)
- await addPart(page, { name: SECOND_PART, quantity: 1, unitPrice: 90 })
- await saveWorkOrder(page)
- await expect(totalsRow(page, 'Parts')).toContainText('$450.00')
- await page.reload()
- await expect(async () => {
- await partRowOf(partRows(page).first())
- .getByRole('button', { name: 'Delete row', exact: true })
- .click()
- await expect(partRows(page)).toHaveCount(1, { timeout: 2_000 })
- }).toPass({ timeout: 30_000 })
- await saveWorkOrder(page)
- await page.reload()
- await expect(totalsRow(page, 'Parts')).toContainText('$360.00')
- await expect(partRows(page)).toHaveCount(1)
- })
- test('a line written over several lines keeps its shape', async ({ page }) => {
- await page.goto(jobUrl)
- // Both fields are textareas, so a description too long for one line is
- // written the way it reads. What is typed has to survive the save, the
- // reload and the customer's copy.
- await addPart(page, { name: MULTILINE_PART, quantity: 1, unitPrice: 4200 })
- await addLabor(page, { description: MULTILINE_LABOR, hours: 4, rate: 800 })
- await saveWorkOrder(page)
- await page.reload()
- await expect(partRows(page).first()).toHaveValue(MULTILINE_PART)
- await expect(laborRows(page).first()).toHaveValue(MULTILINE_LABOR)
- })
- test('the customer copy prints every line of it', async ({ page }) => {
- await page.goto(jobUrl)
- const url = await shareLink(page)
- await page.goto(url)
- for (const line of [...MULTILINE_PART.split('\n'), ...MULTILINE_LABOR.split('\n')]) {
- await expect(
- page.getByText(line, { exact: false }).filter({ visible: true }).first(),
- `"${line}" on the shared invoice`
- ).toBeVisible()
- }
- })
- test('the status walks through to completed', async ({ page }) => {
- await page.goto(jobUrl)
- await setStatus(page, 'In Progress')
- await saveWorkOrder(page)
- await page.reload()
- await settle(page)
- await expect(stage(page, 'In Progress')).toHaveAttribute('aria-current', 'step')
- // The badge in the header used to print the stored value beside a select
- // that said it properly, so the same job read "in-progress" and
- // "In Progress" an inch apart.
- await expect(page.getByText('in-progress', { exact: true })).toHaveCount(0)
- await setStatus(page, 'Completed')
- await saveWorkOrder(page)
- await page.reload()
- await settle(page)
- await expect(stage(page, 'Completed')).toHaveAttribute('aria-current', 'step')
- })
- test('the finished job is on the work orders list with its number', async ({ page }) => {
- const number = await (async () => {
- await page.goto(jobUrl)
- return page.getByLabel('Invoice Number').inputValue()
- })()
- expect(number).not.toBe('')
- // Found through the list's own search, not by scrolling: the list shows
- // twenty jobs a page, and a workshop with a few hundred of them (or a
- // long-lived e2e database) never has today's job on the first one.
- await page.goto(`/work-orders?search=${encodeURIComponent(TITLE)}`)
- // The list draws a card copy for narrow screens beside the table.
- await expect(page.getByText(TITLE).filter({ visible: true }).first()).toBeVisible()
- await expect(page.getByText(number).filter({ visible: true }).first()).toBeVisible()
- })
- })
|