chore: track server action with sentry and general fixes (#5799)

This commit is contained in:
victorvhs017
2025-05-16 19:02:06 +07:00
committed by GitHub
parent 4d157bf8dc
commit 022d33d06f
16 changed files with 1238 additions and 1678 deletions

View File

@@ -11,9 +11,7 @@
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"dependencies": {
"eslint-plugin-react-refresh": "0.4.20",
"react": "19.1.0",
"react-dom": "19.1.0"
"eslint-plugin-react-refresh": "0.4.20"
},
"devDependencies": {
"@chromatic-com/storybook": "3.2.6",

View File

@@ -38,7 +38,7 @@ describe("SentryProvider", () => {
expect(initSpy).toHaveBeenCalledWith(
expect.objectContaining({
dsn: sentryDsn,
tracesSampleRate: 1,
tracesSampleRate: 0,
debug: false,
replaysOnErrorSampleRate: 1.0,
replaysSessionSampleRate: 0.1,
@@ -81,6 +81,26 @@ describe("SentryProvider", () => {
expect(screen.getByTestId("child")).toHaveTextContent("Test Content");
});
test("does not reinitialize Sentry when props change after initial render", () => {
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
const { rerender } = render(
<SentryProvider sentryDsn={sentryDsn} isEnabled>
<div data-testid="child">Test Content</div>
</SentryProvider>
);
expect(initSpy).toHaveBeenCalledTimes(1);
rerender(
<SentryProvider sentryDsn="https://newDsn@o0.ingest.sentry.io/0" isEnabled={false}>
<div data-testid="child">Test Content</div>
</SentryProvider>
);
expect(initSpy).toHaveBeenCalledTimes(1);
});
test("processes beforeSend correctly", () => {
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
@@ -109,4 +129,36 @@ describe("SentryProvider", () => {
const hintWithoutError = { originalException: undefined };
expect(beforeSend(dummyEvent, hintWithoutError)).toEqual(dummyEvent);
});
test("processes beforeSend correctly when hint.originalException is not an Error object", () => {
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
render(
<SentryProvider sentryDsn={sentryDsn} isEnabled>
<div data-testid="child">Test Content</div>
</SentryProvider>
);
const config = initSpy.mock.calls[0][0];
expect(config).toHaveProperty("beforeSend");
const beforeSend = config.beforeSend;
if (!beforeSend) {
throw new Error("beforeSend is not defined");
}
const dummyEvent = { some: "event" } as unknown as Sentry.ErrorEvent;
const hintWithString = { originalException: "string exception" };
expect(() => beforeSend(dummyEvent, hintWithString)).not.toThrow();
expect(beforeSend(dummyEvent, hintWithString)).toEqual(dummyEvent);
const hintWithNumber = { originalException: 123 };
expect(() => beforeSend(dummyEvent, hintWithNumber)).not.toThrow();
expect(beforeSend(dummyEvent, hintWithNumber)).toEqual(dummyEvent);
const hintWithNull = { originalException: null };
expect(() => beforeSend(dummyEvent, hintWithNull)).not.toThrow();
expect(beforeSend(dummyEvent, hintWithNull)).toEqual(dummyEvent);
});
});

View File

@@ -15,8 +15,8 @@ export const SentryProvider = ({ children, sentryDsn, isEnabled }: SentryProvide
Sentry.init({
dsn: sentryDsn,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1,
// No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737
tracesSampleRate: 0,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,

View File

@@ -15,12 +15,20 @@ import {
describe("Time Utilities", () => {
describe("convertDateString", () => {
test("should format date string correctly", () => {
expect(convertDateString("2024-03-20")).toBe("Mar 20, 2024");
expect(convertDateString("2024-03-20:12:30:00")).toBe("Mar 20, 2024");
});
test("should return empty string for empty input", () => {
expect(convertDateString("")).toBe("");
});
test("should return null for null input", () => {
expect(convertDateString(null as any)).toBe(null);
});
test("should handle invalid date strings", () => {
expect(convertDateString("not-a-date")).toBe("Invalid Date");
});
});
describe("convertDateTimeString", () => {
@@ -73,7 +81,7 @@ describe("Time Utilities", () => {
describe("formatDate", () => {
test("should format date correctly", () => {
const date = new Date("2024-03-20");
const date = new Date(2024, 2, 20); // March is month 2 (0-based)
expect(formatDate(date)).toBe("March 20, 2024");
});
});

View File

@@ -2,11 +2,16 @@ import { formatDistance, intlFormat } from "date-fns";
import { de, enUS, fr, pt, ptBR, zhTW } from "date-fns/locale";
import { TUserLocale } from "@formbricks/types/user";
export const convertDateString = (dateString: string) => {
export const convertDateString = (dateString: string | null) => {
if (dateString === null) return null;
if (!dateString) {
return dateString;
}
const date = new Date(dateString);
if (isNaN(date.getTime())) {
return "Invalid Date";
}
return intlFormat(
date,
{

View File

@@ -1,5 +1,6 @@
import { getUser } from "@/lib/user/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import * as Sentry from "@sentry/nextjs";
import { getServerSession } from "next-auth";
import { DEFAULT_SERVER_ERROR_MESSAGE, createSafeActionClient } from "next-safe-action";
import { logger } from "@formbricks/logger";
@@ -14,6 +15,8 @@ import {
export const actionClient = createSafeActionClient({
handleServerError(e) {
Sentry.captureException(e);
if (
e instanceof ResourceNotFoundError ||
e instanceof AuthorizationError ||

View File

@@ -53,10 +53,10 @@ describe("SurveyCard", () => {
survey={{ ...dummySurvey, status: "draft" } as unknown as TSurvey}
environmentId={environmentId}
isReadOnly={false}
WEBAPP_URL={WEBAPP_URL}
duplicateSurvey={mockDuplicateSurvey}
deleteSurvey={mockDeleteSurvey}
locale="en-US"
surveyDomain={WEBAPP_URL}
/>
);
// Draft survey => link should point to edit
@@ -70,10 +70,10 @@ describe("SurveyCard", () => {
survey={{ ...dummySurvey, status: "draft" } as unknown as TSurvey}
environmentId={environmentId}
isReadOnly={true}
WEBAPP_URL={WEBAPP_URL}
duplicateSurvey={mockDuplicateSurvey}
deleteSurvey={mockDeleteSurvey}
locale="en-US"
surveyDomain={WEBAPP_URL}
/>
);
// When it's read only and draft, we expect no link
@@ -87,10 +87,10 @@ describe("SurveyCard", () => {
survey={{ ...dummySurvey, status: "inProgress" } as unknown as TSurvey}
environmentId={environmentId}
isReadOnly={false}
WEBAPP_URL={WEBAPP_URL}
duplicateSurvey={mockDuplicateSurvey}
deleteSurvey={mockDeleteSurvey}
locale="en-US"
surveyDomain={WEBAPP_URL}
/>
);
// For non-draft => link to summary

View File

@@ -127,9 +127,9 @@ input[type="search"]::-ms-reveal {
}
input[type="range"]::-webkit-slider-thumb {
background: #0f172a;
height: 20px;
width: 20px;
border-radius: 50%;
-webkit-appearance: none;
background: #0f172a;
}

View File

@@ -117,11 +117,9 @@
"prismjs": "1.30.0",
"qr-code-styling": "1.9.2",
"qrcode": "1.5.4",
"react": "19.1.0",
"react-colorful": "5.6.1",
"react-confetti": "6.4.0",
"react-day-picker": "9.6.7",
"react-dom": "19.1.0",
"react-hook-form": "7.56.2",
"react-hot-toast": "2.5.2",
"react-turnstile": "1.1.4",

View File

@@ -12,8 +12,8 @@ if (SENTRY_DSN) {
Sentry.init({
dsn: SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1,
// No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737
tracesSampleRate: 0,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,

View File

@@ -11,8 +11,8 @@ if (SENTRY_DSN) {
Sentry.init({
dsn: SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1,
// No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737
tracesSampleRate: 0,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,

View File

@@ -10,6 +10,7 @@
"schema": "packages/database/schema.prisma"
},
"scripts": {
"clean:all": "turbo run clean && rimraf node_modules pnpm-lock.yaml .turbo coverage out",
"clean": "turbo run clean && rimraf node_modules .turbo coverage out",
"build": "turbo run build",
"build:dev": "turbo run build:dev",
@@ -35,6 +36,10 @@
"fb-migrate-dev": "pnpm --filter @formbricks/database create-migration && pnpm prisma generate",
"tolgee-pull": "BRANCH_NAME=$(node -p \"require('./branch.json').branchName\") && tolgee pull --tags \"draft:$BRANCH_NAME\" \"production\" && prettier --write ./apps/web/locales/*.json"
},
"dependencies": {
"react": "19.1.0",
"react-dom": "19.1.0"
},
"devDependencies": {
"@azure/microsoft-playwright-testing": "1.0.0-beta.7",
"@formbricks/eslint-config": "workspace:*",

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { type TResponseData, type TResponseVariables } from "@formbricks/types/responses";
import { type TSurveyQuestion, TSurveyQuestionTypeEnum } from "../../../types/surveys/types";
import { parseRecallInformation, replaceRecallInfo } from "./recall";
@@ -15,7 +15,7 @@ vi.mock("./i18n", () => ({
vi.mock("./date-time", () => ({
isValidDateString: (val: string) => /^\d{4}-\d{2}-\d{2}$/.test(val) || /^\d{2}-\d{2}-\d{4}$/.test(val),
formatDateWithOrdinal: (date: Date) =>
`${date.getFullYear()}-${("0" + (date.getMonth() + 1)).slice(-2)}-${("0" + date.getDate()).slice(-2)}_formatted`,
`${date.getUTCFullYear()}-${("0" + (date.getUTCMonth() + 1)).slice(-2)}-${("0" + date.getUTCDate()).slice(-2)}_formatted`,
}));
describe("replaceRecallInfo", () => {
@@ -34,79 +34,73 @@ describe("replaceRecallInfo", () => {
lastLogin: "2024-03-10",
};
it("should replace recall info from responseData", () => {
test("should replace recall info from responseData", () => {
const text = "Welcome, #recall:name/fallback:Guest#! Your email is #recall:email/fallback:N/A#.";
const expected = "Welcome, John Doe! Your email is john.doe@example.com.";
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
});
it("should replace recall info from variables if not in responseData", () => {
test("should replace recall info from variables if not in responseData", () => {
const text = "Product: #recall:productName/fallback:N/A#. Role: #recall:userRole/fallback:User#.";
const expected = "Product: Formbricks. Role: Admin.";
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
});
it("should use fallback if value is not found in responseData or variables", () => {
test("should use fallback if value is not found in responseData or variables", () => {
const text = "Your organization is #recall:orgName/fallback:DefaultOrg#.";
const expected = "Your organization is DefaultOrg.";
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
});
it("should handle nbsp in fallback", () => {
const text = "Status: #recall:status/fallback:PendingnbspReview#.";
const expected = "Status: Pending Review.";
test("should handle nbsp in fallback", () => {
const text = "Status: #recall:status/fallback:Pending&nbsp;Review#.";
const expected = "Status: Pending& ;Review.";
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
});
it("should format date strings from responseData", () => {
test("should format date strings from responseData", () => {
const text = "Registered on: #recall:registrationDate/fallback:N/A#.";
const expected = "Registered on: 2023-01-15_formatted.";
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
});
it("should format date strings from variables", () => {
test("should format date strings from variables", () => {
const text = "Last login: #recall:lastLogin/fallback:N/A#.";
const expected = "Last login: 2024-03-10_formatted.";
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
});
it("should join array values with a comma and space", () => {
test("should join array values with a comma and space", () => {
const text = "Tags: #recall:tags/fallback:none#.";
const expected = "Tags: beta, user.";
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
});
it("should handle empty array values, replacing with fallback", () => {
test("should handle empty array values, replacing with fallback", () => {
const text = "Categories: #recall:emptyArray/fallback:No&nbsp;Categories#.";
const expected = "Categories: No& ;Categories.";
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
});
it("should handle null values from responseData, replacing with fallback", () => {
const text = "Preference: #recall:nullValue/fallback:Not&nbsp;Set#.";
const expected = "Preference: Not& ;Set.";
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
});
it("should handle multiple recall patterns in a single string", () => {
test("should handle multiple recall patterns in a single string", () => {
const text =
"Hi #recall:name/fallback:User#, welcome to #recall:productName/fallback:Our Product#. Your role is #recall:userRole/fallback:Member#.";
const expected = "Hi John Doe, welcome to #recall:productName/fallback:Our Product#. Your role is Admin.";
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
});
it("should return original text if no recall pattern is found", () => {
test("should return original text if no recall pattern is found", () => {
const text = "This is a normal text without recall info.";
expect(replaceRecallInfo(text, responseData, variables)).toBe(text);
});
it("should handle recall ID not found, using fallback", () => {
test("should handle recall ID not found, using fallback", () => {
const text = "Value: #recall:nonExistent/fallback:FallbackValue#.";
const expected = "Value: FallbackValue.";
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
});
it("should handle if recall info is incomplete (e.g. missing fallback part), effectively using empty fallback", () => {
test("should handle if recall info is incomplete (e.g. missing fallback part), effectively using empty fallback", () => {
// This specific pattern is not fully matched by extractRecallInfo, leading to no replacement.
// The current extractRecallInfo expects #recall:ID/fallback:VALUE#
const text = "Test: #recall:name#";
@@ -114,10 +108,28 @@ describe("replaceRecallInfo", () => {
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
});
it("should handle complex fallback with spaces and special characters encoded as nbsp", () => {
test("should handle complex fallback with spaces and special characters encoded as nbsp", () => {
const text =
"Details: #recall:extraInfo/fallback:ValuenbspWithnbspSpaces# and #recall:anotherInfo/fallback:Default#";
const expected = "Details: Value With Spaces and Default";
"Details: #recall:extraInfo/fallback:Value&nbsp;With&nbsp;Spaces# and #recall:anotherInfo/fallback:Default#";
const expected = "Details: Value& ;With& ;Spaces and Default";
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
});
test("should handle fallback with only 'nbsp'", () => {
const text = "Note: #recall:note/fallback:nbsp#.";
const expected = "Note: .";
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
});
test("should handle fallback with only '&nbsp;'", () => {
const text = "Note: #recall:note/fallback:&nbsp;#.";
const expected = "Note: & ;.";
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
});
test("should handle fallback with '$nbsp;' (should not replace '$nbsp;')", () => {
const text = "Note: #recall:note/fallback:$nbsp;#.";
const expected = "Note: $ ;.";
expect(replaceRecallInfo(text, responseData, variables)).toBe(expected);
});
});
@@ -151,7 +163,7 @@ describe("parseRecallInformation", () => {
// other necessary TSurveyQuestion fields can be added here with default values
};
it("should replace recall info in headline", () => {
test("should replace recall info in headline", () => {
const question: TSurveyQuestion = {
...baseQuestion,
headline: { en: "Welcome, #recall:name/fallback:Guest#!" },
@@ -161,7 +173,7 @@ describe("parseRecallInformation", () => {
expect(result.headline.en).toBe(expectedHeadline);
});
it("should replace recall info in subheader", () => {
test("should replace recall info in subheader", () => {
const question: TSurveyQuestion = {
...baseQuestion,
headline: { en: "Main Question" },
@@ -172,7 +184,7 @@ describe("parseRecallInformation", () => {
expect(result.subheader?.en).toBe(expectedSubheader);
});
it("should replace recall info in both headline and subheader", () => {
test("should replace recall info in both headline and subheader", () => {
const question: TSurveyQuestion = {
...baseQuestion,
headline: { en: "User: #recall:name/fallback:User#" },
@@ -183,7 +195,7 @@ describe("parseRecallInformation", () => {
expect(result.subheader?.en).toBe("Survey: Onboarding");
});
it("should not change text if no recall info is present", () => {
test("should not change text if no recall info is present", () => {
const question: TSurveyQuestion = {
...baseQuestion,
headline: { en: "A simple question." },
@@ -199,7 +211,7 @@ describe("parseRecallInformation", () => {
expect(result.subheader?.en).toBe(question.subheader?.en);
});
it("should handle undefined subheader gracefully", () => {
test("should handle undefined subheader gracefully", () => {
const question: TSurveyQuestion = {
...baseQuestion,
headline: { en: "Question with #recall:name/fallback:User#" },
@@ -210,7 +222,7 @@ describe("parseRecallInformation", () => {
expect(result.subheader).toBeUndefined();
});
it("should not modify subheader if languageCode content is missing, even if recall is in other lang", () => {
test("should not modify subheader if languageCode content is missing, even if recall is in other lang", () => {
const question: TSurveyQuestion = {
...baseQuestion,
headline: { en: "Hello #recall:name/fallback:User#" },
@@ -222,7 +234,7 @@ describe("parseRecallInformation", () => {
expect(result.subheader?.fr).toBe("Bonjour #recall:name/fallback:Utilisateur#");
});
it("should handle malformed recall string (empty ID) leading to no replacement for that pattern", () => {
test("should handle malformed recall string (empty ID) leading to no replacement for that pattern", () => {
// This tests extractId returning null because extractRecallInfo won't match '#recall:/fallback:foo#'
// due to idPattern requiring at least one char for ID.
const question: TSurveyQuestion = {
@@ -233,7 +245,7 @@ describe("parseRecallInformation", () => {
expect(result.headline.en).toBe("Malformed: #recall:/fallback:foo# and valid: John Doe");
});
it("should use empty string for empty fallback value", () => {
test("should use empty string for empty fallback value", () => {
// This tests extractFallbackValue returning ""
const question: TSurveyQuestion = {
...baseQuestion,
@@ -243,7 +255,7 @@ describe("parseRecallInformation", () => {
expect(result.headline.en).toBe("Data: "); // nonExistentData not found, empty fallback used
});
it("should handle recall info if subheader is present but no text for languageCode", () => {
test("should handle recall info if subheader is present but no text for languageCode", () => {
const question: TSurveyQuestion = {
...baseQuestion,
headline: { en: "Headline #recall:name/fallback:User#" },

View File

@@ -7,27 +7,19 @@ import { type TSurveyQuestion } from "@formbricks/types/surveys/types";
const extractId = (text: string): string | null => {
const pattern = /#recall:([A-Za-z0-9_-]+)/;
const match = text.match(pattern);
if (match && match[1]) {
return match[1];
} else {
return null;
}
return match?.[1] ?? null;
};
// Extracts the fallback value from a string containing the "fallback" pattern.
const extractFallbackValue = (text: string): string => {
const pattern = /fallback:(\S*)#/;
const match = text.match(pattern);
if (match && match[1]) {
return match[1];
} else {
return "";
}
return match?.[1] ?? "";
};
// Extracts the complete recall information (ID and fallback) from a headline string.
const extractRecallInfo = (headline: string, id?: string): string | null => {
const idPattern = id ? id : "[A-Za-z0-9_-]+";
const idPattern = id ?? "[A-Za-z0-9_-]+";
const pattern = new RegExp(`#recall:(${idPattern})\\/fallback:(\\S*)#`);
const match = headline.match(pattern);
return match ? match[0] : null;
@@ -47,7 +39,7 @@ export const replaceRecallInfo = (
const recallItemId = extractId(recallInfo);
if (!recallItemId) return modifiedText; // Return the text if no ID could be extracted
const fallback = extractFallbackValue(recallInfo).replaceAll("nbsp", " ");
const fallback = extractFallbackValue(recallInfo).replace(/nbsp/g, " ").trim();
let value: string | null = null;
// Fetching value from variables based on recallItemId

View File

@@ -1,10 +1,11 @@
import preact from "@preact/preset-vite";
import { fileURLToPath } from "url";
import { dirname, resolve } from "path";
import { defineConfig, loadEnv } from "vite";
import { loadEnv } from "vite";
import dts from "vite-plugin-dts";
import tsconfigPaths from "vite-tsconfig-paths";
import { copyCompiledAssetsPlugin } from "../vite-plugins/copy-compiled-assets";
import { defineConfig } from "vitest/config";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -29,7 +30,7 @@ const config = ({ mode }) => {
},
},
define: {
"process.env": env,
"process.env.NODE_ENV": JSON.stringify(mode),
},
build: {
emptyOutDir: false,

2708
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff