diff --git a/packages/@n8n/vitest-config/frontend.ts b/packages/@n8n/vitest-config/frontend.ts index d753ac87a5e..f5106cf57f2 100644 --- a/packages/@n8n/vitest-config/frontend.ts +++ b/packages/@n8n/vitest-config/frontend.ts @@ -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' }, diff --git a/packages/@n8n/vitest-config/package.json b/packages/@n8n/vitest-config/package.json index 34cc00f542e..fee4f66f995 100644 --- a/packages/@n8n/vitest-config/package.json +++ b/packages/@n8n/vitest-config/package.json @@ -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", diff --git a/packages/@n8n/vitest-config/setup/frontend.ts b/packages/@n8n/vitest-config/setup/frontend.ts new file mode 100644 index 00000000000..4f9eef46fde --- /dev/null +++ b/packages/@n8n/vitest-config/setup/frontend.ts @@ -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 = {}; + + 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 `