Compare commits

...

5 Commits

Author SHA1 Message Date
pandeymangg
e28b2e344d fixes build 2026-03-18 18:56:57 +05:30
pandeymangg
287fae568f removes unnecessary html 2026-03-18 18:49:32 +05:30
pandeymangg
e07c0d34ea adds test 2026-03-18 18:41:11 +05:30
pandeymangg
f47bad637f fixes lint 2026-03-18 18:00:32 +05:30
pandeymangg
fb2653049b wip: sdk init issues 2026-03-18 16:28:14 +05:30
4 changed files with 291 additions and 19 deletions

View File

@@ -6,7 +6,7 @@ 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 { closeSurvey } from "@/lib/survey/widget";
import { closeSurvey, preloadSurveysScript } from "@/lib/survey/widget";
import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state";
import { sendUpdatesToBackend } from "@/lib/user/update";
import {
@@ -316,6 +316,9 @@ export const setup = async (
addEventListeners();
addCleanupEventListeners();
// Preload surveys script so it's ready when a survey triggers
preloadSurveysScript(configInput.appUrl);
setIsSetup(true);
logger.debug("Set up complete");

View File

@@ -70,6 +70,12 @@ vi.mock("@/lib/survey/no-code-action", () => ({
checkPageUrl: vi.fn(),
}));
// 9) Mock survey widget
vi.mock("@/lib/survey/widget", () => ({
closeSurvey: vi.fn(),
preloadSurveysScript: vi.fn(),
}));
describe("setup.ts", () => {
let getInstanceConfigMock: MockInstance<() => Config>;
let getInstanceLoggerMock: MockInstance<() => Logger>;

View File

@@ -67,6 +67,8 @@ describe("widget-file", () => {
beforeEach(() => {
vi.clearAllMocks();
document.body.innerHTML = "";
// @ts-expect-error -- cleaning up mock
delete window.formbricksSurveys;
getInstanceConfigMock = vi.spyOn(Config, "getInstance");
getInstanceLoggerMock = vi.spyOn(Logger, "getInstance").mockReturnValue(mockLogger as unknown as Logger);
@@ -464,6 +466,210 @@ describe("widget-file", () => {
expect(window.formbricksSurveys.renderSurvey).not.toHaveBeenCalled();
});
describe("loadFormbricksSurveysExternally and waitForSurveysGlobal", () => {
const scriptLoadMockConfig = {
get: vi.fn().mockReturnValue({
appUrl: "https://fake.app",
environmentId: "env_123",
environment: {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
},
},
},
user: {
data: {
userId: "user_abc",
contactId: "contact_abc",
displays: [],
responses: [],
lastDisplayAt: null,
language: "en",
},
},
}),
update: vi.fn(),
};
// Helper to get the script element passed to document.head.appendChild
const getAppendedScript = (): Record<string, unknown> => {
// eslint-disable-next-line @typescript-eslint/unbound-method -- accessing mock for test assertions
const appendChildMock = vi.mocked(document.head.appendChild);
for (const call of appendChildMock.mock.calls) {
const el = call[0] as unknown as Record<string, unknown>;
if (typeof el.src === "string" && el.src.includes("surveys.umd.cjs")) {
return el;
}
}
throw new Error("No script element for surveys.umd.cjs was appended to document.head");
};
beforeEach(() => {
// Reset mock return values that may have been overridden by previous tests
mockUpdateQueue.hasPendingWork.mockReturnValue(false);
mockUpdateQueue.waitForPendingWork.mockResolvedValue(true);
});
// Test onerror first so surveysLoadPromise is reset to null for subsequent tests
test("rejects when script fails to load (onerror) and allows retry", async () => {
getInstanceConfigMock.mockReturnValue(scriptLoadMockConfig as unknown as Config);
widget.setIsSurveyRunning(false);
// eslint-disable-next-line @typescript-eslint/no-empty-function -- suppress console.error in test
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const renderPromise = widget.renderWidget({
...mockSurvey,
delay: 0,
} as unknown as TEnvironmentStateSurvey);
const scriptEl = getAppendedScript();
expect(scriptEl.src).toBe("https://fake.app/js/surveys.umd.cjs");
expect(scriptEl.async).toBe(true);
// Simulate network error
(scriptEl.onerror as (error: unknown) => void)("Network error");
await expect(renderPromise).rejects.toThrow("Failed to load Formbricks Surveys library");
expect(consoleSpy).toHaveBeenCalledWith("Failed to load Formbricks Surveys library:", "Network error");
consoleSpy.mockRestore();
});
test("rejects when script loads but surveys global never becomes available (timeout)", async () => {
getInstanceConfigMock.mockReturnValue(scriptLoadMockConfig as unknown as Config);
widget.setIsSurveyRunning(false);
// eslint-disable-next-line @typescript-eslint/no-empty-function -- suppress console.error in test
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.useFakeTimers();
const renderPromise = widget.renderWidget({
...mockSurvey,
delay: 0,
} as unknown as TEnvironmentStateSurvey);
const scriptEl = getAppendedScript();
// Script loaded but window.formbricksSurveys is never set
(scriptEl.onload as () => void)();
// Attach rejection handler before advancing timers to prevent unhandled rejection
const rejectAssert = expect(renderPromise).rejects.toThrow("Failed to load Formbricks Surveys library");
// Advance past the 10s timeout (polls every 200ms)
await vi.advanceTimersByTimeAsync(10001);
await rejectAssert;
vi.useRealTimers();
consoleSpy.mockRestore();
});
test("resolves after polling when surveys global becomes available and applies stored nonce", async () => {
getInstanceConfigMock.mockReturnValue(scriptLoadMockConfig as unknown as Config);
widget.setIsSurveyRunning(false);
// Set nonce before surveys load to test nonce application
window.__formbricksNonce = "test-nonce-123";
vi.useFakeTimers();
const renderPromise = widget.renderWidget({
...mockSurvey,
delay: 0,
} as unknown as TEnvironmentStateSurvey);
const scriptEl = getAppendedScript();
// Simulate script loaded
(scriptEl.onload as () => void)();
// Set the global after script "loads" — simulates browser finishing execution
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = { renderSurvey: vi.fn(), setNonce: vi.fn() };
// Advance one polling interval for waitForSurveysGlobal to find it
await vi.advanceTimersByTimeAsync(200);
await renderPromise;
// Run remaining timers for survey.delay setTimeout
vi.runAllTimers();
expect(window.formbricksSurveys.setNonce).toHaveBeenCalledWith("test-nonce-123");
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
expect.objectContaining({
appUrl: "https://fake.app",
environmentId: "env_123",
contactId: "contact_abc",
})
);
vi.useRealTimers();
delete window.__formbricksNonce;
});
test("deduplicates concurrent calls (returns cached promise)", async () => {
getInstanceConfigMock.mockReturnValue(scriptLoadMockConfig as unknown as Config);
widget.setIsSurveyRunning(false);
// After the previous successful test, surveysLoadPromise holds a resolved promise.
// Calling renderWidget again (without formbricksSurveys on window, but with cached promise)
// should reuse the cached promise rather than creating a new script element.
// @ts-expect-error -- cleaning up mock to force dedup path
delete window.formbricksSurveys;
const appendChildSpy = vi.spyOn(document.head, "appendChild");
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = { renderSurvey: vi.fn(), setNonce: vi.fn() };
vi.useFakeTimers();
await widget.renderWidget({
...mockSurvey,
delay: 0,
} as unknown as TEnvironmentStateSurvey);
vi.advanceTimersByTime(0);
// No new script element should have been appended (dedup via early return or cached promise)
const scriptAppendCalls = appendChildSpy.mock.calls.filter((call: unknown[]) => {
const el = call[0] as Record<string, unknown> | undefined;
return typeof el?.src === "string" && el.src.includes("surveys.umd.cjs");
});
expect(scriptAppendCalls.length).toBe(0);
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
vi.useRealTimers();
});
});
test("preloadSurveysScript adds a preload link and deduplicates subsequent calls", () => {
const createElementSpy = vi.spyOn(document, "createElement");
const appendChildSpy = vi.spyOn(document.head, "appendChild");
widget.preloadSurveysScript("https://fake.app");
expect(createElementSpy).toHaveBeenCalledWith("link");
expect(appendChildSpy).toHaveBeenCalledTimes(1);
const linkEl = createElementSpy.mock.results[0].value as Record<string, string>;
expect(linkEl.rel).toBe("preload");
expect(linkEl.as).toBe("script");
expect(linkEl.href).toBe("https://fake.app/js/surveys.umd.cjs");
// Second call should be a no-op (deduplication)
widget.preloadSurveysScript("https://fake.app");
expect(appendChildSpy).toHaveBeenCalledTimes(1);
});
test("renderWidget proceeds when identification fails but survey has no segment filters", async () => {
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
mockUpdateQueue.waitForPendingWork.mockResolvedValue(false);

View File

@@ -219,30 +219,87 @@ export const removeWidgetContainer = (): void => {
document.getElementById(CONTAINER_ID)?.remove();
};
const loadFormbricksSurveysExternally = (): Promise<typeof globalThis.window.formbricksSurveys> => {
const config = Config.getInstance();
const SURVEYS_LOAD_TIMEOUT_MS = 10000;
const SURVEYS_POLL_INTERVAL_MS = 200;
type TFormbricksSurveys = typeof globalThis.window.formbricksSurveys;
let surveysLoadPromise: Promise<TFormbricksSurveys> | null = null;
const waitForSurveysGlobal = (): Promise<TFormbricksSurveys> => {
return new Promise((resolve, reject) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- We need to check if the formbricksSurveys object exists
if (globalThis.window.formbricksSurveys) {
resolve(globalThis.window.formbricksSurveys);
} else {
const script = document.createElement("script");
script.src = `${config.get().appUrl}/js/surveys.umd.cjs`;
script.async = true;
script.onload = () => {
// Apply stored nonce if it was set before surveys package loaded
const startTime = Date.now();
const check = (): void => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
if (globalThis.window.formbricksSurveys) {
const storedNonce = globalThis.window.__formbricksNonce;
if (storedNonce) {
globalThis.window.formbricksSurveys.setNonce(storedNonce);
}
resolve(globalThis.window.formbricksSurveys);
};
script.onerror = (error) => {
console.error("Failed to load Formbricks Surveys library:", error);
reject(new Error(`Failed to load Formbricks Surveys library: ${error as string}`));
};
document.head.appendChild(script);
}
return;
}
if (Date.now() - startTime >= SURVEYS_LOAD_TIMEOUT_MS) {
reject(new Error("Formbricks Surveys library did not become available within timeout"));
return;
}
setTimeout(check, SURVEYS_POLL_INTERVAL_MS);
};
check();
});
};
const loadFormbricksSurveysExternally = (): Promise<TFormbricksSurveys> => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
if (globalThis.window.formbricksSurveys) {
return Promise.resolve(globalThis.window.formbricksSurveys);
}
if (surveysLoadPromise) {
return surveysLoadPromise;
}
surveysLoadPromise = new Promise<TFormbricksSurveys>((resolve, reject: (error: unknown) => void) => {
const config = Config.getInstance();
const script = document.createElement("script");
script.src = `${config.get().appUrl}/js/surveys.umd.cjs`;
script.async = true;
script.onload = () => {
waitForSurveysGlobal()
.then(resolve)
.catch((error: unknown) => {
surveysLoadPromise = null;
console.error("Failed to load Formbricks Surveys library:", error);
reject(new Error(`Failed to load Formbricks Surveys library`));
});
};
script.onerror = (error) => {
surveysLoadPromise = null;
console.error("Failed to load Formbricks Surveys library:", error);
reject(new Error(`Failed to load Formbricks Surveys library`));
};
document.head.appendChild(script);
});
return surveysLoadPromise;
};
let isPreloaded = false;
export const preloadSurveysScript = (appUrl: string): void => {
// Don't preload if already loaded or already preloading
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
if (globalThis.window.formbricksSurveys) return;
if (isPreloaded) return;
isPreloaded = true;
const link = document.createElement("link");
link.rel = "preload";
link.as = "script";
link.href = `${appUrl}/js/surveys.umd.cjs`;
document.head.appendChild(link);
};