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
11 changes: 7 additions & 4 deletions .github/workflows/static-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,13 @@ jobs:
os: 'macos-15'

steps:
# The BASE branch commit must be present for the check-root-dependencies script to read the package.json and
# compare with the HEAD branch. Fetching a depth of 2 ensures that's present.
# All other jobs only require a depth of 1.
# The parent commit must be present for dependency and test-routing
# checks that compare the current change with its baseline.
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
show-progress: ${{ runner.debug == '1' && 'true' || 'false' }}
fetch-depth: ${{ matrix.annotations && github.event_name == 'pull_request' && 2 || 1 }}
fetch-depth: 2
persist-credentials: false

- name: Validate labeler config
Expand Down Expand Up @@ -119,6 +118,10 @@ jobs:
- name: Type checking
run: npm run typecheck

- name: Validate JavaScript test routing
if: ${{ matrix.node == '20' && matrix.os == 'ubuntu-24.04' }}
run: npm run test:unit:routing -- --base="$(git rev-parse HEAD^1)"

- name: Build packages
run: npm run build

Expand Down
22 changes: 0 additions & 22 deletions .github/workflows/unit-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,28 +24,6 @@ concurrency:
permissions: {}

jobs:
unit-js-routing:
name: JavaScript test routing
runs-on: 'ubuntu-24.04'
permissions:
contents: read
if: ${{ github.repository == 'WordPress/gutenberg' || github.event_name == 'pull_request' }}

steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
show-progress: ${{ runner.debug == '1' && 'true' || 'false' }}
persist-credentials: false

- name: Setup Node.js and install dependencies
uses: ./.github/setup-node
with:
node-version: '20'

- name: Validate JavaScript test routing
run: npm run test:unit:routing

unit-js:
name: JavaScript (Node.js ${{ matrix.node }}) ${{ matrix.shard }}
runs-on: 'ubuntu-24.04'
Expand Down
2 changes: 1 addition & 1 deletion docs/contributors/code/testing-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ During the Jest-to-Vitest migration, run both `npm run test:unit` and `npm run t

Keep your tests in a `test` folder in your working directory. The test file should have the same name as the test subject file.

Use `*.jsdom.test.*` for tests that only require a virtual DOM. Use `*.browser.test.*` for tests that require Vitest Browser Mode. Leave Node-compatible test names without an environment suffix. Node tests run in Vitest automatically. The filename selects the test environment in both Jest and Vitest. During the remaining migration, the manifest selects the runner only for JSDOM and Browser Mode tests. Do not use per-file environment overrides.
Use `*.jsdom.test.*` for tests that only require a virtual DOM. Use `*.browser.test.*` for tests that require Vitest Browser Mode. Leave Node-compatible test names without an environment suffix. Every new test runs in Vitest automatically. The filename selects its environment. During the remaining migration, the manifest lists only legacy JSDOM tests that still run in Jest. Do not use per-file environment overrides.

```
+-- test
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@
"test:unit:vitest": "npm run --workspace @wordpress/unit-tests test:unit:vitest --",
"test:unit:vitest:update": "npm run --workspace @wordpress/unit-tests test:unit:vitest:update --",
"test:unit:vitest:watch": "npm run --workspace @wordpress/unit-tests test:unit:vitest:watch --",
"test:unit:routing": "npm run --workspace @wordpress/unit-tests test:unit:routing",
"test:unit:routing": "npm run --workspace @wordpress/unit-tests test:unit:routing --",
"test:unit:date": "bash ./bin/unit-test-date.sh",
"test:unit:debug": "npm run --workspace @wordpress/unit-tests test:unit:debug --",
"test:unit:profile": "npm run --workspace @wordpress/unit-tests test:unit:profile --",
Expand Down
36 changes: 21 additions & 15 deletions test/unit/VITEST_MIGRATION.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
# Vitest migration routing

The migration manifest in `test-migration.json` keeps every JavaScript unit and
integration test assigned to exactly one runner while the repository moves from
Jest to Vitest.
Every new JavaScript unit and integration test runs in Vitest. The migration
manifest in `test-migration.json` contains the exact list of remaining legacy
Jest tests while the repository finishes moving them to Vitest.

The routing validator derives the current test inventory from both runners and
the repository's static test-file patterns. It does not depend on a fixed test
count, so unrelated test additions and removals do not require migration
metadata updates.

When changing test ownership:
When writing or migrating a test:

