diff --git a/.claude/commands/precommit.md b/.claude/commands/precommit.md new file mode 100644 index 0000000..f20fadd --- /dev/null +++ b/.claude/commands/precommit.md @@ -0,0 +1,4 @@ +1. run `pnpm precommit` +2. if tests fail, run `pnpm test -u` might fix them +3. fix other problems manually +4. go back to step 1: `pnpm precommit` \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5c6357a..8cc99bf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,23 +16,22 @@ This extension ***must never transmit any data outside the browser***. ## Developer quickstart -### Hotreload development - - `pnpm install` -- `pnpm dev` -- open [`chrome://extensions`](chrome://extensions) -- toggle **Developer mode** (top-right) -- click "Load unpacked" (far left) - - `.output/chrome-mv3-dev` - - if you can't find `.output`, it's probably hidden, `command+shift+period` will show it -- click the puzzle icon next to the url bar, then pin the Gitcasso icon - -### Testing and quality -- `pnpm biome` - runs `biome check` (lint & formatting) -- `pnpm biome:fix` - fixes most of what `biome check` finds -- `pnpm typecheck` - typechecking -- `pnpm test` - vitest -- `pnpm test -u` - updates all snapshots +- to update the popup: + - `pnpm playground` gives react hotreload environment +- to improve comment detection and metadata extraction: + - `pnpm corpus` gives dev environment for gitcasso's behavior on specific pages and page states +- to open a PR: + - `pnpm precommit` fixes formatting and lints (biome), runs typechecking and tests + - no worries if some intermediate commits don't pass +- to run the entire end-to-end browser extension with [WXT](https://wxt.dev/) hotreload: + - `pnpm dev` + - open [`chrome://extensions`](chrome://extensions) + - toggle **Developer mode** (top-right) + - click "Load unpacked" (far left) + - `.output/chrome-mv3-dev` + - if you can't find `.output`, it's probably hidden, `command+shift+period` will show it + - click the puzzle icon next to the url bar, then pin the Gitcasso icon ## How it works @@ -76,46 +75,52 @@ Those `Spot` values get bundled up with the `HTMLTextAreaElement` itself into an When the `textarea` gets removed from the page, the `TextareaRegistry` is notified so that the `CommentSpot` can be marked as abandoned or submitted as appropriate. -## Testing +## Test corpus -- `pnpm playground` gives you a test environment where you can tinker with the popup with various test data, supports hot reload -- `pnpm corpus` gives you recordings of various web pages which you can see with and without enhancement by the browser extension +We maintain a corpus of test pages in two formats for testing the browser extension: +- `html` created by the [SingleFile](https://chromewebstore.google.com/detail/singlefile/mpiodijhokgodhhofbcjdecpffjipkle) browser extension + - allows snapshotting the DOM at an intermediate step in the user's experience + - most interactivity will be broken +- `har` recording of all network traffic of an initial pageload + - limited to initial page load, but most interactivity will work -### Test Corpus +This corpus of pages is used to power snapshot tests and interactive dev environments. -We maintain a corpus of test pages in two formats for testing the browser extension: +### Viewing corpus -#### HAR Corpus (Automated) +- **DISABLE GITCASSO IN YOUR BROWSER!!** +- Run `pnpm corpus` to start the test server at http://localhost:3001 +- Select any corpus file to view in two modes: + - **Clean**: Original unaltered page + - **Gitcasso**: Page with extension injected for testing + - it shows all data Gitcasso is extracting (if any) + - click the rebuild button to test changes to the Gitcasso source -- For testing initial page loads and network requests -- HAR recordings live in `tests/corpus/*.har`, complete recordings of the network requests of a single page load -- You can add or change URLs in `tests/corpus/_corpus-index.ts` -- **Recording new HAR files:** - - `npx playwright codegen https://github.com/login --save-storage=playwright/.auth/gh.json` will store new auth tokens - - login manually, then close the browser - - ***these cookies are very sensitive! we only run this script using a test account that has no permissions or memberships to anything, recommend you do the same!*** - - `pnpm corpus:har:record` records new HAR files using those auth tokens (it needs args, run it with no args for docs) - - DO NOT COMMIT AND PUSH NEW OR CHANGED HAR files! - - we try to sanitize these (see `corpus-har-record.ts` for details) but there may be important PII in them - - if you need new HAR files for something, let us know and we will generate them ourselves using a dummy account - - IF YOUR PR CHANGES OR ADDS HAR FILES WE WILL CLOSE IT. Ask for HAR files and we'll be happy to generate clean ones you can test against. +### Unit testing against corpus + +- `gh-detection.test.ts` does snapshot testing on the data which gitcasso extracts +- `gh-ui.test.ts` does snapshot testing on the popup table decoration for all data extracted from the snapshots -#### HTML Corpus (Manual) +### Adding HTML to the corpus (Manual) - For testing post-interaction states (e.g., expanded textareas, modal dialogs, dynamic content) -- HTML snapshots live in `tests/corpus/*.html`, manually captured using SingleFile browser extension -- All assets are inlined in a single HTML file by SingleFile -- **Creating new HTML corpus files:** +- HTML snapshots live in `tests/corpus/*.html`, manually captured using SingleFile +- how-to 1. Navigate to the desired page state (click buttons, expand textareas, etc.) 2. Use SingleFile browser extension to save the complete page - 3. Save the `.html` file to `tests/corpus/html/` with a descriptive name + 3. Save the `.html` file to `tests/corpus/` with a descriptive slug 4. Add an entry to `tests/corpus/_corpus-index.ts` with `type: 'html'` and a description of the captured state 5. Feel free to contribute these if you want, but be mindful that they will be part of our immutable git history -#### Viewing Corpus Files +### Adding HAR to the corpus (Automated) -- Run `pnpm corpus` to start the test server at http://localhost:3001 -- Select any corpus file to view in two modes: - - **Clean**: Original page without extension - - **Gitcasso**: Page with extension injected for testing -- Both HAR and HTML corpus types are supported +- For testing initial page loads and network requests +- HAR recordings live in `tests/corpus/*.har`, complete recordings of the network requests of a single page load +- how-to + - update `tests/corpus/_corpus-index.ts` with a descriptive slug and URL which you want to add + - `npx playwright codegen https://github.com/login --save-storage=playwright/.auth/gh.json` will store new auth tokens + - login manually, then close the browser + - ***these cookies are very sensitive! we only run this script using a test account that has no permissions or memberships to anything, recommend you do the same!*** + - `pnpm corpus:har:record {slug}` records new HAR files using those auth tokens (it needs args, run it with no args for docs) + - **CONTRIBUTING THESE IS RISKY!** + - we try to sanitize these (see `corpus-har-record.ts` for details) but there may be important PII in them, which is why we only use a test account diff --git a/package.json b/package.json index 2623443..6955aeb 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "build": "pnpm run build:overtype && wxt build", "build:dev": "pnpm run build:overtype && wxt build --mode development", "build:firefox": "wxt build -b firefox", - "precommit": "npm run biome:fix && npm run typecheck && npm run test", + "precommit": "pnpm biome:fix && pnpm typecheck && pnpm test", "typecheck": "tsc --noEmit", "dev": "wxt", "dev:firefox": "wxt -b firefox", diff --git a/src/entrypoints/background.ts b/src/entrypoints/background.ts index d005150..e9e4812 100644 --- a/src/entrypoints/background.ts +++ b/src/entrypoints/background.ts @@ -1,4 +1,4 @@ -import type { CommentEvent, CommentSpot } from '@/lib/enhancer' +import type { CommentEvent, CommentEventType, CommentSpot } from '@/lib/enhancer' import { type DraftStats, statsFor } from '@/lib/enhancers/draft-stats' import { logger } from '@/lib/logger' import type { GetTableRowsResponse, ToBackgroundMessage } from '@/lib/messages' @@ -38,12 +38,14 @@ export const openSpots = new Map() export function handleCommentEvent(message: CommentEvent, sender: any): boolean { logger.debug('received comment event', message) - if ( - (message.type === 'ENHANCED' || message.type === 'DESTROYED') && - sender.tab?.id && - sender.tab?.windowId - ) { - if (message.type === 'ENHANCED') { + + // Only process events with valid tab information + if (!sender.tab?.id || !sender.tab?.windowId) { + return CLOSE_MESSAGE_PORT + } + + switch (message.type) { + case 'ENHANCED': { const commentState: CommentStorage = { drafts: [[Date.now(), message.draft || '']], sentOn: null, @@ -55,12 +57,27 @@ export function handleCommentEvent(message: CommentEvent, sender: any): boolean trashedOn: null, } openSpots.set(message.spot.unique_key, commentState) - } else if (message.type === 'DESTROYED') { + break + } + case 'DESTROYED': { openSpots.delete(message.spot.unique_key) - } else { - throw new Error(`Unhandled comment event type: ${message.type}`) + break + } + case 'LOST_FOCUS': { + // Update the draft content for existing comment state + const existingState = openSpots.get(message.spot.unique_key) + if (existingState) { + existingState.drafts.push([Date.now(), message.draft || '']) + } + break + } + default: { + // TypeScript exhaustiveness check - will error if we miss any CommentEventType + const exhaustiveCheck: never = message.type satisfies CommentEventType + throw new Error(`Unhandled comment event type: ${exhaustiveCheck}`) } } + return CLOSE_MESSAGE_PORT } diff --git a/src/entrypoints/content.ts b/src/entrypoints/content.ts index 5dc5122..903f4b5 100644 --- a/src/entrypoints/content.ts +++ b/src/entrypoints/content.ts @@ -1,4 +1,4 @@ -import type { CommentEvent, CommentSpot, StrippedLocation } from '../lib/enhancer' +import type { CommentEvent, StrippedLocation } from '../lib/enhancer' import { logger } from '../lib/logger' import { EnhancerRegistry, TextareaRegistry } from '../lib/registries' @@ -20,20 +20,13 @@ function detectLocation(): StrippedLocation { return result } -function sendEventToBackground(type: 'ENHANCED' | 'DESTROYED', spot: CommentSpot): void { - const message: CommentEvent = { - spot, - type, - } +function sendEventToBackground(message: CommentEvent): void { browser.runtime.sendMessage(message).catch((error) => { - logger.debug('Failed to send event to background:', error) + logger.error('Failed to send event to background:', error) }) } -enhancedTextareas.setEventHandlers( - (spot) => sendEventToBackground('ENHANCED', spot), - (spot) => sendEventToBackground('DESTROYED', spot), -) +enhancedTextareas.setCommentEventSender(sendEventToBackground) export default defineContentScript({ main() { @@ -47,6 +40,14 @@ export default defineContentScript({ childList: true, subtree: true, }) + + // Listen for tab visibility changes to capture draft content when switching tabs + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + enhancedTextareas.tabLostFocus() + } + }) + logger.debug('Extension loaded with', enhancers.getEnhancerCount(), 'handlers') }, matches: [''], diff --git a/src/lib/messages.ts b/src/lib/messages.ts index 09d0298..da7fa54 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -1,5 +1,5 @@ import type { CommentTableRow } from '@/entrypoints/background' -import type { CommentEvent } from './enhancer' +import type { CommentEvent, CommentEventType } from './enhancer' // Message handler response types export const CLOSE_MESSAGE_PORT = false as const // No response will be sent @@ -29,12 +29,24 @@ export interface GetTableRowsResponse { rows: CommentTableRow[] } +// Exhaustive list of valid comment event types - TypeScript will error if CommentEventType changes +const COMMENT_EVENT_TYPES = { + DESTROYED: true, + ENHANCED: true, + LOST_FOCUS: true, +} as const satisfies Record + +// Helper function to check if a string is a valid CommentEventType +function isValidCommentEventType(type: string): type is CommentEventType { + return type in COMMENT_EVENT_TYPES +} + // Type guard functions export function isContentToBackgroundMessage(message: any): message is ContentToBackgroundMessage { return ( message && typeof message.type === 'string' && - (message.type === 'ENHANCED' || message.type === 'DESTROYED') && + isValidCommentEventType(message.type) && message.spot ) } diff --git a/src/lib/registries.ts b/src/lib/registries.ts index 859b453..eb12d96 100644 --- a/src/lib/registries.ts +++ b/src/lib/registries.ts @@ -1,6 +1,12 @@ import type { OverTypeInstance } from 'overtype' import OverType from 'overtype' -import type { CommentEnhancer, CommentSpot, StrippedLocation } from './enhancer' +import type { + CommentEnhancer, + CommentEvent, + CommentEventType, + CommentSpot, + StrippedLocation, +} from './enhancer' import { CommentEnhancerMissing } from './enhancers/CommentEnhancerMissing' import { GitHubEditEnhancer } from './enhancers/github/GitHubEditEnhancer' import { GitHubIssueAppendEnhancer } from './enhancers/github/GitHubIssueAppendEnhancer' @@ -101,26 +107,32 @@ export class EnhancerRegistry { export class TextareaRegistry { private textareas = new Map() - private onEnhanced?: (spot: CommentSpot) => void - private onDestroyed?: (spot: CommentSpot) => void + private eventSender: (event: CommentEvent) => void = () => {} - setEventHandlers( - onEnhanced: (spot: CommentSpot) => void, - onDestroyed: (spot: CommentSpot) => void, - ): void { - this.onEnhanced = onEnhanced - this.onDestroyed = onDestroyed + setCommentEventSender(sendEvent: (event: CommentEvent) => void): void { + this.eventSender = sendEvent } - register(textareaInfo: EnhancedTextarea): void { - this.textareas.set(textareaInfo.textarea, textareaInfo) - this.onEnhanced?.(textareaInfo.spot) + private sendEvent(eventType: CommentEventType, enhanced: EnhancedTextarea): void { + this.eventSender({ + draft: enhanced.textarea.value, + spot: enhanced.spot, + type: eventType, + }) + } + + register(enhanced: EnhancedTextarea): void { + this.textareas.set(enhanced.textarea, enhanced) + enhanced.textarea.addEventListener('blur', () => { + this.sendEvent('LOST_FOCUS', enhanced) + }) + this.sendEvent('ENHANCED', enhanced) } unregisterDueToModification(textarea: HTMLTextAreaElement): void { - const textareaInfo = this.textareas.get(textarea) - if (textareaInfo) { - this.onDestroyed?.(textareaInfo.spot) + const enhanced = this.textareas.get(textarea) + if (enhanced) { + this.sendEvent('DESTROYED', enhanced) this.textareas.delete(textarea) } } @@ -129,7 +141,9 @@ export class TextareaRegistry { return this.textareas.get(textarea) } - getAllEnhanced(): EnhancedTextarea[] { - return Array.from(this.textareas.values()) + tabLostFocus(): void { + for (const enhanced of this.textareas.values()) { + this.sendEvent('LOST_FOCUS', enhanced) + } } }