Compare commits

..

8 Commits

Author SHA1 Message Date
Cursor Agent
a509f73eda chore: exclude test files from TypeScript build 2026-02-09 12:08:16 +00:00
Cursor Agent
bbcb48cb1c test: add tests for webkit messageHandlers polyfill 2026-02-09 12:06:01 +00:00
Cursor Agent
34e04f2c8b fix: add webkit messageHandlers polyfill for Facebook iOS browser
Fixes FORMBRICKS-HJ

- Add defensive polyfill for window.webkit.messageHandlers
- Prevents TypeError when accessing unregistered handlers in Facebook's iOS in-app browser
- Uses Proxy to safely intercept access and return no-op postMessage for missing handlers
- Solves issue with Cal.com embed trying to use native WebKit messaging in unsupported environments
2026-02-09 12:04:55 +00:00
Dhruwang Jariwala
07a6cd6c0e chore: survey ui console warnings (#7228) 2026-02-09 07:39:30 +00:00
Dhruwang Jariwala
335da2f1f5 fix: webhook data not being sent (#7219) 2026-02-09 06:06:30 +00:00
bharath kumar
13b9db915b fix(js-core): invert expiration logic for SDK error state (#7190) (#7202)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-09 05:08:19 +00:00
AndresAIFR
76b25476b3 fix: check serverError before showing success toast (#7185)
Co-authored-by: Andres Cruciani <AndresAIFR@users.noreply.github.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-02-09 04:49:36 +00:00
Dhruwang Jariwala
04220902b4 fix: external links are not working in picture selection question and ending card (#7221) 2026-02-06 18:08:00 +00:00
27 changed files with 809 additions and 198 deletions

View File

@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
import { TOrganization } from "@formbricks/types/organizations";
import { deleteOrganizationAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
@@ -32,7 +33,12 @@ export const DeleteOrganization = ({
setIsDeleting(true);
try {
await deleteOrganizationAction({ organizationId: organization.id });
const result = await deleteOrganizationAction({ organizationId: organization.id });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
setIsDeleting(false);
return;
}
toast.success(t("environments.settings.general.organization_deleted_successfully"));
if (typeof localStorage !== "undefined") {
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);

View File

@@ -21,6 +21,7 @@ import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[envir
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/BaseSelectDropdown";
import { fetchTables } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable";
import AirtableLogo from "@/images/airtableLogo.svg";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
@@ -268,7 +269,14 @@ export const AddIntegrationModal = ({
airtableIntegrationData.config?.data.push(integrationData);
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: airtableIntegrationData });
const result = await createOrUpdateIntegrationAction({
environmentId,
integrationData: airtableIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
if (isEditMode) {
toast.success(t("environments.integrations.integration_updated_successfully"));
} else {
@@ -304,7 +312,11 @@ export const AddIntegrationModal = ({
const integrationData = structuredClone(airtableIntegrationData);
integrationData.config.data.splice(index, 1);
await createOrUpdateIntegrationAction({ environmentId, integrationData });
const result = await createOrUpdateIntegrationAction({ environmentId, integrationData });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
handleClose();
router.refresh();

View File

@@ -165,7 +165,14 @@ export const AddIntegrationModal = ({
// create action
googleSheetIntegrationData.config.data.push(integrationData);
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: googleSheetIntegrationData });
const result = await createOrUpdateIntegrationAction({
environmentId,
integrationData: googleSheetIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
if (selectedIntegration) {
toast.success(t("environments.integrations.integration_updated_successfully"));
} else {
@@ -205,7 +212,14 @@ export const AddIntegrationModal = ({
googleSheetIntegrationData.config.data.splice(selectedIntegration!.index, 1);
try {
setIsDeleting(true);
await createOrUpdateIntegrationAction({ environmentId, integrationData: googleSheetIntegrationData });
const result = await createOrUpdateIntegrationAction({
environmentId,
integrationData: googleSheetIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("environments.integrations.integration_removed_successfully"));
setOpen(false);
} catch (error) {
@@ -266,7 +280,7 @@ export const AddIntegrationModal = ({
<div className="space-y-4">
<div>
<Label htmlFor="Surveys">{t("common.questions")}</Label>
<div className="mt-1 max-h-[15vh] overflow-x-hidden overflow-y-auto rounded-lg border border-slate-200">
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{surveyElements.map((question) => (
<div key={question.id} className="my-1 flex items-center space-x-2">

View File

@@ -22,6 +22,7 @@ import {
createEmptyMapping,
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/MappingRow";
import NotionLogo from "@/images/notion.png";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { Button } from "@/modules/ui/components/button";
@@ -217,7 +218,14 @@ export const AddIntegrationModal = ({
notionIntegrationData.config.data.push(integrationData);
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: notionIntegrationData });
const result = await createOrUpdateIntegrationAction({
environmentId,
integrationData: notionIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
if (selectedIntegration) {
toast.success(t("environments.integrations.integration_updated_successfully"));
} else {
@@ -236,7 +244,14 @@ export const AddIntegrationModal = ({
notionIntegrationData.config.data.splice(selectedIntegration!.index, 1);
try {
setIsDeleting(true);
await createOrUpdateIntegrationAction({ environmentId, integrationData: notionIntegrationData });
const result = await createOrUpdateIntegrationAction({
environmentId,
integrationData: notionIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("environments.integrations.integration_removed_successfully"));
setOpen(false);
} catch (error) {

View File

@@ -17,6 +17,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import SlackLogo from "@/images/slacklogo.png";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
@@ -144,7 +145,14 @@ export const AddChannelMappingModal = ({
// create action
slackIntegrationData.config.data.push(integrationData);
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: slackIntegrationData });
const result = await createOrUpdateIntegrationAction({
environmentId,
integrationData: slackIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
if (selectedIntegration) {
toast.success(t("environments.integrations.integration_updated_successfully"));
} else {
@@ -181,7 +189,14 @@ export const AddChannelMappingModal = ({
slackIntegrationData.config.data.splice(selectedIntegration!.index, 1);
try {
setIsDeleting(true);
await createOrUpdateIntegrationAction({ environmentId, integrationData: slackIntegrationData });
const result = await createOrUpdateIntegrationAction({
environmentId,
integrationData: slackIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("environments.integrations.integration_removed_successfully"));
setOpen(false);
} catch (error) {

View File

@@ -141,5 +141,52 @@ describe("Time Utilities", () => {
expect(convertDatesInObject("string")).toBe("string");
expect(convertDatesInObject(123)).toBe(123);
});
test("should not convert dates in contactAttributes", () => {
const input = {
createdAt: "2024-03-20T15:30:00",
contactAttributes: {
createdAt: "2024-03-20T16:30:00",
email: "test@example.com",
},
};
const result = convertDatesInObject(input);
expect(result.createdAt).toBeInstanceOf(Date);
expect(result.contactAttributes.createdAt).toBe("2024-03-20T16:30:00");
expect(result.contactAttributes.email).toBe("test@example.com");
});
test("should not convert dates in variables", () => {
const input = {
updatedAt: "2024-03-20T15:30:00",
variables: {
createdAt: "2024-03-20T16:30:00",
userId: "123",
},
};
const result = convertDatesInObject(input);
expect(result.updatedAt).toBeInstanceOf(Date);
expect(result.variables.createdAt).toBe("2024-03-20T16:30:00");
expect(result.variables.userId).toBe("123");
});
test("should not convert dates in data or meta", () => {
const input = {
createdAt: "2024-03-20T15:30:00",
data: {
createdAt: "2024-03-20T16:30:00",
},
meta: {
updatedAt: "2024-03-20T17:30:00",
},
};
const result = convertDatesInObject(input);
expect(result.createdAt).toBeInstanceOf(Date);
expect(result.data.createdAt).toBe("2024-03-20T16:30:00");
expect(result.meta.updatedAt).toBe("2024-03-20T17:30:00");
});
});
});

View File

@@ -160,7 +160,12 @@ export const convertDatesInObject = <T>(obj: T): T => {
return obj.map((item) => convertDatesInObject(item)) as unknown as T;
}
const newObj: any = {};
const keysToIgnore = new Set(["contactAttributes", "variables", "data", "meta"]);
for (const key in obj) {
if (keysToIgnore.has(key)) {
newObj[key] = obj[key];
continue;
}
if (
(key === "createdAt" || key === "updatedAt") &&
typeof obj[key] === "string" &&

View File

@@ -109,7 +109,13 @@ export function SegmentSettings({
const handleDeleteSegment = async () => {
try {
setIsDeletingSegment(true);
await deleteSegmentAction({ segmentId: segment.id });
const result = await deleteSegmentAction({ segmentId: segment.id });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
setIsDeletingSegment(false);
return;
}
setIsDeletingSegment(false);
toast.success(t("environments.segments.segment_deleted_successfully"));

View File

@@ -17,6 +17,7 @@ import type {
import type { TSurvey } from "@formbricks/types/surveys/types";
import { cn } from "@/lib/cn";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import {
cloneSegmentAction,
createSegmentAction,
@@ -135,7 +136,11 @@ export function TargetingCard({
const handleSaveSegment = async (data: TSegmentUpdateInput) => {
try {
if (!segment) throw new Error(t("environments.segments.invalid_segment"));
await updateSegmentAction({ segmentId: segment.id, environmentId, data });
const result = await updateSegmentAction({ segmentId: segment.id, environmentId, data });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("environments.segments.segment_saved_successfully"));
setIsSegmentEditorOpen(false);

View File

@@ -24,7 +24,6 @@ const mockCache = {
set: vi.fn(),
del: vi.fn(),
exists: vi.fn(),
tryLock: vi.fn(),
withCache: vi.fn(),
getRedisClient: vi.fn(),
};
@@ -33,47 +32,12 @@ vi.mock("@/lib/cache", () => ({
cache: mockCache,
}));
// Helper to set up cache mocks for the new distributed lock flow
const setupCacheMocksForLockFlow = (options: {
cachedLicense?: TEnterpriseLicenseDetails | null;
previousResult?: { active: boolean; features: unknown; lastChecked: Date } | null;
lockAcquired?: boolean;
}) => {
const { cachedLicense, previousResult = null, lockAcquired = true } = options;
// cache.get: returns cached license on status key, previous result on previous_result key
mockCache.get.mockImplementation(async (key: string) => {
if (key.includes(":status")) {
return { ok: true, data: cachedLicense ?? null };
}
if (key.includes(":previous_result")) {
return { ok: true, data: previousResult };
}
return { ok: true, data: null };
});
// cache.exists: for distinguishing cache miss from cached null
mockCache.exists.mockImplementation(async (key: string) => {
if (key.includes(":status") && cachedLicense !== undefined) {
return { ok: true, data: true };
}
return { ok: true, data: false };
});
// cache.tryLock: simulate lock acquisition
mockCache.tryLock.mockResolvedValue({ ok: true, data: lockAcquired });
// cache.set: success by default
mockCache.set.mockResolvedValue({ ok: true });
};
// Mock the createCacheKey functions
vi.mock("@formbricks/cache", () => ({
createCacheKey: {
license: {
status: (identifier: string) => `fb:license:${identifier}:status`,
previous_result: (identifier: string) => `fb:license:${identifier}:previous_result`,
fetch_lock: (identifier: string) => `fb:license:${identifier}:fetch_lock`,
},
custom: (namespace: string, identifier: string, subResource?: string) => {
const base = `fb:${namespace}:${identifier}`;
@@ -130,13 +94,10 @@ describe("License Core Logic", () => {
beforeEach(() => {
originalProcessEnv = { ...process.env };
// Reset all mocks
vi.resetAllMocks();
mockCache.get.mockReset();
mockCache.set.mockReset();
mockCache.del.mockReset();
mockCache.exists.mockReset();
mockCache.tryLock.mockReset();
mockCache.withCache.mockReset();
mockLogger.error.mockReset();
mockLogger.warn.mockReset();
@@ -144,10 +105,7 @@ describe("License Core Logic", () => {
mockLogger.debug.mockReset();
// Set up default mock implementations for Result types
// Default: cache miss (no cached license), lock acquired, no previous result
mockCache.get.mockResolvedValue({ ok: true, data: null });
mockCache.exists.mockResolvedValue({ ok: true, data: false });
mockCache.tryLock.mockResolvedValue({ ok: true, data: true });
mockCache.set.mockResolvedValue({ ok: true });
mockCache.withCache.mockImplementation(async (fn) => await fn());
@@ -161,7 +119,7 @@ describe("License Core Logic", () => {
instanceId: "test-hashed-instance-id",
createdAt: new Date("2024-01-01"),
});
vi.clearAllMocks();
// Mock window to be undefined for server-side tests
vi.stubGlobal("window", undefined);
});
@@ -206,14 +164,16 @@ describe("License Core Logic", () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
// Set up cache to return cached license (cache hit)
setupCacheMocksForLockFlow({ cachedLicense: mockFetchedLicenseDetails });
// Mock cache.withCache to return cached license details (simulating cache hit)
mockCache.withCache.mockResolvedValue(mockFetchedLicenseDetails);
const license = await getEnterpriseLicense();
expect(license).toEqual(expectedActiveLicenseState);
// Should have checked cache but NOT acquired lock or called fetch
expect(mockCache.get).toHaveBeenCalled();
expect(mockCache.tryLock).not.toHaveBeenCalled();
expect(mockCache.withCache).toHaveBeenCalledWith(
expect.any(Function),
expect.stringContaining("fb:license:"),
expect.any(Number)
);
expect(fetch).not.toHaveBeenCalled();
});
@@ -221,8 +181,8 @@ describe("License Core Logic", () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
// Set up cache miss: no cached license, lock acquired
setupCacheMocksForLockFlow({ cachedLicense: undefined, lockAcquired: true });
// Mock cache.withCache to execute the function (simulating cache miss)
mockCache.withCache.mockImplementation(async (fn) => await fn());
fetch.mockResolvedValueOnce({
ok: true,
@@ -232,18 +192,19 @@ describe("License Core Logic", () => {
const license = await getEnterpriseLicense();
expect(fetch).toHaveBeenCalledTimes(1);
// Should have tried to acquire lock and set the cache
expect(mockCache.tryLock).toHaveBeenCalled();
expect(mockCache.set).toHaveBeenCalled();
expect(mockCache.withCache).toHaveBeenCalledWith(
expect.any(Function),
expect.stringContaining("fb:license:"),
expect.any(Number)
);
expect(license).toEqual(expectedActiveLicenseState);
});
test("should handle grace period logic when previous result exists", async () => {
// This test verifies the grace period fallback behavior.
// Due to vitest module caching, full mock verification is limited.
test("should use previous result if fetch fails and previous result exists and is within grace period", async () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
const previousTime = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000); // 1 day ago
const previousTime = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000); // 1 day ago, within grace period
const mockPreviousResult = {
active: true,
features: { removeBranding: true, projects: 5 },
@@ -251,32 +212,37 @@ describe("License Core Logic", () => {
version: 1,
};
// Set up cache miss for license, but have previous result available
mockCache.get.mockImplementation(async (key: string) => {
// Mock cache.withCache to return null (simulating fetch failure)
mockCache.withCache.mockResolvedValue(null);
// Mock cache.get to return previous result when requested
mockCache.get.mockImplementation(async (key) => {
if (key.includes(":previous_result")) {
return { ok: true, data: mockPreviousResult };
}
return { ok: true, data: null };
});
mockCache.exists.mockResolvedValue({ ok: true, data: false });
mockCache.tryLock.mockResolvedValue({ ok: true, data: true });
mockCache.set.mockResolvedValue({ ok: true });
const fetch = (await import("node-fetch")).default as Mock;
fetch.mockResolvedValueOnce({ ok: false, status: 500 } as any);
const license = await getEnterpriseLicense();
// Should return some license state (exact values depend on mock application)
expect(license).toHaveProperty("active");
expect(license).toHaveProperty("fallbackLevel");
expect(mockCache.withCache).toHaveBeenCalled();
expect(license).toEqual({
active: true,
features: mockPreviousResult.features,
lastChecked: previousTime,
isPendingDowngrade: true,
fallbackLevel: "grace" as const,
status: "unreachable" as const,
});
});
test("should handle expired grace period when previous result is too old", async () => {
// This test verifies behavior when previous result is outside grace period.
test("should return inactive and set new previousResult if fetch fails and previous result is outside grace period", async () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
const previousTime = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000); // 5 days ago
const previousTime = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000); // 5 days ago, outside grace period
const mockPreviousResult = {
active: true,
features: { removeBranding: true },
@@ -284,33 +250,80 @@ describe("License Core Logic", () => {
version: 1,
};
// Set up cache miss for license, previous result outside grace period
mockCache.get.mockImplementation(async (key: string) => {
// Mock cache.withCache to return null (simulating fetch failure)
mockCache.withCache.mockResolvedValue(null);
// Mock cache.get to return previous result when requested
mockCache.get.mockImplementation(async (key) => {
if (key.includes(":previous_result")) {
return { ok: true, data: mockPreviousResult };
}
return { ok: true, data: null };
});
mockCache.exists.mockResolvedValue({ ok: true, data: false });
mockCache.tryLock.mockResolvedValue({ ok: true, data: true });
mockCache.set.mockResolvedValue({ ok: true });
const fetch = (await import("node-fetch")).default as Mock;
fetch.mockResolvedValueOnce({ ok: false, status: 500 } as any);
const license = await getEnterpriseLicense();
// Should return some license state
expect(license).toHaveProperty("active");
expect(license).toHaveProperty("fallbackLevel");
expect(mockCache.withCache).toHaveBeenCalled();
expect(mockCache.set).toHaveBeenCalledWith(
expect.stringContaining("fb:license:"),
{
active: false,
features: {
isMultiOrgEnabled: false,
projects: 3,
twoFactorAuth: false,
sso: false,
whitelabel: false,
removeBranding: false,
contacts: false,
ai: false,
saml: false,
spamProtection: false,
auditLogs: false,
multiLanguageSurveys: false,
accessControl: false,
quotas: false,
},
lastChecked: expect.any(Date),
},
expect.any(Number)
);
expect(license).toEqual({
active: false,
features: {
isMultiOrgEnabled: false,
projects: 3,
twoFactorAuth: false,
sso: false,
whitelabel: false,
removeBranding: false,
contacts: false,
ai: false,
saml: false,
spamProtection: false,
auditLogs: false,
multiLanguageSurveys: false,
accessControl: false,
quotas: false,
},
lastChecked: expect.any(Date),
isPendingDowngrade: false,
fallbackLevel: "default" as const,
status: "unreachable" as const,
});
});
test("should return inactive with default features if fetch fails and no previous result (initial fail)", async () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
// Set up cache miss with no previous result
setupCacheMocksForLockFlow({ cachedLicense: undefined, previousResult: null, lockAcquired: true });
// Mock cache.withCache to return null (simulating fetch failure)
mockCache.withCache.mockResolvedValue(null);
// Mock cache.get to return no previous result
mockCache.get.mockResolvedValue({ ok: true, data: null });
fetch.mockRejectedValueOnce(new Error("Network error"));
@@ -331,13 +344,13 @@ describe("License Core Logic", () => {
accessControl: false,
quotas: false,
};
// Should have set previous_result with default features
expect(mockCache.set).toHaveBeenCalledWith(
expect.stringContaining("fb:license:"),
expect.objectContaining({
{
active: false,
features: expectedFeatures,
}),
lastChecked: expect.any(Date),
},
expect.any(Number)
);
expect(license).toEqual({
@@ -355,7 +368,7 @@ describe("License Core Logic", () => {
vi.resetAllMocks();
mockCache.get.mockReset();
mockCache.set.mockReset();
mockCache.tryLock.mockReset();
mockCache.withCache.mockReset();
const fetch = (await import("node-fetch")).default as Mock;
fetch.mockReset();
@@ -382,56 +395,306 @@ describe("License Core Logic", () => {
fallbackLevel: "default" as const,
status: "no-license" as const,
});
// No cache operations should happen when there's no license key
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.set).not.toHaveBeenCalled();
expect(mockCache.tryLock).not.toHaveBeenCalled();
expect(mockCache.withCache).not.toHaveBeenCalled();
});
test("should handle fetch throwing an error gracefully", async () => {
// Set up cache miss with no previous result
setupCacheMocksForLockFlow({ cachedLicense: undefined, previousResult: null, lockAcquired: true });
test("should handle fetch throwing an error and use grace period or return inactive", async () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
// Mock cache.withCache to return null (simulating fetch failure)
mockCache.withCache.mockResolvedValue(null);
// Mock cache.get to return no previous result
mockCache.get.mockResolvedValue({ ok: true, data: null });
fetch.mockRejectedValueOnce(new Error("Network error"));
const { getEnterpriseLicense } = await import("./license");
const license = await getEnterpriseLicense();
// Should return inactive license (exact status depends on env configuration)
expect(license.active).toBe(false);
expect(license.isPendingDowngrade).toBe(false);
expect(license.fallbackLevel).toBe("default");
expect(license).toEqual({
active: false,
features: null,
lastChecked: expect.any(Date),
isPendingDowngrade: false,
fallbackLevel: "default" as const,
status: "no-license" as const,
});
});
});
describe("getLicenseFeatures", () => {
// These tests use dynamic imports which have issues with vitest mocking.
// The core getLicenseFeatures functionality is tested through
// integration tests and the getEnterpriseLicense tests above.
test("should be exported from license module", async () => {
test("should return features if license is active", async () => {
// Set up environment before import
vi.stubGlobal("window", undefined);
vi.doMock("@/lib/env", () => ({
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
},
}));
// Mock cache.withCache to return license details
mockCache.withCache.mockResolvedValue({
status: "active",
features: {
isMultiOrgEnabled: true,
contacts: true,
projects: 5,
whitelabel: true,
removeBranding: true,
twoFactorAuth: true,
sso: true,
saml: true,
spamProtection: true,
ai: true,
auditLogs: true,
},
});
// Import after env and mocks are set
const { getLicenseFeatures } = await import("./license");
expect(typeof getLicenseFeatures).toBe("function");
const features = await getLicenseFeatures();
expect(features).toEqual({
isMultiOrgEnabled: true,
contacts: true,
projects: 5,
whitelabel: true,
removeBranding: true,
twoFactorAuth: true,
sso: true,
saml: true,
spamProtection: true,
ai: true,
auditLogs: true,
});
});
test("should return null if license is inactive", async () => {
const { getLicenseFeatures } = await import("./license");
// Mock cache.withCache to return expired license
mockCache.withCache.mockResolvedValue({ status: "expired", features: null });
const features = await getLicenseFeatures();
expect(features).toBeNull();
});
test("should return null if getEnterpriseLicense throws", async () => {
const { getLicenseFeatures } = await import("./license");
// Mock cache.withCache to throw an error
mockCache.withCache.mockRejectedValue(new Error("Cache error"));
const features = await getLicenseFeatures();
expect(features).toBeNull();
});
});
describe("Cache Key Generation", () => {
test("getCacheKeys should be exported from license module", async () => {
const { getCacheKeys } = await import("./license");
expect(typeof getCacheKeys).toBe("function");
beforeEach(() => {
vi.resetAllMocks();
mockCache.get.mockReset();
mockCache.set.mockReset();
mockCache.del.mockReset();
mockCache.withCache.mockReset();
vi.resetModules();
});
test("should use 'browser' as cache key in browser environment", async () => {
vi.stubGlobal("window", {});
// Set up default mock for cache.withCache
mockCache.withCache.mockImplementation(async (fn) => await fn());
const { getEnterpriseLicense } = await import("./license");
await getEnterpriseLicense();
expect(mockCache.withCache).toHaveBeenCalledWith(
expect.any(Function),
expect.stringContaining("fb:license:browser:status"),
expect.any(Number)
);
});
test("should use 'no-license' as cache key when ENTERPRISE_LICENSE_KEY is not set", async () => {
vi.resetModules();
vi.stubGlobal("window", undefined);
vi.doMock("@/lib/env", () => ({
env: {
ENTERPRISE_LICENSE_KEY: undefined,
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
},
}));
const { getEnterpriseLicense } = await import("./license");
await getEnterpriseLicense();
// The cache should NOT be accessed if there is no license key
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.withCache).not.toHaveBeenCalled();
});
test("should use hashed license key as cache key when ENTERPRISE_LICENSE_KEY is set", async () => {
vi.resetModules();
const testLicenseKey = "test-license-key";
vi.stubGlobal("window", undefined);
vi.doMock("@/lib/env", () => ({
env: {
ENTERPRISE_LICENSE_KEY: testLicenseKey,
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
},
}));
// Set up default mock for cache.withCache
mockCache.withCache.mockImplementation(async (fn) => await fn());
const { hashString } = await import("@/lib/hash-string");
const expectedHash = hashString(testLicenseKey);
const { getEnterpriseLicense } = await import("./license");
await getEnterpriseLicense();
expect(mockCache.withCache).toHaveBeenCalledWith(
expect.any(Function),
expect.stringContaining(`fb:license:${expectedHash}:status`),
expect.any(Number)
);
});
});
describe("Error and Warning Logging", () => {
// These tests verify that logging functions are called correctly.
// Due to vitest module caching, these tests may not work reliably
// in isolation. The logging functionality is tested implicitly
// through the other tests.
test("should have logger available for error tracking", async () => {
// Just verify the mockLogger is set up correctly
expect(mockLogger.error).toBeDefined();
expect(mockLogger.warn).toBeDefined();
expect(mockLogger.info).toBeDefined();
expect(mockLogger.debug).toBeDefined();
test("should log warning when setPreviousResult cache.set fails (line 176-178)", async () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
const mockFetchedLicenseDetails: TEnterpriseLicenseDetails = {
status: "active",
features: {
isMultiOrgEnabled: true,
contacts: true,
projects: 10,
whitelabel: true,
removeBranding: true,
twoFactorAuth: true,
sso: true,
saml: true,
spamProtection: true,
ai: false,
auditLogs: true,
multiLanguageSurveys: true,
accessControl: true,
quotas: true,
},
};
// Mock successful fetch from API
mockCache.withCache.mockResolvedValue(mockFetchedLicenseDetails);
// Mock cache.set to fail when saving previous result
mockCache.set.mockResolvedValue({
ok: false,
error: new Error("Redis connection failed"),
});
await getEnterpriseLicense();
// Verify that the warning was logged
expect(mockLogger.warn).toHaveBeenCalledWith(
{ error: new Error("Redis connection failed") },
"Failed to cache previous result"
);
});
test("should log error when trackApiError is called (line 196-203)", async () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
// Mock cache.withCache to execute the function (simulating cache miss)
mockCache.withCache.mockImplementation(async (fn) => await fn());
// Mock API response with 500 status
const mockStatus = 500;
fetch.mockResolvedValueOnce({
ok: false,
status: mockStatus,
json: async () => ({ error: "Internal Server Error" }),
} as any);
await getEnterpriseLicense();
// Verify that the API error was logged with correct structure
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
status: mockStatus,
code: "API_ERROR",
timestamp: expect.any(String),
}),
expect.stringContaining("License API error:")
);
});
test("should log error when trackApiError is called with different status codes (line 196-203)", async () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
// Test with 403 Forbidden
mockCache.withCache.mockImplementation(async (fn) => await fn());
const mockStatus = 403;
fetch.mockResolvedValueOnce({
ok: false,
status: mockStatus,
json: async () => ({ error: "Forbidden" }),
} as any);
await getEnterpriseLicense();
// Verify that the API error was logged with correct structure
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
status: mockStatus,
code: "API_ERROR",
timestamp: expect.any(String),
}),
expect.stringContaining("License API error:")
);
});
test("should log info when trackFallbackUsage is called during grace period", async () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
const previousTime = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000); // 1 day ago
const mockPreviousResult = {
active: true,
features: { removeBranding: true, projects: 5 },
lastChecked: previousTime,
version: 1,
};
mockCache.withCache.mockResolvedValue(null);
mockCache.get.mockImplementation(async (key) => {
if (key.includes(":previous_result")) {
return { ok: true, data: mockPreviousResult };
}
return { ok: true, data: null };
});
fetch.mockResolvedValueOnce({ ok: false, status: 500 } as any);
await getEnterpriseLicense();
// Verify that the fallback info was logged
expect(mockLogger.info).toHaveBeenCalledWith(
expect.objectContaining({
fallbackLevel: "grace",
timestamp: expect.any(String),
}),
expect.stringContaining("Using license fallback level: grace")
);
});
});
@@ -449,8 +712,8 @@ describe("License Core Logic", () => {
const fetch = (await import("node-fetch")).default as Mock;
// Set up cache miss, lock acquired
setupCacheMocksForLockFlow({ cachedLicense: undefined, lockAcquired: true });
// Mock cache.withCache to execute the function (simulating cache miss)
mockCache.withCache.mockImplementation(async (fn) => await fn());
fetch.mockResolvedValueOnce({
ok: true,

View File

@@ -110,16 +110,11 @@ const getCacheIdentifier = () => {
return hashString(env.ENTERPRISE_LICENSE_KEY); // Valid license key
};
const LICENSE_FETCH_LOCK_TTL_MS = 90 * 1000; // 90s lock so only one process fetches when cache is cold
const LICENSE_FETCH_POLL_MS = 12 * 1000; // Wait up to 12s for another process to populate cache
const LICENSE_FETCH_POLL_INTERVAL_MS = 400;
export const getCacheKeys = () => {
const identifier = getCacheIdentifier();
return {
FETCH_LICENSE_CACHE_KEY: createCacheKey.license.status(identifier),
PREVIOUS_RESULT_CACHE_KEY: createCacheKey.license.previous_result(identifier),
FETCH_LOCK_CACHE_KEY: createCacheKey.license.fetch_lock(identifier),
};
};
@@ -290,19 +285,6 @@ const handleInitialFailure = async (currentTime: Date): Promise<TEnterpriseLicen
};
};
/**
* Try to read cached license from Redis. Returns undefined on miss or error.
*/
const getCachedLicense = async (): Promise<TEnterpriseLicenseDetails | null | undefined> => {
const keys = getCacheKeys();
const result = await cache.get<TEnterpriseLicenseDetails | null>(keys.FETCH_LICENSE_CACHE_KEY);
if (!result.ok) return undefined;
if (result.data !== null && result.data !== undefined) return result.data;
const existsResult = await cache.exists(keys.FETCH_LICENSE_CACHE_KEY);
if (existsResult.ok && existsResult.data) return null; // cached null
return undefined;
};
// API functions
let fetchLicensePromise: Promise<TEnterpriseLicenseDetails | null> | null = null;
@@ -406,10 +388,6 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
}
};
/**
* When Redis license cache is empty, only one process should run the expensive fetch
* (DB count + API call). Others wait for the cache to be populated or fall back after a timeout.
*/
export const fetchLicense = async (): Promise<TEnterpriseLicenseDetails | null> => {
if (!env.ENTERPRISE_LICENSE_KEY) return null;
@@ -424,37 +402,13 @@ export const fetchLicense = async (): Promise<TEnterpriseLicenseDetails | null>
}
fetchLicensePromise = (async () => {
const keys = getCacheKeys();
const cached = await getCachedLicense();
if (cached !== undefined) return cached;
const lockResult = await cache.tryLock(keys.FETCH_LOCK_CACHE_KEY, "1", LICENSE_FETCH_LOCK_TTL_MS);
const acquired = lockResult.ok && lockResult.data === true;
if (acquired) {
try {
const fresh = await fetchLicenseFromServerInternal();
await cache.set(keys.FETCH_LICENSE_CACHE_KEY, fresh, CONFIG.CACHE.FETCH_LICENSE_TTL_MS);
return fresh;
} finally {
// Lock expires automatically; no need to release
}
}
const deadline = Date.now() + LICENSE_FETCH_POLL_MS;
while (Date.now() < deadline) {
await sleep(LICENSE_FETCH_POLL_INTERVAL_MS);
const value = await getCachedLicense();
if (value !== undefined) return value;
}
logger.warn(
{ pollMs: LICENSE_FETCH_POLL_MS },
"License cache not populated by holder within poll window; fetching in this process"
return await cache.withCache(
async () => {
return await fetchLicenseFromServerInternal();
},
getCacheKeys().FETCH_LICENSE_CACHE_KEY,
CONFIG.CACHE.FETCH_LICENSE_TTL_MS
);
const fallback = await fetchLicenseFromServerInternal();
await cache.set(keys.FETCH_LICENSE_CACHE_KEY, fallback, CONFIG.CACHE.FETCH_LICENSE_TTL_MS);
return fallback;
})();
fetchLicensePromise

View File

@@ -154,7 +154,12 @@ export function EditLanguage({
const performLanguageDeletion = async (languageId: string) => {
try {
await deleteLanguageAction({ languageId, projectId: project.id });
const result = await deleteLanguageAction({ languageId, projectId: project.id });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
setConfirmationModal((prev) => ({ ...prev, isOpen: false }));
return;
}
setLanguages((prev) => prev.filter((lang) => lang.id !== languageId));
toast.success(t("environments.workspace.languages.language_deleted_successfully"));
// Close the modal after deletion
@@ -187,7 +192,7 @@ export function EditLanguage({
const handleSaveChanges = async () => {
if (!validateLanguages(languages, t)) return;
await Promise.all(
const results = await Promise.all(
languages.map((lang) => {
return lang.id === "new"
? createLanguageAction({
@@ -201,6 +206,11 @@ export function EditLanguage({
});
})
);
const errorResult = results.find((result) => result?.serverError);
if (errorResult) {
toast.error(getFormattedErrorMessage(errorResult));
return;
}
toast.success(t("environments.workspace.languages.languages_updated_successfully"));
router.refresh();
setIsEditing(false);
@@ -239,7 +249,7 @@ export function EditLanguage({
))}
</>
) : (
<p className="text-sm text-slate-500 italic">
<p className="text-sm italic text-slate-500">
{t("environments.workspace.languages.no_language_found")}
</p>
)}

View File

@@ -4,6 +4,7 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { deleteTeamAction } from "@/modules/ee/teams/team-list/actions";
import { TTeam } from "@/modules/ee/teams/team-list/types/team";
import { Button } from "@/modules/ui/components/button";
@@ -27,6 +28,12 @@ export const DeleteTeam = ({ teamId, onDelete, isOwnerOrManager }: DeleteTeamPro
setIsDeleting(true);
const deleteTeamActionResponse = await deleteTeamAction({ teamId });
if (deleteTeamActionResponse?.serverError) {
toast.error(getFormattedErrorMessage(deleteTeamActionResponse));
setIsDeleteDialogOpen(false);
setIsDeleting(false);
return;
}
if (deleteTeamActionResponse?.data) {
toast.success(t("environments.settings.teams.team_deleted_successfully"));
onDelete?.();

View File

@@ -42,14 +42,27 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
if (!member && invite) {
// This is an invite
await deleteInviteAction({ inviteId: invite?.id, organizationId: organization.id });
const result = await deleteInviteAction({ inviteId: invite?.id, organizationId: organization.id });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
setIsDeleting(false);
return;
}
toast.success(t("environments.settings.general.invite_deleted_successfully"));
}
if (member && !invite) {
// This is a member
await deleteMembershipAction({ userId: member.userId, organizationId: organization.id });
const result = await deleteMembershipAction({
userId: member.userId,
organizationId: organization.id,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
setIsDeleting(false);
return;
}
toast.success(t("environments.settings.general.member_deleted_successfully"));
}

View File

@@ -71,7 +71,12 @@ export const OrganizationActions = ({
const handleLeaveOrganization = async () => {
setLoading(true);
try {
await leaveOrganizationAction({ organizationId: organization.id });
const result = await leaveOrganizationAction({ organizationId: organization.id });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
setLoading(false);
return;
}
toast.success(t("environments.settings.general.member_deleted_successfully"));
router.refresh();
setLoading(false);

View File

@@ -8,6 +8,7 @@ import { FormProvider, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import {
deleteActionClassAction,
updateActionClassAction,
@@ -92,10 +93,14 @@ export const ActionSettingsTab = ({
validatePermissions(isReadOnly, t);
const updatedAction = buildActionObject(data, actionClass.environmentId, t);
await updateActionClassAction({
const result = await updateActionClassAction({
actionClassId: actionClass.id,
updatedAction: updatedAction,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
setOpen(false);
router.refresh();
toast.success(t("environments.actions.action_updated_successfully"));
@@ -109,7 +114,11 @@ export const ActionSettingsTab = ({
const handleDeleteAction = async () => {
try {
setIsDeletingAction(true);
await deleteActionClassAction({ actionClassId: actionClass.id });
const result = await deleteActionClassAction({ actionClassId: actionClass.id });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
router.refresh();
toast.success(t("environments.actions.action_deleted_successfully"));
setOpen(false);

View File

@@ -69,6 +69,7 @@ export const EndScreenForm = ({
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!endingCard.headline?.default || endingCard.headline.default.trim() === ""}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
<div>
{endingCard.subheader !== undefined && (
@@ -87,6 +88,7 @@ export const EndScreenForm = ({
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!endingCard.subheader?.default || endingCard.subheader.default.trim() === ""}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
</div>

View File

@@ -27,6 +27,7 @@ interface PictureSelectionFormProps {
isInvalid: boolean;
locale: TUserLocale;
isStorageConfigured: boolean;
isExternalUrlsAllowed?: boolean;
}
export const PictureSelectionForm = ({
@@ -39,6 +40,7 @@ export const PictureSelectionForm = ({
isInvalid,
locale,
isStorageConfigured = true,
isExternalUrlsAllowed,
}: PictureSelectionFormProps): JSX.Element => {
const environmentId = localSurvey.environmentId;
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
@@ -88,6 +90,7 @@ export const PictureSelectionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
autoFocus={!element.headline?.default || element.headline.default.trim() === ""}
/>
<div ref={parent}>
@@ -106,6 +109,7 @@ export const PictureSelectionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
autoFocus={!element.subheader?.default || element.subheader.default.trim() === ""}
/>
</div>

View File

@@ -69,7 +69,11 @@ export const SurveyDropDownMenu = ({
const handleDeleteSurvey = async (surveyId: string) => {
setLoading(true);
try {
await deleteSurveyAction({ surveyId });
const result = await deleteSurveyAction({ surveyId });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
deleteSurvey(surveyId);
toast.success(t("environments.surveys.survey_deleted_successfully"));
} catch (error) {

View File

@@ -32,7 +32,6 @@ export const createCacheKey = {
status: (organizationId: string): CacheKey => makeCacheKey("license", organizationId, "status"),
previous_result: (organizationId: string): CacheKey =>
makeCacheKey("license", organizationId, "previous_result"),
fetch_lock: (organizationId: string): CacheKey => makeCacheKey("license", organizationId, "fetch_lock"),
},
// Rate limiting and security

View File

@@ -115,7 +115,7 @@ export const setup = async (
const expiresAt = existingConfig.status.expiresAt;
if (expiresAt && isNowExpired(new Date(expiresAt))) {
if (expiresAt && !isNowExpired(new Date(expiresAt))) {
console.error("🧱 Formbricks - Error state is not expired, skipping initialization");
return okVoid();
}

View File

@@ -6,7 +6,7 @@ import { addCleanupEventListeners, addEventListeners } from "@/lib/common/event-
import { Logger } from "@/lib/common/logger";
import { handleErrorOnFirstSetup, setup, tearDown } from "@/lib/common/setup";
import { setIsSetup } from "@/lib/common/status";
import { filterSurveys, isNowExpired } from "@/lib/common/utils";
import { filterSurveys, getIsDebug, isNowExpired } from "@/lib/common/utils";
import type * as Utils from "@/lib/common/utils";
import { fetchEnvironmentState } from "@/lib/environment/state";
import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state";
@@ -56,6 +56,7 @@ vi.mock("@/lib/common/utils", async (importOriginal) => {
...originalModule,
filterSurveys: vi.fn(),
isNowExpired: vi.fn(),
getIsDebug: vi.fn(),
};
});
@@ -86,6 +87,7 @@ describe("setup.ts", () => {
getInstanceConfigMock = vi.spyOn(Config, "getInstance");
getInstanceLoggerMock = vi.spyOn(Logger, "getInstance").mockReturnValue(mockLogger as unknown as Logger);
(getIsDebug as unknown as Mock).mockReturnValue(false);
});
afterEach(() => {
@@ -117,7 +119,8 @@ describe("setup.ts", () => {
}
});
test("skips setup if existing config is in error state and not expired", async () => {
test("skips setup if existing config is in error state and not expired (debug mode)", async () => {
(getIsDebug as unknown as Mock).mockReturnValue(true);
const mockConfig = {
get: vi.fn().mockReturnValue({
environmentId: "env_123",
@@ -131,7 +134,7 @@ describe("setup.ts", () => {
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
(isNowExpired as unknown as Mock).mockReturnValue(true);
(isNowExpired as unknown as Mock).mockReturnValue(false); // Not expired
const result = await setup({ environmentId: "env_123", appUrl: "https://my.url" });
expect(result.ok).toBe(true);
@@ -140,6 +143,59 @@ describe("setup.ts", () => {
);
});
test("skips initialization if error state is active (not expired)", async () => {
(getIsDebug as unknown as Mock).mockReturnValue(false);
const mockConfig = {
get: vi.fn().mockReturnValue({
environmentId: "env_123",
appUrl: "https://my.url",
environment: {},
user: { data: {}, expiresAt: null },
status: { value: "error", expiresAt: new Date(Date.now() + 10000) },
}),
resetConfig: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
(isNowExpired as unknown as Mock).mockReturnValue(false); // Time is NOT up
const result = await setup({ environmentId: "env_123", appUrl: "https://my.url" });
expect(result.ok).toBe(true);
// Should NOT fetch environment or user state
expect(fetchEnvironmentState).not.toHaveBeenCalled();
expect(mockConfig.resetConfig).not.toHaveBeenCalled();
});
test("continues initialization if error state is expired", async () => {
(getIsDebug as unknown as Mock).mockReturnValue(false);
const mockConfig = {
get: vi.fn().mockReturnValue({
environmentId: "env_123",
appUrl: "https://my.url",
environment: { data: { surveys: [] }, expiresAt: new Date() },
user: { data: {}, expiresAt: null },
status: { value: "error", expiresAt: new Date(Date.now() - 10000) },
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
(isNowExpired as unknown as Mock).mockReturnValue(true); // Time IS up
// Mock successful fetch to allow setup to proceed
(fetchEnvironmentState as unknown as Mock).mockResolvedValueOnce({
ok: true,
data: { data: { surveys: [] }, expiresAt: new Date() },
});
(filterSurveys as unknown as Mock).mockReturnValue([]);
const result = await setup({ environmentId: "env_123", appUrl: "https://my.url" });
expect(result.ok).toBe(true);
expect(fetchEnvironmentState).toHaveBeenCalled();
});
test("uses existing config if environmentId/appUrl match, checks for expiration sync", async () => {
const mockConfig = {
get: vi.fn().mockReturnValue({

View File

@@ -19,6 +19,8 @@ import tailwindcss from "@tailwindcss/vite";
*/
export default defineConfig({
build: {
// Keep dist when running watch so surveys (and others) can resolve types during parallel go
emptyOutDir: false,
lib: {
entry: "src/index.ts",
formats: ["es"],

View File

@@ -6,6 +6,36 @@ import { FILE_PICK_EVENT } from "@/lib/constants";
import { getI18nLanguage } from "@/lib/i18n-utils";
import { addCustomThemeToDom, addStylesToDom, setStyleNonce } from "@/lib/styles";
// Polyfill for webkit messageHandlers to prevent errors in browsers that don't fully support it
// (e.g., Facebook's iOS in-app browser). This prevents TypeError when accessing unregistered handlers.
if (typeof window !== "undefined") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- WebKit types are not standard
const win = window as any;
if (win.webkit?.messageHandlers) {
const originalMessageHandlers = win.webkit.messageHandlers;
// Create a Proxy that safely handles access to potentially undefined message handlers
win.webkit.messageHandlers = new Proxy(originalMessageHandlers, {
get(target, prop) {
const handler = target[prop as keyof typeof target];
// If the handler doesn't exist, return a safe mock object with a no-op postMessage
if (!handler) {
return {
postMessage: () => {
// Silently ignore - the message handler is not registered in this environment
console.debug(`WebKit message handler "${String(prop)}" is not available in this environment`);
},
};
}
return handler;
},
});
}
}
export const renderSurveyInline = (props: SurveyContainerProps) => {
const inlineProps: SurveyContainerProps = {
...props,

View File

@@ -0,0 +1,126 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("WebKit messageHandlers polyfill", () => {
let originalWebkit: any;
let consoleDebugSpy: any;
beforeEach(() => {
// Save the original webkit object if it exists
originalWebkit = (window as any).webkit;
consoleDebugSpy = vi.spyOn(console, "debug").mockImplementation(() => {});
});
afterEach(() => {
// Restore the original webkit object
if (originalWebkit) {
(window as any).webkit = originalWebkit;
} else {
delete (window as any).webkit;
}
consoleDebugSpy.mockRestore();
});
it("should not throw when accessing undefined messageHandlers", () => {
// Setup: Create a webkit object with messageHandlers but no specific handlers
(window as any).webkit = {
messageHandlers: {},
};
// Apply the polyfill logic
const originalMessageHandlers = (window as any).webkit.messageHandlers;
(window as any).webkit.messageHandlers = new Proxy(originalMessageHandlers, {
get(target, prop) {
const handler = target[prop as keyof typeof target];
if (!handler) {
return {
postMessage: () => {
console.debug(`WebKit message handler "${String(prop)}" is not available in this environment`);
},
};
}
return handler;
},
});
// Test: Accessing an undefined handler should not throw
expect(() => {
(window as any).webkit.messageHandlers.undefinedHandler.postMessage("test");
}).not.toThrow();
// Verify console.debug was called
expect(consoleDebugSpy).toHaveBeenCalledWith(
'WebKit message handler "undefinedHandler" is not available in this environment'
);
});
it("should still work with existing handlers", () => {
// Setup: Create a webkit object with a real handler
const mockPostMessage = vi.fn();
(window as any).webkit = {
messageHandlers: {
existingHandler: {
postMessage: mockPostMessage,
},
},
};
// Apply the polyfill logic
const originalMessageHandlers = (window as any).webkit.messageHandlers;
(window as any).webkit.messageHandlers = new Proxy(originalMessageHandlers, {
get(target, prop) {
const handler = target[prop as keyof typeof target];
if (!handler) {
return {
postMessage: () => {
console.debug(`WebKit message handler "${String(prop)}" is not available in this environment`);
},
};
}
return handler;
},
});
// Test: Existing handler should still work
(window as any).webkit.messageHandlers.existingHandler.postMessage("test message");
expect(mockPostMessage).toHaveBeenCalledWith("test message");
});
it("should handle multiple undefined handlers", () => {
// Setup
(window as any).webkit = {
messageHandlers: {},
};
// Apply the polyfill logic
const originalMessageHandlers = (window as any).webkit.messageHandlers;
(window as any).webkit.messageHandlers = new Proxy(originalMessageHandlers, {
get(target, prop) {
const handler = target[prop as keyof typeof target];
if (!handler) {
return {
postMessage: () => {
console.debug(`WebKit message handler "${String(prop)}" is not available in this environment`);
},
};
}
return handler;
},
});
// Test: Multiple undefined handlers should not throw
expect(() => {
(window as any).webkit.messageHandlers.handler1.postMessage("test1");
(window as any).webkit.messageHandlers.handler2.postMessage("test2");
(window as any).webkit.messageHandlers.handler3.postMessage("test3");
}).not.toThrow();
expect(consoleDebugSpy).toHaveBeenCalledTimes(3);
});
});

View File

@@ -7,10 +7,12 @@
"jsx": "react-jsx",
"jsxImportSource": "preact",
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"@formbricks/survey-ui": ["../survey-ui"]
},
"resolveJsonModule": true
},
"exclude": ["**/*.test.ts", "**/*.test.tsx"],
"extends": "@formbricks/config-typescript/js-library.json",
"include": ["src", "../types/surveys.d.ts"]
}

View File

@@ -88,16 +88,16 @@
"persistent": true
},
"@formbricks/surveys#build": {
"dependsOn": ["^build"],
"dependsOn": ["^build", "@formbricks/survey-ui#build"],
"outputs": ["dist/**"]
},
"@formbricks/surveys#build:dev": {
"dependsOn": ["^build:dev", "@formbricks/i18n-utils#build"],
"dependsOn": ["^build:dev", "@formbricks/i18n-utils#build", "@formbricks/survey-ui#build:dev"],
"outputs": ["dist/**"]
},
"@formbricks/surveys#go": {
"cache": false,
"dependsOn": ["@formbricks/surveys#build"],
"dependsOn": ["@formbricks/survey-ui#build", "@formbricks/surveys#build"],
"persistent": true
},
"@formbricks/surveys#test": {