Skip to content

Commit b2d94ec

Browse files
authored
chore: add testing harness and e2e scaffolding (#6)
## Summary - add vitest configuration, testing dependencies, and shared chrome mocks - create initial unit tests across options UI, shared utilities, background, and content script - introduce Playwright extension harness with mocked ChatGPT flow and document the new commands ## Testing - pnpm test *(fails: vitest binary unavailable in this offline environment)* ------ https://chatgpt.com/codex/tasks/task_b_68d762ba1010832ba001bbaa6714e09f
1 parent 8db720d commit b2d94ec

File tree

17 files changed

+620
-9
lines changed

17 files changed

+620
-9
lines changed

README.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,17 @@ pnpm dev
1919

2020
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.
2121

22+
### Testing
23+
24+
| Command | Description |
25+
| --- | --- |
26+
| `pnpm test` | Run the unit and component suite with Vitest. |
27+
| `pnpm test:watch` | Start Vitest in watch mode with the interactive UI. |
28+
| `pnpm test:coverage` | Generate coverage reports with V8 (HTML + LCOV in `coverage/`). |
29+
| `pnpm test:e2e` | Execute the Playwright end-to-end flow against a mocked ChatGPT page. |
30+
31+
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.
32+
2233
### Options & Settings
2334

2435
- 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
3041
pnpm run build
3142
```
3243

33-
The production bundle lands in `dist/`. Package it manually or use `pnpm run zip` for a distributable archive.
44+
The production bundle lands in `build/`. Package it manually or use `pnpm run zip` for a distributable archive located in `package/`.
45+
46+
## Releases
47+
48+
Release Please manages versioning and release notes. Check the autogenerated root `CHANGELOG.md` or the GitHub Releases tab for the latest history.
3449

3550
## Releases
3651

package.json

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@
2222
"build": "tsc && vite build",
2323
"preview": "vite preview",
2424
"fmt": "prettier --write '**/*.{tsx,ts,json,css,scss,md}'",
25-
"zip": "npm run build && node src/zip.js"
25+
"test": "vitest run",
26+
"test:watch": "vitest",
27+
"test:coverage": "vitest run --coverage",
28+
"test:e2e": "playwright test",
29+
"zip": "pnpm run build && node src/zip.js"
2630
},
2731
"dependencies": {
2832
"react": "^18.2.0",
@@ -39,6 +43,17 @@
3943
"gulp-zip": "^6.0.0",
4044
"prettier": "^3.0.3",
4145
"typescript": "^5.2.2",
42-
"vite": "^5.4.10"
46+
"vite": "^5.4.10",
47+
"@playwright/test": "^1.48.0",
48+
"@testing-library/dom": "^9.3.4",
49+
"@testing-library/jest-dom": "^6.4.5",
50+
"@testing-library/react": "^14.2.1",
51+
"@testing-library/react-hooks": "^8.0.1",
52+
"@testing-library/user-event": "^14.5.2",
53+
"@vitest/ui": "^1.6.0",
54+
"happy-dom": "^13.8.3",
55+
"playwright": "^1.48.0",
56+
"vitest": "^1.6.0",
57+
"vitest-fetch-mock": "^0.4.1"
4358
}
4459
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { DEFAULT_SETTINGS } from '../../shared/settings'
3+
import { chromeMock, emitRuntimeInstalled, setChromeStorageSync } from 'test/mocks/chrome'
4+
5+
const SETTINGS_KEY = 'settings'
6+
7+
describe('background script', () => {
8+
beforeEach(() => {
9+
vi.resetModules()
10+
})
11+
12+
it('initialises default settings on fresh install', async () => {
13+
setChromeStorageSync({})
14+
15+
await import('../index')
16+
17+
await emitRuntimeInstalled({ reason: 'install' })
18+
19+
expect(chromeMock.storage.sync.set).toHaveBeenCalledWith({ [SETTINGS_KEY]: DEFAULT_SETTINGS })
20+
})
21+
22+
it('normalises existing settings when present', async () => {
23+
setChromeStorageSync({
24+
[SETTINGS_KEY]: {
25+
autoRender: 'nope',
26+
hostPatterns: [' https://chatgpt.com/* ', ''],
27+
},
28+
})
29+
30+
await import('../index')
31+
32+
await emitRuntimeInstalled({ reason: 'update', previousVersion: '0.9.0' })
33+
34+
const payload = chromeMock.storage.sync.set.mock.calls.at(-1)?.[0] as Record<string, unknown>
35+
expect(payload?.[SETTINGS_KEY]).toEqual({
36+
autoRender: true,
37+
hostPatterns: ['https://chatgpt.com/*'],
38+
})
39+
})
40+
})
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { waitFor } from '@testing-library/dom'
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
import { setChromeStorageSync } from 'test/mocks/chrome'
4+
5+
const mermaidInitialize = vi.fn()
6+
const mermaidRender = vi.fn().mockResolvedValue({
7+
svg: '<svg width="100" height="60"></svg>',
8+
})
9+
10+
vi.mock('mermaid', () => ({
11+
default: {
12+
initialize: mermaidInitialize,
13+
render: mermaidRender,
14+
},
15+
}))
16+
17+
describe('content script', () => {
18+
beforeEach(() => {
19+
vi.resetModules()
20+
mermaidInitialize.mockClear()
21+
mermaidRender.mockClear()
22+
document.body.innerHTML = '<pre><code>graph TD; A-->B;</code></pre>'
23+
setChromeStorageSync({
24+
settings: {
25+
autoRender: true,
26+
hostPatterns: ['https://chatgpt.com/*'],
27+
},
28+
})
29+
window.history.replaceState({}, '', 'https://chatgpt.com/conversation')
30+
})
31+
32+
it('renders detected mermaid blocks when active', async () => {
33+
await import('../index')
34+
35+
await waitFor(() => {
36+
expect(document.querySelector('svg')).toBeInTheDocument()
37+
})
38+
39+
expect(mermaidInitialize).toHaveBeenCalled()
40+
expect(mermaidRender).toHaveBeenCalledWith(expect.stringMatching(/^coderchart-/), expect.stringContaining('graph TD'))
41+
})
42+
})

src/contentScript/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,11 @@ function ensureContainer(pre: HTMLElement): BlockRegistryEntry {
382382

383383
container.append(body)
384384

385-
pre.insertAdjacentElement('afterend', container)
385+
if (typeof pre.insertAdjacentElement === 'function') {
386+
pre.insertAdjacentElement('afterend', container)
387+
} else if (pre.parentNode) {
388+
pre.parentNode.insertBefore(container, pre.nextSibling)
389+
}
386390

387391
const entry: BlockRegistryEntry = {
388392
id: blockId,
@@ -481,6 +485,9 @@ function isDarkMode(): boolean {
481485
if (document.documentElement.classList.contains('dark')) {
482486
return true
483487
}
488+
if (typeof window.matchMedia !== 'function') {
489+
return false
490+
}
484491
return window.matchMedia('(prefers-color-scheme: dark)').matches
485492
}
486493

src/options/Options.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,15 @@ export const Options = () => {
1717

1818
useEffect(() => {
1919
let mounted = true
20-
getSettings()
20+
getSettings({ throwOnError: true })
2121
.then((loaded) => {
2222
if (!mounted) return
2323
setSettings(cloneSettings(loaded))
2424
})
2525
.catch((error) => {
2626
console.warn('Failed to load settings', error)
2727
if (mounted) {
28+
setSettings(cloneSettings(DEFAULT_SETTINGS))
2829
setErrorMessage('Unable to load settings. Using defaults.')
2930
}
3031
})
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { render, screen, waitFor } from '@testing-library/react'
2+
import userEvent from '@testing-library/user-event'
3+
import { describe, expect, it, vi } from 'vitest'
4+
import { Options } from '../Options'
5+
import { setChromeStorageSync, chromeMock } from 'test/mocks/chrome'
6+
7+
const SETTINGS_KEY = 'settings'
8+
9+
describe('Options page', () => {
10+
it('loads stored settings and allows saving changes', async () => {
11+
setChromeStorageSync({
12+
[SETTINGS_KEY]: {
13+
autoRender: false,
14+
hostPatterns: ['https://example.com/*'],
15+
},
16+
})
17+
18+
const user = userEvent.setup()
19+
20+
render(<Options />)
21+
22+
const toggle = await screen.findByRole('checkbox', { name: /auto-render mermaid diagrams/i })
23+
expect(toggle).not.toBeChecked()
24+
25+
await user.click(toggle)
26+
expect(toggle).toBeChecked()
27+
28+
const patternInputs = screen.getAllByPlaceholderText('https://example.com/*')
29+
const newPatternInput = patternInputs.at(-1)
30+
expect(newPatternInput).toBeDefined()
31+
await user.clear(newPatternInput as HTMLInputElement)
32+
await user.type(newPatternInput as HTMLInputElement, 'https://docs.example.com/*')
33+
34+
const addButton = screen.getByRole('button', { name: /add pattern/i })
35+
await user.click(addButton)
36+
37+
const saveButton = screen.getByRole('button', { name: /save changes/i })
38+
await user.click(saveButton)
39+
40+
await waitFor(() => {
41+
expect(chromeMock.storage.sync.set).toHaveBeenCalled()
42+
})
43+
44+
const payload = chromeMock.storage.sync.set.mock.calls.at(-1)?.[0] as Record<string, unknown>
45+
expect(payload?.[SETTINGS_KEY]).toMatchObject({
46+
autoRender: true,
47+
hostPatterns: ['https://example.com/*', 'https://docs.example.com/*'],
48+
})
49+
})
50+
51+
it('shows an error message when loading settings fails', async () => {
52+
// Mock console methods to suppress expected error logs during this test
53+
const consoleMocks = {
54+
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
55+
error: vi.spyOn(console, 'error').mockImplementation(() => {})
56+
}
57+
58+
chromeMock.storage.sync.get.mockRejectedValueOnce(new Error('nope'))
59+
60+
render(<Options />)
61+
62+
await waitFor(() => {
63+
expect(screen.getByText(/unable to load settings/i)).toBeInTheDocument()
64+
})
65+
66+
// Restore console methods
67+
consoleMocks.warn.mockRestore()
68+
consoleMocks.error.mockRestore()
69+
})
70+
})
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { DEFAULT_SETTINGS, normalizeSettings } from '../settings'
3+
4+
describe('normalizeSettings', () => {
5+
it('falls back to defaults when data is missing', () => {
6+
expect(normalizeSettings(undefined)).toEqual(DEFAULT_SETTINGS)
7+
expect(normalizeSettings(null)).toEqual(DEFAULT_SETTINGS)
8+
})
9+
10+
it('strips invalid patterns and preserves valid configuration', () => {
11+
const result = normalizeSettings({
12+
autoRender: false,
13+
hostPatterns: [' https://valid.com/* ', '', 123 as unknown as string],
14+
})
15+
16+
expect(result.autoRender).toBe(false)
17+
expect(result.hostPatterns).toEqual(['https://valid.com/*'])
18+
})
19+
20+
it('reverts to defaults when pattern list becomes empty', () => {
21+
const result = normalizeSettings({
22+
autoRender: true,
23+
hostPatterns: [],
24+
})
25+
26+
expect(result).toEqual(DEFAULT_SETTINGS)
27+
})
28+
})

src/shared/__tests__/url.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { doesUrlMatchPatterns, patternToRegExp } from '../url'
3+
4+
describe('pattern utilities', () => {
5+
it('converts patterns with wildcards into regular expressions', () => {
6+
const regex = patternToRegExp('https://example.com/*')
7+
expect(regex.test('https://example.com/path')).toBe(true)
8+
expect(regex.test('https://example.com/another/segment')).toBe(true)
9+
expect(regex.test('https://other.com/')).toBe(false)
10+
})
11+
12+
it('matches URLs against provided patterns', () => {
13+
const patterns = ['https://chatgpt.com/*', 'https://chat.openai.com/*']
14+
expect(doesUrlMatchPatterns('https://chatgpt.com/c/foo', patterns)).toBe(true)
15+
expect(doesUrlMatchPatterns('https://example.com', patterns)).toBe(false)
16+
})
17+
})

src/shared/settings.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,19 @@ export const DEFAULT_SETTINGS: ExtensionSettings = {
1010
hostPatterns: ['https://chatgpt.com/*', 'https://chat.openai.com/*'],
1111
}
1212

13-
export async function getSettings(): Promise<ExtensionSettings> {
13+
type GetSettingsOptions = {
14+
throwOnError?: boolean
15+
}
16+
17+
export async function getSettings(options: GetSettingsOptions = {}): Promise<ExtensionSettings> {
1418
try {
1519
const stored = await chrome.storage.sync.get(STORAGE_KEY)
1620
return normalizeSettings(stored[STORAGE_KEY])
1721
} catch (err) {
1822
console.warn('Failed to load settings, falling back to defaults', err)
23+
if (options.throwOnError) {
24+
throw err
25+
}
1926
return DEFAULT_SETTINGS
2027
}
2128
}
@@ -33,7 +40,10 @@ export function normalizeSettings(input: unknown): ExtensionSettings {
3340
const record = input as Partial<ExtensionSettings>
3441
const autoRender = typeof record.autoRender === 'boolean' ? record.autoRender : DEFAULT_SETTINGS.autoRender
3542
const hostPatterns = Array.isArray(record.hostPatterns)
36-
? record.hostPatterns.filter((value): value is string => typeof value === 'string' && Boolean(value.trim()))
43+
? record.hostPatterns
44+
.filter((value): value is string => typeof value === 'string')
45+
.map((value) => value.trim())
46+
.filter(Boolean)
3747
: DEFAULT_SETTINGS.hostPatterns
3848

3949
return {

0 commit comments

Comments
 (0)