diff --git a/.github/workflows/publish-to-s3.yml b/.github/workflows/publish-to-s3.yml index 736b28ee..530323a7 100644 --- a/.github/workflows/publish-to-s3.yml +++ b/.github/workflows/publish-to-s3.yml @@ -13,6 +13,11 @@ on: type: boolean required: true default: true + includeChromeExtension: + description: 'Release experiment-tag package for chrome extension' + type: boolean + required: true + default: true jobs: authorize: @@ -89,6 +94,14 @@ jobs: PACKAGES="segment-plugin" fi fi + + if [[ "${{ github.event.inputs.includeChromeExtension }}" == "true" ]]; then + if [[ -n "$PACKAGES" ]]; then + PACKAGES="$PACKAGES,chrome-extension" + else + PACKAGES="chrome-extension" + fi + fi if [[ -z "$PACKAGES" ]]; then echo "No packages selected for upload" diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index e9d0ed17..caf67cf1 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -29,8 +29,10 @@ import { PageObject, PageObjects, PreviewVariantsOptions, + PreviewState, RevertVariantsOptions, } from './types'; +import { applyAntiFlickerCss } from './util/anti-flicker'; import { setMarketingCookie } from './util/cookie'; import { getInjectUtils } from './util/inject-utils'; import { VISUAL_EDITOR_SESSION_KEY, WindowMessenger } from './util/messenger'; @@ -53,8 +55,7 @@ import { convertEvaluationVariantToVariant } from './util/variant'; const MUTATE_ACTION = 'mutate'; export const INJECT_ACTION = 'inject'; const REDIRECT_ACTION = 'redirect'; -const PREVIEW_MODE_PARAM = 'PREVIEW'; -export const PREVIEW_SEGMENT_NAME = 'Preview'; +export const PREVIEW_MODE_PARAM = 'PREVIEW'; export const PREVIEW_MODE_SESSION_KEY = 'amp-preview-mode'; const VISUAL_EDITOR_PARAM = 'VISUAL_EDITOR'; @@ -83,6 +84,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { [flagKey: string]: string | undefined; // variant }; } = {}; + // Also used by chrome extension private flagVariantMap: { [flagKey: string]: { [variantKey: string]: Variant; @@ -98,7 +100,9 @@ export class DefaultWebExperimentClient implements WebExperimentClient { private activePages: PageObjects = {}; private subscriptionManager: SubscriptionManager | undefined; private isVisualEditorMode = false; - private previewFlags: Record = {}; + // Preview mode is set by url params, postMessage or session storage, not chrome extension + isPreviewMode = false; + previewFlags: Record = {}; constructor( apiKey: string, @@ -123,21 +127,6 @@ export class DefaultWebExperimentClient implements WebExperimentClient { ...(this.globalScope.experimentConfig ?? {}), }; - const urlParams = getUrlParams(); - - let previewFlags: Record = {}; - // explicit URL params takes precedence over session storage - if (urlParams[PREVIEW_MODE_PARAM]) { - Object.keys(urlParams).forEach((key) => { - if (key !== 'PREVIEW' && urlParams[key]) { - previewFlags[key] = urlParams[key]; - } - }); - } else { - previewFlags = - getStorageItem('sessionStorage', PREVIEW_MODE_SESSION_KEY) || {}; - } - this.initialFlags.forEach((flag: EvaluationFlag) => { const { key, variants, metadata = {} } = flag; @@ -147,48 +136,11 @@ export class DefaultWebExperimentClient implements WebExperimentClient { convertEvaluationVariantToVariant(variants[variantKey]); }); - // Update initialFlags to force variant if in preview mode - if (key in previewFlags && previewFlags[key] in variants) { - this.previewFlags[key] = previewFlags[key]; - - const previewSegment = { - metadata: { segmentName: PREVIEW_SEGMENT_NAME }, - variant: previewFlags[key], - }; - - flag.segments = [previewSegment]; - metadata.evaluationMode = 'local'; - } - if (metadata.evaluationMode !== 'local') { this.remoteFlagKeys.push(key); } - - flag.metadata = metadata; }); - if (Object.keys(this.previewFlags).length > 0) { - if (urlParams[PREVIEW_MODE_PARAM]) { - setStorageItem( - 'sessionStorage', - PREVIEW_MODE_SESSION_KEY, - this.previewFlags, - ); - const previewParamsToRemove = [ - ...Object.keys(this.previewFlags), - PREVIEW_MODE_PARAM, - ]; - this.globalScope.history.replaceState( - {}, - '', - removeQueryParams( - this.globalScope.location.href, - previewParamsToRemove, - ), - ); - } - } - const initialFlagsString = JSON.stringify(this.initialFlags); // initialize the experiment @@ -234,6 +186,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { }, this.globalScope, ); + this.setupPreviewMode(urlParams); this.subscriptionManager.initSubscriptions(); // if in visual edit mode, remove the query param @@ -283,11 +236,14 @@ export class DefaultWebExperimentClient implements WebExperimentClient { if ( this.remoteFlagKeys.includes(flagKey) && variant.metadata?.blockingEvaluation && - Object.keys(this.activePages).includes(flagKey) + Object.keys(this.activePages).includes(flagKey) && + !this.remoteFlagKeys.every((key) => + Object.keys(this.previewFlags).includes(key), + ) ) { this.isRemoteBlocking = true; // Apply anti-flicker CSS to prevent UI flicker - this.applyAntiFlickerCss(); + applyAntiFlickerCss(); } } @@ -311,8 +267,16 @@ export class DefaultWebExperimentClient implements WebExperimentClient { // apply local variants this.applyVariants({ flagKeys: this.localFlagKeys }); + this.previewVariants({ + keyToVariant: this.previewFlags, + }); - if (this.remoteFlagKeys.length === 0) { + if ( + // do not fetch remote flags if all remote flags are in preview mode + this.remoteFlagKeys.every((key) => + Object.keys(this.previewFlags).includes(key), + ) + ) { this.isRunning = true; return; } @@ -369,11 +333,6 @@ export class DefaultWebExperimentClient implements WebExperimentClient { * @param options */ public applyVariants(options?: ApplyVariantsOptions) { - if (Object.keys(this.previewFlags).length > 0) { - showPreviewModeModal({ - flags: this.previewFlags, - }); - } const { flagKeys } = options || {}; const variants = this.getVariants(); if (Object.keys(variants).length === 0) { @@ -391,7 +350,8 @@ export class DefaultWebExperimentClient implements WebExperimentClient { this.fireStoredRedirectImpressions(); for (const key in variants) { - if (flagKeys && !flagKeys.includes(key)) { + // preview actions are handled by previewVariants + if ((flagKeys && !flagKeys.includes(key)) || this.previewFlags[key]) { continue; } const variant = variants[key]; @@ -486,6 +446,12 @@ export class DefaultWebExperimentClient implements WebExperimentClient { if (!variantObject) { return; } + if (this.isPreviewMode) { + this.exposureWithDedupe(key, variantObject, true); + showPreviewModeModal({ + flags: this.previewFlags, + }); + } const payload = variantObject.payload; if (!payload || !Array.isArray(payload)) { return; @@ -791,24 +757,11 @@ export class DefaultWebExperimentClient implements WebExperimentClient { } else { this.experimentClient.exposure(key); } - this.urlExposureCache[currentUrl][key] = variant.key; - } - } - - private applyAntiFlickerCss() { - if (!this.globalScope.document.getElementById('amp-exp-css')) { - const id = 'amp-exp-css'; - const s = document.createElement('style'); - s.id = id; - s.innerText = - '* { visibility: hidden !important; background-image: none !important; }'; - document.head.appendChild(s); - this.globalScope.window.setTimeout(function () { - s.remove(); - }, 1000); + (this.urlExposureCache[currentUrl] ??= {})[key] = variant.key; } } + // Also used by chrome extension updateActivePages(flagKey: string, page: PageObject, isActive: boolean) { if (!this.activePages[flagKey]) { this.activePages[flagKey] = {}; @@ -894,4 +847,47 @@ export class DefaultWebExperimentClient implements WebExperimentClient { removeStorageItem('sessionStorage', redirectStorageKey); } } + + private setupPreviewMode(urlParams: Record) { + // explicit URL params takes precedence over session storage + if (urlParams[PREVIEW_MODE_PARAM] === 'true') { + Object.keys(urlParams).forEach((key) => { + if (key !== PREVIEW_MODE_PARAM && urlParams[key]) { + this.previewFlags[key] = urlParams[key]; + } + }); + + setStorageItem('sessionStorage', PREVIEW_MODE_SESSION_KEY, { + previewFlags: this.previewFlags, + }); + const previewParamsToRemove = [ + ...Object.keys(this.previewFlags), + PREVIEW_MODE_PARAM, + ]; + this.globalScope.history.replaceState( + {}, + '', + removeQueryParams( + this.globalScope.location.href, + previewParamsToRemove, + ), + ); + // if in preview mode, listen for ForceVariant messages + WindowMessenger.setup(); + } else { + const previewState: PreviewState | null = getStorageItem( + 'sessionStorage', + PREVIEW_MODE_SESSION_KEY, + ); + if (previewState) { + this.previewFlags = previewState.previewFlags; + } + } + + if (Object.keys(this.previewFlags).length > 0) { + this.isPreviewMode = true; + } else { + return; + } + } } diff --git a/packages/experiment-tag/src/index.ts b/packages/experiment-tag/src/index.ts index c6953bfe..3ddf260d 100644 --- a/packages/experiment-tag/src/index.ts +++ b/packages/experiment-tag/src/index.ts @@ -1,5 +1,11 @@ +import { getGlobalScope } from '@amplitude/experiment-core'; + import { DefaultWebExperimentClient } from './experiment'; +import { HttpClient } from './preview/http'; +import { SdkPreviewApi } from './preview/preview-api'; import { WebExperimentConfig } from './types'; +import { applyAntiFlickerCss } from './util/anti-flicker'; +import { isPreviewMode } from './util/url'; export const initialize = ( apiKey: string, @@ -7,12 +13,33 @@ export const initialize = ( pageObjects: string, config: WebExperimentConfig, ): void => { - DefaultWebExperimentClient.getInstance( - apiKey, - initialFlags, - pageObjects, - config, - ) + const shouldFetchConfigs = + isPreviewMode() || getGlobalScope()?.WebExperiment.injectedByExtension; + + if (shouldFetchConfigs) { + applyAntiFlickerCss(); + fetchLatestConfigs(apiKey, config.serverZone) + .then((previewState) => { + const flags = JSON.stringify(previewState.flags); + const objects = JSON.stringify(previewState.pageViewObjects); + startClient(apiKey, flags, objects, config); + }) + .catch((error) => { + console.warn('Failed to fetch latest configs for preview:', error); + startClient(apiKey, initialFlags, pageObjects, config); + }); + } else { + startClient(apiKey, initialFlags, pageObjects, config); + } +}; + +const startClient = ( + apiKey: string, + flags: string, + objects: string, + config: WebExperimentConfig, +): void => { + DefaultWebExperimentClient.getInstance(apiKey, flags, objects, config) .start() .finally(() => { // Remove anti-flicker css if it exists @@ -20,6 +47,15 @@ export const initialize = ( }); }; +const fetchLatestConfigs = async (apiKey: string, serverZone?: string) => { + const serverUrl = + serverZone === 'EU' + ? 'https://api.lab.eu.amplitude.com' + : 'https://api.lab.amplitude.com'; + const api = new SdkPreviewApi(apiKey, serverUrl, HttpClient); + return api.getPreviewFlagsAndPageViewObjects(); +}; + export { ApplyVariantsOptions, RevertVariantsOptions, diff --git a/packages/experiment-tag/src/preview/http.ts b/packages/experiment-tag/src/preview/http.ts new file mode 100644 index 00000000..7167f379 --- /dev/null +++ b/packages/experiment-tag/src/preview/http.ts @@ -0,0 +1,53 @@ +import { safeGlobal, TimeoutError } from '@amplitude/experiment-core'; +import unfetch from 'unfetch'; + +export interface SimpleResponse { + status: number; + body: string; +} + +export interface HttpClient { + request( + url: string, + method: string, + headers: Record, + data: string | null, + timeout?: number, + ): Promise; +} + +const fetch = safeGlobal.fetch || unfetch; + +const withTimeout = ( + promise: Promise, + ms?: number, +): Promise => { + if (!ms || ms <= 0) return promise; + + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new TimeoutError(`Timeout after ${ms}ms`)), ms), + ), + ]); +}; + +const makeRequest = async ( + url: string, + method: string, + headers: Record, + data: string, + timeout?: number, +): Promise => { + const request = async () => { + const response = await fetch(url, { method, headers, body: data }); + return { + status: response.status, + body: await response.text(), + }; + }; + + return withTimeout(request(), timeout); +}; + +export const HttpClient: HttpClient = { request: makeRequest }; diff --git a/packages/experiment-tag/src/preview/preview-api.ts b/packages/experiment-tag/src/preview/preview-api.ts new file mode 100644 index 00000000..47aa715c --- /dev/null +++ b/packages/experiment-tag/src/preview/preview-api.ts @@ -0,0 +1,54 @@ +import { EvaluationFlag } from '@amplitude/experiment-core'; + +import { version } from '../../package.json'; +import { PageObjects } from '../types'; + +import { HttpClient } from './http'; + +export interface PreviewApi { + getPreviewFlagsAndPageViewObjects(): Promise<{ + flags: EvaluationFlag[]; + pageViewObjects: PageObjects; + }>; +} + +export class SdkPreviewApi implements PreviewApi { + private readonly deploymentKey: string; + private readonly serverUrl: string; + private readonly httpClient: HttpClient; + + constructor( + deploymentKey: string, + serverUrl: string, + httpClient: HttpClient, + ) { + this.deploymentKey = deploymentKey; + this.serverUrl = serverUrl; + this.httpClient = httpClient; + } + + public async getPreviewFlagsAndPageViewObjects(): Promise<{ + flags: EvaluationFlag[]; + pageViewObjects: PageObjects; + }> { + const headers: Record = { + Authorization: `Api-Key ${this.deploymentKey}`, + }; + headers['X-Amp-Exp-Library'] = `experiment-tag/${version}`; + const response = await this.httpClient.request( + `${this.serverUrl}/web/v1/configs`, + 'GET', + headers, + null, + 10000, + ); + if (response.status != 200) { + throw Error(`Preview error response: status=${response.status}`); + } + const flags: EvaluationFlag[] = JSON.parse(response.body) + .flags as EvaluationFlag[]; + const pageViewObjects: PageObjects = JSON.parse(response.body) + .pageObjects as PageObjects; + return { flags, pageViewObjects }; + } +} diff --git a/packages/experiment-tag/src/preview/preview.ts b/packages/experiment-tag/src/preview/preview.ts index 7f7588e1..fd62339a 100644 --- a/packages/experiment-tag/src/preview/preview.ts +++ b/packages/experiment-tag/src/preview/preview.ts @@ -16,19 +16,11 @@ export class PreviewModeModal { } show(): void { - if (this.modal || document.getElementById('amp-preview-modal')) { + if (document.getElementById('amp-preview-modal')) { return; } - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => { - this.createModal(); - this.attachEventListeners(); - }); - } else { - this.createModal(); - this.attachEventListeners(); - } + this.createModal(); + this.attachEventListeners(); } hide(): void { @@ -104,8 +96,11 @@ export class PreviewModeModal { this.modal.appendChild(closeButton); this.injectStyles(); - - document.body.appendChild(this.modal); + requestAnimationFrame(() => { + if (this.modal && document.body) { + document.body.appendChild(this.modal); + } + }); } private attachEventListeners(): void { @@ -188,6 +183,9 @@ export class PreviewModeModal { color: #1a202c; font-size: 14px; white-space: nowrap; + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; } /* Base badge styles */ @@ -325,13 +323,28 @@ export function showPreviewModeModal( ): PreviewModeModal { const modal = new PreviewModeModal(options); + let documentReady = false; + let timeoutReady = false; + + const tryShow = () => { + if (documentReady && timeoutReady) { + modal.show(); + } + }; + if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { - modal.show(); + documentReady = true; + tryShow(); }); } else { - modal.show(); + documentReady = true; } + setTimeout(() => { + timeoutReady = true; + tryShow(); + }, 500); + return modal; } diff --git a/packages/experiment-tag/src/subscriptions.ts b/packages/experiment-tag/src/subscriptions.ts index a7a1b727..c89f502c 100644 --- a/packages/experiment-tag/src/subscriptions.ts +++ b/packages/experiment-tag/src/subscriptions.ts @@ -135,6 +135,11 @@ export class SubscriptionManager { }); } this.webExperimentClient.applyVariants(); + if (this.webExperimentClient.isPreviewMode) { + this.webExperimentClient.previewVariants({ + keyToVariant: this.webExperimentClient.previewFlags, + }); + } } const activePages = this.webExperimentClient.getActivePages(); diff --git a/packages/experiment-tag/src/types.ts b/packages/experiment-tag/src/types.ts index 4f6bc603..aa7b0b2f 100644 --- a/packages/experiment-tag/src/types.ts +++ b/packages/experiment-tag/src/types.ts @@ -2,6 +2,7 @@ import { EvaluationCondition } from '@amplitude/experiment-core'; import { ExperimentConfig, ExperimentUser, + Variant, } from '@amplitude/experiment-js-client'; import { ExperimentClient, Variants } from '@amplitude/experiment-js-client'; @@ -28,6 +29,10 @@ export type PreviewVariantsOptions = { keyToVariant?: Record; }; +export type PreviewState = { + previewFlags: Record; +}; + export type PageObject = { id: string; name: string; diff --git a/packages/experiment-tag/src/util/anti-flicker.ts b/packages/experiment-tag/src/util/anti-flicker.ts new file mode 100644 index 00000000..d5413dad --- /dev/null +++ b/packages/experiment-tag/src/util/anti-flicker.ts @@ -0,0 +1,16 @@ +import { getGlobalScope } from '@amplitude/experiment-core'; + +export const applyAntiFlickerCss = () => { + const globalScope = getGlobalScope(); + if (!globalScope?.document.getElementById('amp-exp-css')) { + const id = 'amp-exp-css'; + const s = document.createElement('style'); + s.id = id; + s.innerText = + '* { visibility: hidden !important; background-image: none !important; }'; + document.head.appendChild(s); + globalScope?.window.setTimeout(function () { + s.remove(); + }, 1000); + } +}; diff --git a/packages/experiment-tag/src/util/messenger.ts b/packages/experiment-tag/src/util/messenger.ts index 939f8683..97dd5db9 100644 --- a/packages/experiment-tag/src/util/messenger.ts +++ b/packages/experiment-tag/src/util/messenger.ts @@ -33,7 +33,10 @@ export class WindowMessenger { ( e: MessageEvent<{ type: string; - context: { injectSrc: string; amplitudeWindowUrl: string }; + context: { + injectSrc: string; + amplitudeWindowUrl: string; + }; }>, ) => { const match = /^.*\.amplitude\.com$/; @@ -46,7 +49,6 @@ export class WindowMessenger { // new URL(e.origin) can throw. return; } - if (e.data.type === 'OpenOverlay') { if ( state !== 'closed' || diff --git a/packages/experiment-tag/src/util/url.ts b/packages/experiment-tag/src/util/url.ts index 69404a1a..743f2cbc 100644 --- a/packages/experiment-tag/src/util/url.ts +++ b/packages/experiment-tag/src/util/url.ts @@ -1,5 +1,9 @@ -import { EvaluationVariant, getGlobalScope } from '@amplitude/experiment-core'; -import { Variant } from '@amplitude/experiment-js-client'; +import { getGlobalScope } from '@amplitude/experiment-core'; + +import { PREVIEW_MODE_PARAM, PREVIEW_MODE_SESSION_KEY } from '../experiment'; +import { PreviewState } from '../types'; + +import { getStorageItem } from './storage'; export const getUrlParams = (): Record => { const globalScope = getGlobalScope(); @@ -80,3 +84,20 @@ export const concatenateQueryParamsOf = ( return resultUrlObj.toString(); }; + +export const isPreviewMode = (): boolean => { + if (getUrlParams()[PREVIEW_MODE_PARAM] === 'true') { + return true; + } + const previewState = getStorageItem( + 'sessionStorage', + PREVIEW_MODE_SESSION_KEY, + ) as PreviewState; + if ( + previewState?.previewFlags && + Object.keys(previewState.previewFlags).length > 0 + ) { + return true; + } + return false; +}; diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts index 9b3dd8ce..059882be 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -3,6 +3,7 @@ import { safeGlobal } from '@amplitude/experiment-core'; import { ExperimentClient } from '@amplitude/experiment-js-client'; import { Base64 } from 'js-base64'; import { DefaultWebExperimentClient } from 'src/experiment'; +import * as antiFlickerUtils from 'src/util/anti-flicker'; import * as uuid from 'src/util/uuid'; import { stringify } from 'ts-jest'; @@ -137,7 +138,7 @@ describe('initializeExperiment', () => { // Ensure the mock is properly set mockGetGlobalScope.mockReturnValue(mockGlobal); antiFlickerSpy = jest - .spyOn(DefaultWebExperimentClient.prototype as any, 'applyAntiFlickerCss') + .spyOn(antiFlickerUtils, 'applyAntiFlickerCss') .mockImplementation(jest.fn()); }); @@ -328,7 +329,11 @@ describe('initializeExperiment', () => { '', 'http://test.com/', ); - expect(mockExposure).toHaveBeenCalledWith('test'); + expect(mockExposureInternal).toHaveBeenCalledWith('test', { + variant: { key: 'control', value: 'control' }, + source: 'local-evaluation', + hasDefaultVariant: false, + }); }); test('preview - force treatment variant when on control page', async () => { @@ -406,7 +411,7 @@ describe('initializeExperiment', () => { // @ts-ignore mockGetGlobalScope.mockReturnValue(mockGlobal); - DefaultWebExperimentClient.getInstance( + const client = DefaultWebExperimentClient.getInstance( stringify(apiKey), JSON.stringify([ createRedirectFlag('test', 'treatment', 'http://test.com/2', undefined), @@ -414,6 +419,8 @@ describe('initializeExperiment', () => { JSON.stringify(DEFAULT_PAGE_OBJECTS), ); + client.start().then(); + expect(mockGlobal.location.replace).toHaveBeenCalledTimes(0); expect(mockExposure).toHaveBeenCalledTimes(0); expect(mockGlobal.history.replaceState).toHaveBeenCalledWith( diff --git a/scripts/upload-to-s3.js b/scripts/upload-to-s3.js index 1adc7241..8f56c8b5 100644 --- a/scripts/upload-to-s3.js +++ b/scripts/upload-to-s3.js @@ -42,6 +42,17 @@ const availablePackages = { }, ], }, + 'chrome-extension': { + name: 'experiment-tag-latest-chrome-ext-v1', + packagePath: '../packages/experiment-tag/package.json', + distPath: 'packages/experiment-tag/dist', + files: [ + { + file: 'experiment-tag-min.js', + gzipped: false, + }, + ], + }, }; // Parse the packages input @@ -108,7 +119,11 @@ const allPromises = packagesToUpload.flatMap((packageConfig) => { const body = fs.readFileSync(filePath); // Create the key with version and optional branch name - let fileName = `${packageConfig.name}-${version}`; + let fileName = `${packageConfig.name}`; + if (packageConfig.name !== availablePackages['chrome-extension'].name) { + // chrome extension is not versioned + fileName += `-${version}`; + } if (branchName) { fileName += `-${branchName}`; }