Skip to content

Commit 0f7eca5

Browse files
authored
Use newer TestTrack APIs (#98)
This PR migrates from TestTrack's v1 APIs to the v4 APIs, which require app versioning information. This allows TestTrack to serve split configurations specific to each build of your application. ## `load` Fetches visitor config from the server to create a `TestTrack` instance. ```ts import { load, createCookieStorage } from '@betterment-oss/test-track'; const testTrack = await load({ client: { url: 'https://testtrack.example.com', appName: 'my-app', appVersion: '0.0.0', buildTimestamp: import.meta.env.TT_BUILD_TIMESTAMP }, storage: createCookieStorage({ name: 'tt_visitor_id', domain: '.example.com' }) }); ``` ## `create` Creates a `TestTrack` instance with preloaded data (e.g., server-rendered). ```ts import { create, createCookieStorage } from '@betterment-oss/test-track'; const testTrack = create({ client: { url: 'https://testtrack.example.com', appName: 'my-app', appVersion: '0.0.0', buildTimestamp: import.meta.env.TT_BUILD_TIMESTAMP }, storage: createCookieStorage({ name: 'tt_visitor_id', domain: '.example.com' }), visitorConfig: { visitor: { id: '...', assignments: [...] }, splits: [...] } }); ``` ## `initialize` (Deprecated) Uses `window.TT` for configuration. Use `load` or `create` instead. ```ts import { initialize } from '@betterment-oss/test-track'; const testTrack = initialize({ client: { appName: 'my-app', appVersion: '0.0.0', buildTimestamp: import.meta.env.TT_BUILD_TIMESTAMP } }); ``` **Breaking changes:** - Now requires a `client` option with `appName`, `appVersion`, and `buildTimestamp` - Now synchronous (returns `TestTrack` instead of `Promise<TestTrack>`) ## API Endpoints - `GET /api/v4/apps/:app/versions/:version/builds/:build/visitors/:id/config` replaces `GET /api/v1/visitors/:id` - `POST /api/v4/apps/:app/versions/:version/builds/:build/identifier` replaces `POST /api/v1/identifier` - `POST /api/v2/visitors/:id/assignment_overrides` replaces `POST /api/v1/assignment_override` - Request bodies are now JSON instead of form-encoded. This means POST requests to the Test Track API will run a preflight request. See: Betterment/testtrack-cli#69. ## Type Changes - `logIn` and `signUp` now accept `value` as a `string` instead of `number` ## Vite Plugin Provides `import.meta.env.TT_BUILD_TIMESTAMP`. ```ts import { testTrackPlugin } from '@betterment-oss/test-track/vite'; export default defineConfig({ plugins: [testTrackPlugin()] }); ```
1 parent 244722a commit 0f7eca5

29 files changed

Lines changed: 665 additions & 384 deletions

README.md

Lines changed: 122 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,94 @@ pnpm add @betterment-oss/test-track
1818

1919
You can find the latest version of the test track JS client [here](https://github.com/Betterment/test_track_js_client/releases).
2020

21+
## Setup
22+
23+
There are two ways to set up TestTrack:
24+
25+
### `load(options)`
26+
27+
Fetches visitor configuration from the TestTrack server and creates a TestTrack instance. This is the recommended approach for most use cases.
28+
2129
```javascript
22-
import { initialize } from '@betterment-oss/test-track';
30+
import { load, createCookieStorage } from '@betterment-oss/test-track';
31+
32+
const testTrack = await load({
33+
client: {
34+
url: 'https://testtrack.example.com',
35+
appName: 'my_app',
36+
appVersion: '1.0.0',
37+
buildTimestamp: '2024-01-01T00:00:00Z'
38+
},
39+
storage: createCookieStorage({
40+
domain: '.example.com'
41+
})
42+
});
43+
```
44+
45+
**Parameters:**
46+
47+
- `client.url` - The URL of your TestTrack server
48+
- `client.appName` - Your application name
49+
- `client.appVersion` - Your application version
50+
- `client.buildTimestamp` - The build timestamp (ISO 8601 format)
51+
- `storage` - A storage provider for persisting visitor IDs (e.g. `createCookieStorage()`)
52+
- `analytics` (optional) - An analytics provider for tracking assignments (see [Advanced Configuration](#advanced-configuration))
53+
- `errorLogger` (optional) - A function for logging errors
2354

24-
const testTrack = await initialize();
55+
### `create(options)`
56+
57+
Creates a TestTrack instance with preloaded visitor configuration. Use this when you have visitor configuration data available (e.g., from server-side rendering or a cached response).
58+
59+
```javascript
60+
import { create, createCookieStorage } from '@betterment-oss/test-track';
61+
62+
const testTrack = create({
63+
client: {
64+
url: 'https://testtrack.example.com',
65+
appName: 'my_app',
66+
appVersion: '1.0.0',
67+
buildTimestamp: '2024-01-01T00:00:00Z'
68+
},
69+
storage: createCookieStorage({
70+
domain: '.example.com'
71+
}),
72+
visitorConfig: {
73+
splits: [
74+
{
75+
name: 'button_color',
76+
variants: [
77+
{ name: 'blue', weight: 50 },
78+
{ name: 'red', weight: 50 }
79+
],
80+
feature_gate: false
81+
}
82+
],
83+
visitor: {
84+
id: 'visitor-uuid',
85+
assignments: [{ split_name: 'button_color', variant: 'blue' }]
86+
},
87+
experience_sampling_weight: 1
88+
}
89+
});
2590
```
2691

27-
## Configuration
92+
**Parameters:**
2893

29-
Before using the client you must call `initialize()`. This method also takes some optional [configuration parameters](#advanced-configuration), if you fancy.
94+
- Same as `load()`, plus:
95+
- `visitorConfig` - Preloaded visitor configuration data from the TestTrack API
96+
97+
## Configuration
3098

3199
### API
32100

101+
#### `.visitorId`
102+
103+
Returns the current visitor's unique identifier as a string. This ID is persisted in the storage provider and used to maintain consistent split assignments across sessions.
104+
105+
```js
106+
console.log(testTrack.visitorId); // "abc123-def456-..."
107+
```
108+
33109
#### `.vary(split_name, options)`
34110

35111
The `vary` method is used to perform a split. It takes 2 arguments and returns the assigned variant as a string.
@@ -90,57 +166,80 @@ The `ab` method is used exclusively for two-way splits and feature toggles. It t
90166
The `logIn` method is used to ensure a consistent experience across devices. For instance, when a user logs in to your app on a new device, you should also log the user into Test Track in order to grab their existing split assignments instead of treating them like a new visitor. It takes 2 arguments.
91167

92168
- `identifier` -- The first argument is the name of the identifier. This will be a snake_case string, e.g. `"myapp_user_id"`.
93-
- `value` -- The second argument is a primitive value, e.g. `12345`, `"abcd"`
169+
- `value` -- The second argument is a string value, e.g. `"12345"`, `"abcd"`
94170

95171
```js
96-
await testTrack.logIn('myapp_user_id', 12345);
172+
await testTrack.logIn('myapp_user_id', '12345');
97173
// From this point on you have existing split assignments from a previous device.
98174
```
99175

100176
## Advanced Configuration
101177

102-
When you call `initialize()` you can optionally pass in an analytics object and an error logger. For example:
178+
When you call `load()` or `create()` you can optionally pass in an analytics object and an error logger. For example:
103179

104180
```js
105-
const testTrack = await initialize({
181+
const testTrack = await load({
182+
client: {
183+
url: 'https://testtrack.example.com',
184+
appName: 'my_app',
185+
appVersion: '1.0.0',
186+
buildTimestamp: '2024-01-01T00:00:00Z'
187+
},
188+
storage: createCookieStorage({ domain: '.example.com' }),
106189
analytics: {
107-
trackAssignment: function (visitorId, assignment, callback) {
108-
var props = {
190+
trackAssignment: (visitorId, assignment) => {
191+
const props = {
109192
SplitName: assignment.splitName,
110193
SplitVariant: assignment.variant,
111194
SplitContext: assignment.context
112195
};
113196

114-
remoteAnalyticsService.track('SplitAssigned', props, callback);
197+
remoteAnalyticsService.track('SplitAssigned', props);
115198
},
116-
identify: function (visitorId) {
199+
identify: visitorId => {
117200
remoteAnalyticsService.identify(visitorId);
118201
},
119-
alias: function (visitorId) {
202+
alias: visitorId => {
120203
remoteAnalyticsService.alias(visitorId);
121204
}
122205
},
123-
errorLogger: function (message) {
206+
errorLogger: message => {
124207
RemoteLoggingService.log(message); // logs remotely so that you can be alerted to any misconfigured splits
125208
}
126209
});
127210
```
128211

212+
## Vite Plugin
213+
214+
The `@betterment-oss/test-track` package includes a Vite plugin that automatically defines `import.meta.env.TT_BUILD_TIMESTAMP`, which can be used to configure Test Track.
215+
216+
```ts
217+
import { defineConfig } from 'vite';
218+
import { testTrackPlugin } from '@betterment-oss/test-track/vite';
219+
220+
export default defineConfig({
221+
plugins: [testTrackPlugin()]
222+
});
223+
```
224+
129225
## Using TestTrack without a build tool
130226

131-
The `@betterment-oss/test-track` package is distributed as an ES module. The package also provides `dist/index.iffe.js`. This artifact includes all dependencies and can be used directly in the browser.
227+
The `@betterment-oss/test-track` package is distributed as an ES module. The package also provides `dist/index.iife.js`. This artifact includes all dependencies and can be used directly in the browser.
132228

133229
```html
134-
<script>
135-
window.TT = btoa(
136-
JSON.stringify({
137-
/* Config */
138-
})
139-
);
140-
</script>
141230
<script src="/path/to/index.iife.js"></script>
142231
<script type="module">
143-
const testTrack = await TestTrack.initialize();
232+
const testTrack = await TestTrack.load({
233+
client: {
234+
url: 'https://testtrack.example.com',
235+
appName: 'my_app',
236+
appVersion: '1.0.0',
237+
buildTimestamp: '2024-01-01T00:00:00Z'
238+
},
239+
storage: TestTrack.createCookieStorage({
240+
domain: '.example.com'
241+
})
242+
});
144243
// Use testTrack.vary(), testTrack.ab(), etc.
145244
</script>
146245
```

demo.html

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,14 @@
6363
}
6464

6565
// Initialize
66-
const testTrack = await TestTrack.initialize();
66+
const testTrack = await TestTrack.initialize({
67+
client: {
68+
appName: 'TestTrack',
69+
appVersion: '0.0.0',
70+
buildTimestamp: '1970-01-01T00:00:00Z'
71+
}
72+
});
73+
6774
const visitorId = testTrack.visitorId;
6875
visitorIdEl.textContent = visitorId;
6976
await runSplits();

package.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
".": {
99
"types": "./dist/index.d.ts",
1010
"default": "./dist/index.js"
11+
},
12+
"./vite": {
13+
"types": "./dist/vite.d.ts",
14+
"default": "./dist/vite.js"
1115
}
1216
},
1317
"scripts": {
@@ -39,6 +43,14 @@
3943
"js-md5": "^0.8.3",
4044
"uuid": "^13.0.0"
4145
},
46+
"peerDependencies": {
47+
"vite": ">=7.0.0"
48+
},
49+
"peerDependenciesMeta": {
50+
"vite": {
51+
"optional": true
52+
}
53+
},
4254
"files": [
4355
"dist/*"
4456
],

pnpm-lock.yaml

Lines changed: 4 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/api.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { create, initialize, load } from './api';
2+
import type { StorageProvider } from './storageProvider';
3+
import type { ClientConfig, V4VisitorConfig } from './client';
4+
import { v4 as uuid } from 'uuid';
5+
import { server } from './setupTests';
6+
import { http, HttpResponse } from 'msw';
7+
8+
vi.mock('uuid');
9+
10+
const buildURL = 'http://testtrack.dev/api/v4/apps/test_app/versions/1.0.0/builds/2019-04-16T14:35:30Z';
11+
const clientConfig: ClientConfig = {
12+
url: 'http://testtrack.dev',
13+
appName: 'test_app',
14+
appVersion: '1.0.0',
15+
buildTimestamp: '2019-04-16T14:35:30Z'
16+
};
17+
18+
const storage: StorageProvider = {
19+
getVisitorId: vi.fn(),
20+
setVisitorId: vi.fn()
21+
};
22+
23+
const buildVisitorConfig = (visitorId: string): V4VisitorConfig => ({
24+
splits: [
25+
{
26+
name: 'jabba',
27+
variants: [
28+
{ name: 'cgi', weight: 50 },
29+
{ name: 'puppet', weight: 50 }
30+
],
31+
feature_gate: true
32+
}
33+
],
34+
visitor: {
35+
id: visitorId,
36+
assignments: [{ split_name: 'jabba', variant: 'puppet' }]
37+
},
38+
experience_sampling_weight: 1
39+
});
40+
41+
describe('load', () => {
42+
beforeEach(() => {
43+
server.use(
44+
http.get(`${buildURL}/visitors/:visitorId/config`, ({ params }) => {
45+
return HttpResponse.json(buildVisitorConfig(params.visitorId as string));
46+
})
47+
);
48+
});
49+
50+
it('reads the visitor id from storage and sets it back', async () => {
51+
vi.mocked(storage.getVisitorId).mockReturnValue('existing_visitor_id');
52+
53+
const testTrack = await load({ client: clientConfig, storage });
54+
expect(testTrack.visitorId).toEqual('existing_visitor_id');
55+
expect(testTrack.assignments).toEqual([{ splitName: 'jabba', variant: 'puppet', context: null }]);
56+
57+
expect(storage.getVisitorId).toHaveBeenCalledTimes(1);
58+
expect(storage.setVisitorId).toHaveBeenCalledWith('existing_visitor_id');
59+
});
60+
61+
it('generates and saves a visitor id when none exists', async () => {
62+
// @ts-expect-error uuid mock return type
63+
vi.mocked(uuid).mockReturnValue('generated_visitor_id');
64+
vi.mocked(storage.getVisitorId).mockReturnValue(undefined);
65+
66+
const testTrack = await load({ client: clientConfig, storage });
67+
expect(testTrack.visitorId).toEqual('generated_visitor_id');
68+
expect(testTrack.assignments).toEqual([{ splitName: 'jabba', variant: 'puppet', context: null }]);
69+
70+
expect(storage.getVisitorId).toHaveBeenCalledTimes(1);
71+
expect(storage.setVisitorId).toHaveBeenCalledWith('generated_visitor_id');
72+
});
73+
});
74+
75+
describe('create', () => {
76+
it('allows visitorConfig to be provided', () => {
77+
const visitorConfig = buildVisitorConfig('existing_visitor_id');
78+
const testTrack = create({ client: clientConfig, storage, visitorConfig });
79+
80+
expect(testTrack.visitorId).toEqual('existing_visitor_id');
81+
expect(testTrack.assignments).toEqual([{ splitName: 'jabba', variant: 'puppet', context: null }]);
82+
});
83+
});
84+
85+
describe('initialize', () => {
86+
it('creates TestTrack from window.TT config', () => {
87+
const config = {
88+
url: 'http://testtrack.dev',
89+
cookieDomain: '.example.com',
90+
experienceSamplingWeight: 1,
91+
assignments: { jabba: 'puppet' }
92+
};
93+
94+
// @ts-expect-error uuid mock return type
95+
vi.mocked(uuid).mockReturnValue('generated_visitor_id');
96+
97+
window.TT = btoa(JSON.stringify(config));
98+
99+
const testTrack = initialize({
100+
client: { appName: 'test_app', appVersion: '1.0.0', buildTimestamp: '2019-04-16T14:35:30Z' }
101+
});
102+
103+
expect(testTrack.visitorId).toEqual('generated_visitor_id');
104+
expect(testTrack.assignments).toEqual([{ splitName: 'jabba', variant: 'puppet', context: null }]);
105+
});
106+
});

0 commit comments

Comments
 (0)