Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/contexts/MarkdownEditorContext.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useState, ReactNode } from "react";
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disabling react-refresh/only-export-components for the entire file is quite broad and can hide future violations. Prefer scoping the suppression to the specific non-component export(s) (e.g., a single eslint-disable-next-line on the hook/type export), or move the exported types/hooks into a separate module so the context/provider module only exports components.

Copilot uses AI. Check for mistakes.

export interface MarkdownEditorCommands {
Expand Down
42 changes: 23 additions & 19 deletions src/tests/components/SettingsModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,33 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import SettingsModal from '../../components/SettingsModal';

// Mock the store - use inline functions to avoid hoisting issues
interface StoreState {
isSettingsOpen: boolean;
setSettingsOpen: () => void;
showLineNumbers: boolean;
setShowLineNumbers: () => void;
textColor: string;
backgroundColor: string;
toggleDarkMode: () => void;
}
Comment on lines +5 to +13
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The StoreState type used for the mocked store doesn't match the real store API: setSettingsOpen and setShowLineNumbers are declared as () => void, but the actual store signatures accept a boolean argument. Updating these to (value: boolean) => void will keep the mock aligned with production types and prevents the test from silently accepting incorrect selector usage.

Copilot uses AI. Check for mistakes.

// Update the mock
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment on line 15 reads // Update the mock, which is a process-oriented note (describing an action taken during development) rather than explanatory documentation. It doesn't convey why the mock is structured this way. The original comment // Mock the store - use inline functions to avoid hoisting issues was more informative. This should be updated to describe the intent of the mock.

Suggested change
// Update the mock
// Mock the Zustand store selector with a fixed settings state so SettingsModal can be tested in isolation

Copilot uses AI. Check for mistakes.
vi.mock('../../store/store', () => {
return {
default: vi.fn((selector) => selector({
isSettingsOpen: true,
setSettingsOpen: vi.fn(),
showLineNumbers: true,
setShowLineNumbers: vi.fn(),
textColor: '#121212',
backgroundColor: '#ffffff',
toggleDarkMode: vi.fn(),
})),
default: vi.fn((selector: (state: StoreState) => unknown) =>
selector({
isSettingsOpen: true,
setSettingsOpen: vi.fn(),
showLineNumbers: true,
setShowLineNumbers: vi.fn(),
textColor: '#121212',
backgroundColor: '#ffffff',
toggleDarkMode: vi.fn(),
})
),
};
});

// Mock react-dark-mode-toggle
vi.mock('react-dark-mode-toggle', () => ({
default: ({ checked, onChange }: { checked: boolean; onChange: () => void }) => (
<button
Expand All @@ -37,49 +48,42 @@ describe('SettingsModal', () => {

it('renders the modal with title when open', () => {
render(<SettingsModal />);

expect(screen.getByText('Settings')).toBeInTheDocument();
});

it('renders the Dark Mode setting', () => {
render(<SettingsModal />);

expect(screen.getByText('Dark Mode')).toBeInTheDocument();
expect(screen.getByText('Toggle between light and dark theme')).toBeInTheDocument();
});

it('renders the Show Line Numbers setting', () => {
render(<SettingsModal />);

expect(screen.getByText('Show Line Numbers')).toBeInTheDocument();
expect(screen.getByText('Display line numbers in code editors')).toBeInTheDocument();
});

it('renders the dark mode toggle button', () => {
render(<SettingsModal />);

const toggle = screen.getByTestId('dark-mode-toggle');
expect(toggle).toBeInTheDocument();
});

it('renders the line numbers toggle switch', () => {
render(<SettingsModal />);

const toggle = screen.getByRole('switch', { name: /toggle line numbers/i });
expect(toggle).toBeInTheDocument();
});

it('line numbers toggle is checked when showLineNumbers is true', () => {
render(<SettingsModal />);

const toggle = screen.getByRole('switch', { name: /toggle line numbers/i });
expect(toggle).toBeChecked();
});

it('renders divider between settings', () => {
render(<SettingsModal />);

const divider = document.querySelector('hr');
expect(divider).toBeInTheDocument();
});
});
});
51 changes: 25 additions & 26 deletions src/utils/helpers/errorUtils.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,33 @@
// Helper function to extract meaningful error message from complex error objects
interface NestedError {
error?: {
message?: string;
};
}

interface CommonErrorStructure {
error?: string | {
message?: string;
};
detail?: string;
message?: string;
}

export const extractErrorMessage = (error: Error | unknown): string => {
if (!error) return 'An unknown error occurred';

let errorMessage = error instanceof Error ? error.message : String(error);
const errorMessage = error instanceof Error ? error.message : String(error);

// Try to extract JSON from the message (might be prefixed with error type/code)
let jsonMatch = errorMessage.match(/\{.*\}/s);
const jsonMatch = errorMessage.match(/\{.*\}/s);
if (jsonMatch) {
try {
const parsed = JSON.parse(jsonMatch[0]);
const parsed = JSON.parse(jsonMatch[0]) as CommonErrorStructure;

// Handle Google error structure: {"error":{"message":"..."}}
if (parsed.error) {
if (typeof parsed.error === 'object') {
// Handle nested error with message
if (parsed.error.message) {
if (typeof parsed.error.message === 'string') {
try {
const inner = JSON.parse(parsed.error.message);
const inner = JSON.parse(parsed.error.message) as NestedError;
if (inner?.error?.message) {
return inner.error.message;
}
Expand All @@ -26,63 +36,52 @@ export const extractErrorMessage = (error: Error | unknown): string => {
}
}
}
// Handle error as string
if (typeof parsed.error === 'string') {
return parsed.error;
}
} else if (typeof parsed.error === 'string') {
return parsed.error;
}
}

// Handle Mistral error structure: {"detail":"..."}
if (parsed.detail) {
return parsed.detail;
}

// Handle direct error message
if (parsed.message) {
return parsed.message;
}
} catch {
// JSON parsing failed, continue
// Ignore
}
}

// Try to parse the entire message as JSON (for cleanly formatted JSON errors)
try {
const parsed = JSON.parse(errorMessage);
const parsed = JSON.parse(errorMessage) as CommonErrorStructure;

// Handle nested error structures
if (parsed.error) {
if (typeof parsed.error === 'object') {
// Handle Google nested structure
if (parsed.error.message) {
// Check if the inner message is also JSON
try {
const innerParsed = JSON.parse(parsed.error.message);
const innerParsed = JSON.parse(parsed.error.message) as NestedError;
if (innerParsed.error && innerParsed.error.message) {
return innerParsed.error.message;
}
} catch {
return parsed.error.message;
}
}
}
if (typeof parsed.error === 'string') {
} else if (typeof parsed.error === 'string') {
return parsed.error;
}
}

// Handle Mistral error structure: {"detail":"..."}
if (parsed.detail) {
return parsed.detail;
}

// Handle direct error message
if (parsed.message) {
return parsed.message;
}
} catch {
// Not JSON, return as is
// Ignore
}

return errorMessage;
Expand Down
143 changes: 83 additions & 60 deletions src/utils/testing/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,9 @@ afterEach(() => {
cleanup();
});

// Mock getComputedStyle for Ant Design components that use scroll locking
// jsdom doesn't fully support getComputedStyle with pseudo-elements
// rc-util's getScrollBarSize calls .match() on style properties
const originalGetComputedStyle = window.getComputedStyle;
window.getComputedStyle = (elt: Element, pseudoElt?: string | null) => {
if (pseudoElt) {
// Return a mock CSSStyleDeclaration with string properties for .match() calls
return {
width: '0px',
height: '0px',
Expand All @@ -30,62 +26,89 @@ Object.defineProperty(window, "matchMedia", {
matches: false,
media: query,
onchange: null,
addListener: () => {
// Mock implementation for tests
},
removeListener: () => {
// Mock implementation for tests
},
addEventListener: () => {
// Mock implementation for tests
},
removeEventListener: () => {
// Mock implementation for tests
},
dispatchEvent: () => {
return false;
},
addListener: () => { /* mock */ },
removeListener: () => { /* mock */ },
addEventListener: () => { /* mock */ },
removeEventListener: () => { /* mock */ },
dispatchEvent: () => { return false; },
}),
});

// Mock HTMLCanvasElement.getContext for lottie-web library
// jsdom doesn't implement canvas 2D context
// @ts-expect-error: Mock implementation has simplified types
HTMLCanvasElement.prototype.getContext = ((originalGetContext) => {
return function (
this: HTMLCanvasElement,
contextId: string,
options?: unknown
) {
if (contextId === '2d') {
return {
fillStyle: '',
fillRect: () => {},
clearRect: () => {},
getImageData: () => ({ data: [] }),
putImageData: () => {},
createImageData: () => ({ data: [] }),
setTransform: () => {},
drawImage: () => {},
save: () => {},
restore: () => {},
beginPath: () => {},
moveTo: () => {},
lineTo: () => {},
closePath: () => {},
stroke: () => {},
fill: () => {},
translate: () => {},
scale: () => {},
rotate: () => {},
arc: () => {},
measureText: () => ({ width: 0 }),
transform: () => {},
rect: () => {},
clip: () => {},
canvas: this,
} as unknown as CanvasRenderingContext2D;
}
return originalGetContext.call(this, contextId as any, options);
};
})(HTMLCanvasElement.prototype.getContext);
interface MockCanvasContext {
fillStyle: string;
fillRect: () => void;
clearRect: () => void;
getImageData: () => { data: number[] };
putImageData: () => void;
createImageData: () => { data: number[] };
setTransform: () => void;
drawImage: () => void;
save: () => void;
restore: () => void;
beginPath: () => void;
moveTo: () => void;
lineTo: () => void;
closePath: () => void;
stroke: () => void;
fill: () => void;
translate: () => void;
scale: () => void;
rotate: () => void;
arc: () => void;
measureText: () => { width: number };
transform: () => void;
rect: () => void;
clip: () => void;
canvas: HTMLCanvasElement;
}

// capture original method so we can delegate for non-2d calls
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comments explaining the purpose of important code blocks (e.g., "Mock getComputedStyle for Ant Design components...", "Mock HTMLCanvasElement.getContext for lottie-web library...", "Return a mock CSSStyleDeclaration with string properties for .match() calls") were removed from setup.ts. These comments were valuable documentation explaining why the mocks are needed and what specific libraries/behaviors they address. Similarly, explanatory comments describing the various error structures handled in errorUtils.ts (e.g., "Handle Google error structure: ...", "Handle Mistral error structure: ...") were removed. These comments made the code self-documenting and easier to maintain.

Copilot uses AI. Check for mistakes.
// use a loose any type to avoid lib.dom union conflicts
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const originalGetContext: any = HTMLCanvasElement.prototype.getContext.bind(
HTMLCanvasElement.prototype
);
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

originalGetContext is bound to HTMLCanvasElement.prototype, so later calling originalGetContext.call(this, ...) cannot rebind this. This means delegating to the original implementation for non-"2d" context IDs will run with the wrong this (the prototype), which can break callers expecting the native behavior. Preserve the original unbound method and invoke it with .call(this, ...) (or wrap it in a helper with an explicit this: HTMLCanvasElement type) so delegation uses the actual canvas instance; this also lets you avoid the any + broad no-unsafe-* disables.

Copilot uses AI. Check for mistakes.

HTMLCanvasElement.prototype.getContext = function (
this: HTMLCanvasElement,
contextId: string,
options?: unknown
) {
if (contextId === '2d') {
const mockContext: MockCanvasContext = {
fillStyle: '',
fillRect: () => { /* mock */ },
clearRect: () => { /* mock */ },
getImageData: () => ({ data: [] }),
putImageData: () => { /* mock */ },
createImageData: () => ({ data: [] }),
setTransform: () => { /* mock */ },
drawImage: () => { /* mock */ },
save: () => { /* mock */ },
restore: () => { /* mock */ },
beginPath: () => { /* mock */ },
moveTo: () => { /* mock */ },
lineTo: () => { /* mock */ },
closePath: () => { /* mock */ },
stroke: () => { /* mock */ },
fill: () => { /* mock */ },
translate: () => { /* mock */ },
scale: () => { /* mock */ },
rotate: () => { /* mock */ },
arc: () => { /* mock */ },
measureText: () => ({ width: 0 }),
transform: () => { /* mock */ },
rect: () => { /* mock */ },
clip: () => { /* mock */ },
canvas: this,
};
// cast through unknown since the mock only implements a subset of methods
return mockContext as unknown as CanvasRenderingContext2D;
}

// for other context types, delegate to original
// originalGetContext is typed as `any` above, so suppress safety checks
/* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
return originalGetContext.call(this, contextId, options);
/* eslint-enable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
};