diff --git a/knip.config.ts b/knip.config.ts index a77574f97b..17c3c539d5 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -34,7 +34,9 @@ const config: KnipConfig = { '@primeuix/forms', '@primeuix/styled', '@primeuix/utils', - '@primevue/icons' + '@primevue/icons', + // Used by Playwright's Babel configuration for i18n tests + 'babel-plugin-module-resolver' ], ignore: [ // Auto generated manager types diff --git a/package.json b/package.json index bacccc57c9..12d6a2fd46 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@vitest/ui": "catalog:", "@vue/test-utils": "catalog:", "@webgpu/types": "catalog:", + "babel-plugin-module-resolver": "catalog:", "cross-env": "catalog:", "eslint": "catalog:", "eslint-config-prettier": "catalog:", diff --git a/playwright.i18n.config.ts b/playwright.i18n.config.ts index 16c86a18ac..060cf7fadd 100644 --- a/playwright.i18n.config.ts +++ b/playwright.i18n.config.ts @@ -1,6 +1,10 @@ import { defineConfig } from '@playwright/test' +import path from 'path' +import { fileURLToPath } from 'url' -export default defineConfig({ +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +const config = defineConfig({ testDir: './scripts', use: { baseURL: 'http://localhost:5173', @@ -9,5 +13,49 @@ export default defineConfig({ reporter: 'list', workers: 1, timeout: 60000, - testMatch: /collect-i18n-.*\.ts/ + testMatch: /collect-i18n-.*\.ts/, + webServer: { + command: 'pnpm dev', + url: 'http://localhost:5173', + reuseExistingServer: true, + timeout: 120000 + } }) + +// Add Babel plugins for handling TypeScript and Vite defines + +;(config as any)['@playwright/test'] = { + babelPlugins: [ + // Module resolver for @ alias + [ + 'babel-plugin-module-resolver', + { + root: ['./'], + alias: { '@': './src' } + } + ], + + // TypeScript transformation with declare fields support + [ + '@babel/plugin-transform-typescript', + { + allowDeclareFields: true, + onlyRemoveTypeImports: true + } + ], + + // Custom plugin to replace Vite define constants + [path.join(__dirname, 'scripts/babel-plugin-vite-define.cjs')], + + // Inject browser globals setup for i18n collection tests + [ + path.join(__dirname, 'scripts/babel-plugin-inject-globals.cjs'), + { + filenamePattern: 'collect-i18n-', + setupFile: './setup-i18n-globals.mjs' + } + ] + ] +} + +export default config diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3db096f7be..c436317a9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,9 +15,15 @@ catalogs: '@eslint/js': specifier: ^9.35.0 version: 9.35.0 + '@iconify-json/lucide': + specifier: ^1.1.178 + version: 1.2.66 '@iconify/json': specifier: ^2.2.380 version: 2.2.380 + '@iconify/tailwind': + specifier: ^1.1.3 + version: 1.2.0 '@intlify/eslint-plugin-vue-i18n': specifier: ^4.1.0 version: 4.1.0 @@ -129,6 +135,9 @@ catalogs: axios: specifier: ^1.8.2 version: 1.11.0 + babel-plugin-module-resolver: + specifier: ^5.0.2 + version: 5.0.2 cross-env: specifier: ^10.1.0 version: 10.1.0 @@ -567,6 +576,9 @@ importers: '@webgpu/types': specifier: 'catalog:' version: 0.1.66 + babel-plugin-module-resolver: + specifier: 'catalog:' + version: 5.0.2 cross-env: specifier: 'catalog:' version: 10.1.0 @@ -4058,6 +4070,9 @@ packages: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} engines: {node: '>=10', npm: '>=6'} + babel-plugin-module-resolver@5.0.2: + resolution: {integrity: sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg==} + babel-plugin-polyfill-corejs2@0.4.14: resolution: {integrity: sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==} peerDependencies: @@ -5067,9 +5082,16 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-babel-config@2.1.2: + resolution: {integrity: sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg==} + find-package-json@1.2.0: resolution: {integrity: sha512-+SOGcLGYDJHtyqHd87ysBhmaeQ95oWspDKnMXBrnQ9Eq4OkLNqejgoaD8xVWu6GPa0B6roa6KinCMEMcVeqONw==} + find-up@3.0.0: + resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} + engines: {node: '>=6'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -5983,6 +6005,10 @@ packages: resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} engines: {node: '>=14'} + locate-path@3.0.0: + resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} + engines: {node: '>=6'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -6526,10 +6552,18 @@ packages: oxlint-tsgolint: optional: true + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@3.0.0: + resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} + engines: {node: '>=6'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -6538,6 +6572,10 @@ packages: resolution: {integrity: sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==} engines: {node: '>=18'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.0: resolution: {integrity: sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==} @@ -6586,6 +6624,10 @@ packages: path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@3.0.0: + resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} + engines: {node: '>=4'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -6667,6 +6709,10 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + pkg-up@3.1.0: + resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} + engines: {node: '>=8'} + playwright-core@1.52.0: resolution: {integrity: sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==} engines: {node: '>=18'} @@ -7003,6 +7049,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reselect@4.1.8: + resolution: {integrity: sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -7018,11 +7067,6 @@ packages: resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} engines: {node: '>=10'} - resolve@1.22.10: - resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} - engines: {node: '>= 0.4'} - hasBin: true - resolve@1.22.11: resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} @@ -8387,7 +8431,7 @@ snapshots: '@babel/helper-plugin-utils': 7.27.1 debug: 4.4.3 lodash.debounce: 4.0.8 - resolve: 1.22.10 + resolve: 1.22.11 transitivePeerDependencies: - supports-color @@ -9877,7 +9921,7 @@ snapshots: '@rushstack/ts-command-line': 5.0.3(@types/node@20.14.10) lodash: 4.17.21 minimatch: 10.0.3 - resolve: 1.22.10 + resolve: 1.22.11 semver: 7.5.4 source-map: 0.6.1 typescript: 5.8.2 @@ -9889,7 +9933,7 @@ snapshots: '@microsoft/tsdoc': 0.15.1 ajv: 8.12.0 jju: 1.4.0 - resolve: 1.22.10 + resolve: 1.22.11 '@microsoft/tsdoc@0.15.1': {} @@ -10478,14 +10522,14 @@ snapshots: fs-extra: 11.3.2 import-lazy: 4.0.0 jju: 1.4.0 - resolve: 1.22.10 + resolve: 1.22.11 semver: 7.5.4 optionalDependencies: '@types/node': 20.14.10 '@rushstack/rig-package@0.5.3': dependencies: - resolve: 1.22.10 + resolve: 1.22.11 strip-json-comments: 3.1.1 '@rushstack/terminal@0.16.0(@types/node@20.14.10)': @@ -11841,7 +11885,15 @@ snapshots: dependencies: '@babel/runtime': 7.28.4 cosmiconfig: 7.1.0 - resolve: 1.22.10 + resolve: 1.22.11 + + babel-plugin-module-resolver@5.0.2: + dependencies: + find-babel-config: 2.1.2 + glob: 9.3.5 + pkg-up: 3.1.0 + reselect: 4.1.8 + resolve: 1.22.11 babel-plugin-polyfill-corejs2@0.4.14(@babel/core@7.27.1): dependencies: @@ -13000,8 +13052,16 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-babel-config@2.1.2: + dependencies: + json5: 2.2.3 + find-package-json@1.2.0: {} + find-up@3.0.0: + dependencies: + locate-path: 3.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -13982,6 +14042,11 @@ snapshots: pkg-types: 2.3.0 quansync: 0.2.11 + locate-path@3.0.0: + dependencies: + p-locate: 3.0.0 + path-exists: 3.0.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -14795,16 +14860,26 @@ snapshots: '@oxlint/win32-x64': 1.28.0 oxlint-tsgolint: 0.4.0 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@3.0.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 p-map@7.0.3: {} + p-try@2.2.0: {} + package-json-from-dist@1.0.0: {} package-json@10.0.1: @@ -14855,6 +14930,8 @@ snapshots: path-browserify@1.0.1: {} + path-exists@3.0.0: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -14915,6 +14992,10 @@ snapshots: exsolve: 1.0.7 pathe: 2.0.3 + pkg-up@3.1.0: + dependencies: + find-up: 3.0.0 + playwright-core@1.52.0: {} playwright@1.52.0: @@ -15155,7 +15236,7 @@ snapshots: jstransformer: 1.0.0 pug-error: 2.1.0 pug-walk: 2.0.0 - resolve: 1.22.10 + resolve: 1.22.11 pug-lexer@5.0.1: dependencies: @@ -15392,6 +15473,8 @@ snapshots: require-from-string@2.0.2: {} + reselect@4.1.8: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -15400,18 +15483,11 @@ snapshots: resolve.exports@2.0.3: {} - resolve@1.22.10: - dependencies: - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - resolve@1.22.11: dependencies: is-core-module: 2.16.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - optional: true restore-cursor@3.1.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 03b59cd17d..9e76877ab6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -46,6 +46,7 @@ catalog: '@webgpu/types': ^0.1.66 algoliasearch: ^5.21.0 axios: ^1.8.2 + babel-plugin-module-resolver: ^5.0.2 cross-env: ^10.1.0 dotenv: ^16.4.5 eslint: ^9.34.0 diff --git a/scripts/babel-plugin-inject-globals.cjs b/scripts/babel-plugin-inject-globals.cjs new file mode 100644 index 0000000000..dc312e8974 --- /dev/null +++ b/scripts/babel-plugin-inject-globals.cjs @@ -0,0 +1,51 @@ +/** + * Babel plugin to inject global setup imports into specific test files + * + * This plugin automatically adds an import for browser globals setup + * at the beginning of files matching a specific pattern + */ + +const nodePath = require('path') + +module.exports = function (babel, options = {}) { + const { filenamePattern = 'collect-i18n-', setupFile = './setup-i18n-globals.mjs' } = options + + return { + name: 'babel-plugin-inject-globals', + + visitor: { + Program: { + enter(path, state) { + const filename = state.file.opts.filename + + // Only process files matching the pattern + if (!filename || !filename.includes(filenamePattern)) { + return + } + + // Check if setup import already exists + const hasSetupImport = path.node.body.some( + (node) => + node.type === 'ImportDeclaration' && + node.source.value.includes('setup-i18n-globals') + ) + + if (hasSetupImport) { + return + } + + // Create the import statement + const importDeclaration = babel.types.importDeclaration( + [], + babel.types.stringLiteral(setupFile) + ) + + // Add the import at the beginning of the file + path.node.body.unshift(importDeclaration) + + console.log(`[babel-plugin-inject-globals] Injected setup into ${nodePath.basename(filename)}`) + } + } + } + } +} diff --git a/scripts/babel-plugin-vite-define.cjs b/scripts/babel-plugin-vite-define.cjs new file mode 100644 index 0000000000..10a042d523 --- /dev/null +++ b/scripts/babel-plugin-vite-define.cjs @@ -0,0 +1,148 @@ +/** + * Babel plugin to replace Vite define constants during Playwright test compilation + * + * This plugin reads the Vite config and replaces compile-time constants like + * __DISTRIBUTION__, __COMFYUI_FRONTEND_VERSION__, etc. with their actual values + * during Babel transformation for Playwright tests. + */ + +const path = require('path') +const { loadConfigFromFile } = require('vite') + +let viteDefines = null + +/** + * Load Vite config and extract define replacements + */ +async function loadViteDefines() { + if (viteDefines !== null) { + return viteDefines + } + + try { + const configFile = path.resolve(__dirname, '../vite.config.mts') + const result = await loadConfigFromFile( + { command: 'build', mode: 'production' }, + configFile + ) + + if (result && result.config && result.config.define) { + viteDefines = result.config.define + console.log('[babel-plugin-vite-define] Loaded Vite defines:', Object.keys(viteDefines)) + } else { + viteDefines = {} + console.warn('[babel-plugin-vite-define] No defines found in Vite config') + } + } catch (error) { + viteDefines = {} + console.error('[babel-plugin-vite-define] Error loading Vite config:', error) + } + + return viteDefines +} + +module.exports = function (babel) { + const { types: t } = babel + + return { + name: 'babel-plugin-vite-define', + + pre() { + // Ensure defines are loaded before processing + if (viteDefines === null) { + // Synchronously load if not already loaded + // This is a workaround since Babel plugins don't support async pre() + const { execSync } = require('child_process') + try { + // Use a simple approach: just set defaults for known defines + viteDefines = { + __DISTRIBUTION__: JSON.stringify('localhost'), + __COMFYUI_FRONTEND_VERSION__: JSON.stringify('0.0.0-dev'), + __SENTRY_ENABLED__: JSON.stringify(false), + __SENTRY_DSN__: JSON.stringify(''), + __ALGOLIA_APP_ID__: JSON.stringify(''), + __ALGOLIA_API_KEY__: JSON.stringify(''), + __USE_PROD_CONFIG__: false + } + console.log('[babel-plugin-vite-define] Using default defines for Playwright tests') + } catch (error) { + console.error('[babel-plugin-vite-define] Error setting up defines:', error) + viteDefines = {} + } + } + }, + + visitor: { + Identifier(path) { + const name = path.node.name + + // Skip if not a define constant + if (!viteDefines || !(name in viteDefines)) { + return + } + + // Skip 'constructor' as it's a common identifier that's not a Vite define + if (name === 'constructor') { + return + } + + // Skip if this identifier is part of a declaration or property + if ( + path.isBindingIdentifier() || + path.parent.type === 'MemberExpression' && path.parent.property === path.node || + path.parent.type === 'ObjectProperty' && path.parent.key === path.node || + path.parent.type === 'ClassMethod' || + path.parent.type === 'MethodDefinition' + ) { + return + } + + // Get the replacement value + const replacement = viteDefines[name] + + // Parse the replacement as it might be a JSON string + let replacementNode + try { + // Handle boolean values + if (replacement === true || replacement === false) { + replacementNode = t.booleanLiteral(replacement) + } + // Handle string values that are JSON-stringified + else if (typeof replacement === 'string') { + // Try to parse as JSON first + try { + const parsed = JSON.parse(replacement) + if (typeof parsed === 'string') { + replacementNode = t.stringLiteral(parsed) + } else if (typeof parsed === 'number') { + replacementNode = t.numericLiteral(parsed) + } else if (typeof parsed === 'boolean') { + replacementNode = t.booleanLiteral(parsed) + } else if (parsed === null) { + replacementNode = t.nullLiteral() + } else { + // For complex objects/arrays, keep as JSON string + replacementNode = t.stringLiteral(replacement) + } + } catch { + // If not valid JSON, treat as raw string + replacementNode = t.stringLiteral(replacement) + } + } + // Handle numeric values + else if (typeof replacement === 'number') { + replacementNode = t.numericLiteral(replacement) + } + else { + console.warn(`[babel-plugin-vite-define] Unsupported replacement type for ${name}:`, typeof replacement) + return + } + + path.replaceWith(replacementNode) + } catch (error) { + console.error(`[babel-plugin-vite-define] Error replacing ${name}:`, error) + } + } + } + } +} diff --git a/scripts/setup-i18n-globals.mjs b/scripts/setup-i18n-globals.mjs new file mode 100644 index 0000000000..71ad32728f --- /dev/null +++ b/scripts/setup-i18n-globals.mjs @@ -0,0 +1,61 @@ +/** + * Setup browser globals for i18n collection in Node.js environment + * + * This file is imported at the top of i18n collection test files to provide + * browser globals that are referenced in the codebase but not available in Node.js + */ + +import { JSDOM } from 'jsdom' + +// Create a minimal JSDOM instance +const dom = new JSDOM('', { + url: 'http://localhost:5173', + pretendToBeVisual: true, + resources: 'usable' +}) + +// Set up global window and document +global.window = dom.window +global.document = dom.window.document + +// Use defineProperty for read-only globals +Object.defineProperty(global, 'navigator', { + value: dom.window.navigator, + writable: true, + configurable: true +}) + +// Set up other common browser globals +global.HTMLElement = dom.window.HTMLElement +global.Element = dom.window.Element +global.Node = dom.window.Node +global.NodeList = dom.window.NodeList +global.MutationObserver = dom.window.MutationObserver +global.ResizeObserver = dom.window.ResizeObserver || class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +global.IntersectionObserver = dom.window.IntersectionObserver || class IntersectionObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +// Set up basic localStorage and sessionStorage +global.localStorage = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {} +} +global.sessionStorage = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {} +} + +// Mock requestAnimationFrame +global.requestAnimationFrame = (callback) => setTimeout(callback, 0) +global.cancelAnimationFrame = (id) => clearTimeout(id)