diff --git a/apps/web/modules/ee/contacts/segments/components/segment-filter.test.tsx b/apps/web/modules/ee/contacts/segments/components/segment-filter.test.tsx
index 3b79527957..a7de702ad7 100644
--- a/apps/web/modules/ee/contacts/segments/components/segment-filter.test.tsx
+++ b/apps/web/modules/ee/contacts/segments/components/segment-filter.test.tsx
@@ -510,7 +510,7 @@ describe("SegmentFilter", () => {
qualifier: {
operator: "greaterThan",
},
- value: "10",
+ value: "hello",
};
const segmentWithArithmeticFilter: TSegment = {
@@ -527,7 +527,7 @@ describe("SegmentFilter", () => {
const currentProps = { ...baseProps, segment: segmentWithArithmeticFilter };
render();
- const valueInput = screen.getByDisplayValue("10");
+ const valueInput = screen.getByDisplayValue("hello");
await userEvent.clear(valueInput);
fireEvent.change(valueInput, { target: { value: "abc" } });
@@ -694,7 +694,7 @@ describe("SegmentFilter", () => {
id: "filter-person-2",
root: { type: "person", personIdentifier: "userId" },
qualifier: { operator: "greaterThan" },
- value: "10",
+ value: "hello",
};
const segmentWithPersonFilterArithmetic: TSegment = {
@@ -715,7 +715,7 @@ describe("SegmentFilter", () => {
resource={personFilterResourceWithArithmeticOperator}
/>
);
- const valueInput = screen.getByDisplayValue("10");
+ const valueInput = screen.getByDisplayValue("hello");
await userEvent.clear(valueInput);
fireEvent.change(valueInput, { target: { value: "abc" } });
diff --git a/apps/web/modules/ee/contacts/segments/components/segment-filter.tsx b/apps/web/modules/ee/contacts/segments/components/segment-filter.tsx
index b486500c40..8fd093affc 100644
--- a/apps/web/modules/ee/contacts/segments/components/segment-filter.tsx
+++ b/apps/web/modules/ee/contacts/segments/components/segment-filter.tsx
@@ -236,7 +236,7 @@ function AttributeSegmentFilter({
setValueError(t("environments.segments.value_must_be_a_number"));
}
}
- }, [resource.qualifier, resource.value]);
+ }, [resource.qualifier, resource.value, t]);
const operatorArr = ATTRIBUTE_OPERATORS.map((operator) => {
return {
@@ -327,7 +327,7 @@ function AttributeSegmentFilter({
{contactAttributeKeys.map((attrClass) => (
- {attrClass.name}
+ {attrClass.name ?? attrClass.key}
))}
@@ -422,7 +422,7 @@ function PersonSegmentFilter({
setValueError(t("environments.segments.value_must_be_a_number"));
}
}
- }, [resource.qualifier, resource.value]);
+ }, [resource.qualifier, resource.value, t]);
const operatorArr = PERSON_OPERATORS.map((operator) => {
return {
diff --git a/apps/web/vite.config.mts b/apps/web/vite.config.mts
index eea7b413a5..f1dd79a808 100644
--- a/apps/web/vite.config.mts
+++ b/apps/web/vite.config.mts
@@ -91,6 +91,7 @@ export default defineConfig({
"packages/surveys/src/components/general/smileys.tsx", // Smiley components
"modules/analysis/components/SingleResponseCard/components/Smileys.tsx", // Analysis smiley components
"modules/auth/lib/mock-data.ts", // Mock data for authentication
+ "packages/js-core/src/index.ts", // JS Core index file
// Other
"**/scripts/**", // Utility scripts
diff --git a/packages/js-core/src/index.ts b/packages/js-core/src/index.ts
index e3a17b247f..f5e67be484 100644
--- a/packages/js-core/src/index.ts
+++ b/packages/js-core/src/index.ts
@@ -1,5 +1,5 @@
/* eslint-disable import/no-default-export -- required for default export*/
-import { CommandQueue } from "@/lib/common/command-queue";
+import { CommandQueue, CommandType } from "@/lib/common/command-queue";
import * as Setup from "@/lib/common/setup";
import { getIsDebug } from "@/lib/common/utils";
import * as Action from "@/lib/survey/action";
@@ -9,7 +9,7 @@ import * as User from "@/lib/user/user";
import { type TConfigInput, type TLegacyConfigInput } from "@/types/config";
import { type TTrackProperties } from "@/types/survey";
-const queue = new CommandQueue();
+const queue = CommandQueue.getInstance();
const setup = async (setupConfig: TConfigInput): Promise => {
// If the initConfig has a userId or attributes, we need to use the legacy init
@@ -27,45 +27,41 @@ const setup = async (setupConfig: TConfigInput): Promise => {
// eslint-disable-next-line no-console -- legacy init
console.warn("🧱 Formbricks - Warning: Using legacy init");
}
- queue.add(Setup.setup, false, {
+ await queue.add(Setup.setup, CommandType.Setup, false, {
...setupConfig,
// @ts-expect-error -- apiHost was in the older type
...(setupConfig.apiHost && { appUrl: setupConfig.apiHost as string }),
} as unknown as TConfigInput);
} else {
- queue.add(Setup.setup, false, setupConfig);
- await queue.wait();
+ await queue.add(Setup.setup, CommandType.Setup, false, setupConfig);
}
+
+ // wait for setup to complete
+ await queue.wait();
};
const setUserId = async (userId: string): Promise => {
- queue.add(User.setUserId, true, userId);
- await queue.wait();
+ await queue.add(User.setUserId, CommandType.UserAction, true, userId);
};
const setEmail = async (email: string): Promise => {
- await setAttribute("email", email);
- await queue.wait();
+ await queue.add(Attribute.setAttributes, CommandType.UserAction, true, { email });
};
const setAttribute = async (key: string, value: string): Promise => {
- queue.add(Attribute.setAttributes, true, { [key]: value });
- await queue.wait();
+ await queue.add(Attribute.setAttributes, CommandType.UserAction, true, { [key]: value });
};
const setAttributes = async (attributes: Record): Promise => {
- queue.add(Attribute.setAttributes, true, attributes);
- await queue.wait();
+ await queue.add(Attribute.setAttributes, CommandType.UserAction, true, attributes);
};
const setLanguage = async (language: string): Promise => {
- queue.add(Attribute.setAttributes, true, { language });
- await queue.wait();
+ await queue.add(Attribute.setAttributes, CommandType.UserAction, true, { language });
};
const logout = async (): Promise => {
- queue.add(User.logout, true);
- await queue.wait();
+ await queue.add(User.logout, CommandType.GeneralAction);
};
/**
@@ -73,13 +69,11 @@ const logout = async (): Promise => {
* @param properties - Optional properties to set, like the hidden fields (deprecated, hidden fields will be removed in a future version)
*/
const track = async (code: string, properties?: TTrackProperties): Promise => {
- queue.add(Action.trackCodeAction, true, code, properties);
- await queue.wait();
+ await queue.add(Action.trackCodeAction, CommandType.GeneralAction, true, code, properties);
};
const registerRouteChange = async (): Promise => {
- queue.add(checkPageUrl, true);
- await queue.wait();
+ await queue.add(checkPageUrl, CommandType.GeneralAction);
};
const formbricks = {
diff --git a/packages/js-core/src/lib/common/command-queue.ts b/packages/js-core/src/lib/common/command-queue.ts
index f1dcd2f8fb..e91dedc478 100644
--- a/packages/js-core/src/lib/common/command-queue.ts
+++ b/packages/js-core/src/lib/common/command-queue.ts
@@ -1,32 +1,68 @@
/* eslint-disable @typescript-eslint/no-explicit-any -- required for command queue */
/* eslint-disable no-console -- we need to log global errors */
-import { checkSetup } from "@/lib/common/setup";
+import { checkSetup } from "@/lib/common/status";
import { wrapThrowsAsync } from "@/lib/common/utils";
-import type { Result } from "@/types/error";
+import { UpdateQueue } from "@/lib/user/update-queue";
+import { type Result } from "@/types/error";
export type TCommand = (
...args: any[]
) => Promise> | Result | Promise;
+export enum CommandType {
+ Setup,
+ UserAction,
+ GeneralAction,
+}
+
+interface InternalQueueItem {
+ command: TCommand;
+ type: CommandType;
+ checkSetup: boolean;
+ commandArgs: any[];
+}
+
export class CommandQueue {
- private queue: {
- command: TCommand;
- checkSetup: boolean;
- commandArgs: any[];
- }[] = [];
+ private queue: InternalQueueItem[] = [];
private running = false;
private resolvePromise: (() => void) | null = null;
private commandPromise: Promise | null = null;
+ private static instance: CommandQueue | null = null;
- public add(command: TCommand, shouldCheckSetup = true, ...args: A[]): void {
- this.queue.push({ command, checkSetup: shouldCheckSetup, commandArgs: args });
+ public static getInstance(): CommandQueue {
+ CommandQueue.instance ??= new CommandQueue();
+ return CommandQueue.instance;
+ }
- if (!this.running) {
- this.commandPromise = new Promise((resolve) => {
- this.resolvePromise = resolve;
- void this.run();
- });
- }
+ public add(
+ command: TCommand,
+ type: CommandType,
+ shouldCheckSetupFlag = true,
+ ...args: any[]
+ ): Promise> {
+ return new Promise((addResolve) => {
+ try {
+ const newItem: InternalQueueItem = {
+ command,
+ type,
+ checkSetup: shouldCheckSetupFlag,
+ commandArgs: args,
+ };
+
+ this.queue.push(newItem);
+
+ if (!this.running) {
+ this.commandPromise = new Promise((resolve) => {
+ this.resolvePromise = resolve;
+ void this.run();
+ });
+ }
+
+ addResolve({ ok: true, data: undefined });
+ } catch (error) {
+ addResolve({ ok: false, error: error as Error });
+ }
+ });
}
public async wait(): Promise {
@@ -37,21 +73,29 @@ export class CommandQueue {
private async run(): Promise {
this.running = true;
+
while (this.queue.length > 0) {
const currentItem = this.queue.shift();
if (!currentItem) continue;
- // make sure formbricks is setup
if (currentItem.checkSetup) {
- // call different function based on package type
const setupResult = checkSetup();
-
if (!setupResult.ok) {
+ console.warn(`🧱 Formbricks - Setup not complete.`);
continue;
}
}
+ if (currentItem.type === CommandType.GeneralAction) {
+ // first check if there are pending updates in the update queue
+ const updateQueue = UpdateQueue.getInstance();
+ if (!updateQueue.isEmpty()) {
+ console.log("🧱 Formbricks - Waiting for pending updates to complete before executing command");
+ await updateQueue.processUpdates();
+ }
+ }
+
const executeCommand = async (): Promise> => {
return (await currentItem.command.apply(null, currentItem.commandArgs)) as Result;
};
@@ -64,6 +108,7 @@ export class CommandQueue {
console.error("🧱 Formbricks - Global error: ", result.data.error);
}
}
+
this.running = false;
if (this.resolvePromise) {
this.resolvePromise();
diff --git a/packages/js-core/src/lib/common/config.ts b/packages/js-core/src/lib/common/config.ts
index c4f6aa833d..f85fc66504 100644
--- a/packages/js-core/src/lib/common/config.ts
+++ b/packages/js-core/src/lib/common/config.ts
@@ -16,10 +16,7 @@ export class Config {
}
static getInstance(): Config {
- if (!Config.instance) {
- Config.instance = new Config();
- }
-
+ Config.instance ??= new Config();
return Config.instance;
}
diff --git a/packages/js-core/src/lib/common/setup.ts b/packages/js-core/src/lib/common/setup.ts
index 4c471db3bc..78d2fe2c4f 100644
--- a/packages/js-core/src/lib/common/setup.ts
+++ b/packages/js-core/src/lib/common/setup.ts
@@ -1,12 +1,9 @@
/* eslint-disable no-console -- required for logging */
import { Config } from "@/lib/common/config";
import { JS_LOCAL_STORAGE_KEY } from "@/lib/common/constants";
-import {
- addCleanupEventListeners,
- addEventListeners,
- removeAllEventListeners,
-} from "@/lib/common/event-listeners";
+import { addCleanupEventListeners, addEventListeners } from "@/lib/common/event-listeners";
import { Logger } from "@/lib/common/logger";
+import { getIsSetup, setIsSetup } from "@/lib/common/status";
import { filterSurveys, getIsDebug, isNowExpired, wrapThrows } from "@/lib/common/utils";
import { fetchEnvironmentState } from "@/lib/environment/state";
import { checkPageUrl } from "@/lib/survey/no-code-action";
@@ -24,18 +21,11 @@ import {
type MissingFieldError,
type MissingPersonError,
type NetworkError,
- type NotSetupError,
type Result,
err,
okVoid,
} from "@/types/error";
-let isSetup = false;
-
-export const setIsSetup = (state: boolean): void => {
- isSetup = state;
-};
-
const migrateLocalStorage = (): { changed: boolean; newState?: TConfig } => {
const existingConfig = localStorage.getItem(JS_LOCAL_STORAGE_KEY);
@@ -99,7 +89,7 @@ export const setup = async (
}
}
- if (isSetup) {
+ if (getIsSetup()) {
logger.debug("Already set up, skipping setup.");
return okVoid();
}
@@ -334,35 +324,26 @@ export const setup = async (
return okVoid();
};
-export const checkSetup = (): Result => {
- const logger = Logger.getInstance();
- logger.debug("Check if set up");
-
- if (!isSetup) {
- return err({
- code: "not_setup",
- message: "Formbricks is not set up. Call setup() first.",
- });
- }
-
- return okVoid();
-};
-
export const tearDown = (): void => {
const logger = Logger.getInstance();
const appConfig = Config.getInstance();
+ const { environment } = appConfig.get();
+ const filteredSurveys = filterSurveys(environment, DEFAULT_USER_STATE_NO_USER_ID);
+
logger.debug("Setting user state to default");
+
// clear the user state and set it to the default value
appConfig.update({
...appConfig.get(),
user: DEFAULT_USER_STATE_NO_USER_ID,
+ filteredSurveys,
});
+ // remove container element from DOM
removeWidgetContainer();
+ addWidgetContainer();
setIsSurveyRunning(false);
- removeAllEventListeners();
- setIsSetup(false);
};
export const handleErrorOnFirstSetup = (e: { code: string; responseMessage: string }): Promise => {
diff --git a/packages/js-core/src/lib/common/status.ts b/packages/js-core/src/lib/common/status.ts
new file mode 100644
index 0000000000..0a514cab3d
--- /dev/null
+++ b/packages/js-core/src/lib/common/status.ts
@@ -0,0 +1,26 @@
+import { Logger } from "@/lib/common/logger";
+import { type NotSetupError, type Result, err, okVoid } from "@/types/error";
+
+let isSetup = false;
+
+export const setIsSetup = (state: boolean): void => {
+ isSetup = state;
+};
+
+export const getIsSetup = (): boolean => {
+ return isSetup;
+};
+
+export const checkSetup = (): Result => {
+ const logger = Logger.getInstance();
+ logger.debug("Check if set up");
+
+ if (!isSetup) {
+ return err({
+ code: "not_setup",
+ message: "Formbricks is not set up. Call setup() first.",
+ });
+ }
+
+ return okVoid();
+};
diff --git a/packages/js-core/src/lib/common/tests/command-queue.test.ts b/packages/js-core/src/lib/common/tests/command-queue.test.ts
index 5609693d6b..72bde2258d 100644
--- a/packages/js-core/src/lib/common/tests/command-queue.test.ts
+++ b/packages/js-core/src/lib/common/tests/command-queue.test.ts
@@ -1,13 +1,24 @@
-import { CommandQueue } from "@/lib/common/command-queue";
-import { checkSetup } from "@/lib/common/setup";
+import { CommandQueue, CommandType } from "@/lib/common/command-queue";
+import { checkSetup } from "@/lib/common/status";
+import { UpdateQueue } from "@/lib/user/update-queue";
import { type Result } from "@/types/error";
import { beforeEach, describe, expect, test, vi } from "vitest";
// Mock the setup module so we can control checkSetup()
-vi.mock("@/lib/common/setup", () => ({
+vi.mock("@/lib/common/status", () => ({
checkSetup: vi.fn(),
}));
+// Mock the UpdateQueue
+vi.mock("@/lib/user/update-queue", () => ({
+ UpdateQueue: {
+ getInstance: vi.fn(() => ({
+ isEmpty: vi.fn(),
+ processUpdates: vi.fn(),
+ })),
+ },
+}));
+
describe("CommandQueue", () => {
let queue: CommandQueue;
@@ -51,9 +62,9 @@ describe("CommandQueue", () => {
vi.mocked(checkSetup).mockReturnValue({ ok: true, data: undefined });
// Enqueue commands
- queue.add(cmdA, true);
- queue.add(cmdB, true);
- queue.add(cmdC, true);
+ await queue.add(cmdA, CommandType.GeneralAction, true);
+ await queue.add(cmdB, CommandType.GeneralAction, true);
+ await queue.add(cmdC, CommandType.GeneralAction, true);
// Wait for them to finish
await queue.wait();
@@ -79,7 +90,7 @@ describe("CommandQueue", () => {
},
});
- queue.add(cmd, true);
+ await queue.add(cmd, CommandType.GeneralAction, true);
await queue.wait();
// Command should never have been called
@@ -99,7 +110,7 @@ describe("CommandQueue", () => {
vi.mocked(checkSetup).mockReturnValue({ ok: true, data: undefined });
// Here we pass 'false' for the second argument, so no check is performed
- queue.add(cmd, false);
+ await queue.add(cmd, CommandType.GeneralAction, false);
await queue.wait();
expect(cmd).toHaveBeenCalledTimes(1);
@@ -128,7 +139,7 @@ describe("CommandQueue", () => {
throw new Error("some error");
});
- queue.add(failingCmd, true);
+ await queue.add(failingCmd, CommandType.GeneralAction, true);
await queue.wait();
expect(consoleErrorSpy).toHaveBeenCalledWith("🧱 Formbricks - Global error: ", expect.any(Error));
@@ -153,8 +164,8 @@ describe("CommandQueue", () => {
vi.mocked(checkSetup).mockReturnValue({ ok: true, data: undefined });
- queue.add(cmd1, true);
- queue.add(cmd2, true);
+ await queue.add(cmd1, CommandType.GeneralAction, true);
+ await queue.add(cmd2, CommandType.GeneralAction, true);
await queue.wait();
@@ -162,4 +173,70 @@ describe("CommandQueue", () => {
expect(cmd1).toHaveBeenCalled();
expect(cmd2).toHaveBeenCalled();
});
+
+ test("processes UpdateQueue before executing GeneralAction commands", async () => {
+ const mockUpdateQueue = {
+ isEmpty: vi.fn().mockReturnValue(false),
+ processUpdates: vi.fn().mockResolvedValue("test"),
+ };
+
+ const mockUpdateQueueInstance = vi.spyOn(UpdateQueue, "getInstance");
+ mockUpdateQueueInstance.mockReturnValue(mockUpdateQueue as unknown as UpdateQueue);
+
+ const generalActionCmd = vi.fn((): Promise> => {
+ return Promise.resolve({ ok: true, data: undefined });
+ });
+
+ vi.mocked(checkSetup).mockReturnValue({ ok: true, data: undefined });
+
+ await queue.add(generalActionCmd, CommandType.GeneralAction, true);
+ await queue.wait();
+
+ expect(mockUpdateQueue.isEmpty).toHaveBeenCalled();
+ expect(mockUpdateQueue.processUpdates).toHaveBeenCalled();
+ expect(generalActionCmd).toHaveBeenCalled();
+ });
+
+ test("implements singleton pattern correctly", () => {
+ const instance1 = CommandQueue.getInstance();
+ const instance2 = CommandQueue.getInstance();
+ expect(instance1).toBe(instance2);
+ });
+
+ test("handles multiple commands with different types and setup checks", async () => {
+ const executionOrder: string[] = [];
+
+ const cmd1 = vi.fn((): Promise> => {
+ executionOrder.push("cmd1");
+ return Promise.resolve({ ok: true, data: undefined });
+ });
+
+ const cmd2 = vi.fn((): Promise> => {
+ executionOrder.push("cmd2");
+ return Promise.resolve({ ok: true, data: undefined });
+ });
+
+ const cmd3 = vi.fn((): Promise> => {
+ executionOrder.push("cmd3");
+ return Promise.resolve({ ok: true, data: undefined });
+ });
+
+ // Setup check will fail for cmd2
+ vi.mocked(checkSetup)
+ .mockReturnValueOnce({ ok: true, data: undefined }) // for cmd1
+ .mockReturnValueOnce({ ok: false, error: { code: "not_setup", message: "Not setup" } }) // for cmd2
+ .mockReturnValueOnce({ ok: true, data: undefined }); // for cmd3
+
+ await queue.add(cmd1, CommandType.Setup, true);
+ await queue.add(cmd2, CommandType.UserAction, true);
+ await queue.add(cmd3, CommandType.GeneralAction, true);
+
+ await queue.wait();
+
+ // cmd2 should be skipped due to failed setup check
+ expect(executionOrder).toEqual(["cmd1", "cmd3"]);
+ expect(cmd1).toHaveBeenCalled();
+ expect(cmd2).not.toHaveBeenCalled();
+ expect(cmd3).toHaveBeenCalled();
+ });
});
diff --git a/packages/js-core/src/lib/common/tests/setup.test.ts b/packages/js-core/src/lib/common/tests/setup.test.ts
index f895b1fc38..08923b3221 100644
--- a/packages/js-core/src/lib/common/tests/setup.test.ts
+++ b/packages/js-core/src/lib/common/tests/setup.test.ts
@@ -1,13 +1,10 @@
/* eslint-disable @typescript-eslint/unbound-method -- required for testing */
import { Config } from "@/lib/common/config";
import { JS_LOCAL_STORAGE_KEY } from "@/lib/common/constants";
-import {
- addCleanupEventListeners,
- addEventListeners,
- removeAllEventListeners,
-} from "@/lib/common/event-listeners";
+import { addCleanupEventListeners, addEventListeners } from "@/lib/common/event-listeners";
import { Logger } from "@/lib/common/logger";
-import { checkSetup, handleErrorOnFirstSetup, setIsSetup, setup, tearDown } from "@/lib/common/setup";
+import { handleErrorOnFirstSetup, setup, tearDown } from "@/lib/common/setup";
+import { setIsSetup } from "@/lib/common/status";
import { filterSurveys, isNowExpired } from "@/lib/common/utils";
import { fetchEnvironmentState } from "@/lib/environment/state";
import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state";
@@ -287,24 +284,8 @@ describe("setup.ts", () => {
});
});
- describe("checkSetup()", () => {
- test("returns err if not setup", () => {
- const res = checkSetup();
- expect(res.ok).toBe(false);
- if (!res.ok) {
- expect(res.error.code).toBe("not_setup");
- }
- });
-
- test("returns ok if setup", () => {
- setIsSetup(true);
- const res = checkSetup();
- expect(res.ok).toBe(true);
- });
- });
-
describe("tearDown()", () => {
- test("resets user state to default and removes event listeners", () => {
+ test("resets user state to default", () => {
const mockConfig = {
get: vi.fn().mockReturnValue({
user: { data: { userId: "XYZ" } },
@@ -321,7 +302,7 @@ describe("setup.ts", () => {
user: DEFAULT_USER_STATE_NO_USER_ID,
})
);
- expect(removeAllEventListeners).toHaveBeenCalled();
+ expect(filterSurveys).toHaveBeenCalled();
});
});
diff --git a/packages/js-core/src/lib/common/tests/status.test.ts b/packages/js-core/src/lib/common/tests/status.test.ts
new file mode 100644
index 0000000000..48c78e30c6
--- /dev/null
+++ b/packages/js-core/src/lib/common/tests/status.test.ts
@@ -0,0 +1,41 @@
+import { checkSetup, getIsSetup, setIsSetup } from "@/lib/common/status";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+describe("checkSetup()", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ setIsSetup(false);
+ });
+
+ test("returns err if not setup", () => {
+ const res = checkSetup();
+ expect(res.ok).toBe(false);
+ if (!res.ok) {
+ expect(res.error.code).toBe("not_setup");
+ }
+ });
+
+ test("returns ok if setup", () => {
+ setIsSetup(true);
+ const res = checkSetup();
+ expect(res.ok).toBe(true);
+ });
+});
+
+describe("getIsSetup()", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ setIsSetup(false);
+ });
+
+ test("returns false if not setup", () => {
+ const res = getIsSetup();
+ expect(res).toBe(false);
+ });
+
+ test("returns true if setup", () => {
+ setIsSetup(true);
+ const res = getIsSetup();
+ expect(res).toBe(true);
+ });
+});
diff --git a/packages/js-core/src/lib/common/tests/utils.test.ts b/packages/js-core/src/lib/common/tests/utils.test.ts
index acb4689352..4bb402fc11 100644
--- a/packages/js-core/src/lib/common/tests/utils.test.ts
+++ b/packages/js-core/src/lib/common/tests/utils.test.ts
@@ -8,7 +8,9 @@ import {
getDefaultLanguageCode,
getIsDebug,
getLanguageCode,
+ getSecureRandom,
getStyling,
+ handleHiddenFields,
handleUrlFilters,
isNowExpired,
shouldDisplayBasedOnPercentage,
@@ -23,7 +25,7 @@ import type {
TSurveyStyling,
TUserState,
} from "@/types/config";
-import { type TActionClassPageUrlRule } from "@/types/survey";
+import { type TActionClassNoCodeConfig, type TActionClassPageUrlRule } from "@/types/survey";
import { beforeEach, describe, expect, test, vi } from "vitest";
const mockSurveyId1 = "e3kxlpnzmdp84op9qzxl9olj";
@@ -61,7 +63,49 @@ describe("utils.ts", () => {
test("returns ok on success", () => {
const fn = vi.fn(() => "success");
const wrapped = wrapThrows(fn);
- expect(wrapped()).toEqual({ ok: true, data: "success" });
+ const result = wrapped();
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.data).toBe("success");
+ }
+ });
+
+ test("returns err on error", () => {
+ const fn = vi.fn(() => {
+ throw new Error("Something broke");
+ });
+ const wrapped = wrapThrows(fn);
+ const result = wrapped();
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.error.message).toBe("Something broke");
+ }
+ });
+
+ test("passes arguments to wrapped function", () => {
+ const fn = vi.fn((a: number, b: number) => a + b);
+ const wrapped = wrapThrows(fn);
+ const result = wrapped(2, 3);
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.data).toBe(5);
+ }
+ expect(fn).toHaveBeenCalledWith(2, 3);
+ });
+
+ test("handles async function", () => {
+ const fn = vi.fn(async () => {
+ await new Promise((r) => {
+ setTimeout(r, 10);
+ });
+ return "async success";
+ });
+ const wrapped = wrapThrows(fn);
+ const result = wrapped();
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.data).toBeInstanceOf(Promise);
+ }
});
});
@@ -561,6 +605,55 @@ describe("utils.ts", () => {
const result = handleUrlFilters(urlFilters);
expect(result).toBe(true);
});
+
+ test("returns true if urlFilters is empty", () => {
+ const urlFilters: TActionClassNoCodeConfig["urlFilters"] = [];
+
+ const result = handleUrlFilters(urlFilters);
+ expect(result).toBe(true);
+ });
+
+ test("returns false if no urlFilters match", () => {
+ const urlFilters = [
+ {
+ value: "https://example.com/other",
+ rule: "exactMatch" as unknown as TActionClassPageUrlRule,
+ },
+ ];
+
+ // mock window.location.href
+ vi.stubGlobal("window", {
+ location: {
+ href: "https://example.com/path",
+ },
+ });
+
+ const result = handleUrlFilters(urlFilters);
+ expect(result).toBe(false);
+ });
+
+ test("returns true if any urlFilter matches", () => {
+ const urlFilters = [
+ {
+ value: "https://example.com/other",
+ rule: "exactMatch" as unknown as TActionClassPageUrlRule,
+ },
+ {
+ value: "path",
+ rule: "contains" as unknown as TActionClassPageUrlRule,
+ },
+ ];
+
+ // mock window.location.href
+ vi.stubGlobal("window", {
+ location: {
+ href: "https://example.com/path",
+ },
+ });
+
+ const result = handleUrlFilters(urlFilters);
+ expect(result).toBe(true);
+ });
});
// ---------------------------------------------------------------------------------
@@ -571,12 +664,12 @@ describe("utils.ts", () => {
const targetElement = document.createElement("div");
const action: TEnvironmentStateActionClass = {
- id: "clabc123abc", // some valid cuid2 or placeholder
+ id: "clabc123abc",
name: "Test Action",
- type: "noCode", // or "code", but here we have noCode
+ type: "noCode",
key: null,
noCodeConfig: {
- type: "pageView", // the mismatch
+ type: "pageView",
urlFilters: [],
},
};
@@ -590,7 +683,7 @@ describe("utils.ts", () => {
targetElement.innerHTML = "Test";
const action: TEnvironmentStateActionClass = {
- id: "clabc123abc", // some valid cuid2 or placeholder
+ id: "clabc123abc",
name: "Test Action",
type: "noCode",
key: null,
@@ -615,7 +708,7 @@ describe("utils.ts", () => {
targetElement.matches = vi.fn(() => true);
const action: TEnvironmentStateActionClass = {
- id: "clabc123abc", // some valid cuid2 or placeholder
+ id: "clabc123abc",
name: "Test Action",
type: "noCode",
key: null,
@@ -640,14 +733,35 @@ describe("utils.ts", () => {
targetElement.matches = vi.fn(() => false);
const action: TEnvironmentStateActionClass = {
- id: "clabc123abc", // some valid cuid2 or placeholder
+ id: "clabc123abc",
name: "Test Action",
type: "noCode",
key: null,
noCodeConfig: {
type: "click",
urlFilters: [],
- elementSelector: { cssSelector },
+ elementSelector: {
+ cssSelector,
+ },
+ },
+ };
+
+ const result = evaluateNoCodeConfigClick(targetElement, action);
+ expect(result).toBe(false);
+ });
+
+ test("returns false if neither innerHtml nor cssSelector is provided", () => {
+ const targetElement = document.createElement("div");
+
+ const action: TEnvironmentStateActionClass = {
+ id: "clabc123abc",
+ name: "Test Action",
+ type: "noCode",
+ key: null,
+ noCodeConfig: {
+ type: "click",
+ urlFilters: [],
+ elementSelector: {},
},
};
@@ -657,44 +771,240 @@ describe("utils.ts", () => {
test("returns false if urlFilters do not match", () => {
const targetElement = document.createElement("div");
- const urlFilters = [
- {
- value: "https://example.com/path",
- rule: "exactMatch" as unknown as TActionClassPageUrlRule,
+ targetElement.innerHTML = "Test";
+
+ // mock window.location.href
+ vi.stubGlobal("window", {
+ location: {
+ href: "https://example.com/path",
},
- ];
+ });
const action: TEnvironmentStateActionClass = {
- id: "clabc123abc", // some valid cuid2 or placeholder
+ id: "clabc123abc",
name: "Test Action",
type: "noCode",
key: null,
noCodeConfig: {
type: "click",
- urlFilters,
- elementSelector: {},
+ urlFilters: [
+ {
+ value: "https://example.com/other",
+ rule: "exactMatch" as unknown as TActionClassPageUrlRule,
+ },
+ ],
+ elementSelector: {
+ innerHtml: "Test",
+ },
},
};
const result = evaluateNoCodeConfigClick(targetElement, action);
expect(result).toBe(false);
});
+
+ test("returns true if both innerHtml and urlFilters match", () => {
+ const targetElement = document.createElement("div");
+ targetElement.innerHTML = "Test";
+
+ // mock window.location.href
+ vi.stubGlobal("window", {
+ location: {
+ href: "https://example.com/path",
+ },
+ });
+
+ const action: TEnvironmentStateActionClass = {
+ id: "clabc123abc",
+ name: "Test Action",
+ type: "noCode",
+ key: null,
+ noCodeConfig: {
+ type: "click",
+ urlFilters: [
+ {
+ value: "path",
+ rule: "contains" as unknown as TActionClassPageUrlRule,
+ },
+ ],
+ elementSelector: {
+ innerHtml: "Test",
+ },
+ },
+ };
+
+ const result = evaluateNoCodeConfigClick(targetElement, action);
+ expect(result).toBe(true);
+ });
+
+ test("handles multiple cssSelectors correctly", () => {
+ const targetElement = document.createElement("div");
+ targetElement.className = "test other";
+
+ targetElement.matches = vi.fn((selector) => {
+ return selector === ".test" || selector === ".other";
+ });
+
+ const action: TEnvironmentStateActionClass = {
+ id: "clabc123abc",
+ name: "Test Action",
+ type: "noCode",
+ key: null,
+ noCodeConfig: {
+ type: "click",
+ urlFilters: [],
+ elementSelector: {
+ cssSelector: ".test .other",
+ },
+ },
+ };
+
+ const result = evaluateNoCodeConfigClick(targetElement, action);
+ expect(result).toBe(true);
+ });
});
// ---------------------------------------------------------------------------------
// getIsDebug
// ---------------------------------------------------------------------------------
describe("getIsDebug()", () => {
- test("returns true if debug param is set", () => {
- // mock window.location.search
- vi.stubGlobal("window", {
- location: {
- search: "?formbricksDebug=true",
- },
+ beforeEach(() => {
+ // Reset window.location.search before each test
+ Object.defineProperty(window, "location", {
+ value: { search: "" },
+ writable: true,
});
+ });
- const result = getIsDebug();
- expect(result).toBe(true);
+ test("returns true if debug parameter is set", () => {
+ Object.defineProperty(window, "location", {
+ value: { search: "?formbricksDebug=true" },
+ writable: true,
+ });
+ expect(getIsDebug()).toBe(true);
+ });
+
+ test("returns false if debug parameter is not set", () => {
+ Object.defineProperty(window, "location", {
+ value: { search: "?otherParam=value" },
+ writable: true,
+ });
+ expect(getIsDebug()).toBe(false);
+ });
+
+ test("returns false if search string is empty", () => {
+ Object.defineProperty(window, "location", {
+ value: { search: "" },
+ writable: true,
+ });
+ expect(getIsDebug()).toBe(false);
+ });
+
+ test("returns false if search string is just '?'", () => {
+ Object.defineProperty(window, "location", {
+ value: { search: "?" },
+ writable: true,
+ });
+ expect(getIsDebug()).toBe(false);
+ });
+ });
+
+ // ---------------------------------------------------------------------------------
+ // handleHiddenFields
+ // ---------------------------------------------------------------------------------
+ describe("handleHiddenFields()", () => {
+ test("returns empty object when hidden fields are not enabled", () => {
+ const hiddenFieldsConfig = {
+ enabled: false,
+ fieldIds: ["field1", "field2"],
+ };
+ const hiddenFields = {
+ field1: "value1",
+ field2: "value2",
+ };
+
+ const result = handleHiddenFields(hiddenFieldsConfig, hiddenFields);
+ expect(result).toEqual({});
+ });
+
+ test("returns empty object when no hidden fields are provided", () => {
+ const hiddenFieldsConfig = {
+ enabled: true,
+ fieldIds: ["field1", "field2"],
+ };
+
+ const result = handleHiddenFields(hiddenFieldsConfig);
+ expect(result).toEqual({});
+ });
+
+ test("filters and returns only valid hidden fields", () => {
+ const hiddenFieldsConfig = {
+ enabled: true,
+ fieldIds: ["field1", "field2"],
+ };
+ const hiddenFields = {
+ field1: "value1",
+ field2: "value2",
+ field3: "value3", // This should be filtered out
+ };
+
+ const result = handleHiddenFields(hiddenFieldsConfig, hiddenFields);
+ expect(result).toEqual({
+ field1: "value1",
+ field2: "value2",
+ });
+ });
+
+ test("handles empty fieldIds array", () => {
+ const hiddenFieldsConfig = {
+ enabled: true,
+ fieldIds: [],
+ };
+ const hiddenFields = {
+ field1: "value1",
+ field2: "value2",
+ };
+
+ const result = handleHiddenFields(hiddenFieldsConfig, hiddenFields);
+ expect(result).toEqual({});
+ });
+
+ test("handles null fieldIds", () => {
+ const hiddenFieldsConfig = {
+ enabled: true,
+ fieldIds: undefined,
+ };
+ const hiddenFields = {
+ field1: "value1",
+ field2: "value2",
+ };
+
+ const result = handleHiddenFields(hiddenFieldsConfig, hiddenFields);
+ expect(result).toEqual({});
+ });
+ });
+
+ // ---------------------------------------------------------------------------------
+ // getSecureRandom
+ // ---------------------------------------------------------------------------------
+ describe("getSecureRandom()", () => {
+ test("returns a number between 0 and 1", () => {
+ const result = getSecureRandom();
+ expect(result).toBeGreaterThanOrEqual(0);
+ expect(result).toBeLessThan(1);
+ });
+
+ test("returns different values on subsequent calls", () => {
+ const result1 = getSecureRandom();
+ const result2 = getSecureRandom();
+ expect(result1).not.toBe(result2);
+ });
+
+ test("uses crypto.getRandomValues", () => {
+ const mockGetRandomValues = vi.spyOn(crypto, "getRandomValues");
+ getSecureRandom();
+ expect(mockGetRandomValues).toHaveBeenCalled();
+ mockGetRandomValues.mockRestore();
});
});
});
diff --git a/packages/js-core/src/lib/common/utils.ts b/packages/js-core/src/lib/common/utils.ts
index 2d23de1445..9c33966744 100644
--- a/packages/js-core/src/lib/common/utils.ts
+++ b/packages/js-core/src/lib/common/utils.ts
@@ -121,7 +121,11 @@ export const filterSurveys = (
});
if (!userId) {
- return filteredSurveys;
+ // exclude surveys that have a segment with filters
+ return filteredSurveys.filter((survey) => {
+ const segmentFiltersLength = survey.segment?.filters.length ?? 0;
+ return segmentFiltersLength === 0;
+ });
}
if (!segments.length) {
diff --git a/packages/js-core/src/lib/survey/no-code-action.ts b/packages/js-core/src/lib/survey/no-code-action.ts
index 40de3fb2c8..c1d68a53c0 100644
--- a/packages/js-core/src/lib/survey/no-code-action.ts
+++ b/packages/js-core/src/lib/survey/no-code-action.ts
@@ -1,12 +1,33 @@
/* eslint-disable no-console -- required for logging */
+import { CommandQueue, CommandType } from "@/lib/common/command-queue";
import { Config } from "@/lib/common/config";
import { Logger } from "@/lib/common/logger";
import { TimeoutStack } from "@/lib/common/timeout-stack";
import { evaluateNoCodeConfigClick, handleUrlFilters } from "@/lib/common/utils";
import { trackNoCodeAction } from "@/lib/survey/action";
import { setIsSurveyRunning } from "@/lib/survey/widget";
-import { type TEnvironmentStateActionClass } from "@/types/config";
-import { type NetworkError, type Result, type ResultError, err, match, okVoid } from "@/types/error";
+import { type Result } from "@/types/error";
+
+// Factory for creating context-specific tracking handlers
+export const createTrackNoCodeActionWithContext = (context: string) => {
+ return async (actionName: string): Promise> => {
+ const result = await trackNoCodeAction(actionName);
+ if (!result.ok) {
+ const errorToLog = result.error as { message?: string };
+ const errorMessageText = errorToLog.message ?? "An unknown error occurred.";
+ console.error(
+ `🧱 Formbricks - Error in no-code ${context} action '${actionName}': ${errorMessageText}`,
+ errorToLog
+ );
+ }
+ return result;
+ };
+};
+
+const trackNoCodePageViewActionHandler = createTrackNoCodeActionWithContext("page view");
+const trackNoCodeClickActionHandler = createTrackNoCodeActionWithContext("click");
+const trackNoCodeExitIntentActionHandler = createTrackNoCodeActionWithContext("exit intent");
+const trackNoCodeScrollActionHandler = createTrackNoCodeActionWithContext("scroll");
// Event types for various listeners
const events = ["hashchange", "popstate", "pushstate", "replacestate", "load"];
@@ -18,7 +39,8 @@ export const setIsHistoryPatched = (value: boolean): void => {
isHistoryPatched = value;
};
-export const checkPageUrl = async (): Promise> => {
+export const checkPageUrl = async (): Promise> => {
+ const queue = CommandQueue.getInstance();
const appConfig = Config.getInstance();
const logger = Logger.getInstance();
const timeoutStack = TimeoutStack.getInstance();
@@ -35,11 +57,7 @@ export const checkPageUrl = async (): Promise> => {
const isValidUrl = handleUrlFilters(urlFilters);
if (isValidUrl) {
- const trackResult = await trackNoCodeAction(event.name);
-
- if (!trackResult.ok) {
- return err(trackResult.error);
- }
+ await queue.add(trackNoCodePageViewActionHandler, CommandType.GeneralAction, true, event.name);
} else {
const scheduledTimeouts = timeoutStack.getTimeouts();
@@ -52,10 +70,12 @@ export const checkPageUrl = async (): Promise> => {
}
}
- return okVoid();
+ return { ok: true, data: undefined };
};
-const checkPageUrlWrapper = (): ReturnType => checkPageUrl();
+const checkPageUrlWrapper = (): void => {
+ void checkPageUrl();
+};
export const addPageUrlEventListeners = (): void => {
if (typeof window === "undefined" || arePageUrlEventListenersAdded) return;
@@ -92,7 +112,8 @@ export const removePageUrlEventListeners = (): void => {
// Click Event Handlers
let isClickEventListenerAdded = false;
-const checkClickMatch = (event: MouseEvent): void => {
+const checkClickMatch = async (event: MouseEvent): Promise => {
+ const queue = CommandQueue.getInstance();
const appConfig = Config.getInstance();
const { environment } = appConfig.get();
@@ -105,28 +126,15 @@ const checkClickMatch = (event: MouseEvent): void => {
const targetElement = event.target as HTMLElement;
- noCodeClickActionClasses.forEach((action: TEnvironmentStateActionClass) => {
+ for (const action of noCodeClickActionClasses) {
if (evaluateNoCodeConfigClick(targetElement, action)) {
- trackNoCodeAction(action.name)
- .then((res) => {
- match(
- res,
- (_value: unknown) => undefined,
- (actionError: unknown) => {
- // errorHandler.handle(actionError);
- console.error(actionError);
- }
- );
- })
- .catch((error: unknown) => {
- console.error(error);
- });
+ await queue.add(trackNoCodeClickActionHandler, CommandType.GeneralAction, true, action.name);
}
- });
+ }
};
const checkClickMatchWrapper = (e: MouseEvent): void => {
- checkClickMatch(e);
+ void checkClickMatch(e);
};
export const addClickEventListener = (): void => {
@@ -144,7 +152,8 @@ export const removeClickEventListener = (): void => {
// Exit Intent Handlers
let isExitIntentListenerAdded = false;
-const checkExitIntent = async (e: MouseEvent): Promise | undefined> => {
+const checkExitIntent = async (e: MouseEvent): Promise => {
+ const queue = CommandQueue.getInstance();
const appConfig = Config.getInstance();
const { environment } = appConfig.get();
@@ -161,13 +170,14 @@ const checkExitIntent = async (e: MouseEvent): Promise
if (!isValidUrl) continue;
- const trackResult = await trackNoCodeAction(event.name);
- if (!trackResult.ok) return err(trackResult.error);
+ await queue.add(trackNoCodeExitIntentActionHandler, CommandType.GeneralAction, true, event.name);
}
}
};
-const checkExitIntentWrapper = (e: MouseEvent): ReturnType => checkExitIntent(e);
+const checkExitIntentWrapper = (e: MouseEvent): void => {
+ void checkExitIntent(e);
+};
export const addExitIntentListener = (): void => {
if (typeof document !== "undefined" && !isExitIntentListenerAdded) {
@@ -189,7 +199,8 @@ export const removeExitIntentListener = (): void => {
let scrollDepthListenerAdded = false;
let scrollDepthTriggered = false;
-const checkScrollDepth = async (): Promise> => {
+const checkScrollDepth = async (): Promise => {
+ const queue = CommandQueue.getInstance();
const appConfig = Config.getInstance();
const scrollPosition = window.scrollY;
@@ -216,15 +227,14 @@ const checkScrollDepth = async (): Promise> => {
if (!isValidUrl) continue;
- const trackResult = await trackNoCodeAction(event.name);
- if (!trackResult.ok) return err(trackResult.error);
+ await queue.add(trackNoCodeScrollActionHandler, CommandType.GeneralAction, true, event.name);
}
}
-
- return okVoid();
};
-const checkScrollDepthWrapper = (): ReturnType => checkScrollDepth();
+const checkScrollDepthWrapper = (): void => {
+ void checkScrollDepth();
+};
export const addScrollDepthListener = (): void => {
if (typeof window !== "undefined" && !scrollDepthListenerAdded) {
diff --git a/packages/js-core/src/lib/survey/tests/no-code-action.test.ts b/packages/js-core/src/lib/survey/tests/no-code-action.test.ts
index c44e5a1219..182392f18c 100644
--- a/packages/js-core/src/lib/survey/tests/no-code-action.test.ts
+++ b/packages/js-core/src/lib/survey/tests/no-code-action.test.ts
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/unbound-method -- mock functions are unbound */
import { Config } from "@/lib/common/config";
+import { checkSetup } from "@/lib/common/status";
import { TimeoutStack } from "@/lib/common/timeout-stack";
import { handleUrlFilters } from "@/lib/common/utils";
import { trackNoCodeAction } from "@/lib/survey/action";
@@ -9,12 +10,14 @@ import {
addPageUrlEventListeners,
addScrollDepthListener,
checkPageUrl,
+ createTrackNoCodeActionWithContext,
removeClickEventListener,
removeExitIntentListener,
removePageUrlEventListeners,
removeScrollDepthListener,
} from "@/lib/survey/no-code-action";
import { setIsSurveyRunning } from "@/lib/survey/widget";
+import { TActionClassNoCodeConfig } from "@/types/survey";
import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("@/lib/common/config", () => ({
@@ -45,10 +48,15 @@ vi.mock("@/lib/common/timeout-stack", () => ({
},
}));
-vi.mock("@/lib/common/utils", () => ({
- handleUrlFilters: vi.fn(),
- evaluateNoCodeConfigClick: vi.fn(),
-}));
+vi.mock("@/lib/common/utils", async (importOriginal) => {
+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- We need this only for type inference
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ handleUrlFilters: vi.fn(),
+ evaluateNoCodeConfigClick: vi.fn(),
+ };
+});
vi.mock("@/lib/survey/action", () => ({
trackNoCodeAction: vi.fn(),
@@ -58,13 +66,53 @@ vi.mock("@/lib/survey/widget", () => ({
setIsSurveyRunning: vi.fn(),
}));
+vi.mock("@/lib/common/status", () => ({
+ checkSetup: vi.fn(),
+}));
+
+describe("createTrackNoCodeActionWithContext", () => {
+ test("should create a trackNoCodeAction with the correct context", () => {
+ const trackNoCodeActionWithContext = createTrackNoCodeActionWithContext("pageView");
+ expect(trackNoCodeActionWithContext).toBeDefined();
+ });
+
+ test("should log error if trackNoCodeAction fails", async () => {
+ const consoleErrorSpy = vi.spyOn(console, "error");
+ vi.mocked(trackNoCodeAction).mockResolvedValue({
+ ok: false,
+ error: {
+ code: "network_error",
+ message: "Network error",
+ status: 500,
+ url: new URL("https://example.com"),
+ responseMessage: "Network error",
+ },
+ });
+
+ const trackNoCodeActionWithContext = createTrackNoCodeActionWithContext("pageView");
+
+ expect(trackNoCodeActionWithContext).toBeDefined();
+ await trackNoCodeActionWithContext("noCodeAction");
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ `🧱 Formbricks - Error in no-code pageView action 'noCodeAction': Network error`,
+ {
+ code: "network_error",
+ message: "Network error",
+ status: 500,
+ url: new URL("https://example.com"),
+ responseMessage: "Network error",
+ }
+ );
+ });
+});
+
describe("no-code-event-listeners file", () => {
let getInstanceConfigMock: MockInstance<() => Config>;
let getInstanceTimeoutStackMock: MockInstance<() => TimeoutStack>;
beforeEach(() => {
vi.clearAllMocks();
-
getInstanceConfigMock = vi.spyOn(Config, "getInstance");
getInstanceTimeoutStackMock = vi.spyOn(TimeoutStack, "getInstance");
});
@@ -76,6 +124,7 @@ describe("no-code-event-listeners file", () => {
test("checkPageUrl calls handleUrlFilters & trackNoCodeAction for matching actionClasses", async () => {
(handleUrlFilters as Mock).mockReturnValue(true);
(trackNoCodeAction as Mock).mockResolvedValue({ ok: true });
+ (checkSetup as Mock).mockReturnValue({ ok: true });
const mockConfigValue = {
get: vi.fn().mockReturnValue({
@@ -99,11 +148,10 @@ describe("no-code-event-listeners file", () => {
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
- const result = await checkPageUrl();
+ await checkPageUrl();
expect(handleUrlFilters).toHaveBeenCalledWith([{ value: "/some-path", rule: "contains" }]);
expect(trackNoCodeAction).toHaveBeenCalledWith("pageViewAction");
- expect(result.ok).toBe(true);
});
test("checkPageUrl removes scheduled timeouts & calls setIsSurveyRunning(false) if invalid url", async () => {
@@ -138,12 +186,11 @@ describe("no-code-event-listeners file", () => {
getInstanceTimeoutStackMock.mockReturnValue(mockTimeoutStack as unknown as TimeoutStack);
- const result = await checkPageUrl();
+ await checkPageUrl();
expect(trackNoCodeAction).not.toHaveBeenCalled();
expect(mockTimeoutStack.remove).toHaveBeenCalledWith(123);
expect(setIsSurveyRunning).toHaveBeenCalledWith(false);
- expect(result.ok).toBe(true);
});
test("addPageUrlEventListeners adds event listeners to window, patches history if not patched", () => {
@@ -262,4 +309,347 @@ describe("no-code-event-listeners file", () => {
(window.removeEventListener as Mock).mockRestore();
});
+
+ // Test cases for Click Event Handlers
+ describe("Click Event Handlers", () => {
+ beforeEach(() => {
+ vi.stubGlobal("document", {
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ });
+ });
+
+ test("addClickEventListener does not add listener if window is undefined", () => {
+ vi.stubGlobal("window", undefined);
+ addClickEventListener();
+ expect(document.addEventListener).not.toHaveBeenCalled();
+ });
+
+ test("addClickEventListener does not re-add listener if already added", () => {
+ vi.stubGlobal("window", {}); // Ensure window is defined
+ addClickEventListener(); // First call
+ expect(document.addEventListener).toHaveBeenCalledTimes(1);
+ addClickEventListener(); // Second call
+ expect(document.addEventListener).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ // Test cases for Exit Intent Handlers
+ describe("Exit Intent Handlers", () => {
+ let querySelectorMock: MockInstance;
+ let addEventListenerMock: Mock;
+ let removeEventListenerMock: Mock;
+
+ beforeEach(() => {
+ addEventListenerMock = vi.fn();
+ removeEventListenerMock = vi.fn();
+
+ querySelectorMock = vi.fn().mockReturnValue({
+ addEventListener: addEventListenerMock,
+ removeEventListener: removeEventListenerMock,
+ });
+
+ vi.stubGlobal("document", {
+ querySelector: querySelectorMock,
+ removeEventListener: removeEventListenerMock, // For direct document.removeEventListener calls
+ });
+ (handleUrlFilters as Mock).mockReset(); // Reset mock for each test
+ });
+
+ test("addExitIntentListener does not add if document is undefined", () => {
+ vi.stubGlobal("document", undefined);
+ addExitIntentListener();
+ // No explicit expect, passes if no error. querySelector would not be called.
+ });
+
+ test("addExitIntentListener does not add if body is not found", () => {
+ querySelectorMock.mockReturnValue(null); // body not found
+ addExitIntentListener();
+ expect(addEventListenerMock).not.toHaveBeenCalled();
+ });
+
+ test("checkExitIntent does not trigger if clientY > 0", () => {
+ const mockAction = {
+ name: "exitAction",
+ type: "noCode",
+ noCodeConfig: { type: "exitIntent", urlFilters: [] },
+ };
+ const mockConfigValue = {
+ get: vi.fn().mockReturnValue({
+ environment: { data: { actionClasses: [mockAction] } },
+ }),
+ };
+ getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
+ (handleUrlFilters as Mock).mockReturnValue(true);
+
+ addExitIntentListener();
+
+ expect(handleUrlFilters).not.toHaveBeenCalled();
+ expect(trackNoCodeAction).not.toHaveBeenCalled();
+ });
+ });
+
+ // Test cases for Scroll Depth Handlers
+ describe("Scroll Depth Handlers", () => {
+ let addEventListenerSpy: MockInstance;
+ let removeEventListenerSpy: MockInstance;
+
+ beforeEach(() => {
+ addEventListenerSpy = vi.fn();
+ removeEventListenerSpy = vi.fn();
+ vi.stubGlobal("window", {
+ addEventListener: addEventListenerSpy,
+ removeEventListener: removeEventListenerSpy,
+ scrollY: 0,
+ innerHeight: 500,
+ });
+ vi.stubGlobal("document", {
+ readyState: "complete",
+ documentElement: {
+ scrollHeight: 2000, // bodyHeight > windowSize
+ },
+ });
+ (handleUrlFilters as Mock).mockReset();
+ (trackNoCodeAction as Mock).mockReset();
+ // Reset internal state variables (scrollDepthListenerAdded, scrollDepthTriggered)
+ // This is tricky without exporting them. We can call removeScrollDepthListener
+ // to reset scrollDepthListenerAdded. scrollDepthTriggered is reset if scrollY is 0.
+ removeScrollDepthListener(); // Resets scrollDepthListenerAdded
+ window.scrollY = 0; // Resets scrollDepthTriggered assumption in checkScrollDepth
+ });
+
+ afterEach(() => {
+ vi.stubGlobal("document", undefined);
+ });
+
+ test("addScrollDepthListener does not add if window is undefined", () => {
+ vi.stubGlobal("window", undefined);
+ addScrollDepthListener();
+ // No explicit expect. Passes if no error.
+ });
+
+ test("addScrollDepthListener does not re-add listener if already added", () => {
+ addScrollDepthListener(); // First call
+ expect(window.addEventListener).toHaveBeenCalledWith("scroll", expect.any(Function));
+ expect(window.addEventListener).toHaveBeenCalledTimes(1);
+
+ addScrollDepthListener(); // Second call
+ expect(window.addEventListener).toHaveBeenCalledTimes(1);
+ });
+
+ test("checkScrollDepth does nothing if no fiftyPercentScroll actions", async () => {
+ const mockConfigValue = {
+ get: vi.fn().mockReturnValue({
+ environment: { data: { actionClasses: [] } },
+ }),
+ };
+ getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
+
+ window.scrollY = 1000; // Past 50%
+
+ addScrollDepthListener();
+ const scrollCallback = addEventListenerSpy.mock.calls[0][1] as () => Promise; // Added type assertion
+ await scrollCallback();
+
+ expect(handleUrlFilters).not.toHaveBeenCalled();
+ expect(trackNoCodeAction).not.toHaveBeenCalled();
+ });
+
+ test("checkScrollDepth does not trigger if scroll < 50%", async () => {
+ const mockAction = {
+ name: "scrollAction",
+ type: "noCode",
+ noCodeConfig: { type: "fiftyPercentScroll", urlFilters: [] },
+ };
+ const mockConfigValue = {
+ get: vi.fn().mockReturnValue({
+ environment: { data: { actionClasses: [mockAction] } },
+ }),
+ };
+ getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
+ (handleUrlFilters as Mock).mockReturnValue(true);
+
+ window.scrollY = 200; // scrollPosition / (bodyHeight - windowSize) = 200 / (2000 - 500) = 200 / 1500 < 0.5
+
+ addScrollDepthListener();
+ const scrollCallback = addEventListenerSpy.mock.calls[0][1] as () => Promise; // Added type assertion
+ await scrollCallback();
+
+ expect(trackNoCodeAction).not.toHaveBeenCalled();
+ });
+
+ test("checkScrollDepth filters by URL", async () => {
+ (handleUrlFilters as Mock).mockImplementation(
+ (urlFilters: TActionClassNoCodeConfig["urlFilters"]) => urlFilters[0]?.value === "valid-scroll"
+ );
+ (trackNoCodeAction as Mock).mockResolvedValue({ ok: true });
+
+ const mockActionValid = {
+ name: "scrollValid",
+ type: "noCode",
+ noCodeConfig: { type: "fiftyPercentScroll", urlFilters: [{ value: "valid-scroll" }] },
+ };
+ const mockActionInvalid = {
+ name: "scrollInvalid",
+ type: "noCode",
+ noCodeConfig: { type: "fiftyPercentScroll", urlFilters: [{ value: "invalid-scroll" }] },
+ };
+ const mockConfigValue = {
+ get: vi.fn().mockReturnValue({
+ environment: { data: { actionClasses: [mockActionValid, mockActionInvalid] } },
+ }),
+ };
+ getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
+ window.scrollY = 1000; // Past 50%
+
+ addScrollDepthListener();
+ const scrollCallback = addEventListenerSpy.mock.calls[0][1] as () => Promise; // Added type assertion
+ await scrollCallback();
+
+ expect(trackNoCodeAction).not.toHaveBeenCalledWith("scrollInvalid");
+ });
+ });
+});
+
+describe("checkPageUrl additional cases", () => {
+ let getInstanceConfigMock: MockInstance<() => Config>;
+ let getInstanceTimeoutStackMock: MockInstance<() => TimeoutStack>;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ getInstanceConfigMock = vi.spyOn(Config, "getInstance");
+ getInstanceTimeoutStackMock = vi.spyOn(TimeoutStack, "getInstance");
+ });
+
+ test("checkPageUrl does nothing if no pageView actionClasses", async () => {
+ (handleUrlFilters as Mock).mockReturnValue(true);
+ (trackNoCodeAction as Mock).mockResolvedValue({ ok: true });
+ (checkSetup as Mock).mockReturnValue({ ok: true });
+
+ const mockConfigValue = {
+ get: vi.fn().mockReturnValue({
+ environment: {
+ data: {
+ actionClasses: [
+ {
+ name: "clickAction", // Not a pageView action
+ type: "noCode",
+ noCodeConfig: {
+ type: "click",
+ },
+ },
+ ],
+ },
+ },
+ }),
+ update: vi.fn(),
+ };
+ getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
+ vi.stubGlobal("window", { location: { href: "/fail" } });
+ await checkPageUrl();
+ expect(handleUrlFilters).not.toHaveBeenCalled();
+ expect(trackNoCodeAction).not.toHaveBeenCalled();
+ });
+
+ test("checkPageUrl does not remove timeout if not scheduled", async () => {
+ (handleUrlFilters as Mock).mockReturnValue(false); // Invalid URL
+ const mockConfigValue = {
+ get: vi.fn().mockReturnValue({
+ environment: {
+ data: {
+ actionClasses: [
+ {
+ name: "pageViewAction",
+ type: "noCode",
+ noCodeConfig: {
+ type: "pageView",
+ urlFilters: [{ value: "/fail", rule: "contains" }],
+ },
+ },
+ ],
+ },
+ },
+ }),
+ };
+ getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
+ const mockTimeoutStack = {
+ getTimeouts: vi.fn().mockReturnValue([]), // No scheduled timeouts
+ remove: vi.fn(),
+ add: vi.fn(),
+ };
+ getInstanceTimeoutStackMock.mockReturnValue(mockTimeoutStack as unknown as TimeoutStack);
+
+ vi.stubGlobal("window", { location: { href: "/fail" } });
+ await checkPageUrl();
+
+ expect(mockTimeoutStack.remove).not.toHaveBeenCalled();
+ expect(setIsSurveyRunning).not.toHaveBeenCalledWith(false); // Should not be called if timeout was not present
+ });
+});
+
+describe("addPageUrlEventListeners additional cases", () => {
+ test("addPageUrlEventListeners does not add listeners if window is undefined", () => {
+ vi.stubGlobal("window", undefined);
+ addPageUrlEventListeners(); // Call the function
+ // No explicit expect needed, the test passes if no error is thrown
+ // and no listeners were attempted to be added to an undefined window.
+ // We can also assert that isHistoryPatched remains false if it's exported and settable for testing.
+ // For now, we assume it's an internal detail not directly testable without more mocks.
+ });
+
+ test("addPageUrlEventListeners does not re-add listeners if already added", () => {
+ const addEventListenerMock = vi.fn();
+ vi.stubGlobal("window", { addEventListener: addEventListenerMock });
+ vi.stubGlobal("history", { pushState: vi.fn(), replaceState: vi.fn() });
+
+ addPageUrlEventListeners(); // First call
+ expect(addEventListenerMock).toHaveBeenCalledTimes(5); // hashchange, popstate, pushstate, replacestate, load
+
+ addPageUrlEventListeners(); // Second call
+ expect(addEventListenerMock).toHaveBeenCalledTimes(5); // Should not have been called again
+
+ (window.addEventListener as Mock).mockRestore();
+ });
+
+ test("addPageUrlEventListeners does not patch history if already patched", () => {
+ const addEventListenerMock = vi.fn();
+ const originalPushState = vi.fn();
+ vi.stubGlobal("window", { addEventListener: addEventListenerMock, dispatchEvent: vi.fn() });
+ vi.stubGlobal("history", { pushState: originalPushState, replaceState: vi.fn() });
+
+ // Simulate history already patched
+ // This requires isHistoryPatched to be exported or a way to set it.
+ // Assuming we can't directly set isHistoryPatched from outside,
+ // we call it once to patch, then check if pushState is re-assigned.
+ addPageUrlEventListeners(); // First call, patches history
+ const patchedPushState = history.pushState;
+
+ addPageUrlEventListeners(); // Second call
+ expect(history.pushState).toBe(patchedPushState); // pushState should not be a new function
+
+ // Test patched pushState
+ const dispatchEventSpy = vi.spyOn(window, "dispatchEvent");
+ patchedPushState.apply(history, [{}, "", "/new-url"]);
+ expect(originalPushState).toHaveBeenCalled();
+ // expect(dispatchEventSpy).toHaveBeenCalledWith(event);
+
+ (window.addEventListener as Mock).mockRestore();
+ dispatchEventSpy.mockRestore();
+ });
+});
+
+describe("removePageUrlEventListeners additional cases", () => {
+ test("removePageUrlEventListeners does nothing if window is undefined", () => {
+ vi.stubGlobal("window", undefined);
+ removePageUrlEventListeners();
+ // No explicit expect. Passes if no error.
+ });
+
+ test("removePageUrlEventListeners does nothing if listeners were not added", () => {
+ const removeEventListenerMock = vi.fn();
+ vi.stubGlobal("window", { removeEventListener: removeEventListenerMock });
+ // Assuming listeners are not added yet (arePageUrlEventListenersAdded is false)
+ removePageUrlEventListeners();
+ (window.removeEventListener as Mock).mockRestore();
+ });
});
diff --git a/packages/js-core/src/lib/user/tests/user.test.ts b/packages/js-core/src/lib/user/tests/user.test.ts
index ce924a9ded..fc0cedddf3 100644
--- a/packages/js-core/src/lib/user/tests/user.test.ts
+++ b/packages/js-core/src/lib/user/tests/user.test.ts
@@ -116,7 +116,7 @@ describe("user.ts", () => {
});
describe("logout", () => {
- test("successfully sets up formbricks after logout", async () => {
+ test("successfully sets up formbricks after logout", () => {
const mockConfig = {
get: vi.fn().mockReturnValue({
environmentId: mockEnvironmentId,
@@ -129,40 +129,24 @@ describe("user.ts", () => {
(setup as Mock).mockResolvedValue(undefined);
- const result = await logout();
+ const result = logout();
expect(tearDown).toHaveBeenCalled();
- expect(setup).toHaveBeenCalledWith({
- environmentId: mockEnvironmentId,
- appUrl: mockAppUrl,
- });
expect(result.ok).toBe(true);
});
- test("returns error if setup fails", async () => {
+ test("returns error if appConfig.get fails", () => {
const mockConfig = {
- get: vi.fn().mockReturnValue({
- environmentId: mockEnvironmentId,
- appUrl: mockAppUrl,
- user: { data: { userId: mockUserId } },
- }),
+ get: vi.fn().mockReturnValue(null),
};
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
- const mockError = { code: "network_error", message: "Failed to connect" };
- (setup as Mock).mockRejectedValue(mockError);
+ const result = logout();
- const result = await logout();
-
- expect(tearDown).toHaveBeenCalled();
- expect(setup).toHaveBeenCalledWith({
- environmentId: mockEnvironmentId,
- appUrl: mockAppUrl,
- });
expect(result.ok).toBe(false);
if (!result.ok) {
- expect(result.error).toEqual(mockError);
+ expect(result.error).toEqual(new Error("Failed to logout"));
}
});
});
diff --git a/packages/js-core/src/lib/user/update-queue.ts b/packages/js-core/src/lib/user/update-queue.ts
index c17747856e..b4d2037b77 100644
--- a/packages/js-core/src/lib/user/update-queue.ts
+++ b/packages/js-core/src/lib/user/update-queue.ts
@@ -13,9 +13,7 @@ export class UpdateQueue {
private constructor() {}
public static getInstance(): UpdateQueue {
- if (!UpdateQueue.instance) {
- UpdateQueue.instance = new UpdateQueue();
- }
+ UpdateQueue.instance ??= new UpdateQueue();
return UpdateQueue.instance;
}
diff --git a/packages/js-core/src/lib/user/user.ts b/packages/js-core/src/lib/user/user.ts
index ec35df3f29..2c41847a68 100644
--- a/packages/js-core/src/lib/user/user.ts
+++ b/packages/js-core/src/lib/user/user.ts
@@ -1,8 +1,8 @@
import { Config } from "@/lib/common/config";
import { Logger } from "@/lib/common/logger";
-import { setup, tearDown } from "@/lib/common/setup";
+import { tearDown } from "@/lib/common/setup";
import { UpdateQueue } from "@/lib/user/update-queue";
-import { type ApiErrorResponse, type NetworkError, type Result, err, okVoid } from "@/types/error";
+import { type ApiErrorResponse, type Result, err, okVoid } from "@/types/error";
// eslint-disable-next-line @typescript-eslint/require-await -- we want to use promises here
export const setUserId = async (userId: string): Promise> => {
@@ -31,32 +31,22 @@ export const setUserId = async (userId: string): Promise> => {
- const logger = Logger.getInstance();
- const appConfig = Config.getInstance();
-
- const { userId } = appConfig.get().user.data;
-
- if (!userId) {
- logger.error("No userId is set, please use the setUserId function to set a userId first");
- return okVoid();
- }
-
- logger.debug("Resetting state & getting new state from backend");
- const initParams = {
- environmentId: appConfig.get().environmentId,
- appUrl: appConfig.get().appUrl,
- };
-
- // logout the user, remove user state and setup formbricks again
- tearDown();
-
+export const logout = (): Result => {
try {
- await setup(initParams);
+ const logger = Logger.getInstance();
+ const appConfig = Config.getInstance();
+
+ const { userId } = appConfig.get().user.data;
+
+ if (!userId) {
+ logger.error("No userId is set, please use the setUserId function to set a userId first");
+ return okVoid();
+ }
+
+ tearDown();
+
return okVoid();
- } catch (e) {
- const errorTyped = e as { message?: string };
- logger.error(`Failed to setup formbricks after logout: ${errorTyped.message ?? "Unknown error"}`);
- return err(e as NetworkError);
+ } catch {
+ return { ok: false, error: new Error("Failed to logout") };
}
};
diff --git a/sonar-project.properties b/sonar-project.properties
index 93b03c036c..f46accf11d 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -21,5 +21,5 @@ sonar.scm.exclusions.disabled=false
sonar.sourceEncoding=UTF-8
# Coverage
-sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,apps/web/modules/analysis/components/SingleResponseCard/components/Smileys.tsx,packages/surveys/src/components/general/smileys.tsx,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/lib/shortUrl/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/posthogServer.ts,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/**
-sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,apps/web/modules/analysis/components/SingleResponseCard/components/Smileys.tsx,packages/surveys/src/components/general/smileys.tsx,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/lib/shortUrl/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/posthogServer.ts,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/**
+sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,packages/js-core/src/index.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,apps/web/modules/analysis/components/SingleResponseCard/components/Smileys.tsx,packages/surveys/src/components/general/smileys.tsx,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/lib/shortUrl/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/posthogServer.ts,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/**
+sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,packages/js-core/src/index.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,apps/web/modules/analysis/components/SingleResponseCard/components/Smileys.tsx,packages/surveys/src/components/general/smileys.tsx,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/lib/shortUrl/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/posthogServer.ts,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/**