- Node tests run in Vitest automatically. They do not need migration metadata.
- Add migrated JSDOM and Browser Mode tests to `vitest.files`, or use
`vitest.directories` when an entire directory can move as one independently
revertible unit.
- New Node, JSDOM, and Browser Mode tests run in Vitest automatically. They do
not need migration metadata.
- When migrating a legacy Jest test, remove its exact path from `jest.files`.
- When renaming or moving a directory that contains legacy Jest tests, migrate
those tests to Vitest in the same pull request. The validator treats their
new paths as additions to the Jest allowlist.
- Do not add directories, glob patterns, or new files to `jest.files`. The
Comment thread
ciampo marked this conversation as resolved.
routing validator compares it with the pull request base and accepts only
removals.
- Name tests `*.jsdom.test.*` to use JSDOM or `*.browser.test.*` to use Browser
Mode.
- Use Node for pure JavaScript, data, stores, schemas, build tooling, and
Expand All @@ -30,17 +35,18 @@ When changing test ownership:
- Before running Browser Mode tests locally for the first time, install
Chromium with
`npm exec --workspace @wordpress/unit-tests -- playwright install chromium`.
- New Node tests run in Vitest without migration metadata. A new
`*.jsdom.test.*` runs in Jest until it is assigned to Vitest, and a new
`*.browser.test.*` must be assigned to Vitest so it can run in Browser Mode.
- Do not use per-file Jest or Vitest environment overrides. The filename is
the single source of truth.
- Run `npm test` for the complete lint and unit-test suite. For focused
migration validation, run `npm run test:unit:routing`,
`npm run test:unit:conventions`, `npm run test:unit`, and
`npm run test:unit:vitest` together.

The routing validator fails when a test is missing, owned by both runners, or
not assigned to the expected Vitest migration entry. It also rejects per-file
environment overrides, invalid manifest entries, and static/executable
discovery mismatches.
The required `All` CI check runs the routing validator. It fails when a test is
missing, owned by both runners, or does not match the exact legacy Jest
allowlist. It also rejects per-file environment overrides, invalid manifest
entries, and static/executable discovery mismatches. The convention validator
also rejects Vitest isolation opt-outs and global Vitest APIs.

`wpVitest` remains an explicit opt-in for jsdom suites that need hoist-safe
helpers inside `vi.hoisted()`.
212 changes: 212 additions & 0 deletions test/unit/config/setup-globals.vitest.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,215 @@
import ResizeObserverPolyfill from 'resize-observer-polyfill';
import { afterAll, beforeAll, vi } from 'vitest';

function mockCSSSupports() {
const originalCSS = globalThis.CSS;
const originalSupports = globalThis.CSS?.supports;

beforeAll( () => {
if ( ! globalThis.CSS ) {
Reflect.set( globalThis, 'CSS', {} );
}
Reflect.set(
globalThis.CSS,
'supports',
vi.fn( () => false )
);
} );

afterAll( () => {
if ( originalSupports ) {
Reflect.set( globalThis.CSS, 'supports', originalSupports );
} else if ( originalCSS ) {
Reflect.deleteProperty( globalThis.CSS, 'supports' );
} else {
Reflect.deleteProperty( globalThis, 'CSS' );
}
} );
}

function mockMatchMedia() {
if ( typeof window === 'undefined' ) {
return;
}

const originalMatchMedia = window.matchMedia;
const mockedMatchMedia = vi.fn( ( query ) => ( {
matches: /prefers-reduced-motion/.test( query ),
media: query,
onchange: null,
addListener: vi.fn(),
addEventListener: vi.fn(),
dispatchEvent: vi.fn(),
removeListener: vi.fn(),
removeEventListener: vi.fn(),
} ) );

Reflect.set( window, 'matchMedia', mockedMatchMedia );

afterAll( () => {
if ( originalMatchMedia ) {
Reflect.set( window, 'matchMedia', originalMatchMedia );
} else {
Reflect.deleteProperty( window, 'matchMedia' );
}
} );
}

function mockResizeObserver() {
const originalResizeObserver = globalThis.ResizeObserver;

beforeAll( () => {
Reflect.set( globalThis, 'ResizeObserver', ResizeObserverPolyfill );
} );

afterAll( () => {
if ( originalResizeObserver ) {
Reflect.set( globalThis, 'ResizeObserver', originalResizeObserver );
} else {
Reflect.deleteProperty( globalThis, 'ResizeObserver' );
}
} );
}

