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();
+ });
+
+ },
+ };
+}