mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-25 07:50:19 -06:00
fix: js-core trackAction bugs (#5843)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
This commit is contained in:
@@ -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" } });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -16,10 +16,7 @@ export class Config {
|
||||
}
|
||||
|
||||
static getInstance(): Config {
|
||||
if (!Config.instance) {
|
||||
Config.instance = new Config();
|
||||
}
|
||||
|
||||
Config.instance ??= new Config();
|
||||
return Config.instance;
|
||||
}
|
||||
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
26
packages/js-core/src/lib/common/status.ts
Normal file
26
packages/js-core/src/lib/common/status.ts
Normal 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();
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
41
packages/js-core/src/lib/common/tests/status.test.ts
Normal file
41
packages/js-core/src/lib/common/tests/status.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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") };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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/**
|
||||
|
||||
Reference in New Issue
Block a user