From b7894961137fa8d062d915b2d0f655d6c41f99d9 Mon Sep 17 00:00:00 2001 From: bernirosas Date: Mon, 30 Mar 2026 16:31:01 -0300 Subject: [PATCH 1/4] feat: enhance API token handling and UI notifications - Updated the global TS interface to make the token property optional. - Improved the demo HTML to include a notice for when no API token is set, informing users that events will be queued. - Modified the configuration form to allow for an empty token field, with a hint indicating that events will queue until a token is provided. - Enhanced JavaScript logic to manage event statuses based on the presence of a token, updating UI elements accordingly. - Added tests to ensure proper behavior when the token is missing, including capturing events and observing dynamically added elements. These changes improve user experience and ensure better handling of API token scenarios. --- @types/global.d.ts | 2 +- demo/index.html | 56 +++++++++++++---- src/detector.bootstrap.test.ts | 14 +++-- src/detector.deferred-token.test.ts | 98 +++++++++++++++++++++++++++++ src/detector.ts | 27 ++++++-- src/queue.test.ts | 90 ++++++++++++++++++++++++++ src/queue.ts | 19 ++++++ 7 files changed, 286 insertions(+), 20 deletions(-) create mode 100644 src/detector.deferred-token.test.ts diff --git a/@types/global.d.ts b/@types/global.d.ts index d0048f8..da3e08c 100644 --- a/@types/global.d.ts +++ b/@types/global.d.ts @@ -3,7 +3,7 @@ export {}; declare global { interface Window { TS: { - token: string; + token?: string; url?: string; cookieName?: string; getUserId?: () => string; diff --git a/demo/index.html b/demo/index.html index 4015d58..92abccc 100644 --- a/demo/index.html +++ b/demo/index.html @@ -400,6 +400,23 @@ background: #ffebee; } + .event-entry .api-status.queued { + color: #7c4a00; + background: #fff3cd; + } + + .no-token-notice { + display: none; + padding: 10px 16px; + background: #fff8e1; + border-bottom: 1px solid #ffe082; + font-size: 12px; + color: #7c4a00; + line-height: 1.5; + } + + .no-token-notice.visible { display: block; } + /* Snippet */ #snippet-area { padding: 16px; @@ -448,10 +465,11 @@

analytics.js

Configuration
- + +

Leave empty to start without a token — events will queue and flush once a token is set.

- +

Leave empty for production. Use a staging URL for testing.

@@ -533,6 +551,9 @@

Event Log

Captured topsort CustomEvents 0 +
+ No API token set — events are queued locally and won't be sent to the API. Enter a token in the configuration panel to flush the queue. +
Events will appear here as they are detected by analytics.js.
@@ -555,6 +576,7 @@

Code Snippet

const form = document.getElementById("config-form"); const previewArea = document.getElementById("preview-area"); const eventLog = document.getElementById("event-log"); + const noTokenNotice = document.getElementById("no-token-notice"); const snippetCode = document.getElementById("snippet-code"); const elementCountBadge = document.getElementById("element-count"); const eventCountBadge = document.getElementById("event-count"); @@ -582,7 +604,7 @@

Code Snippet

if (!entry) return; const badge = entry.querySelector(".api-status"); if (!badge) return; - badge.classList.remove("pending"); + badge.classList.remove("pending", "queued"); if (ok) { badge.classList.add("ok"); badge.textContent = `Success (${status})`; @@ -687,11 +709,12 @@

Code Snippet

detailText = parts.join(", ") || "no product/bid"; } + const hasToken = !!(window.TS && window.TS.token); entry.innerHTML = ` ${escapeHtml(time)} ${escapeHtml(detail.type)} ${escapeHtml(detailText)} - Sending… + ${hasToken ? "Sending…" : "Queued (no token)"} `; eventLog.prepend(entry); @@ -700,19 +723,17 @@

Code Snippet

} function ensureAnalyticsLoaded() { - if (analyticsLoaded) return; + if (analyticsLoaded) return true; const token = document.getElementById("token").value.trim(); - if (!token) { - alert("API Token is required to initialize analytics.js."); - return false; - } - const url = document.getElementById("api-url").value.trim(); - window.TS = { token }; + window.TS = {}; + if (token) window.TS.token = token; if (url) window.TS.url = url; + if (!token) noTokenNotice.classList.add("visible"); + patchFetchForEventApiStatus(); // Load the analytics.js script @@ -875,11 +896,24 @@

Code Snippet

eventCountBadge.textContent = "0"; snippetCode.textContent = ""; + noTokenNotice.classList.remove("visible"); + // Reset analytics.js so it can re-observe new elements if (window.TS) window.TS.loaded = false; analyticsLoaded = false; }); + // When the token field is updated after the library is already loaded, + // set it directly on window.TS so the watchForToken setter fires and drains the queue. + document.getElementById("token").addEventListener("change", () => { + if (!analyticsLoaded) return; + const token = document.getElementById("token").value.trim(); + if (token) { + window.TS.token = token; + noTokenNotice.classList.remove("visible"); + } + }); + // Initialize snippet updateSnippet(); diff --git a/src/detector.bootstrap.test.ts b/src/detector.bootstrap.test.ts index 13a3d55..f1665c9 100644 --- a/src/detector.bootstrap.test.ts +++ b/src/detector.bootstrap.test.ts @@ -32,10 +32,12 @@ describe("detector bootstrap", () => { expect(observe).not.toHaveBeenCalled(); }); - test("logs when token is missing", async () => { + test("starts observing and installs token watcher when token is missing", async () => { const observe = vi.fn(); class MutationObserverMock { observe = observe; + disconnect = vi.fn(); + takeRecords = vi.fn(() => []); } Object.defineProperty(window, "MutationObserver", { configurable: true, @@ -43,12 +45,16 @@ describe("detector bootstrap", () => { value: MutationObserverMock, }); - const error = vi.spyOn(console, "error").mockImplementation(() => undefined); window.TS = {} as typeof window.TS; await import("./detector"); - expect(error).toHaveBeenCalledWith("Missing TS token"); - expect(observe).not.toHaveBeenCalled(); + + // DOM observation starts even without a token + expect(observe).toHaveBeenCalled(); + // A getter/setter is installed on window.TS.token so setting it later drains the queue + const descriptor = Object.getOwnPropertyDescriptor(window.TS, "token"); + expect(typeof descriptor?.get).toBe("function"); + expect(typeof descriptor?.set).toBe("function"); }); test("registers DOMContentLoaded listener when document is loading", async () => { diff --git a/src/detector.deferred-token.test.ts b/src/detector.deferred-token.test.ts new file mode 100644 index 0000000..649ba0b --- /dev/null +++ b/src/detector.deferred-token.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + document.body.innerHTML = ""; + document.cookie = ""; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +test("captures impressions before token is set", async () => { + window.TS = {} as typeof window.TS; + document.body.innerHTML = '
'; + + const events: CustomEvent[] = []; + window.addEventListener("topsort", (e) => events.push(e as CustomEvent)); + + await import("./detector"); + + expect(events).toHaveLength(1); + expect(events[0]?.detail.type).toBe("Impression"); + expect(events[0]?.detail.product).toBe("prod-1"); +}); + +test("captures clicks before token is set", async () => { + window.TS = {} as typeof window.TS; + document.body.innerHTML = '
'; + + const events: CustomEvent[] = []; + window.addEventListener("topsort", (e) => events.push(e as CustomEvent)); + + await import("./detector"); + + const card = document.querySelector("[data-ts-product]"); + card?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + const clickEvent = events.find((e) => e.detail.type === "Click"); + expect(clickEvent).toBeDefined(); + expect(clickEvent?.detail.product).toBe("prod-click"); +}); + +test("observes dynamically added elements before token is set", async () => { + window.TS = {} as typeof window.TS; + + const events: CustomEvent[] = []; + window.addEventListener("topsort", (e) => events.push(e as CustomEvent)); + + await import("./detector"); + + const div = document.createElement("div"); + div.dataset.tsProduct = "prod-dynamic"; + document.body.appendChild(div); + await new Promise(process.nextTick); + + expect(events.some((e) => e.detail.product === "prod-dynamic")).toBe(true); +}); + +test("installs getter/setter on window.TS.token when no token is provided", async () => { + window.TS = {} as typeof window.TS; + await import("./detector"); + + const descriptor = Object.getOwnPropertyDescriptor(window.TS, "token"); + expect(typeof descriptor?.get).toBe("function"); + expect(typeof descriptor?.set).toBe("function"); + expect(descriptor?.configurable).toBe(true); +}); + +test("does not install token watcher when token is already present", async () => { + window.TS = { token: "existing-token" }; + await import("./detector"); + + // Plain data property — no getter/setter installed + const descriptor = Object.getOwnPropertyDescriptor(window.TS, "token"); + expect(descriptor?.get).toBeUndefined(); + expect(descriptor?.value).toBe("existing-token"); +}); + +test("setting the token via watcher makes it readable through the getter", async () => { + window.TS = {} as typeof window.TS; + await import("./detector"); + + window.TS.token = "deferred-token"; + + expect(window.TS.token).toBe("deferred-token"); +}); + +test("setting the token twice updates the value", async () => { + window.TS = {} as typeof window.TS; + await import("./detector"); + + window.TS.token = "token-v1"; + window.TS.token = "token-v2"; + + expect(window.TS.token).toBe("token-v2"); +}); diff --git a/src/detector.ts b/src/detector.ts index 14f2302..7ff746f 100644 --- a/src/detector.ts +++ b/src/detector.ts @@ -130,6 +130,9 @@ function getApiPayload(event: ProductEvent): TopsortEvent { // TODO: batch requests. Unfortunately at the moment only the impressions are batchable. async function processor(data: ProductEvent[]): Promise { + if (!window.TS.token) { + return { done: new Set(), retry: new Set(), pause: true }; + } const r: ProcessorResult = { done: new Set(), retry: new Set(), @@ -337,15 +340,28 @@ function mutationCallback(mutationsList: MutationRecord[]) { } } +function watchForToken(): void { + const ts = window.TS; + let token: string | undefined; + Object.defineProperty(ts, "token", { + get(): string | undefined { + return token; + }, + set(value: string) { + token = value; + if (value) { + queue.drain(); + } + }, + configurable: true, + }); +} + function start() { if (window.TS?.loaded) { return; } window.TS.loaded = true; - if (!window.TS?.token) { - console.error("Missing TS token"); - return; - } checkChildren(document); const MutationObserverImpl = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver; @@ -356,6 +372,9 @@ function start() { subtree: true, attributeFilter: ["data-ts-product", "data-ts-action", "data-ts-items", "data-ts-resolved-bid"], }); + if (!window.TS.token) { + watchForToken(); + } } if (/complete|interactive|loaded/.test(document.readyState)) { diff --git a/src/queue.test.ts b/src/queue.test.ts index 6ff2fc6..430764c 100644 --- a/src/queue.test.ts +++ b/src/queue.test.ts @@ -146,3 +146,93 @@ test("marks entries done when processor throws", async () => { expect(failingProcessor).toHaveBeenCalledWith([entry]); expectEmptyQueue(q); }); + +test("pause keeps entries in queue with retry count unchanged and no scheduling", async () => { + let paused = true; + const pausingProcessor = vi.fn(async (chunk: Entry[]): Promise => { + if (paused) { + return { done: new Set(), retry: new Set(), pause: true }; + } + processedEvents.push(...chunk); + return { done: new Set(chunk.map((e) => e.id)), retry: new Set() }; + }); + + const q = new Queue(pausingProcessor); + const entry = { id: "entry-paused", t: now() }; + q.append(entry, { highPriority: true }); + await flushPromises(); + + // Entry is still in queue with retry count = 0 (not incremented) + const stored = ( + q as unknown as { _store: { get: () => Array<{ e: Entry; r: number }> } } + )._store.get(); + expect(stored).toHaveLength(1); + expect(stored[0]?.r).toBe(0); + // Released from _processing so drain() can pick it up + expect((q as unknown as { _processing: Set })._processing.size).toBe(0); + // No timer scheduled + expect((q as unknown as { _scheduled: boolean })._scheduled).toBe(false); +}); + +test("drain() triggers immediate processing after pause", async () => { + let paused = true; + const pausingProcessor = vi.fn(async (chunk: Entry[]): Promise => { + if (paused) { + return { done: new Set(), retry: new Set(), pause: true }; + } + processedEvents.push(...chunk); + return { done: new Set(chunk.map((e) => e.id)), retry: new Set() }; + }); + + const q = new Queue(pausingProcessor); + const entry = { id: "entry-drain", t: now() }; + q.append(entry, { highPriority: true }); + await flushPromises(); + + expect(processedEvents).toEqual([]); + + paused = false; + q.drain(); + await flushPromises(); + + expect(processedEvents).toEqual([entry]); + expectEmptyQueue(q); +}); + +test("pause does not consume retry budget", async () => { + let paused = true; + let attempts = 0; + const pausingProcessor = vi.fn(async (chunk: Entry[]): Promise => { + attempts++; + if (paused) { + return { done: new Set(), retry: new Set(), pause: true }; + } + processedEvents.push(...chunk); + return { done: new Set(chunk.map((e) => e.id)), retry: new Set() }; + }); + + const q = new Queue(pausingProcessor); + const entry = { id: "entry-budget", t: now() }; + q.append(entry, { highPriority: true }); + await flushPromises(); + + // Pause multiple times via drain + q.drain(); + await flushPromises(); + q.drain(); + await flushPromises(); + + // Retry count stays 0 — budget is intact + const stored = ( + q as unknown as { _store: { get: () => Array<{ e: Entry; r: number }> } } + )._store.get(); + expect(stored[0]?.r).toBe(0); + + // Now resume — entry should be processed successfully on first real attempt + paused = false; + q.drain(); + await flushPromises(); + + expect(processedEvents).toEqual([entry]); + expectEmptyQueue(q); +}); diff --git a/src/queue.ts b/src/queue.ts index b560368..93cc656 100644 --- a/src/queue.ts +++ b/src/queue.ts @@ -10,6 +10,12 @@ const WAIT_TIME_MS = 250; export interface ProcessorResult { done: Set; retry: Set; + /** + * When true, the processor is not ready to handle events yet (e.g. no API token). + * All events in the current chunk are released back to the queue without incrementing + * their retry counter, and no further processing is scheduled until {@link Queue.drain} is called. + */ + pause?: boolean; } export interface Entry { @@ -80,6 +86,11 @@ export class Queue { this._processor = processor; } + /** Force-trigger processing immediately, bypassing the scheduled delay. */ + drain(): void { + this._processNow(this._store.get()); + } + append(entry: Entry, opts?: { highPriority: boolean }): void { let entries = this._store.get(); entries.push({ @@ -122,6 +133,14 @@ export class Queue { r.done.add(entry.id); } } + if (r.pause) { + // Processor is not ready — release chunk back to queue without retrying or scheduling. + // Processing resumes only when drain() is called externally (e.g. once a token is set). + for (const entry of chunk) { + this._processing.delete(entry.id); + } + return; + } // Remove entries from processing const newProcessing: string[] = []; for (const entryId of this._processing) { From fd24cb668d9f2defdf095bb571e72ddd6cbc72b9 Mon Sep 17 00:00:00 2001 From: bernirosas Date: Mon, 6 Apr 2026 12:13:02 -0400 Subject: [PATCH 2/4] feat(detector): log message for missing API token - Added a console log to notify when the API token is not set, indicating that events will be queued until the token is available. This enhances user awareness regarding token handling in the application. --- src/detector.ts | 3 +++ src/queue.test.ts | 14 +++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/detector.ts b/src/detector.ts index 7ff746f..efc6b8f 100644 --- a/src/detector.ts +++ b/src/detector.ts @@ -373,6 +373,9 @@ function start() { attributeFilter: ["data-ts-product", "data-ts-action", "data-ts-items", "data-ts-resolved-bid"], }); if (!window.TS.token) { + console.log( + "No token, watching for token to be set. Events will be queued until token is set.", + ); watchForToken(); } } diff --git a/src/queue.test.ts b/src/queue.test.ts index 430764c..896a13c 100644 --- a/src/queue.test.ts +++ b/src/queue.test.ts @@ -148,7 +148,7 @@ test("marks entries done when processor throws", async () => { }); test("pause keeps entries in queue with retry count unchanged and no scheduling", async () => { - let paused = true; + const paused = true; const pausingProcessor = vi.fn(async (chunk: Entry[]): Promise => { if (paused) { return { done: new Set(), retry: new Set(), pause: true }; @@ -201,9 +201,7 @@ test("drain() triggers immediate processing after pause", async () => { test("pause does not consume retry budget", async () => { let paused = true; - let attempts = 0; const pausingProcessor = vi.fn(async (chunk: Entry[]): Promise => { - attempts++; if (paused) { return { done: new Set(), retry: new Set(), pause: true }; } @@ -216,23 +214,29 @@ test("pause does not consume retry budget", async () => { q.append(entry, { highPriority: true }); await flushPromises(); - // Pause multiple times via drain + expect(pausingProcessor).toHaveBeenCalledTimes(1); + + // Pause multiple times via drain — each call re-enters the processor without incrementing r q.drain(); await flushPromises(); q.drain(); await flushPromises(); + expect(pausingProcessor).toHaveBeenCalledTimes(3); + // Retry count stays 0 — budget is intact const stored = ( q as unknown as { _store: { get: () => Array<{ e: Entry; r: number }> } } )._store.get(); + expect(stored).toHaveLength(1); expect(stored[0]?.r).toBe(0); - // Now resume — entry should be processed successfully on first real attempt + // Now resume — entry should be processed successfully on first non-pause attempt paused = false; q.drain(); await flushPromises(); + expect(pausingProcessor).toHaveBeenCalledTimes(4); expect(processedEvents).toEqual([entry]); expectEmptyQueue(q); }); From e50afc12e2dedf11fac7e433d412029bb36f3ea7 Mon Sep 17 00:00:00 2001 From: bernirosas Date: Mon, 6 Apr 2026 12:42:35 -0400 Subject: [PATCH 3/4] docs(README): clarify token handling for event flushing - Updated the README to specify that if a token is not provided during library initialization, the `window.TS.token` must be set for the event queue to flush. This enhances user understanding of the token requirement for event processing. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e427171..9e19686 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ window.TS = { import "@topsort/analytics.js"; ``` -The library will automatically start listening for DOM changes and user interactions once it's imported. +The library will automatically start listening for DOM changes and user interactions once it's imported. In case token is not given upon initiation, then window.TS.token has to be set for the queue of events to be flushed. ### Option 2: With a Local Script File From ac680b0a3fb37b2b09f283ba0a344a41c9581325 Mon Sep 17 00:00:00 2001 From: bernirosas Date: Mon, 6 Apr 2026 15:13:14 -0400 Subject: [PATCH 4/4] docs(README): enhance token handling instructions and warnings - Updated the README to clarify that if a token is not provided during library initialization, events will be queued until the token is set via property assignment. - Added a warning against replacing the entire `window.TS` object after the library has loaded, as this would prevent the queued events from being sent. - Enhanced comments in `detector.ts` to reinforce the importance of direct property assignment for setting the token. --- README.md | 8 +++++++- src/detector.ts | 12 +++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9e19686..6f8d892 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,13 @@ window.TS = { import "@topsort/analytics.js"; ``` -The library will automatically start listening for DOM changes and user interactions once it's imported. In case token is not given upon initiation, then window.TS.token has to be set for the queue of events to be flushed. +The library will automatically start listening for DOM changes and user interactions once it's imported. If the token is not provided at initialization, events are queued until the token is set. To flush the queue, assign the token via **property assignment**: + +```js +window.TS.token = ""; +``` + +> **Important:** Do **not** replace the entire `window.TS` object after the library has loaded (e.g. `window.TS = { token: "..." }`). The library installs an internal setter on the existing `window.TS` reference to detect when the token becomes available. Replacing the object destroys that setter and the queued events will never be sent. ### Option 2: With a Local Script File diff --git a/src/detector.ts b/src/detector.ts index efc6b8f..7361482 100644 --- a/src/detector.ts +++ b/src/detector.ts @@ -341,6 +341,10 @@ function mutationCallback(mutationsList: MutationRecord[]) { } function watchForToken(): void { + // NOTE: This setter is installed on the *current* window.TS object reference. + // If the user replaces the entire object (window.TS = { token: "abc" }) the setter + // is lost and the queue will never be drained. Users must assign to the property + // directly: window.TS.token = "abc". const ts = window.TS; let token: string | undefined; Object.defineProperty(ts, "token", { @@ -355,6 +359,11 @@ function watchForToken(): void { }, configurable: true, }); + console.warn( + "[Topsort] No API token found. Events will be queued until window.TS.token is set. " + + "Use property assignment (window.TS.token = ''), not object replacement " + + "(window.TS = { token: '' }), or the queue will not be drained.", + ); } function start() { @@ -373,9 +382,6 @@ function start() { attributeFilter: ["data-ts-product", "data-ts-action", "data-ts-items", "data-ts-resolved-bid"], }); if (!window.TS.token) { - console.log( - "No token, watching for token to be set. Events will be queued until token is set.", - ); watchForToken(); } }