Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
114 changes: 114 additions & 0 deletions app/ui/lib/Truncate.browser.spec.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null)
const [width, setWidth] = useState<number | 'max-content'>('max-content')

const narrowByOnePixel = () => {
const target = wrapperRef.current?.querySelector<HTMLElement>('[aria-label]')
if (target) setWidth(target.scrollWidth - 1)
}

return (
<>
<button type="button" onClick={narrowByOnePixel}>
Narrow by one pixel
</button>
<button type="button" onClick={() => setWidth('max-content')}>
Reset width
</button>
<div ref={wrapperRef} style={{ width }}>
<Truncate text={text} position="middle" tooltipDelay={0} />
</div>
</>
)
}

test('middle-truncates to the rendered width and shows the full text on hover', async () => {
const screen = await render(
<div style={{ width: 160 }}>
<Truncate text={text} position="middle" hasCopyButton tooltipDelay={0} />
</div>
)
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(
<div style={{ width: 160 }}>
<Truncate text={text} tooltipDelay={0} />
</div>
)
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(
<div style={{ width: 160 }}>
<Truncate text={shortText} position={position} tooltipDelay={0} />
</div>
)
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(<OnePixelHarness />)
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(
<div style={{ width: 160 }}>
<Truncate text={unicodeText} position="middle" />
</div>
)

await expect.element(screen.getByText(/^(?:é)+…(?:é)+$/)).toBeVisible()
})
66 changes: 19 additions & 47 deletions app/ui/lib/Truncate.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Truncate text={text} position="middle" />)

const displayedText = screen
.getByLabelText(text)
.querySelector('.absolute')?.textContent
expect(displayedText).toBeDefined()
expect(hasUnpairedSurrogate(displayedText ?? '')).toBe(false)
describe('middleTruncateToFit', () => {
it.each([
['emoji', '😀'.repeat(8), '😀😀😀…😀😀'],

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this does what hasUnpairedSurrogate did, but more neatly

['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(<Truncate text={text} position="middle" />)
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
}
12 changes: 10 additions & 2 deletions app/ui/lib/Truncate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading