diff --git a/.gitignore b/.gitignore index 710fa5a..a659a0a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,6 @@ src/js/* !src/js/__tests__ types js -.claude/worktrees +.claude/ +.DS_Store src/version.ts diff --git a/.prettierignore b/.prettierignore index 22b4251..92641fe 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,5 +2,7 @@ node_modules coverage dist es +js lib +types package-lock.json diff --git a/README.md b/README.md index 043607c..013ce08 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,54 @@ -# A/B Smartly SDK [](https://badge.fury.io/js/%40absmartly%2Fjavascript-sdk) +# ABsmartly JavaScript SDK [](https://badge.fury.io/js/%40absmartly%2Fjavascript-sdk) -A/B Smartly - JavaScript SDK +A/B Smartly - JavaScript SDK. This is the official isomorphic JavaScript SDK for the [A/B Smartly](https://www.absmartly.com/) A/B testing platform, compatible with both Node.js and browser environments. ## Compatibility -The A/B Smartly Javascript SDK is an isomorphic library for Node.js (CommonJS and ES6) and browsers (UMD). +The A/B Smartly JavaScript SDK is an isomorphic library for Node.js (CommonJS and ES6) and browsers (UMD). -It's supported on Node.js version 6.x and npm 3.x or later. +- **Node.js**: Version 6.x and npm 3.x or later +- **Browsers**: IE 10+ and all modern browsers (Chrome, Firefox, Safari, Edge) -It's supported on IE 10+ and all the other major browsers. - -**Note**: IE 10 does not natively support Promises. -If you target IE 10, you must include a polyfill like [es6-promise](https://www.npmjs.com/package/es6-promise) or [rsvp](https://www.npmjs.com/package/rsvp). +**Note**: IE 10 does not natively support Promises. If you target IE 10, you must include a polyfill like [es6-promise](https://www.npmjs.com/package/es6-promise) or [rsvp](https://www.npmjs.com/package/rsvp). ## Installation -#### npm +### npm ```shell npm install @absmartly/javascript-sdk --save ``` -#### Import in your Javascript application +### Import in your JavaScript application + ```javascript -const absmartly = require('@absmartly/javascript-sdk'); +const absmartly = require("@absmartly/javascript-sdk"); + // OR with ES6 modules: -import absmartly from '@absmartly/javascript-sdk'; +import absmartly from "@absmartly/javascript-sdk"; ``` +### Directly in the browser -#### Directly in the browser You can include an optimized and pre-built package directly in your HTML code through [unpkg.com](https://www.unpkg.com). Simply add the following code to your `head` section to include the latest published version. + ```html - + ``` ## Getting Started -Please follow the [installation](#installation) instructions before trying the following code: +Please follow the [installation](#installation) instructions before trying the following code. + +### Initialization + +This example assumes an API Key, an Application, and an Environment have been created in the A/B Smartly web console. -#### Initialization -This example assumes an Api Key, an Application, and an Environment have been created in the A/B Smartly web console. ```javascript -// somewhere in your application initialization code const sdk = new absmartly.SDK({ - endpoint: 'https://sandbox.absmartly.io/v1', + endpoint: "https://your-company.absmartly.io/v1", apiKey: process.env.ABSMARTLY_API_KEY, environment: process.env.NODE_ENV, application: process.env.APPLICATION_NAME, @@ -63,90 +65,123 @@ const sdk = new absmartly.SDK({ }); ``` +**SDK Options** + +| Option | Type | Required? | Default | Description | +| :----------- | :--------- | :-------: | :-----: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| endpoint | `string` | ✅ | `null` | The URL to your API endpoint. Most commonly `"your-company.absmartly.io"` | +| apiKey | `string` | ✅ | `null` | Your API key which can be found on the Web Console. | +| environment | `string` | ✅ | `null` | The environment of the platform where the SDK is installed. Environments are created on the Web Console and should match the available environments in your infrastructure. | +| application | `string` | ✅ | `null` | The name of the application where the SDK is installed. Applications are created on the Web Console and should match the applications where your experiments will be running.| +| retries | `number` | ❌ | `5` | Number of retry attempts for failed HTTP requests. | +| timeout | `number` | ❌ | `3000` | HTTP request timeout in milliseconds. | +| eventLogger | `function` | ❌ | `null` | Callback to handle SDK events (ready, exposure, goal, etc.) | + #### Creating a new Context with raw promises ```javascript -// define a new context request const request = { units: { - session_id: '5ebf06d8cb5d8137290c4abb64155584fbdb64d8', + session_id: "5ebf06d8cb5d8137290c4abb64155584fbdb64d8", }, }; -// create context with raw promises const context = sdk.createContext(request); -context.ready().then((response) => { - console.log("ABSmartly Context ready!") -}).catch((error) => { - console.log(error); +context.ready().then(() => { + console.log("ABsmartly Context ready!"); + if (context.isFailed()) { + console.error("ABsmartly Context failed:", context.readyError()); + } + + const treatment = context.treatment("exp_test"); }); ``` -#### Creating a new Context with async/await +### With async/await + ```javascript -// define a new context request const request = { units: { - session_id: '5ebf06d8cb5d8137290c4abb64155584fbdb64d8', + session_id: "5ebf06d8cb5d8137290c4abb64155584fbdb64d8", }, }; -// create context with raw promises const context = sdk.createContext(request); -try { - await context.ready(); - console.log("ABSmartly Context ready!") -} catch (error) { - console.log(error); +await context.ready(); +if (context.isFailed()) { + console.error("ABsmartly Context failed:", context.readyError()); } + +const treatment = context.treatment("exp_test"); ``` -#### Creating a new Context with pre-fetched data -When doing full-stack experimentation with A/B Smartly, we recommend creating a context only once on the server-side. -Creating a context involves a round-trip to the A/B Smartly event collector. -We can avoid repeating the round-trip on the client-side by sending the server-side data embedded in the first document, for example, by rendering it on the template. -Then we can initialize the A/B Smartly context on the client-side directly with it. +### With Pre-fetched Data + +When doing full-stack experimentation with A/B Smartly, we recommend creating a context only once on the server-side. Creating a context involves a round-trip to the A/B Smartly event collector. We can avoid repeating the round-trip on the client-side by sending the server-side data embedded in the first document, for example, by rendering it on the template. Then we can initialize the A/B Smartly context on the client-side directly with it. ```html -
- - + + + ``` -#### Setting extra units for a context -You can add additional units to a context by calling the `unit()` or the `units()` method. -This method may be used for example, when a user logs in to your application, and you want to use the new unit type to the context. -Please note that **you cannot override an already set unit type** as that would be a change of identity, and will throw an exception. In this case, you must create a new context instead. -The `unit()` and `units()` methods can be called before the context is ready. +### Refreshing the Context with Fresh Experiment Data + +For long-running single-page-applications (SPA), the context is usually created once when the application is first reached. However, any experiments being tracked in your production code, but started after the context was created, will not be triggered. To mitigate this, we can use the `refreshPeriod` option when creating the context. ```javascript -context.unit('db_user_id', 1000013); +const request = { + units: { + session_id: "5ebf06d8cb5d8137290c4abb64155584fbdb64d8", + }, +}; -// or -context.units({ - db_user_id: 1000013, +const context = sdk.createContext(request, { + refreshPeriod: 5 * 60 * 1000, // 5 minutes }); ``` -#### Setting context attributes -The `attribute()` and `attributes()` methods can be called before the context is ready. +Alternatively, the `refresh()` method can be called manually. The `refresh()` method pulls updated experiment data from the A/B Smartly collector and will trigger recently started experiments when `treatment()` is called again. + ```javascript -context.attribute('user_agent', navigator.userAgent); +setTimeout(async () => { + try { + await context.refresh(); + } catch (error) { + console.error(error); + } +}, 5 * 60 * 1000); +``` -context.attributes({ - customer_age: 'new_customer', +### Setting Extra Units + +You can add additional units to a context by calling the `unit()` or the `units()` method. This is useful when a user logs in to your application and you want to add the new unit type to the context. + +> **Note:** You cannot override an already set unit type as that would be a change of identity. In this case, you must create a new context instead. + +The `unit()` and `units()` methods can be called before the context is ready. + +```javascript +context.unit("db_user_id", 1000013); + +context.units({ + db_user_id: 1000013, }); ``` +## Basic Usage + +### Selecting a Treatment + #### Including system attributes You can opt in to automatically include system attributes (SDK name, SDK version, application, environment, and application version) in every publish payload. These are sent as context attributes and can be useful for debugging and filtering in the Web Console. @@ -171,20 +206,89 @@ These system attributes are prepended before any user-defined attributes. #### Selecting a treatment ```javascript -if (context.treament("exp_test_experiment") == 0) { +if (context.treatment("exp_test_experiment") === 0) { // user is in control group (variant 0) } else { // user is in treatment group } ``` -#### Tracking a goal achievement +### Treatment Variables + +Variables allow you to configure experiment variations without code changes. + +```javascript +const defaultButtonColor = "red"; +const buttonColor = context.variableValue("button.color", defaultButtonColor); +``` + +### Peek at Treatment Variants + +Although generally not recommended, it is sometimes necessary to peek at a treatment without triggering an exposure. The A/B Smartly SDK provides a `peek()` method for that. + +```javascript +if (context.peek("exp_test_experiment") === 0) { + // user is in control group (variant 0) +} else { + // user is in treatment group +} +``` + +#### Peeking at Variable Values + +```javascript +const buttonColor = context.peekVariableValue("button.color", "red"); +``` + +### Overriding Treatment Variants + +During development, it is useful to force a treatment for an experiment. This can be achieved with the `override()` and/or `overrides()` methods. The `override()` and `overrides()` methods can be called before the context is ready. + +```javascript +context.override("exp_test_experiment", 1); // force variant 1 + +context.overrides({ + exp_test_experiment: 1, + exp_another_experiment: 0, +}); +``` + +## Advanced + +### Context Attributes + +Attributes are used to pass meta-data about the user and/or the request. They can be used later in the Web Console to create segments or audiences. They can be set using the `attribute()` or `attributes()` methods, before or after the context is ready. + +```javascript +context.attribute("user_agent", navigator.userAgent); + +context.attributes({ + customer_age: "new_customer", +}); +``` + +### Custom Assignments + +Sometimes it may be necessary to override the automatic selection of a variant. For example, if you wish to have your variant chosen based on data from an API call. This can be accomplished using the `customAssignment()` method. + +```javascript +context.customAssignment("exp_test_experiment", 1); + +context.customAssignments({ + exp_test_experiment: 1, +}); +``` + +### Tracking Goals + Goals are created in the A/B Smartly web console. + ```javascript context.track("payment", { item_count: 1, total_amount: 1999.99 }); ``` -#### Publishing pending data +### Publishing Pending Data + Sometimes it is necessary to ensure all events have been published to the A/B Smartly collector, before proceeding. One such case is when the user is about to navigate away right before being exposed to a treatment. You can explicitly call the `publish()` method, which returns a promise, before navigating away. @@ -219,88 +323,55 @@ const context = sdk.createContext(request, { }); ``` -Alternatively, the `refresh()` method can be called manually. -The `refresh()` method pulls updated experiment data from the A/B Smartly collector and will trigger recently started experiments when `treatment()` is called again. -```javascript -setTimeout(async () => { - try { - context.refresh(); - } catch(error) { - console.error(error); - } -}, 5 * 60 * 1000); -``` +### Using a Custom Event Logger + +The A/B Smartly SDK can be instantiated with an event logger used for all contexts. In addition, an event logger can be specified when creating a particular context, in the `createContext` call options. The example below illustrates this with the implementation of the default event logger, used if none is specified. -#### Using a custom Event Logger -The A/B Smartly SDK can be instantiated with an event logger used for all contexts. -In addition, an event logger can be specified when creating a particular context, in the `createContext` call options. -The example below illustrates this with the implementation of the default event logger, used if none is specified. ```javascript const sdk = new absmartly.SDK({ - endpoint: 'https://sandbox-api.absmartly.com/v1', + endpoint: "https://your-company.absmartly.io/v1", apiKey: process.env.ABSMARTLY_API_KEY, environment: process.env.NODE_ENV, application: process.env.APPLICATION_NAME, eventLogger: (context, eventName, data) => { - if (eventName == "error") { + if (eventName === "error") { console.error(data); } }, }); ``` -The data parameter depends on the type of event. -Currently, the SDK logs the following events: +**Event Types** -| eventName | when | data | -|:---: |---|---| -| `"error"` | `Context` receives an error | error object thrown | -| `"ready"` | `Context` turns ready | data used to initialize the context | -| `"refresh"` | `Context.refresh()` method succeeds | data used to refresh the context | -| `"publish"` | `Context.publish()` method succeeds | data sent to the A/B Smartly event collector | -| `"exposure"` | `Context.treatment()` method succeeds on first exposure | exposure data enqueued for publishing | -| `"goal"` | `Context.track()` method succeeds | goal data enqueued for publishing | -| `"finalize"` | `Context.finalize()` method succeeds the first time | undefined | +The data parameter depends on the type of event. Currently, the SDK logs the following events: +| Event | When | Data | +| :----------- | :------------------------------------------------ | :-------------------------------------------- | +| `"error"` | Context receives an error | Error object thrown | +| `"ready"` | Context turns ready | Data used to initialize the context | +| `"refresh"` | `refresh()` method succeeds | Data used to refresh the context | +| `"publish"` | `publish()` method succeeds | Data sent to the A/B Smartly event collector | +| `"exposure"` | `treatment()` method succeeds on first exposure | Exposure data enqueued for publishing | +| `"goal"` | `track()` method succeeds | Goal data enqueued for publishing | +| `"finalize"` | `finalize()` method succeeds the first time | undefined | -#### Peek at treatment variants -Although generally not recommended, it is sometimes necessary to peek at a treatment without triggering an exposure. -The A/B Smartly SDK provides a `peek()` method for that. - -```javascript -if (context.peek("exp_test_experiment") == 0) { - // user is in control group (variant 0) -} else { - // user is in treatment group -} -``` - -#### Overriding treatment variants -During development, for example, it is useful to force a treatment for an experiment. This can be achieved with the `override()` and/or `overrides()` methods. -The `override()` and `overrides()` methods can be called before the context is ready. -```javascript - context.override("exp_test_experiment", 1); // force variant 1 of treatment - context.overrides({ - exp_test_experiment: 1, - exp_another_experiment: 0, - }); -``` +### HTTP Request Timeout -#### HTTP request timeout -It is possible to set a timeout per individual HTTP request, overriding the global timeout set for all request when instantiating the SDK object. +It is possible to set a timeout per individual HTTP request, overriding the global timeout set for all requests when instantiating the SDK object. -Here is an example of setting a timeout only for the createContext request. +Here is an example of setting a timeout only for the `createContext` request. ```javascript const context = sdk.createContext(request, { refreshPeriod: 5 * 60 * 1000 }, { - timeout: 1500 + timeout: 1500, }); ``` -#### HTTP Request cancellation -Sometimes it is useful to cancel an inflight HTTP request, for example, when the user is navigating away. The A/B Smartly SDK also supports a cancellation via an `AbortSignal`. An implementation of AbortController is provided for older platforms, but will use the native implementation where available. +### HTTP Request Cancellation + +Sometimes it is useful to cancel an inflight HTTP request, for example, when the user is navigating away. The A/B Smartly SDK supports cancellation via an `AbortSignal`. An implementation of AbortController is provided for older platforms, but will use the native implementation where available. Here is an example of a cancellation scenario. @@ -309,7 +380,7 @@ const controller = new absmartly.AbortController(); const context = sdk.createContext(request, { refreshPeriod: 5 * 60 * 1000 }, { - signal: controller.signal + signal: controller.signal, }); // abort request if not ready after 1500ms @@ -320,14 +391,168 @@ await context.ready(); clearTimeout(timeoutId); ``` +## Node.js Usage + +### Express.js Middleware Example + +```javascript +const absmartly = require("@absmartly/javascript-sdk"); + +const sdk = new absmartly.SDK({ + endpoint: "https://your-company.absmartly.io/v1", + apiKey: process.env.ABSMARTLY_API_KEY, + environment: "production", + application: "website", +}); + +app.use(async (req, res, next) => { + const context = sdk.createContext({ + units: { + session_id: req.cookies.session_id, + }, + }); + + await context.ready(); + if (context.isFailed()) { + console.error("ABsmartly context failed:", context.readyError()); + } + req.absmartly = context; + next(); +}); + +app.get("/landing", (req, res) => { + const context = req.absmartly; + const treatment = context.treatment("exp_landing_page"); + + if (treatment === 0) { + res.render("landing-control"); + } else { + res.render("landing-treatment"); + } +}); +``` + +### Server-Side Rendering (SSR) with Data Forwarding + +Create the context on the server and pass the data to the client to avoid a second round-trip. + +```javascript +app.get("/", async (req, res) => { + const context = sdk.createContext({ + units: { session_id: req.cookies.session_id }, + }); + + await context.ready(); + + const contextData = context.data(); + const treatment = context.treatment("exp_homepage"); + + res.render("index", { + treatment, + absmartlyData: JSON.stringify(contextData), + }); +}); +``` + +On the client side, initialize the context with the pre-fetched data: + +```javascript +const context = sdk.createContextWith( + { units: { session_id: sessionId } }, + JSON.parse(window.__ABSMARTLY_DATA__) +); +// context is immediately ready, no round-trip needed +``` + +## Browser Usage + +### Single-Page Application (SPA) Example + +```javascript +import absmartly from "@absmartly/javascript-sdk"; + +const sdk = new absmartly.SDK({ + endpoint: "https://your-company.absmartly.io/v1", + apiKey: "YOUR_API_KEY", + environment: "production", + application: "website", + eventLogger: (context, eventName, data) => { + if (eventName === "exposure") { + analytics.track("Experiment Viewed", { + experiment: data.name, + variant: data.variant, + }); + } + }, +}); + +const context = sdk.createContext({ + units: { + session_id: getUserSessionId(), + }, +}); + +await context.ready(); + +const showNewFeature = context.treatment("exp_new_feature") !== 0; + +if (showNewFeature) { + renderNewFeature(); +} else { + renderOldFeature(); +} + +document.getElementById("checkout-btn").addEventListener("click", () => { + context.track("checkout", { total: getCartTotal() }); +}); +``` + +## Migration Guide (1.13.x → 1.14.0) + +Version 1.14.0 contains a few breaking changes. Most applications will not need code changes, but review the items below. + +### `ready()` no longer resolves with the Error object on failure + +**Before:** `ready()` resolved with the Error object on failure (e.g., `const result = await context.ready()` would give you the Error). + +**After:** `ready()` always resolves with `true`. It is a "wait for initialization" signal — you should always proceed with experiment code after it settles, even on failure. The SDK returns control variants (`0`) and default values gracefully when the API is down. Use `isFailed()` and `readyError()` to check for errors if needed. + +```javascript +await context.ready(); +if (context.isFailed()) { + console.error("Context failed:", context.readyError()); +} +const variant = context.treatment("exp_test"); // returns 0 (control) on failure +``` + +**When this might be a problem:** If your code used the return value as the Error object (e.g., `const err = await context.ready(); logError(err)`), it will now receive `true` instead. Use `context.readyError()` to access the error instead. + +### Unit IDs containing astral characters now hash to canonical UTF-8 + +**Before:** `stringToUint8Array` (used to hash unit IDs for variant assignment) encoded each UTF-16 code unit independently. A character outside the Basic Multilingual Plane (≥ U+10000, e.g. an emoji) was encoded as an invalid CESU-8 byte sequence rather than canonical UTF-8. + +**After:** Unit IDs are encoded as canonical 4-byte UTF-8, matching the A/B Smartly collector (which hashes with `UTF_8`). + +**When this might be a problem:** A unit ID that contains an astral character (emoji, rare CJK, etc.) may now be assigned a **different variant** than it was in earlier versions. **Unit IDs composed entirely of BMP characters (≤ U+FFFF) — which covers essentially all typical session IDs, UUIDs, and user IDs — are unaffected.** This only changes assignment for units whose IDs contain astral characters. + +### `audienceMismatch` cache invalidation on indeterminate audiences + +When an audience cannot be evaluated to a boolean (a malformed or non-boolean filter), the cached assignment's `audienceMismatch` flag is now left unchanged instead of being reset to `false`. This keeps a previously valid cached assignment from being needlessly invalidated. Assignment results for well-formed audiences are unchanged. ## About A/B Smartly -**A/B Smartly** is the leading provider of state-of-the-art, on-premises, full-stack experimentation platforms for engineering and product teams that want to confidently deploy features as fast as they can develop them. -A/B Smartly's real-time analytics helps engineering and product teams ensure that new features will improve the customer experience without breaking or degrading performance and/or business metrics. + +**A/B Smartly** is the leading provider of state-of-the-art, on-premises, full-stack experimentation platforms for engineering and product teams that want to confidently deploy features as fast as they can develop them. A/B Smartly's real-time analytics helps engineering and product teams ensure that new features will improve the customer experience without breaking or degrading performance and/or business metrics. ### Have a look at our growing list of clients and SDKs: +- [JavaScript SDK](https://www.github.com/absmartly/javascript-sdk) (this package) +- [React SDK](https://www.github.com/absmartly/react-sdk) +- [Vue2 SDK](https://www.github.com/absmartly/vue2-sdk) +- [Vue3 SDK](https://www.github.com/absmartly/vue3-sdk) - [Java SDK](https://www.github.com/absmartly/java-sdk) -- [JavaScript SDK](https://www.github.com/absmartly/javascript-sdk) -- [PHP SDK](https://www.github.com/absmartly/php-sdk) - [Swift SDK](https://www.github.com/absmartly/swift-sdk) -- [Vue2 SDK](https://www.github.com/absmartly/vue2-sdk) +- [Flutter SDK](https://www.github.com/absmartly/flutter-sdk) +- [PHP SDK](https://www.github.com/absmartly/php-sdk) +- [Python3 SDK](https://www.github.com/absmartly/python3-sdk) +- [Go SDK](https://www.github.com/absmartly/go-sdk) +- [Ruby SDK](https://www.github.com/absmartly/ruby-sdk) +- [.NET SDK](https://www.github.com/absmartly/dotnet-sdk) diff --git a/package-lock.json b/package-lock.json index 50528ba..d921a01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@absmartly/javascript-sdk", - "version": "1.14.0-beta.1", + "version": "2.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@absmartly/javascript-sdk", - "version": "1.14.0-beta.1", + "version": "2.0.0", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.29.2", diff --git a/package.json b/package.json index 220053b..4739452 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@absmartly/javascript-sdk", - "version": "1.14.0-beta.1", + "version": "1.14.0", "description": "A/B Smartly Javascript SDK", "homepage": "https://github.com/absmartly/javascript-sdk#README.md", "bugs": "https://github.com/absmartly/javascript-sdk/issues", diff --git a/src/__tests__/abort-controller-shim.test.js b/src/__tests__/abort-controller-shim.test.js index fdc3ee0..aa1680e 100644 --- a/src/__tests__/abort-controller-shim.test.js +++ b/src/__tests__/abort-controller-shim.test.js @@ -88,4 +88,65 @@ describe("AbortController", () => { expect(aborter[Symbol.toStringTag]).toEqual("AbortController"); }); + + describe("signal.reason", () => { + it("should set default reason on abort()", () => { + const controller = new AbortController(); + controller.abort(); + expect(controller.signal.aborted).toBe(true); + expect(controller.signal.reason).toBeInstanceOf(Error); + expect(controller.signal.reason.message).toBe("The operation was aborted."); + }); + + it("should set custom reason on abort(reason)", () => { + const controller = new AbortController(); + const customReason = new Error("custom abort"); + controller.abort(customReason); + expect(controller.signal.reason).toBe(customReason); + }); + + it("should have undefined reason before abort", () => { + const controller = new AbortController(); + expect(controller.signal.reason).toBeUndefined(); + }); + + it("should preserve an explicit null reason instead of falling back to the default error", () => { + // Matches native AbortController: only an omitted/undefined reason + // gets the default error; an explicit null is preserved as-is. + const controller = new AbortController(); + controller.abort(null); + expect(controller.signal.reason).toBeNull(); + }); + + it("should latch the first reason and ignore a second abort() call", () => { + const controller = new AbortController(); + const handler = jest.fn(); + controller.signal.addEventListener("abort", handler); + + controller.abort("first"); + controller.abort("second"); + + expect(controller.signal.reason).toBe("first"); + expect(handler).toHaveBeenCalledTimes(1); + }); + }); + + describe("dispatchEvent onabort handling", () => { + it("should call onabort handler on abort dispatch", () => { + const signal = new AbortSignal(); + const handler = jest.fn(); + signal.onabort = handler; + signal.dispatchEvent({ type: "abort" }); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ type: "abort" }); + }); + + it("should not call onabort for non-abort events", () => { + const signal = new AbortSignal(); + const handler = jest.fn(); + signal.onabort = handler; + signal.dispatchEvent({ type: "other" }); + expect(handler).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/__tests__/client.test.js b/src/__tests__/client.test.js index fca41fd..ca943c4 100644 --- a/src/__tests__/client.test.js +++ b/src/__tests__/client.test.js @@ -687,6 +687,29 @@ describe("Client", () => { }); }); + it("request() should not throw on unmatched surrogates in query parameters", () => { + fetch.mockResolvedValueOnce(responseMock(200, "OK", defaultMockResponse)); + + const client = new Client(clientOptions); + + // A lone UTF-16 surrogate is accepted JavaScript string content but is not + // valid UTF-8; unlike URLSearchParams (which substitutes U+FFFD), + // encodeURIComponent() throws URIError on it directly, so it must be + // normalized to well-formed UTF-16 first. + return client + .request({ + method: "GET", + path: "/context", + query: { application: "\uD800" }, + }) + .then((response) => { + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenLastCalledWith(`${endpoint}/context?application=%EF%BF%BD`, expect.any(Object)); + + expect(response).toEqual(defaultMockResponse); + }); + }); + it("request() should omit query parameters if dict empty", (done) => { fetch.mockResolvedValueOnce(responseMock(200, "OK", defaultMockResponse)); @@ -1188,4 +1211,41 @@ describe("Client", () => { done(); }); }); + + describe("timeout option", () => { + it("should accept an explicit timeout of 0 (nullish coalescing, not falsy)", () => { + const client = new Client({ + endpoint, + agent, + environment, + apiKey, + application, + timeout: 0, + }); + + expect(client).toBeInstanceOf(Client); + }); + + it("should still retry a failing-then-succeeding request when timeout is 0 (no deadline)", (done) => { + fetch + .mockResolvedValueOnce(responseMock(500, "server error", "server error text")) + .mockResolvedValueOnce(responseMock(200, "OK", defaultMockResponse)); + + const client = new Client(Object.assign({}, clientOptions, { timeout: 0, retries: 5 })); + + client + .request({ + method: "GET", + path: "/context", + }) + .then((response) => { + expect(fetch).toHaveBeenCalledTimes(2); + expect(response).toEqual(defaultMockResponse); + + done(); + }); + + advanceFakeTimers(); + }); + }); }); diff --git a/src/__tests__/constructor-options.test.js b/src/__tests__/constructor-options.test.js new file mode 100644 index 0000000..d857835 --- /dev/null +++ b/src/__tests__/constructor-options.test.js @@ -0,0 +1,59 @@ +import SDK from "../sdk"; +import Client from "../client"; + +describe("SDK and Client constructor option merging", () => { + it("should extract client options from SDK options and pass them to the real Client", () => { + const sdk = new SDK({ + agent: "test-agent", + apiKey: "key", + application: "app", + endpoint: "http://localhost", + environment: "test", + timeout: 5000, + }); + + const client = sdk.getClient(); + expect(client).toBeInstanceOf(Client); + expect(client.getAgent()).toBe("test-agent"); + expect(client.getEnvironment()).toBe("test"); + expect(client.getApplication()).toEqual({ name: "app", version: 0 }); + }); + + it("should accept the SDK application option as an object", () => { + const sdk = new SDK({ + agent: "test", + apiKey: "key", + application: { name: "myapp", version: "1.2.3" }, + endpoint: "http://localhost", + environment: "prod", + }); + + expect(sdk.getClient().getApplication()).toEqual({ name: "myapp", version: "1.2.3" }); + }); + + it("should merge provided Client options with the defaults", () => { + const client = new Client({ + endpoint: "http://test", + agent: "custom-agent", + environment: "prod", + apiKey: "key123", + application: "myapp", + timeout: 10000, + }); + + expect(client.getAgent()).toBe("custom-agent"); + expect(client.getEnvironment()).toBe("prod"); + expect(client.getApplication()).toEqual({ name: "myapp", version: 0 }); + }); + + it("should fall back to the default agent when it is omitted", () => { + const client = new Client({ + endpoint: "http://test", + environment: "prod", + apiKey: "key123", + application: "myapp", + }); + + expect(client.getAgent()).toBe("javascript-client"); + }); +}); diff --git a/src/__tests__/context.test.js b/src/__tests__/context.test.js index fc080e5..326e053 100644 --- a/src/__tests__/context.test.js +++ b/src/__tests__/context.test.js @@ -166,7 +166,7 @@ describe("Context", () => { config: '{"card.width":"75%"}', }, ], - audience: "{}", + audience: "", customFieldValues: null, }, { @@ -204,7 +204,7 @@ describe("Context", () => { config: '{"submit.color":"green","submit.shape":"square"}', }, ], - audience: "null", + audience: "", customFieldValues: null, }, { @@ -612,12 +612,12 @@ describe("Context", () => { expect(context.isFinalized()).toEqual(false); expect(() => context.data()).toThrow(); - expect(() => context.treatment("test")).toThrow(); - expect(() => context.peek("test")).toThrow(); - expect(() => context.experiments()).toThrow(); - expect(() => context.variableKeys()).toThrow(); - expect(() => context.variableValue("a", 17)).toThrow(); - expect(() => context.peekVariableValue("a", 17)).toThrow(); + expect(() => context.treatment("test")).toThrow("ABsmartly Context is not yet ready."); + expect(() => context.peek("test")).toThrow("ABsmartly Context is not yet ready."); + expect(() => context.experiments()).toThrow("ABsmartly Context is not yet ready."); + expect(() => context.variableKeys()).toThrow("ABsmartly Context is not yet ready."); + expect(() => context.variableValue("a", 17)).toThrow("ABsmartly Context is not yet ready."); + expect(() => context.peekVariableValue("a", 17)).toThrow("ABsmartly Context is not yet ready."); done(); }); @@ -1074,6 +1074,158 @@ describe("Context", () => { }); }); + it("should clear assignment cache for started experiment", (done) => { + const context = new Context(sdk, contextOptions, contextParams, getContextResponse); + + expect(context.treatment("exp_test_new")).toEqual(0); + expect(context.treatment("not_found")).toEqual(0); + + expect(context.pending()).toEqual(2); + + provider.getContextData.mockReturnValue(Promise.resolve(refreshContextResponse)); + + context.refresh().then(() => { + expect(context.treatment("exp_test_new")).toEqual(expectedVariants["exp_test_new"]); + expect(context.treatment("not_found")).toEqual(0); + + expect(context.pending()).toEqual(3); + + done(); + }); + }); + + it("should clear assignment cache for stopped experiment", (done) => { + const context = new Context(sdk, contextOptions, contextParams, getContextResponse); + + expect(context.treatment("exp_test_abc")).toEqual(expectedVariants["exp_test_abc"]); + expect(context.treatment("not_found")).toEqual(0); + + expect(context.pending()).toEqual(2); + + const refreshWithStoppedExperiment = { + ...getContextResponse, + experiments: getContextResponse.experiments.filter((x) => x.name !== "exp_test_abc"), + }; + + provider.getContextData.mockReturnValue(Promise.resolve(refreshWithStoppedExperiment)); + + context.refresh().then(() => { + expect(context.treatment("exp_test_abc")).toEqual(0); + expect(context.treatment("not_found")).toEqual(0); + + expect(context.pending()).toEqual(3); + + done(); + }); + }); + + it("should clear assignment cache when experiment ID changes", (done) => { + const context = new Context(sdk, contextOptions, contextParams, getContextResponse); + + expect(context.treatment("exp_test_abc")).toEqual(expectedVariants["exp_test_abc"]); + expect(context.treatment("not_found")).toEqual(0); + + expect(context.pending()).toEqual(2); + + const refreshWithChangedId = { + ...getContextResponse, + experiments: getContextResponse.experiments.map((x) => { + if (x.name === "exp_test_abc") { + return { + ...x, + id: 11, + trafficSeedHi: 54870830, + trafficSeedLo: 398724581, + // Chosen so the resulting variant (1) differs from + // expectedVariants["exp_test_abc"] (2): proves the cache was + // actually recomputed with the new seed, not just re-serving a + // stale cached assignment that happens to still be valid. + seedHi: 1, + seedLo: 3, + }; + } + return x; + }), + }; + + provider.getContextData.mockReturnValue(Promise.resolve(refreshWithChangedId)); + + context.refresh().then(() => { + expect(context.treatment("exp_test_abc")).toEqual(1); + expect(context.treatment("not_found")).toEqual(0); + + expect(context.pending()).toEqual(3); + + done(); + }); + }); + + it("should clear assignment cache when full-on changes", (done) => { + const context = new Context(sdk, contextOptions, contextParams, getContextResponse); + + expect(context.treatment("exp_test_abc")).toEqual(expectedVariants["exp_test_abc"]); + expect(context.treatment("not_found")).toEqual(0); + + expect(context.pending()).toEqual(2); + + const refreshWithFullOn = { + ...getContextResponse, + experiments: getContextResponse.experiments.map((x) => { + if (x.name === "exp_test_abc") { + return { + ...x, + fullOnVariant: 1, + }; + } + return x; + }), + }; + + provider.getContextData.mockReturnValue(Promise.resolve(refreshWithFullOn)); + + context.refresh().then(() => { + expect(context.treatment("exp_test_abc")).toEqual(1); + expect(context.treatment("not_found")).toEqual(0); + + expect(context.pending()).toEqual(3); + + done(); + }); + }); + + it("should clear assignment cache when traffic split changes", (done) => { + const context = new Context(sdk, contextOptions, contextParams, getContextResponse); + + expect(context.treatment("exp_test_not_eligible")).toEqual(expectedVariants["exp_test_not_eligible"]); + expect(context.treatment("not_found")).toEqual(0); + + expect(context.pending()).toEqual(2); + + const refreshWithTrafficSplit = { + ...getContextResponse, + experiments: getContextResponse.experiments.map((x) => { + if (x.name === "exp_test_not_eligible") { + return { + ...x, + trafficSplit: [0.0, 1.0], + }; + } + return x; + }), + }; + + provider.getContextData.mockReturnValue(Promise.resolve(refreshWithTrafficSplit)); + + context.refresh().then(() => { + expect(context.treatment("exp_test_not_eligible")).toEqual(2); + expect(context.treatment("not_found")).toEqual(0); + + expect(context.pending()).toEqual(3); + + done(); + }); + }); + it("should throw after finalized() call", (done) => { const context = new Context(sdk, contextOptions, contextParams, getContextResponse); publisher.publish.mockReturnValue(Promise.resolve()); @@ -1372,6 +1524,30 @@ describe("Context", () => { done(); }); + + it("should throw when not ready", (done) => { + const context = new Context(sdk, contextOptions, contextParams, Promise.resolve(getContextResponse)); + expect(context.isReady()).toEqual(false); + + expect(() => context.peek("exp_test_ab")).toThrow("ABsmartly Context is not yet ready."); + + done(); + }); + + it("should throw after finalize", (done) => { + const context = new Context(sdk, contextOptions, contextParams, getContextResponse); + publisher.publish.mockReturnValue(Promise.resolve()); + + context.treatment("exp_test_ab"); + + context.finalize().then(() => { + expect(() => context.peek("exp_test_ab")).toThrow("ABsmartly Context is finalized."); + done(); + }); + + expect(context.isFinalizing()).toEqual(true); + expect(() => context.peek("exp_test_ab")).toThrow("ABsmartly Context is finalizing."); + }); }); describe("treatment()", () => { @@ -1815,13 +1991,13 @@ describe("Context", () => { expect(context.pending()).toEqual(1); context.finalize().then(() => { - expect(() => context.treatment("exp_test_ab")).toThrow(); + expect(() => context.treatment("exp_test_ab")).toThrow("ABsmartly Context is finalized."); done(); }); expect(context.isFinalizing()).toEqual(true); - expect(() => context.treatment("exp_test_ab")).toThrow(); + expect(() => context.treatment("exp_test_ab")).toThrow("ABsmartly Context is finalizing."); }); it("should re-evaluate audience expression when attributes change in strict mode", (done) => { @@ -2021,6 +2197,15 @@ describe("Context", () => { done(); }); + it("should throw when not ready", (done) => { + const context = new Context(sdk, contextOptions, contextParams, Promise.resolve(getContextResponse)); + expect(context.isReady()).toEqual(false); + + expect(() => context.treatment("exp_test_ab")).toThrow("ABsmartly Context is not yet ready."); + + done(); + }); + it("should update attrsSeq after checking unchanged audience to avoid repeated evaluation", (done) => { const context = new Context(sdk, contextOptions, contextParams, audienceStrictContextResponse); @@ -3128,13 +3313,13 @@ describe("Context", () => { expect(context.pending()).toEqual(1); context.finalize().then(() => { - expect(() => context.variableValue("button.color", 17)).toThrow(); + expect(() => context.variableValue("button.color", 17)).toThrow("ABsmartly Context is finalized."); done(); }); expect(context.isFinalizing()).toEqual(true); - expect(() => context.variableValue("button.color", 17)).toThrow(); + expect(() => context.variableValue("button.color", 17)).toThrow("ABsmartly Context is finalizing."); }); }); @@ -3978,6 +4163,46 @@ describe("Context", () => { expect(context.isFinalizing()).toEqual(true); expect(() => context.publish()).toThrow(); }); + + it("should not restore or resend an already-delivered batch when a custom eventLogger throws on the publish success event", (done) => { + const observerError = new Error("eventLogger failure"); + const throwingEventLogger = jest.fn((_, eventName) => { + if (eventName === "publish") { + throw observerError; + } + }); + + const context = new Context( + sdk, + { ...contextOptions, eventLogger: throwingEventLogger }, + contextParams, + getContextResponse + ); + + context.track("goal1", { amount: 125 }); + expect(context.pending()).toEqual(1); + + publisher.publish.mockReturnValue(Promise.resolve()); + + const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + + context.publish().then(() => { + // The batch was already delivered successfully; the observer's throw + // must not be treated as a publish failure that restores the queue + // and resends the batch on the next flush. + expect(context.pending()).toEqual(0); + expect(consoleErrorSpy).toHaveBeenCalledWith(observerError); + + publisher.publish.mockClear(); + + context.publish().then(() => { + expect(publisher.publish).not.toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + done(); + }); + }); + }); }); describe("finalize()", () => { @@ -4185,6 +4410,46 @@ describe("Context", () => { }); }); + it("should still settle finalize() when a custom eventLogger throws while discarding events after failed initialization", (done) => { + // The constructor's own ready-rejection handler also calls the + // eventLogger with "error" — only start throwing after that call, so + // this isolates the discard path inside _flush()/_finalize(). + const throwingEventLogger = jest.fn((_, eventName) => { + if (eventName === "error" && throwingEventLogger.mock.calls.length > 1) { + throw new Error("eventLogger boom on error"); + } + }); + + const context = new Context( + sdk, + { ...contextOptions, eventLogger: throwingEventLogger }, + contextParams, + Promise.reject("bad request error text") + ); + + context.ready().then(() => { + context.treatment("exp_test_ab"); + expect(context.pending()).toEqual(1); + + const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + + // Discarding queued events after a failed init must still settle + // finalize() (and clear the queue) even when the eventLogger throws — + // otherwise `_finalizing` is left referencing a promise that never + // resolves or rejects. + context.finalize().then(() => { + expect(publisher.publish).not.toHaveBeenCalled(); + expect(context.pending()).toEqual(0); + expect(context.isFinalizing()).toEqual(false); + expect(context.isFinalized()).toEqual(true); + expect(consoleErrorSpy).toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + done(); + }); + }); + }); + it("should return current promise when called twice", (done) => { const context = new Context(sdk, contextOptions, contextParams, getContextResponse); @@ -4255,82 +4520,473 @@ describe("Context", () => { done(); }); }); - }); - describe("override()", () => { - it("should be callable before ready()", (done) => { - const context = new Context(sdk, contextOptions, contextParams, Promise.resolve(getContextResponse)); - expect(context.isReady()).toEqual(false); - expect(context.isFailed()).toEqual(false); - expect(context.isFinalized()).toEqual(false); + it("should not finalize while a concurrent publish() is still in flight", (done) => { + jest.useRealTimers(); - context.override("exp_test_ab", 1); - context.overrides({ - exp_test_ab: 2, - exp_test_abc: 2, - not_found: 3, - }); + const context = new Context( + sdk, + { ...contextOptions, publishDelay: -1, refreshPeriod: 0 }, + contextParams, + getContextResponse + ); - context.ready().then(() => { - expect(context.isReady()).toEqual(true); - expect(context.data()).toStrictEqual(getContextResponse); + context.treatment("exp_test_ab"); + expect(context.pending()).toEqual(1); - context.treatment("exp_test_ab"); - context.treatment("exp_test_abc"); + let resolvePublish; + publisher.publish.mockReturnValue( + new Promise((resolve) => { + resolvePublish = resolve; + }) + ); - publisher.publish.mockReturnValue(Promise.resolve()); + const publishPromise = context.publish(); - jest.spyOn(Date, "now").mockImplementation(() => timeOrigin + 100); + // publish() has already reset the internal queue synchronously, so a + // naive pending()-based check would see an empty queue here even though + // the request has not settled yet. + expect(context.pending()).toEqual(0); - context.publish().then(() => { + const finalizePromise = context.finalize(); + + // finalize() must not complete (or mark the context finalized) while the + // in-flight publish it is racing against hasn't settled. + expect(context.isFinalized()).toEqual(false); + expect(context.isFinalizing()).toEqual(true); + + // Give any (incorrect) synchronous finalize path a chance to run before + // resolving the in-flight publish. + Promise.resolve() + .then(() => Promise.resolve()) + .then(() => Promise.resolve()) + .then(() => { + expect(context.isFinalized()).toEqual(false); + expect(publisher.publish).toHaveBeenCalledTimes(1); + + resolvePublish(); + + return Promise.all([publishPromise, finalizePromise]); + }) + .then(() => { + expect(context.isFinalized()).toEqual(true); + expect(context.isFinalizing()).toEqual(false); + expect(context.pending()).toEqual(0); + // finalize() waited on the same in-flight publish instead of + // triggering a second, redundant request. expect(publisher.publish).toHaveBeenCalledTimes(1); - expect(publisher.publish).toHaveBeenCalledWith( - { - publishedAt: 1611141535829, - units: publishUnits, - hashed: true, - sdkVersion: SDK_VERSION, - exposures: [ - { - id: 1, - name: "exp_test_ab", - unit: "session_id", - exposedAt: 1611141535729, - variant: 2, - assigned: false, - eligible: true, - overridden: true, - fullOn: false, - custom: false, - audienceMismatch: false, - ruleOverride: false, - }, - { - id: 2, - name: "exp_test_abc", - unit: "session_id", - exposedAt: 1611141535729, - variant: 2, - assigned: false, - eligible: true, - overridden: true, - fullOn: false, - custom: false, - audienceMismatch: false, - ruleOverride: false, - }, - ], - }, - sdk, - context, - undefined - ); done(); }); - }); }); - }); + + it("should restore the queue and reject when the publisher throws synchronously", (done) => { + const context = new Context( + sdk, + { ...contextOptions, publishDelay: -1, refreshPeriod: 0 }, + contextParams, + getContextResponse + ); + + context.treatment("exp_test_ab"); + expect(context.pending()).toEqual(1); + + const syncError = new Error("synchronous publisher failure"); + publisher.publish.mockImplementation(() => { + throw syncError; + }); + + context.publish().catch((e) => { + expect(e).toBe(syncError); + // The snapshot taken before the (synchronously throwing) publish call + // must be restored so the events are retried on the next flush. + expect(context.pending()).toEqual(1); + + done(); + }); + }); + + it("should not brick finalize() when a custom publisher returns a non-Promise value", (done) => { + const context = new Context( + sdk, + { ...contextOptions, publishDelay: -1, refreshPeriod: 0 }, + contextParams, + getContextResponse + ); + + context.treatment("exp_test_ab"); + expect(context.pending()).toEqual(1); + + // A beacon-style or misimplemented custom publisher that forgets to + // return a promise (e.g. `navigator.sendBeacon`-style success flag). + publisher.publish.mockReturnValue(true); + + context.finalize().then(() => { + expect(context.pending()).toEqual(0); + expect(context.isFinalizing()).toEqual(false); + expect(context.isFinalized()).toEqual(true); + + done(); + }); + }); + + it("should restore a failed batch ahead of events recorded during the in-flight publish, preserving chronological order", (done) => { + const context = new Context( + sdk, + { ...contextOptions, publishDelay: -1, refreshPeriod: 0 }, + contextParams, + getContextResponse + ); + + context.track("old_goal"); + expect(context.pending()).toEqual(1); + + let rejectFirst; + publisher.publish.mockReturnValueOnce( + new Promise((resolve, reject) => { + rejectFirst = reject; + }) + ); + + const firstPublish = context.publish(); + + // Record a newer event while the first publish is still in flight (its + // snapshot was already taken and _goals/_exposures were reset). + context.track("new_goal"); + + rejectFirst(new Error("transport failed")); + + firstPublish.catch((e) => { + expect(e.message).toEqual("transport failed"); + expect(context.pending()).toEqual(2); + + publisher.publish.mockReturnValueOnce(Promise.resolve()); + + context.publish().then(() => { + const retryRequest = publisher.publish.mock.calls[1][0]; + // The restored (older) batch must come before the newer event, not + // after it — otherwise the collector sees goals out of chronological + // order. + expect(retryRequest.goals.map((g) => g.name)).toEqual(["old_goal", "new_goal"]); + + done(); + }); + }); + }); + + it("should clear isFinalizing() and allow a retry after a synchronously throwing publisher", (done) => { + const context = new Context( + sdk, + { ...contextOptions, publishDelay: -1, refreshPeriod: 0 }, + contextParams, + getContextResponse + ); + + context.treatment("exp_test_ab"); + expect(context.pending()).toEqual(1); + + const syncError = new Error("synchronous publisher failure"); + publisher.publish.mockImplementationOnce(() => { + throw syncError; + }); + + context.finalize().catch((e) => { + expect(e).toBe(syncError); + // A `finalize()` callback that fires synchronously (as it does here, + // since the publisher throws before any microtask boundary) must not + // leave isFinalizing() stuck true — otherwise the context can never + // finalize or retry. + expect(context.isFinalizing()).toEqual(false); + expect(context.isFinalized()).toEqual(false); + expect(context.pending()).toEqual(1); + + publisher.publish.mockReturnValue(Promise.resolve()); + + context.finalize().then(() => { + expect(context.isFinalizing()).toEqual(false); + expect(context.isFinalized()).toEqual(true); + expect(context.pending()).toEqual(0); + expect(publisher.publish).toHaveBeenCalledTimes(2); + + done(); + }); + }); + }); + + it("should settle the finalize() promise even when a custom eventLogger throws on the finalize event", (done) => { + const throwingEventLogger = jest.fn((_, eventName) => { + if (eventName === "finalize") { + throw new Error("eventLogger boom on finalize"); + } + }); + + const context = new Context( + sdk, + { ...contextOptions, publishDelay: -1, refreshPeriod: 0, eventLogger: throwingEventLogger }, + contextParams, + getContextResponse + ); + + context.treatment("exp_test_ab"); + expect(context.pending()).toEqual(1); + + publisher.publish.mockReturnValue(Promise.resolve()); + + const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + + // A throwing eventLogger on the "finalize" event must not prevent + // finalize() from resolving, or leave isFinalizing()/isFinalized() stuck. + context.finalize().then(() => { + expect(context.isFinalizing()).toEqual(false); + expect(context.isFinalized()).toEqual(true); + expect(consoleErrorSpy).toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + done(); + }); + }); + + it("should not get stuck when a custom eventLogger throws on the error event during finalize()", (done) => { + const throwingEventLogger = jest.fn((_, eventName) => { + if (eventName === "error") { + throw new Error("eventLogger boom on error"); + } + }); + + const context = new Context( + sdk, + { ...contextOptions, publishDelay: -1, refreshPeriod: 0, eventLogger: throwingEventLogger }, + contextParams, + getContextResponse + ); + + context.treatment("exp_test_ab"); + expect(context.pending()).toEqual(1); + + publisher.publish.mockReturnValue(Promise.reject(new Error("transport failed"))); + + const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + + context.finalize().catch((e) => { + expect(e.message).toEqual("transport failed"); + expect(context.isFinalizing()).toEqual(false); + expect(context.isFinalized()).toEqual(false); + expect(context.pending()).toEqual(1); + expect(consoleErrorSpy).toHaveBeenCalled(); + + // Retry must still work — the flush must not be left permanently stuck. + publisher.publish.mockReturnValue(Promise.resolve()); + + context.finalize().then(() => { + expect(context.isFinalized()).toEqual(true); + + consoleErrorSpy.mockRestore(); + done(); + }); + }); + }); + + it("should reschedule the automatic publish timer after a scheduled flush fails", (done) => { + jest.useFakeTimers("legacy"); + jest.spyOn(global, "setTimeout"); + + const publishDelay = 100; + const context = new Context( + sdk, + { ...contextOptions, publishDelay, refreshPeriod: 0 }, + contextParams, + getContextResponse + ); + + context.treatment("exp_test_ab"); + expect(context.pending()).toEqual(1); + expect(setTimeout).toHaveBeenCalledTimes(1); + + publisher.publish.mockReturnValueOnce(Promise.reject(new Error("network error"))); + + jest.advanceTimersByTime(publishDelay); + + // Flush the microtask queue so the rejection handler (which restores the + // queue and reschedules) has run before we assert on it. + Promise.resolve() + .then(() => Promise.resolve()) + .then(() => { + expect(context.pending()).toEqual(1); + // A new automatic-publish timer must have been scheduled for the + // restored batch, otherwise it is silently dropped forever. + expect(setTimeout).toHaveBeenCalledTimes(2); + + publisher.publish.mockReturnValueOnce(Promise.resolve()); + + jest.advanceTimersByTime(publishDelay); + + Promise.resolve() + .then(() => Promise.resolve()) + .then(() => { + expect(context.pending()).toEqual(0); + expect(publisher.publish).toHaveBeenCalledTimes(2); + + done(); + }); + }); + }); + }); + + describe("override()", () => { + it("should be callable before ready()", (done) => { + const context = new Context(sdk, contextOptions, contextParams, Promise.resolve(getContextResponse)); + expect(context.isReady()).toEqual(false); + expect(context.isFailed()).toEqual(false); + expect(context.isFinalized()).toEqual(false); + + context.override("exp_test_ab", 1); + context.overrides({ + exp_test_ab: 2, + exp_test_abc: 2, + not_found: 3, + }); + + context.ready().then(() => { + expect(context.isReady()).toEqual(true); + expect(context.data()).toStrictEqual(getContextResponse); + + context.treatment("exp_test_ab"); + context.treatment("exp_test_abc"); + + publisher.publish.mockReturnValue(Promise.resolve()); + + jest.spyOn(Date, "now").mockImplementation(() => timeOrigin + 100); + + context.publish().then(() => { + expect(publisher.publish).toHaveBeenCalledTimes(1); + expect(publisher.publish).toHaveBeenCalledWith( + { + publishedAt: 1611141535829, + units: publishUnits, + hashed: true, + sdkVersion: SDK_VERSION, + exposures: [ + { + id: 1, + name: "exp_test_ab", + unit: "session_id", + exposedAt: 1611141535729, + variant: 2, + assigned: false, + eligible: true, + overridden: true, + fullOn: false, + custom: false, + audienceMismatch: false, + ruleOverride: false, + }, + { + id: 2, + name: "exp_test_abc", + unit: "session_id", + exposedAt: 1611141535729, + variant: 2, + assigned: false, + eligible: true, + overridden: true, + fullOn: false, + custom: false, + audienceMismatch: false, + ruleOverride: false, + }, + ], + }, + sdk, + context, + undefined + ); + + done(); + }); + }); + }); + + it("should clear assignment cache when override changes", (done) => { + const context = new Context(sdk, contextOptions, contextParams, getContextResponse); + + context.override("exp_test_ab", 2); + context.treatment("exp_test_ab"); + + expect(context.pending()).toEqual(1); + + context.override("exp_test_ab", 2); + context.treatment("exp_test_ab"); + + expect(context.pending()).toEqual(1); + + context.override("exp_test_ab", 3); + context.treatment("exp_test_ab"); + + expect(context.pending()).toEqual(2); + + publisher.publish.mockReturnValue(Promise.resolve()); + + context.publish().then(() => { + expect(publisher.publish).toHaveBeenCalledWith( + { + publishedAt: 1611141535729, + units: publishUnits, + hashed: true, + sdkVersion: SDK_VERSION, + exposures: [ + { + id: 1, + name: "exp_test_ab", + unit: "session_id", + exposedAt: 1611141535729, + variant: 2, + assigned: false, + eligible: true, + overridden: true, + fullOn: false, + custom: false, + audienceMismatch: false, + ruleOverride: false, + }, + { + id: 1, + name: "exp_test_ab", + unit: "session_id", + exposedAt: 1611141535729, + variant: 3, + assigned: false, + eligible: true, + overridden: true, + fullOn: false, + custom: false, + audienceMismatch: false, + ruleOverride: false, + }, + ], + }, + sdk, + context, + undefined + ); + + done(); + }); + }); + + it("should clear assignment cache when overriding computed assignment", (done) => { + const context = new Context(sdk, contextOptions, contextParams, getContextResponse); + + expect(context.treatment("exp_test_ab")).toEqual(expectedVariants["exp_test_ab"]); + expect(context.pending()).toEqual(1); + + context.override("exp_test_ab", 9); + expect(context.treatment("exp_test_ab")).toEqual(9); + + expect(context.pending()).toEqual(2); + + done(); + }); + }); describe("customAssignment()", () => { it("should override natural assignment and set custom flag", (done) => { @@ -4606,27 +5262,28 @@ describe("Context", () => { expect(context.customFieldValue("exp_test_custom_fields", "false_boolean_field")).toEqual(false); }); - it("should console an error when JSON cannot be parsed", () => { - const errorSpy = jest.spyOn(console, "error"); - const context = new Context(sdk, contextOptions, contextParams, getContextResponse); + it("should log an error through eventLogger when JSON cannot be parsed", () => { + const eventLogger = jest.fn(); + const context = new Context(sdk, { ...contextOptions, eventLogger }, contextParams, getContextResponse); expect(context.pending()).toEqual(0); expect(context.customFieldValue("exp_test_abc", "json_invalid")).toEqual(null); - expect(errorSpy).toHaveBeenCalledTimes(1); - expect(errorSpy).toHaveBeenCalledWith( - "Failed to parse JSON custom field value 'json_invalid' for experiment 'exp_test_abc'" - ); + expect(eventLogger).toHaveBeenCalledWith(context, "error", expect.any(Error)); }); - it("should console an error when a field type is invalid", () => { - const errorSpy = jest.spyOn(console, "error"); - const context = new Context(sdk, contextOptions, contextParams, getContextResponse); + it("should log an error through eventLogger when a field type is invalid", () => { + const eventLogger = jest.fn(); + const context = new Context(sdk, { ...contextOptions, eventLogger }, contextParams, getContextResponse); expect(context.pending()).toEqual(0); expect(context.customFieldValue("exp_test_custom_fields", "invalid_type_field")).toEqual(null); - expect(errorSpy).toHaveBeenCalledTimes(1); - expect(errorSpy).toHaveBeenCalledWith( - "Unknown custom field type 'invalid' for experiment 'exp_test_custom_fields' and key 'invalid_type_field' - you may need to upgrade to the latest SDK version" + expect(eventLogger).toHaveBeenCalledWith( + context, + "error", + expect.objectContaining({ + message: + "Unknown custom field type 'invalid' for experiment 'exp_test_custom_fields' and key 'invalid_type_field' - you may need to upgrade to the latest SDK version", + }) ); }); }); @@ -4869,3 +5526,296 @@ describe("Context", () => { }); }); }); + +describe("Context input handling and lifecycle regressions", () => { + const contextOptions = { + publishDelay: -1, + refreshPeriod: 0, + }; + + const contextParams = { + units: { + session_id: "test-session", + }, + }; + + function newMockSDK() { + const sdk = new SDK(); + const publisher = new ContextPublisher(); + const provider = new ContextDataProvider(); + + sdk.getContextDataProvider.mockReturnValue(provider); + sdk.getContextPublisher.mockReturnValue(publisher); + sdk.getClient.mockReturnValue(new Client()); + sdk.getEventLogger.mockReturnValue(SDK.defaultEventLogger); + + return sdk; + } + + describe("ready() error handling and readyError()", () => { + it("should store error via readyError() when context fetch fails", async () => { + const error = new Error("fetch failed"); + const context = new Context(newMockSDK(), contextOptions, contextParams, Promise.reject(error)); + await context.ready(); + + expect(context.isFailed()).toBe(true); + expect(context.readyError()).toBe(error); + }); + + it("should return null for readyError() when no failure", () => { + const context = new Context(newMockSDK(), contextOptions, contextParams, { experiments: [] }); + expect(context.readyError()).toBe(null); + }); + + it("should allow treatment/peek/track calls after failed init without throwing", async () => { + const error = new Error("fetch failed"); + const context = new Context(newMockSDK(), contextOptions, contextParams, Promise.reject(error)); + const result = await context.ready(); + + expect(result).toBe(true); + expect(context.isFailed()).toBe(true); + expect(context.isReady()).toBe(true); + + expect(context.treatment("any_experiment")).toBe(0); + expect(context.peek("any_experiment")).toBe(0); + expect(context.variableValue("any_key", "fallback")).toBe("fallback"); + expect(context.peekVariableValue("any_key", "fallback")).toBe("fallback"); + expect(context.experiments()).toBeUndefined(); + expect(context.variableKeys()).toEqual({}); + + expect(() => context.track("goal_name")).not.toThrow(); + expect(() => context.attribute("attr", "value")).not.toThrow(); + }); + + it("should still resolve true and record the error when a custom eventLogger throws on init failure", async () => { + const initError = new Error("fetch failed"); + const throwingEventLogger = jest.fn((_, eventName) => { + if (eventName === "error") { + throw new Error("eventLogger boom on error"); + } + }); + + const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + + const context = new Context( + newMockSDK(), + { ...contextOptions, eventLogger: throwingEventLogger }, + contextParams, + Promise.reject(initError) + ); + + // The v2 migration guide promises ready() always resolves true; a + // throwing observer on the "error" event must not turn that into a + // rejection. + const result = await context.ready(); + + expect(result).toBe(true); + expect(context.isFailed()).toBe(true); + expect(context.readyError()).toBe(initError); + expect(consoleErrorSpy).toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + + it("should not mark a successful init as failed when a custom eventLogger throws on the ready event", async () => { + const throwingEventLogger = jest.fn((_, eventName) => { + if (eventName === "ready") { + throw new Error("eventLogger boom on ready"); + } + }); + + const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + + const context = new Context( + newMockSDK(), + { ...contextOptions, eventLogger: throwingEventLogger }, + contextParams, + Promise.resolve({ experiments: [] }) + ); + + // The success handler runs `this._logEvent("ready", data)`; an observer + // exception there sits between a `.then()` and the constructor's own + // `.catch()` on the same promise chain, so an unguarded throw would + // incorrectly route through the failure branch and mark this a failed + // init even though the fetch itself succeeded. + const result = await context.ready(); + + expect(result).toBe(true); + expect(context.isFailed()).toBe(false); + expect(context.readyError()).toBe(null); + expect(consoleErrorSpy).toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe("variable resolution over experiment arrays", () => { + it("should handle unknown variable keys without error", () => { + const context = new Context( + newMockSDK(), + contextOptions, + { + units: { session_id: "e791e240fcd3df7d238cfc285f475e8152fcc0ec" }, + }, + { + experiments: [ + { + id: 1, + name: "exp_test", + iteration: 1, + unitType: "session_id", + seedHi: 3603515, + seedLo: 233373850, + split: [0.5, 0.5], + trafficSeedHi: 449867249, + trafficSeedLo: 455443629, + trafficSplit: [0.0, 1.0], + fullOnVariant: 0, + audience: null, + audienceStrict: false, + variants: [ + { name: "A", config: null }, + { name: "B", config: '{"color":"red"}' }, + ], + customFieldValues: null, + }, + ], + } + ); + + expect(context.variableValue("nonexistent_key", "default")).toBe("default"); + }); + }); + + describe("error logging routed through eventLogger", () => { + it("should not call console.error directly for custom field parse errors", () => { + const eventLogger = jest.fn(); + const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + + const sdk = newMockSDK(); + sdk.getEventLogger.mockReturnValue(eventLogger); + const context = new Context( + sdk, + { publishDelay: -1, refreshPeriod: 0, eventLogger }, + { units: { session_id: "test" } }, + { + experiments: [ + { + id: 1, + name: "exp", + iteration: 1, + unitType: "session_id", + seedHi: 1, + seedLo: 1, + split: [1], + trafficSeedHi: 1, + trafficSeedLo: 1, + trafficSplit: [0, 1], + fullOnVariant: 0, + audience: null, + audienceStrict: false, + variants: [{ name: "A", config: null }], + customFieldValues: [{ name: "bad_json", value: "{invalid", type: "json" }], + }, + ], + } + ); + + context.customFieldValue("exp", "bad_json"); + expect(errorSpy).not.toHaveBeenCalled(); + expect(eventLogger).toHaveBeenCalledWith(context, "error", expect.any(Error)); + errorSpy.mockRestore(); + }); + + it("should route variant config parse errors through eventLogger", () => { + const eventLogger = jest.fn(); + const sdk = newMockSDK(); + sdk.getEventLogger.mockReturnValue(eventLogger); + + const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + + const context = new Context( + sdk, + { publishDelay: -1, refreshPeriod: 0, eventLogger }, + { units: { session_id: "test" } }, + { + experiments: [ + { + id: 1, + name: "exp_bad_config", + iteration: 1, + unitType: "session_id", + seedHi: 1, + seedLo: 1, + split: [1], + trafficSeedHi: 1, + trafficSeedLo: 1, + trafficSplit: [0, 1], + fullOnVariant: 0, + audience: null, + audienceStrict: false, + variants: [{ name: "A", config: "{invalid json}" }], + customFieldValues: null, + }, + ], + } + ); + + expect(errorSpy).not.toHaveBeenCalled(); + expect(eventLogger).toHaveBeenCalledWith(context, "error", expect.any(Error)); + errorSpy.mockRestore(); + }); + }); + + describe("finalizing state", () => { + it("should expose isFinalizing/isFinalized as false before finalize", () => { + const sdk = newMockSDK(); + sdk.getEventLogger.mockReturnValue(jest.fn()); + + const context = new Context( + sdk, + { publishDelay: -1, refreshPeriod: 0 }, + { units: { session_id: "test" } }, + { experiments: [] } + ); + + expect(context.isFinalizing()).toBe(false); + expect(context.isFinalized()).toBe(false); + }); + }); + + describe("attribute map caching", () => { + it("should return correct attributes after multiple attribute() calls", () => { + const sdk = newMockSDK(); + sdk.getEventLogger.mockReturnValue(jest.fn()); + + const context = new Context( + sdk, + { publishDelay: -1, refreshPeriod: 0 }, + { units: { session_id: "test" } }, + { experiments: [] } + ); + + context.attribute("age", 25); + context.attribute("country", "US"); + + const attrs = context.getAttributes(); + expect(attrs).toEqual({ age: 25, country: "US" }); + }); + }); + + describe("getOptions() returns a shallow copy", () => { + it("should not allow mutation of internal options", () => { + const sdk = newMockSDK(); + sdk.getEventLogger.mockReturnValue(jest.fn()); + + const originalOptions = { publishDelay: 100, refreshPeriod: 0 }; + const context = new Context(sdk, originalOptions, { units: { session_id: "test" } }, { experiments: [] }); + + const opts = context.getOptions(); + opts.publishDelay = 9999; + + expect(context.getOptions().publishDelay).toBe(100); + }); + }); +}); diff --git a/src/__tests__/entrypoints.test.js b/src/__tests__/entrypoints.test.js new file mode 100644 index 0000000..217979c --- /dev/null +++ b/src/__tests__/entrypoints.test.js @@ -0,0 +1,19 @@ +import indexDefault, { ABsmartly, SDK } from "../index"; +import browserDefault from "../browser"; + +// Guards against the two declared package entry points (CommonJS/ES via +// `src/index.ts`, and the UMD build via `src/browser.ts`) drifting apart: +// `browser.ts` previously omitted a public API addition (the `ABsmartly` +// alias) that `index.ts` had, so `require("dist/absmartly.js").ABsmartly` +// was `undefined` in the built UMD artifact while the CJS/ES entry worked. +describe("entry point parity", () => { + it("should expose the same public API keys from index and browser default exports", () => { + expect(Object.keys(browserDefault).sort()).toEqual(Object.keys(indexDefault).sort()); + }); + + it("should expose ABsmartly as an alias for SDK from both entry points", () => { + expect(ABsmartly).toBe(SDK); + expect(indexDefault.ABsmartly).toBe(indexDefault.SDK); + expect(browserDefault.ABsmartly).toBe(browserDefault.SDK); + }); +}); diff --git a/src/__tests__/fetch-shim.test.js b/src/__tests__/fetch-shim.test.js index d214b7a..542d1a2 100644 --- a/src/__tests__/fetch-shim.test.js +++ b/src/__tests__/fetch-shim.test.js @@ -133,3 +133,12 @@ describe("fetch", () => { }); }); }); + +describe("fetch implementation resolver", () => { + it("should resolve to a function, never undefined", async () => { + const fetchModule = await import("../fetch"); + const fetchImpl = fetchModule.default; + expect(fetchImpl).not.toBeUndefined(); + expect(typeof fetchImpl).toBe("function"); + }); +}); diff --git a/src/__tests__/jsonexpr/operators/eq.test.js b/src/__tests__/jsonexpr/operators/eq.test.js index d7132af..40c9803 100644 --- a/src/__tests__/jsonexpr/operators/eq.test.js +++ b/src/__tests__/jsonexpr/operators/eq.test.js @@ -106,5 +106,9 @@ describe("EqOperator", () => { evaluator.evaluate.mockClear(); evaluator.compare.mockClear(); }); + + it("should return null for empty args", () => { + expect(operator.evaluate(evaluator, [])).toBe(null); + }); }); }); diff --git a/src/__tests__/local-server-integration.test.js b/src/__tests__/local-server-integration.test.js new file mode 100644 index 0000000..83d7387 --- /dev/null +++ b/src/__tests__/local-server-integration.test.js @@ -0,0 +1,118 @@ +import http from "http"; +import SDK from "../sdk"; + +// Hermetic integration test: spins up a real local HTTP server on an ephemeral +// port, points the SDK's client endpoint at it, and drives the PUBLIC SDK API so +// the REAL HTTP client performs a GET /context (createContext -> ready) and a +// PUT /context (treatment + track -> publish). Asserts the wire contract. +describe("Local server integration (real HTTP)", () => { + let server; + let baseUrl; + const requests = []; + + beforeAll((done) => { + server = http.createServer((req, res) => { + const chunks = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + const bodyStr = Buffer.concat(chunks).toString("utf8"); + const record = { + method: req.method, + url: req.url, + headers: req.headers, + body: bodyStr.length > 0 ? JSON.parse(bodyStr) : undefined, + }; + requests.push(record); + + res.setHeader("Content-Type", "application/json"); + if (req.method === "GET") { + res.statusCode = 200; + res.end(JSON.stringify({ experiments: [] })); + } else if (req.method === "PUT") { + res.statusCode = 200; + res.end(JSON.stringify({})); + } else { + res.statusCode = 405; + res.end("{}"); + } + }); + }); + + server.listen(0, "127.0.0.1", () => { + const { port } = server.address(); + baseUrl = `http://127.0.0.1:${port}`; + done(); + }); + }); + + afterAll((done) => { + server.close(done); + server.closeIdleConnections?.(); + }); + + it("performs a real GET /context and PUT /context against a local server", async () => { + const sdk = new SDK({ + endpoint: baseUrl, + apiKey: "test-api-key", + application: "www", + environment: "development", + }); + + const context = sdk.createContext( + { + units: { + session_id: "e791e240fcd3df7d238cfc285f475e8152fcc0ec", + user_id: "123456789", + }, + }, + { publishDelay: -1, refreshPeriod: 0 } + ); + + await context.ready(); + + // --- assert the GET /context --- + const getReq = requests.find((r) => r.method === "GET"); + expect(getReq).toBeDefined(); + const getUrl = new URL(getReq.url, baseUrl); + expect(getUrl.pathname).toBe("/context"); + expect(getUrl.searchParams.get("application")).toBe("www"); + expect(getUrl.searchParams.get("environment")).toBe("development"); + // JS sends the full auth header set on GET too (per wire contract). + expect(getReq.headers["x-api-key"]).toBe("test-api-key"); + + // --- drive an exposure + a goal, then publish --- + context.treatment("not_found_experiment"); + context.track("payment", { value: 99 }); + + await context.publish(); + + const putReq = requests.find((r) => r.method === "PUT"); + expect(putReq).toBeDefined(); + const putUrl = new URL(putReq.url, baseUrl); + expect(putUrl.pathname).toBe("/context"); + expect(putUrl.search).toBe(""); + + // --- headers --- + expect(putReq.headers["x-api-key"]).toBe("test-api-key"); + expect(putReq.headers["x-application"]).toBe("www"); + expect(putReq.headers["x-environment"]).toBe("development"); + expect(putReq.headers["x-application-version"]).toBe("0"); + expect(putReq.headers["x-agent"]).toBeDefined(); + expect(putReq.headers["x-agent"].length).toBeGreaterThan(0); + expect(putReq.headers["content-type"]).toMatch(/application\/json/); + + // --- body --- + const body = putReq.body; + expect(body.hashed).toBe(true); + expect(Array.isArray(body.units)).toBe(true); + expect(body.units.length).toBeGreaterThan(0); + expect(body.units[0]).toHaveProperty("type"); + expect(body.units[0]).toHaveProperty("uid"); + expect(typeof body.publishedAt).toBe("number"); + expect(Array.isArray(body.goals)).toBe(true); + expect(body.goals.length).toBeGreaterThan(0); + expect(body.goals[0].name).toBe("payment"); + expect(Array.isArray(body.exposures)).toBe(true); + expect(body.exposures.length).toBeGreaterThan(0); + }); +}); diff --git a/src/__tests__/sdk.test.js b/src/__tests__/sdk.test.js index 3dc75de..7a0ad73 100644 --- a/src/__tests__/sdk.test.js +++ b/src/__tests__/sdk.test.js @@ -476,4 +476,21 @@ describe("SDK", () => { done(); }); }); + + describe("defaultEventLogger", () => { + it("should log full Error object to preserve stack traces", () => { + const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + const error = new Error("something failed"); + SDK.defaultEventLogger(null, "error", error); + expect(errorSpy).toHaveBeenCalledWith(error); + errorSpy.mockRestore(); + }); + + it("should log raw data for non-Error values", () => { + const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + SDK.defaultEventLogger(null, "error", "plain text error"); + expect(errorSpy).toHaveBeenCalledWith("plain text error"); + errorSpy.mockRestore(); + }); + }); }); diff --git a/src/__tests__/utils.test.js b/src/__tests__/utils.test.js index 39cf8d4..62aec2b 100644 --- a/src/__tests__/utils.test.js +++ b/src/__tests__/utils.test.js @@ -8,6 +8,7 @@ import { isObject, isPromise, stringToUint8Array, + toWellFormedString, } from "../utils"; class SomeClass {} @@ -235,6 +236,17 @@ describe("hashUnit()", () => { done(); }); + + it("should hash astral/multibyte characters correctly", (done) => { + // Characters outside the BMP are stored as UTF-16 surrogate pairs and must + // encode to 4-byte UTF-8; these canonical hashes are shared across all SDKs. + expect(hashUnit("😀")).toBe("KgLqw51xanDs83V5GFkntg"); + expect(hashUnit("😀😁")).toBe("ZJuDalvUWRJnVtkspj-2bQ"); + expect(hashUnit("世界你好")).toBe("v2CJG7YcjjWncKOSCzF2GA"); + expect(hashUnit("user_世界_123")).toBe("SCgk4OzXlFMvo1UMsP88fA"); + + done(); + }); }); describe("chooseVariant()", () => { @@ -303,6 +315,74 @@ describe("stringToUint8Array()", () => { } done(); }); + + describe("unmatched surrogates", () => { + // Unmatched surrogate code units are not valid UTF-8 code points; both TextEncoder + // and the manual fallback must emit U+FFFD (ef bf bd) for each one, matching the + // canonical UTF-8 replacement-character behavior. + const testCases = [ + ["lone high surrogate at end of string", "\uD800", Uint8Array.from([0xef, 0xbf, 0xbd])], + ["lone low surrogate", "\uDC00", Uint8Array.from([0xef, 0xbf, 0xbd])], + ["high surrogate followed by non-surrogate", "\uD800X", Uint8Array.from([0xef, 0xbf, 0xbd, 0x58])], + [ + "two consecutive lone high surrogates", + "\uD800\uD800", + Uint8Array.from([0xef, 0xbf, 0xbd, 0xef, 0xbf, 0xbd]), + ], + [ + "two consecutive lone low surrogates", + "\uDC00\uDC00", + Uint8Array.from([0xef, 0xbf, 0xbd, 0xef, 0xbf, 0xbd]), + ], + [ + "low surrogate followed by high surrogate (wrong order)", + "\uDC00\uD800", + Uint8Array.from([0xef, 0xbf, 0xbd, 0xef, 0xbf, 0xbd]), + ], + ["lone high surrogate after ascii", "a\uD800", Uint8Array.from([0x61, 0xef, 0xbf, 0xbd])], + ["lone low surrogate before ascii", "\uDC00b", Uint8Array.from([0xef, 0xbf, 0xbd, 0x62])], + ]; + + it("should emit U+FFFD for unmatched surrogates via the built-in TextEncoder", (done) => { + for (const [, input, expected] of testCases) { + const array = stringToUint8Array(input); + expect(Array.from(array)).toEqual(Array.from(expected)); + } + done(); + }); + + it("should emit U+FFFD for unmatched surrogates via the manual fallback", (done) => { + const OriginalTextEncoder = global.TextEncoder; + // eslint-disable-next-line no-global-assign + delete global.TextEncoder; + + try { + for (const [, input, expected] of testCases) { + const array = stringToUint8Array(input); + expect(Array.from(array)).toEqual(Array.from(expected)); + } + } finally { + global.TextEncoder = OriginalTextEncoder; + } + done(); + }); + + it("should produce identical hashUnit results for both code paths", (done) => { + const OriginalTextEncoder = global.TextEncoder; + + for (const [, input] of testCases) { + const nativeHash = hashUnit(input); + + // eslint-disable-next-line no-global-assign + delete global.TextEncoder; + const fallbackHash = hashUnit(input); + global.TextEncoder = OriginalTextEncoder; + + expect(fallbackHash).toBe(nativeHash); + } + done(); + }); + }); }); describe("base64UrlNoPadding()", () => { @@ -341,3 +421,36 @@ describe("base64UrlNoPadding()", () => { done(); }); }); + +describe("toWellFormedString()", () => { + it("should leave well-formed strings unchanged", (done) => { + expect(toWellFormedString("")).toBe(""); + expect(toWellFormedString("normal string")).toBe("normal string"); + expect(toWellFormedString("açb↓c")).toBe("açb↓c"); + expect(toWellFormedString("😀")).toBe("😀"); + expect(toWellFormedString("a😀b")).toBe("a😀b"); + + done(); + }); + + it("should replace unmatched surrogates with U+FFFD", (done) => { + expect(toWellFormedString("\uD800")).toBe("�"); + expect(toWellFormedString("\uDC00")).toBe("�"); + expect(toWellFormedString("\uD800X")).toBe("�X"); + expect(toWellFormedString("a\uD800b")).toBe("a�b"); + expect(toWellFormedString("\uD800\uD800")).toBe("��"); + expect(toWellFormedString("\uDC00\uD800")).toBe("��"); + + done(); + }); + + it("should always be safe to pass to encodeURIComponent()", (done) => { + const inputs = ["\uD800", "\uDC00", "\uD800X", "a\uD800b", "\uD800\uD800", "\uDC00\uD800", "normal", "😀"]; + + for (const input of inputs) { + expect(() => encodeURIComponent(toWellFormedString(input))).not.toThrow(); + } + + done(); + }); +}); diff --git a/src/abort-controller-shim.ts b/src/abort-controller-shim.ts index 1456e47..e41c780 100644 --- a/src/abort-controller-shim.ts +++ b/src/abort-controller-shim.ts @@ -5,6 +5,8 @@ export type AbortControllerEvents = { // eslint-disable-next-line no-shadow export class AbortSignal { aborted = false; + reason: unknown = undefined; + onabort?: ((evt: { type: string }) => void) | null; private readonly _events: AbortControllerEvents; constructor() { @@ -34,9 +36,9 @@ export class AbortSignal { } dispatchEvent(evt: { type: string }) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - this[`on${evt.type}`] && this[`on${evt.type}`](evt); + if (evt.type === "abort" && this.onabort) { + this.onabort(evt); + } const listeners = this._events[evt.type]; if (listeners) { for (const listener of listeners) { @@ -54,11 +56,20 @@ export class AbortSignal { export class AbortController { signal = new AbortSignal(); - abort() { + abort(reason?: unknown) { + // Match native AbortController: a second call is a no-op (the first + // reason is latched, and the "abort" event fires at most once), and an + // explicit `null` reason is preserved as-is — only an omitted/undefined + // reason falls back to the default error. `??` would incorrectly replace + // an explicit `null` with the default. + if (this.signal.aborted) { + return; + } + let evt: Event | { type: string; bubbles: boolean; cancelable: boolean }; try { evt = new Event("abort"); - } catch (e) { + } catch (error) { evt = { type: "abort", bubbles: false, @@ -67,6 +78,7 @@ export class AbortController { } this.signal.aborted = true; + this.signal.reason = reason === undefined ? new Error("The operation was aborted.") : reason; this.signal.dispatchEvent(evt); } diff --git a/src/browser.ts b/src/browser.ts index c3399d1..450a290 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -6,4 +6,4 @@ import { ContextPublisher } from "./publisher"; // eslint-disable-next-line no-shadow import { AbortController } from "./abort"; -export default { mergeConfig, AbortController, Context, ContextDataProvider, ContextPublisher, SDK }; +export default { mergeConfig, AbortController, Context, ContextDataProvider, ContextPublisher, SDK, ABsmartly: SDK }; diff --git a/src/client.ts b/src/client.ts index 56d91a2..c0f5fe1 100644 --- a/src/client.ts +++ b/src/client.ts @@ -4,9 +4,10 @@ import { AbortController } from "./abort"; // eslint-disable-next-line no-shadow import { AbortError, RetryError, TimeoutError } from "./errors"; -import { AbortSignal as ABsmartlyAbortSignal } from "./abort-controller-shim"; -import { ContextOptions, ContextParams } from "./context"; -import { PublishParams } from "./publisher"; +import { type AbortSignal as ABsmartlyAbortSignal } from "./abort-controller-shim"; +import { type ContextOptions, type ContextParams } from "./context"; +import { type PublishParams } from "./publisher"; +import { toWellFormedString } from "./utils"; export type FetchResponse = { status: number; @@ -29,6 +30,10 @@ export type ClientRequestOptions = { export type ApplicationObject = { name: string; version: number | string }; +const DEFAULT_RETRIES = 5; +const DEFAULT_TIMEOUT_MS = 3000; +const RETRY_DELAY_MS = 50; + export type ClientOptions = { agent?: string; apiKey: string; @@ -52,8 +57,8 @@ export default class Client { const merged: Record