test(editor): Move reusable frontend test setup into @n8n/vitest-config (no-changelog) (#35637)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alex Grozav
2026-08-07 13:14:37 +03:00
committed by GitHub
parent 561af4c567
commit 0d450085bb
13 changed files with 503 additions and 482 deletions
+6
View File
@@ -25,6 +25,12 @@ export const createVitestConfig = (options: InlineConfig = {}) => {
// spies set up once don't leak across tests. Packages may override via `options`.
restoreMocks: true,
environment: 'jsdom',
// CI shards the frontend suite 2-way (`--shard=N/2`). A package with fewer
// test files than shards leaves a shard with nothing to run, and vitest
// treats that as an error — so a sparse package (a freshly scaffolded
// module, a config-only package) would fail outright. Default it on here so
// every frontend package inherits it instead of rediscovering the failure.
passWithNoTests: true,
setupFiles: ['./src/__tests__/setup.ts'],
reporters: process.env.CI === 'true' ? ['default', 'junit'] : ['default'],
outputFile: { junit: './junit.xml' },
+17
View File
@@ -3,12 +3,24 @@
"version": "1.19.0",
"type": "module",
"peerDependencies": {
"@testing-library/jest-dom": "catalog:frontend",
"@testing-library/vue": "catalog:frontend",
"vite": "catalog:",
"vitest": "catalog:"
},
"peerDependenciesMeta": {
"@testing-library/jest-dom": {
"optional": true
},
"@testing-library/vue": {
"optional": true
}
},
"dependencies": {},
"devDependencies": {
"@n8n/typescript-config": "workspace:*",
"@testing-library/jest-dom": "catalog:frontend",
"@testing-library/vue": "catalog:frontend",
"typescript": "catalog:typescript",
"vite": "catalog:",
"vitest": "catalog:"
@@ -28,6 +40,11 @@
"require": "./dist/frontend.js",
"types": "./dist/frontend.d.ts"
},
"./setup/frontend": {
"import": "./dist/setup/frontend.js",
"require": "./dist/setup/frontend.js",
"types": "./dist/setup/frontend.d.ts"
},
"./node": {
"import": "./dist/node.js",
"require": "./dist/node.js",
@@ -0,0 +1,415 @@
/**
* Shared jsdom harness for every frontend package's vitest suite.
*
* Import it for side effects from a package's `src/__tests__/setup.ts` (the path
* `@n8n/vitest-config/frontend` points `setupFiles` at):
*
* ```ts
* import '@n8n/vitest-config/setup/frontend';
* ```
*
* Everything here is framework-agnostic jsdom patching: it must not import
* `vue`, `pinia`, `@n8n/i18n` or any other workspace package. `@n8n/i18n`,
* `@n8n/stores` and friends already devDepend on `@n8n/vitest-config`, so a
* dependency in that direction is a turbo build cycle. App-level boot (pinia,
* i18n messages, plugins, app-specific polyfills) therefore stays in each
* package's own setup file.
*/
import '@testing-library/jest-dom/vitest';
import { configure } from '@testing-library/vue';
import { beforeAll, vi } from 'vitest';
// Avoid tests failing because of difference between local and GitHub actions timezone
process.env.TZ = 'UTC';
configure({ testIdAttribute: 'data-test-id' });
/**
* PointerEvent polyfill for JSDOM
* Required for Reka UI tooltip hover to work (checks event.pointerType)
*/
class JsonDomPointerEvent extends MouseEvent implements PointerEvent {
readonly pointerId: number;
readonly pointerType: string;
readonly pressure: number;
readonly tangentialPressure: number;
readonly tiltX: number;
readonly tiltY: number;
readonly twist: number;
readonly width: number;
readonly height: number;
readonly isPrimary: boolean;
readonly altitudeAngle: number;
readonly azimuthAngle: number;
readonly persistentDeviceId: number;
constructor(type: string, params: PointerEventInit = {}) {
super(type, params);
this.pointerId = params.pointerId ?? 0;
this.pointerType = params.pointerType ?? 'mouse';
this.pressure = params.pressure ?? 0;
this.tangentialPressure = params.tangentialPressure ?? 0;
this.tiltX = params.tiltX ?? 0;
this.tiltY = params.tiltY ?? 0;
this.twist = params.twist ?? 0;
this.width = params.width ?? 1;
this.height = params.height ?? 1;
this.altitudeAngle = params.altitudeAngle ?? Math.PI / 2;
this.azimuthAngle = params.azimuthAngle ?? 0;
this.isPrimary = params.isPrimary ?? true;
this.persistentDeviceId = 0;
}
getCoalescedEvents(): PointerEvent[] {
return [];
}
getPredictedEvents(): PointerEvent[] {
return [];
}
}
// Always apply our PointerEvent polyfill - JSDOM's PointerEvent is incomplete
// and doesn't properly support pointerType which Reka UI requires for tooltips
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).PointerEvent = JsonDomPointerEvent;
/**
* Fixes missing pointer APIs and defaultPrevented issues for jsdom + user-event
* Required for Reka UI components (tooltips, etc.) to work properly in tests
*/
beforeAll(() => {
// Patch missing pointer APIs
const elementProto = HTMLElement.prototype as HTMLElement & {
hasPointerCapture?: (pointerId: number) => boolean;
setPointerCapture?: (pointerId: number) => void;
releasePointerCapture?: (pointerId: number) => void;
};
if (!elementProto.hasPointerCapture) {
Object.defineProperties(elementProto, {
hasPointerCapture: {
value: (_: number) => false,
writable: true,
},
setPointerCapture: {
value: (_: number) => {},
writable: true,
},
releasePointerCapture: {
value: (_: number) => {},
writable: true,
},
});
}
});
if (!window.ResizeObserver) {
// Use function constructor instead of class to allow vi.spyOn to work
function MockResizeObserver(this: ResizeObserver, _cb: ResizeObserverCallback) {
this.disconnect = vi.fn();
this.observe = vi.fn();
this.unobserve = vi.fn();
}
window.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
}
Element.prototype.scrollIntoView = vi.fn();
Range.prototype.getBoundingClientRect = vi.fn();
Range.prototype.getClientRects = vi.fn(() => ({
item: vi.fn(),
length: 0,
[Symbol.iterator]: vi.fn(),
}));
export class IntersectionObserver {
root = null;
rootMargin = '';
scrollMargin = '';
thresholds = [];
disconnect() {
return null;
}
observe() {
return null;
}
takeRecords() {
return [];
}
unobserve() {
return null;
}
}
window.IntersectionObserver = IntersectionObserver;
global.IntersectionObserver = IntersectionObserver;
// jsdom's MediaQueryList lacks the legacy addListener/removeListener pair that
// several libraries still feature-detect, so provide a complete stub.
//
// `matches: false` is deliberate — it must stay the shared default. A stub that
// answers `true` to every query silently opts every component into whichever
// branch a media query guards: `prefers-reduced-motion: reduce` disables
// animations, `prefers-color-scheme: dark` flips themes, print styles apply.
// editor-ui overrides this to `true` locally for `useDeviceSupport`; a package
// that needs a specific query to match should do the same rather than widening
// this default.
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
class Worker {
onmessage = vi.fn();
url: string;
constructor(url: string) {
this.url = url;
}
postMessage = vi.fn((message: string) => {
this.onmessage(message);
});
addEventListener = vi.fn();
terminate = vi.fn();
}
class MockMessagePort {
onmessage = vi.fn();
onmessageerror = vi.fn();
postMessage = vi.fn();
start = vi.fn();
close = vi.fn();
addEventListener = vi.fn();
removeEventListener = vi.fn();
dispatchEvent = vi.fn(() => true);
}
class SharedWorker {
port: MockMessagePort;
onerror = vi.fn();
constructor(_url: string | URL, _options?: string | WorkerOptions) {
this.port = new MockMessagePort();
}
addEventListener = vi.fn();
removeEventListener = vi.fn();
dispatchEvent = vi.fn(() => true);
}
class DataTransfer {
private data: Record<string, unknown> = {};
setData = vi.fn((type: string, data) => {
this.data[type] = data;
});
getData = vi.fn((type) => {
if (type.startsWith('text')) type = 'text';
return this.data[type] ?? null;
});
}
Object.defineProperty(window, 'Worker', {
writable: true,
value: Worker,
});
Object.defineProperty(window, 'SharedWorker', {
writable: true,
value: SharedWorker,
});
Object.defineProperty(window, 'DataTransfer', {
writable: true,
value: DataTransfer,
});
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
writable: true,
value: vi.fn(),
});
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
writable: true,
value: vi.fn(),
});
class SpeechSynthesisUtterance {
text = '';
lang = '';
voice = null;
volume = 1;
rate = 1;
pitch = 1;
onstart = null;
onend = null;
onerror = null;
onpause = null;
onresume = null;
onmark = null;
onboundary = null;
constructor(text?: string) {
if (text) {
this.text = text;
}
}
addEventListener = vi.fn();
removeEventListener = vi.fn();
dispatchEvent = vi.fn(() => true);
}
Object.defineProperty(window, 'SpeechSynthesisUtterance', {
writable: true,
value: SpeechSynthesisUtterance,
});
Object.defineProperty(window, 'speechSynthesis', {
writable: true,
value: {
cancel: vi.fn(),
speak: vi.fn(),
pause: vi.fn(),
resume: vi.fn(),
getVoices: vi.fn(() => []),
pending: false,
speaking: false,
paused: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(() => true),
},
});
// element-plus ElTable schedules a debounced doLayout that calls
// requestAnimationFrame on the trailing edge. When the timer fires after the
// test finishes, jsdom has torn down the window proxy and the bare
// requestAnimationFrame reference resolves to globalThis, where it is
// undefined — vitest 4 promotes the resulting ReferenceError to a run-level
// failure. Defining it on globalThis (not window) keeps it alive past teardown.
// Unconditional assignment (no ??=): jsdom seeds window.requestAnimationFrame
// at startup but revokes it during teardown, and consumers like CodeMirror
// capture the window reference at construction (this.win.requestAnimationFrame),
// so we need to own the property — not just fill in when absent — to survive
// teardown. The callback itself is guarded against post-teardown firing:
// Vue's whenTransitionEnds reads bare `window.getComputedStyle`, which throws
// ReferenceError once jsdom revokes `window`. Browsers don't fire rAF callbacks
// after the document is gone, so dropping them here matches that semantic.
// See DEVP-206 (and DEVP-201 for the original bare-global flavour).
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) =>
setTimeout(() => {
if (typeof window === 'undefined') return;
cb(performance.now());
}, 0) as unknown as number;
globalThis.cancelAnimationFrame = (id: number) => clearTimeout(id);
// Block jsdom XHRs from making real network requests in tests. Unmocked store
// actions used to fire real /rest/* calls; on Node 22 the resulting dual-stack
// DNS AggregateError emits via socketErrorListener AFTER the test has finished,
// and vitest 4 promotes that to a test-run failure (~22% miss rate on shard 2).
// Short-circuiting send() means any unmocked request fails synchronously during
// the test instead of racing teardown.
XMLHttpRequest.prototype.send = function (this: XMLHttpRequest) {
Object.defineProperty(this, 'readyState', { value: 4, configurable: true });
Object.defineProperty(this, 'status', { value: 0, configurable: true });
Object.defineProperty(this, 'statusText', { value: '', configurable: true });
queueMicrotask(() => {
this.dispatchEvent(new Event('readystatechange'));
this.dispatchEvent(new Event('error'));
this.dispatchEvent(new Event('loadend'));
});
};
// DEVP-209: Vite emits Vue SFC `<style module lang="scss">` blocks as virtual
// modules (e.g. `Foo.vue?vue&type=style&index=0&lang.module.scss`). The SCSS
// preprocessor pipeline is async (worker-backed); if a resolution is still in
// flight when Vitest 4 tears down the worker environment, the loader throws
// EnvironmentTeardownError and Vitest promotes the unhandled rejection to a
// run-level failure. Test authors can't avoid this — the imports are static
// and the async pipeline is Vite plumbing, not test code.
//
// Filter ONLY the SCSS virtual-module URL pattern. Do NOT broaden to all
// EnvironmentTeardownError — DEVP-206 (CodeMirror leaked timers) surfaces as
// the same error class but the right fix there is code-side cleanup, and a
// broad filter would mask that signal. Sibling to the rAF polyfill (DEVP-201,
// DEVP-206) and the XHR short-circuit above — both narrow harness defences
// against Vitest 4's post-teardown rejection promotion.
//
// Match BOTH module and non-module SCSS style blocks. `@vitejs/plugin-vue`
// emits `<style lang="scss">` as `...?vue&type=style&index=N&lang.scss` and
// `<style module lang="scss">` as `...&lang.module.scss` (the CSS-modules
// codegen rewrites the request via `.replace(/\.(\w+)$/, '.module.$1')`). A
// component can ship both kinds (e.g. design-system's `Button.vue`), so the
// `.module.` segment must stay optional or the non-module block's teardown
// rejection slips through and gets re-thrown. The `?vue&type=style` anchor
// keeps this scoped to Vue SFC style virtual modules, so DEVP-206 timer
// errors (not style URLs) are still surfaced.
process.on('unhandledRejection', (reason) => {
if (
reason instanceof Error &&
reason.name === 'EnvironmentTeardownError' &&
/\?vue&type=style.*lang(\.module)?\.scss/.test(reason.message)
) {
return;
}
throw reason;
});
@@ -1,21 +1 @@
import '@testing-library/jest-dom/vitest';
import { configure } from '@testing-library/vue';
configure({ testIdAttribute: 'data-test-id' });
class ResizeObserverMock extends EventTarget {
constructor() {
super();
}
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
}
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
});
afterEach(() => vi.unstubAllGlobals());
import '@n8n/vitest-config/setup/frontend';
@@ -1,4 +1 @@
import '@testing-library/jest-dom/vitest';
import { configure } from '@testing-library/vue';
configure({ testIdAttribute: 'data-test-id' });
import '@n8n/vitest-config/setup/frontend';
@@ -1,49 +1,18 @@
import '@testing-library/jest-dom/vitest';
import { configure } from '@testing-library/vue';
import '@n8n/vitest-config/setup/frontend';
import { config } from '@vue/test-utils';
import { beforeAll } from 'vitest';
import { afterEach, beforeAll, beforeEach, vi } from 'vitest';
import { N8nPlugin } from '@n8n/design-system/plugin';
configure({ testIdAttribute: 'data-test-id' });
config.global.plugins = [N8nPlugin];
// Globally mock is-emoji-supported
vi.mock('is-emoji-supported', () => ({
isEmojiSupported: () => true,
}));
/**
* Fixes missing pointer APIs and defaultPrevented issues for jsdom + user-event
*/
beforeAll(() => {
// Patch missing pointer APIs
const elementProto = HTMLElement.prototype as HTMLElement & {
hasPointerCapture?: (pointerId: number) => boolean;
setPointerCapture?: (pointerId: number) => void;
releasePointerCapture?: (pointerId: number) => void;
};
if (!elementProto.hasPointerCapture) {
Object.defineProperties(elementProto, {
hasPointerCapture: {
value: (_: number) => false,
writable: true,
},
setPointerCapture: {
value: (_: number) => {},
writable: true,
},
releasePointerCapture: {
value: (_: number) => {},
writable: true,
},
});
}
// jsdom lacks elementFromPoint; ProseMirror's posAtCoords calls it during
// editor mount (tiptap placeholder viewport tracking). null is a valid result.
//
// Kept local, not shared: defining it flips the behaviour of anything that
// feature-detects it. editor-ui's suite has always run without it, and adding
// it globally hung one of its agents-view tests.
const documentProto = Document.prototype as Document & {
elementFromPoint?: (x: number, y: number) => Element | null;
};
@@ -52,11 +21,19 @@ beforeAll(() => {
}
});
// Preserve originals
// Globally mock is-emoji-supported
vi.mock('is-emoji-supported', () => ({
isEmojiSupported: () => true,
}));
// jsdom + user-event mark synthetic pointer/mouse events as defaultPrevented,
// which makes Reka UI's dismissable-layer logic swallow interactions. Force it
// back to false. Kept local, not shared: the shared harness installs a
// spec-faithful PointerEvent polyfill, and forcing `defaultPrevented` to false
// for every frontend package would hide genuine preventDefault() calls.
const OriginalMouseEvent = window.MouseEvent;
const OriginalPointerEvent = window.PointerEvent || window.MouseEvent;
// Patched MouseEvent
class PatchedMouseEvent extends OriginalMouseEvent {
constructor(type: string, eventInit?: MouseEventInit) {
super(type, eventInit);
@@ -66,7 +43,6 @@ class PatchedMouseEvent extends OriginalMouseEvent {
}
}
// Patched PointerEvent
class PatchedPointerEvent extends OriginalPointerEvent {
constructor(type: string, eventInit?: PointerEventInit) {
super(type, eventInit);
@@ -76,21 +52,8 @@ class PatchedPointerEvent extends OriginalPointerEvent {
}
}
class ResizeObserverMock extends EventTarget {
constructor() {
super();
}
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
}
beforeEach(() => {
vi.stubGlobal('MouseEvent', PatchedMouseEvent);
vi.stubGlobal('PointerEvent', PatchedPointerEvent);
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
});
afterEach(() => vi.unstubAllGlobals());
@@ -1,4 +1 @@
// Test setup entry required by the shared frontend vitest config
// (`@n8n/vitest-config/frontend` points `setupFiles` here). The registry suites
// need no global setup, so this file intentionally stays empty.
export {};
import '@n8n/vitest-config/setup/frontend';
@@ -1,4 +1 @@
import '@testing-library/jest-dom/vitest';
import { configure } from '@testing-library/vue';
configure({ testIdAttribute: 'data-test-id' });
import '@n8n/vitest-config/setup/frontend';
@@ -1,2 +1 @@
// Avoid tests failing because of difference between local and GitHub actions timezone
process.env.TZ = 'UTC';
import '@n8n/vitest-config/setup/frontend';
@@ -1,7 +1 @@
import '@testing-library/jest-dom/vitest';
import { configure } from '@testing-library/vue';
// Avoid tests failing because of difference between local and GitHub actions timezone
process.env.TZ = 'UTC';
configure({ testIdAttribute: 'data-test-id' });
import '@n8n/vitest-config/setup/frontend';
@@ -1,6 +1,5 @@
import '@testing-library/jest-dom/vitest';
import '@n8n/vitest-config/setup/frontend';
import 'fake-indexeddb/auto';
import { configure } from '@testing-library/vue';
import 'core-js/proposals/set-methods-v2';
import englishBaseText from '@n8n/i18n/locales/en.json';
import { loadLanguage, type LocaleMessages } from '@n8n/i18n';
@@ -13,6 +12,11 @@ import { APP_MODALS_ELEMENT_ID } from '@/app/constants';
// (no teleportation), so tests can interact with popovers naturally.
// - Controlled mode (open prop provided): respects open state
// - Uncontrolled mode (no open prop): clicking trigger toggles visibility
//
// Stays here rather than in `@n8n/vitest-config/setup/frontend`: `reka-ui` is a
// dependency of editor-ui alone, and `vi.mock`'s specifier resolves relative to
// the file that calls it — a shared config package cannot mock a module it
// cannot resolve.
vi.mock('reka-ui', async (importOriginal) => {
const actual = await importOriginal<object>();
const { ref, provide, inject, computed, defineComponent, h } = await import('vue');
@@ -82,100 +86,21 @@ vi.mock('reka-ui', async (importOriginal) => {
};
});
// Avoid tests failing because of difference between local and GitHub actions timezone
process.env.TZ = 'UTC';
configure({ testIdAttribute: 'data-test-id' });
/**
* PointerEvent polyfill for JSDOM
* Required for Reka UI tooltip hover to work (checks event.pointerType)
*/
class JsonDomPointerEvent extends MouseEvent implements PointerEvent {
readonly pointerId: number;
readonly pointerType: string;
readonly pressure: number;
readonly tangentialPressure: number;
readonly tiltX: number;
readonly tiltY: number;
readonly twist: number;
readonly width: number;
readonly height: number;
readonly isPrimary: boolean;
readonly altitudeAngle: number;
readonly azimuthAngle: number;
readonly persistentDeviceId: number;
constructor(type: string, params: PointerEventInit = {}) {
super(type, params);
this.pointerId = params.pointerId ?? 0;
this.pointerType = params.pointerType ?? 'mouse';
this.pressure = params.pressure ?? 0;
this.tangentialPressure = params.tangentialPressure ?? 0;
this.tiltX = params.tiltX ?? 0;
this.tiltY = params.tiltY ?? 0;
this.twist = params.twist ?? 0;
this.width = params.width ?? 1;
this.height = params.height ?? 1;
this.altitudeAngle = params.altitudeAngle ?? Math.PI / 2;
this.azimuthAngle = params.azimuthAngle ?? 0;
this.isPrimary = params.isPrimary ?? true;
this.persistentDeviceId = 0;
}
getCoalescedEvents(): PointerEvent[] {
return [];
}
getPredictedEvents(): PointerEvent[] {
return [];
}
}
// Always apply our PointerEvent polyfill - JSDOM's PointerEvent is incomplete
// and doesn't properly support pointerType which Reka UI requires for tooltips
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).PointerEvent = JsonDomPointerEvent;
/**
* Fixes missing pointer APIs and defaultPrevented issues for jsdom + user-event
* Required for Reka UI components (tooltips, etc.) to work properly in tests
*/
beforeAll(() => {
// Patch missing pointer APIs
const elementProto = HTMLElement.prototype as HTMLElement & {
hasPointerCapture?: (pointerId: number) => boolean;
setPointerCapture?: (pointerId: number) => void;
releasePointerCapture?: (pointerId: number) => void;
};
if (!elementProto.hasPointerCapture) {
Object.defineProperties(elementProto, {
hasPointerCapture: {
value: (_: number) => false,
writable: true,
},
setPointerCapture: {
value: (_: number) => {},
writable: true,
},
releasePointerCapture: {
value: (_: number) => {},
writable: true,
},
});
}
// Mocks for useDeviceSupport. The shared harness answers `false` to every media
// query (the safe default — see its comment); editor-ui's suite has always run
// with every query matching, so keep that here rather than in the shared file.
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn((query) => ({
matches: true,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Create DOM containers for Element Plus components before each test
@@ -204,293 +129,4 @@ afterEach(() => {
}
});
if (!window.ResizeObserver) {
// Use function constructor instead of class to allow vi.spyOn to work
function MockResizeObserver(this: ResizeObserver, _cb: ResizeObserverCallback) {
this.disconnect = vi.fn();
this.observe = vi.fn();
this.unobserve = vi.fn();
}
window.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
}
Element.prototype.scrollIntoView = vi.fn();
Range.prototype.getBoundingClientRect = vi.fn();
Range.prototype.getClientRects = vi.fn(() => ({
item: vi.fn(),
length: 0,
[Symbol.iterator]: vi.fn(),
}));
export class IntersectionObserver {
root = null;
rootMargin = '';
scrollMargin = '';
thresholds = [];
disconnect() {
return null;
}
observe() {
return null;
}
takeRecords() {
return [];
}
unobserve() {
return null;
}
}
window.IntersectionObserver = IntersectionObserver;
global.IntersectionObserver = IntersectionObserver;
// Mocks for useDeviceSupport
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn((query) => ({
matches: true,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
class Worker {
onmessage = vi.fn();
url: string;
constructor(url: string) {
this.url = url;
}
postMessage = vi.fn((message: string) => {
this.onmessage(message);
});
addEventListener = vi.fn();
terminate = vi.fn();
}
class MockMessagePort {
onmessage = vi.fn();
onmessageerror = vi.fn();
postMessage = vi.fn();
start = vi.fn();
close = vi.fn();
addEventListener = vi.fn();
removeEventListener = vi.fn();
dispatchEvent = vi.fn(() => true);
}
class SharedWorker {
port: MockMessagePort;
onerror = vi.fn();
constructor(_url: string | URL, _options?: string | WorkerOptions) {
this.port = new MockMessagePort();
}
addEventListener = vi.fn();
removeEventListener = vi.fn();
dispatchEvent = vi.fn(() => true);
}
class DataTransfer {
private data: Record<string, unknown> = {};
setData = vi.fn((type: string, data) => {
this.data[type] = data;
});
getData = vi.fn((type) => {
if (type.startsWith('text')) type = 'text';
return this.data[type] ?? null;
});
}
Object.defineProperty(window, 'Worker', {
writable: true,
value: Worker,
});
Object.defineProperty(window, 'SharedWorker', {
writable: true,
value: SharedWorker,
});
Object.defineProperty(window, 'DataTransfer', {
writable: true,
value: DataTransfer,
});
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
writable: true,
value: vi.fn(),
});
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
writable: true,
value: vi.fn(),
});
class SpeechSynthesisUtterance {
text = '';
lang = '';
voice = null;
volume = 1;
rate = 1;
pitch = 1;
onstart = null;
onend = null;
onerror = null;
onpause = null;
onresume = null;
onmark = null;
onboundary = null;
constructor(text?: string) {
if (text) {
this.text = text;
}
}
addEventListener = vi.fn();
removeEventListener = vi.fn();
dispatchEvent = vi.fn(() => true);
}
Object.defineProperty(window, 'SpeechSynthesisUtterance', {
writable: true,
value: SpeechSynthesisUtterance,
});
Object.defineProperty(window, 'speechSynthesis', {
writable: true,
value: {
cancel: vi.fn(),
speak: vi.fn(),
pause: vi.fn(),
resume: vi.fn(),
getVoices: vi.fn(() => []),
pending: false,
speaking: false,
paused: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(() => true),
},
});
loadLanguage('en', englishBaseText as unknown as LocaleMessages);
// element-plus ElTable schedules a debounced doLayout that calls
// requestAnimationFrame on the trailing edge. When the timer fires after the
// test finishes, jsdom has torn down the window proxy and the bare
// requestAnimationFrame reference resolves to globalThis, where it is
// undefined — vitest 4 promotes the resulting ReferenceError to a run-level
// failure. Defining it on globalThis (not window) keeps it alive past teardown.
// Unconditional assignment (no ??=): jsdom seeds window.requestAnimationFrame
// at startup but revokes it during teardown, and consumers like CodeMirror
// capture the window reference at construction (this.win.requestAnimationFrame),
// so we need to own the property — not just fill in when absent — to survive
// teardown. The callback itself is guarded against post-teardown firing:
// Vue's whenTransitionEnds reads bare `window.getComputedStyle`, which throws
// ReferenceError once jsdom revokes `window`. Browsers don't fire rAF callbacks
// after the document is gone, so dropping them here matches that semantic.
// See DEVP-206 (and DEVP-201 for the original bare-global flavour).
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) =>
setTimeout(() => {
if (typeof window === 'undefined') return;
cb(performance.now());
}, 0) as unknown as number;
globalThis.cancelAnimationFrame = (id: number) => clearTimeout(id);
// Block jsdom XHRs from making real network requests in tests. Unmocked store
// actions used to fire real /rest/* calls; on Node 22 the resulting dual-stack
// DNS AggregateError emits via socketErrorListener AFTER the test has finished,
// and vitest 4 promotes that to a test-run failure (~22% miss rate on shard 2).
// Short-circuiting send() means any unmocked request fails synchronously during
// the test instead of racing teardown.
XMLHttpRequest.prototype.send = function (this: XMLHttpRequest) {
Object.defineProperty(this, 'readyState', { value: 4, configurable: true });
Object.defineProperty(this, 'status', { value: 0, configurable: true });
Object.defineProperty(this, 'statusText', { value: '', configurable: true });
queueMicrotask(() => {
this.dispatchEvent(new Event('readystatechange'));
this.dispatchEvent(new Event('error'));
this.dispatchEvent(new Event('loadend'));
});
};
// DEVP-209: Vite emits Vue SFC `<style module lang="scss">` blocks as virtual
// modules (e.g. `Foo.vue?vue&type=style&index=0&lang.module.scss`). The SCSS
// preprocessor pipeline is async (worker-backed); if a resolution is still in
// flight when Vitest 4 tears down the worker environment, the loader throws
// EnvironmentTeardownError and Vitest promotes the unhandled rejection to a
// run-level failure. Test authors can't avoid this — the imports are static
// and the async pipeline is Vite plumbing, not test code.
//
// Filter ONLY the SCSS virtual-module URL pattern. Do NOT broaden to all
// EnvironmentTeardownError — DEVP-206 (CodeMirror leaked timers) surfaces as
// the same error class but the right fix there is code-side cleanup, and a
// broad filter would mask that signal. Sibling to the rAF polyfill (DEVP-201,
// DEVP-206) and the XHR short-circuit above — both narrow harness defences
// against Vitest 4's post-teardown rejection promotion.
//
// Match BOTH module and non-module SCSS style blocks. `@vitejs/plugin-vue`
// emits `<style lang="scss">` as `...?vue&type=style&index=N&lang.scss` and
// `<style module lang="scss">` as `...&lang.module.scss` (the CSS-modules
// codegen rewrites the request via `.replace(/\.(\w+)$/, '.module.$1')`). A
// component can ship both kinds (e.g. design-system's `Button.vue`), so the
// `.module.` segment must stay optional or the non-module block's teardown
// rejection slips through and gets re-thrown. The `?vue&type=style` anchor
// keeps this scoped to Vue SFC style virtual modules, so DEVP-206 timer
// errors (not style URLs) are still surfaced.
process.on('unhandledRejection', (reason) => {
if (
reason instanceof Error &&
reason.name === 'EnvironmentTeardownError' &&
/\?vue&type=style.*lang(\.module)?\.scss/.test(reason.message)
) {
return;
}
throw reason;
});
@@ -25,6 +25,12 @@ export const GLOBAL_TRIGGER_FILES = new Set(['pnpm-lock.yaml', 'package.json']);
* ~everything; a behaviour change that keeps the same type signature is not
* visible to a downstream import-graph walk (typecheck only catches the
* contract). DEVP-195.
* - `packages/@n8n/vitest-config/` — the shared vitest config and the shared
* jsdom harness (`setup/frontend.ts`) that every frontend package's
* `src/__tests__/setup.ts` imports. A per-package setup file is already a
* bail-to-full-run trigger below; once the harness body lives here, editing
* it must trigger the same full run, or a change to 350 lines of global DOM
* patching would scope to zero test files and report a false green.
*
* Over-broad on the rare PRs that touch these (keeps the failure mode "ran too
* much" rather than "ran nothing"), which is the intended trade-off.
@@ -33,6 +39,7 @@ export const GLOBAL_TRIGGER_PREFIXES = [
'packages/@n8n/db/',
'packages/workflow/',
'packages/core/',
'packages/@n8n/vitest-config/',
];
/** True when a repo-root-relative path forces a full workspace run. */
+15 -2
View File
@@ -3981,6 +3981,12 @@ importers:
'@n8n/typescript-config':
specifier: workspace:*
version: link:../typescript-config
'@testing-library/jest-dom':
specifier: catalog:frontend
version: 6.6.3
'@testing-library/vue':
specifier: catalog:frontend
version: 8.1.0(@vue/compiler-sfc@3.5.26)(vue@3.5.26(typescript@7.0.2))
typescript:
specifier: catalog:typescript
version: 7.0.2
@@ -29990,6 +29996,15 @@ snapshots:
optionalDependencies:
'@vue/compiler-sfc': 3.5.26
'@testing-library/vue@8.1.0(@vue/compiler-sfc@3.5.26)(vue@3.5.26(typescript@7.0.2))':
dependencies:
'@babel/runtime': 7.29.7
'@testing-library/dom': 9.3.4
'@vue/test-utils': 2.4.6
vue: 3.5.26(typescript@7.0.2)
optionalDependencies:
'@vue/compiler-sfc': 3.5.26
'@thednp/dommatrix@2.0.12': {}
'@tiptap/core@3.27.0(@tiptap/pm@3.27.0)':
@@ -31607,7 +31622,6 @@ snapshots:
'@vue/compiler-ssr': 3.5.26
'@vue/shared': 3.5.26
vue: 3.5.26(typescript@7.0.2)
optional: true
'@vue/shared@3.5.26': {}
@@ -42543,7 +42557,6 @@ snapshots:
'@vue/shared': 3.5.26
optionalDependencies:
typescript: 7.0.2
optional: true
vuedraggable@4.1.0(patch_hash=eaa2c80f80cdc4293b0f1c9c0410823960f839fc15f155c8cb23666d5950e049)(vue@3.5.26(typescript@6.0.2)):
dependencies: