Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .claude/commands/precommit.md
Original file line number Diff line number Diff line change
@@ -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`
95 changes: 50 additions & 45 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
37 changes: 27 additions & 10 deletions src/entrypoints/background.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -38,12 +38,14 @@ export const openSpots = new Map<string, CommentStorage>()

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,
Expand All @@ -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
}

Expand Down
23 changes: 12 additions & 11 deletions src/entrypoints/content.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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() {
Expand All @@ -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: ['<all_urls>'],
Expand Down
16 changes: 14 additions & 2 deletions src/lib/messages.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<CommentEventType, true>

// 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
)
}
Expand Down
48 changes: 31 additions & 17 deletions src/lib/registries.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -101,26 +107,32 @@ export class EnhancerRegistry {

export class TextareaRegistry {
private textareas = new Map<HTMLTextAreaElement, EnhancedTextarea>()
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<T extends CommentSpot>(textareaInfo: EnhancedTextarea<T>): 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<T extends CommentSpot>(enhanced: EnhancedTextarea<T>): 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)
}
}
Expand All @@ -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)
}
}
}