fix: js-core trackAction bugs (#5843)

Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
This commit is contained in:
Anshuman Pandey
2025-05-27 22:44:21 +05:30
committed by GitHub
parent 2b9cd37c6c
commit ce00ec97d1
19 changed files with 1070 additions and 241 deletions

View File

@@ -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(<SegmentFilter {...currentProps} connector="and" resource={arithmeticFilterResource} />);
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" } });

View File

@@ -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({
<SelectContent>
{contactAttributeKeys.map((attrClass) => (
<SelectItem key={attrClass.id} value={attrClass.key}>
{attrClass.name}
{attrClass.name ?? attrClass.key}
</SelectItem>
))}
</SelectContent>
@@ -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 {

View File

@@ -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

View File

@@ -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<void> => {
// 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<void> => {
// 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<void> => {
queue.add(User.setUserId, true, userId);
await queue.wait();
await queue.add(User.setUserId, CommandType.UserAction, true, userId);
};
const setEmail = async (email: string): Promise<void> => {
await setAttribute("email", email);
await queue.wait();
await queue.add(Attribute.setAttributes, CommandType.UserAction, true, { email });
};
const setAttribute = async (key: string, value: string): Promise<void> => {
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<string, string>): Promise<void> => {
queue.add(Attribute.setAttributes, true, attributes);
await queue.wait();
await queue.add(Attribute.setAttributes, CommandType.UserAction, true, attributes);
};
const setLanguage = async (language: string): Promise<void> => {
queue.add(Attribute.setAttributes, true, { language });
await queue.wait();
await queue.add(Attribute.setAttributes, CommandType.UserAction, true, { language });
};
const logout = async (): Promise<void> => {
queue.add(User.logout, true);
await queue.wait();
await queue.add(User.logout, CommandType.GeneralAction);
};
/**
@@ -73,13 +69,11 @@ const logout = async (): Promise<void> => {
* @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<void> => {
queue.add<string | TTrackProperties | undefined>(Action.trackCodeAction, true, code, properties);
await queue.wait();
await queue.add(Action.trackCodeAction, CommandType.GeneralAction, true, code, properties);
};
const registerRouteChange = async (): Promise<void> => {
queue.add(checkPageUrl, true);
await queue.wait();
await queue.add(checkPageUrl, CommandType.GeneralAction);
};
const formbricks = {

View File

@@ -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<void, unknown>> | Result<void, unknown> | Promise<void>;
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<void> | null = null;
private static instance: CommandQueue | null = null;
public add<A>(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<Result<void, unknown>> {
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<void> {
@@ -37,21 +73,29 @@ export class CommandQueue {
private async run(): Promise<void> {
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<Result<void, unknown>> => {
return (await currentItem.command.apply(null, currentItem.commandArgs)) as Result<void, unknown>;
};
@@ -64,6 +108,7 @@ export class CommandQueue {
console.error("🧱 Formbricks - Global error: ", result.data.error);
}
}
this.running = false;
if (this.resolvePromise) {
this.resolvePromise();

View File

@@ -16,10 +16,7 @@ export class Config {
}
static getInstance(): Config {
if (!Config.instance) {
Config.instance = new Config();
}
Config.instance ??= new Config();
return Config.instance;
}

View File

@@ -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<void, NotSetupError> => {
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<never> => {

View File

@@ -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<void, NotSetupError> => {
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();
};

View File

@@ -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<Result<void, unknown>> => {
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<Result<void, unknown>> => {
executionOrder.push("cmd1");
return Promise.resolve({ ok: true, data: undefined });
});
const cmd2 = vi.fn((): Promise<Result<void, unknown>> => {
executionOrder.push("cmd2");
return Promise.resolve({ ok: true, data: undefined });
});
const cmd3 = vi.fn((): Promise<Result<void, unknown>> => {
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();
});
});

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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();
});
});
});

View File

@@ -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) {

View File

@@ -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<Result<void, unknown>> => {
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<Result<void, NetworkError>> => {
export const checkPageUrl = async (): Promise<Result<void, unknown>> => {
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<Result<void, NetworkError>> => {
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<Result<void, NetworkError>> => {
}
}
return okVoid();
return { ok: true, data: undefined };
};
const checkPageUrlWrapper = (): ReturnType<typeof checkPageUrl> => 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<void> => {
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<ResultError<NetworkError> | undefined> => {
const checkExitIntent = async (e: MouseEvent): Promise<void> => {
const queue = CommandQueue.getInstance();
const appConfig = Config.getInstance();
const { environment } = appConfig.get();
@@ -161,13 +170,14 @@ const checkExitIntent = async (e: MouseEvent): Promise<ResultError<NetworkError>
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<typeof checkExitIntent> => 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<Result<void, unknown>> => {
const checkScrollDepth = async (): Promise<void> => {
const queue = CommandQueue.getInstance();
const appConfig = Config.getInstance();
const scrollPosition = window.scrollY;
@@ -216,15 +227,14 @@ const checkScrollDepth = async (): Promise<Result<void, unknown>> => {
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<typeof checkScrollDepth> => checkScrollDepth();
const checkScrollDepthWrapper = (): void => {
void checkScrollDepth();
};
export const addScrollDepthListener = (): void => {
if (typeof window !== "undefined" && !scrollDepthListenerAdded) {

View File

@@ -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<typeof import("@/lib/common/utils")>();
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<void>; // 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<void>; // 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<void>; // 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();
});
});

View File

@@ -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"));
}
});
});

View File

@@ -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;
}

View File

@@ -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<Result<void, ApiErrorResponse>> => {
@@ -31,32 +31,22 @@ export const setUserId = async (userId: string): Promise<Result<void, ApiErrorRe
return okVoid();
};
export const logout = async (): Promise<Result<void, NetworkError>> => {
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<void> => {
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") };
}
};

View File

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