From 9bf433b373c15f2efd13ccb9451f3e0676861133 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Tue, 16 Apr 2024 09:25:44 -0700 Subject: [PATCH] chore: add Split URL script tag for Web Experimentation (#103) --- .eslintignore | 1 + .gitignore | 5 +- .prettierignore | 1 + packages/experiment-tag/README.md | 16 + .../experiment-tag/example/build_example.js | 79 +++ packages/experiment-tag/example/control.html | 47 ++ .../example/css/recipe-styles.css | 47 ++ packages/experiment-tag/example/css/style.css | 247 +++++++ .../experiment-tag/example/css/styles_1.css | 46 ++ .../experiment-tag/example/treatment.html | 50 ++ packages/experiment-tag/jest.config.js | 18 + packages/experiment-tag/package.json | 36 + packages/experiment-tag/rollup.config.js | 74 +++ packages/experiment-tag/src/experiment.ts | 117 ++++ packages/experiment-tag/src/script.ts | 5 + packages/experiment-tag/src/util.ts | 96 +++ .../experiment-tag/test/experiment.test.ts | 621 ++++++++++++++++++ packages/experiment-tag/test/util.test.ts | 123 ++++ packages/experiment-tag/tsconfig.json | 13 + packages/experiment-tag/tsconfig.test.json | 13 + yarn.lock | 103 ++- 21 files changed, 1751 insertions(+), 7 deletions(-) create mode 100644 packages/experiment-tag/README.md create mode 100644 packages/experiment-tag/example/build_example.js create mode 100644 packages/experiment-tag/example/control.html create mode 100644 packages/experiment-tag/example/css/recipe-styles.css create mode 100644 packages/experiment-tag/example/css/style.css create mode 100644 packages/experiment-tag/example/css/styles_1.css create mode 100644 packages/experiment-tag/example/treatment.html create mode 100644 packages/experiment-tag/jest.config.js create mode 100644 packages/experiment-tag/package.json create mode 100644 packages/experiment-tag/rollup.config.js create mode 100644 packages/experiment-tag/src/experiment.ts create mode 100644 packages/experiment-tag/src/script.ts create mode 100644 packages/experiment-tag/src/util.ts create mode 100644 packages/experiment-tag/test/experiment.test.ts create mode 100644 packages/experiment-tag/test/util.test.ts create mode 100644 packages/experiment-tag/tsconfig.json create mode 100644 packages/experiment-tag/tsconfig.test.json diff --git a/.eslintignore b/.eslintignore index 849ddff3..7be09517 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,2 @@ dist/ +example/ diff --git a/.gitignore b/.gitignore index 9f427d59..2a10b15b 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,7 @@ dist/ .idea # For CI to ignore .npmrc file when publishing -.npmrc \ No newline at end of file +.npmrc + +# Example Experiment tag script example +packages/experiment-tag/example/ diff --git a/.prettierignore b/.prettierignore index 94f48112..98c3c799 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,3 @@ dist/ +example/ *.md diff --git a/packages/experiment-tag/README.md b/packages/experiment-tag/README.md new file mode 100644 index 00000000..b325675c --- /dev/null +++ b/packages/experiment-tag/README.md @@ -0,0 +1,16 @@ +# Experiment Web Experimentation Javascript Snippet + +## Overview + +This is the Web Experimentation SDK for Amplitude Experiment. Currently, only split-URL experiments are supported. + +## Generate example + +To generate an example snippet with custom flag configurations: +1. Set `apiKey` (your Amplitude Project API key) and `initialFlags` in `example/build_example.js` +2. Run `yarn build` to build minified UMD `experiment-tag.umd.js` and example `script.js` + +To test the snippet's behavior on web pages relevant to your experiment, the pages should: +1. Include `script.js` +2. Have the Amplitude Analytics SDK loaded (see [examples](https://github.com/amplitude/Amplitude-TypeScript/tree/main/packages/analytics-browser)) + diff --git a/packages/experiment-tag/example/build_example.js b/packages/experiment-tag/example/build_example.js new file mode 100644 index 00000000..57136634 --- /dev/null +++ b/packages/experiment-tag/example/build_example.js @@ -0,0 +1,79 @@ +const fs = require('fs'); +const apiKey = 'a6dd847b9d2f03c816d4f3f8458cdc1d'; +const initialFlags = `[ + { + "key": "test", + "metadata": { + "deployed": true, + "evaluationMode": "local", + "experimentKey": "exp-1", + "flagType": "experiment", + "flagVersion": 20, + "urlMatch": [ + "http://localhost:63342/experiment-js-client/packages/experiment-tag/example/control.html/" + ] + }, + "segments": [ + { + "metadata": { + "segmentName": "All Other Users" + }, + "variant": "treatment" + } + ], + "variants": { + "control": { + "key": "control", + "payload": [ + { + "action": "redirect", + "data": { + "url": "http://localhost:63342/experiment-js-client/packages/experiment-tag/example/control.html" + } + } + ], + "value": "control" + }, + "off": { + "key": "off", + "metadata": { + "default": true + } + }, + "treatment": { + "key": "treatment", + "payload": [ + { + "action": "redirect", + "data": { + "url": "http://localhost:63342/experiment-js-client/packages/experiment-tag/example/treatment.html" + } + } + ], + "value": "treatment" + } + } + } +] +`.replace(/\n\s*/g, ''); + +fs.readFile('dist/experiment-tag.umd.js', 'utf8', (err, data) => { + if (err) { + console.error('Error reading file:', err); + return; + } + + // Perform string replacements + const modifiedData = data + .replace(/{{DEPLOYMENT_KEY}}/g, apiKey) + .replace(/"{{INITIAL_FLAGS}}"/g, `'${initialFlags}'`); + + // Write the modified content to a new file + fs.writeFile('example/script.js', modifiedData, 'utf8', (err) => { + if (err) { + console.error('Error writing file:', err); + return; + } + console.log('File successfully written!'); + }); +}); diff --git a/packages/experiment-tag/example/control.html b/packages/experiment-tag/example/control.html new file mode 100644 index 00000000..a0324699 --- /dev/null +++ b/packages/experiment-tag/example/control.html @@ -0,0 +1,47 @@ + + + + + + + Original page + + + + + +
+
+

Original page

+
+
+

Ingredients

+ +
+
+

Instructions

+
    +
  1. Preheat oven to 350 degrees F (175 degrees C).
  2. +
  3. Cream together the butter, white sugar, + and brown sugar until smooth. +
  4. +
  5. Beat in the eggs one at a time, + then stir in the vanilla. +
  6. + +
+
+
+ + + diff --git a/packages/experiment-tag/example/css/recipe-styles.css b/packages/experiment-tag/example/css/recipe-styles.css new file mode 100644 index 00000000..148b664a --- /dev/null +++ b/packages/experiment-tag/example/css/recipe-styles.css @@ -0,0 +1,47 @@ +/* Basic Reset */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + line-height: 1.6; + padding: 20px; + background-color: #fff9e8; +} + +article { + max-width: 800px; + margin: 20px auto; + padding: 20px; + background: #fff; + border: 1px solid #ddd; + border-radius: 8px; +} + +h1, h2 { + color: #5a2c02; +} + +.recipe-img { + max-width: 100%; + height: auto; + border-radius: 8px; +} + +.ingredients ul, .instructions ol { + margin: 20px 0; +} + +.ingredients li, .instructions li { + margin-bottom: 10px; +} + +footer { + text-align: center; + margin-top: 20px; + padding-top: 20px; + border-top: 1px solid #ddd; +} diff --git a/packages/experiment-tag/example/css/style.css b/packages/experiment-tag/example/css/style.css new file mode 100644 index 00000000..654e8767 --- /dev/null +++ b/packages/experiment-tag/example/css/style.css @@ -0,0 +1,247 @@ +/*! HTML5 Boilerplate v9.0.0-RC1 | MIT License | https://html5boilerplate.com/ */ + +/* main.css 3.0.0 | MIT License | https://github.com/h5bp/main.css#readme */ +/* + * What follows is the result of much research on cross-browser styling. + * Credit left inline and big thanks to Nicolas Gallagher, Jonathan Neal, + * Kroc Camen, and the H5BP dev community and team. + */ + +/* ========================================================================== + Base styles: opinionated defaults + ========================================================================== */ + +html { + color: #222; + font-size: 1em; + line-height: 1.4; +} + +/* + * Remove text-shadow in selection highlight: + * https://twitter.com/miketaylr/status/12228805301 + * + * Customize the background color to match your design. + */ + +::-moz-selection { + background: #b3d4fc; + text-shadow: none; +} + +::selection { + background: #b3d4fc; + text-shadow: none; +} + +/* + * A better looking default horizontal rule + */ + +hr { + display: block; + height: 1px; + border: 0; + border-top: 1px solid #ccc; + margin: 1em 0; + padding: 0; +} + +/* + * Remove the gap between audio, canvas, iframes, + * images, videos and the bottom of their containers: + * https://github.com/h5bp/html5-boilerplate/issues/440 + */ + +audio, +canvas, +iframe, +img, +svg, +video { + vertical-align: middle; +} + +/* + * Remove default fieldset styles. + */ + +fieldset { + border: 0; + margin: 0; + padding: 0; +} + +/* + * Allow only vertical resizing of textareas. + */ + +textarea { + resize: vertical; +} + +/* ========================================================================== + Author's custom styles + ========================================================================== */ + +/* ========================================================================== + Helper classes + ========================================================================== */ + +/* + * Hide visually and from screen readers + */ + +.hidden, +[hidden] { + display: none !important; +} + +/* + * Hide only visually, but have it available for screen readers: + * https://snook.ca/archives/html_and_css/hiding-content-for-accessibility + * + * 1. For long content, line feeds are not interpreted as spaces and small width + * causes content to wrap 1 word per line: + * https://medium.com/@jessebeach/beware-smushed-off-screen-accessible-text-5952a4c2cbfe + */ + +.visually-hidden { + border: 0; + clip: rect(0, 0, 0, 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; + /* 1 */ +} + +/* + * Extends the .visually-hidden class to allow the element + * to be focusable when navigated to via the keyboard: + * https://www.drupal.org/node/897638 + */ + +.visually-hidden.focusable:active, +.visually-hidden.focusable:focus { + clip: auto; + height: auto; + margin: 0; + overflow: visible; + position: static; + white-space: inherit; + width: auto; +} + +/* + * Hide visually and from screen readers, but maintain layout + */ + +.invisible { + visibility: hidden; +} + +/* + * Clearfix: contain floats + * + * The use of `table` rather than `block` is only necessary if using + * `::before` to contain the top-margins of child elements. + */ + +.clearfix::before, +.clearfix::after { + content: ""; + display: table; +} + +.clearfix::after { + clear: both; +} + +/* ========================================================================== + EXAMPLE Media Queries for Responsive Design. + These examples override the primary ('mobile first') styles. + Modify as content requires. + ========================================================================== */ + +@media only screen and (min-width: 35em) { + /* Style adjustments for viewports that meet the condition */ +} + +@media print, + (-webkit-min-device-pixel-ratio: 1.25), + (min-resolution: 1.25dppx), + (min-resolution: 120dpi) { + /* Style adjustments for high resolution devices */ +} + +/* ========================================================================== + Print styles. + Inlined to avoid the additional HTTP request: + https://www.phpied.com/delay-loading-your-print-css/ + ========================================================================== */ + +@media print { + *, + *::before, + *::after { + background: #fff !important; + color: #000 !important; + /* Black prints faster */ + box-shadow: none !important; + text-shadow: none !important; + } + + a, + a:visited { + text-decoration: underline; + } + + a[href]::after { + content: " (" attr(href) ")"; + } + + abbr[title]::after { + content: " (" attr(title) ")"; + } + + /* + * Don't show links that are fragment identifiers, + * or use the `javascript:` pseudo protocol + */ + a[href^="#"]::after, + a[href^="javascript:"]::after { + content: ""; + } + + pre { + white-space: pre-wrap !important; + } + + pre, + blockquote { + border: 1px solid #999; + page-break-inside: avoid; + } + + tr, + img { + page-break-inside: avoid; + } + + p, + h2, + h3 { + orphans: 3; + widows: 3; + } + + h2, + h3 { + page-break-after: avoid; + } +} + diff --git a/packages/experiment-tag/example/css/styles_1.css b/packages/experiment-tag/example/css/styles_1.css new file mode 100644 index 00000000..4077e765 --- /dev/null +++ b/packages/experiment-tag/example/css/styles_1.css @@ -0,0 +1,46 @@ +/* Basic Reset */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Arial', sans-serif; + line-height: 1.6; + padding: 20px; + background-color: #f4f4f4; +} + +.header { + text-align: center; + margin-bottom: 20px; +} + +.profile-img { + width: 150px; + height: auto; + border-radius: 50%; +} + +h1 { + margin-top: 10px; +} + +.bio, .projects { + margin-bottom: 20px; +} + +a { + color: #333; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +footer { + text-align: center; + margin-top: 20px; +} diff --git a/packages/experiment-tag/example/treatment.html b/packages/experiment-tag/example/treatment.html new file mode 100644 index 00000000..6213c400 --- /dev/null +++ b/packages/experiment-tag/example/treatment.html @@ -0,0 +1,50 @@ + + + + + + + Redirected page + + + + + +
+

Redirected page

+

Web Developer, Programmer, and Tech Student

+

Follow me on social media:

+

+ Twitter | + LinkedIn +

+
+
+
+

About Me

+

Hello! I'm Your Name, a web developer with a passion + for front-end design and user experience. + I've been building websites for over 5 years + and love what I do.

+ +
+
+

Projects

+ +
+
+ + + diff --git a/packages/experiment-tag/jest.config.js b/packages/experiment-tag/jest.config.js new file mode 100644 index 00000000..cadd7714 --- /dev/null +++ b/packages/experiment-tag/jest.config.js @@ -0,0 +1,18 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const { pathsToModuleNameMapper } = require('ts-jest'); + +const package = require('./package'); +const { compilerOptions } = require('./tsconfig.test.json'); + +module.exports = { + preset: 'ts-jest', + testEnvironment: 'jsdom', + displayName: package.name, + rootDir: '.', + moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { + prefix: '/', + }), + transform: { + '^.+\\.tsx?$': ['ts-jest', { tsconfig: './tsconfig.test.json' }], + }, +}; diff --git a/packages/experiment-tag/package.json b/packages/experiment-tag/package.json new file mode 100644 index 00000000..68e641e6 --- /dev/null +++ b/packages/experiment-tag/package.json @@ -0,0 +1,36 @@ +{ + "name": "@amplitude/experiment-tag", + "version": "0.1.0", + "description": "Amplitude Experiment Javascript Snippet", + "author": "Amplitude", + "homepage": "https://github.com/amplitude/experiment-js-client", + "license": "MIT", + "main": "dist/experiment-tag.umd.js", + "types": "dist/types/src/index.d.ts", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/amplitude/experiment-js-client.git", + "directory": "packages/experiment-tag" + }, + "scripts": { + "build": "rm -rf dist && rollup -c && node example/build_example.js", + "clean": "rimraf node_modules dist", + "lint": "eslint . --ignore-path ../../.eslintignore && prettier -c . --ignore-path ../../.prettierignore", + "test": "jest" + }, + "bugs": { + "url": "https://github.com/amplitude/experiment-js-client/issues" + }, + "dependencies": { + "@amplitude/experiment-js-client": "^1.10.0" + }, + "devDependencies": { + "@rollup/plugin-terser": "^0.4.4" + }, + "files": [ + "dist" + ] +} diff --git a/packages/experiment-tag/rollup.config.js b/packages/experiment-tag/rollup.config.js new file mode 100644 index 00000000..cac63a7f --- /dev/null +++ b/packages/experiment-tag/rollup.config.js @@ -0,0 +1,74 @@ +import { resolve as pathResolve } from 'path'; + +import tsConfig from '@amplitude/experiment-js-client/tsconfig.json'; +import babel from '@rollup/plugin-babel'; +import commonjs from '@rollup/plugin-commonjs'; +import json from '@rollup/plugin-json'; +import resolve from '@rollup/plugin-node-resolve'; +import replace from '@rollup/plugin-replace'; +import terser from '@rollup/plugin-terser'; +import typescript from '@rollup/plugin-typescript'; +import analyze from 'rollup-plugin-analyzer'; + +const getCommonBrowserConfig = (target) => ({ + input: 'src/script.ts', + treeshake: { + moduleSideEffects: 'no-external', + }, + plugins: [ + replace({ + preventAssignment: true, + BUILD_BROWSER: true, + }), + resolve(), + json(), + commonjs(), + typescript({ + ...(target === 'es2015' + ? { target: 'es2015', downlevelIteration: true } + : { downlevelIteration: true }), + declaration: true, + declarationDir: 'dist/types', + include: tsConfig.include, + rootDir: '.', + }), + babel({ + configFile: + target === 'es2015' + ? pathResolve(__dirname, '../..', 'babel.es2015.config.js') + : undefined, + babelHelpers: 'bundled', + exclude: ['node_modules/**'], + }), + analyze({ + summaryOnly: true, + }), + ], +}); + +const getOutputConfig = (outputOptions) => ({ + output: { + dir: 'dist', + name: 'Experiment-Tag', + ...outputOptions, + }, +}); + +const configs = [ + // legacy build for field "main" - ie8, umd, es5 syntax + { + ...getCommonBrowserConfig('es5'), + ...getOutputConfig({ + entryFileNames: 'experiment-tag.umd.js', + exports: 'named', + format: 'umd', + }), + plugins: [ + ...getCommonBrowserConfig('es5').plugins, + terser(), // Apply terser plugin for minification + ], + external: [], + }, +]; + +export default configs; diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts new file mode 100644 index 00000000..4432aef6 --- /dev/null +++ b/packages/experiment-tag/src/experiment.ts @@ -0,0 +1,117 @@ +import { EvaluationFlag } from '@amplitude/experiment-core'; +import { Experiment, ExperimentUser } from '@amplitude/experiment-js-client'; + +import { + getGlobalScope, + getUrlParams, + isLocalStorageAvailable, + matchesUrl, + removeQueryParams, + urlWithoutParamsAndAnchor, + UUID, +} from './util'; + +export const initializeExperiment = (apiKey: string, initialFlags: string) => { + const globalScope = getGlobalScope(); + const experimentStorageName = `EXP_${apiKey.slice(0, 10)}`; + + if (isLocalStorageAvailable() && globalScope) { + let user: ExperimentUser = {}; + try { + user = JSON.parse( + globalScope?.localStorage.getItem(experimentStorageName) || '{}', + ); + } catch (error) { + // catch error + } + + // create new user if it does not exist, or it does not have device_id + if (Object.keys(user).length === 0 || !user.device_id) { + user = {}; + user.device_id = UUID(); + globalScope?.localStorage.setItem( + experimentStorageName, + JSON.stringify(user), + ); + } + + const urlParams = getUrlParams(); + // force variant if in preview mode + if (urlParams['PREVIEW']) { + const parsedFlags = JSON.parse(initialFlags); + parsedFlags.forEach((flag: EvaluationFlag) => { + if (flag.key in urlParams && urlParams[flag.key] in flag.variants) { + flag.segments = [ + { + metadata: { + segmentName: 'preview', + }, + variant: urlParams[flag.key], + }, + ]; + } + }); + initialFlags = JSON.stringify(parsedFlags); + } + globalScope.experiment = Experiment.initializeWithAmplitudeAnalytics( + apiKey, + { + debug: true, + fetchOnStart: false, + initialFlags: initialFlags, + }, + ); + + globalScope.experiment.setUser(user); + + const variants = globalScope.experiment.all(); + + for (const key in variants) { + const variant = variants[key]; + + if (!Array.isArray(variant?.payload)) { + continue; + } + for (const action of variant.payload) { + if (action.action === 'redirect') { + const urlExactMatch = variant?.metadata?.['urlMatch']; + const currentUrl = urlWithoutParamsAndAnchor( + globalScope.location.href, + ); + const referrerUrl = urlWithoutParamsAndAnchor( + globalScope.document.referrer, + ); + const redirectUrl = action?.data?.url; + // if in preview mode, strip query params + if (variant.metadata?.segmentName === 'preview') { + globalScope.history.replaceState( + {}, + '', + removeQueryParams(globalScope.location.href, ['PREVIEW', key]), + ); + } + if (matchesUrl(urlExactMatch, currentUrl)) { + if ( + !matchesUrl([redirectUrl], currentUrl) && + currentUrl !== referrerUrl + ) { + // perform redirection + globalScope.location.replace(redirectUrl); + } else { + // if redirection is not required + globalScope.experiment.exposure(key); + } + } else if ( + // if at the redirected page + matchesUrl(urlExactMatch, referrerUrl) && + (matchesUrl([redirectUrl], currentUrl) || + // case when redirected url has query and anchor + matchesUrl([redirectUrl], globalScope.location.href)) + ) { + globalScope.experiment.exposure(key); + } + } + } + } + } +}; diff --git a/packages/experiment-tag/src/script.ts b/packages/experiment-tag/src/script.ts new file mode 100644 index 00000000..0e6c3f46 --- /dev/null +++ b/packages/experiment-tag/src/script.ts @@ -0,0 +1,5 @@ +import { initializeExperiment } from './experiment'; + +const API_KEY = '{{DEPLOYMENT_KEY}}'; +const initialFlags = '{{INITIAL_FLAGS}}'; +initializeExperiment(API_KEY, initialFlags); diff --git a/packages/experiment-tag/src/util.ts b/packages/experiment-tag/src/util.ts new file mode 100644 index 00000000..1b5e2e74 --- /dev/null +++ b/packages/experiment-tag/src/util.ts @@ -0,0 +1,96 @@ +export const getGlobalScope = (): typeof globalThis | undefined => { + if (typeof globalThis !== 'undefined') { + return globalThis; + } + if (typeof window !== 'undefined') { + return window; + } + if (typeof self !== 'undefined') { + return self; + } + if (typeof global !== 'undefined') { + return global; + } + return undefined; +}; + +// Get URL parameters +export const getUrlParams = (): Record => { + const globalScope = getGlobalScope(); + const searchParams = new URLSearchParams(globalScope?.location.search); + const params: Record = {}; + for (const [key, value] of searchParams) { + params[key] = value; + } + return params; +}; + +export const urlWithoutParamsAndAnchor = (url: string): string => { + if (!url) { + return ''; + } + const urlObj = new URL(url); + urlObj.search = ''; + urlObj.hash = ''; + return urlObj.toString(); +}; + +export const removeQueryParams = ( + url: string, + paramsToRemove: string[], +): string => { + const urlObj = new URL(url); + for (const param of paramsToRemove) { + urlObj.searchParams.delete(param); + } + return urlObj.toString(); +}; + +export const UUID = function (a?: never): string { + return a // if the placeholder was passed, return + ? // a random number from 0 to 15 + ( + a ^ // unless b is 8, + ((Math.random() * // in which case + 16) >> // a random number from + (a / 4)) + ) // 8 to 11 + .toString(16) // in hexadecimal + : // or otherwise a concatenated string: + ( + String(1e7) + // 10000000 + + String(-1e3) + // -1000 + + String(-4e3) + // -4000 + + String(-8e3) + // -80000000 + + String(-1e11) + ) // -100000000000, + .replace( + // replacing + /[018]/g, // zeroes, ones, and eights with + UUID(), // random hex digits + ); +}; + +export const matchesUrl = (urlArray: string[], urlString: string): boolean => { + const cleanUrlString = urlString.replace(/\/$/, ''); + + return urlArray.some((url) => { + const cleanUrl = url.replace(/\/$/, ''); + return cleanUrl === cleanUrlString; + }); +}; + +export const isLocalStorageAvailable = (): boolean => { + const globalScope = getGlobalScope(); + if (globalScope) { + try { + const testKey = 'EXP_test'; + globalScope.localStorage.setItem(testKey, testKey); + globalScope.localStorage.removeItem(testKey); + return true; + } catch (e) { + return false; + } + } + return false; +}; diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts new file mode 100644 index 00000000..b7a061af --- /dev/null +++ b/packages/experiment-tag/test/experiment.test.ts @@ -0,0 +1,621 @@ +import { ExperimentClient } from '@amplitude/experiment-js-client'; +import { initializeExperiment } from 'src/experiment'; +import * as util from 'src/util'; + +describe('initializeExperiment', () => { + const mockGetGlobalScope = jest.spyOn(util, 'getGlobalScope'); + jest.spyOn(ExperimentClient.prototype, 'setUser'); + jest.spyOn(ExperimentClient.prototype, 'all'); + const mockExposure = jest.spyOn(ExperimentClient.prototype, 'exposure'); + jest.spyOn(util, 'UUID').mockReturnValue('mock'); + let mockGlobal; + + beforeEach(() => { + jest.spyOn(util, 'isLocalStorageAvailable').mockReturnValue(true); + jest.clearAllMocks(); + mockGlobal = { + localStorage: { + getItem: jest.fn().mockReturnValue(undefined), + setItem: jest.fn(), + }, + location: { + href: 'http://test.com', + replace: jest.fn(), + search: '', + }, + document: { referrer: '' }, + history: { replaceState: jest.fn() }, + }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + mockGetGlobalScope.mockReturnValue(mockGlobal); + }); + + test('should initialize experiment with empty user', () => { + initializeExperiment( + 'apiKey_1', + JSON.stringify([ + { + key: 'test', + metadata: { + deployed: true, + evaluationMode: 'local', + experimentKey: 'exp-1', + flagType: 'experiment', + flagVersion: 20, + urlMatch: ['http://test.com'], + }, + segments: [ + { + metadata: { + segmentName: 'All Other Users', + }, + variant: 'treatment', + }, + ], + variants: { + control: { + key: 'control', + payload: [ + { + action: 'redirect', + data: { + url: 'http://test.com', + }, + }, + ], + value: 'control', + }, + off: { + key: 'off', + metadata: { + default: true, + }, + }, + treatment: { + key: 'treatment', + payload: [ + { + action: 'redirect', + data: { + url: 'http://test.com/2', + }, + }, + ], + value: 'treatment', + }, + }, + }, + ]), + ); + expect(ExperimentClient.prototype.setUser).toHaveBeenCalledWith({ + device_id: 'mock', + }); + expect(mockGlobal.localStorage.setItem).toHaveBeenCalledWith( + 'EXP_apiKey_1', + JSON.stringify({ device_id: 'mock' }), + ); + }); + + test('experiment should not run without localStorage', () => { + jest.spyOn(util, 'isLocalStorageAvailable').mockReturnValue(false); + initializeExperiment('no_local', ''); + expect(mockGlobal.localStorage.getItem).toHaveBeenCalledTimes(0); + }); + + test('should redirect and not call exposure', () => { + initializeExperiment( + 'apiKey_2', + JSON.stringify([ + { + key: 'test', + metadata: { + deployed: true, + evaluationMode: 'local', + experimentKey: 'exp-1', + flagType: 'experiment', + flagVersion: 20, + urlMatch: ['http://test.com'], + }, + segments: [ + { + metadata: { + segmentName: 'All Other Users', + }, + variant: 'treatment', + }, + ], + variants: { + control: { + key: 'control', + payload: [ + { + action: 'redirect', + data: { + url: 'http://test.com', + }, + }, + ], + value: 'control', + }, + off: { + key: 'off', + metadata: { + default: true, + }, + }, + treatment: { + key: 'treatment', + payload: [ + { + action: 'redirect', + data: { + url: 'http://test.com/2', + }, + }, + ], + value: 'treatment', + }, + }, + }, + ]), + ); + + expect(mockGlobal.location.replace).toHaveBeenCalledWith( + 'http://test.com/2', + ); + expect(mockExposure).toHaveBeenCalledTimes(0); + }); + + test('should not redirect but call exposure', () => { + initializeExperiment( + 'apiKey_3', + JSON.stringify([ + { + key: 'test', + metadata: { + deployed: true, + evaluationMode: 'local', + experimentKey: 'exp-1', + flagType: 'experiment', + flagVersion: 20, + urlMatch: ['http://test.com'], + }, + segments: [ + { + metadata: { + segmentName: 'All Other Users', + }, + variant: 'control', + }, + ], + variants: { + control: { + key: 'control', + payload: [ + { + action: 'redirect', + data: { + url: 'http://test.com', + }, + }, + ], + value: 'control', + }, + off: { + key: 'off', + metadata: { + default: true, + }, + }, + treatment: { + key: 'treatment', + payload: [ + { + action: 'redirect', + data: { + url: 'http://test.com/2', + }, + }, + ], + value: 'treatment', + }, + }, + }, + ]), + ); + + expect(mockGlobal.location.replace).toBeCalledTimes(0); + expect(mockExposure).toHaveBeenCalledWith('test'); + expect(mockGlobal.history.replaceState).toBeCalledTimes(0); + }); + + test('should not redirect or exposure', () => { + initializeExperiment( + 'apiKey_4', + JSON.stringify([ + { + key: 'test', + metadata: { + deployed: true, + evaluationMode: 'local', + experimentKey: 'exp-1', + flagType: 'experiment', + flagVersion: 20, + urlMatch: ['http://should.not.match'], + }, + segments: [ + { + metadata: { + segmentName: 'All Other Users', + }, + variant: 'control', + }, + ], + variants: { + control: { + key: 'control', + payload: [ + { + action: 'redirect', + data: { + url: 'http://test.com', + }, + }, + ], + value: 'control', + }, + off: { + key: 'off', + metadata: { + default: true, + }, + }, + treatment: { + key: 'treatment', + payload: [ + { + action: 'redirect', + data: { + url: 'http://test.com/2', + }, + }, + ], + value: 'treatment', + }, + }, + }, + ]), + ); + + expect(mockGlobal.location.replace).toBeCalledTimes(0); + expect(mockExposure).toHaveBeenCalledTimes(0); + }); + + test('exposure fired when on redirected page', () => { + const mockGlobal = { + localStorage: { + getItem: jest.fn().mockReturnValue(undefined), + setItem: jest.fn(), + }, + location: { + href: 'http://test.com/2', + replace: jest.fn(), + search: '', + }, + document: { referrer: 'http://test.com' }, + }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + mockGetGlobalScope.mockReturnValue(mockGlobal); + + initializeExperiment( + 'apiKey_5', + JSON.stringify([ + { + key: 'test', + metadata: { + deployed: true, + evaluationMode: 'local', + experimentKey: 'exp-1', + flagType: 'experiment', + flagVersion: 20, + urlMatch: ['http://test.com'], + }, + segments: [ + { + metadata: { + segmentName: 'All Other Users', + }, + variant: 'treatment', + }, + ], + variants: { + control: { + key: 'control', + payload: [ + { + action: 'redirect', + data: { + url: 'http://test.com', + }, + }, + ], + value: 'control', + }, + off: { + key: 'off', + metadata: { + default: true, + }, + }, + treatment: { + key: 'treatment', + payload: [ + { + action: 'redirect', + data: { + url: 'http://test.com/2', + }, + }, + ], + value: 'treatment', + }, + }, + }, + ]), + ); + + expect(mockGlobal.location.replace).toHaveBeenCalledTimes(0); + expect(mockExposure).toHaveBeenCalledWith('test'); + }); + + test('preview - force control variant', () => { + const mockGlobal = { + localStorage: { + getItem: jest.fn().mockReturnValue(undefined), + setItem: jest.fn(), + }, + location: { + href: 'http://test.com', + replace: jest.fn(), + search: '?test=control&PREVIEW=true', + }, + document: { referrer: '' }, + history: { replaceState: jest.fn() }, + }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + mockGetGlobalScope.mockReturnValue(mockGlobal); + + initializeExperiment( + 'prev_control', + JSON.stringify([ + { + key: 'test', + metadata: { + deployed: true, + evaluationMode: 'local', + experimentKey: 'exp-1', + flagType: 'experiment', + flagVersion: 20, + urlMatch: ['http://test.com'], + }, + segments: [ + { + metadata: { + segmentName: 'All Other Users', + }, + variant: 'treatment', + }, + ], + variants: { + control: { + key: 'treatment', + payload: [ + { + action: 'redirect', + data: { + url: 'http://test.com', + }, + }, + ], + value: 'control', + }, + off: { + key: 'off', + metadata: { + default: true, + }, + }, + treatment: { + key: 'treatment', + payload: [ + { + action: 'redirect', + data: { + url: 'http://test.com/2', + }, + }, + ], + value: 'treatment', + }, + }, + }, + ]), + ); + + expect(mockGlobal.location.replace).toHaveBeenCalledTimes(0); + expect(mockGlobal.history.replaceState).toHaveBeenCalledWith( + {}, + '', + 'http://test.com/', + ); + expect(mockExposure).toHaveBeenCalledWith('test'); + }); + + test('preview - force treatment variant when on control page', () => { + const mockGlobal = { + localStorage: { + getItem: jest.fn().mockReturnValue(undefined), + setItem: jest.fn(), + }, + location: { + href: 'http://test.com/', + replace: jest.fn(), + search: '?test=treatment&PREVIEW=true', + }, + document: { referrer: '' }, + history: { replaceState: jest.fn() }, + }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + mockGetGlobalScope.mockReturnValue(mockGlobal); + + initializeExperiment( + 'prev_treatment', + JSON.stringify([ + { + key: 'test', + metadata: { + deployed: true, + evaluationMode: 'local', + experimentKey: 'exp-1', + flagType: 'experiment', + flagVersion: 20, + urlMatch: ['http://test.com'], + }, + segments: [ + { + metadata: { + segmentName: 'All Other Users', + }, + variant: 'control', + }, + ], + variants: { + control: { + key: 'treatment', + payload: [ + { + action: 'redirect', + data: { + url: 'http://test.com', + }, + }, + ], + value: 'control', + }, + off: { + key: 'off', + metadata: { + default: true, + }, + }, + treatment: { + key: 'treatment', + payload: [ + { + action: 'redirect', + data: { + url: 'http://test.com/2', + }, + }, + ], + value: 'treatment', + }, + }, + }, + ]), + ); + + expect(mockGlobal.location.replace).toHaveBeenCalledWith( + 'http://test.com/2', + ); + expect(mockExposure).toHaveBeenCalledTimes(0); + }); + + test('preview - force treatment variant when on treatment page', () => { + const mockGlobal = { + localStorage: { + getItem: jest.fn().mockReturnValue(undefined), + setItem: jest.fn(), + }, + location: { + href: 'http://test.com/2', + replace: jest.fn(), + search: '?test=treatment&PREVIEW=true', + }, + document: { referrer: '' }, + history: { replaceState: jest.fn() }, + }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + mockGetGlobalScope.mockReturnValue(mockGlobal); + + initializeExperiment( + 'prev_treatment', + JSON.stringify([ + { + key: 'test', + metadata: { + deployed: true, + evaluationMode: 'local', + experimentKey: 'exp-1', + flagType: 'experiment', + flagVersion: 20, + urlMatch: ['http://test.com'], + }, + segments: [ + { + metadata: { + segmentName: 'All Other Users', + }, + variant: 'control', + }, + ], + variants: { + control: { + key: 'treatment', + payload: [ + { + action: 'redirect', + data: { + url: 'http://test.com', + }, + }, + ], + value: 'control', + }, + off: { + key: 'off', + metadata: { + default: true, + }, + }, + treatment: { + key: 'treatment', + payload: [ + { + action: 'redirect', + data: { + url: 'http://test.com/2', + }, + }, + ], + value: 'treatment', + }, + }, + }, + ]), + ); + + expect(mockGlobal.location.replace).toHaveBeenCalledTimes(0); + expect(mockExposure).toHaveBeenCalledTimes(0); + expect(mockGlobal.history.replaceState).toHaveBeenCalledWith( + {}, + '', + 'http://test.com/2', + ); + }); +}); diff --git a/packages/experiment-tag/test/util.test.ts b/packages/experiment-tag/test/util.test.ts new file mode 100644 index 00000000..8179e102 --- /dev/null +++ b/packages/experiment-tag/test/util.test.ts @@ -0,0 +1,123 @@ +import { getUrlParams, matchesUrl, urlWithoutParamsAndAnchor } from 'src/util'; +import * as util from 'src/util'; + +// Mock the getGlobalScope function +const spyGetGlobalScope = jest.spyOn(util, 'getGlobalScope'); + +describe('matchesUrl', () => { + // Existing test cases + it('should return true if the URL matches in the array without trailing slash', () => { + const urlArray: string[] = ['http://example.com', 'http://example.org/']; + const urlString = 'http://example.org'; + + expect(matchesUrl(urlArray, urlString)).toBe(true); + }); + + it('should return false if the URL does not match in the array', () => { + const urlArray: string[] = ['http://example.com', 'http://example.org/']; + const urlString = 'http://example.net'; + + expect(matchesUrl(urlArray, urlString)).toBe(false); + }); + + // Additional test cases + it('should handle URLs with different protocols', () => { + const urlArray: string[] = ['https://example.com', 'http://example.org/']; + const urlString = 'https://example.com'; + + expect(matchesUrl(urlArray, urlString)).toBe(true); + }); + + it('should handle URLs with paths', () => { + const urlArray: string[] = [ + 'http://example.com/page', + 'http://example.org/', + ]; + const urlString = 'http://example.com/page'; + + expect(matchesUrl(urlArray, urlString)).toBe(true); + }); + + it('should handle URLs with query parameters', () => { + const urlArray: string[] = [ + 'http://example.com?param=value', + 'http://example.org/', + ]; + const urlString = 'http://example.com?param=value'; + + expect(matchesUrl(urlArray, urlString)).toBe(true); + }); + + it('should handle URLs with ports', () => { + const urlArray: string[] = [ + 'http://example.com:8080', + 'http://example.org/', + ]; + const urlString = 'http://example.com:8080'; + + expect(matchesUrl(urlArray, urlString)).toBe(true); + }); +}); + +describe('urlWithoutParamsAndAnchor', () => { + // Existing test cases + it('should return the URL without parameters and anchor', () => { + const url = 'http://example.com/page?param1=value1¶m2=value2#section'; + + expect(urlWithoutParamsAndAnchor(url)).toBe('http://example.com/page'); + }); + + it('should return the same URL if it does not contain parameters and anchor', () => { + const url = 'http://example.com/page'; + + expect(urlWithoutParamsAndAnchor(url)).toBe('http://example.com/page'); + }); + + // Additional test cases + it('should handle URLs with anchors', () => { + const url = 'http://example.com/page#section'; + + expect(urlWithoutParamsAndAnchor(url)).toBe('http://example.com/page'); + }); +}); + +describe('getUrlParams', () => { + // Existing test cases + it('should return URL parameters as an object', () => { + const mockGlobal = { + location: { + search: '?param1=value1¶m2=value2', + }, + }; + + // Mock the global scope and location + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + spyGetGlobalScope.mockReturnValue(mockGlobal); + + expect(getUrlParams()).toEqual({ + param1: 'value1', + param2: 'value2', + }); + }); + + it('should return an empty object if there are no URL parameters', () => { + const mockGlobal = { + location: { + search: '', + }, + }; + + // Mock the global scope and location + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + spyGetGlobalScope.mockReturnValue(mockGlobal); + + expect(getUrlParams()).toEqual({}); + }); +}); + +afterAll(() => { + // Restore the original getGlobalScope function after all tests + spyGetGlobalScope.mockRestore(); +}); diff --git a/packages/experiment-tag/tsconfig.json b/packages/experiment-tag/tsconfig.json new file mode 100644 index 00000000..ece1f0c8 --- /dev/null +++ b/packages/experiment-tag/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts", "package.json"], + "compilerOptions": { + "noImplicitAny": false, + "declaration": true, + "declarationDir": "dist/types", + "downlevelIteration": true, + "strict": true, + "baseUrl": "./src", + "rootDir": "." + } +} diff --git a/packages/experiment-tag/tsconfig.test.json b/packages/experiment-tag/tsconfig.test.json new file mode 100644 index 00000000..a6ff84d1 --- /dev/null +++ b/packages/experiment-tag/tsconfig.test.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "rootDir": ".", + "baseUrl": ".", + "paths": { + "src/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["dist"] +} diff --git a/yarn.lock b/yarn.lock index d24a6c27..507e8a06 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1389,22 +1389,49 @@ "@jridgewell/sourcemap-codec" "^1.4.10" "@jridgewell/trace-mapping" "^0.3.9" +"@jridgewell/gen-mapping@^0.3.5": + version "0.3.5" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz#dcce6aff74bdf6dad1a95802b69b04a2fcb1fb36" + integrity sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg== + dependencies: + "@jridgewell/set-array" "^1.2.1" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping" "^0.3.24" + "@jridgewell/resolve-uri@3.1.0": version "3.1.0" resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78" integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w== +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + "@jridgewell/set-array@^1.0.1": version "1.1.2" resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72" integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw== +"@jridgewell/set-array@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.2.1.tgz#558fb6472ed16a4c850b889530e6b36438c49280" + integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== + +"@jridgewell/source-map@^0.3.3": + version "0.3.6" + resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.6.tgz#9d71ca886e32502eb9362c9a74a46787c36df81a" + integrity sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.25" + "@jridgewell/sourcemap-codec@1.4.14": version "1.4.14" resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24" integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw== -"@jridgewell/sourcemap-codec@^1.4.10": +"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14": version "1.4.15" resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== @@ -1417,6 +1444,14 @@ "@jridgewell/resolve-uri" "3.1.0" "@jridgewell/sourcemap-codec" "1.4.14" +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": + version "0.3.25" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" + integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + "@lerna/child-process@6.6.2": version "6.6.2" resolved "https://registry.yarnpkg.com/@lerna/child-process/-/child-process-6.6.2.tgz#5d803c8dee81a4e013dc428292e77b365cba876c" @@ -1967,6 +2002,15 @@ "@rollup/pluginutils" "^3.1.0" magic-string "^0.25.7" +"@rollup/plugin-terser@^0.4.4": + version "0.4.4" + resolved "https://registry.yarnpkg.com/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz#15dffdb3f73f121aa4fbb37e7ca6be9aeea91962" + integrity sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A== + dependencies: + serialize-javascript "^6.0.1" + smob "^1.0.0" + terser "^5.17.4" + "@rollup/plugin-typescript@^11.1.0": version "11.1.2" resolved "https://registry.yarnpkg.com/@rollup/plugin-typescript/-/plugin-typescript-11.1.2.tgz#09eb5690a650bb0334bf84125bce9abd296442e4" @@ -2403,6 +2447,11 @@ acorn@^8.1.0, acorn@^8.8.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.10.0.tgz#8be5b3907a67221a81ab23c7889c4c5526b62ec5" integrity sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw== +acorn@^8.8.2: + version "8.11.3" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a" + integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg== + add-stream@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/add-stream/-/add-stream-1.0.0.tgz#6a7990437ca736d5e1288db92bd3266d5f5cb2aa" @@ -3145,6 +3194,11 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" +commander@^2.20.0: + version "2.20.3" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" + integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== + common-ancestor-path@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz#4f7d2d1394d91b7abdf51871c62f71eadb0182a7" @@ -7177,6 +7231,13 @@ quick-lru@^4.0.1: resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f" integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g== +randombytes@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" + integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== + dependencies: + safe-buffer "^5.1.0" + react-is@^17.0.1: version "17.0.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" @@ -7508,16 +7569,16 @@ safe-array-concat@^1.0.0: has-symbols "^1.0.3" isarray "^2.0.5" +safe-buffer@^5.1.0, safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@~5.2.0: - version "5.2.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - safe-regex-test@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.0.tgz#793b874d524eb3640d1873aad03596db2d4f2295" @@ -7570,6 +7631,13 @@ semver@^7.0.0, semver@^7.1.1, semver@^7.2.1, semver@^7.3.4, semver@^7.3.5, semve dependencies: lru-cache "^6.0.0" +serialize-javascript@^6.0.1: + version "6.0.2" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" + integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== + dependencies: + randombytes "^2.1.0" + set-blocking@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" @@ -7657,6 +7725,11 @@ smart-buffer@^4.2.0: resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== +smob@^1.0.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/smob/-/smob-1.5.0.tgz#85d79a1403abf128d24d3ebc1cdc5e1a9548d3ab" + integrity sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig== + socks-proxy-agent@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz#dc069ecf34436621acb41e3efa66ca1b5fed15b6" @@ -7689,6 +7762,14 @@ source-map-support@0.5.13: buffer-from "^1.0.0" source-map "^0.6.0" +source-map-support@~0.5.20: + version "0.5.21" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" + integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" @@ -7985,6 +8066,16 @@ tempy@1.0.0: type-fest "^0.16.0" unique-string "^2.0.0" +terser@^5.17.4: + version "5.30.3" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.30.3.tgz#f1bb68ded42408c316b548e3ec2526d7dd03f4d2" + integrity sha512-STdUgOUx8rLbMGO9IOwHLpCqolkDITFFQSMYYwKE1N2lY6MVSaeoi10z/EhWxRc6ybqoVmKSkhKYH/XUpl7vSA== + dependencies: + "@jridgewell/source-map" "^0.3.3" + acorn "^8.8.2" + commander "^2.20.0" + source-map-support "~0.5.20" + test-exclude@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e"