diff --git a/apps/web/modules/core/rate-limit/rate-limit-load.test.ts b/apps/web/modules/core/rate-limit/rate-limit-load.test.ts index 1aecd36f79..5afcfcb3b2 100644 --- a/apps/web/modules/core/rate-limit/rate-limit-load.test.ts +++ b/apps/web/modules/core/rate-limit/rate-limit-load.test.ts @@ -69,10 +69,11 @@ async function checkRedisAvailability() { * - Failure Indicates: TTL or window calculation issues * * 5. High Throughput Stress Test - * - Purpose: Test performance under sustained load - * - Method: 200 requests in batches (limit: 50) + * - Purpose: Test performance under sustained load within single time window + * - Method: 200 concurrent requests (limit: 50) * - Expected: Exactly 50 requests allowed, consistent performance * - Failure Indicates: Performance degradation or counter corruption + * - Note: Fixed to send all requests concurrently to avoid window boundary race conditions * * 6. applyRateLimit Function Test * - Purpose: Test the higher-level wrapper function @@ -315,29 +316,27 @@ describe("Rate Limiter Load Tests - Race Conditions", () => { const config = TEST_CONFIGS.high; const totalRequests = 200; - const batchSize = 50; const identifier = "stress-test"; - let totalAllowed = 0; - let totalDenied = 0; - - // Send requests in batches to simulate real load - for (let i = 0; i < totalRequests; i += batchSize) { - const batchEnd = Math.min(i + batchSize, totalRequests); - const batchPromises = Array.from({ length: batchEnd - i }, () => checkRateLimit(config, identifier)); - - const batchResults = await Promise.all(batchPromises); - - const batchAllowed = batchResults.filter((r) => r.ok && r.data.allowed).length; - const batchDenied = batchResults.filter((r) => r.ok && !r.data.allowed).length; - - totalAllowed += batchAllowed; - totalDenied += batchDenied; - - // Small delay between batches - await new Promise((resolve) => setTimeout(resolve, 10)); + // Clear any existing keys first to ensure clean state + const redis = getRedisClient(); + if (redis) { + const existingKeys = await redis.keys(`fb:rate_limit:${config.namespace}:*`); + if (existingKeys.length > 0) { + await redis.del(existingKeys); + } } + // Send ALL requests concurrently within the same time window + // This eliminates window boundary race conditions that caused intermittent failures + const allPromises = Array.from({ length: totalRequests }, () => checkRateLimit(config, identifier)); + + console.log(`Sending ${totalRequests} concurrent requests...`); + const results = await Promise.all(allPromises); + + const totalAllowed = results.filter((r) => r.ok && r.data.allowed).length; + const totalDenied = results.filter((r) => r.ok && !r.data.allowed).length; + console.log(`Stress test: ${totalAllowed} allowed, ${totalDenied} denied`); // Should respect the rate limit even under high load diff --git a/apps/web/modules/ee/multi-language-surveys/components/default-language-select.tsx b/apps/web/modules/ee/multi-language-surveys/components/default-language-select.tsx index 4c981b4656..279daee173 100644 --- a/apps/web/modules/ee/multi-language-surveys/components/default-language-select.tsx +++ b/apps/web/modules/ee/multi-language-surveys/components/default-language-select.tsx @@ -62,7 +62,7 @@ export function DefaultLanguageSelect({ {projectLanguages.map((language) => ( {`${getLanguageLabel(language.code, locale)} (${language.code})`} diff --git a/apps/web/modules/ee/multi-language-surveys/components/multi-language-card.tsx b/apps/web/modules/ee/multi-language-surveys/components/multi-language-card.tsx index d32e12e7a0..f759acf99e 100644 --- a/apps/web/modules/ee/multi-language-surveys/components/multi-language-card.tsx +++ b/apps/web/modules/ee/multi-language-surveys/components/multi-language-card.tsx @@ -215,7 +215,8 @@ export const MultiLanguageCard: FC = ({ checked={isMultiLanguageActivated} disabled={!isMultiLanguageAllowed || projectLanguages.length === 0} id="multi-lang-toggle" - onClick={() => { + onClick={(e) => { + e.stopPropagation(); handleActivationSwitchLogic(); }} /> diff --git a/apps/web/modules/survey/editor/components/logic-editor-actions.tsx b/apps/web/modules/survey/editor/components/logic-editor-actions.tsx index d5cc1a90cb..1a2c098d37 100644 --- a/apps/web/modules/survey/editor/components/logic-editor-actions.tsx +++ b/apps/web/modules/survey/editor/components/logic-editor-actions.tsx @@ -239,7 +239,7 @@ export function LogicEditorActions({ )} - + {showLanguageDropdown ? (
+

Protected by reCAPTCHA and the Google{" "} diff --git a/packages/surveys/src/components/general/survey-close-button.test.tsx b/packages/surveys/src/components/general/survey-close-button.test.tsx index 6ddd473811..9aeb2689d7 100644 --- a/packages/surveys/src/components/general/survey-close-button.test.tsx +++ b/packages/surveys/src/components/general/survey-close-button.test.tsx @@ -1,5 +1,5 @@ import "@testing-library/jest-dom/vitest"; -import { cleanup, render, screen } from "@testing-library/preact"; +import { cleanup, fireEvent, render, screen } from "@testing-library/preact"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, test, vi } from "vitest"; import { SurveyCloseButton } from "./survey-close-button"; @@ -13,18 +13,48 @@ describe("SurveyCloseButton", () => { const { container } = render(); const wrapper = container.firstChild as HTMLElement; - expect(wrapper).toHaveClass( - "fb-z-[1001]", - "fb-flex", - "fb-w-fit", - "fb-items-center", - "even:fb-border-l", - "even:fb-pl-1" - ); + expect(wrapper).toHaveClass("fb-z-[1001]", "fb-flex", "fb-w-fit", "fb-items-center"); const button = wrapper.querySelector("button"); expect(button).toBeInTheDocument(); - expect(button).toHaveClass("fb-text-heading", "fb-relative", "fb-h-6", "fb-w-6", "fb-rounded-md"); + expect(button).toHaveClass("fb-text-heading", "fb-relative", "fb-h-8", "fb-w-8"); + expect(button).toHaveAttribute("aria-label", "Close survey"); + + const backgroundColor = button?.style?.backgroundColor; + expect(backgroundColor).toBe("transparent"); + }); + + test("renders close button with correct hover color", () => { + const { container } = render(); + + const wrapper = container.firstChild as HTMLElement; + expect(wrapper).toHaveClass("fb-z-[1001]", "fb-flex", "fb-w-fit", "fb-items-center"); + + const button = wrapper.querySelector("button"); + expect(button).toBeInTheDocument(); + expect(button).toHaveClass("fb-text-heading", "fb-relative", "fb-h-8", "fb-w-8"); + expect(button).toHaveAttribute("aria-label", "Close survey"); + + // hover over the button + fireEvent.mouseEnter(button as HTMLButtonElement); + const backgroundColor = button?.style?.backgroundColor; + + expect(backgroundColor).toBe("rgb(0, 128, 128)"); + }); + + test("renders close button with correct border radius", () => { + const { container } = render(); + + const wrapper = container.firstChild as HTMLElement; + expect(wrapper).toHaveClass("fb-z-[1001]", "fb-flex", "fb-w-fit", "fb-items-center"); + + const button = wrapper.querySelector("button"); + expect(button).toBeInTheDocument(); + expect(button).toHaveClass("fb-text-heading", "fb-relative", "fb-h-8", "fb-w-8"); + expect(button).toHaveAttribute("aria-label", "Close survey"); + expect(button).toHaveStyle({ + borderRadius: "12px", + }); }); test("renders SVG icon with correct attributes", () => { @@ -32,16 +62,16 @@ describe("SurveyCloseButton", () => { const svg = container.querySelector("svg"); expect(svg).toBeInTheDocument(); - expect(svg).toHaveClass("fb-h-6", "fb-w-6", "fb-p-0.5"); - expect(svg).toHaveAttribute("viewBox", "0 0 24 24"); - expect(svg).toHaveAttribute("stroke", "currentColor"); + expect(svg).toHaveAttribute("viewBox", "0 0 16 16"); expect(svg).toHaveAttribute("aria-hidden", "true"); const path = svg?.querySelector("path"); expect(path).toBeInTheDocument(); + expect(path).toHaveAttribute("stroke", "currentColor"); + expect(path).toHaveAttribute("d", "M12 4L4 12M4 4L12 12"); + expect(path).toHaveAttribute("strokeWidth", "1.33"); expect(path).toHaveAttribute("strokeLinecap", "round"); expect(path).toHaveAttribute("strokeLinejoin", "round"); - expect(path).toHaveAttribute("d", "M4 4L20 20M4 20L20 4"); }); test("calls onClose when clicked", async () => { diff --git a/packages/surveys/src/components/general/survey-close-button.tsx b/packages/surveys/src/components/general/survey-close-button.tsx index ccde9d8c5e..9a51e36a0e 100644 --- a/packages/surveys/src/components/general/survey-close-button.tsx +++ b/packages/surveys/src/components/general/survey-close-button.tsx @@ -1,23 +1,35 @@ +import { CloseIcon } from "@/components/icons/close-icon"; +import { mixColor } from "@/lib/color"; +import { cn } from "@/lib/utils"; +import { useState } from "preact/hooks"; + interface SurveyCloseButtonProps { onClose?: () => void; + hoverColor?: string; + borderRadius?: number; } -export function SurveyCloseButton({ onClose }: SurveyCloseButtonProps) { +export function SurveyCloseButton({ onClose, hoverColor, borderRadius }: Readonly) { + const [isHovered, setIsHovered] = useState(false); + const hoverColorWithOpacity = hoverColor ?? mixColor("#000000", "#ffffff", 0.8); + return ( -

+
); diff --git a/packages/surveys/src/components/general/survey.tsx b/packages/surveys/src/components/general/survey.tsx index b8a8a37df1..3cb529ffac 100644 --- a/packages/surveys/src/components/general/survey.tsx +++ b/packages/surveys/src/components/general/survey.tsx @@ -736,6 +736,9 @@ export function Survey({ } }; + const isLanguageSwitchVisible = getShowLanguageSwitch(offset); + const isCloseButtonVisible = getShowSurveyCloseButton(offset); + return ( -
- {getShowLanguageSwitch(offset) && ( - - )} - {getShowSurveyCloseButton(offset) && } -
-
- {content()} -
-
-
+
+
+ {showProgressBar ? : null} + +
+
+ {isLanguageSwitchVisible && ( + + )} + {isLanguageSwitchVisible && isCloseButtonVisible && ( + +
+
+
+ {content()} +
+ +
{isBrandingEnabled ? : null} {isSpamProtectionEnabled ? : null}
- {showProgressBar ? :
}
diff --git a/packages/surveys/src/components/general/welcome-card.test.tsx b/packages/surveys/src/components/general/welcome-card.test.tsx index ee764f6ed4..8ed56712e3 100644 --- a/packages/surveys/src/components/general/welcome-card.test.tsx +++ b/packages/surveys/src/components/general/welcome-card.test.tsx @@ -1,5 +1,5 @@ import "@testing-library/jest-dom/vitest"; -import { fireEvent, render, screen } from "@testing-library/preact"; +import { cleanup, fireEvent, render, screen } from "@testing-library/preact"; import { afterEach, describe, expect, test, vi } from "vitest"; import { WelcomeCard } from "./welcome-card"; @@ -7,7 +7,9 @@ describe("WelcomeCard", () => { afterEach(() => { vi.clearAllMocks(); document.body.innerHTML = ""; + cleanup(); }); + const mockSurvey = { questions: [ { id: "q1", logic: [] }, @@ -52,9 +54,9 @@ describe("WelcomeCard", () => { }); test("shows response count when showResponseCount is true and count > 3", () => { - const { container } = render(); + const { queryByTestId } = render(); - const responseText = container.querySelector(".fb-text-xs"); + const responseText = queryByTestId("fb__surveys__welcome-card__response-count"); expect(responseText).toHaveTextContent(/10 people responded/); }); @@ -77,9 +79,9 @@ describe("WelcomeCard", () => { }); test("does not show response count when count <= 3", () => { - const { container } = render(); + const { queryByTestId } = render(); - const responseText = container.querySelector(".fb-text-xs"); + const responseText = queryByTestId("fb__surveys__welcome-card__response-count"); expect(responseText).not.toHaveTextContent(/3 people responded/); }); @@ -122,7 +124,7 @@ describe("WelcomeCard", () => { }); test("shows both time and response count when both flags are true", () => { - const { container } = render( + const { queryByTestId } = render( { /> ); - const textDisplay = container.querySelector(".fb-text-xs"); + const textDisplay = queryByTestId("fb__surveys__welcome-card__info-text-test"); expect(textDisplay).toHaveTextContent(/Takes.*10 people responded/); }); @@ -200,12 +202,21 @@ describe("WelcomeCard", () => { test("handles response counts at boundary conditions", () => { // Test with exactly 3 responses (boundary) - const { container: container3 } = render(); - expect(container3.querySelector(".fb-text-xs")).not.toHaveTextContent(/3 people responded/); + const { queryByTestId: queryByTestId3, unmount } = render( + + ); + expect(queryByTestId3("fb__surveys__welcome-card__response-count")).not.toHaveTextContent( + /3 people responded/ + ); + + // unmount to not have conflicting test ids + unmount(); // Test with 4 responses (just above boundary) - const { container: container4 } = render(); - expect(container4.querySelector(".fb-text-xs")).toHaveTextContent(/4 people responded/); + const { queryByTestId: queryByTestId4 } = render(); + expect(queryByTestId4("fb__surveys__welcome-card__response-count")).toHaveTextContent( + /4 people responded/ + ); }); test("handles time calculation edge cases", () => { diff --git a/packages/surveys/src/components/general/welcome-card.tsx b/packages/surveys/src/components/general/welcome-card.tsx index 75220f4e11..644be05e10 100644 --- a/packages/surveys/src/components/general/welcome-card.tsx +++ b/packages/surveys/src/components/general/welcome-card.tsx @@ -174,16 +174,16 @@ export function WelcomeCard({

- {`${responseCount.toString()} people responded`} + {`${responseCount.toString()} people responded`}

) : null} {timeToFinish && showResponseCount ? (
-

+

Takes {calculateTimeToComplete()} - + {responseCount && responseCount > 3 ? `⋅ ${responseCount.toString()} people responded` : ""}

diff --git a/packages/surveys/src/components/icons/close-icon.test.tsx b/packages/surveys/src/components/icons/close-icon.test.tsx new file mode 100644 index 0000000000..c546611562 --- /dev/null +++ b/packages/surveys/src/components/icons/close-icon.test.tsx @@ -0,0 +1,35 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render } from "@testing-library/preact"; +import { afterEach, describe, expect, test } from "vitest"; +import { CloseIcon } from "./close-icon"; + +describe("CloseIcon", () => { + afterEach(() => { + cleanup(); + }); + + test("renders SVG with correct attributes", () => { + const { container } = render(); + + const svg = container.querySelector("svg"); + expect(svg).toBeInTheDocument(); + expect(svg).toHaveAttribute("xmlns", "http://www.w3.org/2000/svg"); + expect(svg).toHaveAttribute("viewBox", "0 0 16 16"); + expect(svg).toHaveAttribute("fill", "none"); + expect(svg).toHaveAttribute("aria-hidden", "true"); + + const path = svg?.querySelector("path"); + expect(path).toBeInTheDocument(); + expect(path).toHaveAttribute("stroke", "currentColor"); + expect(path).toHaveAttribute("d", "M12 4L4 12M4 4L12 12"); + expect(path).toHaveAttribute("strokeWidth", "1.33"); + expect(path).toHaveAttribute("strokeLinecap", "round"); + expect(path).toHaveAttribute("strokeLinejoin", "round"); + }); + + test("applies additional className", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg).toHaveClass("custom-class"); + }); +}); diff --git a/packages/surveys/src/components/icons/close-icon.tsx b/packages/surveys/src/components/icons/close-icon.tsx new file mode 100644 index 0000000000..67174a6d62 --- /dev/null +++ b/packages/surveys/src/components/icons/close-icon.tsx @@ -0,0 +1,24 @@ +interface CloseIconProps { + className?: string; +} + +export const CloseIcon = ({ className }: CloseIconProps) => { + return ( + + ); +}; diff --git a/packages/surveys/src/components/icons/language-icon.test.tsx b/packages/surveys/src/components/icons/language-icon.test.tsx new file mode 100644 index 0000000000..811ec4037e --- /dev/null +++ b/packages/surveys/src/components/icons/language-icon.test.tsx @@ -0,0 +1,34 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render } from "@testing-library/preact"; +import { afterEach, describe, expect, test } from "vitest"; +import { LanguageIcon } from "./language-icon"; + +describe("LanguageIcon", () => { + afterEach(() => { + cleanup(); + }); + + test("renders SVG with correct attributes", () => { + const { container } = render(); + + const svg = container.querySelector("svg"); + expect(svg).toBeInTheDocument(); + expect(svg).toHaveAttribute("xmlns", "http://www.w3.org/2000/svg"); + expect(svg).toHaveAttribute("viewBox", "0 0 16 16"); + expect(svg).toHaveAttribute("fill", "none"); + expect(svg).toHaveAttribute("aria-hidden", "true"); + + const path = svg?.querySelector("path"); + expect(path).toBeInTheDocument(); + expect(path).toHaveAttribute("stroke", "currentColor"); + expect(path).toHaveAttribute("strokeWidth", "1.33"); + expect(path).toHaveAttribute("strokeLinecap", "round"); + expect(path).toHaveAttribute("strokeLinejoin", "round"); + }); + + test("applies additional className", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg).toHaveClass("custom-class"); + }); +}); diff --git a/packages/surveys/src/components/icons/language-icon.tsx b/packages/surveys/src/components/icons/language-icon.tsx new file mode 100644 index 0000000000..b1d5efa903 --- /dev/null +++ b/packages/surveys/src/components/icons/language-icon.tsx @@ -0,0 +1,31 @@ +interface LanguageIconProps { + className?: string; +} + +export const LanguageIcon = ({ className }: LanguageIconProps) => { + return ( + + ); +}; diff --git a/packages/surveys/src/components/wrappers/auto-close-wrapper.test.tsx b/packages/surveys/src/components/wrappers/auto-close-wrapper.test.tsx index f225849f12..82aaf88573 100644 --- a/packages/surveys/src/components/wrappers/auto-close-wrapper.test.tsx +++ b/packages/surveys/src/components/wrappers/auto-close-wrapper.test.tsx @@ -228,7 +228,7 @@ describe("AutoCloseWrapper", () => { }); test("stops countdown on interaction (click)", () => { - const { container } = render( + const { queryByTestId } = render( { ); // Find the wrapper div that has the click handler (the inner one with event handlers) - const wrapper = container.querySelector(".fb-h-full.fb-w-full:nth-child(2)"); + const wrapper = queryByTestId("fb__surveys__auto-close-wrapper-test"); expect(wrapper).toBeTruthy(); // Use fireEvent instead of userEvent for more reliable event triggering @@ -253,7 +253,7 @@ describe("AutoCloseWrapper", () => { }); test("stops countdown on interaction (mouseover)", () => { - const { container } = render( + const { queryByTestId } = render( { ); // Find the wrapper div that has the mouseover handler (the inner one with event handlers) - const wrapper = container.querySelector(".fb-h-full.fb-w-full:nth-child(2)"); + const wrapper = queryByTestId("fb__surveys__auto-close-wrapper-test"); expect(wrapper).toBeTruthy(); // Use fireEvent instead of userEvent for more reliable event triggering diff --git a/packages/surveys/src/components/wrappers/auto-close-wrapper.tsx b/packages/surveys/src/components/wrappers/auto-close-wrapper.tsx index f45330fa60..00bc6b7422 100644 --- a/packages/surveys/src/components/wrappers/auto-close-wrapper.tsx +++ b/packages/surveys/src/components/wrappers/auto-close-wrapper.tsx @@ -62,13 +62,22 @@ export function AutoCloseWrapper({ }, [survey.autoClose]); return ( -
- {survey.autoClose && showAutoCloseProgressBar ? ( - - ) : null} -
+
+
{children}
+ {survey.type === "app" && survey.autoClose && ( +
+ {showAutoCloseProgressBar && } +
+ )}
); } diff --git a/packages/surveys/src/components/wrappers/scrollable-container.tsx b/packages/surveys/src/components/wrappers/scrollable-container.tsx index 4de2849154..2ef8a8e712 100644 --- a/packages/surveys/src/components/wrappers/scrollable-container.tsx +++ b/packages/surveys/src/components/wrappers/scrollable-container.tsx @@ -69,10 +69,9 @@ export const ScrollableContainer = forwardRef + className={cn("fb-overflow-auto fb-px-4 fb-bg-survey-bg")}> {children}
{!isAtBottom && (