modern-layout.spec.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. import { expect, test } from '@playwright/test'
  2. import { settle } from '../../support/hydration'
  3. import { addPart, newWorkOrder, saveWorkOrder, seededVehicleUrl } from '../../support/work-order'
  4. /**
  5. * The overhauled work order page, and the rule that nobody is moved to it
  6. * without asking.
  7. *
  8. * The classic page carries an invitation; accepting it switches this browser
  9. * (a cookie, so the server renders the right page from then on), and the new
  10. * page carries the way back. Both pages are the same form, so what these
  11. * tests hold the new one to is what `layout.spec.ts` holds the old one to:
  12. * each field once, and a save that keeps what was on screen.
  13. *
  14. * Every test here starts from a context with no layout cookie, so the rest of
  15. * the suite, which shares the owner's storage state, stays on the classic page.
  16. */
  17. test.describe.configure({ mode: 'serial' })
  18. const stamp = Date.now()
  19. const partName = `E2E wiper blade ${stamp}`
  20. let jobUrl = ''
  21. test.beforeAll(async ({ browser }) => {
  22. const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
  23. const vehicleUrl = await seededVehicleUrl(page)
  24. jobUrl = await newWorkOrder(page, vehicleUrl, `E2E modern layout ${stamp}`)
  25. await addPart(page, { name: partName, quantity: 2, unitPrice: 150 })
  26. await saveWorkOrder(page)
  27. await page.close()
  28. })
  29. test.describe('the overhauled work order page', () => {
  30. test('is offered on the classic page and not forced on anybody', async ({ page }) => {
  31. await page.goto(jobUrl)
  32. await settle(page)
  33. await expect(page.getByTestId('service-layout')).toBeVisible()
  34. await expect(page.getByTestId('service-layout-modern')).toHaveCount(0)
  35. await expect(page.getByTestId('try-new-layout')).toContainText(
  36. 'The work order page has been overhauled.'
  37. )
  38. })
  39. test('opens on "Try it now", survives a reload, and leads back', async ({ page }) => {
  40. await page.goto(jobUrl)
  41. await settle(page)
  42. await page.getByRole('button', { name: 'Try it now' }).click()
  43. await expect(page.getByTestId('service-layout-modern')).toBeVisible()
  44. await expect(page.getByTestId('service-layout')).toHaveCount(0)
  45. await expect(page.getByTestId('try-new-layout')).toHaveCount(0)
  46. // The choice is the server's to read: a reload lands on the new page
  47. // without the classic one flashing past first.
  48. await page.reload()
  49. await settle(page)
  50. await expect(page.getByTestId('service-layout-modern')).toBeVisible()
  51. // The top of the page is the work order's number and its status, then
  52. // what the job is called.
  53. const hero = page.getByTestId('service-hero')
  54. await expect(hero.getByTestId('service-number')).not.toBeEmpty()
  55. await expect(hero.getByTestId('service-status')).toHaveText('Pending')
  56. await expect(hero).toContainText(`E2E modern layout ${stamp}`)
  57. await page.getByRole('button', { name: 'Back to the classic layout' }).click()
  58. await expect(page.getByTestId('service-layout')).toBeVisible()
  59. await page.reload()
  60. await settle(page)
  61. await expect(page.getByTestId('service-layout')).toBeVisible()
  62. })
  63. test('holds one copy of each field and keeps photos on the job', async ({ page }) => {
  64. await page.goto(jobUrl)
  65. await settle(page)
  66. await page.getByRole('button', { name: 'Try it now' }).click()
  67. await expect(page.getByTestId('service-layout-modern')).toBeVisible()
  68. for (const selector of [
  69. 'input[name="title"]',
  70. '#invoiceNumber',
  71. '#invoiceDate',
  72. '#mileage',
  73. 'input[name="techName"]',
  74. 'input[name="serviceDate"]',
  75. 'textarea[placeholder="Name *"]',
  76. ]) {
  77. await expect(page.locator(selector), `${selector} appears once`).toHaveCount(1)
  78. }
  79. const duplicates = await page.evaluate(() => {
  80. const form = document.querySelector('form')
  81. if (!form) return ['no form']
  82. const seen = new Map<string, number>()
  83. for (const el of form.querySelectorAll<HTMLInputElement>('input[name], textarea[name]')) {
  84. seen.set(el.name, (seen.get(el.name) ?? 0) + 1)
  85. }
  86. return [...seen.entries()].filter(([, count]) => count > 1).map(([name]) => name)
  87. })
  88. expect(duplicates, 'field names used twice in the form').toEqual([])
  89. // No tab bar at the top of the page: photos, documents, diagnostics,
  90. // video and status reports are tabs of one card on the job itself.
  91. const files = page.getByTestId('files-media')
  92. await expect(files).toBeVisible()
  93. await expect(page.getByRole('button', { name: /^Images/ })).toHaveCount(0)
  94. await expect(page.getByRole('button', { name: /^Status Reports/ })).toHaveCount(0)
  95. for (const name of [/^Photos/, /^Documents/, /^Diagnostics/, /^Video/, /^Status Reports/]) {
  96. await expect(files.getByRole('tab', { name })).toBeVisible()
  97. }
  98. await expect(files.getByTestId('media-add')).toContainText('Add photos')
  99. await files.getByRole('tab', { name: /^Status Reports/ }).click()
  100. await expect(files.getByTestId('status-reports-section')).toBeVisible()
  101. // The money stays on screen along the bottom, with what is done about it.
  102. const bar = page.getByTestId('money-bar')
  103. await expect(bar).toBeInViewport()
  104. await expect(bar.getByTestId('money-balance')).toContainText('300')
  105. await expect(bar.getByRole('button', { name: 'Take payment' })).toBeVisible()
  106. await expect(bar.getByRole('button', { name: /Preview/ })).toBeVisible()
  107. await expect(bar.getByRole('button', { name: /Send/ })).toBeVisible()
  108. })
  109. test('saves the same job the classic page does', async ({ page }) => {
  110. await page.goto(jobUrl)
  111. await settle(page)
  112. await page.getByRole('button', { name: 'Try it now' }).click()
  113. await expect(page.getByTestId('service-layout-modern')).toBeVisible()
  114. // The part saved from the classic page is here, with its figures.
  115. await expect(page.locator('textarea[placeholder="Name *"]')).toHaveValue(partName)
  116. // The stepper is the status control: walk the job on and save.
  117. const stepper = page.getByTestId('status-stepper')
  118. await stepper.getByRole('button', { name: /In Progress/i }).click()
  119. await expect(stepper.getByRole('button', { name: /In Progress/i })).toHaveAttribute(
  120. 'aria-current',
  121. 'step'
  122. )
  123. // The title is edited in the header, behind its pencil; the form carries
  124. // it as a hidden field, so Save takes it with everything else.
  125. const title = `E2E modern layout ${stamp} renamed`
  126. await page.getByTestId('edit-title').click()
  127. await page.getByTestId('title-input').fill(title)
  128. await page.getByTestId('title-input').press('Enter')
  129. await expect(page.getByTestId('service-title')).toHaveText(title)
  130. await expect(page.locator('input[name="title"]')).toHaveValue(title)
  131. await saveWorkOrder(page)
  132. // Read back on the classic page, which is what everybody else still uses.
  133. await page.getByRole('button', { name: 'Back to the classic layout' }).click()
  134. await expect(page.getByTestId('service-layout')).toBeVisible()
  135. await page.reload()
  136. await settle(page)
  137. await expect(page.locator('input[name="title"]')).toHaveValue(title)
  138. await expect(page.getByRole('combobox').filter({ hasText: 'In Progress' })).toBeVisible()
  139. await expect(page.locator('textarea[placeholder="Name *"]')).toHaveValue(partName)
  140. })
  141. test('stacks into one column on a phone without overflowing sideways', async ({ page }) => {
  142. await page.setViewportSize({ width: 390, height: 844 })
  143. await page.goto(jobUrl)
  144. await settle(page)
  145. await page.getByRole('button', { name: 'Try it now' }).click()
  146. const layout = page.getByTestId('service-layout-modern')
  147. await expect(layout).toBeVisible()
  148. // The money bar is a desktop thing: the page already carries its totals,
  149. // payment form, preview and send, and a phone has no height to spare.
  150. await expect(page.getByTestId('money-bar')).toBeHidden()
  151. // What it leaves behind is the running total, in the flow under the parts,
  152. // so adding a line on a small screen still shows what it did to the bill.
  153. await expect(page.getByTestId('money-line')).toBeVisible()
  154. // The header's second line is which job this is; when it was opened is
  155. // left for a wider screen.
  156. await expect(page.getByTestId('service-opened')).toBeHidden()
  157. // Each control of the status stepper stays in its own space: a label is
  158. // cut short rather than running into the next. Three stages, and the
  159. // arrow beside "In Progress" that opens the statuses filed under it
  160. // (always there, for "Waiting Parts"): four buttons in all on a workshop
  161. // that has defined no statuses of its own.
  162. const stages = await page
  163. .getByTestId('status-stepper')
  164. .getByRole('button')
  165. .evaluateAll((buttons) => buttons.map((b) => b.getBoundingClientRect().toJSON()))
  166. expect(stages).toHaveLength(4)
  167. for (let i = 1; i < stages.length; i++) {
  168. expect(
  169. stages[i - 1].right,
  170. `stage ${i} ends before stage ${i + 1} starts`
  171. ).toBeLessThanOrEqual(stages[i].left)
  172. }
  173. const overflow = await layout.evaluate((el) => el.scrollWidth - el.clientWidth)
  174. expect(overflow, 'sideways overflow in pixels').toBeLessThanOrEqual(1)
  175. const main = await page.getByTestId('service-main').boundingBox()
  176. const sidebar = await page.getByTestId('service-sidebar').boundingBox()
  177. expect(sidebar?.y ?? 0, 'the sidebar sits under the job').toBeGreaterThan(
  178. (main?.y ?? 0) + (main?.height ?? 0) - 1
  179. )
  180. })
  181. })