function mockPointerEvent() {
const originalPointerEvent = globalThis.PointerEvent;

beforeAll( () => {
class PointerEvent extends globalThis.MouseEvent {
constructor( type, init = {} ) {
super( type, init );
this.pointerId = init.pointerId ?? 0;
this.pointerType = init.pointerType ?? '';
this.isPrimary = init.isPrimary ?? false;
}
}

Reflect.set( globalThis, 'PointerEvent', PointerEvent );
} );

afterAll( () => {
if ( originalPointerEvent ) {
Reflect.set( globalThis, 'PointerEvent', originalPointerEvent );
} else {
Reflect.deleteProperty( globalThis, 'PointerEvent' );
}
} );
}

function mockScrollIntoView() {
const originalScrollIntoView = globalThis.Element.prototype.scrollIntoView;

beforeAll( () => {
Reflect.set( globalThis.Element.prototype, 'scrollIntoView', vi.fn() );
} );

afterAll( () => {
if ( originalScrollIntoView ) {
Reflect.set(
globalThis.Element.prototype,
'scrollIntoView',
originalScrollIntoView
);
} else {
Reflect.deleteProperty(
globalThis.Element.prototype,
'scrollIntoView'
);
}
} );
}

class FakeDOMRectList extends Array {
/**
* @param {number} index Index of the rectangle to return.
* @return {DOMRect | null} The rectangle at the requested index.
*/
item( index ) {
return this[ index ] ?? null;
}
}

/**
* @param {Element} element Element to inspect.
* @return {boolean} Whether the element has a layout box.
*/
function hasAssociatedLayoutBox( element ) {
if ( ! element.isConnected ) {
return false;
}

let current = element;
while ( current ) {
if (
current instanceof globalThis.HTMLElement &&
( current.hidden || current.style.display === 'none' )
) {
return false;
}

if (
current === element &&
current instanceof globalThis.HTMLElement &&
current.style.display === 'contents'
) {
return false;
}

current = current.parentElement;
}

return true;
}

function mockVisibleElements() {
const originalGetClientRects = globalThis.Element.prototype.getClientRects;

beforeAll( () => {
Reflect.set(
globalThis.Element.prototype,
'getClientRects',
function () {
const rects = [];

if ( hasAssociatedLayoutBox( this ) ) {
rects.push( {
bottom: 1,
height: 1,
left: 0,
right: 1,
top: 0,
width: 1,
x: 0,
y: 0,
} );
}

return new FakeDOMRectList( ...rects );
}
);
} );

afterAll( () => {
Reflect.set(
globalThis.Element.prototype,
'getClientRects',
originalGetClientRects
);
} );
}

// Keep these opt-in mocks on `globalThis` so `vi.hoisted()` can call them
// synchronously before static imports run.
globalThis.wpVitest = {
mockCSSSupports,
mockMatchMedia,
mockPointerEvent,
mockResizeObserver,
mockScrollIntoView,
mockVisibleElements,
timers: vi,
};

if ( typeof globalThis.window !== 'undefined' ) {
globalThis.window.tinyMCEPreInit = {
baseURL: 'about:blank',
Expand Down
16 changes: 3 additions & 13 deletions test/unit/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,6 @@ const testMigration = require( './test-migration.json' );
*/
const ROOT_DIR = path.resolve( __dirname, '../..' );

const escapeRegExp = ( value ) =>
value.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
const vitestTestPathIgnorePatterns = [
...testMigration.vitest.files.map(
( testPath ) => `<rootDir>/${ escapeRegExp( testPath ) }$`
),
...testMigration.vitest.directories.map(
( directoryPath ) => `<rootDir>/${ escapeRegExp( directoryPath ) }/`
),
];

// Ensure Babel config resolution works from the repo root,
// even when Jest runs from the workspace directory.
process.chdir( ROOT_DIR );
Expand Down Expand Up @@ -114,7 +103,6 @@ const commonProjectConfig = {
'<rootDir>/.*/build-module/',
'<rootDir>/.*/build-types/',
'<rootDir>/.+\\.d\\.ts$',
...vitestTestPathIgnorePatterns,
],
resolver: '<rootDir>/test/unit/scripts/resolver.js',
transform: {
Expand Down Expand Up @@ -144,7 +132,9 @@ module.exports = {
testEnvironmentOptions: {
url: 'http://localhost/',
},
testMatch: [ '**/*.jsdom.test.[jt]s?(x)' ],
testMatch: testMigration.jest.files.map(
( testPath ) => `<rootDir>/${ testPath }`
),
},
],
watchPlugins: [
Expand Down
Loading
Loading