lifecycle.spec.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. import { expect, type Locator, type Page, test } from '@playwright/test'
  2. import { settle } from '../../support/hydration'
  3. import {
  4. addLabor,
  5. addPart,
  6. laborRows,
  7. newWorkOrder,
  8. partRowOf,
  9. partRows,
  10. saveWorkOrder,
  11. seededVehicleUrl,
  12. shareLink,
  13. totalsRow,
  14. } from '../../support/work-order'
  15. /**
  16. * A job from the moment it is written up to the moment it is finished: parts
  17. * and labour added, priced, corrected, a line removed, the status walked
  18. * through to completed, and the customer's copy checked at the end.
  19. *
  20. * This is the path every workshop walks several times a day, so it is a
  21. * single serial story rather than independent tests: each step edits the job
  22. * the step before it left.
  23. */
  24. test.describe.configure({ mode: 'serial' })
  25. const stamp = Date.now()
  26. const TITLE = `E2E lifecycle ${stamp}`
  27. const PART = `E2E oil filter ${stamp}`
  28. const SECOND_PART = `E2E air filter ${stamp}`
  29. /** A part name written over three lines, the way a counter hand describes one. */
  30. const MULTILINE_PART = `E2E timing belt kit ${stamp}\nGates K015603XS\nincludes tensioner and idler`
  31. const MULTILINE_LABOR = 'Replace timing belt\nand water pump\nrefill coolant'
  32. let vehicleUrl = ''
  33. let jobUrl = ''
  34. /** The status stepper in the page header: one button per stage. */
  35. function stage(page: Page, name: string): Locator {
  36. return page.getByTestId('status-stepper').getByRole('button', { name: new RegExp(name) })
  37. }
  38. /** Moves the job to a stage. A click before hydration does nothing, so it is repeated. */
  39. async function setStatus(page: Page, name: string): Promise<void> {
  40. await expect(async () => {
  41. await stage(page, name).click()
  42. await expect(stage(page, name)).toHaveAttribute('aria-current', 'step', { timeout: 2_000 })
  43. }).toPass({ timeout: 30_000 })
  44. }
  45. test.beforeAll(async ({ browser }) => {
  46. const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
  47. vehicleUrl = await seededVehicleUrl(page)
  48. await page.close()
  49. })
  50. test.describe('a work order from intake to completion', () => {
  51. test('a job is opened on the vehicle and saved with one part', async ({ page }) => {
  52. jobUrl = await newWorkOrder(page, vehicleUrl, TITLE)
  53. await addPart(page, { name: PART, quantity: 2, unitPrice: 120 })
  54. await saveWorkOrder(page)
  55. await page.reload()
  56. await expect(page.locator('input[name="title"]')).toHaveValue(TITLE)
  57. await expect(totalsRow(page, 'Parts')).toContainText('$240.00')
  58. })
  59. test('labour is added and the totals follow', async ({ page }) => {
  60. await page.goto(jobUrl)
  61. await addLabor(page, { description: 'Change the oil and filter', hours: 1, rate: 800 })
  62. await saveWorkOrder(page)
  63. await expect(totalsRow(page, 'Labor')).toContainText('$800.00')
  64. await expect(totalsRow(page, 'Subtotal')).toContainText('$1,040.00')
  65. })
  66. test('a price is corrected and the totals follow it', async ({ page }) => {
  67. await page.goto(jobUrl)
  68. const quantity = partRowOf(partRows(page).first()).locator('input[type="number"]').first()
  69. await expect(quantity).toHaveValue('2')
  70. // A field can carry the typed value while React's state is still empty,
  71. // and the editor saves its state, not the DOM. The totals moving is the
  72. // only proof the change was taken.
  73. await expect(async () => {
  74. await quantity.fill('3')
  75. await expect(totalsRow(page, 'Parts')).toContainText('$360.00', { timeout: 2_000 })
  76. }).toPass({ timeout: 30_000 })
  77. await saveWorkOrder(page)
  78. await page.reload()
  79. await expect(totalsRow(page, 'Parts')).toContainText('$360.00')
  80. await expect(totalsRow(page, 'Subtotal')).toContainText('$1,160.00')
  81. })
  82. test('a second part is added and then removed again', async ({ page }) => {
  83. await page.goto(jobUrl)
  84. await addPart(page, { name: SECOND_PART, quantity: 1, unitPrice: 90 })
  85. await saveWorkOrder(page)
  86. await expect(totalsRow(page, 'Parts')).toContainText('$450.00')
  87. await page.reload()
  88. await expect(async () => {
  89. await partRowOf(partRows(page).first())
  90. .getByRole('button', { name: 'Delete row', exact: true })
  91. .click()
  92. await expect(partRows(page)).toHaveCount(1, { timeout: 2_000 })
  93. }).toPass({ timeout: 30_000 })
  94. await saveWorkOrder(page)
  95. await page.reload()
  96. await expect(totalsRow(page, 'Parts')).toContainText('$360.00')
  97. await expect(partRows(page)).toHaveCount(1)
  98. })
  99. test('a line written over several lines keeps its shape', async ({ page }) => {
  100. await page.goto(jobUrl)
  101. // Both fields are textareas, so a description too long for one line is
  102. // written the way it reads. What is typed has to survive the save, the
  103. // reload and the customer's copy.
  104. await addPart(page, { name: MULTILINE_PART, quantity: 1, unitPrice: 4200 })
  105. await addLabor(page, { description: MULTILINE_LABOR, hours: 4, rate: 800 })
  106. await saveWorkOrder(page)
  107. await page.reload()
  108. await expect(partRows(page).first()).toHaveValue(MULTILINE_PART)
  109. await expect(laborRows(page).first()).toHaveValue(MULTILINE_LABOR)
  110. })
  111. test('the customer copy prints every line of it', async ({ page }) => {
  112. await page.goto(jobUrl)
  113. const url = await shareLink(page)
  114. await page.goto(url)
  115. for (const line of [...MULTILINE_PART.split('\n'), ...MULTILINE_LABOR.split('\n')]) {
  116. await expect(
  117. page.getByText(line, { exact: false }).filter({ visible: true }).first(),
  118. `"${line}" on the shared invoice`
  119. ).toBeVisible()
  120. }
  121. })
  122. test('the status walks through to completed', async ({ page }) => {
  123. await page.goto(jobUrl)
  124. await setStatus(page, 'In Progress')
  125. await saveWorkOrder(page)
  126. await page.reload()
  127. await settle(page)
  128. await expect(stage(page, 'In Progress')).toHaveAttribute('aria-current', 'step')
  129. // The badge in the header used to print the stored value beside a select
  130. // that said it properly, so the same job read "in-progress" and
  131. // "In Progress" an inch apart.
  132. await expect(page.getByText('in-progress', { exact: true })).toHaveCount(0)
  133. await setStatus(page, 'Completed')
  134. await saveWorkOrder(page)
  135. await page.reload()
  136. await settle(page)
  137. await expect(stage(page, 'Completed')).toHaveAttribute('aria-current', 'step')
  138. })
  139. test('the finished job is on the work orders list with its number', async ({ page }) => {
  140. const number = await (async () => {
  141. await page.goto(jobUrl)
  142. return page.getByLabel('Invoice Number').inputValue()
  143. })()
  144. expect(number).not.toBe('')
  145. // Found through the list's own search, not by scrolling: the list shows
  146. // twenty jobs a page, and a workshop with a few hundred of them (or a
  147. // long-lived e2e database) never has today's job on the first one.
  148. await page.goto(`/work-orders?search=${encodeURIComponent(TITLE)}`)
  149. // The list draws a card copy for narrow screens beside the table.
  150. await expect(page.getByText(TITLE).filter({ visible: true }).first()).toBeVisible()
  151. await expect(page.getByText(number).filter({ visible: true }).first()).toBeVisible()
  152. })
  153. })