lifecycle.spec.ts 6.8 KB

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