Modal resize + size persistence - Extract `useModalResizePersist` hook (ResizeObserver + localStorage), apply to Files, Git Manager, GitHub Import, Workflow Steps, Automations (ScheduledTasks), and Settings modals. Each gets `resize: both`, sane min/max constraints, and a unique storage key. - Bump default heights so the modals feel less cramped (Git: 92vh, Workflow/Automation: 80vh, Settings: 80vh / 1100px). Scrollbar theme - Add a global `*::-webkit-scrollbar*` + `scrollbar-color` rule in styles.css so chat, document, system stats, file browser, usage indicator, etc. inherit the theme. Existing per-component overrides (.board, .column-body, .settings-sidebar, planning modal) still win. Document view - Collapse "Show hidden" toggle and search input onto the same row as the Project Files / Task Documents segmented control. Stack again below 768px. Mailbox / Todos - Match Todos header treatment to the Mailbox header (typography, padding, border). - Add top spacing above Mailbox Inbox/Outbox/Agents tab bar so the vertical gaps balance. Settings - Wider, resizable, persisted Settings modal. - Project Models description and Authentication panel get proper horizontal padding. - Reorder project sidebar so "General" is first. - Plugins page: clean margins, integrate refresh button, exclude bundled runtimes from the "Installed Plugins" list (they were appearing twice — once erroring, once in their own section). - New "Updates" panel with auto-check toggle + "Check now" button (frequency control noted as needing a backend schema field). Background sessions - Fix stale "AI N" / planning-icon badge: `handleDeleted` in `useBackgroundSessions` now writes a tombstone, advances the timestamp guard, and broadcasts completion so the cross-tab sync store stops resurrecting the deleted session on the next merge tick. Added regression test. - Re-fetch list on SSE reconnect so terminal events fired during a network blip don't get permanently lost. System stats - Refresh button uses correct single class (was getting both `btn` and `btn-icon`, which conflicted on padding/border). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
622 lines
19 KiB
TypeScript
622 lines
19 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { FileBrowserModal } from "../FileBrowserModal";
|
|
import * as workspaceBrowserHook from "../../hooks/useWorkspaceFileBrowser";
|
|
import * as workspaceEditorHook from "../../hooks/useWorkspaceFileEditor";
|
|
import * as workspacesHook from "../../hooks/useWorkspaces";
|
|
|
|
vi.mock("../../hooks/useWorkspaceFileBrowser");
|
|
vi.mock("../../hooks/useWorkspaceFileEditor");
|
|
vi.mock("../../hooks/useWorkspaces");
|
|
|
|
const mockUseWorkspaceFileBrowser = vi.mocked(workspaceBrowserHook.useWorkspaceFileBrowser);
|
|
const mockUseWorkspaceFileEditor = vi.mocked(workspaceEditorHook.useWorkspaceFileEditor);
|
|
const mockUseWorkspaces = vi.mocked(workspacesHook.useWorkspaces);
|
|
|
|
describe("FileBrowserModal", () => {
|
|
const mockOnClose = vi.fn();
|
|
const mockOnWorkspaceChange = vi.fn();
|
|
const mockSave = vi.fn().mockResolvedValue(undefined);
|
|
const mockSetContent = vi.fn();
|
|
const mockSetPath = vi.fn();
|
|
const mockRefresh = vi.fn();
|
|
|
|
const defaultBrowserState = {
|
|
entries: [
|
|
{ name: "file1.ts", type: "file" as const, size: 1024, mtime: "2024-01-01" },
|
|
{ name: "folder1", type: "directory" as const, mtime: "2024-01-01" },
|
|
],
|
|
currentPath: ".",
|
|
setPath: mockSetPath,
|
|
loading: false,
|
|
error: null,
|
|
refresh: mockRefresh,
|
|
};
|
|
|
|
const defaultEditorState = {
|
|
content: "console.log('hello');",
|
|
setContent: mockSetContent,
|
|
originalContent: "console.log('hello');",
|
|
loading: false,
|
|
saving: false,
|
|
error: null,
|
|
save: mockSave,
|
|
hasChanges: false,
|
|
mtime: "2024-01-01",
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
|
|
mockUseWorkspaceFileBrowser.mockReturnValue(defaultBrowserState);
|
|
mockUseWorkspaceFileEditor.mockReturnValue(defaultEditorState);
|
|
mockUseWorkspaces.mockReturnValue({
|
|
projectName: "kb",
|
|
workspaces: [
|
|
{ id: "FN-001", label: "FN-001", title: "Task One", worktree: "/repo/.worktrees/kb-001", kind: "task" },
|
|
{ id: "FN-002", label: "FN-002", title: "Task Two", worktree: "/repo/.worktrees/kb-002", kind: "task" },
|
|
],
|
|
loading: false,
|
|
error: null,
|
|
});
|
|
|
|
Object.defineProperty(window, "innerWidth", {
|
|
writable: true,
|
|
configurable: true,
|
|
value: 1024,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("renders project-root modal title and workspace selector", () => {
|
|
render(
|
|
<FileBrowserModal
|
|
initialWorkspace="project"
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onWorkspaceChange={mockOnWorkspaceChange}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("Files — Project")).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: /kb/i })).toBeInTheDocument();
|
|
expect(mockUseWorkspaceFileBrowser).toHaveBeenCalledWith("project", true, undefined);
|
|
});
|
|
|
|
it("opens a file in the editor when selected", async () => {
|
|
render(
|
|
<FileBrowserModal
|
|
initialWorkspace="project"
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("file1.ts"));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText("Editor for file1.ts")).toBeInTheDocument();
|
|
});
|
|
|
|
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "file1.ts", true, undefined);
|
|
});
|
|
|
|
it("switches workspace and notifies parent", async () => {
|
|
const user = userEvent.setup();
|
|
render(
|
|
<FileBrowserModal
|
|
initialWorkspace="project"
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onWorkspaceChange={mockOnWorkspaceChange}
|
|
/>,
|
|
);
|
|
|
|
await user.click(screen.getByRole("button", { name: /kb/i }));
|
|
await user.click(screen.getByRole("button", { name: /FN-002 Task Two/i }));
|
|
|
|
expect(mockOnWorkspaceChange).toHaveBeenCalledWith("FN-002");
|
|
});
|
|
|
|
it("shows back button in mobile editor view", async () => {
|
|
Object.defineProperty(window, "innerWidth", {
|
|
writable: true,
|
|
configurable: true,
|
|
value: 375,
|
|
});
|
|
|
|
render(
|
|
<FileBrowserModal
|
|
initialWorkspace="project"
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
/>,
|
|
);
|
|
|
|
fireEvent(window, new Event("resize"));
|
|
fireEvent.click(screen.getByText("file1.ts"));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText("Back to file list")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("keeps mobile close button visible and clickable", async () => {
|
|
Object.defineProperty(window, "innerWidth", {
|
|
writable: true,
|
|
configurable: true,
|
|
value: 375,
|
|
});
|
|
|
|
const { container } = render(
|
|
<FileBrowserModal
|
|
initialWorkspace="project"
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
/>,
|
|
);
|
|
|
|
fireEvent(window, new Event("resize"));
|
|
|
|
const closeButton = container.querySelector("button.modal-close");
|
|
expect(closeButton).toBeInTheDocument();
|
|
expect(closeButton).toBeVisible();
|
|
|
|
fireEvent.click(closeButton!);
|
|
await waitFor(() => {
|
|
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
it("close button is visible on mobile after selecting a file with a long path", async () => {
|
|
Object.defineProperty(window, "innerWidth", {
|
|
writable: true,
|
|
configurable: true,
|
|
value: 375,
|
|
});
|
|
|
|
// Provide a file with a long path name
|
|
const longFileName = "packages/dashboard/app/components/SomeVeryLongComponentName.tsx";
|
|
mockUseWorkspaceFileBrowser.mockReturnValue({
|
|
...defaultBrowserState,
|
|
entries: [
|
|
{ name: longFileName, type: "file" as const, size: 2048, mtime: "2024-01-01" },
|
|
],
|
|
});
|
|
|
|
const { container } = render(
|
|
<FileBrowserModal
|
|
initialWorkspace="project"
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
/>,
|
|
);
|
|
|
|
fireEvent(window, new Event("resize"));
|
|
|
|
// Select the long-named file
|
|
fireEvent.click(screen.getByText(longFileName));
|
|
|
|
// Verify the file path appears in the header
|
|
await waitFor(() => {
|
|
const pathEl = container.querySelector(".file-browser-header-path");
|
|
expect(pathEl).toBeInTheDocument();
|
|
expect(pathEl?.textContent).toBe(longFileName);
|
|
});
|
|
|
|
const closeButton = container.querySelector("button.modal-close");
|
|
expect(closeButton).toBeInTheDocument();
|
|
expect(closeButton).toBeVisible();
|
|
|
|
// Clicking the close button should trigger onClose
|
|
fireEvent.click(closeButton!);
|
|
await waitFor(() => {
|
|
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
it("long file path is truncated on mobile", async () => {
|
|
// Read CSS file directly to verify the overflow/ellipsis rules
|
|
// (JSDOM doesn't apply stylesheets, so computed style checks won't work)
|
|
const { loadAllAppCss } = await import("../../test/cssFixture");
|
|
const cssContent = loadAllAppCss();
|
|
|
|
// Extract mobile media query blocks
|
|
function extractMobileMediaBlocks(content: string): string {
|
|
const blocks: string[] = [];
|
|
const regex = /@media\s*\(\s*max-width:\s*768px\s*\)\s*\{/g;
|
|
let match;
|
|
|
|
while ((match = regex.exec(content)) !== null) {
|
|
const startIdx = match.index + match[0].length;
|
|
let braceCount = 1;
|
|
let endIdx = startIdx;
|
|
|
|
while (braceCount > 0 && endIdx < content.length) {
|
|
if (content[endIdx] === "{") braceCount += 1;
|
|
if (content[endIdx] === "}") braceCount -= 1;
|
|
endIdx += 1;
|
|
}
|
|
|
|
if (braceCount === 0) {
|
|
blocks.push(content.slice(startIdx, endIdx - 1));
|
|
}
|
|
}
|
|
|
|
return blocks.join("\n");
|
|
}
|
|
|
|
const mobileBlock = extractMobileMediaBlocks(cssContent);
|
|
|
|
// Find the file-browser-header-path rule within mobile blocks
|
|
const pathMatch = mobileBlock.match(
|
|
/\.file-browser-header-path\s*\{([^}]*)\}/,
|
|
);
|
|
expect(pathMatch).not.toBeNull();
|
|
|
|
const pathRules = pathMatch![1];
|
|
expect(pathRules).toContain("text-overflow: ellipsis");
|
|
expect(pathRules).toContain("white-space: nowrap");
|
|
expect(pathRules).toContain("overflow: hidden");
|
|
expect(pathRules).toContain("max-width: 50vw");
|
|
});
|
|
|
|
it("closes on Escape and saves on Cmd+S", () => {
|
|
mockUseWorkspaceFileEditor.mockReturnValue({
|
|
...defaultEditorState,
|
|
hasChanges: true,
|
|
});
|
|
|
|
render(
|
|
<FileBrowserModal
|
|
initialWorkspace="project"
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.keyDown(document, { key: "Escape" });
|
|
fireEvent.keyDown(document, { key: "s", metaKey: true });
|
|
|
|
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
|
expect(mockSave).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("renders hidden files and directories from the file listing", () => {
|
|
mockUseWorkspaceFileBrowser.mockReturnValue({
|
|
...defaultBrowserState,
|
|
entries: [
|
|
{ name: ".env.example", type: "file", size: 42, mtime: "2024-01-01" },
|
|
{ name: ".github", type: "directory", mtime: "2024-01-01" },
|
|
{ name: "src", type: "directory", mtime: "2024-01-01" },
|
|
],
|
|
});
|
|
|
|
render(
|
|
<FileBrowserModal
|
|
initialWorkspace="project"
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText(".env.example")).toBeInTheDocument();
|
|
expect(screen.getByText(".github")).toBeInTheDocument();
|
|
expect(screen.getByText("src")).toBeInTheDocument();
|
|
});
|
|
|
|
describe("image file preview", () => {
|
|
it("renders image preview for .png files instead of editor", async () => {
|
|
mockUseWorkspaceFileBrowser.mockReturnValue({
|
|
...defaultBrowserState,
|
|
entries: [
|
|
{ name: "screenshot.png", type: "file" as const, size: 102400, mtime: "2024-01-01" },
|
|
],
|
|
});
|
|
|
|
render(
|
|
<FileBrowserModal
|
|
initialWorkspace="project"
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
/>,
|
|
);
|
|
|
|
// Select the image file
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByText("screenshot.png"));
|
|
});
|
|
|
|
// Should render an image preview
|
|
const imagePreview = screen.getByRole("img", { name: "screenshot.png" });
|
|
expect(imagePreview).toBeInTheDocument();
|
|
expect(imagePreview).toHaveAttribute("src", expect.stringContaining("screenshot.png"));
|
|
|
|
// Should NOT render the text editor
|
|
expect(screen.queryByLabelText(/Editor for screenshot.png/)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("renders image preview for .jpg files instead of editor", async () => {
|
|
mockUseWorkspaceFileBrowser.mockReturnValue({
|
|
...defaultBrowserState,
|
|
entries: [
|
|
{ name: "photo.jpg", type: "file" as const, size: 204800, mtime: "2024-01-01" },
|
|
],
|
|
});
|
|
|
|
render(
|
|
<FileBrowserModal
|
|
initialWorkspace="project"
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
/>,
|
|
);
|
|
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByText("photo.jpg"));
|
|
});
|
|
|
|
const imagePreview = screen.getByRole("img", { name: "photo.jpg" });
|
|
expect(imagePreview).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders image preview for .gif files instead of editor", async () => {
|
|
mockUseWorkspaceFileBrowser.mockReturnValue({
|
|
...defaultBrowserState,
|
|
entries: [
|
|
{ name: "animation.gif", type: "file" as const, size: 51200, mtime: "2024-01-01" },
|
|
],
|
|
});
|
|
|
|
render(
|
|
<FileBrowserModal
|
|
initialWorkspace="project"
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
/>,
|
|
);
|
|
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByText("animation.gif"));
|
|
});
|
|
|
|
const imagePreview = screen.getByRole("img", { name: "animation.gif" });
|
|
expect(imagePreview).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders image preview for .webp files instead of editor", async () => {
|
|
mockUseWorkspaceFileBrowser.mockReturnValue({
|
|
...defaultBrowserState,
|
|
entries: [
|
|
{ name: "image.webp", type: "file" as const, size: 76800, mtime: "2024-01-01" },
|
|
],
|
|
});
|
|
|
|
render(
|
|
<FileBrowserModal
|
|
initialWorkspace="project"
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
/>,
|
|
);
|
|
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByText("image.webp"));
|
|
});
|
|
|
|
const imagePreview = screen.getByRole("img", { name: "image.webp" });
|
|
expect(imagePreview).toBeInTheDocument();
|
|
});
|
|
|
|
it("hides save/discard actions for image files", async () => {
|
|
mockUseWorkspaceFileBrowser.mockReturnValue({
|
|
...defaultBrowserState,
|
|
entries: [
|
|
{ name: "test.png", type: "file" as const, size: 1024, mtime: "2024-01-01" },
|
|
],
|
|
});
|
|
|
|
// Mock editor state with changes
|
|
mockUseWorkspaceFileEditor.mockReturnValue({
|
|
...defaultEditorState,
|
|
hasChanges: true,
|
|
});
|
|
|
|
render(
|
|
<FileBrowserModal
|
|
initialWorkspace="project"
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
/>,
|
|
);
|
|
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByText("test.png"));
|
|
});
|
|
|
|
// Should NOT show Discard or Save buttons for images
|
|
expect(screen.queryByRole("button", { name: /Discard/ })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: /Save/ })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("still shows save/discard actions for text files with changes", async () => {
|
|
// Mock editor state with changes
|
|
mockUseWorkspaceFileEditor.mockReturnValue({
|
|
...defaultEditorState,
|
|
hasChanges: true,
|
|
});
|
|
|
|
render(
|
|
<FileBrowserModal
|
|
initialWorkspace="project"
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
/>,
|
|
);
|
|
|
|
// Select a text file
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByText("file1.ts"));
|
|
});
|
|
|
|
// Should show Discard and Save buttons
|
|
expect(screen.getByRole("button", { name: /Discard/ })).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: /Save/ })).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders file editor for non-image binary files like .pdf", async () => {
|
|
mockUseWorkspaceFileBrowser.mockReturnValue({
|
|
...defaultBrowserState,
|
|
entries: [
|
|
{ name: "document.pdf", type: "file" as const, size: 1024000, mtime: "2024-01-01" },
|
|
],
|
|
});
|
|
|
|
render(
|
|
<FileBrowserModal
|
|
initialWorkspace="project"
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
/>,
|
|
);
|
|
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByText("document.pdf"));
|
|
});
|
|
|
|
// Should show binary indicator
|
|
expect(screen.getByText(/Binary file — read only/)).toBeInTheDocument();
|
|
|
|
// Should NOT render an image preview
|
|
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("image preview uses workspace-safe URL pattern", async () => {
|
|
mockUseWorkspaceFileBrowser.mockReturnValue({
|
|
...defaultBrowserState,
|
|
entries: [
|
|
{ name: "test.png", type: "file" as const, size: 1024, mtime: "2024-01-01" },
|
|
],
|
|
});
|
|
|
|
render(
|
|
<FileBrowserModal
|
|
initialWorkspace="FN-001"
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
/>,
|
|
);
|
|
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByText("test.png"));
|
|
});
|
|
|
|
const imagePreview = screen.getByRole("img", { name: "test.png" });
|
|
// URL should include workspace parameter
|
|
expect(imagePreview).toHaveAttribute(
|
|
"src",
|
|
expect.stringContaining("workspace=FN-001")
|
|
);
|
|
expect(imagePreview).toHaveAttribute(
|
|
"src",
|
|
expect.stringContaining("test.png")
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("modal height constraint regression", () => {
|
|
it("max-height uses calc() to stay within viewport padding", async () => {
|
|
const { loadAllAppCss } = await import("../../test/cssFixture");
|
|
const css = loadAllAppCss();
|
|
|
|
// Extract the first .file-browser-modal block (desktop base styles)
|
|
// Match from ".file-browser-modal {" to its closing "}"
|
|
const blockMatch = css.match(
|
|
/\.file-browser-modal\s*\{[^}]*max-height:\s*([^;]+);/,
|
|
);
|
|
expect(blockMatch).toBeTruthy();
|
|
const maxHeightValue = blockMatch![1].trim();
|
|
|
|
// The max-height must use calc() with the overlay-padding-top variable
|
|
// so the modal fits within the visible viewport. We accept either
|
|
// 100vh or 100dvh (the latter accounts for mobile dynamic viewport
|
|
// chrome and is preferred for the resize-aware modals).
|
|
expect(maxHeightValue).toContain("calc(");
|
|
expect(maxHeightValue).toContain("--overlay-padding-top");
|
|
expect(maxHeightValue).toMatch(/100d?vh/);
|
|
});
|
|
|
|
it("height and max-height together do not exceed viewport on desktop", async () => {
|
|
const { loadAllAppCss } = await import("../../test/cssFixture");
|
|
const css = loadAllAppCss();
|
|
|
|
const blockMatch = css.match(
|
|
/\.file-browser-modal\s*\{([^}]*)\}/,
|
|
);
|
|
expect(blockMatch).toBeTruthy();
|
|
const block = blockMatch![1];
|
|
|
|
// Extract height value
|
|
const heightMatch = block.match(/height:\s*([^;]+);/);
|
|
expect(heightMatch).toBeTruthy();
|
|
const heightValue = heightMatch![1].trim();
|
|
|
|
// height should be a reasonable vh value (≤ 85vh for desktop)
|
|
const heightNum = parseFloat(heightValue);
|
|
expect(heightNum).toBeGreaterThan(0);
|
|
expect(heightNum).toBeLessThanOrEqual(85);
|
|
|
|
// max-height must be present and use calc()
|
|
const maxHeightMatch = block.match(/max-height:\s*([^;]+);/);
|
|
expect(maxHeightMatch).toBeTruthy();
|
|
expect(maxHeightMatch![1].trim()).toContain("calc(");
|
|
});
|
|
|
|
it("mobile styles use 100dvh for full-screen behavior", async () => {
|
|
const { loadAllAppCss } = await import("../../test/cssFixture");
|
|
const css = loadAllAppCss();
|
|
|
|
// Extract mobile media query blocks (similar to existing pattern)
|
|
function extractMobileMediaBlocks(content: string): string {
|
|
const blocks: string[] = [];
|
|
const regex = /@media\s*\(\s*max-width:\s*768px\s*\)\s*\{/g;
|
|
let match;
|
|
|
|
while ((match = regex.exec(content)) !== null) {
|
|
const startIdx = match.index + match[0].length;
|
|
let braceCount = 1;
|
|
let endIdx = startIdx;
|
|
|
|
while (braceCount > 0 && endIdx < content.length) {
|
|
if (content[endIdx] === "{") braceCount += 1;
|
|
if (content[endIdx] === "}") braceCount -= 1;
|
|
endIdx += 1;
|
|
}
|
|
|
|
if (braceCount === 0) {
|
|
blocks.push(content.slice(startIdx, endIdx - 1));
|
|
}
|
|
}
|
|
|
|
return blocks.join("\n");
|
|
}
|
|
|
|
const mobileBlock = extractMobileMediaBlocks(css);
|
|
|
|
// Find the file-browser-modal rule within mobile blocks
|
|
const modalMatch = mobileBlock.match(
|
|
/\.file-browser-modal\s*\{([^}]*)\}/,
|
|
);
|
|
expect(modalMatch).not.toBeNull();
|
|
|
|
const modalRules = modalMatch![1];
|
|
// Mobile should use 100dvh for height/max-height
|
|
expect(modalRules).toContain("100dvh");
|
|
});
|
|
});
|
|
});
|