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/**