marine.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. import { expect, type Locator, type Page, test } from '@playwright/test'
  2. import { signInWithPassword } from '../../support/cloud'
  3. import {
  4. connectRegistry,
  5. customerIdNamed,
  6. disconnectRegistry,
  7. plantWorkshop,
  8. userIdFor,
  9. workshopSetting,
  10. } from '../../support/db'
  11. import { settle } from '../../support/hydration'
  12. import { pdfContent } from '../../support/pdf'
  13. import { newWorkOrder, saveWorkOrder, seededVehicleUrl } from '../../support/work-order'
  14. /**
  15. * A marine workshop, and the words the app uses for it.
  16. *
  17. * Settings → Workshop → Service Type is one choice with a long reach. Picked
  18. * Marine, the workshop services vessels: a make is a manufacturer, the
  19. * odometer is engine hours, a VIN is a HIN, a plate is a registration number,
  20. * and a transmission is an outboard or inboard engine. Each place that is
  21. * meant to switch is pinned here, and so is the way back, because a workshop
  22. * that picked Marine by mistake has to get its car words back.
  23. *
  24. * A workshop of its own is opened by signing up. The seeded one is shared by
  25. * the whole run, and a failure halfway would leave every later spec reading
  26. * about vessels.
  27. */
  28. // Planting and signing into a workshop in a hook takes longer than a test.
  29. test.describe.configure({ mode: 'serial', timeout: 180_000 })
  30. const stamp = Date.now()
  31. const EMAIL = `e2e-marine-${stamp}@example.com`
  32. const STATE = `e2e/.auth/marine-${stamp}.json`
  33. const SERVICE_TYPE = 'workshop.serviceType'
  34. // The model carries the run in letters and digits: the list's search reads an
  35. // all-digit word as a number, and the stamp is too big for one.
  36. const VESSEL = {
  37. make: 'Boston Whaler',
  38. model: `Montauk ${stamp.toString(36)}`,
  39. year: '2021',
  40. hours: '1234',
  41. }
  42. const SKIPPER = `E2E Skipper ${stamp}`
  43. /** A plate registry (the Dutch RDW): enough for the header to offer a lookup. */
  44. const REGISTRY = 'rdw'
  45. let organizationId = ''
  46. let vesselUrl = ''
  47. test.use({ storageState: STATE })
  48. test.beforeAll(async ({ browser }) => {
  49. // A workshop of its own, planted: a self-hosted install opens one workshop
  50. // and would send a sign-up to ask for an invitation instead.
  51. const planted = await plantWorkshop({
  52. name: 'E2E Marine Owner',
  53. email: EMAIL,
  54. password: `E2e-pass-${stamp}`,
  55. workshopName: `E2E Marina ${stamp}`,
  56. })
  57. organizationId = planted.organizationId
  58. const context = await browser.newContext({ storageState: { cookies: [], origins: [] } })
  59. const page = await context.newPage()
  60. await signInWithPassword(page, { email: EMAIL, password: `E2e-pass-${stamp}` })
  61. await context.storageState({ path: STATE })
  62. await context.close()
  63. })
  64. test.afterAll(async () => {
  65. if (organizationId) await disconnectRegistry(organizationId, REGISTRY)
  66. })
  67. function sidebar(page: Page): Locator {
  68. return page.locator('[data-sidebar="sidebar"]')
  69. }
  70. function serviceTypeSelect(page: Page): Locator {
  71. return page.getByRole('combobox').filter({ hasText: /^(Automotive|Marine)$/ })
  72. }
  73. /** Settings → Workshop → Service Type, saved, and checked as stored. */
  74. async function setServiceType(page: Page, type: 'Automotive' | 'Marine'): Promise<void> {
  75. await page.goto('/settings/workshop')
  76. await settle(page)
  77. const select = serviceTypeSelect(page)
  78. await expect(async () => {
  79. if ((await select.textContent())?.trim() !== type) {
  80. await select.click()
  81. await page.getByRole('option', { name: type, exact: true }).click({ timeout: 2_000 })
  82. }
  83. await expect(select).toHaveText(type, { timeout: 2_000 })
  84. }).toPass({ timeout: 30_000 })
  85. await page.getByRole('button', { name: 'Save Workshop Settings' }).click()
  86. await expect(page.getByText('Workshop settings saved')).toBeVisible()
  87. // The page shows its toast whatever the save returned, so what was stored
  88. // is what counts.
  89. await expect
  90. .poll(() => workshopSetting(organizationId, SERVICE_TYPE), { timeout: 15_000 })
  91. .toBe(type.toLowerCase())
  92. }
  93. /** The vehicle list's add form, open. */
  94. async function openAddVehicle(page: Page): Promise<Locator> {
  95. await page.goto('/vehicles')
  96. await settle(page)
  97. const dialog = page.getByRole('dialog', { name: 'Add New Vehicle' })
  98. await expect(async () => {
  99. await page
  100. .getByRole('button', { name: 'Add Vehicle' })
  101. .filter({ visible: true })
  102. .first()
  103. .click()
  104. await expect(dialog).toBeVisible({ timeout: 2_000 })
  105. }).toPass({ timeout: 30_000 })
  106. return dialog
  107. }
  108. function label(dialog: Locator, field: string): Locator {
  109. return dialog.locator(`label[for="${field}"]`)
  110. }
  111. /** The select drawn after a label. Its trigger carries no id of its own. */
  112. function selectAfter(dialog: Locator, field: string): Locator {
  113. return dialog.locator(`label[for="${field}"] ~ [role="combobox"]`)
  114. }
  115. test.describe('a workshop that picks Marine', () => {
  116. test('starts out as automotive', async ({ page }) => {
  117. await page.goto('/settings/workshop')
  118. await settle(page)
  119. await expect(serviceTypeSelect(page)).toHaveText('Automotive')
  120. expect(await workshopSetting(organizationId, SERVICE_TYPE), 'nothing stored yet').toBeNull()
  121. await expect(sidebar(page).getByRole('link', { name: /^Vehicles/ })).toBeVisible()
  122. })
  123. test('keeps the choice once saved', async ({ page }) => {
  124. await setServiceType(page, 'Marine')
  125. await page.reload()
  126. await settle(page)
  127. await expect(serviceTypeSelect(page)).toHaveText('Marine')
  128. })
  129. test('lists vessels in the sidebar', async ({ page }) => {
  130. await page.goto('/vehicles')
  131. await settle(page)
  132. await expect(sidebar(page).getByRole('link', { name: /^Vessels/ })).toHaveAttribute(
  133. 'href',
  134. '/vehicles'
  135. )
  136. await expect(sidebar(page).getByRole('link', { name: /^Vehicles/ })).toHaveCount(0)
  137. })
  138. test('asks for what a boat has when one is added', async ({ page }) => {
  139. const dialog = await openAddVehicle(page)
  140. await expect(label(dialog, 'make')).toHaveText('Manufacturer *')
  141. await expect(label(dialog, 'mileage')).toHaveText('Engine Hours')
  142. await expect(label(dialog, 'vin')).toHaveText('HIN')
  143. await expect(label(dialog, 'licensePlate')).toContainText('Registration Number')
  144. await expect(label(dialog, 'transmission')).toHaveText('Engine Type')
  145. await expect(label(dialog, 'engineSize')).toHaveText('Engine')
  146. // A periodic roadworthiness inspection is a road vehicle's.
  147. await expect(dialog.locator('#inspectionDueAt')).toHaveCount(0)
  148. // Two-stroke instead of electric and hybrid.
  149. await selectAfter(dialog, 'fuelType').click()
  150. await expect(page.getByRole('option')).toHaveText(['Gasoline', 'Diesel', 'Two-Stroke', 'Other'])
  151. await page.getByRole('option', { name: 'Two-Stroke' }).click()
  152. // Outboard or inboard, outboard unless told otherwise.
  153. const engineType = selectAfter(dialog, 'transmission')
  154. await expect(engineType).toHaveText('Outboard')
  155. await engineType.click()
  156. await expect(page.getByRole('option')).toHaveText(['Outboard', 'Inboard'])
  157. await page.getByRole('option', { name: 'Inboard' }).click()
  158. await dialog.locator('#make').fill(VESSEL.make)
  159. await dialog.locator('#model').fill(VESSEL.model)
  160. await dialog.locator('#year').fill(VESSEL.year)
  161. await dialog.locator('#mileage').fill(VESSEL.hours)
  162. await dialog.locator('#vin').fill(`BWC${stamp}`.slice(0, 12))
  163. await dialog.locator('#licensePlate').fill('NB-4521-E')
  164. await dialog.getByRole('button', { name: 'Add New Vehicle' }).click()
  165. await expect(page.getByText('Vehicle added')).toBeVisible({ timeout: 30_000 })
  166. })
  167. test('counts a vessel in engine hours, from its page to its invoice', async ({ page }) => {
  168. vesselUrl = await seededVehicleUrl(page, VESSEL.model)
  169. await settle(page)
  170. await expect(page.getByText('1,234', { exact: true }).first()).toBeVisible()
  171. await expect(page.getByText('hrs', { exact: true }).first()).toBeVisible()
  172. const jobUrl = await newWorkOrder(page, vesselUrl, `E2E Vessel service ${stamp}`)
  173. const hours = page.locator('#mileage')
  174. await expect(page.locator('label[for="mileage"]')).toHaveText('Engine Hours')
  175. await hours.fill('1250')
  176. await saveWorkOrder(page)
  177. const id = new URL(jobUrl).pathname.split('/').pop()
  178. const response = await page.request.get(`/api/protected/services/${id}/pdf`)
  179. expect(response.ok(), 'the invoice PDF is served').toBe(true)
  180. const pdf = await pdfContent(await response.body())
  181. expect(pdf.flat).toMatch(/Engine Hours:?\s*1,250 hrs/)
  182. expect(pdf.flat).not.toContain('Mileage')
  183. // The vessel's own numbers under a vessel's names.
  184. expect(pdf.flat).toContain(`HIN: ${`BWC${stamp}`.slice(0, 12)}`)
  185. expect(pdf.flat).toContain('Registration: NB-4521-E')
  186. expect(pdf.flat).not.toMatch(/\bVIN:|\bPlate:/)
  187. })
  188. test('names vessels in the breadcrumbs', async ({ page }) => {
  189. const breadcrumb = page.getByRole('navigation', { name: 'breadcrumb' })
  190. await page.goto('/vehicles')
  191. await settle(page)
  192. await expect(breadcrumb).toContainText('Vessels')
  193. await expect(breadcrumb).toContainText('All Vessels')
  194. await expect(breadcrumb).not.toContainText('Vehicle')
  195. await page.goto(vesselUrl)
  196. await settle(page)
  197. await expect(breadcrumb).toContainText('Vessel Details')
  198. await expect(breadcrumb).not.toContainText('Vehicle')
  199. })
  200. test('names vessels in the bottom bar on a phone', async ({ page }) => {
  201. await page.setViewportSize({ width: 390, height: 844 })
  202. await page.goto('/vehicles')
  203. await settle(page)
  204. const vessels = page
  205. .getByRole('link', { name: 'Vessels', exact: true })
  206. .filter({ visible: true })
  207. await expect(vessels).toHaveAttribute('href', '/vehicles')
  208. await expect(
  209. page.getByRole('link', { name: 'Vehicles', exact: true }).filter({ visible: true })
  210. ).toHaveCount(0)
  211. })
  212. test('starts a work order on a vessel, with a registration number', async ({ page }) => {
  213. await page.goto('/work-orders?new=1')
  214. await settle(page)
  215. const picker = page.getByRole('dialog', { name: 'Select Vessel for Work Order' })
  216. await expect(picker).toBeVisible({ timeout: 30_000 })
  217. await expect(picker.getByPlaceholder('Search vessels...')).toBeVisible()
  218. await expect(async () => {
  219. await picker.getByRole('button', { name: 'Add New Vessel' }).first().click()
  220. await expect(page.locator('#new-make')).toBeVisible({ timeout: 2_000 })
  221. }).toPass({ timeout: 30_000 })
  222. const create = page.getByRole('dialog', { name: 'Add New Vessel' })
  223. await expect(create.getByText('Manufacturer *', { exact: true })).toBeVisible()
  224. await expect(create.getByText('Registration Number', { exact: true })).toBeVisible()
  225. await expect(create.getByText('License Plate', { exact: true })).toHaveCount(0)
  226. // A vessel and its owner, made in one go, for the customer page below.
  227. await page.locator('#new-make').fill('Yamaha')
  228. await page.locator('#new-model').fill(`242X ${stamp.toString(36)}`)
  229. await page.locator('#new-year').fill('2019')
  230. await page.getByRole('combobox').filter({ hasText: 'Select a customer (optional)' }).click()
  231. await page.getByRole('option', { name: 'Create new customer' }).click()
  232. await page.locator('#new-customer-name').fill(SKIPPER)
  233. await page.getByRole('button', { name: 'Create & Continue' }).click()
  234. await page.waitForURL(/\/vehicles\/[^/]+\/service\//, { timeout: 30_000 })
  235. })
  236. test("shows a customer's vessels in engine hours", async ({ page }) => {
  237. await page.goto(`/customers/${await customerIdNamed(organizationId, SKIPPER)}`)
  238. await settle(page)
  239. await expect(page.getByText('Engine Hours', { exact: true }).first()).toBeVisible()
  240. // The list is drawn twice, cards for a phone and a table for a desk, so
  241. // only the one on screen counts; neither may say km or mi.
  242. await expect(
  243. page
  244. .getByText(/^\d[\d,]* hrs$/)
  245. .filter({ visible: true })
  246. .first()
  247. ).toBeVisible()
  248. await expect(page.getByText(/^\d[\d,]* (km|mi)$/)).toHaveCount(0)
  249. })
  250. test('does not offer a plate lookup, even with a registry connected', async ({ page }) => {
  251. // Registries answer for road vehicles. Connected, the header would offer
  252. // one to an automotive workshop; the last test below proves it does.
  253. await connectRegistry(organizationId, await userIdFor(EMAIL), REGISTRY)
  254. await page.goto('/vehicles')
  255. await settle(page)
  256. await expect(page.getByRole('heading', { name: /./ }).first()).toBeVisible()
  257. await expect(page.getByRole('button', { name: 'Plate lookup' })).toHaveCount(0)
  258. })
  259. test('gets its car words back when switched to Automotive', async ({ page }) => {
  260. await setServiceType(page, 'Automotive')
  261. const dialog = await openAddVehicle(page)
  262. await expect(label(dialog, 'make')).toHaveText('Make *')
  263. await expect(label(dialog, 'mileage')).toHaveText('Mileage')
  264. await expect(label(dialog, 'vin')).toHaveText('VIN')
  265. await expect(label(dialog, 'licensePlate')).toContainText('License Plate')
  266. await expect(label(dialog, 'transmission')).toHaveText('Transmission')
  267. await expect(dialog.locator('#inspectionDueAt')).toBeVisible()
  268. await selectAfter(dialog, 'fuelType').click()
  269. await expect(page.getByRole('option')).toHaveText([
  270. 'Gasoline',
  271. 'Diesel',
  272. 'Electric',
  273. 'Hybrid',
  274. 'Other',
  275. ])
  276. await page.keyboard.press('Escape')
  277. await dialog.getByRole('button', { name: 'Cancel' }).click()
  278. await expect(sidebar(page).getByRole('link', { name: /^Vehicles/ })).toBeVisible()
  279. await expect(sidebar(page).getByRole('link', { name: /^Vessels/ })).toHaveCount(0)
  280. // The same pages, back in car words, and the registry connected earlier is
  281. // offered again.
  282. await page.goto('/vehicles')
  283. await settle(page)
  284. const breadcrumb = page.getByRole('navigation', { name: 'breadcrumb' })
  285. await expect(breadcrumb).toContainText('All Vehicles')
  286. await expect(page.getByRole('button', { name: 'Plate lookup' }).first()).toBeVisible()
  287. })
  288. })