For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build the v1.0 release: a Manifest V3 Chrome extension that lets a user define a single Markdown/WYSIWYG template and auto-inserts (or manually inserts) it into the Details field of new Google Calendar events. Replaces an unreleased Manifest V2 prototype.
Architecture: Three contexts (popup / service-worker / content-script) glued by typed chrome.runtime messages. Pure library code (lib/) holds the testable logic (storage facade, Markdown→sanitized HTML, Details-field locator). The Preact popup hosts a Tiptap editor with Markdown shortcuts, undo/redo, auto-save, and a Save/Insert action bar. The content script runs only on calendar.google.com, observes the event dialog opening, and injects sanitized HTML when auto-insert is on and Details is empty.
Tech Stack: TypeScript, Vite + @crxjs/vite-plugin (MV3), Preact (@preact/preset-vite), Tiptap (StarterKit + Underline + Placeholder + History), marked, DOMPurify, Vitest + happy-dom + @testing-library/preact, Playwright.
Reference spec: docs/superpowers/specs/2026-05-20-meet-formatter-v1-design.md
Versioning note: This plan and the linked spec use product version “v1.0” throughout. Earlier drafts referred to “v2” because the rewrite superseded an unreleased Manifest V2 prototype; for the first public release we renumbered. “Manifest V2” still appears below in the context of the deprecated Chrome extension format that the prototype used — that’s a separate concept from the product version.
Note on commits: This repo is intentionally not under git for now. Steps labeled “Checkpoint” mark logical breakpoints but contain no git commands. If the repo is initialized later, those checkpoints make natural squash points.
Files to delete (repo root, Manifest V2 leftovers):
popup.html, popup.js, action.js, background.js, injector.js, app.jsjquery-3.5.1.min.jsmanifest.json (replaced by Vite-emitted MV3 manifest)Files to keep:
assets/images/icon{16,48,128}.pngLICENSE.gitignore (extend)README.md (rewrite contents)CLAUDE.md (update post-implementation)Files to create:
| Path | Responsibility |
|---|---|
package.json |
npm scripts + pinned deps |
tsconfig.json |
TS config (strict, ESM, JSX→Preact) |
vite.config.ts |
Vite + crxjs + preact preset |
vitest.config.ts |
Vitest with happy-dom, jsdom-style globals |
playwright.config.ts |
Playwright with persistent context for extension load |
eslint.config.js |
ESLint flat config |
src/manifest.config.ts |
MV3 manifest as TS |
src/messages.ts |
MessageKind enum + payload types |
src/lib/storage.ts |
getSettings, setSettings, onSettingsChanged |
src/lib/markdown.ts |
markdownToSanitizedHtml(md) |
src/lib/details-locator.ts |
findDetailsEditor, findDescriptionExpander, waitForEventEditor, waitFor, isEditorEmpty |
src/background/service-worker.ts |
Message router; relays INSERT_TEMPLATE to active calendar tab |
src/content/calendar-injector.ts |
Observer + locator + DOM injection on calendar.google.com |
src/popup/popup.html |
Vite popup entry |
src/popup/main.tsx |
Preact mount |
src/popup/App.tsx |
Top-level component |
src/popup/Editor.tsx |
Tiptap wrapper |
src/popup/Toolbar.tsx |
B / I / U / • / 1. / H |
src/popup/SettingsRow.tsx |
Auto-insert toggle |
src/popup/ActionBar.tsx |
Save / Insert |
src/popup/popup.css |
Notion-ish design tokens |
src/popup/useAutoSave.ts |
Debounced auto-save hook |
tests/setup.ts |
Vitest setup (chrome API mocks, testing-library matchers) |
tests/fixtures/fake-calendar.html |
Static Calendar-shaped page for E2E |
tests/fixtures/event-dialog.html |
Snapshot of the dialog for locator unit tests |
tests/e2e/popup.spec.ts |
Popup load + auto-save E2E |
tests/e2e/auto-insert.spec.ts |
Auto-insert + manual-insert E2E |
tests/verify-live.spec.ts |
Claude-driven live verification against real Calendar |
Files:
Delete: popup.html, popup.js, action.js, background.js, injector.js, app.js, jquery-3.5.1.min.js, manifest.json
Step 1: Delete the old files
cd "/Users/pbrowne/scripts/Google Meets Formatter"
rm -f popup.html popup.js action.js background.js injector.js app.js jquery-3.5.1.min.js manifest.json
ls -1 | grep -E '^(popup|action|background|injector|app|jquery|manifest)\.' || echo "clean"
Expected output: clean
Files:
Create: package.json
Step 1: Write package.json
{
"name": "meet-formatter",
"version": "1.0.0",
"description": "Inject a Markdown-defined template into Google Calendar event Details.",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test --config=playwright.config.ts tests/e2e",
"test:live": "playwright test --config=playwright.config.ts tests/verify-live.spec.ts --headed",
"typecheck": "tsc --noEmit",
"lint": "eslint src tests"
},
"dependencies": {
"@tiptap/core": "^2.10.3",
"@tiptap/extension-placeholder": "^2.10.3",
"@tiptap/extension-underline": "^2.10.3",
"@tiptap/pm": "^2.10.3",
"@tiptap/react": "^2.10.3",
"@tiptap/starter-kit": "^2.10.3",
"dompurify": "^3.2.2",
"marked": "^14.1.4",
"preact": "^10.25.1",
"turndown": "^7.2.0"
},
"devDependencies": {
"@crxjs/vite-plugin": "^2.0.0-beta.30",
"@playwright/test": "^1.49.1",
"@preact/preset-vite": "^2.10.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/preact": "^3.2.4",
"@testing-library/user-event": "^14.5.2",
"@types/chrome": "^0.0.287",
"@types/dompurify": "^3.2.0",
"@types/node": "^22.10.2",
"@types/turndown": "^5.0.5",
"@typescript-eslint/eslint-plugin": "^8.18.1",
"@typescript-eslint/parser": "^8.18.1",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.1.0",
"happy-dom": "^15.11.7",
"http-server": "^14.1.1",
"typescript": "^5.7.2",
"vite": "^5.4.11",
"vitest": "^2.1.8"
}
}
Run:
cd "/Users/pbrowne/scripts/Google Meets Formatter"
npm install
Expected: npm install completes without peerdependency errors. If @crxjs/vite-plugin@beta.30 is unavailable, try npm install @crxjs/vite-plugin@beta --save-dev to pull the current beta. The plan tolerates any beta ≥ 2.0.0-beta.25.
Vite pin gotcha: stay on
vite@^5.4.x. Bumping to vite 6 tripsexactOptionalPropertyTypesagainst vitest 2’s bundled vite types when both configs share apreact()plugin instance. If you must use vite 6, also bump to vitest 3.
Private npm registry: if the developer’s global
~/.npmrcpoints at a private registry (e.g. JFrog), add a project-level.npmrcto override:registry=https://registry.npmjs.org/
npx playwright install chromium
Expected: download completes; final line includes “✔ Successfully installed”.
node_modules/ and package-lock.json exist.Files:
Create: tsconfig.json
Step 1: Write tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"jsxImportSource": "preact",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": false,
"noEmit": true,
"types": ["chrome", "node", "vitest/globals", "@testing-library/jest-dom"],
"paths": {
"react": ["./node_modules/preact/compat"],
"react/jsx-runtime": ["./node_modules/preact/jsx-runtime"],
"react-dom": ["./node_modules/preact/compat"],
"react-dom/*": ["./node_modules/preact/compat/*"]
},
"baseUrl": "."
},
"include": ["src", "tests", "*.config.ts", "*.config.js"]
}
npm run typecheck 2>&1 | head -5
Expected: tsc reports error TS18003: No inputs were found or succeeds silently. Either is fine — we just need confirmation tsc is wired up.
Files:
vite.config.tsCreate: src/manifest.config.ts
src/manifest.config.tsimport { defineManifest } from '@crxjs/vite-plugin'
export default defineManifest({
manifest_version: 3,
name: 'Meet Formatter',
version: '1.0.0',
description: 'Inject a Markdown-defined template into Google Calendar event Details.',
action: {
default_popup: 'src/popup/popup.html',
default_title: 'Meet Formatter',
},
background: {
service_worker: 'src/background/service-worker.ts',
type: 'module',
},
content_scripts: [
{
matches: ['*://calendar.google.com/*'],
js: ['src/content/calendar-injector.ts'],
run_at: 'document_idle',
},
],
permissions: ['storage'],
host_permissions: ['*://calendar.google.com/*'],
icons: {
'16': 'assets/images/icon16.png',
'48': 'assets/images/icon48.png',
'128': 'assets/images/icon128.png',
},
})
vite.config.tsimport { defineConfig } from 'vite'
import { crx } from '@crxjs/vite-plugin'
import preact from '@preact/preset-vite'
import manifest from './src/manifest.config'
export default defineConfig({
plugins: [preact(), crx({ manifest })],
build: {
outDir: 'dist',
emptyOutDir: true,
sourcemap: true,
},
})
Files:
vitest.config.tsCreate: tests/setup.ts
vitest.config.tsimport { defineConfig } from 'vitest/config'
import preact from '@preact/preset-vite'
export default defineConfig({
plugins: [preact()],
test: {
environment: 'happy-dom',
globals: true,
setupFiles: ['./tests/setup.ts'],
include: ['src/**/*.test.{ts,tsx}', 'tests/**/*.test.{ts,tsx}'],
exclude: ['tests/e2e/**', 'tests/verify-live.spec.ts', 'node_modules/**'],
},
resolve: {
alias: {
react: 'preact/compat',
'react-dom': 'preact/compat',
},
},
})
tests/setup.tsimport '@testing-library/jest-dom/vitest'
import { vi, beforeEach } from 'vitest'
type StorageListener = (
changes: Record<string, chrome.storage.StorageChange>,
area: string,
) => void
interface MockStorage {
data: Record<string, unknown>
listeners: StorageListener[]
}
const stores: Record<string, MockStorage> = {
sync: { data: {}, listeners: [] },
local: { data: {}, listeners: [] },
}
function makeArea(name: 'sync' | 'local') {
const store = stores[name]!
return {
get: vi.fn((keys: string | string[] | null) => {
if (keys === null || keys === undefined) return Promise.resolve({ ...store.data })
const arr = Array.isArray(keys) ? keys : [keys]
const out: Record<string, unknown> = {}
for (const k of arr) if (k in store.data) out[k] = store.data[k]
return Promise.resolve(out)
}),
set: vi.fn((items: Record<string, unknown>) => {
const changes: Record<string, chrome.storage.StorageChange> = {}
for (const [k, v] of Object.entries(items)) {
changes[k] = { oldValue: store.data[k], newValue: v }
store.data[k] = v
}
for (const l of store.listeners) l(changes, name)
return Promise.resolve()
}),
clear: vi.fn(() => {
store.data = {}
return Promise.resolve()
}),
}
}
const runtimeListeners: Array<(msg: unknown, sender: unknown, sendResponse: (r?: unknown) => void) => void> = []
;(globalThis as unknown as { chrome: typeof chrome }).chrome = {
storage: {
sync: makeArea('sync') as unknown as chrome.storage.StorageArea,
local: makeArea('local') as unknown as chrome.storage.StorageArea,
onChanged: {
addListener: vi.fn((cb: StorageListener) => {
stores.sync.listeners.push(cb)
stores.local.listeners.push(cb)
}),
removeListener: vi.fn(),
hasListener: vi.fn(),
hasListeners: vi.fn(),
} as unknown as chrome.storage.StorageChangedEvent,
},
runtime: {
sendMessage: vi.fn(),
onMessage: {
addListener: vi.fn((cb: typeof runtimeListeners[number]) => runtimeListeners.push(cb)),
removeListener: vi.fn(),
},
lastError: undefined,
},
tabs: {
query: vi.fn(() => Promise.resolve([])),
sendMessage: vi.fn(),
},
} as unknown as typeof chrome
beforeEach(() => {
stores.sync.data = {}
stores.local.data = {}
stores.sync.listeners.length = 0
stores.local.listeners.length = 0
runtimeListeners.length = 0
vi.clearAllMocks()
})
npm test 2>&1 | tail -10
Expected: No test files found, exiting with code 1 or similar — confirms Vitest is wired up and finds zero specs.
Files:
Create: src/messages.ts
Step 1: Write src/messages.ts
export const MessageKind = {
InsertTemplate: 'INSERT_TEMPLATE',
} as const
export type MessageKind = typeof MessageKind[keyof typeof MessageKind]
export interface InsertTemplateMessage {
kind: typeof MessageKind.InsertTemplate
/** Markdown source; the content script renders → sanitizes → injects. */
markdown: string
/** When true, the content script must inject even if Details is non-empty. */
manual: boolean
}
export type ExtensionMessage = InsertTemplateMessage
lib/storage.ts — TDDFiles:
src/lib/storage.tsTest: src/lib/storage.test.ts
// src/lib/storage.test.ts
import { describe, it, expect, vi } from 'vitest'
import { getSettings, setSettings, onSettingsChanged, defaultSettings } from './storage'
describe('storage facade', () => {
it('returns defaults when storage is empty', async () => {
const s = await getSettings()
expect(s).toEqual(defaultSettings)
})
it('round-trips template and autoInsert', async () => {
await setSettings({ template: '# hi', autoInsert: true })
const s = await getSettings()
expect(s).toEqual({ template: '# hi', autoInsert: true })
})
it('partial updates do not clobber the other field', async () => {
await setSettings({ template: 'a', autoInsert: true })
await setSettings({ template: 'b' })
const s = await getSettings()
expect(s).toEqual({ template: 'b', autoInsert: true })
})
it('notifies subscribers on change', async () => {
const listener = vi.fn()
onSettingsChanged(listener)
await setSettings({ template: 'x' })
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({ template: 'x', autoInsert: false }),
)
})
})
npx vitest run src/lib/storage.test.ts
Expected: Cannot find module './storage' or getSettings is not a function.
src/lib/storage.tsexport interface Settings {
template: string
autoInsert: boolean
}
export const defaultSettings: Settings = {
template: '',
autoInsert: false,
}
const KEY = 'settings'
export async function getSettings(): Promise<Settings> {
const result = await chrome.storage.sync.get(KEY)
const stored = (result[KEY] ?? {}) as Partial<Settings>
return { ...defaultSettings, ...stored }
}
export async function setSettings(patch: Partial<Settings>): Promise<void> {
const current = await getSettings()
const next = { ...current, ...patch }
await chrome.storage.sync.set({ [KEY]: next })
}
export function onSettingsChanged(cb: (s: Settings) => void): () => void {
const listener = (
changes: Record<string, chrome.storage.StorageChange>,
area: string,
) => {
if (area !== 'sync') return
if (!changes[KEY]) return
const next = { ...defaultSettings, ...(changes[KEY].newValue as Partial<Settings>) }
cb(next)
}
chrome.storage.onChanged.addListener(listener)
return () => chrome.storage.onChanged.removeListener(listener)
}
npx vitest run src/lib/storage.test.ts
Expected: 4 passing.
lib/markdown.ts — TDDFiles:
src/lib/markdown.tsTest: src/lib/markdown.test.ts
// src/lib/markdown.test.ts
import { describe, it, expect } from 'vitest'
import { markdownToSanitizedHtml } from './markdown'
describe('markdownToSanitizedHtml', () => {
it('renders bold, italic, and underline (raw <u>)', () => {
const html = markdownToSanitizedHtml('**b** _i_ <u>u</u>')
expect(html).toContain('<strong>b</strong>')
expect(html).toContain('<em>i</em>')
expect(html).toContain('<u>u</u>')
})
it('renders bullet and ordered lists', () => {
const ul = markdownToSanitizedHtml('- one\n- two')
expect(ul).toContain('<ul>')
expect(ul).toContain('<li>one</li>')
const ol = markdownToSanitizedHtml('1. one\n2. two')
expect(ol).toContain('<ol>')
})
it('renders headings up to h3 but no h4+', () => {
expect(markdownToSanitizedHtml('# a')).toContain('<h1>a</h1>')
expect(markdownToSanitizedHtml('### c')).toContain('<h3>c</h3>')
const h4 = markdownToSanitizedHtml('#### d')
expect(h4).not.toContain('<h4>')
})
it('keeps http and https links, drops javascript: URLs', () => {
expect(markdownToSanitizedHtml('[ok](https://example.com)'))
.toContain('href="https://example.com"')
const bad = markdownToSanitizedHtml('[x](javascript:alert(1))')
expect(bad).not.toContain('javascript:')
})
it('strips <script> and on* handlers', () => {
const html = markdownToSanitizedHtml('<script>alert(1)</script><img src=x onerror=alert(1)>')
expect(html).not.toContain('<script>')
expect(html).not.toContain('onerror')
})
it('preserves empty paragraph for empty input', () => {
expect(markdownToSanitizedHtml('').trim()).toBe('')
})
})
npx vitest run src/lib/markdown.test.ts
Expected: module-not-found errors.
src/lib/markdown.tsimport { marked } from 'marked'
import DOMPurify from 'dompurify'
const ALLOWED_TAGS = [
'p', 'br', 'strong', 'em', 'u', 's',
'h1', 'h2', 'h3',
'ul', 'ol', 'li',
'a', 'code', 'pre', 'blockquote',
]
const ALLOWED_ATTR = ['href']
marked.setOptions({
gfm: true,
breaks: false,
})
export function markdownToSanitizedHtml(md: string): string {
if (!md) return ''
const raw = marked.parse(md, { async: false }) as string
return DOMPurify.sanitize(raw, {
ALLOWED_TAGS,
ALLOWED_ATTR,
ALLOW_DATA_ATTR: false,
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|#)/i,
})
}
npx vitest run src/lib/markdown.test.ts
Expected: 6 passing. If the heading test fails because marked still emits <h4> outside the allowed list, that’s fine — DOMPurify will strip it. The assertion not.toContain('<h4>') confirms this.
lib/details-locator.ts — TDDFiles:
src/lib/details-locator.tssrc/lib/details-locator.test.tstests/fixtures/event-dialog.htmlDOM source of truth: the locator targets real Google Calendar markup captured during development (see Spec §4 + Appendix A). Stable anchors used:
#xDescIn,[contenteditable="true"][role="textbox"][aria-label="Add description"],[data-key="description"]. Class names andc####ids are dynamic and must NOT appear in selectors.
tests/fixtures/event-dialog.html<!doctype html>
<html><body>
<div id="root">
<!-- decoy: title field, also contenteditable, must NOT be picked by the locator -->
<div contenteditable="true" data-role="title-decoy" aria-label="Add title"></div>
<!-- Models the real Google Calendar event dialog with description expanded -->
<div role="dialog" aria-label="Create event">
<!-- collapsed prompt remains in DOM alongside the editor in real Calendar -->
<span>
Add
<span class="JyrDof" data-key="description">description</span>
or
<span class="JyrDof" data-key="attachments">a Google Drive attachment</span>
</span>
<!-- expanded description panel -->
<div data-expandable jsname="Ofl2hc">
<button jsname="Zqjuqb" aria-expanded="true">Add description</button>
<div jsname="xpDKHf">
<div role="toolbar" aria-label="Formatting options">
<div role="button" aria-label="Bold"></div>
<div role="button" aria-label="Italic"></div>
<div role="button" aria-label="Underline"></div>
</div>
<div id="xDescIn">
<div aria-hidden="true">Add description</div>
<div data-role="details-real"
role="textbox"
aria-multiline="true"
aria-label="Add description"
contenteditable="true"
g_editable="true">
<div><br></div>
</div>
</div>
</div>
</div>
</div>
</div>
</body></html>
// src/lib/details-locator.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { findDetailsEditor, waitForEventEditor } from './details-locator'
import fixture from '../../tests/fixtures/event-dialog.html?raw'
beforeEach(() => {
document.body.innerHTML = fixture
})
describe('findDetailsEditor', () => {
it('finds the description contenteditable, not the title decoy', () => {
const dialog = document.querySelector('[role="dialog"]')!
const editor = findDetailsEditor(dialog as HTMLElement)
expect(editor).toBeInstanceOf(HTMLElement)
expect(editor?.dataset.role).toBe('details-real')
})
it('returns null when no matching label exists', () => {
document.body.innerHTML = '<div role="dialog"><div contenteditable="true"></div></div>'
const dialog = document.querySelector('[role="dialog"]')!
expect(findDetailsEditor(dialog as HTMLElement)).toBeNull()
})
it('also accepts "Add description" as aria-label', () => {
document.body.innerHTML = `
<div role="dialog">
<div aria-label="Add description">
<div contenteditable="true" data-role="real"></div>
</div>
</div>`
const dialog = document.querySelector('[role="dialog"]')!
expect(findDetailsEditor(dialog as HTMLElement)?.dataset.role).toBe('real')
})
})
describe('waitForEventEditor', () => {
it('resolves with the dialog when one appears', async () => {
document.body.innerHTML = '<main></main>'
const promise = waitForEventEditor(document.querySelector('main') as HTMLElement)
const dialog = document.createElement('div')
dialog.setAttribute('role', 'dialog')
dialog.setAttribute('aria-label', 'Create event')
document.querySelector('main')!.appendChild(dialog)
const found = await promise
expect(found).toBe(dialog)
})
it('times out and resolves null if no dialog appears within timeoutMs', async () => {
const result = await waitForEventEditor(document.body, { timeoutMs: 20 })
expect(result).toBeNull()
})
})
npx vitest run src/lib/details-locator.test.ts
Expected: module-not-found.
src/lib/details-locator.tsconst DESCRIPTION_LABELS = ['Description', 'Add description']
/**
* Returns the Details contenteditable if the description panel is already
* expanded, or null otherwise.
*/
export function findDetailsEditor(dialog: HTMLElement): HTMLElement | null {
// 1) Real Google Calendar: editor lives inside #xDescIn
const xDescIn = dialog.querySelector<HTMLElement>('#xDescIn')
if (xDescIn) {
const editable = xDescIn.querySelector<HTMLElement>('[contenteditable="true"]')
if (editable) return editable
}
// 2) Generic role=textbox + aria-label
const textbox = dialog.querySelector<HTMLElement>(
'[contenteditable="true"][role="textbox"][aria-label="Add description"]',
)
if (textbox) return textbox
// 3) Legacy aria-label="Description" wrapper containing a contenteditable
for (const label of DESCRIPTION_LABELS) {
const wrappers = dialog.querySelectorAll<HTMLElement>(`[aria-label="${label}"]`)
for (const wrapper of wrappers) {
const editable = wrapper.matches('[contenteditable="true"]')
? wrapper
: wrapper.querySelector<HTMLElement>('[contenteditable="true"]')
if (editable) return editable
}
}
return null
}
/**
* Returns the element to click to expand the description panel, or null.
*/
export function findDescriptionExpander(dialog: HTMLElement): HTMLElement | null {
const dataKeySpan = dialog.querySelector<HTMLElement>('[data-key="description"]')
if (dataKeySpan) return dataKeySpan
const buttons = dialog.querySelectorAll<HTMLElement>('button')
for (const btn of buttons) {
const text = (btn.textContent ?? '').trim()
if (text === 'Add description' || text === 'Add description or attachment') {
return btn
}
}
return null
}
export interface WaitOptions {
/** Hard upper bound; resolves null if exceeded. Default: 15s */
timeoutMs?: number
}
export function waitForEventEditor(
root: HTMLElement,
opts: WaitOptions = {},
): Promise<HTMLElement | null> {
const timeoutMs = opts.timeoutMs ?? 15_000
const existing = root.querySelector<HTMLElement>('[role="dialog"]')
if (existing) return Promise.resolve(existing)
return new Promise((resolve) => {
const timer = setTimeout(() => { observer.disconnect(); resolve(null) }, timeoutMs)
const observer = new MutationObserver(() => {
const dialog = root.querySelector<HTMLElement>('[role="dialog"]')
if (dialog) { clearTimeout(timer); observer.disconnect(); resolve(dialog) }
})
observer.observe(root, { childList: true, subtree: true })
})
}
/** Poll until `find()` returns non-null or the timeout elapses. */
export function waitFor<T>(
find: () => T | null,
opts: { timeoutMs?: number; intervalMs?: number } = {},
): Promise<T | null> {
const timeoutMs = opts.timeoutMs ?? 3_000
const intervalMs = opts.intervalMs ?? 50
const start = Date.now()
return new Promise((resolve) => {
const tick = () => {
const r = find()
if (r) return resolve(r)
if (Date.now() - start >= timeoutMs) return resolve(null)
setTimeout(tick, intervalMs)
}
tick()
})
}
/** Google Calendar's empty Details editor is typically `<div><br></div>`. */
export function isEditorEmpty(editor: HTMLElement): boolean {
const text = editor.textContent?.trim() ?? ''
if (text.length > 0) return false
const meaningful = editor.querySelector(':not(br):not(div):not(p)')
if (meaningful) return false
return true
}
Tests must cover both branches of findDetailsEditor (real-DOM #xDescIn path AND legacy aria-label="Description" fallback), both branches of findDescriptionExpander ([data-key="description"] AND the button fallback), and waitFor (resolves and times out).
isEditorEmpty testAdd to src/lib/details-locator.test.ts:
import { findDetailsEditor, waitForEventEditor, isEditorEmpty } from './details-locator'
describe('isEditorEmpty', () => {
it('is true for <div></div>', () => {
const el = document.createElement('div')
expect(isEditorEmpty(el)).toBe(true)
})
it('is true for <div><br></div>', () => {
const el = document.createElement('div')
el.innerHTML = '<br>'
expect(isEditorEmpty(el)).toBe(true)
})
it('is false when there is text', () => {
const el = document.createElement('div')
el.textContent = 'hello'
expect(isEditorEmpty(el)).toBe(false)
})
it('is false when there is a structural child like <strong>', () => {
const el = document.createElement('div')
el.innerHTML = '<strong></strong>'
expect(isEditorEmpty(el)).toBe(false)
})
})
npx vitest run src/lib/details-locator.test.ts
Expected: 9 passing.
Files:
Create: src/popup/popup.css
Step 1: Write src/popup/popup.css
:root {
--bg: #fdfcfa;
--surface: #ffffff;
--border: #e9e6e0;
--border-soft: #f1ede6;
--text: #37352f;
--text-muted: #b5b2ab;
--toolbar-bg: #f1ede6;
--accent: #2eaadc;
--primary: #37352f;
--primary-text: #ffffff;
--radius-sm: 6px;
--radius-md: 9px;
--radius-lg: 12px;
--shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 13px;
line-height: 1.45;
}
body {
width: 380px;
max-height: 600px;
overflow: hidden;
}
#app {
display: flex;
flex-direction: column;
height: 100%;
}
.header {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 16px 10px;
border-bottom: 1px solid var(--border-soft);
font-weight: 600;
}
.header-icon { font-size: 16px; }
.settings-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 16px;
font-size: 13px;
}
.toggle {
width: 30px;
height: 17px;
border-radius: 9px;
background: #d1d5db;
position: relative;
cursor: pointer;
transition: background 0.15s;
border: none;
padding: 0;
}
.toggle[aria-checked="true"] { background: var(--accent); }
.toggle::after {
content: "";
position: absolute;
top: 1px;
left: 1px;
width: 14px;
height: 14px;
background: #fff;
border-radius: 50%;
transition: transform 0.15s;
}
.toggle[aria-checked="true"]::after { transform: translateX(13px); }
.toolbar {
display: flex;
gap: 3px;
padding: 6px 16px 0;
}
.tb-btn {
font-size: 11px;
padding: 4px 8px;
border-radius: var(--radius-sm);
background: var(--toolbar-bg);
color: var(--text);
border: none;
cursor: pointer;
font-family: inherit;
min-width: 26px;
}
.tb-btn[aria-pressed="true"] {
background: var(--primary);
color: var(--primary-text);
}
.tb-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.editor-wrapper {
margin: 8px 16px 0;
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
min-height: 220px;
max-height: 360px;
overflow-y: auto;
font-size: 12px;
}
.editor-wrapper .ProseMirror { outline: none; min-height: 200px; }
.editor-wrapper .ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: var(--text-muted);
pointer-events: none;
height: 0;
}
.action-bar {
display: flex;
gap: 6px;
justify-content: flex-end;
padding: 10px 16px 14px;
}
.btn {
font-size: 12px;
padding: 5px 14px;
border-radius: var(--radius-sm);
cursor: pointer;
font-family: inherit;
}
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.btn-secondary { background: var(--surface); border: 1px solid var(--border); color: var(--text); }
.btn-primary { background: var(--primary); border: none; color: var(--primary-text); }
popup.html entryFiles:
Create: src/popup/popup.html
Step 1: Write src/popup/popup.html
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Meet Formatter</title>
<link rel="stylesheet" href="./popup.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
Files:
src/popup/ActionBar.tsxTest: src/popup/ActionBar.test.tsx
// src/popup/ActionBar.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/preact'
import userEvent from '@testing-library/user-event'
import { ActionBar } from './ActionBar'
describe('ActionBar', () => {
it('renders Save then Insert, with Save left of Insert', () => {
render(<ActionBar onSave={() => {}} onInsert={() => {}} />)
const buttons = screen.getAllByRole('button')
expect(buttons[0]).toHaveTextContent('Save')
expect(buttons[1]).toHaveTextContent('Insert')
})
it('clicking Save calls onSave', async () => {
const onSave = vi.fn()
render(<ActionBar onSave={onSave} onInsert={() => {}} />)
await userEvent.click(screen.getByRole('button', { name: /save/i }))
expect(onSave).toHaveBeenCalledOnce()
})
it('clicking Insert calls onInsert', async () => {
const onInsert = vi.fn()
render(<ActionBar onSave={() => {}} onInsert={onInsert} />)
await userEvent.click(screen.getByRole('button', { name: /insert/i }))
expect(onInsert).toHaveBeenCalledOnce()
})
it('shows "Saved ✓" briefly after save confirmation', async () => {
const { rerender } = render(
<ActionBar onSave={() => {}} onInsert={() => {}} confirmation="saved" />,
)
expect(screen.getByRole('button', { name: /saved/i })).toBeInTheDocument()
rerender(<ActionBar onSave={() => {}} onInsert={() => {}} confirmation="idle" />)
expect(screen.getByRole('button', { name: /^save$/i })).toBeInTheDocument()
})
})
npx vitest run src/popup/ActionBar.test.tsx
Expected: module-not-found.
src/popup/ActionBar.tsxexport type SaveConfirmation = 'idle' | 'saved'
export interface ActionBarProps {
onSave: () => void
onInsert: () => void
confirmation?: SaveConfirmation
}
export function ActionBar({ onSave, onInsert, confirmation = 'idle' }: ActionBarProps) {
const saveLabel = confirmation === 'saved' ? 'Saved ✓' : 'Save'
return (
<div class="action-bar">
<button type="button" class="btn btn-secondary" onClick={onSave}>
{saveLabel}
</button>
<button type="button" class="btn btn-primary" onClick={onInsert}>
Insert
</button>
</div>
)
}
npx vitest run src/popup/ActionBar.test.tsx
Expected: 4 passing.
Files:
src/popup/SettingsRow.tsxTest: src/popup/SettingsRow.test.tsx
// src/popup/SettingsRow.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/preact'
import userEvent from '@testing-library/user-event'
import { SettingsRow } from './SettingsRow'
describe('SettingsRow', () => {
it('renders the auto-insert label and toggle', () => {
render(<SettingsRow autoInsert={false} onChange={() => {}} />)
expect(screen.getByText(/auto-insert on event open/i)).toBeInTheDocument()
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
})
it('reflects autoInsert=true in aria-checked', () => {
render(<SettingsRow autoInsert={true} onChange={() => {}} />)
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
})
it('clicking the toggle calls onChange with the inverse value', async () => {
const onChange = vi.fn()
render(<SettingsRow autoInsert={false} onChange={onChange} />)
await userEvent.click(screen.getByRole('switch'))
expect(onChange).toHaveBeenCalledWith(true)
})
})
npx vitest run src/popup/SettingsRow.test.tsx
src/popup/SettingsRow.tsxexport interface SettingsRowProps {
autoInsert: boolean
onChange: (next: boolean) => void
}
export function SettingsRow({ autoInsert, onChange }: SettingsRowProps) {
return (
<div class="settings-row">
<span id="auto-insert-label">Auto-insert on event open</span>
<button
type="button"
role="switch"
aria-checked={autoInsert}
aria-labelledby="auto-insert-label"
class="toggle"
onClick={() => onChange(!autoInsert)}
/>
</div>
)
}
npx vitest run src/popup/SettingsRow.test.tsx
Expected: 3 passing.
Files:
src/popup/Toolbar.tsxTest: src/popup/Toolbar.test.tsx
// src/popup/Toolbar.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/preact'
import userEvent from '@testing-library/user-event'
import { Toolbar } from './Toolbar'
const labels = ['Bold', 'Italic', 'Underline', 'Bullet list', 'Ordered list', 'Heading']
describe('Toolbar', () => {
it('renders six buttons in the documented order', () => {
render(<Toolbar active= onCommand={() => {}} />)
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(6)
for (let i = 0; i < labels.length; i++) {
expect(buttons[i]).toHaveAccessibleName(new RegExp(labels[i]!, 'i'))
}
})
it('aria-pressed reflects active marks', () => {
render(<Toolbar active= onCommand={() => {}} />)
expect(screen.getByRole('button', { name: /bold/i })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: /italic/i })).toHaveAttribute('aria-pressed', 'false')
})
it('clicking a button fires onCommand with its kind', async () => {
const onCommand = vi.fn()
render(<Toolbar active= onCommand={onCommand} />)
await userEvent.click(screen.getByRole('button', { name: /bullet list/i }))
expect(onCommand).toHaveBeenCalledWith('bulletList')
})
})
npx vitest run src/popup/Toolbar.test.tsx
src/popup/Toolbar.tsxexport type ToolbarCommand =
| 'bold' | 'italic' | 'underline'
| 'bulletList' | 'orderedList'
| 'heading'
export interface ToolbarProps {
active: Partial<Record<ToolbarCommand, boolean>>
onCommand: (kind: ToolbarCommand) => void
}
const BUTTONS: { kind: ToolbarCommand; label: string; glyph: string }[] = [
{ kind: 'bold', label: 'Bold', glyph: 'B' },
{ kind: 'italic', label: 'Italic', glyph: 'I' },
{ kind: 'underline', label: 'Underline', glyph: 'U' },
{ kind: 'bulletList', label: 'Bullet list', glyph: '•' },
{ kind: 'orderedList', label: 'Ordered list', glyph: '1.' },
{ kind: 'heading', label: 'Heading', glyph: 'H' },
]
export function Toolbar({ active, onCommand }: ToolbarProps) {
return (
<div class="toolbar" role="toolbar" aria-label="Text formatting">
{BUTTONS.map((b) => (
<button
key={b.kind}
type="button"
class="tb-btn"
aria-label={b.label}
aria-pressed={Boolean(active[b.kind])}
onClick={() => onCommand(b.kind)}
>
{b.glyph}
</button>
))}
</div>
)
}
npx vitest run src/popup/Toolbar.test.tsx
Expected: 3 passing.
useAutoSave hook — TDDFiles:
src/popup/useAutoSave.tsTest: src/popup/useAutoSave.test.tsx
// src/popup/useAutoSave.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/preact'
import { useAutoSave } from './useAutoSave'
beforeEach(() => {
vi.useFakeTimers()
})
describe('useAutoSave', () => {
it('debounces save: calls once after the wait, not on every change', () => {
const save = vi.fn()
const { result } = renderHook(() => useAutoSave(save, 400))
act(() => { result.current.schedule('one') })
act(() => { result.current.schedule('two') })
act(() => { result.current.schedule('three') })
expect(save).not.toHaveBeenCalled()
act(() => { vi.advanceTimersByTime(400) })
expect(save).toHaveBeenCalledTimes(1)
expect(save).toHaveBeenCalledWith('three')
})
it('flush() persists the pending value immediately', () => {
const save = vi.fn()
const { result } = renderHook(() => useAutoSave(save, 400))
act(() => { result.current.schedule('pending') })
act(() => { result.current.flush() })
expect(save).toHaveBeenCalledWith('pending')
act(() => { vi.advanceTimersByTime(400) })
expect(save).toHaveBeenCalledTimes(1)
})
it('flush() is a no-op when nothing is pending', () => {
const save = vi.fn()
const { result } = renderHook(() => useAutoSave(save, 400))
act(() => { result.current.flush() })
expect(save).not.toHaveBeenCalled()
})
})
npx vitest run src/popup/useAutoSave.test.tsx
src/popup/useAutoSave.tsimport { useEffect, useRef } from 'preact/hooks'
export interface AutoSave<T> {
schedule: (value: T) => void
flush: () => void
}
export function useAutoSave<T>(save: (v: T) => void, waitMs: number): AutoSave<T> {
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
const pending = useRef<{ value: T } | null>(null)
const saveRef = useRef(save)
saveRef.current = save
useEffect(() => () => {
if (timer.current) clearTimeout(timer.current)
}, [])
function flush() {
if (timer.current) {
clearTimeout(timer.current)
timer.current = null
}
if (pending.current) {
const v = pending.current.value
pending.current = null
saveRef.current(v)
}
}
function schedule(value: T) {
pending.current = { value }
if (timer.current) clearTimeout(timer.current)
timer.current = setTimeout(flush, waitMs)
}
return { schedule, flush }
}
npx vitest run src/popup/useAutoSave.test.tsx
Expected: 3 passing.
Files:
src/popup/Editor.tsxsrc/popup/Editor.test.tsxNote: Tiptap’s ProseMirror view layer has some quirks in happy-dom. The unit test here exercises the wrapper’s API (props, onUpdate firing) rather than ProseMirror’s internal selection model. End-to-end formatting is exercised in the Playwright tests in Task 22+.
// src/popup/Editor.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, waitFor } from '@testing-library/preact'
import { Editor } from './Editor'
describe('Editor', () => {
it('renders a ProseMirror surface with the placeholder', async () => {
const { container } = render(
<Editor markdown="" placeholder="Markdown supported" onChange={() => {}} onActiveMarksChange={() => {}} />,
)
await waitFor(() => {
const pm = container.querySelector('.ProseMirror')
expect(pm).not.toBeNull()
})
const empty = container.querySelector('p.is-editor-empty')
expect(empty?.getAttribute('data-placeholder')).toBe('Markdown supported')
})
it('loading initial markdown produces matching HTML', async () => {
const { container } = render(
<Editor markdown="**hi**" placeholder="" onChange={() => {}} onActiveMarksChange={() => {}} />,
)
await waitFor(() => {
expect(container.querySelector('strong')?.textContent).toBe('hi')
})
})
it('typing fires onChange with the serialized markdown', async () => {
const onChange = vi.fn()
const { container } = render(
<Editor markdown="" placeholder="" onChange={onChange} onActiveMarksChange={() => {}} />,
)
await waitFor(() => container.querySelector('.ProseMirror'))
const pm = container.querySelector('.ProseMirror') as HTMLElement
pm.focus()
pm.textContent = 'hello'
pm.dispatchEvent(new Event('input', { bubbles: true }))
await waitFor(() => expect(onChange).toHaveBeenCalled())
expect(onChange.mock.calls.at(-1)?.[0]).toContain('hello')
})
})
npx vitest run src/popup/Editor.test.tsx
src/popup/Editor.tsximport { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Underline from '@tiptap/extension-underline'
import Placeholder from '@tiptap/extension-placeholder'
import type { Editor as TiptapEditor } from '@tiptap/core'
import { useEffect, forwardRef, useImperativeHandle } from 'preact/compat'
import { serializeDocToMarkdown, parseMarkdownToHtml } from './markdown-bridge'
import type { ToolbarCommand } from './Toolbar'
export interface EditorProps {
markdown: string
placeholder: string
onChange: (markdown: string) => void
onActiveMarksChange: (active: Partial<Record<ToolbarCommand, boolean>>) => void
}
export interface EditorHandle {
runCommand: (kind: ToolbarCommand) => void
}
export const Editor = forwardRef<EditorHandle, EditorProps>(function Editor(
{ markdown, placeholder, onChange, onActiveMarksChange },
ref,
) {
const editor = useEditor({
extensions: [
StarterKit.configure({ heading: { levels: [1, 2, 3] } }),
Underline,
Placeholder.configure({ placeholder }),
],
content: parseMarkdownToHtml(markdown),
onUpdate: ({ editor }) => {
onChange(serializeDocToMarkdown(editor))
onActiveMarksChange(readActiveMarks(editor))
},
onSelectionUpdate: ({ editor }) => onActiveMarksChange(readActiveMarks(editor)),
})
useEffect(() => {
if (!editor) return
const current = serializeDocToMarkdown(editor)
if (markdown !== current) {
editor.commands.setContent(parseMarkdownToHtml(markdown), false)
}
}, [editor, markdown])
useImperativeHandle(ref, () => ({
runCommand(kind) {
if (!editor) return
const chain = editor.chain().focus()
switch (kind) {
case 'bold': chain.toggleBold().run(); break
case 'italic': chain.toggleItalic().run(); break
case 'underline': chain.toggleUnderline().run(); break
case 'bulletList': chain.toggleBulletList().run(); break
case 'orderedList': chain.toggleOrderedList().run(); break
case 'heading': chain.toggleHeading({ level: 2 }).run(); break
}
},
}), [editor])
return (
<div class="editor-wrapper">
<EditorContent editor={editor} />
</div>
)
})
function readActiveMarks(editor: TiptapEditor): Partial<Record<ToolbarCommand, boolean>> {
return {
bold: editor.isActive('bold'),
italic: editor.isActive('italic'),
underline: editor.isActive('underline'),
bulletList: editor.isActive('bulletList'),
orderedList: editor.isActive('orderedList'),
heading: editor.isActive('heading'),
}
}
Create src/popup/markdown-bridge.ts:
import { marked } from 'marked'
import type { Editor as TiptapEditor } from '@tiptap/core'
import TurndownService from 'turndown'
// Lazily constructed because turndown reaches into globalThis.
let _td: TurndownService | null = null
function td(): TurndownService {
if (_td) return _td
_td = new TurndownService({
headingStyle: 'atx',
bulletListMarker: '-',
codeBlockStyle: 'fenced',
})
_td.addRule('underline', {
filter: ['u'],
replacement: (content) => `<u>${content}</u>`,
})
return _td
}
export function parseMarkdownToHtml(md: string): string {
if (!md) return ''
return marked.parse(md, { async: false }) as string
}
export function serializeDocToMarkdown(editor: TiptapEditor): string {
const html = editor.getHTML()
return td().turndown(html)
}
turndown to package.json dependenciesEdit package.json and add to dependencies:
"turndown": "^7.2.0"
Then add to devDependencies:
"@types/turndown": "^5.0.5"
Run:
npm install
npx vitest run src/popup/Editor.test.tsx
Expected: 3 passing.
If the
typing fires onChangetest is flaky in happy-dom because ProseMirror swallows the synthetic input event, replace the innerpm.textContent = …portion with an Editor-imperative call: importEditorHandlevia ref and invokerunCommand('bold')instead. The point of this test is to confirmonChangewiring, not to validate ProseMirror’s input pipeline (that’s the E2E job).
Files:
src/popup/App.tsxsrc/popup/main.tsxTest: src/popup/App.test.tsx
// src/popup/App.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/preact'
import userEvent from '@testing-library/user-event'
import { App } from './App'
import { setSettings, getSettings } from '../lib/storage'
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
})
describe('App', () => {
it('loads existing settings into the UI on mount', async () => {
await setSettings({ template: '# saved', autoInsert: true })
render(<App />)
await waitFor(() => {
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
})
})
it('toggling auto-insert persists', async () => {
render(<App />)
await waitFor(() => screen.getByRole('switch'))
await userEvent.click(screen.getByRole('switch'))
await waitFor(async () => {
expect((await getSettings()).autoInsert).toBe(true)
})
})
it('clicking Save flushes auto-save and shows Saved ✓', async () => {
render(<App />)
await waitFor(() => screen.getByRole('button', { name: /^save$/i }))
await userEvent.click(screen.getByRole('button', { name: /^save$/i }))
expect(screen.getByRole('button', { name: /saved/i })).toBeInTheDocument()
})
it('clicking Insert sends an INSERT_TEMPLATE message', async () => {
await setSettings({ template: 'hello', autoInsert: false })
const sendMessage = vi.spyOn(chrome.runtime, 'sendMessage')
render(<App />)
await waitFor(() => screen.getByRole('button', { name: /insert/i }))
await userEvent.click(screen.getByRole('button', { name: /insert/i }))
expect(sendMessage).toHaveBeenCalledWith(
expect.objectContaining({ kind: 'INSERT_TEMPLATE', markdown: 'hello', manual: true }),
)
})
})
npx vitest run src/popup/App.test.tsx
src/popup/App.tsximport { useEffect, useRef, useState } from 'preact/hooks'
import { Editor } from './Editor'
import type { EditorHandle } from './Editor'
import { Toolbar } from './Toolbar'
import type { ToolbarCommand } from './Toolbar'
import { SettingsRow } from './SettingsRow'
import { ActionBar } from './ActionBar'
import type { SaveConfirmation } from './ActionBar'
import { getSettings, setSettings, defaultSettings, type Settings } from '../lib/storage'
import { useAutoSave } from './useAutoSave'
import { MessageKind, type InsertTemplateMessage } from '../messages'
const AUTO_SAVE_MS = 400
const CONFIRMATION_MS = 1200
const PLACEHOLDER = 'Markdown supported'
export function App() {
const [settings, setLocal] = useState<Settings>(defaultSettings)
const [active, setActive] = useState<Partial<Record<ToolbarCommand, boolean>>>({})
const [confirmation, setConfirmation] = useState<SaveConfirmation>('idle')
const editorRef = useRef<EditorHandle>(null)
useEffect(() => {
getSettings().then(setLocal)
}, [])
const autoSave = useAutoSave((md: string) => {
setSettings({ template: md })
}, AUTO_SAVE_MS)
function onEditorChange(md: string) {
setLocal((s) => ({ ...s, template: md }))
autoSave.schedule(md)
}
function onAutoInsertChange(next: boolean) {
setLocal((s) => ({ ...s, autoInsert: next }))
setSettings({ autoInsert: next })
}
function onSaveClick() {
autoSave.flush()
setConfirmation('saved')
setTimeout(() => setConfirmation('idle'), CONFIRMATION_MS)
}
function onInsertClick() {
autoSave.flush()
const msg: InsertTemplateMessage = {
kind: MessageKind.InsertTemplate,
markdown: settings.template,
manual: true,
}
chrome.runtime.sendMessage(msg)
}
return (
<>
<div class="header">
<span class="header-icon" aria-hidden="true">📅</span>
<span>Meet Formatter</span>
</div>
<SettingsRow autoInsert={settings.autoInsert} onChange={onAutoInsertChange} />
<Toolbar active={active} onCommand={(c) => editorRef.current?.runCommand(c)} />
<Editor
ref={editorRef}
markdown={settings.template}
placeholder={PLACEHOLDER}
onChange={onEditorChange}
onActiveMarksChange={setActive}
/>
<ActionBar onSave={onSaveClick} onInsert={onInsertClick} confirmation={confirmation} />
</>
)
}
src/popup/main.tsximport { render } from 'preact'
import { App } from './App'
const root = document.getElementById('app')!
render(<App />, root)
npx vitest run src/popup/App.test.tsx
Expected: 4 passing.
npm test
Expected: every spec green.
Files:
src/background/service-worker.tsTest: src/background/service-worker.test.ts
// src/background/service-worker.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { MessageKind } from '../messages'
import { handleMessage } from './service-worker'
describe('service-worker handleMessage', () => {
beforeEach(() => {
;(chrome.tabs.query as ReturnType<typeof vi.fn>).mockReset()
;(chrome.tabs.sendMessage as ReturnType<typeof vi.fn>).mockReset()
})
it('forwards INSERT_TEMPLATE to the active calendar tab', async () => {
;(chrome.tabs.query as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 42, url: 'https://calendar.google.com/calendar/u/0/r' },
])
await handleMessage({ kind: MessageKind.InsertTemplate, markdown: 'hi', manual: true })
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
42,
expect.objectContaining({ kind: 'INSERT_TEMPLATE', markdown: 'hi' }),
)
})
it('ignores when no calendar tab is active', async () => {
;(chrome.tabs.query as ReturnType<typeof vi.fn>).mockResolvedValue([])
await handleMessage({ kind: MessageKind.InsertTemplate, markdown: 'hi', manual: true })
expect(chrome.tabs.sendMessage).not.toHaveBeenCalled()
})
})
npx vitest run src/background/service-worker.test.ts
src/background/service-worker.tsimport { MessageKind, type ExtensionMessage } from '../messages'
const CALENDAR_URL_MATCH = '*://calendar.google.com/*'
export async function handleMessage(message: ExtensionMessage): Promise<void> {
switch (message.kind) {
case MessageKind.InsertTemplate: {
// Query ANY open calendar tab — not just `active:true`+`currentWindow`.
// When the popup is open, the calendar tab isn't the active one, and the
// popup may live in its own window. Filtering on `active` would return
// the popup instead of the calendar tab. host_permissions in the manifest
// is required for the URL field to be populated on the query result.
const tabs = await chrome.tabs.query({ url: CALENDAR_URL_MATCH })
const tabId = tabs.find((t) => typeof t.id === 'number')?.id
if (typeof tabId !== 'number') return
await chrome.tabs.sendMessage(tabId, message)
return
}
}
}
if (typeof chrome !== 'undefined' && chrome.runtime?.onMessage) {
chrome.runtime.onMessage.addListener((message: unknown) => {
void handleMessage(message as ExtensionMessage)
})
}
npx vitest run src/background/service-worker.test.ts
Expected: 2 passing.
Files:
src/content/calendar-injector.tsTest: src/content/calendar-injector.test.ts
// src/content/calendar-injector.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import fixture from '../../tests/fixtures/event-dialog.html?raw'
import { injectTemplate, INSERTED_FLAG } from './calendar-injector'
beforeEach(() => {
document.body.innerHTML = fixture
})
function realEditor() {
return document.querySelector<HTMLElement>('[data-role="details-real"]')!
}
describe('injectTemplate', () => {
it('inserts sanitized HTML when Details is empty', () => {
injectTemplate({ markdown: '**hi**', manual: false })
expect(realEditor().innerHTML).toContain('<strong>hi</strong>')
})
it('does NOT overwrite non-empty Details when manual=false', () => {
realEditor().textContent = 'user wrote this'
injectTemplate({ markdown: '**hi**', manual: false })
expect(realEditor().textContent).toBe('user wrote this')
})
it('appends to (does not replace) non-empty Details when manual=true', () => {
realEditor().textContent = 'user wrote this'
injectTemplate({ markdown: '**hi**', manual: true })
expect(realEditor().innerHTML).toContain('<strong>hi</strong>')
expect(realEditor().textContent).toContain('user wrote this')
})
it('flags the editor so a second auto-insert is a no-op', () => {
injectTemplate({ markdown: '**one**', manual: false })
injectTemplate({ markdown: '**two**', manual: false })
expect(realEditor().innerHTML).toContain('<strong>one</strong>')
expect(realEditor().innerHTML).not.toContain('<strong>two</strong>')
expect(realEditor().dataset[INSERTED_FLAG]).toBe('true')
})
it('strips <script> tags from the markdown payload', () => {
injectTemplate({ markdown: '<script>alert(1)</script>**ok**', manual: true })
expect(realEditor().innerHTML).not.toContain('<script>')
expect(realEditor().innerHTML).toContain('<strong>ok</strong>')
})
it('does nothing if no Details editor is present', () => {
document.body.innerHTML = '<div role="dialog"></div>'
expect(() => injectTemplate({ markdown: 'x', manual: false })).not.toThrow()
})
it('dispatches an input event so Calendar persists the change', () => {
const editor = realEditor()
const listener = vi.fn()
editor.addEventListener('input', listener)
injectTemplate({ markdown: 'hi', manual: false })
expect(listener).toHaveBeenCalled()
})
})
npx vitest run src/content/calendar-injector.test.ts
src/content/calendar-injector.tsimport { MessageKind, type ExtensionMessage } from '../messages'
import { markdownToSanitizedHtml } from '../lib/markdown'
import {
findDetailsEditor,
findDescriptionExpander,
isEditorEmpty,
waitFor,
} from '../lib/details-locator'
import { getSettings, onSettingsChanged } from '../lib/storage'
export const INSERTED_FLAG = 'meetFormatterInserted'
export interface InjectArgs {
markdown: string
manual: boolean
}
/**
* Synchronous, fixture-friendly injection used by unit tests. Requires the
* editor to already be present in the DOM.
*/
export function injectTemplate({ markdown, manual }: InjectArgs): void {
const dialog = document.querySelector<HTMLElement>('[role="dialog"]')
if (!dialog) return
const editor = findDetailsEditor(dialog)
if (!editor) return
applyInjection(editor, markdown, manual)
}
/**
* Runtime version against real Google Calendar: clicks the description
* expander if the panel is still collapsed, then waits for the contenteditable
* to appear, then injects.
*/
export async function ensureAndInject({ markdown, manual }: InjectArgs): Promise<boolean> {
const dialog = document.querySelector<HTMLElement>('[role="dialog"]')
if (!dialog) return false
let editor = findDetailsEditor(dialog)
if (!editor) {
const expander = findDescriptionExpander(dialog)
if (!expander) return false
expander.click()
editor = await waitFor(() => findDetailsEditor(dialog), { timeoutMs: 2_000 })
if (!editor) return false
}
return applyInjection(editor, markdown, manual)
}
function applyInjection(editor: HTMLElement, markdown: string, manual: boolean): boolean {
const empty = isEditorEmpty(editor)
if (!manual && !empty) return false
if (!manual && editor.dataset[INSERTED_FLAG] === 'true') return false
const html = markdownToSanitizedHtml(markdown)
if (!html) return false
if (manual && !empty) {
editor.insertAdjacentHTML('beforeend', html)
} else {
editor.innerHTML = html
}
editor.dataset[INSERTED_FLAG] = 'true'
editor.dispatchEvent(new Event('input', { bubbles: true }))
return true
}
// --- Runtime wiring (no-op in unit tests where chrome.runtime is mocked) ---
let unsubscribeSettings: (() => void) | null = null
let currentAutoInsert = false
async function ensureAutoInsert() {
const settings = await getSettings()
currentAutoInsert = settings.autoInsert
unsubscribeSettings?.()
unsubscribeSettings = onSettingsChanged((s) => {
currentAutoInsert = s.autoInsert
})
}
async function attachAutoInsertObserver() {
await ensureAutoInsert()
const observer = new MutationObserver(async () => {
if (!currentAutoInsert) return
const dialog = document.querySelector<HTMLElement>('[role="dialog"]')
if (!dialog) return
// Cheap early-out: if neither editor nor expander is in this mutation batch
if (!findDetailsEditor(dialog) && !findDescriptionExpander(dialog)) return
// Once-per-dialog guard
const probeEditor = findDetailsEditor(dialog)
if (probeEditor?.dataset[INSERTED_FLAG] === 'true') return
const { template } = await getSettings()
if (!template) return
await ensureAndInject({ markdown: template, manual: false })
})
observer.observe(document.body, { childList: true, subtree: true })
}
if (typeof chrome !== 'undefined' && chrome.runtime?.onMessage) {
chrome.runtime.onMessage.addListener((msg: unknown) => {
const m = msg as ExtensionMessage
if (m.kind === MessageKind.InsertTemplate) {
void ensureAndInject({ markdown: m.markdown, manual: m.manual })
}
})
void attachAutoInsertObserver()
}
The unit tests must cover both the synchronous injectTemplate (when the editor is already in the fixture) AND the async ensureAndInject (collapsed → click expander → editor appears → inject).
npx vitest run src/content/calendar-injector.test.ts
Expected: 7 passing.
npm run typecheck
Expected: zero errors.
npm run build
Expected: dist/ directory created. Should contain:
manifest.json (MV3, generated by crxjs)assets/popup-*.js, assets/popup-*.cssCopied assets/images/icon{16,48,128}.png
ls dist/
test -f dist/manifest.json && echo "manifest ok"
test -d dist/assets && echo "assets ok"
Expected: both ok lines.
Manually: open chrome://extensions, enable Developer mode, click “Load unpacked”, pick the dist/ directory. Click the toolbar icon — the popup should open and show the header + toggle + toolbar + empty editor with “Markdown supported” placeholder + Save/Insert buttons.
Type some Markdown (e.g. **bold**) — confirm bold appears. Close the popup, reopen it — text should persist (auto-save worked).
Files:
Create: tests/fixtures/fake-calendar.html
Step 1: Write tests/fixtures/fake-calendar.html
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Fake Calendar</title>
<style>
body { font-family: -apple-system, sans-serif; padding: 20px; background: #fafafa; }
button { padding: 8px 16px; cursor: pointer; }
#dialog { display: none; margin-top: 16px; padding: 16px; background: white; border: 1px solid #ddd; border-radius: 8px; max-width: 480px; }
#dialog[data-open="true"] { display: block; }
.field { margin-bottom: 12px; }
.field label { display: block; font-size: 12px; color: #555; margin-bottom: 4px; }
[contenteditable] { min-height: 60px; padding: 8px; border: 1px solid #ccc; border-radius: 4px; background: white; }
[contenteditable]:empty::before { content: attr(data-empty-text); color: #aaa; }
</style>
</head>
<body>
<h1>Fake Calendar</h1>
<button id="create">+ Create event</button>
<div id="dialog" role="dialog" aria-label="Create event">
<div class="field">
<label>Title</label>
<div contenteditable="true" data-role="title-decoy"></div>
</div>
<div class="field">
<label aria-label="Description">Description</label>
<div aria-label="Description">
<div contenteditable="true" data-role="details-real" data-empty-text="Add a description"></div>
</div>
</div>
<button id="save-event">Save</button>
</div>
<script>
document.getElementById('create').addEventListener('click', () => {
const d = document.getElementById('dialog')
d.setAttribute('data-open', 'true')
})
document.getElementById('save-event').addEventListener('click', () => {
const details = document.querySelector('[data-role="details-real"]')
window.__savedDescription = details ? details.innerHTML : ''
document.getElementById('dialog').setAttribute('data-open', 'false')
})
</script>
</body>
</html>
Files:
Create: playwright.config.ts
Step 1: Generate a self-signed cert for calendar.google.com HSTS workaround
cd "/Users/pbrowne/scripts/Google Meets Formatter/tests/fixtures"
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout key.pem -out cert.pem -days 3650 \
-subj "/CN=calendar.google.com"
The cert + key go under tests/fixtures/ and are listed in .gitignore. They’re consumed by http-server -S -C cert.pem -K key.pem so the test server can speak HTTPS. Chrome’s HSTS preload list force-upgrades any http://calendar.google.com/... URL to HTTPS, so the fixture server must serve TLS — Chrome’s --ignore-certificate-errors flag (added in the E2E launch args, Task 23) lets it accept the self-signed cert.
playwright.config.tsimport { defineConfig } from '@playwright/test'
import path from 'node:path'
export default defineConfig({
testDir: './tests',
timeout: 30_000,
fullyParallel: false,
workers: 1,
reporter: 'list',
use: {
baseURL: 'https://localhost:5174',
actionTimeout: 5_000,
trace: 'retain-on-failure',
video: 'retain-on-failure',
},
webServer: {
command:
'npx http-server tests/fixtures -p 5174 -s ' +
'-S -C tests/fixtures/cert.pem -K tests/fixtures/key.pem',
url: 'https://localhost:5174/fake-calendar.html',
ignoreHTTPSErrors: true,
reuseExistingServer: true,
timeout: 10_000,
},
})
export const EXTENSION_PATH = path.resolve('./dist')
E2E spec launch flags (in Task 23) include --ignore-certificate-errors and --host-resolver-rules=MAP calendar.google.com 127.0.0.1:5174.
In package.json devDependencies:
"http-server": "^14.1.1"
Then:
npm install
Files:
Create: tests/e2e/popup.spec.ts
Step 1: Write tests/e2e/popup.spec.ts
import { test, expect, chromium, type BrowserContext } from '@playwright/test'
import path from 'node:path'
import os from 'node:os'
import fs from 'node:fs'
const EXT_PATH = path.resolve('./dist')
async function launchExtension(): Promise<BrowserContext> {
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'meet-formatter-'))
return chromium.launchPersistentContext(userDataDir, {
headless: true,
channel: 'chromium',
args: [
`--disable-extensions-except=${EXT_PATH}`,
`--load-extension=${EXT_PATH}`,
'--no-sandbox',
],
})
}
async function openPopup(context: BrowserContext) {
// Resolve extension ID by inspecting service workers.
let worker = context.serviceWorkers()[0]
if (!worker) worker = await context.waitForEvent('serviceworker')
const extId = new URL(worker.url()).host
const popup = await context.newPage()
await popup.goto(`chrome-extension://${extId}/src/popup/popup.html`)
return popup
}
test('popup loads and shows the empty editor with placeholder', async () => {
const ctx = await launchExtension()
try {
const popup = await openPopup(ctx)
await expect(popup.locator('text=Meet Formatter')).toBeVisible()
await expect(popup.locator('text=Auto-insert on event open')).toBeVisible()
await expect(popup.locator('.ProseMirror')).toBeVisible()
await expect(popup.locator('p.is-editor-empty'))
.toHaveAttribute('data-placeholder', 'Markdown supported')
await expect(popup.locator('button:has-text("Save")')).toBeVisible()
await expect(popup.locator('button:has-text("Insert")')).toBeVisible()
} finally {
await ctx.close()
}
})
test('typing in the editor auto-saves; reopening shows the same content', async () => {
const ctx = await launchExtension()
try {
let popup = await openPopup(ctx)
await popup.locator('.ProseMirror').click()
await popup.keyboard.type('Hello world')
await popup.waitForTimeout(600) // > 400 ms debounce
await popup.close()
popup = await openPopup(ctx)
await expect(popup.locator('.ProseMirror')).toContainText('Hello world')
} finally {
await ctx.close()
}
})
test('Save button shows "Saved ✓" confirmation', async () => {
const ctx = await launchExtension()
try {
const popup = await openPopup(ctx)
await popup.locator('button:has-text("Save")').click()
await expect(popup.locator('button:has-text("Saved")')).toBeVisible()
} finally {
await ctx.close()
}
})
npm run build
npm run test:e2e -- tests/e2e/popup.spec.ts
Expected: 3 passing.
Files:
Create: tests/e2e/auto-insert.spec.ts
Step 1: Write tests/e2e/auto-insert.spec.ts
import { test, expect, chromium, type BrowserContext, type Page } from '@playwright/test'
import path from 'node:path'
import os from 'node:os'
import fs from 'node:fs'
const EXT_PATH = path.resolve('./dist')
// Important: the content script's match pattern is calendar.google.com only.
// For E2E we serve the fake page from a hosts-mapped subdomain by using
// Playwright's request routing to alias calendar.google.com → localhost:5174.
async function launchExtension(): Promise<BrowserContext> {
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'meet-formatter-'))
return chromium.launchPersistentContext(userDataDir, {
headless: true,
channel: 'chromium',
args: [
`--disable-extensions-except=${EXT_PATH}`,
`--load-extension=${EXT_PATH}`,
'--no-sandbox',
'--host-resolver-rules=MAP calendar.google.com 127.0.0.1:5174',
'--ignore-certificate-errors',
],
})
}
async function openPopup(context: BrowserContext): Promise<Page> {
let worker = context.serviceWorkers()[0]
if (!worker) worker = await context.waitForEvent('serviceworker')
const extId = new URL(worker.url()).host
const popup = await context.newPage()
await popup.goto(`chrome-extension://${extId}/src/popup/popup.html`)
return popup
}
async function seedTemplate(context: BrowserContext, markdown: string, autoInsert: boolean) {
const popup = await openPopup(context)
// Use the popup's storage API directly via evaluate
await popup.evaluate(
async ([md, auto]) => {
await chrome.storage.sync.set({ settings: { template: md, autoInsert: auto } })
},
[markdown, autoInsert] as const,
)
await popup.close()
}
test('auto-insert fills empty Details when toggle is on', async () => {
const ctx = await launchExtension()
try {
await seedTemplate(ctx, '**Agenda**\n- one\n- two', true)
const page = await ctx.newPage()
await page.goto('https://calendar.google.com/fake-calendar.html')
await page.locator('#create').click()
await expect(page.locator('[data-role="details-real"] strong')).toHaveText('Agenda')
await expect(page.locator('[data-role="details-real"] li')).toHaveCount(2)
} finally {
await ctx.close()
}
})
test('auto-insert does NOT overwrite non-empty Details', async () => {
const ctx = await launchExtension()
try {
await seedTemplate(ctx, '**Agenda**', true)
const page = await ctx.newPage()
await page.goto('https://calendar.google.com/fake-calendar.html')
await page.evaluate(() => {
const e = document.querySelector('[data-role="details-real"]') as HTMLElement
e.textContent = 'pre-existing'
})
await page.locator('#create').click()
await expect(page.locator('[data-role="details-real"]')).toContainText('pre-existing')
await expect(page.locator('[data-role="details-real"] strong')).toHaveCount(0)
} finally {
await ctx.close()
}
})
test('auto-insert is skipped when toggle is off', async () => {
const ctx = await launchExtension()
try {
await seedTemplate(ctx, '**Agenda**', false)
const page = await ctx.newPage()
await page.goto('https://calendar.google.com/fake-calendar.html')
await page.locator('#create').click()
await expect(page.locator('[data-role="details-real"] strong')).toHaveCount(0)
} finally {
await ctx.close()
}
})
test('manual Insert appends even when Details is non-empty', async () => {
const ctx = await launchExtension()
try {
await seedTemplate(ctx, '**Agenda**', false)
const page = await ctx.newPage()
await page.goto('https://calendar.google.com/fake-calendar.html')
await page.evaluate(() => {
const e = document.querySelector('[data-role="details-real"]') as HTMLElement
e.textContent = 'user typed first'
})
await page.locator('#create').click()
const popup = await openPopup(ctx)
await popup.locator('button:has-text("Insert")').click()
await popup.close()
await expect(page.locator('[data-role="details-real"]')).toContainText('user typed first')
await expect(page.locator('[data-role="details-real"] strong')).toHaveText('Agenda')
} finally {
await ctx.close()
}
})
npm run build
npm run test:e2e -- tests/e2e/auto-insert.spec.ts
Expected: 4 passing.
Files:
tests/verify-live.spec.ts (Playwright Option A — kept for repeatability)tests/live-profile/.gitkeepPurpose: Verify on the real
calendar.google.comDOM. We support two harnesses; pick whichever matches the situation.Option A — Playwright headed (
npm run test:live). Self-contained but launches a fresh Chromium with a persistent profile that needs Google login on first run.Option B — Claude-driven via
Claude_in_ChromeMCP (preferred for interactive use). Pairs to the developer’s already-authenticated Chrome via the Claude_in_Chrome extension. No login automation needed.
There is no checked-in file for this harness — it’s a sequence Claude runs at the end of execution:
chrome://extensions → Load unpacked → dist/) in their normal Chrome and authorizes the Claude_in_Chrome extension to access calendar.google.com.mcp__Claude_in_Chrome__list_connected_browsers → select_browser.tabs_context_mcp({ createIfEmpty: true }) to get a tab handle.navigate({ url: 'https://calendar.google.com/calendar/u/0/r' }). (Permission prompt may appear; user grants.)computer({ action: 'screenshot' }) → screenshot 1 of the calendar grid.computer({ action: 'left_click' }) on the calendar grid (a free time slot) to open the event dialog, OR navigate via the Create → Event button by find({ query: 'Create button' }).Claude: javascript_tool runs the exact locator we ship:
(() => {
const dialog = document.querySelector('[role="dialog"]')
if (!dialog) return { error: 'no dialog' }
const xDescIn = dialog.querySelector('#xDescIn')
const editor = xDescIn?.querySelector('[contenteditable="true"][role="textbox"]')
?? dialog.querySelector('[contenteditable="true"][role="textbox"][aria-label="Add description"]')
return {
hasDialog: true,
hasXDescIn: !!xDescIn,
hasEditor: !!editor,
editorHtml: editor?.innerHTML ?? null,
}
})()
Asserts: hasDialog: true, hasEditor: true, and after auto-insert fires, editorHtml contains the template’s first heading (e.g. <strong>Live verification template</strong>).
screenshot → screenshot 2 showing the injected template visible.find({ query: 'Save button' }) → left_click. Wait. screenshot → screenshot 3 of the saved event.If any step fails, Claude updates §4 of the spec (Appendix A captures the new DOM sample) and re-runs the unit fixture before reporting.
tests/live-profile/.gitkeep(Empty file. The directory holds the user’s authenticated Chrome profile.)
tests/verify-live.spec.ts/* eslint-disable no-console */
import { test, expect, chromium } from '@playwright/test'
import path from 'node:path'
import fs from 'node:fs'
const EXT_PATH = path.resolve('./dist')
const PROFILE_PATH = path.resolve('./tests/live-profile')
const SCREENSHOT_DIR = path.resolve('./tests/live-screenshots')
const TEMPLATE_MARKDOWN = `**Live verification template**
- Agenda
- Notes
`
test.describe('LIVE: real Google Calendar', () => {
test.skip(
!process.env['RUN_LIVE'],
'Set RUN_LIVE=1 to run against real calendar.google.com',
)
test('finds Details, auto-inserts, persists on Save', async () => {
test.setTimeout(180_000) // allow time for manual login
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true })
const ctx = await chromium.launchPersistentContext(PROFILE_PATH, {
headless: false,
channel: 'chromium',
args: [
`--disable-extensions-except=${EXT_PATH}`,
`--load-extension=${EXT_PATH}`,
],
})
// Service worker can take >5s to register in headed mode; allow 30s.
let worker = ctx.serviceWorkers()[0]
if (!worker) worker = await ctx.waitForEvent('serviceworker', { timeout: 30_000 })
const extId = new URL(worker.url()).host
const popup = await ctx.newPage()
await popup.goto(`chrome-extension://${extId}/src/popup/popup.html`)
await popup.evaluate(async (md) => {
await chrome.storage.sync.set({ settings: { template: md, autoInsert: true } })
}, TEMPLATE_MARKDOWN)
await popup.close()
const page = await ctx.newPage()
await page.goto('https://calendar.google.com/calendar/u/0/r')
// Allow up to 2 minutes for the user to log in if needed. The Create button
// appearing is our signal that the calendar app finished loading.
console.log('Waiting for Calendar (log in via the opened window if prompted)…')
await page.locator('[aria-label="Create"]').first().waitFor({ timeout: 120_000 })
await page.locator('[aria-label="Create"]').first().click()
await page.locator('[role="menuitem"]:has-text("Event")').click()
const dialog = page.locator('[role="dialog"]').first()
await dialog.waitFor({ state: 'visible', timeout: 15_000 })
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '1-dialog-open.png') })
// Probe the live DOM with the same locator strategy the extension ships.
const detailsHtml = await page.evaluate(() => {
const dialog = document.querySelector('[role="dialog"]')
if (!dialog) return null
const xDescIn = dialog.querySelector('#xDescIn')
const editor = (xDescIn?.querySelector('[contenteditable="true"]')
?? dialog.querySelector(
'[contenteditable="true"][role="textbox"][aria-label="Add description"]',
)) as HTMLElement | null
return editor?.innerHTML ?? null
})
expect(detailsHtml, 'locator must find the Details contenteditable').not.toBeNull()
expect(detailsHtml!).toContain('<strong>Live verification template</strong>')
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '2-auto-inserted.png') })
// Save the event and reload to confirm the description persisted.
await page.locator('[aria-label*="Save"]').click()
await page.waitForLoadState('networkidle')
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '3-after-save.png') })
console.log(`Screenshots saved to ${SCREENSHOT_DIR}`)
await ctx.close()
})
})
Append to README.md (final form in Task 25 — for now just keep this in mind).
npm run test:live until Task 26.)Files:
Modify: README.md (full rewrite)
Step 1: Replace README.md contents
# Meet Formatter
A Chrome extension (Manifest V3) that lets you define a single Markdown/WYSIWYG template once and have it inserted into the **Details** field of any new Google Calendar event — automatically when the toggle is on, or manually via the popup's **Insert** button.
## Install (development)
1. `npm install`
2. `npm run build`
3. Open `chrome://extensions/`, enable Developer mode, click **Load unpacked**, pick the `dist/` folder.
For HMR-driven development of the popup:
```bash
npm run dev
Then “Load unpacked” the dist/ folder once; subsequent saves hot-reload the popup. (Reload the calendar tab to pick up content-script changes.)
**bold**, _italic_, - bullet, # heading). Use the toolbar for B / I / U / lists / heading; ⌘Z / ⌘⇧Z for undo/redo. Auto-save runs after 400 ms of inactivity.npm test # unit (Vitest + happy-dom)
npm run test:e2e # Playwright against the fake-Calendar fixture (CI-safe)
RUN_LIVE=1 npm run test:live # headed Playwright run against real calendar.google.com
The live test requires a Chrome profile pre-authenticated with Google — it stores the profile under tests/live-profile/ (gitignored). On first run, log in manually in the launched browser, close it, then re-run.
See docs/superpowers/specs/2026-05-20-meet-formatter-v1-design.md for the full design.
See LICENSE.
- [ ] **Step 2: Extend `.gitignore`**
Append to `.gitignore`:
node_modules/ dist/ *.tsbuildinfo
tests/live-profile/* !tests/live-profile/.gitkeep tests/live-screenshots/ playwright-report/ test-results/
- [ ] **Step 3: Checkpoint.**
---
## Task 26: Final verification (full green build)
- [ ] **Step 1: Typecheck**
```bash
npm run typecheck
Expected: zero errors.
eslint.config.js first)Create eslint.config.js:
import tseslint from '@typescript-eslint/eslint-plugin'
import tsparser from '@typescript-eslint/parser'
export default [
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: tsparser,
parserOptions: { project: false, ecmaFeatures: { jsx: true } },
},
plugins: { '@typescript-eslint': tseslint },
rules: {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
},
},
{
ignores: ['dist/**', 'node_modules/**', '.superpowers/**'],
},
]
Run:
npm run lint
Expected: zero errors. Warnings are OK.
npm test
Expected: every spec green.
npm run build
Expected: dist/ populated, build exits 0.
npm run test:e2e
Expected: 7 passing across popup.spec.ts (3) and auto-insert.spec.ts (4).
The executing agent (Claude) runs the live spec interactively:
RUN_LIVE=1 npm run test:live
On first execution: the browser opens to Calendar; if not logged in, the executing agent must pause, log in manually, then re-run. After login, the script:
<strong>Live verification template</strong>.tests/live-screenshots/{1,2,3}.png.The executing agent reads each screenshot via the Read tool and visually confirms:
Only after all three screenshots are read and confirmed visually does the agent report the task complete.
CLAUDE.mdReplace the architecture/run sections in CLAUDE.md to describe the new MV3 + Vite + Preact + Tiptap setup, drop the V2-era gotchas (in-memory background storage, duplicate storage paths, jQuery dead code — none of that exists anymore), and point to this plan and the spec.
Coverage of the spec:
Known compromises:
runCommand instead). E2E covers real keyboard input.calendar.google.com via --host-resolver-rules because the content script match pattern is fixed. This is intentional — we want to exercise the same match pattern as production.verify-live.spec.ts is intentionally gated behind RUN_LIVE=1 so it never runs accidentally in CI.