From 2b01d2683af1148c9fe1256732b325f83cbae4d3 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 17 Aug 2026 15:09:52 -0500 Subject: [PATCH] add vitest browser tests for new truncate logic --- AGENTS.md | 1 + app/ui/lib/Truncate.browser.spec.tsx | 114 +++++++++++++++++++++++++++ app/ui/lib/Truncate.spec.tsx | 66 +++++----------- app/ui/lib/Truncate.tsx | 12 ++- 4 files changed, 144 insertions(+), 49 deletions(-) create mode 100644 app/ui/lib/Truncate.browser.spec.tsx diff --git a/AGENTS.md b/AGENTS.md index bbee0fd1c..32a74b8c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ - Consider `expectVisible` and `expectNotVisible` deprecated: prefer `expect().toBeVisible()` and `toBeHidden()` in new code. - When UI needs new mock behavior, extend the MSW handlers/db minimally so E2E tests stay deterministic; prefer storing full API responses so subsequent calls see the updated state (`mock-api/msw/db.ts`, `mock-api/msw/handlers.ts`). - Co-locate Vitest specs next to the code they cover; use Testing Library utilities (`render`, `renderHook`, `fireEvent`, fake timers) to assert observable output rather than implementation details (`app/ui/lib/FileInput.spec.tsx`, `app/hooks/use-pagination.spec.ts`). +- Treat Vitest browser specs as small e2e tests: query by accessible role, label, or visible text and use retrying browser matchers. Avoid selectors coupled to CSS classes or internal DOM structure; inspect layout or computed styles only when the behavior has no semantic representation. - For sweeping styling changes, coordinate with the visual regression harness and follow `test/visual/README.md` for the workflow. - Fix root causes of flaky timing rather than adding `sleep()` workarounds in tests. - Local Playwright runs write a compact plain-text report to `.e2e-logs/` (gitignored, one timestamped `.log` per run, last 10 kept) via the custom reporter at `test/e2e/compact-reporter.ts`. Top line is `status: ... total=N passed=N ...`; each failure is a `── UNEXPECTED|FLAKY file:line title` block followed by the error (ANSI stripped). Latest run: `ls .e2e-logs | tail -1` — Read it directly, no parsing needed. diff --git a/app/ui/lib/Truncate.browser.spec.tsx b/app/ui/lib/Truncate.browser.spec.tsx new file mode 100644 index 000000000..b929e9786 --- /dev/null +++ b/app/ui/lib/Truncate.browser.spec.tsx @@ -0,0 +1,114 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { useRef, useState } from 'react' +import { expect, test } from 'vitest' +import { render } from 'vitest-browser-react' + +import { Truncate } from './Truncate' + +const text = '6e762538-dd89-454e-b6e7-82a199b6e51a' + +function OnePixelHarness() { + const wrapperRef = useRef(null) + const [width, setWidth] = useState('max-content') + + const narrowByOnePixel = () => { + const target = wrapperRef.current?.querySelector('[aria-label]') + if (target) setWidth(target.scrollWidth - 1) + } + + return ( + <> + + +
+ +
+ + ) +} + +test('middle-truncates to the rendered width and shows the full text on hover', async () => { + const screen = await render( +
+ +
+ ) + const value = screen.getByLabelText(text) + + await expect.element(screen.getByText(/^6.+….+a$/)).toBeVisible() + await expect.element(screen.getByRole('button', { name: 'Click to copy' })).toBeVisible() + + await value.hover() + await expect.element(screen.getByRole('tooltip')).toHaveTextContent(text) +}) + +test('end-truncates with CSS and shows the full text on hover', async () => { + const screen = await render( +
+ +
+ ) + const value = screen.getByLabelText(text) + + expect(value.element().scrollWidth).toBeGreaterThan(value.element().clientWidth) + expect(getComputedStyle(value.element()).textOverflow).toBe('ellipsis') + + await value.hover() + await expect.element(screen.getByRole('tooltip')).toHaveTextContent(text) +}) + +test.each(['middle', 'end'] as const)( + 'does not show a tooltip when %s-positioned text fits', + async (position) => { + const shortText = 'short text' + const screen = await render( +
+ +
+ ) + const value = screen.getByLabelText(shortText) + + await value.hover() + await expect.element(screen.getByRole('tooltip')).not.toBeInTheDocument() + } +) + +test('recomputes when the container becomes one pixel too narrow and widens again', async () => { + const screen = await render() + const value = screen.getByLabelText(text) + + await expect.element(screen.getByText(/…/)).not.toBeInTheDocument() + + await screen.getByRole('button', { name: 'Narrow by one pixel' }).click() + await expect.element(screen.getByText(/…/)).toBeVisible() + await value.hover() + await expect.element(screen.getByRole('tooltip')).toHaveTextContent(text) + + await screen.getByRole('button', { name: 'Reset width' }).click() + await expect.element(screen.getByText(/…/)).not.toBeInTheDocument() + await value.hover() + await expect.element(screen.getByRole('tooltip')).not.toBeInTheDocument() +}) + +test('does not split combining-character graphemes', async () => { + const grapheme = 'é' + const unicodeText = grapheme.repeat(40) + const screen = await render( +
+ +
+ ) + + await expect.element(screen.getByText(/^(?:é)+…(?:é)+$/)).toBeVisible() +}) diff --git a/app/ui/lib/Truncate.spec.tsx b/app/ui/lib/Truncate.spec.tsx index 0f307b2d3..ba549ba7d 100644 --- a/app/ui/lib/Truncate.spec.tsx +++ b/app/ui/lib/Truncate.spec.tsx @@ -6,59 +6,31 @@ * Copyright Oxide Computer Company */ -import { render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' -import { Truncate } from './Truncate' +import { middleTruncateToFit } from './Truncate' -const measureText = vi.fn((text: string) => ({ width: text.length })) +const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }) +const graphemeWidth = (text: string) => Array.from(segmenter.segment(text)).length -Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { - configurable: true, - value: () => ({ font: '', letterSpacing: '', measureText }), -}) - -afterEach(() => { - vi.restoreAllMocks() - measureText.mockClear() -}) - -describe('Truncate', () => { - it('preserves complete Unicode characters when truncating in the middle', () => { - vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(6) - vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(16) - const text = '😀'.repeat(8) - - render() - - const displayedText = screen - .getByLabelText(text) - .querySelector('.absolute')?.textContent - expect(displayedText).toBeDefined() - expect(hasUnpairedSurrogate(displayedText ?? '')).toBe(false) +describe('middleTruncateToFit', () => { + it.each([ + ['emoji', '😀'.repeat(8), '😀😀😀…😀😀'], + ['combining marks', 'é'.repeat(8), 'ééé…éé'], + ])('preserves complete %s graphemes', (_name, text, expected) => { + expect(middleTruncateToFit(text, 6, graphemeWidth)).toBe(expected) }) - it('truncates whenever the rendered text is wider than its container', () => { - vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(9) - vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(10) - const text = 'abcdefghij' + it('keeps the largest middle-truncated value that fits', () => { + expect(middleTruncateToFit('abcdefghij', 9, graphemeWidth)).toBe('abcd…ghij') + }) - render() + it('accounts for variable-width graphemes', () => { + const variableWidth = (text: string) => + Array.from(segmenter.segment(text), ({ segment }) => + segment === 'W' ? 3 : 1 + ).reduce((sum, width) => sum + width, 0) - expect(screen.getByLabelText(text).querySelector('.absolute')).not.toBeNull() + expect(middleTruncateToFit('WWiiiiWW', 9, variableWidth)).toBe('W…W') }) }) - -function hasUnpairedSurrogate(text: string) { - for (let i = 0; i < text.length; i++) { - const code = text.charCodeAt(i) - if (code >= 0xd800 && code <= 0xdbff) { - const next = text.charCodeAt(i + 1) - if (next < 0xdc00 || next > 0xdfff) return true - i++ - } else if (code >= 0xdc00 && code <= 0xdfff) { - return true - } - } - return false -} diff --git a/app/ui/lib/Truncate.tsx b/app/ui/lib/Truncate.tsx index 41c2ab651..46309a42f 100644 --- a/app/ui/lib/Truncate.tsx +++ b/app/ui/lib/Truncate.tsx @@ -144,9 +144,17 @@ function truncateToFit(text: string, el: HTMLElement): string { const width = el.clientWidth if (el.scrollWidth <= width) return text + return middleTruncateToFit(text, width, (candidate) => ctx.measureText(candidate).width) +} + +/** Middle-truncate known-overflowing text using rendered-width measurements. */ +export function middleTruncateToFit( + text: string, + width: number, + measure: (text: string) => number +) { const graphemes = Array.from(graphemeSegmenter.segment(text), ({ segment }) => segment) - const fits = (keep: number) => - ctx.measureText(middleEllipsis(graphemes, keep)).width <= width + const fits = (keep: number) => measure(middleEllipsis(graphemes, keep)) <= width // binary search for the largest number of kept graphemes that fits let lo = 0