@dotcms/experiments 0.0.1-alpha.38 → 0.0.1-alpha.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.babelrc +12 -0
- package/.eslintrc.json +26 -0
- package/jest.config.ts +11 -0
- package/package.json +4 -8
- package/project.json +55 -0
- package/src/lib/components/{DotExperimentHandlingComponent.d.ts → DotExperimentHandlingComponent.tsx} +20 -3
- package/src/lib/components/DotExperimentsProvider.spec.tsx +62 -0
- package/src/lib/components/{DotExperimentsProvider.d.ts → DotExperimentsProvider.tsx} +41 -3
- package/src/lib/components/withExperiments.tsx +52 -0
- package/src/lib/contexts/DotExperimentsContext.spec.tsx +42 -0
- package/src/lib/contexts/{DotExperimentsContext.d.ts → DotExperimentsContext.tsx} +5 -2
- package/src/lib/dot-experiments.spec.ts +285 -0
- package/src/lib/dot-experiments.ts +716 -0
- package/src/lib/hooks/useExperimentVariant.spec.tsx +111 -0
- package/src/lib/hooks/useExperimentVariant.ts +55 -0
- package/src/lib/hooks/useExperiments.ts +90 -0
- package/src/lib/shared/{constants.d.ts → constants.ts} +35 -18
- package/src/lib/shared/mocks/mock.ts +209 -0
- package/src/lib/shared/{models.d.ts → models.ts} +35 -2
- package/src/lib/shared/parser/parse.spec.ts +187 -0
- package/src/lib/shared/parser/parser.ts +171 -0
- package/src/lib/shared/persistence/index-db-database-handler.spec.ts +100 -0
- package/src/lib/shared/persistence/index-db-database-handler.ts +218 -0
- package/src/lib/shared/utils/DotLogger.ts +57 -0
- package/src/lib/shared/utils/memoize.spec.ts +49 -0
- package/src/lib/shared/utils/memoize.ts +49 -0
- package/src/lib/shared/utils/utils.spec.ts +142 -0
- package/src/lib/shared/utils/utils.ts +203 -0
- package/src/lib/standalone.spec.ts +36 -0
- package/src/lib/standalone.ts +28 -0
- package/tsconfig.json +20 -0
- package/tsconfig.lib.json +20 -0
- package/tsconfig.spec.json +9 -0
- package/vite.config.ts +41 -0
- package/index.esm.d.ts +0 -1
- package/index.esm.js +0 -7174
- package/src/lib/components/withExperiments.d.ts +0 -20
- package/src/lib/dot-experiments.d.ts +0 -289
- package/src/lib/hooks/useExperimentVariant.d.ts +0 -21
- package/src/lib/hooks/useExperiments.d.ts +0 -14
- package/src/lib/shared/mocks/mock.d.ts +0 -43
- package/src/lib/shared/parser/parser.d.ts +0 -54
- package/src/lib/shared/persistence/index-db-database-handler.d.ts +0 -87
- package/src/lib/shared/utils/DotLogger.d.ts +0 -15
- package/src/lib/shared/utils/memoize.d.ts +0 -7
- package/src/lib/shared/utils/utils.d.ts +0 -73
- package/src/lib/standalone.d.ts +0 -7
- /package/src/{index.d.ts → index.ts} +0 -0
package/.babelrc
ADDED
package/.eslintrc.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": ["plugin:@nrwl/nx/react", "../../../.eslintrc.base.json"],
|
|
3
|
+
"ignorePatterns": ["!**/*"],
|
|
4
|
+
"overrides": [
|
|
5
|
+
{
|
|
6
|
+
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
|
7
|
+
"rules": {
|
|
8
|
+
"padding-line-between-statements": [
|
|
9
|
+
"error",
|
|
10
|
+
{ "blankLine": "always", "prev": "const", "next": "*" },
|
|
11
|
+
{ "blankLine": "always", "prev": "*", "next": "if" },
|
|
12
|
+
{ "blankLine": "always", "prev": "if", "next": "*" },
|
|
13
|
+
{ "blankLine": "always", "prev": "*", "next": "const" }
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"files": ["*.ts", "*.tsx"],
|
|
19
|
+
"rules": {}
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"files": ["*.js", "*.jsx"],
|
|
23
|
+
"rules": {}
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
package/jest.config.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
export default {
|
|
3
|
+
displayName: 'sdk-experiments',
|
|
4
|
+
preset: '../../../jest.preset.js',
|
|
5
|
+
transform: {
|
|
6
|
+
'^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nrwl/react/plugins/jest',
|
|
7
|
+
'^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nrwl/react/babel'] }]
|
|
8
|
+
},
|
|
9
|
+
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
|
10
|
+
coverageDirectory: '../../../coverage/libs/sdk/experiments'
|
|
11
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotcms/experiments",
|
|
3
|
-
"version": "0.0.1-alpha.
|
|
3
|
+
"version": "0.0.1-alpha.39",
|
|
4
4
|
"description": "Official JavaScript library to use Experiments with DotCMS.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -25,10 +25,6 @@
|
|
|
25
25
|
"peerDependencies": {
|
|
26
26
|
"react": ">=18",
|
|
27
27
|
"react-dom": ">=18",
|
|
28
|
-
"@dotcms/client": "0.0.1-alpha.
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
"type": "module",
|
|
32
|
-
"main": "./index.esm.js",
|
|
33
|
-
"types": "./index.esm.d.ts"
|
|
34
|
-
}
|
|
28
|
+
"@dotcms/client": "0.0.1-alpha.39"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/project.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sdk-experiments",
|
|
3
|
+
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
|
4
|
+
"sourceRoot": "libs/sdk/experiments/src",
|
|
5
|
+
"projectType": "library",
|
|
6
|
+
"targets": {
|
|
7
|
+
"build": {
|
|
8
|
+
"executor": "@nrwl/rollup:rollup",
|
|
9
|
+
"outputs": ["{options.outputPath}"],
|
|
10
|
+
"options": {
|
|
11
|
+
"outputPath": "dist/libs/sdk/experiments",
|
|
12
|
+
"tsConfig": "libs/sdk/experiments/tsconfig.lib.json",
|
|
13
|
+
"project": "libs/sdk/experiments/package.json",
|
|
14
|
+
"entryFile": "libs/sdk/experiments/src/index.ts",
|
|
15
|
+
"external": ["react/jsx-runtime"],
|
|
16
|
+
"rollupConfig": "@nrwl/react/plugins/bundle-rollup",
|
|
17
|
+
"compiler": "babel",
|
|
18
|
+
"assets": [
|
|
19
|
+
{
|
|
20
|
+
"glob": "libs/sdk/experiments/README.md",
|
|
21
|
+
"input": ".",
|
|
22
|
+
"output": "."
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"build:iife": {
|
|
28
|
+
"executor": "@nx/vite:build",
|
|
29
|
+
"outputs": ["{options.outputPath}"],
|
|
30
|
+
"options": {
|
|
31
|
+
"outputPath": "dist/libs/sdk/experiments/iife",
|
|
32
|
+
"main": "libs/sdk/experiments/src/lib/standalone.ts",
|
|
33
|
+
"tsConfig": "libs/sdk/experiments/tsconfig.lib.json"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"publish": {
|
|
37
|
+
"command": "node tools/scripts/publish.mjs sdk-experiments {args.ver} {args.tag}",
|
|
38
|
+
"outputPath": "dist/libs/sdk/experiments",
|
|
39
|
+
"dependsOn": ["build", "test"]
|
|
40
|
+
},
|
|
41
|
+
"nx-release-publish": {
|
|
42
|
+
"options": {
|
|
43
|
+
"packageRoot": "dist/libs/sdk/experiments"
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"test": {
|
|
47
|
+
"executor": "@nx/jest:jest",
|
|
48
|
+
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
|
49
|
+
"options": {
|
|
50
|
+
"jestConfig": "libs/sdk/experiments/jest.config.ts"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"tags": []
|
|
55
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
/// <reference types="react" />
|
|
2
1
|
import { DotcmsPageProps } from '@dotcms/react';
|
|
2
|
+
|
|
3
|
+
import { useExperimentVariant } from '../hooks/useExperimentVariant';
|
|
4
|
+
|
|
3
5
|
interface ExperimentHandlingProps extends DotcmsPageProps {
|
|
4
6
|
WrappedComponent: React.ComponentType<DotcmsPageProps>;
|
|
5
7
|
}
|
|
8
|
+
|
|
6
9
|
/**
|
|
7
10
|
* A React functional component that conditionally renders a WrappedComponent based on the
|
|
8
11
|
* experiment variant state. It uses the `useExperimentVariant` hook to determine if there's a
|
|
@@ -17,5 +20,19 @@ interface ExperimentHandlingProps extends DotcmsPageProps {
|
|
|
17
20
|
* @returns {React.ReactElement} A React element that either renders the WrappedComponent hidden or visible
|
|
18
21
|
* based on the experiment variant.
|
|
19
22
|
*/
|
|
20
|
-
export
|
|
21
|
-
|
|
23
|
+
export const DotExperimentHandlingComponent: React.FC<ExperimentHandlingProps> = ({
|
|
24
|
+
WrappedComponent,
|
|
25
|
+
...props
|
|
26
|
+
}) => {
|
|
27
|
+
const { shouldWaitForVariant } = useExperimentVariant(props.pageContext.pageAsset);
|
|
28
|
+
|
|
29
|
+
if (shouldWaitForVariant) {
|
|
30
|
+
return (
|
|
31
|
+
<div style={{ visibility: 'hidden' }}>
|
|
32
|
+
<WrappedComponent {...props} />
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return <WrappedComponent {...props} />;
|
|
38
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { render, waitFor } from '@testing-library/react';
|
|
2
|
+
|
|
3
|
+
import * as dotcmsClient from '@dotcms/client';
|
|
4
|
+
|
|
5
|
+
import { DotExperimentsProvider } from './DotExperimentsProvider';
|
|
6
|
+
|
|
7
|
+
import { DotExperiments } from '../dot-experiments';
|
|
8
|
+
|
|
9
|
+
jest.mock('../dot-experiments');
|
|
10
|
+
jest.mock('@dotcms/client');
|
|
11
|
+
|
|
12
|
+
const mockDotExperimentsInstance = {
|
|
13
|
+
getInstance: jest.fn().mockResolvedValue(true),
|
|
14
|
+
ready: jest.fn().mockResolvedValue(true),
|
|
15
|
+
locationChanged: jest.fn().mockResolvedValue(true)
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe('DotExperimentsProvider', () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
DotExperiments.getInstance = jest.fn().mockReturnValue(mockDotExperimentsInstance);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('initializes DotExperiments instance when not inside the editor', async () => {
|
|
24
|
+
const config = { apiKey: 'key', server: 'server', debug: true };
|
|
25
|
+
|
|
26
|
+
jest.spyOn(dotcmsClient, 'isInsideEditor').mockReturnValue(true);
|
|
27
|
+
|
|
28
|
+
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
|
29
|
+
|
|
30
|
+
render(
|
|
31
|
+
<DotExperimentsProvider config={config}>
|
|
32
|
+
<div>Test</div>
|
|
33
|
+
</DotExperimentsProvider>
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
await waitFor(() => expect(DotExperiments.getInstance).not.toHaveBeenCalled());
|
|
37
|
+
|
|
38
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
39
|
+
'DotExperimentsProvider: DotExperiments instance not initialized because it is inside the editor.'
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
consoleWarnSpy.mockRestore();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('initializes DotExperiments instance when is inside the editor', async () => {
|
|
46
|
+
const config = { apiKey: 'key', server: 'server', debug: true };
|
|
47
|
+
|
|
48
|
+
jest.spyOn(dotcmsClient, 'isInsideEditor').mockReturnValue(false);
|
|
49
|
+
|
|
50
|
+
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
|
51
|
+
|
|
52
|
+
render(
|
|
53
|
+
<DotExperimentsProvider config={config}>
|
|
54
|
+
<div>Test</div>
|
|
55
|
+
</DotExperimentsProvider>
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
await waitFor(() => expect(DotExperiments.getInstance).toHaveBeenCalled());
|
|
59
|
+
|
|
60
|
+
consoleWarnSpy.mockRestore();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -1,9 +1,17 @@
|
|
|
1
|
-
import { ReactElement, ReactNode } from 'react';
|
|
1
|
+
import { ReactElement, ReactNode, useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import { isInsideEditor } from '@dotcms/client';
|
|
4
|
+
|
|
5
|
+
import DotExperimentsContext from '../contexts/DotExperimentsContext';
|
|
6
|
+
import { DotExperiments } from '../dot-experiments';
|
|
7
|
+
import { useExperiments } from '../hooks/useExperiments';
|
|
2
8
|
import { DotExperimentConfig } from '../shared/models';
|
|
9
|
+
|
|
3
10
|
interface DotExperimentsProviderProps {
|
|
4
11
|
children?: ReactNode;
|
|
5
12
|
config: DotExperimentConfig;
|
|
6
13
|
}
|
|
14
|
+
|
|
7
15
|
/**
|
|
8
16
|
* `DotExperimentsProvider` is a component that uses React's Context API to provide
|
|
9
17
|
* an instance of `DotExperiments` to all of its descendants.
|
|
@@ -43,5 +51,35 @@ interface DotExperimentsProviderProps {
|
|
|
43
51
|
* @returns {ReactElement} The provider component, which should wrap the components
|
|
44
52
|
* that need access to the `DotExperiments` instance.
|
|
45
53
|
*/
|
|
46
|
-
export
|
|
47
|
-
|
|
54
|
+
export const DotExperimentsProvider = ({
|
|
55
|
+
children,
|
|
56
|
+
config
|
|
57
|
+
}: DotExperimentsProviderProps): ReactElement => {
|
|
58
|
+
const [instance, setInstance] = useState<DotExperiments | null>(null);
|
|
59
|
+
|
|
60
|
+
// Run Experiments detection
|
|
61
|
+
useExperiments(instance);
|
|
62
|
+
|
|
63
|
+
// Initialize the DotExperiments instance
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const insideEditor = isInsideEditor();
|
|
66
|
+
|
|
67
|
+
if (!insideEditor) {
|
|
68
|
+
const dotExperimentsInstance = DotExperiments.getInstance(config);
|
|
69
|
+
|
|
70
|
+
dotExperimentsInstance.ready().then(() => {
|
|
71
|
+
setInstance(dotExperimentsInstance);
|
|
72
|
+
});
|
|
73
|
+
} else {
|
|
74
|
+
if (config.debug) {
|
|
75
|
+
console.warn(
|
|
76
|
+
'DotExperimentsProvider: DotExperiments instance not initialized because it is inside the editor.'
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}, [config]);
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<DotExperimentsContext.Provider value={instance}>{children}</DotExperimentsContext.Provider>
|
|
84
|
+
);
|
|
85
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/* eslint-disable react-hooks/rules-of-hooks */
|
|
2
|
+
import React, { ReactNode, useCallback } from 'react';
|
|
3
|
+
|
|
4
|
+
import { DotcmsPageProps } from '@dotcms/react';
|
|
5
|
+
|
|
6
|
+
import { DotExperimentHandlingComponent } from './DotExperimentHandlingComponent';
|
|
7
|
+
import { DotExperimentsProvider } from './DotExperimentsProvider';
|
|
8
|
+
|
|
9
|
+
import { DotExperimentConfig } from '../shared/models';
|
|
10
|
+
import { useMemoizedObject } from '../shared/utils/memoize';
|
|
11
|
+
|
|
12
|
+
export interface PageProviderProps {
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
14
|
+
readonly entity: any;
|
|
15
|
+
readonly children: ReactNode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Wraps a given component with experiment handling capabilities using the 'useExperimentVariant' hook.
|
|
20
|
+
* This HOC checks if the entity's assigned experiment variant differs from the currently displayed variant.
|
|
21
|
+
* If they differ, the content is hidden until the correct variant is displayed. Once the assigned variant
|
|
22
|
+
* matches the displayed variant, the content of the WrappedComponent is shown.
|
|
23
|
+
*
|
|
24
|
+
* @param {React.ComponentType<DotcmsPageProps>} WrappedComponent - The component to be enhanced.
|
|
25
|
+
* @param {DotExperimentConfig} config - Configuration for experiment handling, including any necessary
|
|
26
|
+
* redirection functions or other settings.
|
|
27
|
+
* @returns {React.FunctionComponent<DotcmsPageProps>} A component that wraps the original component,
|
|
28
|
+
* adding experiment handling based on the specified configuration.
|
|
29
|
+
*/
|
|
30
|
+
export const withExperiments = (
|
|
31
|
+
WrappedComponent: React.ComponentType<DotcmsPageProps>,
|
|
32
|
+
config: DotExperimentConfig
|
|
33
|
+
) => {
|
|
34
|
+
// We need to use a custom memoization hook
|
|
35
|
+
// because the useMemo or React.memo lose the reference of the object
|
|
36
|
+
// in each render, causing the experiment handling to be reinitialized.
|
|
37
|
+
const memoizedConfig = useMemoizedObject(config);
|
|
38
|
+
|
|
39
|
+
return useCallback(
|
|
40
|
+
(props: DotcmsPageProps) => {
|
|
41
|
+
return (
|
|
42
|
+
<DotExperimentsProvider config={memoizedConfig}>
|
|
43
|
+
<DotExperimentHandlingComponent
|
|
44
|
+
{...props}
|
|
45
|
+
WrappedComponent={WrappedComponent}
|
|
46
|
+
/>
|
|
47
|
+
</DotExperimentsProvider>
|
|
48
|
+
);
|
|
49
|
+
},
|
|
50
|
+
[WrappedComponent, memoizedConfig]
|
|
51
|
+
);
|
|
52
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react-hooks';
|
|
2
|
+
import { ReactNode, useContext } from 'react';
|
|
3
|
+
|
|
4
|
+
import DotExperimentsContext from './DotExperimentsContext';
|
|
5
|
+
|
|
6
|
+
import { DotExperiments } from '../dot-experiments';
|
|
7
|
+
|
|
8
|
+
jest.mock('../dot-experiments', () => {
|
|
9
|
+
return jest.fn().mockImplementation(() => {
|
|
10
|
+
return {};
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('useDotExperimentsContext', () => {
|
|
15
|
+
it('returns the context value null', () => {
|
|
16
|
+
const mockContextValue = null;
|
|
17
|
+
|
|
18
|
+
const { result } = renderHook(() => useContext(DotExperimentsContext), {
|
|
19
|
+
wrapper: ({ children }: { children: ReactNode }) => (
|
|
20
|
+
<DotExperimentsContext.Provider value={mockContextValue}>
|
|
21
|
+
{children}
|
|
22
|
+
</DotExperimentsContext.Provider>
|
|
23
|
+
)
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
expect(result.current).toEqual(mockContextValue);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns the context value DotExperiment', () => {
|
|
30
|
+
const mockContextValue = {} as DotExperiments;
|
|
31
|
+
|
|
32
|
+
const { result } = renderHook(() => useContext(DotExperimentsContext), {
|
|
33
|
+
wrapper: ({ children }: { children: ReactNode }) => (
|
|
34
|
+
<DotExperimentsContext.Provider value={mockContextValue}>
|
|
35
|
+
{children}
|
|
36
|
+
</DotExperimentsContext.Provider>
|
|
37
|
+
)
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
expect(result.current).toEqual(mockContextValue);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
import { createContext } from 'react';
|
|
2
|
+
|
|
2
3
|
import { DotExperiments } from '../dot-experiments';
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* `DotExperimentsContext` is a React context that is designed to provide an instance of
|
|
5
7
|
* `DotExperiments` to all of the components within its tree that are Consumers of this context.
|
|
@@ -9,5 +11,6 @@ import { DotExperiments } from '../dot-experiments';
|
|
|
9
11
|
*
|
|
10
12
|
* @see {@link https://reactjs.org/docs/context.html|React Context}
|
|
11
13
|
*/
|
|
12
|
-
|
|
14
|
+
const DotExperimentsContext = createContext<DotExperiments | null>(null);
|
|
15
|
+
|
|
13
16
|
export default DotExperimentsContext;
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
|
2
|
+
import fakeIndexedDB from 'fake-indexeddb';
|
|
3
|
+
import fetchMock from 'fetch-mock';
|
|
4
|
+
|
|
5
|
+
import { DotExperiments } from './dot-experiments';
|
|
6
|
+
import { API_EXPERIMENTS_URL, EXPERIMENT_QUERY_PARAM_KEY } from './shared/constants';
|
|
7
|
+
import {
|
|
8
|
+
After15DaysIsUserIncludedResponse,
|
|
9
|
+
IsUserIncludedResponse,
|
|
10
|
+
LocationMock,
|
|
11
|
+
MOCK_CURRENT_TIMESTAMP,
|
|
12
|
+
MockDataStoredIndexDB,
|
|
13
|
+
MockDataStoredIndexDBWithNew,
|
|
14
|
+
MockDataStoredIndexDBWithNew15DaysLater,
|
|
15
|
+
NewIsUserIncludedResponse,
|
|
16
|
+
NoExperimentsIsUserIncludedResponse,
|
|
17
|
+
sessionStorageMock,
|
|
18
|
+
TIME_15_DAYS_MILLISECONDS,
|
|
19
|
+
TIME_5_DAYS_MILLISECONDS
|
|
20
|
+
} from './shared/mocks/mock';
|
|
21
|
+
import { DotExperimentConfig } from './shared/models';
|
|
22
|
+
|
|
23
|
+
jest.spyOn(Date, 'now').mockImplementation(() => MOCK_CURRENT_TIMESTAMP);
|
|
24
|
+
|
|
25
|
+
// Jitsu SDK Mock
|
|
26
|
+
jest.mock('@jitsu/sdk-js', () => ({
|
|
27
|
+
jitsuClient: jest.fn(() => ({
|
|
28
|
+
set: jest.fn(),
|
|
29
|
+
track: jest.fn().mockResolvedValue(true)
|
|
30
|
+
}))
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
// SessionStorage mock
|
|
34
|
+
global.sessionStorage = sessionStorageMock;
|
|
35
|
+
|
|
36
|
+
// IndexDB Mock
|
|
37
|
+
Object.defineProperty(window, 'indexedDB', {
|
|
38
|
+
writable: true,
|
|
39
|
+
value: fakeIndexedDB
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (!globalThis.structuredClone) {
|
|
43
|
+
globalThis.structuredClone = function (obj) {
|
|
44
|
+
return JSON.parse(JSON.stringify(obj));
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Windows Mock
|
|
49
|
+
global.window = Object.create(window);
|
|
50
|
+
Object.defineProperty(window, 'location', {
|
|
51
|
+
value: {
|
|
52
|
+
href: 'http://localhost:8080/',
|
|
53
|
+
origin: 'http://localhost:8080'
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('DotExperiments', () => {
|
|
58
|
+
const configMock: DotExperimentConfig = {
|
|
59
|
+
apiKey: 'yourApiKey',
|
|
60
|
+
server: 'http://localhost:8080/',
|
|
61
|
+
debug: false,
|
|
62
|
+
trackPageView: true
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
describe('DotExperiments Instance and Initialization', () => {
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
// destroy the instance of the singleton
|
|
68
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
69
|
+
(DotExperiments as any).instance = null;
|
|
70
|
+
});
|
|
71
|
+
it('should throw an error if config is not provided', () => {
|
|
72
|
+
expect(() => DotExperiments.getInstance()).toThrow(
|
|
73
|
+
'Configuration is required to create a new instance.'
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should instantiate the class when getInstance is called with config', () => {
|
|
78
|
+
const dotExperimentsInstance = DotExperiments.getInstance(configMock);
|
|
79
|
+
|
|
80
|
+
expect(dotExperimentsInstance).toBeInstanceOf(DotExperiments);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should throw an error if server is not provided in config', () => {
|
|
84
|
+
expect(() =>
|
|
85
|
+
// @ts-ignore
|
|
86
|
+
DotExperiments.getInstance({ 'api-key': 'api-key-test', debug: true })
|
|
87
|
+
).toThrow('`server` must be provided and should not be empty!');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should throw an error if api-key is not provided in config', () => {
|
|
91
|
+
expect(() =>
|
|
92
|
+
// @ts-ignore
|
|
93
|
+
DotExperiments.getInstance({ server: 'http://server-test.com', debug: true })
|
|
94
|
+
).toThrow('`apiKey` must be provided and should not be empty!');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should return false if the debug inactive', async () => {
|
|
98
|
+
const instance = DotExperiments.getInstance(configMock);
|
|
99
|
+
|
|
100
|
+
expect(instance.getIsDebugActive()).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should not call to trackPageView if you send the flag', async () => {
|
|
104
|
+
const config: DotExperimentConfig = { ...configMock, trackPageView: false };
|
|
105
|
+
|
|
106
|
+
fetchMock.post(`${configMock.server}/${API_EXPERIMENTS_URL}`, {
|
|
107
|
+
status: 200,
|
|
108
|
+
body: IsUserIncludedResponse,
|
|
109
|
+
headers: {
|
|
110
|
+
Accept: 'application/json',
|
|
111
|
+
'Content-Type': 'application/json'
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const instance = DotExperiments.getInstance(config);
|
|
116
|
+
|
|
117
|
+
const spyTrackPageView = jest.spyOn(instance, 'trackPageView');
|
|
118
|
+
|
|
119
|
+
expect(spyTrackPageView).not.toHaveBeenCalled();
|
|
120
|
+
|
|
121
|
+
await instance.locationChanged(LocationMock).then(() => {
|
|
122
|
+
expect(spyTrackPageView).not.toHaveBeenCalled();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should not call to trackPageView if you dont have experiment to track', async () => {
|
|
127
|
+
const config: DotExperimentConfig = { ...configMock, trackPageView: false };
|
|
128
|
+
|
|
129
|
+
fetchMock.post(
|
|
130
|
+
`${configMock.server}/${API_EXPERIMENTS_URL}`,
|
|
131
|
+
{
|
|
132
|
+
status: 200,
|
|
133
|
+
body: NoExperimentsIsUserIncludedResponse,
|
|
134
|
+
headers: {
|
|
135
|
+
Accept: 'application/json',
|
|
136
|
+
'Content-Type': 'application/json'
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
{ overwriteRoutes: true }
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const instance = DotExperiments.getInstance(config);
|
|
143
|
+
|
|
144
|
+
const spyTrackPageView = jest.spyOn(instance, 'trackPageView');
|
|
145
|
+
|
|
146
|
+
expect(spyTrackPageView).not.toHaveBeenCalled();
|
|
147
|
+
|
|
148
|
+
await instance.locationChanged(LocationMock).then(() => {
|
|
149
|
+
expect(spyTrackPageView).not.toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should return a a string with the query params of variant by the url given', async () => {
|
|
154
|
+
const config: DotExperimentConfig = { ...configMock, trackPageView: false };
|
|
155
|
+
|
|
156
|
+
fetchMock.post(
|
|
157
|
+
`${configMock.server}/${API_EXPERIMENTS_URL}`,
|
|
158
|
+
{
|
|
159
|
+
status: 200,
|
|
160
|
+
body: IsUserIncludedResponse,
|
|
161
|
+
headers: {
|
|
162
|
+
Accept: 'application/json',
|
|
163
|
+
'Content-Type': 'application/json'
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
{ overwriteRoutes: true }
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const instance = DotExperiments.getInstance(config);
|
|
170
|
+
|
|
171
|
+
await instance.ready();
|
|
172
|
+
|
|
173
|
+
const EMPTY_URL = '';
|
|
174
|
+
|
|
175
|
+
const expected1 = new URLSearchParams({});
|
|
176
|
+
|
|
177
|
+
expect(instance.getVariantAsQueryParam(EMPTY_URL)).toStrictEqual(expected1);
|
|
178
|
+
|
|
179
|
+
const URL_WITH_EXPERIMENT = '/blog';
|
|
180
|
+
|
|
181
|
+
const expected2 = new URLSearchParams({
|
|
182
|
+
[EXPERIMENT_QUERY_PARAM_KEY]:
|
|
183
|
+
IsUserIncludedResponse.entity.experiments[0].variant.name
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
expect(instance.getVariantAsQueryParam(URL_WITH_EXPERIMENT)).toStrictEqual(expected2);
|
|
187
|
+
|
|
188
|
+
const URL_NO_EXPERIMENT = '/destinations';
|
|
189
|
+
|
|
190
|
+
const expected3 = new URLSearchParams({});
|
|
191
|
+
|
|
192
|
+
expect(instance.getVariantAsQueryParam(URL_NO_EXPERIMENT)).toStrictEqual(expected3);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('Class interactions', () => {
|
|
197
|
+
beforeEach(() => {
|
|
198
|
+
fetchMock.restore();
|
|
199
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
200
|
+
(DotExperiments as any).instance = null;
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('should simulate the changes of the data in first run, after 5 days and 15 days.', async () => {
|
|
204
|
+
// First time the user enter to the page
|
|
205
|
+
|
|
206
|
+
fetchMock.post(`${configMock.server}/${API_EXPERIMENTS_URL}`, {
|
|
207
|
+
status: 200,
|
|
208
|
+
body: IsUserIncludedResponse,
|
|
209
|
+
headers: {
|
|
210
|
+
Accept: 'application/json',
|
|
211
|
+
'Content-Type': 'application/json'
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const instance = DotExperiments.getInstance(configMock);
|
|
216
|
+
|
|
217
|
+
const spyTrackPageView = jest.spyOn(instance, 'trackPageView');
|
|
218
|
+
|
|
219
|
+
await instance.ready().then(() => {
|
|
220
|
+
const experiments = instance.experiments;
|
|
221
|
+
|
|
222
|
+
expect(experiments.length).toBe(1);
|
|
223
|
+
expect(experiments).toEqual(MockDataStoredIndexDB);
|
|
224
|
+
|
|
225
|
+
expect(spyTrackPageView).toBeCalledTimes(1);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Second time the user enter to the page
|
|
229
|
+
// change the time 5 days later
|
|
230
|
+
jest.spyOn(Date, 'now').mockImplementation(
|
|
231
|
+
() => MOCK_CURRENT_TIMESTAMP + TIME_5_DAYS_MILLISECONDS
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
fetchMock.post(
|
|
235
|
+
`${configMock.server}/${API_EXPERIMENTS_URL}`,
|
|
236
|
+
{
|
|
237
|
+
status: 200,
|
|
238
|
+
body: NewIsUserIncludedResponse,
|
|
239
|
+
headers: {
|
|
240
|
+
Accept: 'application/json',
|
|
241
|
+
'Content-Type': 'application/json'
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
{ overwriteRoutes: true }
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
await instance.locationChanged(LocationMock).then(() => {
|
|
248
|
+
// get the experiments stored in the indexDB
|
|
249
|
+
const experiments = instance.experiments;
|
|
250
|
+
|
|
251
|
+
expect(experiments.length).toBe(1);
|
|
252
|
+
expect(experiments).toEqual(MockDataStoredIndexDBWithNew);
|
|
253
|
+
expect(spyTrackPageView).toBeCalledTimes(2);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
fetchMock.post(
|
|
257
|
+
`${configMock.server}/${API_EXPERIMENTS_URL}`,
|
|
258
|
+
{
|
|
259
|
+
status: 200,
|
|
260
|
+
body: After15DaysIsUserIncludedResponse,
|
|
261
|
+
headers: {
|
|
262
|
+
Accept: 'application/json',
|
|
263
|
+
'Content-Type': 'application/json'
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
{ overwriteRoutes: true }
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
// Third try, after 15 days
|
|
270
|
+
const location = { ...LocationMock, href: 'http://localhost/destinations' };
|
|
271
|
+
|
|
272
|
+
jest.spyOn(Date, 'now').mockImplementation(
|
|
273
|
+
() => MOCK_CURRENT_TIMESTAMP + TIME_15_DAYS_MILLISECONDS
|
|
274
|
+
);
|
|
275
|
+
await instance.locationChanged(location).then(() => {
|
|
276
|
+
// get the experiments stored in the indexDB
|
|
277
|
+
const experiments = instance.experiments;
|
|
278
|
+
|
|
279
|
+
expect(experiments.length).toBe(1);
|
|
280
|
+
expect(experiments).toEqual(MockDataStoredIndexDBWithNew15DaysLater);
|
|
281
|
+
expect(spyTrackPageView).toBeCalledTimes(3);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
});
|