|
| 1 | +import { test, expect, type Page } from '@playwright/test'; |
| 2 | +import { compile } from 'tailwindcss'; |
| 3 | +import { readFileSync } from 'node:fs'; |
| 4 | +import { fileURLToPath } from 'node:url'; |
| 5 | +import path from 'node:path'; |
| 6 | +import { uiRoot } from '../scripts/ui-root.mjs'; |
| 7 | + |
| 8 | +/** |
| 9 | + * GEOMETRY pin for `record:path`'s phone row (objectstack#26897). |
| 10 | + * |
| 11 | + * At 390×844 on the HotCRM seed the path's row below `sm` cut the stage at its |
| 12 | + * right edge mid-word ("Resolu"): the stages were content-width and |
| 13 | + * `whitespace-nowrap` inside a sideways scroller whose scrollbar is hidden, so |
| 14 | + * nothing said the row scrolled. The fix gives every stage an equal share with |
| 15 | + * a 2.5rem floor, truncates an ordinary label with an ellipsis and wraps a |
| 16 | + * terminal one, as the desktop rail does. |
| 17 | + * |
| 18 | + * ⚠️ A Playwright spec and not a vitest test for the reason |
| 19 | + * `record-header-title-width.spec.ts` gives: jsdom has no layout engine, so a |
| 20 | + * width read there is 0 and an assertion on it is vacuous. The class-shape |
| 21 | + * half (and the `title` that keeps a cut label readable) is pinned beside the |
| 22 | + * renderer in `record-path.phoneRow-26897.test.tsx`. |
| 23 | + * |
| 24 | + * REAL, read out of `record-path.tsx` at run time: the phone row's class |
| 25 | + * string, the stage, rail and label base strings `renderStage` composes, and |
| 26 | + * the three named label/stage constants — plus real Tailwind compiled from the |
| 27 | + * installed `tailwindcss` for exactly those candidates. FIXTURE: the stage |
| 28 | + * labels, and the element tree, transcribed from `renderStage`. |
| 29 | + */ |
| 30 | + |
| 31 | +const REPO_ROOT = uiRoot(); |
| 32 | +const PATH_FILE = 'packages/plugin-detail/src/renderers/record-path.tsx'; |
| 33 | +const pathSrc = readFileSync(path.join(REPO_ROOT, PATH_FILE), 'utf8'); |
| 34 | + |
| 35 | +/** One string out of the source, asserting a token so a drifted match throws. */ |
| 36 | +function grab(re: RegExp, mustContain: string[]): string { |
| 37 | + const m = pathSrc.match(re); |
| 38 | + if (!m) throw new Error(`${PATH_FILE}: nothing matched ${re}`); |
| 39 | + const cls = m[1]; |
| 40 | + for (const token of mustContain) { |
| 41 | + if (!cls.split(/\s+/).includes(token)) { |
| 42 | + throw new Error( |
| 43 | + `${PATH_FILE}: ${re} matched "${cls}", which does not carry "${token}" — the extraction ` + |
| 44 | + 'walked onto the wrong string, so this measurement would be meaningless.', |
| 45 | + ); |
| 46 | + } |
| 47 | + } |
| 48 | + return cls; |
| 49 | +} |
| 50 | + |
| 51 | +const CLS = { |
| 52 | + row: grab(/\{\/\* Mobile:[^\n]*\*\/\}\s*<div\s+className="([^"]+)"/, ['sm:hidden', 'overflow-x-auto']), |
| 53 | + stage: grab(/className=\{cn\('([^']+)', o\.className\)\}/, ['flex-col']), |
| 54 | + rail: grab(/const railClass = [\s\S]*?cn\(\s*'([^']+)'/, ['w-full']), |
| 55 | + label: grab(/const labelClass = [\s\S]*?cn\(\s*'([^']+)'/, ['min-w-0']), |
| 56 | + phoneStage: grab(/const PHONE_STAGE_CLASS = '([^']+)'/, ['min-w-10']), |
| 57 | + phoneLabel: grab(/const PHONE_LABEL_CLASS = '([^']+)'/, ['truncate']), |
| 58 | + terminalLabel: grab(/const TERMINAL_LABEL_CLASS = '([^']+)'/, ['[overflow-wrap:anywhere]']), |
| 59 | +}; |
| 60 | + |
| 61 | +type Stage = { label: string; terminal?: boolean }; |
| 62 | + |
| 63 | +/** The card's case path, as the HotCRM seed draws it. */ |
| 64 | +const CASE_STAGES: Stage[] = [ |
| 65 | + { label: 'New' }, |
| 66 | + { label: 'In Progress' }, |
| 67 | + { label: 'Waiting on Customer' }, |
| 68 | + { label: 'Resolution Proposed' }, |
| 69 | + { label: 'Resolved' }, |
| 70 | + { label: 'Closed', terminal: true }, |
| 71 | +]; |
| 72 | + |
| 73 | +/** A long path: ten stages, so 2.5rem floors cannot all fit a 320px row. */ |
| 74 | +const LONG_STAGES: Stage[] = Array.from({ length: 10 }, (_, i) => ({ label: `Stage number ${i + 1}` })); |
| 75 | + |
| 76 | +const fixture = (stages: Stage[]) => |
| 77 | + `<div id="row" role="list" class="${CLS.row}">` + |
| 78 | + stages |
| 79 | + .map( |
| 80 | + (s) => |
| 81 | + `<div role="listitem" class="${CLS.stage} ${CLS.phoneStage}">` + |
| 82 | + `<span class="${CLS.rail} bg-muted"></span>` + |
| 83 | + `<span class="${CLS.label} ${s.terminal ? CLS.terminalLabel : CLS.phoneLabel}">${s.label}</span>` + |
| 84 | + '</div>', |
| 85 | + ) |
| 86 | + .join('') + |
| 87 | + '</div>'; |
| 88 | + |
| 89 | +let css = ''; |
| 90 | +test.beforeAll(async () => { |
| 91 | + const twEntry = fileURLToPath(import.meta.resolve('tailwindcss/index.css')); |
| 92 | + const compiler = await compile('@import "tailwindcss";', { |
| 93 | + base: path.dirname(twEntry), |
| 94 | + loadStylesheet: async (id: string, base: string) => { |
| 95 | + const file = id === 'tailwindcss' ? twEntry : path.resolve(base, id); |
| 96 | + return { path: file, base: path.dirname(file), content: readFileSync(file, 'utf8') }; |
| 97 | + }, |
| 98 | + }); |
| 99 | + css = compiler.build([...Object.values(CLS), 'bg-muted'].join(' ').split(/\s+/).filter(Boolean)); |
| 100 | + if (!css.includes('@media')) { |
| 101 | + throw new Error('Tailwind produced no media queries — `sm:hidden` would be inert.'); |
| 102 | + } |
| 103 | +}); |
| 104 | + |
| 105 | +type Reading = { |
| 106 | + rowClientWidth: number; |
| 107 | + rowScrollWidth: number; |
| 108 | + stages: { label: string; left: number; right: number; width: number; clipped: boolean; ellipsis: boolean }[]; |
| 109 | +}; |
| 110 | + |
| 111 | +async function readRow(page: Page, stages: Stage[], width: number): Promise<Reading> { |
| 112 | + await page.setViewportSize({ width, height: 844 }); |
| 113 | + await page.setContent(`<style>html,body{margin:0}${css}</style>${fixture(stages)}`, { waitUntil: 'load' }); |
| 114 | + // Wait for the row's layout to settle across two frames, then for fonts. |
| 115 | + await page.waitForFunction( |
| 116 | + () => { |
| 117 | + const row = document.getElementById('row'); |
| 118 | + if (!row || row.getBoundingClientRect().width <= 0) return false; |
| 119 | + const w = row.scrollWidth; |
| 120 | + const prev = (window as unknown as { __w?: number }).__w; |
| 121 | + (window as unknown as { __w?: number }).__w = w; |
| 122 | + return prev !== undefined && prev === w; |
| 123 | + }, |
| 124 | + null, |
| 125 | + { polling: 'raf', timeout: 10_000 }, |
| 126 | + ); |
| 127 | + await page.evaluate(() => document.fonts?.ready); |
| 128 | + return page.evaluate(() => { |
| 129 | + const round = (n: number) => Math.round(n * 100) / 100; |
| 130 | + const row = document.getElementById('row')!; |
| 131 | + return { |
| 132 | + rowClientWidth: row.clientWidth, |
| 133 | + rowScrollWidth: row.scrollWidth, |
| 134 | + stages: Array.from(row.children).map((item) => { |
| 135 | + const label = item.querySelector('span:not(.bg-muted)') as HTMLElement; |
| 136 | + const r = item.getBoundingClientRect(); |
| 137 | + return { |
| 138 | + label: label.textContent ?? '', |
| 139 | + left: round(r.left), |
| 140 | + right: round(r.right), |
| 141 | + width: round(r.width), |
| 142 | + clipped: label.scrollWidth > label.clientWidth, |
| 143 | + ellipsis: getComputedStyle(label).textOverflow === 'ellipsis', |
| 144 | + }; |
| 145 | + }), |
| 146 | + }; |
| 147 | + }); |
| 148 | +} |
| 149 | + |
| 150 | +test.describe('record:path phone row (objectstack#26897)', () => { |
| 151 | + for (const width of [320, 375, 390]) { |
| 152 | + test(`the case path fits its row at ${width}px and every cut label ellipsises`, async ({ page }) => { |
| 153 | + const r = await readRow(page, CASE_STAGES, width); |
| 154 | + // THE assertion: nothing hides past the row's edge, where the hidden |
| 155 | + // scrollbar gave no sign that it was there. |
| 156 | + expect( |
| 157 | + r.rowScrollWidth, |
| 158 | + `row holds ${r.rowScrollWidth}px of stages in ${r.rowClientWidth}px — the last stage is cut at the edge`, |
| 159 | + ).toBeLessThanOrEqual(r.rowClientWidth); |
| 160 | + for (const s of r.stages) { |
| 161 | + expect(s.right, `stage "${s.label}" ends past the row`).toBeLessThanOrEqual(width + 0.5); |
| 162 | + // A label that does not fit is cut WITH an ellipsis, never bare. |
| 163 | + if (s.clipped) expect(s.ellipsis, `stage "${s.label}" is cut with no ellipsis`).toBe(true); |
| 164 | + } |
| 165 | + // Not vacuous: at these widths the long labels really are cut. |
| 166 | + expect(r.stages.some((s) => s.clipped), 'no label was cut, so the ellipsis went unmeasured').toBe(true); |
| 167 | + // Equal shares: every stage is one width. |
| 168 | + const widths = new Set(r.stages.map((s) => Math.round(s.width))); |
| 169 | + expect(widths.size, `stage widths ${[...widths].join(', ')}`).toBe(1); |
| 170 | + }); |
| 171 | + } |
| 172 | + |
| 173 | + test('a terminal label wraps instead of being cut, as on the desktop rail', async ({ page }) => { |
| 174 | + const r = await readRow(page, [...CASE_STAGES.slice(0, 5), { label: 'Closed — Duplicate', terminal: true }], 320); |
| 175 | + const terminal = r.stages[r.stages.length - 1]; |
| 176 | + expect(terminal.clipped, 'the terminal stage label is cut').toBe(false); |
| 177 | + }); |
| 178 | + |
| 179 | + test('past the floor the stages keep 2.5rem and the row scrolls instead', async ({ page }) => { |
| 180 | + const r = await readRow(page, LONG_STAGES, 320); |
| 181 | + for (const s of r.stages) expect(s.width).toBeGreaterThanOrEqual(40); |
| 182 | + expect(r.rowScrollWidth).toBeGreaterThan(r.rowClientWidth); |
| 183 | + }); |
| 184 | +}); |
0 commit comments