Skip to content
Draft
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
11 changes: 11 additions & 0 deletions .changeset/test-helpers-runtime-agnostic.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@primer/react': patch
---

Make the published `@primer/react/test-helpers` entry runtime-agnostic so it works with both Jest and Vitest consumers.

Previously the file hard-referenced the `jest` global to create mock functions for its JSDOM polyfills (`ResizeObserver`, `HTMLDialogElement.showModal/close`, `HTMLCanvasElement.getContext`, `Element.scrollIntoView`, `matchMedia`, `CSS.escape/supports`). Importing the helper from a Vitest test threw `ReferenceError: jest is not defined`.

The polyfills now detect `globalThis.jest?.fn` or `globalThis.vi?.fn` at runtime and fall back to a plain no-op function if neither is present. Mock-style introspection (`toHaveBeenCalled` etc.) still works for both Jest and Vitest consumers; consumers without a test runtime get a silent no-op which is sufficient for the polyfill use case.

Also removes a stale `// jest function` comment from `SelectPanel.test.tsx` (the test actually uses `vi.fn()`).
1 change: 0 additions & 1 deletion packages/react/src/SelectPanel/SelectPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,6 @@ for (const usingRemoveActiveDescendant of [false, true]) {

it('should call onActiveDescendantChanged when using keyboard while focusing on an item', async () => {
const user = userEvent.setup()
// jest function
const onActiveDescendantChanged = vi.fn()

render(<BasicSelectPanel onActiveDescendantChanged={onActiveDescendantChanged} />)
Expand Down
44 changes: 28 additions & 16 deletions packages/react/src/utils/test-helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,31 @@
// @ts-nocheck
import {TextEncoder} from 'node:util'

// Runtime-agnostic mock helper: works with Jest, Vitest, or any other test runtime.
// Falls back to a plain function when no runtime is detected, so polyfills still apply
// even outside a test runner (e.g. an SSR-only import).
const mockFn = (impl?: (...args: unknown[]) => unknown) => {
const runtimeMockFactory =
(globalThis as {jest?: {fn: typeof Function}}).jest?.fn ?? (globalThis as {vi?: {fn: typeof Function}}).vi?.fn
if (runtimeMockFactory) {
return impl ? runtimeMockFactory(impl) : runtimeMockFactory()
}
return impl ?? (() => {})
}

// JSDOM doesn't mock ResizeObserver
global.ResizeObserver = jest.fn().mockImplementation(() => {
global.ResizeObserver = mockFn(() => {
return {
observe: jest.fn(),
disconnect: jest.fn(),
unobserve: jest.fn(),
observe: mockFn(),
disconnect: mockFn(),
unobserve: mockFn(),
}
})

// @ts-expect-error only declare properties used internally
global.CSS = {
escape: jest.fn(),
supports: jest.fn().mockImplementation(() => {
escape: mockFn(),
supports: mockFn(() => {
return false
}),
}
Expand All @@ -29,23 +41,23 @@ global.TextEncoder = TextEncoder
* bonus: we only want to mock browser globals in DOM (or js-dom) environments – not in SSR / node
*/
if (typeof document !== 'undefined') {
global.HTMLDialogElement.prototype.showModal = jest.fn(function mock(this: HTMLDialogElement) {
global.HTMLDialogElement.prototype.showModal = mockFn(function mock(this: HTMLDialogElement) {
this.open = true
})

global.HTMLDialogElement.prototype.close = jest.fn(function mock(this: HTMLDialogElement) {
global.HTMLDialogElement.prototype.close = mockFn(function mock(this: HTMLDialogElement) {
this.open = false
})

// Add a fallback for getContext if it does not exist in the test, used for axe
global.HTMLCanvasElement.prototype.getContext = jest.fn()
global.HTMLCanvasElement.prototype.getContext = mockFn()
}

// Add a fallback for scrollIntoView if it does not exist in the test
// environment.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (global.Element.prototype.scrollIntoView === undefined) {
global.Element.prototype.scrollIntoView = jest.fn()
global.Element.prototype.scrollIntoView = mockFn()
}

// setup match media for tests that use useResponsiveValue or use a compone that uses useResponsiveValue
Expand All @@ -55,15 +67,15 @@ export const setupMatchMedia = () => {
// https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
value: mockFn(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
addListener: mockFn(), // deprecated
removeListener: mockFn(), // deprecated
addEventListener: mockFn(),
removeEventListener: mockFn(),
dispatchEvent: mockFn(),
})),
})
}
Loading