work-order.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. import { expect, type BrowserContext, type Locator, type Page } from '@playwright/test'
  2. import { settle } from './hydration'
  3. /**
  4. * Driving a work order through the browser the way a workshop does.
  5. *
  6. * Everything here reads visible English and the placeholders the editors
  7. * print; the suite pins the locale so both hold. Clicks that add a row are
  8. * retried until the row is there: a click that lands before React has
  9. * hydrated the page does nothing, and the editor gives no other sign.
  10. */
  11. /** The address of one seeded vehicle, found through the list's own search. */
  12. export async function seededVehicleUrl(page: Page, search = 'Camry'): Promise<string> {
  13. await page.goto(`/vehicles?search=${encodeURIComponent(search)}`)
  14. await page
  15. .getByRole('link', { name: new RegExp(search, 'i') })
  16. .first()
  17. .click()
  18. await page.waitForURL(/\/vehicles\/[^/?]+$/)
  19. return page.url()
  20. }
  21. /**
  22. * Puts this browser on the overhauled work order page, the way "Try it now"
  23. * does: a cookie the server reads. Only this context carries it; the rest of
  24. * the suite shares the owner's storage state and stays on the classic page.
  25. */
  26. export async function useModernLayout(context: BrowserContext, baseURL: string): Promise<void> {
  27. await context.addCookies([{ name: 'workOrderLayout', value: 'modern', url: baseURL }])
  28. }
  29. /** The work order's id, from an editor address. */
  30. export function jobIdOf(jobUrl: string): string {
  31. const id = new URL(jobUrl).pathname.split('/').pop()
  32. if (!id) throw new Error(`no job id in ${jobUrl}`)
  33. return id
  34. }
  35. /** A fresh draft work order on the vehicle, titled, open in the editor. */
  36. export async function newWorkOrder(page: Page, vehicleUrl: string, title: string): Promise<string> {
  37. await page.goto(`${vehicleUrl}/service/new`)
  38. await page.waitForURL(/\/vehicles\/[^/]+\/service\/[^/]+$/)
  39. // `/service/new` makes the draft and redirects to it, and for a moment the
  40. // page being left and the page arriving are both in the document: two title
  41. // fields, and a strict-mode error instead of a retry. Settled, there is one.
  42. await settle(page)
  43. const titleField = page.locator('input[name="title"]')
  44. await expect(titleField).toBeVisible()
  45. await titleField.fill(title)
  46. return page.url()
  47. }
  48. /** The editor row an input belongs to: the nearest ancestor that also holds the given input. */
  49. function rowContaining(field: Locator, siblingPlaceholder: string): Locator {
  50. return field.locator(`xpath=ancestor::*[.//input[@placeholder="${siblingPlaceholder}"]][1]`)
  51. }
  52. /**
  53. * The part rows, in the order the editor lists them. One locator per row: the
  54. * page used to draw the whole editor twice, once per breakpoint, and every row
  55. * came back doubled with half of them impossible to type into.
  56. * `specs/work-orders/layout.spec.ts` is what keeps it to one.
  57. */
  58. export function partRows(page: Page): Locator {
  59. return page.getByPlaceholder('Name *')
  60. }
  61. /** The labour rows, likewise. */
  62. export function laborRows(page: Page): Locator {
  63. return page.getByPlaceholder('Description *')
  64. }
  65. /** The whole row a part field sits in, buttons and figures included. */
  66. export function partRowOf(nameField: Locator): Locator {
  67. return rowContaining(nameField, 'Cost')
  68. }
  69. /** The whole row a labour field sits in. */
  70. export function laborRowOf(descriptionField: Locator): Locator {
  71. return rowContaining(descriptionField, 'Hours')
  72. }
  73. export interface PartInput {
  74. name: string
  75. quantity: number
  76. /** Vendor cost; with a markup the unit price is expected to follow from it. */
  77. cost?: number
  78. markupPercent?: number
  79. unitPrice?: number
  80. }
  81. /**
  82. * Adds a part line. The number inputs sit in the order the editor prints
  83. * them: quantity, cost, markup, unit price.
  84. */
  85. export async function addPart(page: Page, part: PartInput): Promise<void> {
  86. const rows = partRows(page)
  87. // Counted first, and waited for: a click before hydration adds nothing, and
  88. // a guard that only asked whether some name field was on screen would be
  89. // satisfied by the rows already there and type over the last of them.
  90. const before = await rows.count()
  91. await expect(async () => {
  92. // An empty list offers the button twice, in the toolbar and as the dashed
  93. // row beneath it; once there are rows, only the toolbar one is named.
  94. await page.getByRole('button', { name: 'Add Part' }).last().click()
  95. await expect(rows).toHaveCount(before + 1, { timeout: 2_000 })
  96. }).toPass({ timeout: 30_000 })
  97. // The toolbar button puts the new row at the top of the list, which is
  98. // where the workshop looks after clicking it; the dashed one appends, and
  99. // on an empty list either way leaves the row it made as the only one.
  100. const name = rows.first()
  101. await name.fill(part.name)
  102. const numbers = rowContaining(name, 'Cost').locator('input[type="number"]')
  103. await numbers.nth(0).fill(String(part.quantity))
  104. if (part.cost !== undefined) await numbers.nth(1).fill(String(part.cost))
  105. if (part.markupPercent !== undefined) await numbers.nth(2).fill(String(part.markupPercent))
  106. if (part.unitPrice !== undefined) await numbers.nth(3).fill(String(part.unitPrice))
  107. }
  108. /** What the editor worked out as the unit price of the part added last. */
  109. export async function lastPartUnitPrice(page: Page): Promise<string> {
  110. return partRowOf(partRows(page).first()).locator('input[type="number"]').nth(3).inputValue()
  111. }
  112. export interface LaborInput {
  113. description: string
  114. hours: number
  115. rate: number
  116. }
  117. /** Adds an hourly labour line: description, hours, rate. */
  118. export async function addLabor(page: Page, labor: LaborInput): Promise<void> {
  119. const rows = laborRows(page)
  120. const before = await rows.count()
  121. await expect(async () => {
  122. await page.getByRole('button', { name: 'Add Labor' }).last().click()
  123. await expect(rows).toHaveCount(before + 1, { timeout: 2_000 })
  124. }).toPass({ timeout: 30_000 })
  125. // Added at the top, like a part.
  126. const description = rows.first()
  127. await description.fill(labor.description)
  128. const numbers = rowContaining(description, 'Hours').locator('input[type="number"]')
  129. await numbers.nth(0).fill(String(labor.hours))
  130. await numbers.nth(1).fill(String(labor.rate))
  131. }
  132. /** Saves the work order and waits for the header to say so. */
  133. export async function saveWorkOrder(page: Page): Promise<void> {
  134. await page.getByRole('button', { name: 'Save', exact: true }).click()
  135. await expect(page.getByText('Saved', { exact: true })).toBeVisible()
  136. }
  137. /**
  138. * One line of the totals panel, found by its label. The panel prints each
  139. * line as a label and a figure side by side, so the row is the label's
  140. * parent and the figure is read from it.
  141. */
  142. export function totalsRow(page: Page, label: string): Locator {
  143. // Scoped to the Totals panel: "Parts" and "Labor" are also section headings.
  144. const panel = page
  145. .getByRole('heading', { name: 'Totals', exact: true })
  146. .locator('xpath=ancestor::div[1]')
  147. // The row is the nearest box that spreads label and figure apart; the tax
  148. // label sits one level deeper, beside its percentage input.
  149. return panel
  150. .getByText(label, { exact: true })
  151. .first()
  152. .locator('xpath=ancestor::div[contains(@class,"justify-between")][1]')
  153. }
  154. /** Sets the discount on the open work order. */
  155. export async function setDiscount(
  156. page: Page,
  157. kind: 'None' | 'Percentage' | 'Fixed',
  158. value?: number
  159. ): Promise<void> {
  160. const row = totalsRow(page, 'Discount')
  161. await row.getByRole('combobox').click()
  162. await page.getByRole('option', { name: kind, exact: true }).click()
  163. if (value !== undefined) await row.locator('input[type="number"]').fill(String(value))
  164. }
  165. /** The public share link for the open work order, generating it if needed. */
  166. export async function shareLink(page: Page): Promise<string> {
  167. await page.getByRole('button', { name: 'Share', exact: true }).click()
  168. // A document whose invoice date has passed is offered a fresh one before it
  169. // goes out. Declined here: a spec that shares a seeded invoice must hand the
  170. // customer the document as it stands, not rewrite its dates on the way.
  171. const expired = page.getByRole('dialog', { name: /invoice dates expired/i })
  172. const asksAboutDates = await expired
  173. .waitFor({ state: 'visible', timeout: 3_000 })
  174. .then(() => true)
  175. .catch(() => false)
  176. if (asksAboutDates) {
  177. await expired.getByRole('button', { name: /proceed without changes/i }).click()
  178. }
  179. // By name: a workshop-wide announcement is a dialog too, and can be open on
  180. // the same page.
  181. const dialog = page.getByRole('dialog', { name: 'Share Invoice' })
  182. await expect(dialog).toBeVisible()
  183. const generate = dialog.getByRole('button', { name: /generate public link/i })
  184. if (await generate.isVisible()) {
  185. await generate.click()
  186. // A job that has gone to the customer is asked to leave the work board.
  187. const prompt = page.getByRole('alertdialog', { name: /mark this invoice as completed/i })
  188. const asked = await prompt
  189. .waitFor({ state: 'visible', timeout: 3_000 })
  190. .then(() => true)
  191. .catch(() => false)
  192. if (asked) await prompt.getByRole('button', { name: 'Mark completed', exact: true }).click()
  193. }
  194. const field = dialog.locator('input[readonly]')
  195. await expect(field).toHaveValue(/\/share\/invoice\//)
  196. const url = await field.inputValue()
  197. await page.keyboard.press('Escape')
  198. return url
  199. }