mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
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:
@@ -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' },
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Generated
+15
-2
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user