diff --git a/README.md b/README.md index 6f370aa..1c28f79 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,17 @@ pnpm dev The dev server pipes hot reloads into the extension. Load the unpacked folder shown in the terminal (usually `dist`) via Chrome's Extensions page while Developer Mode is enabled. +### Testing + +| Command | Description | +| --- | --- | +| `pnpm test` | Run the unit and component suite with Vitest. | +| `pnpm test:watch` | Start Vitest in watch mode with the interactive UI. | +| `pnpm test:coverage` | Generate coverage reports with V8 (HTML + LCOV in `coverage/`). | +| `pnpm test:e2e` | Execute the Playwright end-to-end flow against a mocked ChatGPT page. | + +The Vitest environment uses `happy-dom` alongside Testing Library helpers and a mocked `chrome` namespace so background, content script, and UI logic can share fixtures. End-to-end tests require Chromium with extension support; Playwright automatically builds and packages the extension via `pnpm zip` before launching the browser. + ### Options & Settings - Open `options.html` during development (served at `http://localhost:5173/options.html`) to manage the whitelist or disable auto rendering. @@ -30,7 +41,11 @@ The dev server pipes hot reloads into the extension. Load the unpacked folder sh pnpm run build ``` -The production bundle lands in `dist/`. Package it manually or use `pnpm run zip` for a distributable archive. +The production bundle lands in `build/`. Package it manually or use `pnpm run zip` for a distributable archive located in `package/`. + +## Releases + +Release Please manages versioning and release notes. Check the autogenerated root `CHANGELOG.md` or the GitHub Releases tab for the latest history. --- diff --git a/docs/README.md b/docs/README.md index c2258d5..c4f718b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,7 +23,8 @@ Additional scripts live in `package.json`, including `pnpm build` for production - [Extension Flow](./extension-flow.md) - [UI Guidelines](./ui-guidelines.md) - [Testing Guide](./testing.md) -- [Changelog](./changelog.md) + +Release notes are generated automatically via release-please and published with GitHub Releases; there is no manual changelog in this folder. ## Doc Conventions diff --git a/docs/architecture.md b/docs/architecture.md index 3cb43b4..858a2a5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,6 +2,8 @@ CoderChart follows the typical MV3 separation: a background service worker maintains defaults, the content script renders diagrams in-page, and the options UI manages configuration. Shared utilities in `src/shared` allow all surfaces to agree on settings and URL matching. +For a user-centric walkthrough of the lifecycle, see [`extension-flow.md`](./extension-flow.md). + ## Runtime Components - **Background (`src/background/index.ts`)** @@ -25,7 +27,7 @@ CoderChart follows the typical MV3 separation: a background service worker maint ```mermaid flowchart TD - A[Load content script] --> B[getSettings()] + A[Load content script] --> B[Fetch settings] B --> C{autoRender enabled?} C -- No --> D[Stay idle] C -- Yes --> E{URL matches whitelist?} @@ -55,5 +57,6 @@ flowchart TD ## Known Constraints -- Builds occasionally report Rollup chunk-size warnings; adjust splitting before release if bundle size grows. -- Rendering currently assumes Mermaid syntax; invalid diagrams surface an inline error but do not retry automatically. +- Clearing all host patterns in options is not persisted: `normalizeSettings` restores the default ChatGPT domains, so disable `autoRender` to pause rendering globally. +- PNG export relies on drawing the generated SVG into a canvas; diagrams that pull in external assets or exceed canvas limits can fail to export and log a warning. +- Mermaid parse failures surface inline with a copyable fix prompt, but there is still no automatic retry or self-healing. diff --git a/docs/changelog.md b/docs/changelog.md deleted file mode 100644 index 46fe6cd..0000000 --- a/docs/changelog.md +++ /dev/null @@ -1,11 +0,0 @@ -# Changelog - -## 2025-09-26 – Initial Mermaid Rendering Milestone - -- Content script renders Mermaid diagrams inline with themed containers and toolbar controls. -- Toolbar enables show/hide, scroll-to-code, and SVG/PNG downloads. -- Options page manages auto-render toggle and host whitelist synced via `chrome.storage.sync`. -- Background script seeds default settings (auto-render true, ChatGPT host patterns) on install. -- Known follow-up: monitor Vite build warnings about chunk sizes before publishing. - -_Use ISO dates and reverse chronological order for future entries._ diff --git a/docs/extension-flow.md b/docs/extension-flow.md index e02adc8..ddd5417 100644 --- a/docs/extension-flow.md +++ b/docs/extension-flow.md @@ -1,17 +1,17 @@ # Extension Flow -This guide walks through the end-to-end experience from installation to exporting rendered diagrams. +This guide outlines the user-visible lifecycle. For component responsibilities and deeper mechanics, see [`architecture.md`](./architecture.md). -## Narrative Flow +## Lifecycle Overview -1. **Install:** User loads the unpacked build or installs from the Chrome Web Store. The background script seeds default settings in `chrome.storage.sync`. -2. **Grant permissions:** Chrome applies host permissions declared in `manifest.ts` (ChatGPT domains and localhost during development). -3. **Open supported page:** When the user opens chatgpt.com or another whitelisted host, the content script loads, retrieves settings, and checks the URL against the whitelist. -4. **Render diagrams:** Matching Mermaid code fences trigger the inline renderer. Mermaid initialises with theme selection, generates SVG, and injects a container with toolbar controls. -5. **Adjust settings:** Users can visit `options.html` to disable auto-rendering, edit host patterns, or reset to defaults. Changes sync across devices. -6. **Use toolbar:** Inline buttons allow collapsing/expanding the diagram, scrolling back to the source code block, and exporting as SVG or PNG. -7. **Download pipeline:** SVG downloads reuse the cached render, while PNG downloads convert the SVG via an off-screen canvas before prompting a file save. -8. **Keep updated:** A MutationObserver re-runs rendering when the chat adds new content, and storage listeners reconfigure the script when settings change. +| Step | Trigger | Primary owner | Notes | +| --- | --- | --- | --- | +| Install or upgrade | Extension installed from source or Web Store | Background service worker | Seeds defaults and handles migrations (`architecture.md` → Runtime Components). | +| Host access granted | Chrome applies `manifest.ts` host permissions | Chrome / MV3 platform | Controls where the content script can run (`architecture.md` → Runtime Components). | +| Page activation | User opens a whitelisted URL | Content script | Fetches settings, checks URL against patterns, and, if allowed, initialises Mermaid (`architecture.md` → Data Flow Sequence). | +| Inline rendering | Mermaid blocks detected | Content script + Mermaid runtime | Injects managed containers, themes Mermaid, and caches SVG output (`architecture.md` → Mermaid Rendering & Downloads). | +| Settings changes | Options UI edits or resets settings | Options UI + shared settings utils | Saves updates through `saveSettings`; listeners reconcile state across surfaces (`architecture.md` → Settings Synchronisation). | +| Continuous updates | Chat adds new content or settings sync changes arrive | Content script | MutationObserver re-runs detection; storage listeners reapply activation rules (`architecture.md` → Settings Synchronisation). | ## Sequence Diagram @@ -21,7 +21,8 @@ sequenceDiagram participant C as Chrome participant B as Background participant CS as Content Script - participant Opt as Options UI + participant Options as Options UI + participant Toolbar as Download Toolbar U->>C: Install extension C->>B: fire onInstalled @@ -40,8 +41,8 @@ sequenceDiagram U->>Toolbar: Click download SVG/PNG Toolbar->>CS: fetch cached SVG / convert to PNG CS->>U: Trigger file download - U->>Opt: Open options page - Opt->>chrome.storage.sync: save updated settings + U->>Options: Open options page + Options->>chrome.storage.sync: save updated settings chrome.storage.sync-->>CS: onChanged event CS->>CS: Apply settings (activate/deactivate/re-render) ``` diff --git a/package.json b/package.json index 9816411..ba32c71 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,11 @@ "build": "tsc && vite build", "preview": "vite preview", "fmt": "prettier --write '**/*.{tsx,ts,json,css,scss,md}'", - "zip": "npm run build && node src/zip.js" + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test", + "zip": "pnpm run build && node src/zip.js" }, "dependencies": { "react": "^18.2.0", @@ -39,6 +43,17 @@ "gulp-zip": "^6.0.0", "prettier": "^3.0.3", "typescript": "^5.2.2", - "vite": "^5.4.10" + "vite": "^5.4.10", + "@playwright/test": "^1.48.0", + "@testing-library/dom": "^9.3.4", + "@testing-library/jest-dom": "^6.4.5", + "@testing-library/react": "^14.2.1", + "@testing-library/react-hooks": "^8.0.1", + "@testing-library/user-event": "^14.5.2", + "@vitest/ui": "^1.6.0", + "happy-dom": "^13.8.3", + "playwright": "^1.48.0", + "vitest": "^1.6.0", + "vitest-fetch-mock": "^0.4.1" } } diff --git a/src/background/__tests__/index.test.ts b/src/background/__tests__/index.test.ts new file mode 100644 index 0000000..6af0ced --- /dev/null +++ b/src/background/__tests__/index.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DEFAULT_SETTINGS } from '../../shared/settings' +import { chromeMock, emitRuntimeInstalled, setChromeStorageSync } from 'test/mocks/chrome' + +const SETTINGS_KEY = 'settings' + +describe('background script', () => { + beforeEach(() => { + vi.resetModules() + }) + + it('initialises default settings on fresh install', async () => { + setChromeStorageSync({}) + + await import('../index') + + await emitRuntimeInstalled({ reason: 'install' }) + + expect(chromeMock.storage.sync.set).toHaveBeenCalledWith({ [SETTINGS_KEY]: DEFAULT_SETTINGS }) + }) + + it('normalises existing settings when present', async () => { + setChromeStorageSync({ + [SETTINGS_KEY]: { + autoRender: 'nope', + hostPatterns: [' https://chatgpt.com/* ', ''], + }, + }) + + await import('../index') + + await emitRuntimeInstalled({ reason: 'update', previousVersion: '0.9.0' }) + + const payload = chromeMock.storage.sync.set.mock.calls.at(-1)?.[0] as Record + expect(payload?.[SETTINGS_KEY]).toEqual({ + autoRender: true, + hostPatterns: ['https://chatgpt.com/*'], + }) + }) +}) diff --git a/src/contentScript/__tests__/index.test.ts b/src/contentScript/__tests__/index.test.ts new file mode 100644 index 0000000..59a0ae9 --- /dev/null +++ b/src/contentScript/__tests__/index.test.ts @@ -0,0 +1,42 @@ +import { waitFor } from '@testing-library/dom' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { setChromeStorageSync } from 'test/mocks/chrome' + +const mermaidInitialize = vi.fn() +const mermaidRender = vi.fn().mockResolvedValue({ + svg: '', +}) + +vi.mock('mermaid', () => ({ + default: { + initialize: mermaidInitialize, + render: mermaidRender, + }, +})) + +describe('content script', () => { + beforeEach(() => { + vi.resetModules() + mermaidInitialize.mockClear() + mermaidRender.mockClear() + document.body.innerHTML = '
graph TD; A-->B;
' + setChromeStorageSync({ + settings: { + autoRender: true, + hostPatterns: ['https://chatgpt.com/*'], + }, + }) + window.history.replaceState({}, '', 'https://chatgpt.com/conversation') + }) + + it('renders detected mermaid blocks when active', async () => { + await import('../index') + + await waitFor(() => { + expect(document.querySelector('svg')).toBeInTheDocument() + }) + + expect(mermaidInitialize).toHaveBeenCalled() + expect(mermaidRender).toHaveBeenCalledWith(expect.stringMatching(/^coderchart-/), expect.stringContaining('graph TD')) + }) +}) diff --git a/src/contentScript/index.ts b/src/contentScript/index.ts index 5026b30..6f16930 100644 --- a/src/contentScript/index.ts +++ b/src/contentScript/index.ts @@ -8,6 +8,7 @@ import { doesUrlMatchPatterns } from '../shared/url' const BLOCK_DATA_STATUS = 'coderchartStatus' const BLOCK_DATA_SOURCE = 'coderchartSource' const PNG_PREPARING_LABEL = 'Preparing PNG…' +const PROMPT_RESET_DELAY_MS = 2000 let diagramCounter = 0 let blockIdentifierCounter = 0 let observer: MutationObserver | null = null @@ -381,7 +382,11 @@ function ensureContainer(pre: HTMLElement): BlockRegistryEntry { container.append(body) - pre.insertAdjacentElement('afterend', container) + if (typeof pre.insertAdjacentElement === 'function') { + pre.insertAdjacentElement('afterend', container) + } else if (pre.parentNode) { + pre.parentNode.insertBefore(container, pre.nextSibling) + } const entry: BlockRegistryEntry = { id: blockId, @@ -480,6 +485,9 @@ function isDarkMode(): boolean { if (document.documentElement.classList.contains('dark')) { return true } + if (typeof window.matchMedia !== 'function') { + return false + } return window.matchMedia('(prefers-color-scheme: dark)').matches } @@ -737,9 +745,10 @@ function createErrorNotice(doc: Document, err: unknown, source: string): HTMLEle } setTimeout(() => { + if (!promptButton.isConnected) return promptButton.disabled = false promptButton.textContent = defaultLabel - }, 2000) + }, PROMPT_RESET_DELAY_MS) }) promptSection.append(promptButton, promptStatus, promptPreview) diff --git a/src/options/Options.tsx b/src/options/Options.tsx index 0fba4b7..06a7a2d 100644 --- a/src/options/Options.tsx +++ b/src/options/Options.tsx @@ -1,4 +1,4 @@ -import { FormEvent, useEffect, useMemo, useState } from 'react' +import { FormEvent, useEffect, useMemo, useRef, useState } from 'react' import { DEFAULT_SETTINGS, ExtensionSettings, getSettings, saveSettings, normalizeSettings } from '../shared/settings' import './Options.css' @@ -6,16 +6,18 @@ import './Options.css' type SaveStatus = 'idle' | 'saving' | 'saved' | 'error' const defaultState = cloneSettings(DEFAULT_SETTINGS) +const SAVE_STATUS_RESET_DELAY_MS = 2400 export const Options = () => { const [settings, setSettings] = useState(defaultState) const [newPattern, setNewPattern] = useState('') const [status, setStatus] = useState('idle') const [errorMessage, setErrorMessage] = useState(null) + const resetStatusTimeout = useRef(null) useEffect(() => { let mounted = true - getSettings() + getSettings({ throwOnError: true }) .then((loaded) => { if (!mounted) return setSettings(cloneSettings(loaded)) @@ -23,6 +25,7 @@ export const Options = () => { .catch((error) => { console.warn('Failed to load settings', error) if (mounted) { + setSettings(cloneSettings(DEFAULT_SETTINGS)) setErrorMessage('Unable to load settings. Using defaults.') } }) @@ -31,6 +34,14 @@ export const Options = () => { } }, []) + useEffect(() => { + return () => { + if (resetStatusTimeout.current !== null) { + window.clearTimeout(resetStatusTimeout.current) + } + } + }, []) + const normalizedPatterns = useMemo(() => settings.hostPatterns.map((pattern) => pattern.trim()), [settings.hostPatterns]) const canAddPattern = useMemo(() => { @@ -74,6 +85,10 @@ export const Options = () => { const handleSubmit = async (event: FormEvent) => { event.preventDefault() + if (resetStatusTimeout.current !== null) { + window.clearTimeout(resetStatusTimeout.current) + resetStatusTimeout.current = null + } setStatus('saving') setErrorMessage(null) try { @@ -81,9 +96,10 @@ export const Options = () => { await saveSettings(normalized) setSettings(cloneSettings(normalized)) setStatus('saved') - setTimeout(() => { + resetStatusTimeout.current = window.setTimeout(() => { setStatus('idle') - }, 2400) + resetStatusTimeout.current = null + }, SAVE_STATUS_RESET_DELAY_MS) } catch (error) { console.error('Failed to save settings', error) setStatus('error') diff --git a/src/options/__tests__/Options.test.tsx b/src/options/__tests__/Options.test.tsx new file mode 100644 index 0000000..2a63114 --- /dev/null +++ b/src/options/__tests__/Options.test.tsx @@ -0,0 +1,70 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { Options } from '../Options' +import { setChromeStorageSync, chromeMock } from 'test/mocks/chrome' + +const SETTINGS_KEY = 'settings' + +describe('Options page', () => { + it('loads stored settings and allows saving changes', async () => { + setChromeStorageSync({ + [SETTINGS_KEY]: { + autoRender: false, + hostPatterns: ['https://example.com/*'], + }, + }) + + const user = userEvent.setup() + + render() + + const toggle = await screen.findByRole('checkbox', { name: /auto-render mermaid diagrams/i }) + expect(toggle).not.toBeChecked() + + await user.click(toggle) + expect(toggle).toBeChecked() + + const patternInputs = screen.getAllByPlaceholderText('https://example.com/*') + const newPatternInput = patternInputs.at(-1) + expect(newPatternInput).toBeDefined() + await user.clear(newPatternInput as HTMLInputElement) + await user.type(newPatternInput as HTMLInputElement, 'https://docs.example.com/*') + + const addButton = screen.getByRole('button', { name: /add pattern/i }) + await user.click(addButton) + + const saveButton = screen.getByRole('button', { name: /save changes/i }) + await user.click(saveButton) + + await waitFor(() => { + expect(chromeMock.storage.sync.set).toHaveBeenCalled() + }) + + const payload = chromeMock.storage.sync.set.mock.calls.at(-1)?.[0] as Record + expect(payload?.[SETTINGS_KEY]).toMatchObject({ + autoRender: true, + hostPatterns: ['https://example.com/*', 'https://docs.example.com/*'], + }) + }) + + it('shows an error message when loading settings fails', async () => { + // Mock console methods to suppress expected error logs during this test + const consoleMocks = { + warn: vi.spyOn(console, 'warn').mockImplementation(() => {}), + error: vi.spyOn(console, 'error').mockImplementation(() => {}) + } + + chromeMock.storage.sync.get.mockRejectedValueOnce(new Error('nope')) + + render() + + await waitFor(() => { + expect(screen.getByText(/unable to load settings/i)).toBeInTheDocument() + }) + + // Restore console methods + consoleMocks.warn.mockRestore() + consoleMocks.error.mockRestore() + }) +}) diff --git a/src/shared/__tests__/settings.test.ts b/src/shared/__tests__/settings.test.ts new file mode 100644 index 0000000..9c00112 --- /dev/null +++ b/src/shared/__tests__/settings.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { DEFAULT_SETTINGS, normalizeSettings } from '../settings' + +describe('normalizeSettings', () => { + it('falls back to defaults when data is missing', () => { + expect(normalizeSettings(undefined)).toEqual(DEFAULT_SETTINGS) + expect(normalizeSettings(null)).toEqual(DEFAULT_SETTINGS) + }) + + it('strips invalid patterns and preserves valid configuration', () => { + const result = normalizeSettings({ + autoRender: false, + hostPatterns: [' https://valid.com/* ', '', 123 as unknown as string], + }) + + expect(result.autoRender).toBe(false) + expect(result.hostPatterns).toEqual(['https://valid.com/*']) + }) + + it('reverts to defaults when pattern list becomes empty', () => { + const result = normalizeSettings({ + autoRender: true, + hostPatterns: [], + }) + + expect(result).toEqual(DEFAULT_SETTINGS) + }) +}) diff --git a/src/shared/__tests__/url.test.ts b/src/shared/__tests__/url.test.ts new file mode 100644 index 0000000..0902b1d --- /dev/null +++ b/src/shared/__tests__/url.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { doesUrlMatchPatterns, patternToRegExp } from '../url' + +describe('pattern utilities', () => { + it('converts patterns with wildcards into regular expressions', () => { + const regex = patternToRegExp('https://example.com/*') + expect(regex.test('https://example.com/path')).toBe(true) + expect(regex.test('https://example.com/another/segment')).toBe(true) + expect(regex.test('https://other.com/')).toBe(false) + }) + + it('matches URLs against provided patterns', () => { + const patterns = ['https://chatgpt.com/*', 'https://chat.openai.com/*'] + expect(doesUrlMatchPatterns('https://chatgpt.com/c/foo', patterns)).toBe(true) + expect(doesUrlMatchPatterns('https://example.com', patterns)).toBe(false) + }) +}) diff --git a/src/shared/settings.ts b/src/shared/settings.ts index 2ade848..2ee1390 100644 --- a/src/shared/settings.ts +++ b/src/shared/settings.ts @@ -10,12 +10,19 @@ export const DEFAULT_SETTINGS: ExtensionSettings = { hostPatterns: ['https://chatgpt.com/*', 'https://chat.openai.com/*'], } -export async function getSettings(): Promise { +type GetSettingsOptions = { + throwOnError?: boolean +} + +export async function getSettings(options: GetSettingsOptions = {}): Promise { try { const stored = await chrome.storage.sync.get(STORAGE_KEY) return normalizeSettings(stored[STORAGE_KEY]) } catch (err) { console.warn('Failed to load settings, falling back to defaults', err) + if (options.throwOnError) { + throw err + } return DEFAULT_SETTINGS } } @@ -33,7 +40,10 @@ export function normalizeSettings(input: unknown): ExtensionSettings { const record = input as Partial const autoRender = typeof record.autoRender === 'boolean' ? record.autoRender : DEFAULT_SETTINGS.autoRender const hostPatterns = Array.isArray(record.hostPatterns) - ? record.hostPatterns.filter((value): value is string => typeof value === 'string' && Boolean(value.trim())) + ? record.hostPatterns + .filter((value): value is string => typeof value === 'string') + .map((value) => value.trim()) + .filter(Boolean) : DEFAULT_SETTINGS.hostPatterns return { diff --git a/test/mocks/chrome.ts b/test/mocks/chrome.ts new file mode 100644 index 0000000..50452bc --- /dev/null +++ b/test/mocks/chrome.ts @@ -0,0 +1,192 @@ +import { vi } from 'vitest' + +type Listener any> = T + +type ChromeEvent any> = { + addListener: (listener: Listener) => void + removeListener: (listener: Listener) => void + hasListener: (listener: Listener) => boolean + hasListeners: () => boolean + emit: (...args: Parameters) => Promise + clearListeners: () => void +} + +function createChromeEvent any>(): ChromeEvent { + const listeners = new Set>() + return { + addListener: (listener) => { + listeners.add(listener) + }, + removeListener: (listener) => { + listeners.delete(listener) + }, + hasListener: (listener) => listeners.has(listener), + hasListeners: () => listeners.size > 0, + emit: async (...args) => { + await Promise.all(Array.from(listeners).map((listener) => listener(...args))) + }, + clearListeners: () => { + listeners.clear() + }, + } +} + +type StorageChange = { + oldValue?: unknown + newValue?: unknown +} + +type StorageSnapshot = Record + +const storageState = new Map() + +const storageOnChanged = createChromeEvent[0]>() +const runtimeOnInstalled = createChromeEvent[0]>() +const runtimeOnMessage = createChromeEvent[0]>() + +function cloneValue(value: T): T { + if (typeof structuredClone === 'function') { + return structuredClone(value) + } + return JSON.parse(JSON.stringify(value)) +} + +function resolveStorageSnapshot(keys?: string | string[] | StorageSnapshot | null): StorageSnapshot { + if (!keys) { + return Object.fromEntries(storageState.entries()) + } + + if (typeof keys === 'string') { + return { [keys]: storageState.get(keys) } + } + + if (Array.isArray(keys)) { + return keys.reduce((acc, key) => { + acc[key] = storageState.get(key) + return acc + }, {}) + } + + const defaults: StorageSnapshot = { ...keys } + for (const [key, value] of Object.entries(defaults)) { + if (!storageState.has(key)) continue + defaults[key] = storageState.get(key) + } + return defaults +} + +function applyStorageUpdate(entries: StorageSnapshot): Record { + const changes: Record = {} + for (const [key, value] of Object.entries(entries)) { + const oldValue = storageState.get(key) + storageState.set(key, value) + changes[key] = { oldValue, newValue: value } + } + return changes +} + +export const chromeMock: typeof chrome = { + runtime: { + id: 'mock-extension-id', + sendMessage: vi.fn(), + getURL: vi.fn((path: string) => `chrome-extension://mock/${path}`), + onMessage: runtimeOnMessage, + onInstalled: runtimeOnInstalled, + lastError: undefined, + }, + storage: { + sync: { + get: vi.fn(async (keys?: string | string[] | StorageSnapshot | null) => { + return resolveStorageSnapshot(keys) + }), + set: vi.fn(async (items: StorageSnapshot) => { + const changes = applyStorageUpdate(items) + if (Object.keys(changes).length > 0) { + await storageOnChanged.emit(changes, 'sync') + } + }), + remove: vi.fn(async (keys: string | string[]) => { + const keyList = Array.isArray(keys) ? keys : [keys] + const changes: Record = {} + keyList.forEach((key) => { + if (!storageState.has(key)) return + const oldValue = storageState.get(key) + storageState.delete(key) + changes[key] = { oldValue } + }) + if (Object.keys(changes).length > 0) { + await storageOnChanged.emit(changes, 'sync') + } + }), + clear: vi.fn(async () => { + const changes: Record = {} + for (const [key, value] of storageState.entries()) { + changes[key] = { oldValue: value, newValue: undefined } + } + storageState.clear() + if (Object.keys(changes).length > 0) { + await storageOnChanged.emit(changes, 'sync') + } + }), + }, + onChanged: storageOnChanged, + }, + scripting: { + executeScript: vi.fn(), + }, + tabs: { + create: vi.fn(), + query: vi.fn(), + sendMessage: vi.fn(), + }, +} as unknown as typeof chrome + +export function installChromeMock() { + Object.defineProperty(globalThis, 'chrome', { + configurable: true, + value: chromeMock, + }) +} + +export function resetChromeMock() { + chromeMock.runtime.sendMessage.mockClear() + chromeMock.runtime.getURL.mockClear() + chromeMock.storage.sync.get.mockClear() + chromeMock.storage.sync.set.mockClear() + chromeMock.storage.sync.remove.mockClear() + chromeMock.storage.sync.clear.mockClear() + chromeMock.scripting.executeScript.mockClear() + chromeMock.tabs.create.mockClear() + chromeMock.tabs.query.mockClear() + chromeMock.tabs.sendMessage.mockClear() + storageState.clear() + storageOnChanged.clearListeners() + runtimeOnInstalled.clearListeners() + runtimeOnMessage.clearListeners() +} + +export function setChromeStorageSync(initial: StorageSnapshot) { + for (const [key, value] of Object.entries(initial)) { + storageState.set(key, cloneValue(value)) + } +} + +export function getChromeStorageSync(): StorageSnapshot { + return Object.fromEntries(storageState.entries()) +} + +export function emitStorageChange(changes: Record, area: chrome.storage.AreaName = 'sync') { + return storageOnChanged.emit(changes, area) +} + +export function emitRuntimeInstalled(details: chrome.runtime.InstalledDetails) { + return runtimeOnInstalled.emit(details) +} + +export function emitRuntimeMessage( + message: unknown, + sender: chrome.runtime.MessageSender, + sendResponse: (response?: unknown) => void, +) { + return runtimeOnMessage.emit(message, sender, sendResponse) +} diff --git a/test/setup/vitest.setup.ts b/test/setup/vitest.setup.ts new file mode 100644 index 0000000..6f46c9b --- /dev/null +++ b/test/setup/vitest.setup.ts @@ -0,0 +1,47 @@ +import createFetchMock from 'vitest-fetch-mock' +import '@testing-library/jest-dom/vitest' +import { afterEach, beforeEach, vi } from 'vitest' +import { installChromeMock, resetChromeMock } from '../mocks/chrome' + +const fetchMock = createFetchMock(vi) +fetchMock.enableMocks() + +installChromeMock() + +beforeEach(() => { + fetchMock.resetMocks() +}) + +afterEach(() => { + resetChromeMock() +}) + +globalThis.requestAnimationFrame = (callback: FrameRequestCallback): number => { + return setTimeout(() => callback(performance.now()), 0) as unknown as number +} + +globalThis.cancelAnimationFrame = (handle: number) => { + clearTimeout(handle as unknown as NodeJS.Timeout) +} + +function updateHistoryLocation(url?: string | URL | null) { + if (!url) return + const target = typeof url === 'string' ? new URL(url, window.location.href) : new URL(url.toString(), window.location.href) + try { + window.location.href = target.toString() + } catch { + window.location.assign(target.toString()) + } +} + +const originalReplaceState = window.history.replaceState.bind(window.history) +window.history.replaceState = ((data: any, unused: string, url?: string | URL | null) => { + updateHistoryLocation(url) + return originalReplaceState(data, unused, url ?? null) +}) as typeof window.history.replaceState + +const originalPushState = window.history.pushState.bind(window.history) +window.history.pushState = ((data: any, unused: string, url?: string | URL | null) => { + updateHistoryLocation(url) + return originalPushState(data, unused, url ?? null) +}) as typeof window.history.pushState diff --git a/tests/e2e/extension.spec.ts b/tests/e2e/extension.spec.ts new file mode 100644 index 0000000..7510676 --- /dev/null +++ b/tests/e2e/extension.spec.ts @@ -0,0 +1,35 @@ +import { expect, test } from './fixtures/extension' + +const CHAT_GPT_URL = 'https://chatgpt.com/c/example' + +const mockChatGptHtml = ` + + + ChatGPT mock + +
+
+
graph TD; A-->B;
+
+
+ + +` + +test.describe('CoderChart extension e2e', () => { + test('renders mermaid diagrams on ChatGPT pages', async ({ page, context }) => { + await context.route('https://chatgpt.com/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/html', + body: mockChatGptHtml, + }) + }) + + await page.goto(CHAT_GPT_URL) + + const diagram = page.locator('svg') + await expect(diagram).toHaveCount(1) + await expect(diagram).toBeVisible() + }) +}) diff --git a/tests/e2e/fixtures/extension.ts b/tests/e2e/fixtures/extension.ts new file mode 100644 index 0000000..8aecd6c --- /dev/null +++ b/tests/e2e/fixtures/extension.ts @@ -0,0 +1,18 @@ +import { execSync } from 'node:child_process' +import path from 'node:path' +import { test as base } from '@playwright/test' + +const extensionFixture = base.extend<{ extensionPath: string }>({ + extensionPath: [ + async ({}, use) => { + execSync('pnpm zip', { stdio: 'inherit' }) + const extensionDir = path.resolve(__dirname, '../../build') + process.env.PLAYWRIGHT_EXTENSION_PATH = extensionDir + await use(extensionDir) + }, + { scope: 'worker', auto: true }, + ], +}) + +export const test = extensionFixture +export const expect = extensionFixture.expect diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts new file mode 100644 index 0000000..0495ca9 --- /dev/null +++ b/tests/e2e/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from '@playwright/test' +import path from 'node:path' + +const extensionPath = process.env.PLAYWRIGHT_EXTENSION_PATH ?? path.resolve(__dirname, '../../build') + +export default defineConfig({ + testDir: __dirname, + timeout: 120_000, + expect: { + timeout: 10_000, + }, + reporter: [['list']], + use: { + headless: false, + viewport: { width: 1280, height: 720 }, + launchOptions: { + args: [ + `--disable-extensions-except=${extensionPath}`, + `--load-extension=${extensionPath}`, + ], + }, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/tsconfig.json b/tsconfig.json index 3d0a51a..a3c2f50 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,8 +14,15 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "jsx": "react-jsx" + "jsx": "react-jsx", + "types": ["vitest/globals", "@testing-library/jest-dom"], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "test/*": ["test/*"], + "tests/*": ["tests/*"] + } }, - "include": ["src"], + "include": ["src", "test", "tests"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..e97759c --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,38 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' +import { resolve } from 'node:path' + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + test: resolve(__dirname, 'test'), + }, + }, + test: { + environment: 'happy-dom', + setupFiles: ['test/setup/vitest.setup.ts'], + globals: true, + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + reportsDirectory: 'coverage', + include: ['src/**/*.{ts,tsx}'], + exclude: ['src/manifest.ts', 'src/zip.js'], + }, + include: [ + 'src/**/*.test.{ts,tsx}', + 'src/**/*.spec.{ts,tsx}', + 'src/**/__tests__/**/*.{ts,tsx}', + 'test/**/*.test.ts', + ], + deps: { + optimizer: { + web: { + include: ['@testing-library/jest-dom'], + }, + }, + }, + }, +})