Merge remote-tracking branch 'origin/main' into SaaS

# Conflicts:
#	frontend/editor/src/core/components/shared/AppConfigModal.tsx
This commit is contained in:
Anthony Stirling
2026-06-10 10:51:06 +01:00
143 changed files with 28540 additions and 984 deletions
@@ -104,44 +104,44 @@ const AddFileCard = ({
}}
onMouseLeave={() => setIsUploadHover(false)}
>
<Button
style={{
backgroundColor: "var(--landing-button-bg)",
color: "var(--landing-button-color)",
border: "1px solid var(--landing-button-border)",
borderRadius: "2rem",
height: "38px",
paddingLeft: isUploadHover ? 0 : "1rem",
paddingRight: isUploadHover ? 0 : "1rem",
width: isUploadHover ? "58px" : "calc(100% - 58px - 0.6rem)",
minWidth: isUploadHover ? "58px" : undefined,
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "width .5s ease, padding .5s ease",
}}
onClick={handleOpenFilesModal}
onMouseEnter={() => setIsUploadHover(false)}
>
<LocalIcon
icon="add"
width="1.5rem"
height="1.5rem"
className="text-[var(--accent-interactive)]"
/>
{!isUploadHover && (
{!isUploadHover && (
<Button
style={{
backgroundColor: "var(--landing-button-bg)",
color: "var(--landing-button-color)",
border: "1px solid var(--landing-button-border)",
borderRadius: "2rem",
height: "38px",
paddingLeft: "1rem",
paddingRight: "1rem",
width: "calc(100% - 58px - 0.6rem)",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "width .5s ease, padding .5s ease",
}}
onClick={handleOpenFilesModal}
onMouseEnter={() => setIsUploadHover(false)}
>
<LocalIcon
icon="add"
width="1.5rem"
height="1.5rem"
className="text-[var(--accent-interactive)]"
/>
<span>{t("landing.addFiles", "Add Files")}</span>
)}
</Button>
</Button>
)}
<Button
aria-label={t("addFileCard.upload", "Upload")}
title={terminology.uploadFromComputer}
style={{
backgroundColor: "var(--landing-button-bg)",
color: "var(--landing-button-color)",
border: "1px solid var(--landing-button-border)",
borderRadius: "1rem",
height: "38px",
width: isUploadHover ? "calc(100% - 58px - 0.6rem)" : "58px",
width: isUploadHover ? "100%" : "58px",
minWidth: "58px",
paddingLeft: isUploadHover ? "1rem" : 0,
paddingRight: isUploadHover ? "1rem" : 0,
@@ -149,6 +149,7 @@ const AddFileCard = ({
alignItems: "center",
justifyContent: "center",
transition: "width .5s ease, padding .5s ease",
overflow: "hidden",
}}
onClick={handleNativeUploadClick}
onMouseEnter={() => setIsUploadHover(true)}
@@ -157,10 +158,18 @@ const AddFileCard = ({
icon={icons.uploadIconName}
width="1.25rem"
height="1.25rem"
style={{ color: "var(--accent-interactive)" }}
style={{ color: "var(--accent-interactive)", flexShrink: 0 }}
/>
{isUploadHover && (
<span style={{ marginLeft: ".5rem" }}>
<span
style={{
marginLeft: ".5rem",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
minWidth: 0,
}}
>
{terminology.uploadFromComputer}
</span>
)}
@@ -743,6 +743,7 @@ function FileCard({
size="sm"
onClick={(e) => e.stopPropagation()}
aria-label={t("filesPage.fileMenu", "File actions")}
data-testid="file-card-actions"
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
@@ -773,6 +774,7 @@ function FileCard({
e.stopPropagation();
onMove();
}}
data-testid="file-menu-move-to"
>
{t("filesPage.moveTo", "Move to…")}
</Menu.Item>
@@ -1331,6 +1333,7 @@ function FileRow({
size="sm"
onClick={(e) => e.stopPropagation()}
aria-label={t("filesPage.fileMenu", "File actions")}
data-testid="file-card-actions"
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
@@ -191,6 +191,7 @@ export function MoveToFolderDialog({
return creatingFolder ? (
<Group gap="xs" align="flex-end" wrap="nowrap">
<TextInput
data-testid="move-dialog-new-folder-name"
label={t(
"filesPage.moveDialog.newFolderLabel",
"New folder name",
@@ -251,6 +252,7 @@ export function MoveToFolderDialog({
setNewFolderName("");
}}
styles={{ root: { alignSelf: "flex-start" } }}
data-testid="move-dialog-create-folder-toggle"
>
{t(
"filesPage.moveDialog.newFolderToggle",
@@ -106,6 +106,15 @@
margin-bottom: 0.25rem; /* 4px */
}
.modal-nav-item-badge {
display: none;
}
.modal-nav-item:hover .modal-nav-item-badge,
.modal-nav-item:focus-within .modal-nav-item-badge,
.modal-nav-item.active .modal-nav-item-badge {
display: inline-flex;
}
.modal-content {
flex: 1;
display: flex;
@@ -24,19 +24,33 @@ import {
useUnsavedChanges,
} from "@app/contexts/UnsavedChangesContext";
import { SettingsSearchBar } from "@app/components/shared/config/SettingsSearchBar";
import { stripBasePath } from "@app/constants/app";
import { stripBasePath, withBasePath } from "@app/constants/app";
interface AppConfigModalProps {
opened: boolean;
onClose: () => void;
}
// Extract section from URL path (e.g., /settings/people -> people)
const getSectionFromPath = (pathname: string): NavKey | null => {
const match = pathname.match(/\/settings\/([^/]+)/);
if (match && match[1]) {
const section = match[1] as NavKey;
return VALID_NAV_KEYS.includes(section as NavKey) ? section : null;
}
return null;
};
const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
opened,
onClose,
}) => {
const { t } = useTranslation();
const [active, setActive] = useState<NavKey>("general");
// Initialize from the URL so a deep link (`/settings/people`) lands on the
// right tab without a one-frame "general" flicker.
const [active, setActive] = useState<NavKey>(
() => getSectionFromPath(window.location.pathname) ?? "general",
);
const isMobile = useIsMobile();
const navigate = useNavigate();
const location = useLocation();
@@ -45,17 +59,10 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
const { confirmIfDirty } = useUnsavedChanges();
const closeButtonRef = useRef<HTMLButtonElement>(null);
// Extract section from URL path (e.g., /settings/people -> people)
const getSectionFromPath = (pathname: string): NavKey | null => {
const match = pathname.match(/\/settings\/([^/]+)/);
if (match && match[1]) {
const section = match[1] as NavKey;
return VALID_NAV_KEYS.includes(section as NavKey) ? section : null;
}
return null;
};
// Sync active state with URL path
// Sync active state with URL path. Runs on open, on external URL changes,
// and on the redirect path below - NOT on intra-modal tab clicks, because
// those update the URL via `history.replaceState` directly and never push
// a new React Router location.
useEffect(() => {
const section = getSectionFromPath(location.pathname);
if (opened && section) {
@@ -77,19 +84,42 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
}
}, [opened]);
// Handle custom events for backwards compatibility.
// Use replace when already on /settings/* so external tab-switches
// don't pile up history entries that would break close-by-back.
// Switch tab without forcing every `useLocation()` subscriber (HomePage and
// its FileSidebar/Workbench/RightSidebar/FileManager tree) to re-render.
//
// First entry into /settings/* still goes through React Router so HomePage's
// location-watching effect opens the modal and pushes a real history entry -
// so the back button can close us. Subsequent tab clicks bypass React Router
// and mutate the URL bar via `history.replaceState`. The browser sees the
// URL update (deep-link / refresh still work) but React Router never fires a
// location change, so the layer behind the Mantine overlay never repaints
// and the backdrop-filter blur stops flashing.
const switchSection = useCallback(
(key: NavKey) => {
setActive(key);
const alreadyInSettings = stripBasePath(
window.location.pathname,
).startsWith("/settings");
if (alreadyInSettings) {
window.history.replaceState(
window.history.state,
"",
withBasePath(`/settings/${key}`),
);
} else {
navigate(`/settings/${key}`);
}
},
[navigate],
);
// Backwards-compat: external `appConfig:navigate` events route through the
// same switchSection path so they get the no-flash treatment too.
useEffect(() => {
const handler = (ev: Event) => {
const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined;
if (detail?.key) {
const alreadyInSettings = stripBasePath(
window.location.pathname,
).startsWith("/settings");
navigate(`/settings/${detail.key}`, {
replace: alreadyInSettings,
});
switchSection(detail.key);
}
};
window.addEventListener("appConfig:navigate", handler as EventListener);
@@ -98,7 +128,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
"appConfig:navigate",
handler as EventListener,
);
}, [navigate]);
}, [switchSection]);
const colors = useMemo(
() => ({
@@ -167,23 +197,9 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
async (key: NavKey) => {
const canProceed = await confirmIfDirty();
if (!canProceed) return;
setActive(key);
// First in-modal nav (when current path isn't `/settings/*` yet) must
// PUSH so the originating page stays in history and close-by-back can
// return to it. Subsequent tab switches REPLACE so they don't pile up
// history entries that handleClose's navigate(-1) can't unwind.
//
// Read window.location.pathname directly (not the React hook's
// location.pathname) so rapid successive clicks pick up the URL
// change from the previous click immediately. The hook snapshot is
// stale between render cycles - relying on it lets a second click
// PUSH again before React re-renders, producing a history pile-up.
const alreadyInSettings =
window.location.pathname.startsWith("/settings");
navigate(`/settings/${key}`, { replace: alreadyInSettings });
switchSection(key);
},
[confirmIfDirty, navigate],
[confirmIfDirty, switchSection],
);
return (
@@ -244,7 +260,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
handleNavigation(item.key);
}
}}
className={`modal-nav-item ${isMobile ? "mobile" : ""}`}
className={`modal-nav-item ${isActive ? "active" : ""} ${isMobile ? "mobile" : ""}`}
style={{
background: isActive
? colors.navItemActiveBg
@@ -261,8 +277,19 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
style={{ color }}
/>
{!isMobile && (
<Group gap={4} align="center" wrap="nowrap">
<Text size="sm" fw={500} style={{ color }}>
<Group
gap={4}
align="center"
wrap="nowrap"
style={{ minWidth: 0, flex: 1 }}
>
<Text
size="sm"
fw={500}
truncate
style={{ color, minWidth: 0, flex: 1 }}
title={item.label}
>
{item.label}
</Text>
{item.badge && (
@@ -270,6 +297,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
size="xs"
variant="light"
color={item.badgeColor ?? "orange"}
className="modal-nav-item-badge"
style={{ flexShrink: 0 }}
>
{item.badge}
@@ -97,9 +97,16 @@
order: 3;
flex: 0 0 100%;
height: auto;
overflow: visible;
padding: 4px 0;
border-top: 1px solid var(--border-subtle);
justify-content: flex-start;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
-webkit-overflow-scrolling: touch;
}
.workbench-bar[data-wrapped="true"] .workbench-bar-center > * {
flex-shrink: 0;
}
/* ---- Right: global buttons (theme / language / download) ---- */
@@ -492,7 +492,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t("settings.general.theme", "Theme")}
</Text>
@@ -527,7 +527,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t("settings.general.language", "Language")}
</Text>
@@ -552,7 +552,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"settings.general.defaultToolPickerMode",
@@ -590,7 +590,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"settings.general.defaultStartupView",
@@ -632,7 +632,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t("settings.general.defaultViewerZoom", "Default reader zoom")}
</Text>
@@ -687,7 +687,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"settings.general.hideUnavailableTools",
@@ -718,7 +718,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"settings.general.hideUnavailableConversions",
@@ -759,7 +759,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
cursor: "help",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t("settings.general.autoUnzip", "Auto-unzip API responses")}
</Text>
@@ -796,7 +796,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
cursor: "help",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"settings.general.autoUnzipFileLimit",
@@ -101,7 +101,7 @@ export default function ProviderCard({
justifyContent: "space-between",
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{field.label}
</Text>
@@ -91,7 +91,18 @@ const OperationButton = ({
color={color}
data-testid={dataTestId}
data-tour={dataTour}
style={{ minHeight: "2.5rem", position: "relative" }}
style={{ minHeight: "2.5rem", height: "auto", position: "relative" }}
styles={{
label: {
whiteSpace: "normal",
textAlign: "center",
lineHeight: 1.2,
},
inner: {
paddingTop: "0.25rem",
paddingBottom: "0.25rem",
},
}}
>
{isLoading
? loadingText || t("loading", "Loading...")
@@ -4,11 +4,13 @@ import {
Button,
Paper,
Group,
Menu,
NumberInput,
Slider,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useViewer } from "@app/contexts/ViewerContext";
import { useIsPhone } from "@app/hooks/useIsMobile";
import { Tooltip } from "@app/components/shared/Tooltip";
import FirstPageIcon from "@mui/icons-material/FirstPage";
import ArrowBackIosIcon from "@mui/icons-material/ArrowBackIos";
@@ -21,6 +23,7 @@ import WbSunnyIcon from "@mui/icons-material/WbSunny";
import WbTwilightIcon from "@mui/icons-material/WbTwilight";
import ZoomInIcon from "@mui/icons-material/ZoomIn";
import ZoomOutIcon from "@mui/icons-material/ZoomOut";
import MoreVertIcon from "@mui/icons-material/MoreVert";
interface PdfViewerToolbarProps {
// Page navigation props (placeholders for now)
@@ -35,6 +38,9 @@ export function PdfViewerToolbar({
onPageChange,
}: PdfViewerToolbarProps) {
const { t } = useTranslation();
const isPhone = useIsPhone();
const buttonMinWidth = isPhone ? "3rem" : "2.5rem";
const buttonSize = isPhone ? "lg" : "md";
const {
getScrollState,
getZoomState,
@@ -141,41 +147,45 @@ export function PdfViewerToolbar({
style={{
display: "flex",
alignItems: "center",
flexWrap: "wrap",
rowGap: 8,
gap: 10,
justifyContent: "center",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
boxShadow: "0 -2px 8px rgba(0,0,0,0.04)",
pointerEvents: "auto",
minWidth: "30rem",
}}
>
{/* First Page Button */}
<Button
variant="subtle"
color="blue"
size="md"
px={8}
radius="xl"
onClick={handleFirstPage}
disabled={scrollState.currentPage === 1}
style={{ minWidth: "2.5rem" }}
title={t("viewer.firstPage", "First Page")}
>
<FirstPageIcon fontSize="small" />
</Button>
{!isPhone && (
<Button
variant="subtle"
color="blue"
size={buttonSize}
px={8}
radius="xl"
onClick={handleFirstPage}
disabled={scrollState.currentPage === 1}
style={{ minWidth: buttonMinWidth }}
title={t("viewer.firstPage", "First Page")}
>
<FirstPageIcon fontSize="small" />
</Button>
)}
{/* Previous Page Button */}
<Button
variant="subtle"
color="blue"
size="md"
size={buttonSize}
px={8}
radius="xl"
onClick={handlePreviousPage}
disabled={scrollState.currentPage === 1}
style={{ minWidth: "2.5rem" }}
style={{ minWidth: buttonMinWidth }}
title={t("viewer.previousPage", "Previous Page")}
>
<ArrowBackIosIcon fontSize="small" />
@@ -212,137 +222,242 @@ export function PdfViewerToolbar({
<Button
variant="subtle"
color="blue"
size="md"
size={buttonSize}
px={8}
radius="xl"
onClick={handleNextPage}
disabled={scrollState.currentPage === scrollState.totalPages}
style={{ minWidth: "2.5rem" }}
style={{ minWidth: buttonMinWidth }}
title={t("viewer.nextPage", "Next Page")}
>
<ArrowForwardIosIcon fontSize="small" />
</Button>
{/* Last Page Button */}
<Button
variant="subtle"
color="blue"
size="md"
px={8}
radius="xl"
onClick={handleLastPage}
disabled={scrollState.currentPage === scrollState.totalPages}
style={{ minWidth: "2.5rem" }}
title={t("viewer.lastPage", "Last Page")}
>
<LastPageIcon fontSize="small" />
</Button>
{!isPhone && (
<Button
variant="subtle"
color="blue"
size={buttonSize}
px={8}
radius="xl"
onClick={handleLastPage}
disabled={scrollState.currentPage === scrollState.totalPages}
style={{ minWidth: buttonMinWidth }}
title={t("viewer.lastPage", "Last Page")}
>
<LastPageIcon fontSize="small" />
</Button>
)}
{/* Dual Page Toggle */}
<Tooltip
content={
isDualPageActive
? t("viewer.singlePageView", "Single Page View")
: t("viewer.dualPageView", "Dual Page View")
}
position="top"
arrow
>
<Button
variant={isDualPageActive ? "filled" : "light"}
color="blue"
size="md"
radius="xl"
onClick={handleDualPageToggle}
disabled={scrollState.totalPages <= 1}
style={{ minWidth: "2.5rem" }}
{!isPhone && (
<Tooltip
content={
isDualPageActive
? t("viewer.singlePageView", "Single Page View")
: t("viewer.dualPageView", "Dual Page View")
}
position="top"
arrow
>
{isDualPageActive ? (
<DescriptionIcon fontSize="small" />
) : (
<ViewWeekIcon fontSize="small" />
)}
</Button>
</Tooltip>
<Button
variant={isDualPageActive ? "filled" : "light"}
color="blue"
size={buttonSize}
radius="xl"
onClick={handleDualPageToggle}
disabled={scrollState.totalPages <= 1}
style={{ minWidth: buttonMinWidth }}
>
{isDualPageActive ? (
<DescriptionIcon fontSize="small" />
) : (
<ViewWeekIcon fontSize="small" />
)}
</Button>
</Tooltip>
)}
{/* PDF Render Mode Toggle */}
<Tooltip
content={
pdfRenderMode === "normal"
? t("viewer.enableDarkFilter", "Enable Dark Filter")
: pdfRenderMode === "dark"
? t("viewer.enableSepiaFilter", "Enable Sepia Filter")
: t("viewer.disableColorFilter", "Disable Color Filter")
}
position="top"
arrow
>
<Button
variant={pdfRenderMode !== "normal" ? "filled" : "light"}
color="blue"
size="md"
radius="xl"
onClick={cyclePdfRenderMode}
style={{ minWidth: "2.5rem" }}
aria-label={
{!isPhone && (
<Tooltip
content={
pdfRenderMode === "normal"
? t("viewer.enableDarkFilter", "Enable Dark Filter")
: pdfRenderMode === "dark"
? t("viewer.enableSepiaFilter", "Enable Sepia Filter")
: t("viewer.disableColorFilter", "Disable Color Filter")
}
position="top"
arrow
>
{pdfRenderMode === "normal" && <DarkModeIcon fontSize="small" />}
{pdfRenderMode === "dark" && <WbTwilightIcon fontSize="small" />}
{pdfRenderMode === "sepia" && <WbSunnyIcon fontSize="small" />}
</Button>
</Tooltip>
<Button
variant={pdfRenderMode !== "normal" ? "filled" : "light"}
color="blue"
size={buttonSize}
radius="xl"
onClick={cyclePdfRenderMode}
style={{ minWidth: buttonMinWidth }}
aria-label={
pdfRenderMode === "normal"
? t("viewer.enableDarkFilter", "Enable Dark Filter")
: pdfRenderMode === "dark"
? t("viewer.enableSepiaFilter", "Enable Sepia Filter")
: t("viewer.disableColorFilter", "Disable Color Filter")
}
>
{pdfRenderMode === "normal" && <DarkModeIcon fontSize="small" />}
{pdfRenderMode === "dark" && <WbTwilightIcon fontSize="small" />}
{pdfRenderMode === "sepia" && <WbSunnyIcon fontSize="small" />}
</Button>
</Tooltip>
)}
{/* Zoom Controls */}
<Group gap={4} align="center" style={{ marginLeft: 16 }}>
<ActionIcon
variant="subtle"
color="blue"
radius="md"
onClick={handleZoomOut}
aria-label={t("viewer.zoomOut", "Zoom out")}
{/* Desktop zoom controls (slider + buttons) */}
{!isPhone && (
<Group
gap={4}
align="center"
wrap="nowrap"
style={{ marginLeft: 16, flexShrink: 0 }}
>
<ZoomOutIcon fontSize="small" />
</ActionIcon>
<Slider
value={Math.min(Math.max(displayZoomPercent, 20), 500)}
min={20}
max={500}
step={5}
onChange={(val) => zoomActions.setZoomLevel?.(val / 100)}
size="xs"
styles={{
root: { width: "6rem" },
thumb: { width: 14, height: 14 },
track: { height: 3 },
}}
label={null}
/>
<ActionIcon
variant="subtle"
color="blue"
radius="md"
onClick={handleZoomIn}
aria-label={t("viewer.zoomIn", "Zoom in")}
<ActionIcon
variant="subtle"
color="blue"
radius="md"
onClick={handleZoomOut}
aria-label={t("viewer.zoomOut", "Zoom out")}
>
<ZoomOutIcon fontSize="small" />
</ActionIcon>
<Slider
value={Math.min(Math.max(displayZoomPercent, 20), 500)}
min={20}
max={500}
step={5}
onChange={(val) => zoomActions.setZoomLevel?.(val / 100)}
size="xs"
styles={{
root: { minWidth: "6rem", width: "6rem", flexShrink: 0 },
thumb: { width: 14, height: 14 },
track: { height: 3 },
}}
label={null}
/>
<ActionIcon
variant="subtle"
color="blue"
radius="md"
onClick={handleZoomIn}
aria-label={t("viewer.zoomIn", "Zoom in")}
>
<ZoomInIcon fontSize="small" />
</ActionIcon>
<span
style={{
minWidth: "2.5rem",
textAlign: "center",
fontSize: 12,
color: "var(--text-muted)",
}}
>
{displayZoomPercent}%
</span>
</Group>
)}
{isPhone && (
<Menu
shadow="md"
width={240}
position="top-end"
closeOnItemClick={false}
>
<ZoomInIcon fontSize="small" />
</ActionIcon>
<span
style={{
minWidth: "2.5rem",
textAlign: "center",
fontSize: 12,
color: "var(--text-muted)",
}}
>
{displayZoomPercent}%
</span>
</Group>
<Menu.Target>
<ActionIcon
variant="light"
color="blue"
radius="md"
size="lg"
aria-label={t("viewer.moreOptions", "More")}
style={{ marginLeft: 4 }}
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>
{t("viewer.pageNavigation", "Page navigation")}
</Menu.Label>
<Menu.Item
leftSection={<FirstPageIcon fontSize="small" />}
disabled={scrollState.currentPage === 1}
onClick={handleFirstPage}
>
{t("viewer.firstPage", "First page")}
</Menu.Item>
<Menu.Item
leftSection={<LastPageIcon fontSize="small" />}
disabled={scrollState.currentPage === scrollState.totalPages}
onClick={handleLastPage}
>
{t("viewer.lastPage", "Last page")}
</Menu.Item>
<Menu.Divider />
<Menu.Label>{t("viewer.zoom", "Zoom")}</Menu.Label>
<Menu.Item
leftSection={<ZoomOutIcon fontSize="small" />}
onClick={handleZoomOut}
>
{t("viewer.zoomOut", "Zoom out")}
</Menu.Item>
<Menu.Item
leftSection={<ZoomInIcon fontSize="small" />}
onClick={handleZoomIn}
>
{t("viewer.zoomIn", "Zoom in")} ({displayZoomPercent}%)
</Menu.Item>
<Menu.Divider />
<Menu.Label>{t("viewer.view", "View")}</Menu.Label>
<Menu.Item
leftSection={
isDualPageActive ? (
<DescriptionIcon fontSize="small" />
) : (
<ViewWeekIcon fontSize="small" />
)
}
disabled={scrollState.totalPages <= 1}
onClick={handleDualPageToggle}
>
{isDualPageActive
? t("viewer.singlePageView", "Single Page View")
: t("viewer.dualPageView", "Dual Page View")}
</Menu.Item>
<Menu.Item
leftSection={
pdfRenderMode === "normal" ? (
<DarkModeIcon fontSize="small" />
) : pdfRenderMode === "dark" ? (
<WbTwilightIcon fontSize="small" />
) : (
<WbSunnyIcon fontSize="small" />
)
}
onClick={cyclePdfRenderMode}
>
{pdfRenderMode === "normal"
? t("viewer.enableDarkFilter", "Enable Dark Filter")
: pdfRenderMode === "dark"
? t("viewer.enableSepiaFilter", "Enable Sepia Filter")
: t("viewer.disableColorFilter", "Disable Color Filter")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Paper>
);
}
@@ -5,6 +5,7 @@ import {
useAppConfig,
} from "@app/contexts/AppConfigContext";
import apiClient from "@app/services/apiClient";
import { allowConsole, expectConsole } from "@app/tests/failOnConsole";
import { ReactNode } from "react";
// Mock apiClient
@@ -92,6 +93,7 @@ describe("AppConfigContext", () => {
});
it("should handle network errors", async () => {
expectConsole.error(/\[AppConfig\] Failed to fetch app config/);
const errorMessage = "Network error occurred";
const mockError = new Error(errorMessage);
// Network errors don't have response property
@@ -249,6 +251,10 @@ describe("AppConfigContext", () => {
});
it("should handle initial config prop", async () => {
// apiClient.get isn't mocked here, so the implicit "fetch fails" path
// exhausts retries and logs. Test only asserts the API was called, so
// the failure log is incidental.
allowConsole.error(/\[AppConfig\] Failed to fetch app config/);
const initialConfig = {
enableLogin: false,
appNameNavbar: "Initial App",
@@ -107,12 +107,10 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
if (attempt > 0) {
const delay = initialDelay * Math.pow(2, attempt - 1);
console.log(
console.debug(
`[AppConfig] Retry attempt ${attempt}/${maxRetries} after ${delay}ms delay...`,
);
await sleep(delay);
} else {
console.log("[AppConfig] Fetching app config...");
}
// apiClient automatically adds JWT header if available via interceptors
@@ -165,7 +163,7 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
(!status || status >= 500) && attempt < maxRetries;
if (shouldRetry) {
console.warn(
console.debug(
`[AppConfig] Attempt ${attempt + 1} failed (status ${status || "network error"}):`,
err.message,
"- will retry...",
@@ -3,6 +3,8 @@ import { describe, expect, test, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act } from "@testing-library/react";
import { FolderProvider, useFolders } from "@app/contexts/FolderContext";
import { createFolderId, FolderId, FolderRecord } from "@app/types/folder";
import { expectConsole } from "@app/tests/failOnConsole";
/**
* Regression test for the sync-banner 4xx gating fix in commit c38b646c5.
@@ -25,21 +27,43 @@ import { FolderProvider, useFolders } from "@app/contexts/FolderContext";
// require a running backend (folderSyncService) and a populated IDB
// (folderStorage). Both are out of scope for testing the error gate.
const mockList = vi.fn();
const mockUpdate = vi.fn();
const mockDelete = vi.fn();
vi.mock("@app/services/folderSyncService", () => ({
folderSyncService: {
list: () => mockList(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
update: (...args: unknown[]) => mockUpdate(...args),
delete: (...args: unknown[]) => mockDelete(...args),
},
}));
// Stateful IDB mock - the revision-driven refresh re-reads getAllFolders
// after every state change, so a stateless [] mock would clobber pull results.
const { mockIdb } = vi.hoisted(() => ({
mockIdb: { folders: [] as { id: string }[] },
}));
vi.mock("@app/services/folderStorage", () => ({
folderStorage: {
getAllFolders: vi.fn().mockResolvedValue([]),
replaceAll: vi.fn().mockResolvedValue(undefined),
upsert: vi.fn().mockResolvedValue(undefined),
getAllFolders: vi.fn(() => Promise.resolve([...mockIdb.folders])),
replaceAll: vi.fn((next: { id: string }[]) => {
mockIdb.folders = [...next];
return Promise.resolve();
}),
upsert: vi.fn(),
upsertFolder: vi.fn((next: { id: string }) => {
mockIdb.folders = [
...mockIdb.folders.filter((f) => f.id !== next.id),
next,
];
return Promise.resolve();
}),
delete: vi.fn().mockResolvedValue(undefined),
removeFolders: vi.fn((ids: string[]) => {
const drop = new Set(ids);
mockIdb.folders = mockIdb.folders.filter((f) => !drop.has(f.id));
return Promise.resolve();
}),
},
}));
@@ -104,6 +128,8 @@ async function renderAndWaitForPull(): Promise<void> {
describe("FolderContext sync-banner gating", () => {
beforeEach(() => {
mockList.mockReset();
mockUpdate.mockReset();
mockDelete.mockReset();
});
test("401 (unauthorized) does NOT surface a banner", async () => {
@@ -122,6 +148,9 @@ describe("FolderContext sync-banner gating", () => {
});
test("500 (server error) DOES surface a banner", async () => {
// Surfacing the banner also logs a warning - that's the contract this branch
// tests for.
expectConsole.warn(/\[FolderContext\] pullFromServer failed/);
mockList.mockRejectedValue(axiosError(500, "internal"));
await renderAndWaitForPull();
expect(screen.getByTestId("error").textContent).toContain(
@@ -133,7 +162,8 @@ describe("FolderContext sync-banner gating", () => {
test("network error (no response) DOES surface a banner", async () => {
// axios on a network failure rejects with an Error that has NO
// `.response` property - that's the "status === undefined" branch
// the gate explicitly covers.
// the gate explicitly covers. Surfacing the banner also logs a warning.
expectConsole.warn(/\[FolderContext\] pullFromServer failed/);
mockList.mockRejectedValue(new Error("ECONNREFUSED"));
await renderAndWaitForPull();
expect(screen.getByTestId("error").textContent).toContain(
@@ -152,3 +182,163 @@ describe("FolderContext sync-banner gating", () => {
expect(screen.getByTestId("reachable").textContent).toBe("false");
});
});
/** Per-folder mutation 404 = silent cleanup (drop subtree, no banner, pull). */
describe("FolderContext stale-folder 404 cleanup", () => {
beforeEach(() => {
mockList.mockReset();
mockUpdate.mockReset();
mockDelete.mockReset();
// Reset the stateful IDB mock so each test starts with an empty cache.
mockIdb.folders = [];
});
function makeFolder(name: string, parentFolderId: FolderId | null = null) {
return {
id: createFolderId(),
name,
parentFolderId,
createdAt: 0,
updatedAt: 0,
} as FolderRecord;
}
type ProbeApi = {
error: string | null;
folderCount: number;
currentFolderId: FolderId | null;
setCurrentFolderId: (id: FolderId | null) => void;
rename: (id: FolderId, name: string) => Promise<unknown>;
delete: (id: FolderId) => Promise<unknown>;
};
function ApiProbe(props: { onReady: (api: ProbeApi) => void }) {
const f = useFolders();
React.useEffect(() => {
props.onReady({
error: f.error,
folderCount: f.folders.length,
currentFolderId: f.currentFolderId,
setCurrentFolderId: f.setCurrentFolderId,
rename: (id, name) => f.renameFolder(id, name),
delete: (id) => f.deleteFolder(id),
});
}, [f, props]);
return (
<>
<div data-testid="error">{f.error ?? "<null>"}</div>
<div data-testid="reachable">{String(f.serverReachable)}</div>
<div data-testid="count">{f.folders.length}</div>
<div data-testid="current">{f.currentFolderId ?? "<null>"}</div>
</>
);
}
async function setupWithFolders(
initial: FolderRecord[],
): Promise<{ current: ProbeApi }> {
// Ref (not plain object) so tests see the latest f-bound api after re-renders.
mockList.mockResolvedValueOnce(initial);
const apiRef: { current: ProbeApi | null } = { current: null };
render(
<FolderProvider>
<ApiProbe onReady={(api) => (apiRef.current = api)} />
</FolderProvider>,
);
await waitFor(() =>
expect(screen.getByTestId("count").textContent).toBe(
String(initial.length),
),
);
if (!apiRef.current) throw new Error("ApiProbe never reported ready");
return apiRef as { current: ProbeApi };
}
test("renameFolder 404 silently drops folder + descendants, no banner", async () => {
const parent = makeFolder("parent");
const child = makeFolder("child", parent.id);
const sibling = makeFolder("sibling");
const api = await setupWithFolders([parent, child, sibling]);
// Convergence pull returns just sibling after the local drop.
mockList.mockResolvedValueOnce([sibling]);
mockUpdate.mockRejectedValueOnce(axiosError(404, "Folder not found"));
await act(async () => {
const result = await api.current.rename(parent.id, "newname");
expect(result).toBeNull();
});
expect(screen.getByTestId("error").textContent).toBe("<null>");
await waitFor(() =>
expect(screen.getByTestId("count").textContent).toBe("1"),
);
expect(screen.getByTestId("reachable").textContent).toBe("true");
});
test("deleteFolder 404 is treated as already-deleted, returns [id], no banner", async () => {
const target = makeFolder("target");
const other = makeFolder("other");
const api = await setupWithFolders([target, other]);
mockList.mockResolvedValueOnce([other]);
mockDelete.mockRejectedValueOnce(axiosError(404, "Folder not found"));
let result: unknown;
await act(async () => {
result = await api.current.delete(target.id);
});
expect(result).toEqual([target.id]);
expect(screen.getByTestId("error").textContent).toBe("<null>");
await waitFor(() =>
expect(screen.getByTestId("count").textContent).toBe("1"),
);
expect(screen.getByTestId("reachable").textContent).toBe("true");
});
test("stale 404 strand-resets currentFolderId when user is inside the dropped subtree", async () => {
// User is parked inside the about-to-be-deleted subtree → must navigate out.
const parent = makeFolder("parent");
const child = makeFolder("child", parent.id);
const sibling = makeFolder("sibling");
const api = await setupWithFolders([parent, child, sibling]);
act(() => {
api.current.setCurrentFolderId(child.id);
});
await waitFor(() =>
expect(screen.getByTestId("current").textContent).toBe(child.id),
);
mockList.mockResolvedValueOnce([sibling]);
mockUpdate.mockRejectedValueOnce(axiosError(404, "Folder not found"));
await act(async () => {
await api.current.rename(parent.id, "doomed-rename");
});
// ROOT_FOLDER_ID is null → probe renders "<null>".
expect(screen.getByTestId("current").textContent).toBe("<null>");
expect(screen.getByTestId("error").textContent).toBe("<null>");
});
test("non-404 mutation errors still surface (regression guard)", async () => {
const target = makeFolder("target");
const api = await setupWithFolders([target]);
mockUpdate.mockRejectedValueOnce(axiosError(500, "boom"));
let threw = false;
await act(async () => {
try {
await api.current.rename(target.id, "newname");
} catch {
threw = true;
}
});
expect(threw).toBe(true);
expect(screen.getByTestId("count").textContent).toBe("1");
expect(screen.getByTestId("error").textContent).not.toBe("<null>");
});
});
@@ -178,6 +178,40 @@ function reachabilityFromError(err: unknown): boolean {
return status !== undefined && status >= 400 && status < 500;
}
/** Root + every local descendant via parentFolderId. Bounded for corrupted chains. */
function collectLocalSubtreeIds(
rootId: FolderId,
folders: FolderRecord[],
): Set<FolderId> {
const childrenByParent = new Map<FolderId, FolderId[]>();
for (const f of folders) {
if (f.parentFolderId === null) continue;
const list = childrenByParent.get(f.parentFolderId) ?? [];
list.push(f.id);
childrenByParent.set(f.parentFolderId, list);
}
const result = new Set<FolderId>([rootId]);
const stack: FolderId[] = [rootId];
const MAX_LOCAL_SUBTREE_NODES = 10_000;
while (stack.length > 0 && result.size < MAX_LOCAL_SUBTREE_NODES) {
const cur = stack.pop()!;
const children = childrenByParent.get(cur);
if (!children) continue;
for (const childId of children) {
if (!result.has(childId)) {
result.add(childId);
stack.push(childId);
}
}
}
return result;
}
/** Extract HTTP status off an axios-style error, or undefined on network failure. */
function errorStatus(err: unknown): number | undefined {
return (err as { response?: { status?: number } })?.response?.status;
}
/**
* True if `currentId` or any of its ancestors is in `removedSet`. Walks up via
* `parentFolderId` using the pre-removal `folders` snapshot so the chain is
@@ -272,7 +306,6 @@ export function FolderProvider({ children }: FolderProviderProps) {
if (mountedRef.current) setServerReachable(false);
return { ok: false, reason: "endpoint-missing" };
}
console.warn("[FolderContext] pullFromServer failed", err);
if (mountedRef.current) {
setServerReachable(false);
// Only surface a banner when this is a server-side outage or
@@ -284,6 +317,7 @@ export function FolderProvider({ children }: FolderProviderProps) {
// manager. Folder-mutation buttons get individual disabled
// tooltips via `serverReachable`, which is enough signal.
if (status === undefined || status >= 500) {
console.warn("[FolderContext] pullFromServer failed", err);
setError(`Folder sync failed: ${formatServerError(err)}`);
}
}
@@ -390,24 +424,58 @@ export function FolderProvider({ children }: FolderProviderProps) {
// ─── mutations: server-first, cache update on success ──────────────
// Lifted up so handleStaleFolder below can reuse the file-detach primitive.
const { clearFolderForFiles } = useIndexedDB();
/** Treat a per-folder 404 as "deleted elsewhere": drop subtree, strand-reset, pull. */
const handleStaleFolder = useCallback(
(staleId: FolderId, foldersSnapshot: FolderRecord[]) => {
const subtree = collectLocalSubtreeIds(staleId, foldersSnapshot);
if (mountedRef.current) {
setServerReachable(true);
setError(null);
setFolders((prev) => prev.filter((f) => !subtree.has(f.id)));
if (
currentFolderId !== null &&
shouldStrandedReset(currentFolderId, subtree, foldersSnapshot)
) {
setCurrentFolderId(ROOT_FOLDER_ID);
}
}
const subtreeIds = [...subtree];
// Best-effort - pullFromServer is authoritative if these miss.
void folderStorage
.removeFolders(subtreeIds)
.catch((e) => console.warn("[FolderContext] stale cache cleanup", e));
void clearFolderForFiles(subtreeIds).catch((e) =>
console.warn("[FolderContext] stale file-folder cleanup", e),
);
bumpFolderRevision();
void pullFromServer();
},
[bumpFolderRevision, clearFolderForFiles, currentFolderId, pullFromServer],
);
/**
* Centralised mutation wrapper:
* 1. Calls the server op.
* 2. On success: flips `serverReachable=true`, updates in-memory state
* from the server response, then best-effort writes the cache (cache
* failure does NOT roll back; the in-memory truth came from the server).
* 3. On failure: updates `serverReachable` per the error class, surfaces
* via {@link setError}, re-throws so the caller's dialog can stay open.
* Server-first mutation wrapper. On 404 with `staleFolderId`, hands off to
* handleStaleFolder and resolves `null`; other errors surface + re-throw.
*/
const runFolderMutation = useCallback(
async <T,>(
serverOp: () => Promise<T>,
onSuccess: (result: T) => void | Promise<void>,
): Promise<T> => {
staleFolderId: FolderId | null = null,
): Promise<T | null> => {
let result: T;
try {
result = await serverOp();
} catch (err) {
if (errorStatus(err) === 404 && staleFolderId !== null) {
// `folders` is a closure snapshot; functional setFolders + the pull
// keep this race-safe even if a concurrent mutation shifted state.
handleStaleFolder(staleFolderId, folders);
return null;
}
if (mountedRef.current) {
setServerReachable(reachabilityFromError(err));
setError(formatServerError(err));
@@ -421,18 +489,13 @@ export function FolderProvider({ children }: FolderProviderProps) {
try {
await onSuccess(result);
} catch (cacheErr) {
// The server is authoritative - a cache write failure must not be
// surfaced as if the operation failed. Log + leave the in-memory
// state authoritative; next pullFromServer will re-seed the cache.
console.warn(
"[FolderContext] cache update failed after successful mutation",
cacheErr,
);
// Cache write failure is non-fatal; next pull re-seeds.
console.warn("[FolderContext] cache update after mutation", cacheErr);
}
bumpFolderRevision();
return result;
},
[bumpFolderRevision],
[bumpFolderRevision, folders, handleStaleFolder],
);
const createFolder = useCallback(
@@ -441,10 +504,9 @@ export function FolderProvider({ children }: FolderProviderProps) {
parentFolderId: FolderId | null = currentFolderId,
): Promise<FolderRecord> => {
const color = pickFolderColor(name);
// Generate id client-side so the server's idempotency check makes
// retries safe (network blip → second POST returns the same row).
// Client-side id makes server idempotency check safe on retry.
const id = createFolderId();
return runFolderMutation(
const result = await runFolderMutation(
() =>
folderSyncService.create({
id,
@@ -460,6 +522,11 @@ export function FolderProvider({ children }: FolderProviderProps) {
await folderStorage.upsertFolder(record);
},
);
// No staleFolderId passed → null branch can't fire; defensive throw.
if (result === null) {
throw new Error("createFolder unexpectedly returned null");
}
return result;
},
[currentFolderId, runFolderMutation],
);
@@ -474,6 +541,7 @@ export function FolderProvider({ children }: FolderProviderProps) {
);
await folderStorage.upsertFolder(record);
},
id,
);
},
[runFolderMutation],
@@ -493,6 +561,7 @@ export function FolderProvider({ children }: FolderProviderProps) {
);
await folderStorage.upsertFolder(record);
},
id,
);
},
[runFolderMutation],
@@ -515,13 +584,12 @@ export function FolderProvider({ children }: FolderProviderProps) {
);
await folderStorage.upsertFolder(record);
},
id,
);
},
[runFolderMutation],
);
const { clearFolderForFiles } = useIndexedDB();
const deleteFolder = useCallback(
async (id: FolderId): Promise<FolderId[]> => {
// Custom path (not runFolderMutation) because we have two best-effort
@@ -532,6 +600,11 @@ export function FolderProvider({ children }: FolderProviderProps) {
try {
removed = await folderSyncService.delete(id);
} catch (err) {
if (errorStatus(err) === 404) {
// Already gone; treat as success. Return [id]; pull is authoritative.
handleStaleFolder(id, folders);
return [id];
}
if (mountedRef.current) {
setServerReachable(reachabilityFromError(err));
setError(formatServerError(err));
@@ -580,7 +653,13 @@ export function FolderProvider({ children }: FolderProviderProps) {
}
return removed;
},
[bumpFolderRevision, clearFolderForFiles, currentFolderId, folders],
[
bumpFolderRevision,
clearFolderForFiles,
currentFolderId,
folders,
handleStaleFolder,
],
);
const value = useMemo<FolderContextValue>(
@@ -27,7 +27,7 @@ export function buildInputTracking(
inputFileIds.push(fileId);
inputStirlingFileStubs.push(record);
} else {
console.warn(`No file stub found for file: ${file.name}`);
console.debug(`No file stub found for file: ${file.name}`);
inputFileIds.push(fileId);
inputStirlingFileStubs.push(createNewStirlingFileStub(file, fileId));
}
@@ -15,3 +15,11 @@ export const useIsMobile = (): boolean => {
export const useIsPhone = (): boolean => {
return useMediaQuery("(max-width: 768px)") ?? false;
};
/**
* Custom hook to detect a coarse pointer (touch device)
* Use to gate touch-only UI hints when combined with useIsMobile
*/
export const useIsTouch = (): boolean => {
return useMediaQuery("(pointer: coarse)") ?? false;
};
@@ -29,9 +29,6 @@ export function useScarfTracking() {
// Listen to cookie consent changes and auto-fire pixel when consent is granted
useEffect(() => {
const handleConsentChange = () => {
console.warn(
"[useScarfTracking] Consent changed, checking scarf service acceptance",
);
if (isServiceAccepted("scarf", "analytics")) {
firePixel(window.location.pathname);
}
+10 -7
View File
@@ -5,7 +5,7 @@ import { Group } from "@mantine/core";
import { useSidebarContext } from "@app/contexts/SidebarContext";
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import { useBaseUrl } from "@app/hooks/useBaseUrl";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { useIsMobile, useIsTouch } from "@app/hooks/useIsMobile";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { LogoIcon } from "@app/components/shared/LogoIcon";
import { Wordmark } from "@app/components/shared/Wordmark";
@@ -84,6 +84,7 @@ export default function HomePage() {
const navigate = useNavigate();
const { config } = useAppConfig();
const isMobile = useIsMobile();
const isTouch = useIsTouch();
const sliderRef = useRef<HTMLDivElement | null>(null);
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
const isProgrammaticScroll = useRef(false);
@@ -352,12 +353,14 @@ export default function HomePage() {
{t("home.mobile.workspace", "Workspace")}
</button>
</div>
<span className="mobile-toggle-hint">
{t(
"home.mobile.swipeHint",
"Swipe left or right to switch views",
)}
</span>
{isTouch && (
<span className="mobile-toggle-hint">
{t(
"home.mobile.swipeHint",
"Swipe left or right to switch views",
)}
</span>
)}
</div>
)}
{navigationState.workbench === "myFiles" ? (
@@ -1,5 +1,6 @@
import { describe, expect, test, beforeEach } from "vitest";
import "fake-indexeddb/auto";
import { expectConsole } from "@app/tests/failOnConsole";
import {
DATABASE_CONFIGS,
@@ -363,6 +364,9 @@ describe("IndexedDB migration (FILES store)", () => {
});
test("SaaS v6 database is force-deleted (data lost, schema reset to v9)", async () => {
// The force-delete warn IS the contract under test; production fires it
// to surface why data was wiped.
expectConsole.warn(/Deleting corrupt SaaS v6/);
await seedSaasDatabase(6, ["v6-corrupt-file"]);
await indexedDBManager.openDatabase(DATABASE_CONFIGS.FILES);
@@ -381,6 +385,9 @@ describe("IndexedDB migration (FILES store)", () => {
});
test("SaaS v7 database is force-deleted (data lost, schema reset to v9)", async () => {
// The force-delete warn IS the contract under test; production fires it
// to surface why data was wiped.
expectConsole.warn(/Deleting corrupt SaaS v7/);
await seedSaasDatabase(7, ["v7-corrupt-file"]);
await indexedDBManager.openDatabase(DATABASE_CONFIGS.FILES);
@@ -2,22 +2,19 @@ import { BASE_PATH } from "@app/constants/app";
import pdfiumWasmAssetUrl from "@embedpdf/pdfium/pdfium.wasm?url";
const getWasmUrl = (): string => {
if (
pdfiumWasmAssetUrl.startsWith("http://") ||
pdfiumWasmAssetUrl.startsWith("https://") ||
pdfiumWasmAssetUrl.startsWith("//")
) {
return pdfiumWasmAssetUrl;
}
const origin = typeof window !== "undefined" ? window.location.origin : "";
// In dev, Vite serves the statically-copied asset from the dev server root.
if (import.meta.env.DEV) {
const origin = typeof window !== "undefined" ? window.location.origin : "";
return `${origin}${BASE_PATH}/pdfium/pdfium.wasm`;
}
const cleanAssetUrl = pdfiumWasmAssetUrl
.replace(/^\.\//, "")
.replace(/^\//, "");
return `${origin}${BASE_PATH}/${cleanAssetUrl}`;
// Vite has already produced a base-aware asset URL (absolute under a relative
// base, root-relative under an absolute base). Resolve it against the document
// to get a fetchable absolute URL that is also safe to pass to Web Workers.
if (typeof window !== "undefined") {
return new URL(pdfiumWasmAssetUrl, window.location.href).href;
}
return pdfiumWasmAssetUrl;
};
export const pdfiumWasmUrl = getWasmUrl();
+3
View File
@@ -1,5 +1,8 @@
import "@testing-library/jest-dom";
import { vi } from "vitest";
import { installFailOnConsole } from "@app/tests/failOnConsole";
installFailOnConsole();
// Mock localStorage for tests
class LocalStorageMock implements Storage {
@@ -29,6 +29,8 @@ import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import { I18nextProvider } from "react-i18next";
import i18n from "@app/i18n/config";
import { createTestStirlingFile } from "@app/tests/utils/testFileHelpers";
import { expectConsole } from "@app/tests/failOnConsole";
import { fileStorage } from "@app/services/fileStorage";
import { StirlingFile } from "@app/types/fileContext";
import { MantineProvider } from "@mantine/core";
@@ -78,6 +80,7 @@ vi.mock("../../services/fileStorage", () => ({
thumbnail: thumbnail,
});
}),
storeStirlingFile: vi.fn().mockResolvedValue(undefined),
getAllFileMetadata: vi.fn().mockResolvedValue([]),
cleanup: vi.fn().mockResolvedValue(undefined),
},
@@ -209,6 +212,10 @@ describe("Convert Tool Integration Tests", () => {
expect(result.current.downloadFilename).toBe("test.png");
expect(result.current.isLoading).toBe(false);
expect(result.current.errorMessage).toBe(null);
// The output file must be persisted via fileStorage.storeStirlingFile
// so downstream tools see it in the registry.
expect(fileStorage.storeStirlingFile).toHaveBeenCalled();
});
test("should handle API error responses correctly", async () => {
@@ -571,6 +578,9 @@ describe("Convert Tool Integration Tests", () => {
describe("File Upload Integration", () => {
test("should handle multiple file uploads correctly", async () => {
// Test mocks only one apiClient.post response; the second file hits an
// undefined response and production warns about the conversion failure.
expectConsole.warn(/Failed to convert file test2\.pdf/);
const mockBlob = new Blob(["zip-content"], { type: "application/zip" });
(mockedApiClient.post as Mock).mockResolvedValueOnce({ data: mockBlob });
@@ -28,6 +28,8 @@ import {
createTestStirlingFile,
createTestFilesWithId,
} from "@app/tests/utils/testFileHelpers";
import { allowConsole, expectConsole } from "@app/tests/failOnConsole";
import { fileStorage } from "@app/services/fileStorage";
import { MantineProvider } from "@mantine/core";
// Mock axios (for static methods like CancelToken, isCancel)
@@ -76,6 +78,7 @@ vi.mock("../../services/fileStorage", () => ({
thumbnail: thumbnail,
});
}),
storeStirlingFile: vi.fn().mockResolvedValue(undefined),
getAllFileMetadata: vi.fn().mockResolvedValue([]),
cleanup: vi.fn().mockResolvedValue(undefined),
},
@@ -169,6 +172,10 @@ describe("Convert Tool - Smart Detection Integration Tests", () => {
responseType: "blob",
},
);
// The output file must be persisted via fileStorage.storeStirlingFile
// so downstream tools see it in the registry.
expect(fileStorage.storeStirlingFile).toHaveBeenCalled();
});
test("should handle unknown file type with file-to-pdf fallback", async () => {
@@ -496,6 +503,12 @@ describe("Convert Tool - Smart Detection Integration Tests", () => {
});
test("should send correct PDF/A parameters for pdf-to-pdfa conversion", async () => {
// Test files aren't registered in the FileContext stub registry, so
// production warns about the stub/output mismatch - incidental to what
// this test asserts.
allowConsole.warn(
/\[useToolOperation\] Mismatch successInputStubs vs outputs/,
);
const { result: paramsResult } = renderHook(
() => useConvertParameters(),
{
@@ -638,6 +651,9 @@ describe("Convert Tool - Smart Detection Integration Tests", () => {
describe("Error Scenarios in Smart Detection", () => {
test("should handle partial failures in multi-file processing", async () => {
// Production warns when an individual file in a batch fails; the test
// deliberately drives one mock rejection to exercise that path.
expectConsole.warn(/Failed to convert file doc2\.xyz/);
const { result: paramsResult } = renderHook(
() => useConvertParameters(),
{
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import {
allowConsole,
expectConsole,
formatArgs,
matches,
} from "@app/tests/failOnConsole";
describe("failOnConsole", () => {
describe("matches", () => {
it("treats a string matcher as a substring test", () => {
expect(matches("foo", "the foo bar")).toBe(true);
expect(matches("foo", "no match here")).toBe(false);
});
it("treats a RegExp matcher as a .test() call", () => {
expect(matches(/^\[Tag\]/, "[Tag] something")).toBe(true);
expect(matches(/^\[Tag\]/, "prefix [Tag] something")).toBe(false);
});
it("string match is case-sensitive", () => {
expect(matches("Foo", "foo")).toBe(false);
});
});
describe("formatArgs", () => {
it("joins string args with spaces", () => {
expect(formatArgs(["one", "two", "three"])).toBe("one two three");
});
it("stringifies plain objects with JSON", () => {
expect(formatArgs([{ a: 1, b: "x" }])).toBe('{"a":1,"b":"x"}');
});
it("uses the stack for Error instances when present", () => {
const err = new Error("boom");
const result = formatArgs(["context:", err]);
expect(result).toContain("context:");
expect(result).toContain("boom");
});
it("falls back to String() when JSON.stringify throws (circular ref)", () => {
const circular: Record<string, unknown> = { name: "loop" };
circular.self = circular;
const out = formatArgs([circular]);
expect(out).toBe("[object Object]");
});
it("handles non-string, non-Error primitives", () => {
expect(formatArgs([42, true, null])).toBe("42 true null");
});
});
describe("expectConsole / allowConsole integration", () => {
it("expectConsole.error absorbs a matching call and satisfies the expectation", () => {
expectConsole.error(/expected sentinel/);
console.error("an expected sentinel was emitted");
// No assertion failure here means: the call was absorbed AND
// afterEach won't throw because the expectation matched.
});
it("allowConsole.warn absorbs a matching call without asserting it fires", () => {
allowConsole.warn(/optional sentinel/);
// Intentionally do NOT call console.warn. The test should still
// pass because allowConsole is non-required.
});
it("allowConsole.warn absorbs the call when it does fire too", () => {
allowConsole.warn(/optional sentinel that does fire/);
console.warn("optional sentinel that does fire now");
});
it("supports a string matcher (substring)", () => {
expectConsole.warn("substring sentinel inside");
console.warn("a substring sentinel inside a longer message");
});
});
});
@@ -0,0 +1,152 @@
import { afterEach, beforeEach, vi, type MockInstance } from "vitest";
type ConsoleMethod = "error" | "warn";
type Matcher = string | RegExp;
const WATCHED: ConsoleMethod[] = ["error", "warn"];
type Capture = { method: ConsoleMethod; args: unknown[] };
type Expectation = {
method: ConsoleMethod;
matcher: Matcher;
/**
* `true` for `expectConsole` (a contract: the test fails if no matching
* call fires); `false` for `allowConsole` (incidental noise: absorbed if
* it happens, ignored if it doesn't).
*/
required: boolean;
matched: boolean;
};
const captured: Capture[] = [];
const expectations: Expectation[] = [];
/**
* Vitest setup that fails any test whose code calls console.error or
* console.warn. The goal is to keep the browser console clean during normal
* usage of the app: React key warnings, act() warnings, deprecation
* notices, and runtime errors should all surface as test failures rather
* than silently scrolling past in CI.
*
* If a test legitimately needs to drive a log:
* - Use `expectConsole.error(/pattern/)` when the log is part of the
* contract under test. The matching call is absorbed AND the test
* fails if it never fires, so the log can't silently disappear later.
* - Use `allowConsole.warn(/pattern/)` when the log is incidental
* (third-party noise, an artefact of a fake JWT, etc.). The matching
* call is absorbed; nothing is asserted.
*
* Anything that doesn't match an expectation/allowance still fails the
* test as before.
*/
export function installFailOnConsole(): void {
const spies: MockInstance[] = [];
beforeEach(() => {
captured.length = 0;
expectations.length = 0;
spies.length = 0;
for (const method of WATCHED) {
const original = console[method].bind(console);
const spy = vi
.spyOn(console, method)
.mockImplementation((...args: unknown[]) => {
const text = formatArgs(args);
const match = expectations.find(
(e) => e.method === method && matches(e.matcher, text),
);
if (match) {
match.matched = true;
return;
}
captured.push({ method, args });
original(...args);
});
spies.push(spy);
}
});
afterEach(() => {
for (const spy of spies.splice(0)) {
spy.mockRestore();
}
const unmet = expectations.filter((e) => e.required && !e.matched);
const localCaptured = captured.splice(0);
expectations.length = 0;
if (unmet.length > 0) {
const lines = unmet
.map((e) => ` console.${e.method}: ${formatMatcher(e.matcher)}`)
.join("\n");
throw new Error(
`Expected console output never fired:\n${lines}\n\n` +
"If production stopped logging this, either restore the log or " +
"update/remove the expectConsole call.",
);
}
if (localCaptured.length === 0) return;
const lines = localCaptured
.map((c) => ` console.${c.method}: ${formatArgs(c.args)}`)
.join("\n");
throw new Error(
`Test produced unexpected console output:\n${lines}\n\n` +
"If the log is expected, capture it with expectConsole.error/warn " +
"(contract) or allowConsole.error/warn (incidental) from " +
"@app/tests/failOnConsole.",
);
});
}
/**
* Register a required console expectation. The test passes only if at
* least one matching call fires; matching calls are absorbed instead of
* failing the test.
*/
export const expectConsole = {
error: (matcher: Matcher) => register("error", matcher, true),
warn: (matcher: Matcher) => register("warn", matcher, true),
};
/**
* Register an optional console allowance. Matching calls are absorbed;
* the test does NOT fail if no matching call fires. Use for incidental
* noise (fake-JWT decode warnings, third-party library logs in dev, etc.).
*/
export const allowConsole = {
error: (matcher: Matcher) => register("error", matcher, false),
warn: (matcher: Matcher) => register("warn", matcher, false),
};
function register(method: ConsoleMethod, matcher: Matcher, required: boolean) {
expectations.push({ method, matcher, required, matched: false });
}
/** Exported for the failOnConsole unit tests; not part of the public API. */
export function matches(matcher: Matcher, text: string): boolean {
return typeof matcher === "string"
? text.includes(matcher)
: matcher.test(text);
}
function formatMatcher(matcher: Matcher): string {
return matcher instanceof RegExp
? matcher.toString()
: JSON.stringify(matcher);
}
/** Exported for the failOnConsole unit tests; not part of the public API. */
export function formatArgs(args: unknown[]): string {
return args.map(formatArg).join(" ");
}
function formatArg(value: unknown): string {
if (value instanceof Error) return value.stack ?? value.message;
if (typeof value === "string") return value;
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
@@ -0,0 +1,114 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import { errors, type ConsoleMessage, type Page } from "@playwright/test";
/**
* Smoke test: standard usage of the app must not produce any
* console.error, console.warn, or uncaught page errors.
*
* Each test disables the fixture's auto-goto (via `test.use({ autoGoto:
* false })`), attaches listeners inline with `attachListeners(page)` BEFORE
* navigating, walks a representative route, lets it settle, then asserts
* the captured buffer is empty.
*
* If you have a legitimate reason a warning fires on a given route
* (third-party library noise we cannot influence, etc.), filter it via the
* `IGNORED` allowlist below, but the default expectation is that the
* console stays clean. Do not add entries casually; prefer fixing the
* underlying issue.
*/
type ConsoleEntry = {
type: "error" | "warn" | "pageerror";
text: string;
location?: string;
};
const IGNORED: RegExp[] = [
// Add entries here only with a comment explaining why the warning is
// unavoidable. Default: keep this list empty.
];
function shouldIgnore(text: string): boolean {
return IGNORED.some((re) => re.test(text));
}
function attachListeners(page: Page): ConsoleEntry[] {
const entries: ConsoleEntry[] = [];
page.on("console", (msg: ConsoleMessage) => {
const type = msg.type();
if (type !== "error" && type !== "warning") return;
const text = msg.text();
if (shouldIgnore(text)) return;
const loc = msg.location();
entries.push({
type: type === "warning" ? "warn" : "error",
text,
location: loc.url
? `${loc.url}:${loc.lineNumber}:${loc.columnNumber}`
: undefined,
});
});
page.on("pageerror", (err) => {
if (shouldIgnore(err.message)) return;
entries.push({ type: "pageerror", text: err.stack ?? err.message });
});
return entries;
}
function formatEntries(entries: ConsoleEntry[]): string {
return entries
.map(
(e) =>
` [${e.type}] ${e.text}${e.location ? `\n at ${e.location}` : ""}`,
)
.join("\n");
}
async function expectCleanConsole(entries: ConsoleEntry[]) {
expect(
entries,
`Page produced unexpected console output:\n${formatEntries(entries)}`,
).toEqual([]);
}
// ─── Routes to sweep ────────────────────────────────────────────────────────
//
// One entry per route we want to guarantee is console-clean on load. Mirrors
// the most common user entry points; expand cautiously - every entry adds CI
// time and triage surface for new warnings.
const ROUTES: { name: string; path: string }[] = [
{ name: "landing", path: "/" },
{ name: "files", path: "/files" },
{ name: "compress", path: "/compress" },
{ name: "split", path: "/split" },
{ name: "merge", path: "/merge" },
{ name: "convert", path: "/convert" },
{ name: "rotate", path: "/rotate" },
{ name: "addPageNumbers", path: "/add-page-numbers" },
];
// Disable the fixture's auto-goto so we can attach listeners before any
// navigation happens. Otherwise listeners miss early load-time noise.
test.use({ autoGoto: false });
test.describe("Console hygiene: representative routes load cleanly", () => {
for (const route of ROUTES) {
test(`${route.name} (${route.path})`, async ({ page }) => {
const entries = attachListeners(page);
await page.goto(route.path, { waitUntil: "domcontentloaded" });
// Give async effects (i18n load, lazy chunks, posthog init) a beat to
// surface anything they were going to log.
await page
.waitForLoadState("networkidle", { timeout: 10_000 })
.catch((err) => {
// networkidle can flake on third-party CDNs; treat ONLY the
// timeout as benign and still run the console assertion on what
// we captured. Anything else (frame detached, navigation abort,
// etc.) is a real problem and should fail the test.
if (!(err instanceof errors.TimeoutError)) throw err;
});
await expectCleanConsole(entries);
});
}
});
@@ -486,12 +486,13 @@ test.describe("Files page screenshots", () => {
const card = page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: "alpha.pdf" });
await card.getByRole("button", { name: /File actions/i }).click();
await page.getByRole("menuitem", { name: /Move to/i }).click();
await page.getByRole("button", { name: /Create new folder/i }).click();
await expect(
page.getByRole("textbox", { name: /New folder name/i }),
).toBeVisible();
// Locate by stable test ids, not translated accessible names: this test
// runs in Arabic (enableRtl), so English-text locators break once the
// ar-AR strings are actually translated.
await card.getByTestId("file-card-actions").click();
await page.getByTestId("file-menu-move-to").click();
await page.getByTestId("move-dialog-create-folder-toggle").click();
await expect(page.getByTestId("move-dialog-new-folder-name")).toBeVisible();
await settle(page);
await page.screenshot({
path: shotPath("17_rtl_move_dialog_create_folder"),
@@ -121,6 +121,81 @@ test.describe("Settings dialog", () => {
await restored.click();
});
test("intra-modal tab switching updates URL via replaceState, never pushState", async ({
page,
}) => {
// Mechanism test for the "background flash" fix. Before the fix, every
// tab click called `navigate(...)` which fired React Router's location
// subscribers - HomePage, QuickAccessBar, FileManagerView, ... - and the
// layer behind the Mantine overlay repainted, causing backdrop-filter
// blur to recompute and visibly flash. After the fix, only the very
// first nav into /settings/* is allowed to go through React Router (so
// HomePage's location-watching effect opens the modal and the back
// button has a real history entry to pop). Every subsequent tab click
// updates the URL bar via raw `window.history.replaceState`, which
// React Router does NOT subscribe to. We assert this directly by
// counting calls.
await page.addInitScript(() => {
const w = window as unknown as {
__historyOps: { push: number; replace: number };
};
w.__historyOps = { push: 0, replace: 0 };
const origPush = window.history.pushState.bind(window.history);
const origReplace = window.history.replaceState.bind(window.history);
window.history.pushState = function (...args) {
w.__historyOps.push++;
return origPush(
...(args as Parameters<typeof window.history.pushState>),
);
};
window.history.replaceState = function (...args) {
w.__historyOps.replace++;
return origReplace(
...(args as Parameters<typeof window.history.replaceState>),
);
};
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await openSettings(page);
const generalNav = page.locator('[data-tour="admin-general-nav"]').first();
const hotkeysNav = page.locator('[data-tour="admin-hotkeys-nav"]').first();
await expect(generalNav).toBeVisible({ timeout: 5_000 });
// First nav into /settings/* takes the React Router path (push). We
// snapshot both counters AFTER this to isolate the intra-modal delta.
await generalNav.click();
await page.waitForURL(/\/settings\/general/, { timeout: 5_000 });
const baseline = await page.evaluate(() => {
const w = window as unknown as {
__historyOps: { push: number; replace: number };
};
return { ...w.__historyOps };
});
// Now do 4 round-trips between two tabs - 8 intra-modal clicks total.
for (let i = 0; i < 4; i++) {
await hotkeysNav.click();
await page.waitForURL(/\/settings\/hotkeys/, { timeout: 5_000 });
await generalNav.click();
await page.waitForURL(/\/settings\/general/, { timeout: 5_000 });
}
const after = await page.evaluate(() => {
const w = window as unknown as {
__historyOps: { push: number; replace: number };
};
return { ...w.__historyOps };
});
// Zero pushes during 8 intra-modal clicks - the regression would show
// up here as `after.push - baseline.push >= 1`.
expect(after.push - baseline.push).toBe(0);
// Exactly 8 replaces - one per click.
expect(after.replace - baseline.replace).toBe(8);
});
test("close returns to origin URL even after switching tabs (no history pile-up)", async ({
page,
}) => {
@@ -0,0 +1,81 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
/**
* Verifies that the Stripe SDK (@stripe/stripe-js, @stripe/react-stripe-js,
* and the js.stripe.com remote script) is NOT fetched on cold page loads —
* only when the checkout modal actually mounts. The proprietary
* CheckoutProvider, the SaaS TrialStatusBanner, and the SaaS Plan settings
* page all gate the modal behind React.lazy + a conditional render so the
* Stripe chunk lives in its own async bundle.
*/
const STRIPE_URL_FRAGMENTS = [
"js.stripe.com",
"@stripe/stripe-js",
// Vite's optimizeDeps id mangles slashes to underscores when serving
// pre-bundled dependencies from node_modules/.vite/deps.
"@stripe_stripe-js",
"@stripe/react-stripe-js",
"@stripe_react-stripe-js",
];
function isStripeRequest(url: string): boolean {
return STRIPE_URL_FRAGMENTS.some((fragment) => url.includes(fragment));
}
test.describe("Stripe SDK lazy loading", () => {
test("landing page does not fetch Stripe SDK on cold load", async ({
page,
}) => {
const stripeRequests: string[] = [];
page.on("request", (req) => {
const url = req.url();
if (isStripeRequest(url)) {
stripeRequests.push(url);
}
});
// The stub fixture already navigated to "/" with waitUntil:
// domcontentloaded. Wait for the app to actually settle so any lurking
// module-eval-time loadStripe() call would have already kicked off.
await page
.waitForLoadState("networkidle", { timeout: 15_000 })
.catch(() => {
// Posthog / iconify keep some connections warm — fall back to a
// brief settle window if networkidle never resolves.
});
await page.waitForTimeout(2_000);
expect(
stripeRequests,
`Expected no Stripe-related network activity on landing, but observed:\n${stripeRequests.join("\n")}`,
).toEqual([]);
});
test("settings modal does not fetch Stripe SDK", async ({ page }) => {
const stripeRequests: string[] = [];
page.on("request", (req) => {
const url = req.url();
if (isStripeRequest(url)) {
stripeRequests.push(url);
}
});
// Opening Settings is the closest a default proprietary user gets to
// checkout without actually clicking Upgrade. Even rendering the
// settings drawer must NOT pull Stripe into the entry path — only the
// upgrade modal itself, which sits one click further in.
const settingsButton = page
.getByRole("button", { name: /settings/i })
.first();
if (await settingsButton.isVisible({ timeout: 5_000 }).catch(() => false)) {
await settingsButton.click();
await page.waitForTimeout(1_500);
}
expect(
stripeRequests,
`Expected no Stripe-related network activity after opening settings, but observed:\n${stripeRequests.join("\n")}`,
).toEqual([]);
});
});
@@ -3,6 +3,7 @@
*/
import { describe, test, expect } from "vitest";
import { expectConsole } from "@app/tests/failOnConsole";
import {
convertToAutomationConfig,
convertToFolderScanningConfig,
@@ -111,6 +112,7 @@ describe("automationConverter", () => {
});
test("falls back to operation key when no endpoint is registered", () => {
expectConsole.warn(/No endpoint found for operation "unknownTool"/);
const automation: AutomationConfig = {
...sampleAutomation,
operations: [{ operation: "unknownTool", parameters: {} }],
@@ -6,8 +6,9 @@
* injected via setScarfConfig() which should be called from a React hook
* during app initialization.
*
* IMPORTANT: setScarfConfig() must be called before firePixel() will work.
* The initialization hook (useScarfTracking) is mounted in App.tsx.
* firePixel() can be called BEFORE setScarfConfig() runs: pre-config calls
* are queued and replayed once setScarfConfig fires, so the initial-page-load
* pixel from useUrlSync doesn't race the useScarfTracking init effect.
*
* For testing: Use resetScarfConfig() to clear module state between tests.
*/
@@ -19,6 +20,11 @@ let isServiceAccepted: ((service: string, category: string) => boolean) | null =
null;
let lastFiredPathname: string | null = null;
let lastFiredTime = 0;
// Pathnames passed to firePixel() before setScarfConfig() has run. Drained
// once configured. Bounded to the most recent few entries since intermediate
// path changes during a slow init are uninteresting.
const pendingPaths: string[] = [];
const PENDING_PATHS_CAP = 8;
/**
* Configure scarf tracking with app config and consent checker
@@ -34,6 +40,10 @@ export function setScarfConfig(
configured = true;
enableScarf = scarfEnabled;
isServiceAccepted = consentChecker;
// Drain anything queued before we were configured. Splice first so a
// re-entrant firePixel from inside the drain can't re-queue.
const queued = pendingPaths.splice(0);
for (const path of queued) firePixel(path);
}
/**
@@ -47,12 +57,10 @@ export function setScarfConfig(
* @param pathname - The pathname to track (usually window.location.pathname)
*/
export function firePixel(pathname: string): void {
// Dev-mode warning if called before initialization
// Pre-init: queue and bail. setScarfConfig() drains.
if (!configured) {
console.warn(
"[scarfTracking] firePixel() called before setScarfConfig(). " +
"Ensure useScarfTracking() hook is mounted in App.tsx.",
);
if (pendingPaths.length >= PENDING_PATHS_CAP) pendingPaths.shift();
pendingPaths.push(pathname);
return;
}
@@ -91,8 +99,10 @@ export function firePixel(pathname: string): void {
* Useful for testing to ensure clean state between test runs
*/
export function resetScarfConfig(): void {
configured = false;
enableScarf = null;
isServiceAccepted = null;
lastFiredPathname = null;
lastFiredTime = 0;
pendingPaths.length = 0;
}
+4 -22
View File
@@ -4,34 +4,16 @@ import { TFunction } from "i18next";
export const getSynonyms = (t: TFunction, toolId: string): string[] => {
try {
const candidateKeys = [`home.${toolId}.tags`, `${toolId}.tags`];
const match = candidateKeys
const tags = candidateKeys
.map((key) => ({ key, value: t(key) as unknown as string }))
.find(({ key, value }) => value && value !== key);
const tags = match?.value;
const usedKey = match?.key;
.find(({ key, value }) => value && value !== key)?.value;
if (!tags) {
console.warn(`[Tags] Missing tags for tool: ${toolId}`);
return [];
}
if (!tags) return [];
// Split by comma and clean up the tags
const cleanedTags = tags
return tags
.split(",")
.map((tag: string) => tag.trim())
.filter((tag: string) => tag.length > 0);
// Log the tags found for this tool
if (cleanedTags.length > 0) {
console.info(
`[Tags] Tool "${toolId}" (${usedKey}) has ${cleanedTags.length} tags:`,
cleanedTags,
);
} else {
console.warn(`[Tags] Tool "${toolId}" has empty tags value`);
}
return cleanedTags;
} catch (error) {
console.error(
`[Tags] Failed to get translated synonyms for tool ${toolId}:`,