diff --git a/.gitignore b/.gitignore index 19d04cedd..71c227dbc 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,4 @@ local.log **/*.gen.ts -.env \ No newline at end of file +.env diff --git a/lib/index.umd.spec.ts b/lib/index.umd.spec.ts new file mode 100644 index 000000000..4e157c395 --- /dev/null +++ b/lib/index.umd.spec.ts @@ -0,0 +1,38 @@ +/** + * Copyright 2025, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { expect, describe, it } from 'vitest'; + +import * as optimizely from './index.browser'; + +type OptimizelySdk = typeof optimizely; + +declare global { + interface Window { + optimizelySdk: OptimizelySdk; + } +} + +describe('UMD Bundle', () => { + // these are just initial tests to check the UMD bundle is loaded correctly + // we will add more comprehensive umd tests later + it('should have optimizelySdk on the window object', () => { + expect(window.optimizelySdk).toBeDefined(); + }); + + it('should export createInstance function', () => { + expect(typeof window.optimizelySdk.createInstance).toBe('function'); + }); +}); diff --git a/package.json b/package.json index ed4df2062..9ea6a2e21 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,9 @@ "test-browser": "node ./scripts/run-browser-tests.js", "test-browser-local": "USE_LOCAL_BROWSER=true node ./scripts/run-browser-tests.js", "test-browser-browserstack": "USE_LOCAL_BROWSER=false node ./scripts/run-browser-tests.js", + "test-umd": "node ./scripts/run-umd-tests.js", + "test-umd-local": "USE_LOCAL_BROWSER=true node ./scripts/run-umd-tests.js", + "test-umd-browserstack": "USE_LOCAL_BROWSER=false node ./scripts/run-umd-tests.js", "test-mocha": "TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\" }' mocha -r ts-node/register -r tsconfig-paths/register -r lib/tests/exit_on_unhandled_rejection.js 'lib/**/*.tests.ts' 'lib/**/*.tests.js'", "test": "npm run test-mocha && npm run test-vitest", "posttest": "npm run lint", @@ -74,7 +77,7 @@ "prebuild": "npm run clean", "build": "npm run validate-platform-isolation && tsc --noEmit && npm run genmsg && rollup -c && cp dist/index.browser.d.ts dist/index.d.ts", "build:win": "tsc --noEmit && npm run genmsg && rollup -c && type nul > dist/optimizely.lite.es.d.ts && type nul > dist/optimizely.lite.es.min.d.ts && type nul > dist/optimizely.lite.min.d.ts", - "build-browser-umd": "rollup -c --config-umd", + "build-browser-umd": "npm run validate-platform-isolation && tsc --noEmit && npm run genmsg && rollup -c --config-umd", "coveralls": "nyc --reporter=lcov npm test", "prepare": "npm run build", "prepublishOnly": "npm test", diff --git a/scripts/run-umd-tests.js b/scripts/run-umd-tests.js new file mode 100755 index 000000000..9aafa9fe8 --- /dev/null +++ b/scripts/run-umd-tests.js @@ -0,0 +1,294 @@ +#!/usr/bin/env node + +/** + * Copyright 2025, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const { execSync } = require('child_process'); +const browserstack = require('browserstack-local'); +const fs = require('fs'); +const path = require('path'); + +// Browser configurations grouped by browser name +const BROWSER_CONFIGS = { + chrome: [ + { name: 'chrome-102-windows', browserVersion: '102', os: 'Windows', osVersion: '11' }, + { name: 'chrome-latest-windows', browserVersion: 'latest', os: 'Windows', osVersion: '11' }, + ], + firefox: [ + { name: 'firefox-91-windows', browserVersion: '91', os: 'Windows', osVersion: '11' }, + { name: 'firefox-latest-windows', browserVersion: 'latest', os: 'Windows', osVersion: '11' }, + ], + edge: [ + { name: 'edge-89-windows', browserVersion: '89', os: 'Windows', osVersion: '11' }, + { name: 'edge-latest-windows', browserVersion: 'latest', os: 'Windows', osVersion: '11' }, + ], + safari: [ + { name: 'safari-monterey', os: 'OS X', osVersion: 'Monterey' }, + { name: 'safari-sequoia', os: 'OS X', osVersion: 'Sequoia' }, + ] +}; + +// Determine if we should use local browser or BrowserStack +// Priority: USE_LOCAL_BROWSER env var, then check for BrowserStack credentials +let useLocalBrowser = process.env.USE_LOCAL_BROWSER === 'true'; + +if (!useLocalBrowser) { + // Check for BrowserStack credentials + const username = process.env.BROWSERSTACK_USERNAME || process.env.BROWSER_STACK_USERNAME; + const accessKey = process.env.BROWSERSTACK_ACCESS_KEY || process.env.BROWSER_STACK_ACCESS_KEY; + + console.log('\n' + '='.repeat(80)); + console.log('BrowserStack Credentials Check:'); + console.log('='.repeat(80)); + console.log(`BROWSERSTACK_USERNAME: ${username ? '✓ Available' : '✗ Not found'}`); + console.log(`BROWSERSTACK_ACCESS_KEY: ${accessKey ? '✓ Available' : '✗ Not found'}`); + console.log('='.repeat(80) + '\n'); + + if (!username || !accessKey) { + console.log('BrowserStack credentials not found - falling back to local browser mode'); + useLocalBrowser = true; + } +} + + +let bs_local = null; + +function startTunnel(localIdentifier) { + const accessKey = process.env.BROWSERSTACK_ACCESS_KEY || process.env.BROWSER_STACK_ACCESS_KEY; + + console.log(`Starting BrowserStack Local tunnel with identifier: ${localIdentifier}...`); + bs_local = new browserstack.Local(); + const bsLocalArgs = { + key: accessKey, + force: true, + forceLocal: true, + // Enable verbose logging to debug tunnel issues + verbose: true, + // Use the provided identifier for parallel tunnel support + localIdentifier: localIdentifier, + }; + + return new Promise((resolve, reject) => { + bs_local.start(bsLocalArgs, (error) => { + if (error) { + console.error('Error starting BrowserStack Local:', error); + reject(error); + } else { + console.log('BrowserStack Local tunnel started successfully'); + console.log(`BrowserStack Local PID: ${bs_local.pid}`); + console.log(`Local Identifier: ${localIdentifier}`); + // Wait longer for tunnel to fully establish and register with BrowserStack + console.log('Waiting for tunnel to establish...'); + setTimeout(() => { + console.log('Tunnel ready!'); + resolve(); + }, 10000); + } + }); + }); +} + +function stopTunnel() { + if (!bs_local) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + bs_local.stop(() => { + console.log('BrowserStack Local tunnel stopped'); + resolve(); + }); + }); +} + +async function runTests() { + let exitCode = 0; + + try { + // Step 1: Run npm run build + console.log('\n' + '='.repeat(80)); + console.log('Building project...'); + console.log('='.repeat(80)); + try { + execSync('npm run build-browser-umd', { stdio: 'inherit' }); + console.log('Build completed successfully!'); + } catch (error) { + console.error('Failed to build project:', error.message); + exitCode = 1; + return; + } + + // Step 2: Copy the UMD file to vitest/public/dist/ + console.log('\n' + '='.repeat(80)); + console.log('Copying UMD file to vitest/public/dist/...'); + console.log('='.repeat(80)); + try { + const sourceFile = path.join(process.cwd(), 'dist/optimizely.browser.umd.min.js'); + const destDir = path.join(process.cwd(), 'vitest/public/dist'); + const destFile = path.join(destDir, 'optimizely.browser.umd.min.js'); + + // Create destination directory if it doesn't exist + if (!fs.existsSync(destDir)) { + fs.mkdirSync(destDir, { recursive: true }); + console.log(`Created directory: ${destDir}`); + } + + // Copy the file + fs.copyFileSync(sourceFile, destFile); + console.log(`Copied ${sourceFile} to ${destFile}`); + } catch (error) { + console.error('Failed to copy UMD file:', error.message); + exitCode = 1; + return; + } + + // Patch Vitest viewport command to prevent WebDriver Bidi errors + console.log('\n' + '='.repeat(80)); + console.log('Patching Vitest viewport command...'); + console.log('='.repeat(80)); + try { + execSync('node ./scripts/patch-vitest-viewport.js', { stdio: 'inherit' }); + } catch (error) { + console.error('Failed to patch Vitest viewport command:', error.message); + exitCode = 1; + return; + } + + // Get browser name from environment variable (default to chrome) + const browserName = (process.env.TEST_BROWSER || 'chrome').toLowerCase(); + + let configs; + + if (useLocalBrowser) { + configs = [{ + name: `${browserName}`, + }]; + console.log('Local browser mode: using local browser installation'); + } else { + // For BrowserStack, use the defined configs + configs = BROWSER_CONFIGS[browserName]; + if (!configs || configs.length === 0) { + console.error(`Error: No configurations found for browser '${browserName}'`); + console.error(`Available browsers: ${Object.keys(BROWSER_CONFIGS).join(', ')}`); + exitCode = 1; + return; + } + } + + // Only start tunnel if using BrowserStack + let localIdentifier; + if (!useLocalBrowser) { + // Generate a random identifier for parallel tunnel support (100000-900000) + localIdentifier = Math.floor(Math.random() * 800000) + 100000; + localIdentifier = localIdentifier.toString(); + await startTunnel(localIdentifier); + } else { + console.log('Using local browser mode - no BrowserStack connection needed'); + } + + console.log('\n' + '='.repeat(80)); + console.log(`Running UMD tests for browser: ${browserName}`); + console.log(`Total configurations: ${configs.length}`); + console.log('='.repeat(80) + '\n'); + + const results = []; + + // Run each config serially + for (const config of configs) { + console.log(`\n${'='.repeat(80)}`); + console.log(`Running: ${config.name}`); + console.log(`Browser: ${browserName}${config.browserVersion ? ` ${config.browserVersion}` : ''}`); + console.log(`OS: ${config.os} ${config.osVersion}`); + console.log('='.repeat(80)); + + // Set environment variables for this config + const env = { + ...process.env, + TEST_BROWSER: browserName, + TEST_BROWSER_VERSION: config.browserVersion, + TEST_OS_NAME: config.os, + TEST_OS_VERSION: config.osVersion, + // Pass the local identifier to vitest config for BrowserStack capabilities + BROWSERSTACK_LOCAL_IDENTIFIER: localIdentifier, + }; + + + try { + console.log('Starting vitest UMD test...'); + // Run vitest with the UMD config + execSync('npm run test-vitest -- --config vitest.umd.config.mts', { + stdio: 'inherit', + env, + }); + + console.log(`\n✓ ${config.name} passed!`); + results.push({ config: config.name, success: true }); + } catch (error) { + console.error(`\n✗ ${config.name} failed`); + if (error.message) { + console.error('Error message:', error.message); + } + results.push({ config: config.name, success: false }); + } + } + + // Print summary + console.log('\n' + '='.repeat(80)); + console.log(`UMD test summary for ${browserName}:`); + console.log('='.repeat(80)); + + const failures = []; + const successes = []; + + results.forEach(({ config, success }) => { + if (success) { + successes.push(config); + console.log(`✓ ${config}: PASSED`); + } else { + failures.push(config); + console.error(`✗ ${config}: FAILED`); + } + }); + + console.log('='.repeat(80)); + console.log(`Total: ${results.length} configurations`); + console.log(`Passed: ${successes.length}`); + console.log(`Failed: ${failures.length}`); + console.log('='.repeat(80)); + + // Set exit code based on results + if (failures.length > 0) { + console.error(`\nSome ${browserName} configurations failed. See above for details.`); + exitCode = 1; + } else { + console.log(`\nAll ${browserName} configurations passed!`); + exitCode = 0; + } + } finally { + // Only stop tunnel if using BrowserStack + if (!useLocalBrowser) { + await stopTunnel(); + } + + // Exit after tunnel is properly closed + process.exit(exitCode); + } +} + +// Run the tests +runTests().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/vitest.browser.config.mts b/vitest.browser.config.mts index f5c20e13c..7bc7d43c4 100644 --- a/vitest.browser.config.mts +++ b/vitest.browser.config.mts @@ -42,7 +42,7 @@ const testOsVersion = process.env.TEST_OS_VERSION; // Build local browser capabilities function buildLocalCapabilities() { return { - testBrowser, + browserName: testBrowser, 'goog:chromeOptions': { args: [ '--disable-blink-features=AutomationControlled', @@ -149,8 +149,7 @@ export default defineConfig({ test: { isolate: false, fileParallelism: true, - // Reduce concurrency for BrowserStack to minimize tunnel load and WebSocket connection issues - maxConcurrency: useLocalBrowser ? 5 : 1, + maxConcurrency: 5, onConsoleLog: () => true, browser: { enabled: true, @@ -167,6 +166,7 @@ export default defineConfig({ exclude: [ 'lib/**/*.react_native.spec.ts', 'lib/**/*.node.spec.ts', + 'lib/*.umd.spec.ts' ], typecheck: { enabled: true, diff --git a/vitest.config.mts b/vitest.config.mts index 178edb642..256405faf 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -27,6 +27,7 @@ export default defineConfig({ onConsoleLog: () => true, environment: 'happy-dom', include: ['lib/**/*.spec.ts'], + exclude: ['lib/*.umd.spec.ts'], typecheck: { enabled: true, tsconfig: 'tsconfig.spec.json', diff --git a/vitest.umd.config.mts b/vitest.umd.config.mts new file mode 100644 index 000000000..de9090d02 --- /dev/null +++ b/vitest.umd.config.mts @@ -0,0 +1,169 @@ +/** + * Copyright 2025, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/// +import path from 'path'; +import { defineConfig } from 'vitest/config' +import { umdPlugin } from './vitest/umd-plugin'; + +// Check if we should use local browser instead of BrowserStack +const useLocalBrowser = process.env.USE_LOCAL_BROWSER === 'true'; + + +// Get browser configuration from TEST_* environment variables +const testBrowser = process.env.TEST_BROWSER || 'chrome'; +const testBrowserVersion = process.env.TEST_BROWSER_VERSION; +const testOsName = process.env.TEST_OS_NAME; +const testOsVersion = process.env.TEST_OS_VERSION; + +// const browserConfig = { +// name: testBrowser, +// browserName: testBrowser, +// browserVersion: testBrowserVersion, +// os: testOsName, +// osVersion: testOsVersion, +// }; + +// const browserConfigs = [browserConfig]; + +// Build local browser capabilities +function buildLocalCapabilities() { + return { + browserName: testBrowser, + 'goog:chromeOptions': { + args: [ + '--disable-blink-features=AutomationControlled', + '--disable-dev-shm-usage', + '--no-sandbox', + ], + }, + }; +} + +// Build BrowserStack capabilities +function buildBrowserStackCapabilities() { + const localIdentifier = process.env.BROWSERSTACK_LOCAL_IDENTIFIER; + + return { + browserName: testBrowser, + 'wdio:enforceWebDriverClassic': true, // this doesn't work due to vitest bug, still keeping here for future reference + 'goog:chromeOptions': { + args: [ + '--disable-blink-features=AutomationControlled', + '--disable-dev-shm-usage', + '--no-sandbox', + ], + }, + 'bstack:options': { + os: testOsName, + osVersion: testOsVersion, + browserVersion: testBrowserVersion, + buildName: process.env.VITEST_BUILD_NAME || 'Vitest Browser Tests', + projectName: 'Optimizely JavaScript SDK', + sessionName: `${testBrowser} ${testBrowserVersion || ''} on ${testOsName} ${testOsVersion}`, + local: true, + // Include localIdentifier for parallel tunnel support + ...(localIdentifier && { localIdentifier }), + debug: false, + networkLogs: false, + consoleLogs: 'errors' as const, + seleniumLogs: false, + idleTimeout: 900, // 15 minutes idle timeout - prevents premature session closure during long test runs + }, + }; +} + +function buildBrowserInstances() { + if (useLocalBrowser) { + // Local browser configurations - all browsers + return [{ + browser: testBrowser, + capabilities: buildLocalCapabilities(), + }]; + } else { + + const username = process.env.BROWSERSTACK_USERNAME || process.env.BROWSER_STACK_USERNAME; + const key = process.env.BROWSERSTACK_ACCESS_KEY || process.env.BROWSER_STACK_ACCESS_KEY; + + return [{ + browser: testBrowser, + user: username, + key: key, + capabilities: buildBrowserStackCapabilities(), + connectionRetryTimeout: 120000, // 2 minutes connection retry timeout + connectionRetryCount: 3, // Retry 3 times on connection failure + waitforTimeout: 30000, // 30 seconds wait timeout - matches test expectations + waitforInterval: 1000, // Poll every 1 second - faster feedback + keepAlive: true, + keepAliveInterval: 30000, + }]; + } +} + +export default defineConfig({ + plugins: [ + umdPlugin(), + ], + resolve: { + alias: { + 'error_message': path.resolve(__dirname, './lib/message/error_message'), + 'log_message': path.resolve(__dirname, './lib/message/log_message'), + }, + }, + esbuild: { + target: 'es2015', + format: 'esm', + }, + build: { + target: 'es2015', + }, + optimizeDeps: { + // Force chai to be pre-bundled with ES6 target to remove class static blocks + // This avoids issues with browsers that do not support class static blocks like firefox 91 + include: ['chai'], + esbuildOptions: { + target: 'es2015', + }, + }, + // Serve public files from vitest/public directory + publicDir: 'vitest/public', + server: { + host: '0.0.0.0', + // for safari, browserstack redirects localhost to bs-local.com + allowedHosts: ['bs-local.com', 'localhost'], + }, + test: { + isolate: false, + fileParallelism: true, + maxConcurrency: 5, + onConsoleLog: () => true, + browser: { + enabled: true, + provider: 'webdriverio', + headless: false, + instances: buildBrowserInstances(), + connectTimeout: 300000, + }, + testTimeout: 60000, + hookTimeout: 30000, + include: [ + 'lib/**/*.umd.spec.ts', + ], + typecheck: { + enabled: true, + tsconfig: 'tsconfig.spec.json', + }, + }, +}); diff --git a/vitest/umd-plugin.ts b/vitest/umd-plugin.ts new file mode 100644 index 000000000..9c285b835 --- /dev/null +++ b/vitest/umd-plugin.ts @@ -0,0 +1,65 @@ +/** + * Copyright 2025, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { Plugin } from 'vitest/config'; +import type { IncomingMessage, ServerResponse } from 'http'; + +export function umdPlugin(): Plugin { + return { + name: 'umd-plugin', + enforce: 'pre' as const, + configureServer(server) { + // Add middleware to inject umd script into HTML responses + server.middlewares.use((_req: IncomingMessage, res: ServerResponse, next: () => void) => { + const originalWrite = res.write; + const originalEnd = res.end; + const chunks: any[] = []; + + // @ts-ignore + res.write = function(chunk: any, ..._args: any[]) { + chunks.push(Buffer.from(chunk)); + return true; + }; + + // @ts-ignore + res.end = function(chunk: any, ...args: any[]) { + if (chunk) { + chunks.push(Buffer.from(chunk)); + } + + const buffer = Buffer.concat(chunks); + let body = buffer.toString('utf8'); + + // Inject console-capture script into HTML responses + if (res.getHeader('content-type')?.toString().includes('text/html')) { + const scriptTag = ''; + if (body.includes('')) { + body = body.replace('', `${scriptTag}\n`); + res.setHeader('content-length', Buffer.byteLength(body)); + } + } + + // Restore original methods and send response + res.write = originalWrite; + res.end = originalEnd; + res.end(body, ...args); + }; + + next(); + }); + + }, + }; +}