mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-18 17:51:38 -05:00
Compare commits
5 Commits
cursor/for
...
fix/sdk-in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e28b2e344d | ||
|
|
287fae568f | ||
|
|
e07c0d34ea | ||
|
|
f47bad637f | ||
|
|
fb2653049b |
@@ -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");
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user