layout.spec.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  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 shape of the work order page, and the one rule underneath it: each field
  6. * exists once.
  7. *
  8. * Both columns used to be rendered twice, one layer per breakpoint with the
  9. * other hidden by CSS, which put two of every input in the document under the
  10. * same id and name. The form submitted the hidden copy's values, native
  11. * validation objected to controls the browser then refused to focus and
  12. * abandoned the submit in silence, and every editor row was mounted twice.
  13. * These tests are what keeps the page down to one copy.
  14. *
  15. * The rest is what the layout has to keep doing either way: the job and the
  16. * sidebar scrolling separately on a wide screen, one stack on a narrow one,
  17. * a sidebar that can be dragged, and a page that never grows taller than the
  18. * window whatever is piled into it.
  19. */
  20. test.describe.configure({ mode: 'serial' })
  21. const stamp = Date.now()
  22. let jobUrl = ''
  23. test.beforeAll(async ({ browser }) => {
  24. const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
  25. const vehicleUrl = await seededVehicleUrl(page)
  26. jobUrl = await newWorkOrder(page, vehicleUrl, `E2E layout ${stamp}`)
  27. await addPart(page, { name: `E2E cabin filter ${stamp}`, quantity: 1, unitPrice: 240 })
  28. await saveWorkOrder(page)
  29. await page.close()
  30. })
  31. test.describe('the work order page', () => {
  32. test('holds one copy of each field, not one per breakpoint', async ({ page }) => {
  33. await page.goto(jobUrl)
  34. await expect(page.getByTestId('service-layout')).toBeVisible()
  35. // Counted on a page that has finished arriving: across a navigation the
  36. // old document and the new one can both answer for a moment, and a count
  37. // taken then is a count of two pages.
  38. await settle(page)
  39. // Named fields, an id, and an editor row: one of each.
  40. for (const selector of [
  41. 'input[name="title"]',
  42. '#invoiceNumber',
  43. 'textarea[placeholder="Name *"]',
  44. ]) {
  45. await expect(page.locator(selector), `${selector} appears once`).toHaveCount(1)
  46. }
  47. // And nothing in the form shares a name with anything else in it.
  48. const duplicates = await page.evaluate(() => {
  49. const form = document.querySelector('form')
  50. if (!form) return ['no form']
  51. const seen = new Map<string, number>()
  52. for (const el of form.querySelectorAll<HTMLInputElement>('input[name], textarea[name]')) {
  53. seen.set(el.name, (seen.get(el.name) ?? 0) + 1)
  54. }
  55. return [...seen.entries()].filter(([, count]) => count > 1).map(([name]) => name)
  56. })
  57. expect(duplicates, 'field names used twice in the form').toEqual([])
  58. })
  59. test('scrolls the job and the sidebar separately on a wide screen', async ({ page }) => {
  60. await page.setViewportSize({ width: 1440, height: 800 })
  61. await page.goto(jobUrl)
  62. const main = page.getByTestId('service-main')
  63. const sidebar = page.getByTestId('service-sidebar')
  64. // The two columns share one row of the grid, so they are the same height
  65. // as the layer around them and each takes its own overflow.
  66. const layerHeight = await page.getByTestId('service-layout').evaluate((el) => el.clientHeight)
  67. for (const [name, column] of [
  68. ['the job', main],
  69. ['the sidebar', sidebar],
  70. ] as const) {
  71. const box = await column.evaluate((el) => ({
  72. client: el.clientHeight,
  73. scroll: el.scrollHeight,
  74. overflowY: getComputedStyle(el).overflowY,
  75. }))
  76. expect(box.client, `${name} fills the row`).toBe(layerHeight)
  77. expect(box.overflowY, `${name} scrolls itself`).toBe('auto')
  78. expect(box.scroll, `${name} has more than fits`).toBeGreaterThan(box.client)
  79. }
  80. await main.evaluate((el) => el.scrollBy(0, 200))
  81. expect(await main.evaluate((el) => el.scrollTop)).toBeGreaterThan(0)
  82. expect(
  83. await sidebar.evaluate((el) => el.scrollTop),
  84. 'scrolling the job leaves the sidebar where it was'
  85. ).toBe(0)
  86. })
  87. test('never lets the page grow taller than the window', async ({ page }) => {
  88. await page.setViewportSize({ width: 1440, height: 800 })
  89. await page.goto(jobUrl)
  90. // The whole reason the shell is built the way it is: content in either
  91. // column must not push the document past the viewport.
  92. const overflow = await page.evaluate(
  93. () => (document.scrollingElement?.scrollHeight ?? 0) - window.innerHeight
  94. )
  95. expect(overflow, 'the document is no taller than the window').toBeLessThanOrEqual(1)
  96. })
  97. test('lets the sidebar be dragged wider', async ({ page }) => {
  98. await page.setViewportSize({ width: 1440, height: 800 })
  99. await page.goto(jobUrl)
  100. const sidebar = page.getByTestId('service-sidebar')
  101. const before = (await sidebar.boundingBox())?.width ?? 0
  102. const handle = page.getByTestId('service-resize')
  103. const grip = await handle.boundingBox()
  104. expect(grip, 'the drag handle is on screen').not.toBeNull()
  105. await page.mouse.move(grip!.x + grip!.width / 2, grip!.y + grip!.height / 2)
  106. await page.mouse.down()
  107. await page.mouse.move(grip!.x - 160, grip!.y + grip!.height / 2, { steps: 8 })
  108. await page.mouse.up()
  109. const after = (await sidebar.boundingBox())?.width ?? 0
  110. expect(after, 'dragging left widens the sidebar').toBeGreaterThan(before + 100)
  111. })
  112. test('stacks into one scroller on a narrow screen', async ({ page }) => {
  113. await page.setViewportSize({ width: 390, height: 844 })
  114. await page.goto(jobUrl)
  115. // Still one of each field, and still exactly one of them.
  116. await expect(page.locator('input[name="title"]')).toHaveCount(1)
  117. await expect(page.locator('input[name="title"]')).toBeVisible()
  118. // Nothing to drag when there is nothing beside anything.
  119. await expect(page.getByTestId('service-resize')).toBeHidden()
  120. // One scroller around the pair, rather than one each.
  121. const layer = page.getByTestId('service-layout')
  122. expect(await layer.evaluate((el) => el.scrollHeight > el.clientHeight + 1)).toBe(true)
  123. for (const id of ['service-main', 'service-sidebar']) {
  124. expect(
  125. await page.getByTestId(id).evaluate((el) => el.scrollHeight > el.clientHeight + 1),
  126. `${id} does not scroll on its own`
  127. ).toBe(false)
  128. }
  129. await layer.evaluate((el) => el.scrollBy(0, 300))
  130. expect(await layer.evaluate((el) => el.scrollTop)).toBeGreaterThan(0)
  131. })
  132. test('saves from a narrow screen, with the values that are on it', async ({ page }) => {
  133. await page.setViewportSize({ width: 390, height: 844 })
  134. await page.goto(jobUrl)
  135. const title = page.locator('input[name="title"]')
  136. const renamed = `E2E layout narrow ${stamp}`
  137. await expect(async () => {
  138. await title.fill(renamed)
  139. await expect(title).toHaveValue(renamed, { timeout: 2_000 })
  140. }).toPass({ timeout: 30_000 })
  141. await saveWorkOrder(page)
  142. // The old shell submitted whichever copy the layout had hidden, so a
  143. // narrow screen could save the desktop copy's stale title.
  144. await page.reload()
  145. await settle(page)
  146. await expect(page.locator('input[name="title"]')).toHaveCount(1)
  147. await expect(page.locator('input[name="title"]')).toHaveValue(renamed)
  148. })
  149. })