diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 84bc682..08bba9f 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,6 +1,7 @@ {"id":"light-session-16d","title":"Implement code review recommendations","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-08T15:51:21.70403199+03:00","updated_at":"2026-01-08T15:54:39.953267761+03:00","closed_at":"2026-01-08T15:54:39.953267761+03:00","close_reason":"Closed"} {"id":"light-session-2hd","title":"Test cache + navigation trigger for load more feature","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-09T00:46:45.660702419+03:00","updated_at":"2026-01-09T10:45:29.927755814+03:00","closed_at":"2026-01-09T10:45:29.927759601+03:00"} {"id":"light-session-4ec","title":"Fix off-by-one error in turn counting - trimMapping","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-07T21:21:25.453075056+03:00","updated_at":"2026-01-07T21:23:59.887784624+03:00","closed_at":"2026-01-07T21:23:59.887784624+03:00","close_reason":"Closed"} +{"id":"light-session-67h","title":"Bug: trimming not working on page reload with extension enabled","description":"Settings show keep=5 but more messages visible. Race condition or localStorage sync issue.","status":"in_progress","priority":1,"issue_type":"task","created_at":"2026-01-09T22:39:11.800084105+03:00","created_by":"mayor","updated_at":"2026-01-09T22:40:24.864320247+03:00"} {"id":"light-session-6sc","title":"Implement code review improvements","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-07T22:59:50.319980052+03:00","updated_at":"2026-01-07T23:06:39.948042561+03:00","closed_at":"2026-01-07T23:06:39.948042561+03:00","close_reason":"Closed"} {"id":"light-session-7sq","title":"Rename 'turns' to 'messages' in UI","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-07T21:50:55.111515332+03:00","updated_at":"2026-01-07T21:53:42.151552035+03:00","closed_at":"2026-01-07T21:53:42.151552035+03:00","close_reason":"Closed"} {"id":"light-session-8bq","title":"Fix 'Body has already been consumed' error in fetch interceptor","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-07T22:06:28.74686929+03:00","updated_at":"2026-01-07T22:07:29.911516018+03:00","closed_at":"2026-01-07T22:07:29.911516018+03:00","close_reason":"Fixed by extracting URL/method before nativeFetch"} @@ -9,3 +10,4 @@ {"id":"light-session-hqc","title":"Fix Firefox Xray vision bug - cloneInto for CustomEvent detail","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-07T21:09:56.751185775+03:00","updated_at":"2026-01-07T21:12:56.968676465+03:00","closed_at":"2026-01-07T21:12:56.968676465+03:00","close_reason":"Closed"} {"id":"light-session-kf1","title":"Fix ESLint error for cloneInto Firefox API","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-08T19:23:38.808971016+03:00","updated_at":"2026-01-08T19:24:30.315635209+03:00","closed_at":"2026-01-08T19:24:30.315635209+03:00","close_reason":"Closed"} {"id":"light-session-oho","title":"Fix: empty user nodes counted but not rendered","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-07T22:21:42.141200219+03:00","updated_at":"2026-01-07T22:44:23.70359928+03:00","closed_at":"2026-01-07T22:44:23.70359928+03:00","close_reason":"Fixed: preserve original root node as tree anchor for ChatGPT"} +{"id":"light-session-q3p","title":"Fix race condition: sync settings to localStorage for page-script","status":"in_progress","priority":1,"issue_type":"task","created_at":"2026-01-09T22:28:10.93947054+03:00","created_by":"mayor","updated_at":"2026-01-09T22:28:17.158771046+03:00"} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 516dbeb..183a66e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,23 +34,45 @@ jobs: - name: Type check run: npm run build:types - - name: Build - run: npm run build - - name: Get version from tag id: version run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT - - name: Create extension zip + # ========================================================================= + # Firefox Build + # ========================================================================= + - name: Build Firefox extension + run: npm run build:prod:firefox + + - name: Create Firefox extension zip run: | cd extension - zip -r ../light-session-${{ steps.version.outputs.VERSION }}.zip \ + zip -r ../light-session-${{ steps.version.outputs.VERSION }}-firefox.zip \ manifest.json \ dist/ \ popup/ \ icons/ \ -x "*.map" + # ========================================================================= + # Chrome Build + # ========================================================================= + - name: Build Chrome extension + run: npm run build:prod:chrome + + - name: Create Chrome extension zip + run: | + cd extension + zip -r ../light-session-${{ steps.version.outputs.VERSION }}-chrome.zip \ + manifest.json \ + dist/ \ + popup/ \ + icons/ \ + -x "*.map" + + # ========================================================================= + # Source Archive + # ========================================================================= - name: Create source zip run: | zip -r light-session-${{ steps.version.outputs.VERSION }}-source.zip \ @@ -68,15 +90,20 @@ jobs: docs/ \ extension/src/ \ extension/icons/ \ - extension/manifest.json \ + extension/manifest.firefox.json \ + extension/manifest.chrome.json \ tests/ + # ========================================================================= + # GitHub Release + # ========================================================================= - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: generate_release_notes: true files: | - light-session-${{ steps.version.outputs.VERSION }}.zip + light-session-${{ steps.version.outputs.VERSION }}-firefox.zip + light-session-${{ steps.version.outputs.VERSION }}-chrome.zip light-session-${{ steps.version.outputs.VERSION }}-source.zip body: | ## LightSession v${{ steps.version.outputs.VERSION }} @@ -86,19 +113,43 @@ jobs: **Firefox Add-ons (recommended):** [Install from AMO](https://addons.mozilla.org/en-US/firefox/addon/lightsession-for-chatgpt/) - **Manual install:** - 1. Download `light-session-${{ steps.version.outputs.VERSION }}.zip` + **Chrome Web Store:** + [Install from Chrome Web Store](https://chrome.google.com/webstore/detail/lightsession-for-chatgpt/${{ secrets.CHROME_EXTENSION_ID }}) + + **Manual install (Firefox):** + 1. Download `light-session-${{ steps.version.outputs.VERSION }}-firefox.zip` 2. Open `about:debugging#/runtime/this-firefox` in Firefox 3. Click "Load Temporary Add-on" 4. Select the downloaded zip file + **Manual install (Chrome):** + 1. Download `light-session-${{ steps.version.outputs.VERSION }}-chrome.zip` + 2. Open `chrome://extensions` in Chrome + 3. Enable "Developer mode" + 4. Click "Load unpacked" and select the extracted folder + --- + # ========================================================================= + # Firefox Add-ons Publishing + # ========================================================================= - name: Publish to Firefox Add-ons uses: browser-actions/release-firefox-addon@latest with: addon-id: ${{ secrets.FIREFOX_ADDON_ID }} - addon-path: light-session-${{ steps.version.outputs.VERSION }}.zip + addon-path: light-session-${{ steps.version.outputs.VERSION }}-firefox.zip auth-api-issuer: ${{ secrets.FIREFOX_API_ISSUER }} auth-api-secret: ${{ secrets.FIREFOX_API_SECRET }} release-note: "See release notes at https://github.com/11me/light-session/releases/tag/v${{ steps.version.outputs.VERSION }}" + + # ========================================================================= + # Chrome Web Store Publishing + # ========================================================================= + - name: Publish to Chrome Web Store + uses: browser-actions/release-chrome-extension@latest + with: + extension-id: ${{ secrets.CHROME_EXTENSION_ID }} + extension-path: light-session-${{ steps.version.outputs.VERSION }}-chrome.zip + oauth-client-id: ${{ secrets.CHROME_CLIENT_ID }} + oauth-client-secret: ${{ secrets.CHROME_CLIENT_SECRET }} + oauth-refresh-token: ${{ secrets.CHROME_REFRESH_TOKEN }} diff --git a/.gitignore b/.gitignore index d9e83df..c123a67 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ extension/.dev *.tmp *.temp .serena/ +extension/manifest.json diff --git a/CLAUDE.md b/CLAUDE.md index dd4357f..74bd8be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,19 +5,22 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Build & Test Commands ```bash -npm install # Install dependencies -npm run build # Build extension (esbuild) -npm run dev # Run in Firefox Developer Edition with auto-reload -npm run test # Run unit tests (vitest) -npm run test:watch # Run tests in watch mode -npm run lint # ESLint check -npm run lint:fix # ESLint autofix -npm run build:types # TypeScript type check (no emit) +npm install # Install dependencies +npm run build # Build for Firefox (default) +npm run build:firefox # Build for Firefox +npm run build:chrome # Build for Chrome +npm run dev # Run in Firefox Developer Edition +npm run watch:chrome # Watch mode for Chrome development +npm run test # Run unit tests (vitest) +npm run lint # ESLint check +npm run build:types # TypeScript type check +npm run package # Package for Firefox (web-ext-artifacts/) +npm run package:chrome # Package for Chrome (ZIP) ``` ## Architecture -**Firefox extension (Manifest V3)** that uses Fetch Proxy to trim ChatGPT conversations before React renders. +**Cross-browser extension (Manifest V3)** for Firefox and Chrome that uses Fetch Proxy to trim ChatGPT conversations before React renders. ### Core Components @@ -48,7 +51,7 @@ content.ts → dispatches settings via CustomEvent → receives status updates ### Message-Based Counting ChatGPT creates multiple nodes per assistant response (especially with Extended Thinking). -LightSession counts **messages** (role changes) instead of nodes: +LightSession Pro counts **messages** (role changes) instead of nodes: - `[user, assistant, assistant, user, assistant]` = 4 messages - Consecutive same-role nodes are aggregated as ONE message - HIDDEN_ROLES: `system`, `tool`, `thinking` excluded from count @@ -56,14 +59,18 @@ LightSession counts **messages** (role changes) instead of nodes: ## Project Structure ``` -extension/src/ -├── page/ # Page script (Fetch Proxy, runs in page context) -├── content/ # Content scripts (settings, status bar) -├── background/ # Background service worker -├── popup/ # Popup HTML/CSS/TS -└── shared/ # Types, constants, storage, logger -tests/ # Unit tests (vitest + happy-dom) -build.cjs # esbuild build script (CommonJS) +extension/ +├── manifest.json # Symlink → manifest.firefox.json (or chrome copy) +├── manifest.firefox.json # Firefox-specific manifest +├── manifest.chrome.json # Chrome-specific manifest +└── src/ + ├── page/ # Page script (Fetch Proxy, runs in page context) + ├── content/ # Content scripts (settings, status bar) + ├── background/ # Background service worker + ├── popup/ # Popup HTML/CSS/TS + └── shared/ # Types, constants, storage, logger +tests/ # Unit tests (vitest + happy-dom) +build.cjs # esbuild build script (supports --target=firefox|chrome) ``` ## Conventions diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..366660b --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,46 @@ +# Privacy Policy + +**LightSession for ChatGPT** is a privacy-first browser extension. This policy explains what data the extension accesses and how it is handled. + +## Data Collection + +**We do not collect any data.** + +LightSession operates entirely locally in your browser. It does not: + +- Collect personal information +- Track your browsing activity +- Send data to external servers +- Use analytics or telemetry +- Store conversation content + +## Permissions Explained + +| Permission | Purpose | +|------------|---------| +| `storage` | Saves your preferences (message limit, UI settings) locally in your browser | +| `tabs` | Detects when you navigate to ChatGPT to apply settings | +| Host permissions (`chatgpt.com`, `chat.openai.com`) | Required to inject the performance optimization script on ChatGPT pages | + +## How It Works + +LightSession intercepts ChatGPT's API responses **locally in your browser** and trims the conversation data before React renders it. This keeps the UI fast without modifying your actual conversation on OpenAI's servers. + +All processing happens entirely within your browser. No data ever leaves your device. + +## Third Parties + +This extension does not share any data with third parties because it does not collect any data. + +## Open Source + +LightSession is open source. You can review the code at: +https://github.com/11me/light-session + +## Contact + +For privacy questions, open an issue on GitHub. + +--- + +*Last updated: January 2026* diff --git a/build.cjs b/build.cjs index eb4b7be..4678b92 100755 --- a/build.cjs +++ b/build.cjs @@ -1,12 +1,14 @@ #!/usr/bin/env node /** - * Build script for LightSession extension + * Build script for LightSession Pro extension * Bundles TypeScript → single JS files (no imports) for MV3 compatibility * * Usage: - * node build.js - Development build (with sourcemaps) - * node build.js --watch - Watch mode for development - * NODE_ENV=production node build.js - Production build (minified, no sourcemaps) + * node build.cjs - Development build for Firefox (default) + * node build.cjs --target=firefox - Build for Firefox + * node build.cjs --target=chrome - Build for Chrome + * node build.cjs --watch - Watch mode for development + * NODE_ENV=production node build.cjs - Production build (minified, no sourcemaps) */ const esbuild = require('esbuild'); @@ -16,6 +18,38 @@ const path = require('path'); const isWatch = process.argv.includes('--watch'); const isProduction = process.env.NODE_ENV === 'production'; +// Parse --target=firefox|chrome (default: firefox) +const targetArg = process.argv.find((arg) => arg.startsWith('--target=')); +const target = targetArg ? targetArg.split('=')[1] : 'firefox'; +const validTargets = ['firefox', 'chrome']; +if (!validTargets.includes(target)) { + console.error(`❌ Invalid target: ${target}. Use: ${validTargets.join(', ')}`); + process.exit(1); +} + +/** + * Copy manifest for target browser + */ +function copyManifest() { + const manifestSrc = `extension/manifest.${target}.json`; + const manifestDest = 'extension/manifest.json'; + + // Always remove existing manifest.json first + if (fs.existsSync(manifestDest)) { + fs.unlinkSync(manifestDest); + } + + if (target === 'chrome') { + // For Chrome, copy manifest.chrome.json + fs.copyFileSync(manifestSrc, manifestDest); + console.log(`✓ Copied manifest.${target}.json → manifest.json`); + } else { + // For Firefox, create symlink to manifest.firefox.json + fs.symlinkSync('manifest.firefox.json', manifestDest); + console.log('✓ Created symlink manifest.json → manifest.firefox.json'); + } +} + /** * Copy static files from src to extension folder */ @@ -33,6 +67,26 @@ function copyStaticFiles() { console.log('✓ Copied static files (popup.html, popup.css)'); } +/** + * Create or remove .dev marker file for development mode detection. + * The popup checks for this file to show/hide debug options. + */ +function handleDevMarker() { + const devMarkerPath = 'extension/.dev'; + + if (isProduction) { + // Remove .dev marker in production + if (fs.existsSync(devMarkerPath)) { + fs.unlinkSync(devMarkerPath); + console.log('✓ Removed .dev marker (production build)'); + } + } else { + // Create .dev marker in development + fs.writeFileSync(devMarkerPath, 'Development build marker\n'); + console.log('✓ Created .dev marker (development build)'); + } +} + const buildOptions = { bundle: true, format: 'iife', @@ -52,7 +106,7 @@ const buildOptions = { async function build() { const mode = isProduction ? 'production' : 'development'; - console.log(`🔧 Building in ${mode} mode${isProduction ? ' (minified)' : ' (with sourcemaps)'}...\n`); + console.log(`🔧 Building for ${target.toUpperCase()} in ${mode} mode${isProduction ? ' (minified)' : ' (with sourcemaps)'}...\n`); try { await esbuild.build({ @@ -91,8 +145,10 @@ async function build() { console.log('✓ Built popup script'); copyStaticFiles(); + copyManifest(); + handleDevMarker(); - console.log(`\n✅ ${mode.charAt(0).toUpperCase() + mode.slice(1)} build complete! Extension ready for Firefox.`); + console.log(`\n✅ ${mode.charAt(0).toUpperCase() + mode.slice(1)} build complete! Extension ready for ${target.charAt(0).toUpperCase() + target.slice(1)}.`); } catch (error) { console.error('❌ Build failed:', error); process.exit(1); @@ -100,7 +156,7 @@ async function build() { } async function watch() { - console.log('👀 Watch mode enabled. Watching for changes...\n'); + console.log(`👀 Watch mode enabled for ${target.toUpperCase()}. Watching for changes...\n`); const contexts = await Promise.all([ esbuild.context({ @@ -135,7 +191,9 @@ async function watch() { await ctx.rebuild(); } copyStaticFiles(); - console.log('✅ Initial build complete.\n'); + copyManifest(); + handleDevMarker(); + console.log(`✅ Initial build complete for ${target.toUpperCase()}.\n`); // Start watching for (const ctx of contexts) { diff --git a/docs/development.md b/docs/development.md index 6072909..e6953e2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -277,3 +277,65 @@ The extension uses a tiered selector approach: - Check `showStatusBar` setting is enabled - Verify content script is running (check console) - Inspect DOM for `#lightsession-status-bar` element + +## Release Process + +Releases are automated via GitHub Actions when a tag is pushed: + +```bash +# Create and push a release tag +git tag v1.2.3 +git push origin v1.2.3 +``` + +The workflow builds both Firefox and Chrome versions, creates a GitHub Release, +and publishes to both browser stores. + +### Required GitHub Secrets + +The following secrets must be configured in the repository settings: + +#### Firefox Add-ons + +| Secret | Description | +|--------|-------------| +| `FIREFOX_ADDON_ID` | AMO extension ID (e.g., `lightsession@example.com`) | +| `FIREFOX_API_ISSUER` | JWT issuer from AMO API credentials | +| `FIREFOX_API_SECRET` | JWT secret from AMO API credentials | + +#### Chrome Web Store + +| Secret | Description | +|--------|-------------| +| `CHROME_EXTENSION_ID` | Chrome extension ID (32-char alphanumeric) | +| `CHROME_CLIENT_ID` | OAuth 2.0 client ID from Google Cloud Console | +| `CHROME_CLIENT_SECRET` | OAuth 2.0 client secret | +| `CHROME_REFRESH_TOKEN` | OAuth 2.0 refresh token for Chrome Web Store API | + +### Getting Chrome Web Store Credentials + +1. **Create a Google Cloud Project** at [console.cloud.google.com](https://console.cloud.google.com) +2. **Enable the Chrome Web Store API** in the API Library +3. **Create OAuth 2.0 credentials** (Desktop application type) +4. **Get a refresh token** using the OAuth 2.0 flow: + - Use the client ID/secret to authorize + - Request scope: `https://www.googleapis.com/auth/chromewebstore` + - Exchange the authorization code for a refresh token + +For detailed instructions, see the [Chrome Web Store API documentation](https://developer.chrome.com/docs/webstore/using_webstore_api/). + +### Multi-Browser Build + +```bash +# Build for Firefox +npm run build:prod:firefox + +# Build for Chrome +npm run build:prod:chrome + +# Build both (development) +npm run build:firefox && npm run build:chrome +``` + +The build system automatically switches between `manifest.firefox.json` and +`manifest.chrome.json` based on the target. diff --git a/extension/manifest.chrome.json b/extension/manifest.chrome.json new file mode 100644 index 0000000..53f7a63 --- /dev/null +++ b/extension/manifest.chrome.json @@ -0,0 +1,48 @@ +{ + "manifest_version": 3, + "name": "LightSession Pro", + "version": "1.6.1", + "description": "Keep ChatGPT fast by keeping only the last N messages in the DOM. Local-only.", + "icons": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + }, + "action": { + "default_title": "LightSession Pro", + "default_popup": "popup/popup.html" + }, + "permissions": ["storage", "tabs"], + "host_permissions": [ + "*://chat.openai.com/*", + "*://chatgpt.com/*" + ], + "content_scripts": [ + { + "matches": [ + "*://chat.openai.com/*", + "*://chatgpt.com/*" + ], + "js": ["dist/page-inject.js"], + "run_at": "document_start" + }, + { + "matches": [ + "*://chat.openai.com/*", + "*://chatgpt.com/*" + ], + "js": ["dist/content.js"], + "run_at": "document_idle" + } + ], + "background": { + "service_worker": "dist/background.js" + }, + "web_accessible_resources": [ + { + "resources": ["dist/page-script.js", ".dev"], + "matches": ["*://chat.openai.com/*", "*://chatgpt.com/*"] + } + ] +} diff --git a/extension/manifest.json b/extension/manifest.firefox.json similarity index 82% rename from extension/manifest.json rename to extension/manifest.firefox.json index a1f27a0..7881096 100644 --- a/extension/manifest.json +++ b/extension/manifest.firefox.json @@ -1,6 +1,6 @@ { "manifest_version": 3, - "name": "LightSession for ChatGPT", + "name": "LightSession Pro", "version": "1.6.1", "description": "Keep ChatGPT fast by keeping only the last N messages in the DOM. Local-only.", "icons": { @@ -10,10 +10,10 @@ "128": "icons/icon-128.png" }, "action": { - "default_title": "LightSession", + "default_title": "LightSession Pro", "default_popup": "popup/popup.html" }, - "permissions": ["storage"], + "permissions": ["storage", "tabs"], "host_permissions": [ "*://chat.openai.com/*", "*://chatgpt.com/*" @@ -37,9 +37,7 @@ } ], "background": { - "scripts": ["dist/background.js"], - "service_worker": "dist/background.js", - "preferred_environment": ["service_worker", "document"] + "scripts": ["dist/background.js"] }, "web_accessible_resources": [ { diff --git a/extension/popup/popup.html b/extension/popup/popup.html index 7447ab3..8e3f029 100644 --- a/extension/popup/popup.html +++ b/extension/popup/popup.html @@ -3,7 +3,7 @@ - LightSession Settings + LightSession Pro @@ -12,7 +12,7 @@
diff --git a/extension/src/background/background.ts b/extension/src/background/background.ts index 6c782a5..1e1a991 100644 --- a/extension/src/background/background.ts +++ b/extension/src/background/background.ts @@ -1,9 +1,9 @@ /** - * LightSession for ChatGPT - Background Script + * LightSession Pro - Background Script * Manages settings and routes messages between content and popup scripts */ -import '../shared/browser-polyfill'; +import browser from '../shared/browser-polyfill'; import type { RuntimeMessage, RuntimeResponse } from '../shared/types'; import { initializeSettings, loadSettings, updateSettings } from '../shared/storage'; import { setDebugMode, logDebug, logError } from '../shared/logger'; @@ -73,9 +73,8 @@ browser.storage.onChanged.addListener((changes, areaName) => { }); // Register message listener -// Cast needed to work around Firefox WebExtensions type mismatch -// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any -browser.runtime.onMessage.addListener(messageHandler as any); +// The handler returns true to indicate async response (required for Chrome) +browser.runtime.onMessage.addListener(messageHandler); // Initialize on script load initialize().catch((error) => { diff --git a/extension/src/content/content.ts b/extension/src/content/content.ts index 53315eb..acf1a7c 100644 --- a/extension/src/content/content.ts +++ b/extension/src/content/content.ts @@ -20,16 +20,6 @@ import { setStatusBarVisibility, } from './status-bar'; -// ============================================================================ -// Firefox-specific Global Declarations -// ============================================================================ - -/** - * Firefox's cloneInto() function for Xray vision workaround. - * Clones objects from content script context into page context. - * @see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Sharing_objects_with_page_scripts - */ -declare function cloneInto(obj: T, targetScope: Window): T; // ============================================================================ // Types for Page Script Communication @@ -71,10 +61,14 @@ let proxyReady = false; * Dispatch configuration to the page script via CustomEvent. * The page script listens for 'lightsession-config' events. * - * In Firefox, content scripts run in an isolated sandbox. Objects created - * in this sandbox are not accessible from page context due to Xray vision. - * We use cloneInto() to clone the config object into the page context, - * making it accessible to the page script. + * Cross-browser compatibility: + * - Firefox: Content scripts run in an isolated sandbox (Xray vision). + * We use cloneInto() to clone objects into page context. + * - Chrome: Content scripts run in "isolated worlds". Objects passed via + * CustomEvent.detail may not be accessible to page scripts reliably. + * + * Solution: Always serialize config to JSON string. This works in both browsers + * and avoids issues with object cloning across isolation boundaries. * * @see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Sharing_objects_with_page_scripts */ @@ -85,12 +79,12 @@ function dispatchConfig(settings: LsSettings): void { debug: settings.debug, }; - // Clone config into page context for Firefox (Xray vision workaround) - // cloneInto is a Firefox-specific API, check for availability - const detail = - typeof cloneInto === 'function' ? cloneInto(config, window) : config; + // Serialize to JSON string for cross-browser compatibility + // Chrome's isolated worlds don't reliably pass objects via CustomEvent.detail + // JSON string is safely passed as a primitive + const jsonString = JSON.stringify(config); - window.dispatchEvent(new CustomEvent('lightsession-config', { detail })); + window.dispatchEvent(new CustomEvent('lightsession-config', { detail: jsonString })); logDebug('Dispatched config to page script:', config); } diff --git a/extension/src/content/page-inject.ts b/extension/src/content/page-inject.ts index 8599877..c06c735 100644 --- a/extension/src/content/page-inject.ts +++ b/extension/src/content/page-inject.ts @@ -1,31 +1,64 @@ /** * LightSession for ChatGPT - Page Script Injector * - * This content script runs at document_start to inject the page script - * into the page context BEFORE any other scripts run. + * This content script runs at document_start to: + * 1. Sync settings from browser.storage to localStorage (for page-script access) + * 2. Inject the page script into the page context BEFORE any other scripts run * - * This is critical for patching window.fetch before ChatGPT's code uses it. + * This is critical for patching window.fetch before ChatGPT's code uses it, + * and ensures page-script has access to correct settings immediately. */ import browser from '../shared/browser-polyfill'; -(function injectPageScript(): void { - // Create script element pointing to our page script +const STORAGE_KEY = 'ls_settings'; +const LOCAL_STORAGE_KEY = 'ls_config'; + +/** + * Sync settings from browser.storage to localStorage. + * This runs BEFORE page-script injection to ensure localStorage has correct data. + */ +async function syncSettingsToLocalStorage(): Promise { + try { + const result = await browser.storage.local.get(STORAGE_KEY); + const stored = result[STORAGE_KEY] as { enabled?: boolean; keep?: number; debug?: boolean } | undefined; + + if (stored) { + const config = { + enabled: stored.enabled ?? true, + limit: stored.keep ?? 10, + debug: stored.debug ?? false, + }; + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(config)); + } + } catch { + // Storage access failed - page-script will use defaults or existing localStorage + } +} + +/** + * Inject the page script into page context. + */ +function injectPageScript(): void { const script = document.createElement('script'); script.src = browser.runtime.getURL('dist/page-script.js'); - // Insert at the very beginning of document const target = document.head || document.documentElement; target.insertBefore(script, target.firstChild); - // Remove script tag after load (cleanup, keeps DOM tidy) script.onload = (): void => { script.remove(); }; - // Handle load errors (shouldn't happen, but good to log) script.onerror = (): void => { console.error('[LightSession] Failed to load page script'); script.remove(); }; -})(); +} + +// Main execution: +// 1. Start syncing settings (async, but fast) +// 2. Inject page script immediately (can't wait - need to patch fetch early) +// The sync will complete and update localStorage, which page-script checks on each fetch. +void syncSettingsToLocalStorage(); +injectPageScript(); diff --git a/extension/src/page/page-script.ts b/extension/src/page/page-script.ts index 0a63833..f7c129a 100644 --- a/extension/src/page/page-script.ts +++ b/extension/src/page/page-script.ts @@ -48,6 +48,33 @@ const DEFAULT_CONFIG: LsConfig = { debug: false, }; +/** + * localStorage key - must match storage.ts LOCAL_STORAGE_KEY + */ +const LOCAL_STORAGE_KEY = 'ls_config'; + +/** + * Load config from localStorage (synced by content script). + * This eliminates race conditions where fetch happens before + * content script can send config via CustomEvent. + */ +function loadFromLocalStorage(): LsConfig | null { + try { + const stored = localStorage.getItem(LOCAL_STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored) as Partial; + return { + enabled: parsed.enabled ?? DEFAULT_CONFIG.enabled, + limit: Math.max(1, parsed.limit ?? DEFAULT_CONFIG.limit), + debug: parsed.debug ?? DEFAULT_CONFIG.debug, + }; + } + } catch { + // localStorage unavailable or invalid JSON + } + return null; +} + // ============================================================================ // Logging // ============================================================================ @@ -80,12 +107,26 @@ function dispatchStatus(status: TrimStatus): void { * Get current config (with defaults) */ function getConfig(): LsConfig { + // Always check localStorage first (source of truth, synced by content scripts) + // This ensures we pick up settings even if they were synced after page-script loaded + const stored = loadFromLocalStorage(); + if (stored) { + // Update window cache for consistency + window.__LS_CONFIG__ = stored; + return stored; + } + + // Fall back to window config (set by content script events) const cfg = window.__LS_CONFIG__; - return { - enabled: cfg?.enabled ?? DEFAULT_CONFIG.enabled, - limit: Math.max(1, cfg?.limit ?? DEFAULT_CONFIG.limit), - debug: cfg?.debug ?? DEFAULT_CONFIG.debug, - }; + if (cfg) { + return { + enabled: cfg.enabled ?? DEFAULT_CONFIG.enabled, + limit: Math.max(1, cfg.limit ?? DEFAULT_CONFIG.limit), + debug: cfg.debug ?? DEFAULT_CONFIG.debug, + }; + } + + return DEFAULT_CONFIG; } /** @@ -282,11 +323,28 @@ function patchFetch(): void { } /** - * Listen for config updates from content script + * Listen for config updates from content script. + * Config is received as JSON string for cross-browser compatibility. */ function setupConfigListener(): void { - window.addEventListener('lightsession-config', ((event: CustomEvent) => { - const config = event.detail; + window.addEventListener('lightsession-config', ((event: CustomEvent) => { + const detail = event.detail; + + // Parse JSON string (content script serializes config for Chrome compatibility) + let config: LsConfig | null = null; + + if (typeof detail === 'string') { + try { + config = JSON.parse(detail) as LsConfig; + } catch { + // Invalid JSON, ignore + return; + } + } else if (detail && typeof detail === 'object') { + // Fallback: handle object directly (backwards compatibility) + config = detail as unknown as LsConfig; + } + if (config && typeof config === 'object') { // Update debug flag first so logging works immediately window.__LS_DEBUG__ = config.debug ?? false; diff --git a/extension/src/popup/popup.html b/extension/src/popup/popup.html index 7447ab3..8e3f029 100644 --- a/extension/src/popup/popup.html +++ b/extension/src/popup/popup.html @@ -3,7 +3,7 @@ - LightSession Settings + LightSession Pro @@ -12,7 +12,7 @@
diff --git a/extension/src/popup/popup.ts b/extension/src/popup/popup.ts index 03f74e6..b3081ec 100644 --- a/extension/src/popup/popup.ts +++ b/extension/src/popup/popup.ts @@ -1,8 +1,9 @@ /** - * LightSession for ChatGPT - Popup UI Logic + * LightSession Pro - Popup UI Logic * Settings interface and interaction handlers */ +import browser from '../shared/browser-polyfill'; import type { LsSettings } from '../shared/types'; import { sendMessageWithTimeout } from '../shared/messages'; import { SUPPORT_URL } from '../shared/constants'; @@ -122,6 +123,32 @@ async function isDevMode(): Promise { } } +/** + * Reload the active ChatGPT tab to apply settings changes. + * Settings like message limit require a page reload to take effect + * because the fetch proxy caches settings at intercept time. + */ +async function reloadActiveChatGPTTab(): Promise { + try { + // Query for active tab in current window + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + const activeTab = tabs[0]; + + if (activeTab?.id && activeTab.url) { + // Only reload if it's a ChatGPT page + const isChatGPT = + activeTab.url.includes('chat.openai.com') || + activeTab.url.includes('chatgpt.com'); + + if (isChatGPT) { + await browser.tabs.reload(activeTab.id); + } + } + } catch (error) { + console.error('Failed to reload tab:', error); + } +} + /** * Initialize popup UI */ @@ -158,9 +185,10 @@ async function initialize(): Promise { await loadSettings(); // Setup event listeners - enableToggle.addEventListener('change', handleEnableToggle); + // Wrap async handlers to satisfy ESLint no-misused-promises rule + enableToggle.addEventListener('change', () => void handleEnableToggle()); keepSlider.addEventListener('input', handleKeepSliderInput); - keepSlider.addEventListener('change', handleKeepSliderChange); + keepSlider.addEventListener('change', () => void handleKeepSliderChange()); // Slider visual feedback keepSlider.addEventListener('mousedown', () => { @@ -237,10 +265,12 @@ async function updateSettings( /** * Handle enable/disable toggle */ -function handleEnableToggle(): void { +async function handleEnableToggle(): Promise { const enabled = enableToggle.checked; - void updateSettings({ enabled }); + await updateSettings({ enabled }); updateDisabledState(enabled); + // Reload page to apply the change + await reloadActiveChatGPTTab(); } /** @@ -255,11 +285,21 @@ function handleKeepSliderInput(): void { } /** - * Handle keep slider change (debounced save) + * Handle keep slider change (final value when user releases slider) */ -function handleKeepSliderChange(): void { +async function handleKeepSliderChange(): Promise { const value = parseInt(keepSlider.value, 10); - scheduleKeepUpdate(value, true); + + // Clear any pending debounced updates + if (sliderDebounceTimeout !== null) { + clearTimeout(sliderDebounceTimeout); + sliderDebounceTimeout = null; + } + pendingKeepValue = null; + + // Save immediately and reload page + await updateSettings({ keep: value }); + await reloadActiveChatGPTTab(); } /** diff --git a/extension/src/shared/browser-polyfill.ts b/extension/src/shared/browser-polyfill.ts index ce87c5d..6a5dbbc 100644 --- a/extension/src/shared/browser-polyfill.ts +++ b/extension/src/shared/browser-polyfill.ts @@ -1,9 +1,25 @@ /** - * LightSession for ChatGPT - Browser API Polyfill - * Firefox natively supports the browser API via @types/firefox-webext-browser - * This module re-exports the global browser object for consistent imports + * LightSession Pro - Browser API Polyfill + * + * Cross-browser compatibility layer for WebExtension APIs. + * - Firefox: uses global `browser` object (Promise-based) + * - Chrome: uses global `chrome` object (callback-based, but MV3 supports Promises) + * + * Modern Chrome (MV3) supports Promise-based APIs similar to Firefox, + * so we can use `chrome` directly as a drop-in replacement for `browser`. + * + * We type the export as `typeof browser` (Firefox types) because: + * 1. Firefox types are Promise-based (what we use) + * 2. Chrome MV3 also supports Promise-based APIs + * 3. This gives us consistent typing across the codebase */ -// The global browser object is provided by Firefox and typed by @types/firefox-webext-browser -// We re-export it for consistent module imports across the codebase -export default browser; +// Detect which API is available +// Firefox provides `browser`, Chrome provides `chrome` +// Some Chrome versions also provide `browser` as an alias +const api: typeof browser = + typeof browser !== 'undefined' + ? browser + : (chrome as unknown as typeof browser); + +export default api; diff --git a/extension/src/shared/constants.ts b/extension/src/shared/constants.ts index f504b41..ae080a0 100644 --- a/extension/src/shared/constants.ts +++ b/extension/src/shared/constants.ts @@ -89,6 +89,17 @@ export const TIMING = { */ MESSAGE_TIMEOUT_MS: 500, + /** + * Retry delays for Chrome service worker wake-up (ms). + * + * Rationale: + * - Chrome MV3 service workers can be inactive when popup opens + * - sendMessage returns undefined if no listener registered yet + * - Exponential backoff: 50ms, 100ms, 200ms allows progressive wake-up + * - Total max wait: 350ms, within user tolerance for popup load + */ + MESSAGE_RETRY_DELAYS_MS: [50, 100, 200] as const, + /** * Timeout for fetch proxy ready signal (ms). * diff --git a/extension/src/shared/messages.ts b/extension/src/shared/messages.ts index f52ecb2..c040b80 100644 --- a/extension/src/shared/messages.ts +++ b/extension/src/shared/messages.ts @@ -1,8 +1,9 @@ /** - * LightSession for ChatGPT - Message Protocol + * LightSession Pro - Message Protocol * Runtime communication between background, content, and popup scripts */ +import browser from './browser-polyfill'; import type { RuntimeMessage, RuntimeResponse } from './types'; import { TIMING } from './constants'; import { logError } from './logger'; @@ -17,33 +18,110 @@ export async function sendMessageWithTimeout( message: RuntimeMessage, timeoutMs: number = TIMING.MESSAGE_TIMEOUT_MS ): Promise { - return Promise.race([ - browser.runtime.sendMessage(message) as Promise, - new Promise((_, reject) => - setTimeout(() => reject(new Error('Message timeout')), timeoutMs) - ), - ]); + const isChrome = typeof chrome !== 'undefined' && typeof browser === 'undefined'; + const retryDelays = TIMING.MESSAGE_RETRY_DELAYS_MS; + let lastError: Error | undefined; + + // Attempt with retries for Chrome service worker wake-up + for (let attempt = 0; attempt <= retryDelays.length; attempt++) { + try { + const response = await Promise.race([ + browser.runtime.sendMessage(message) as Promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error('Message timeout')), timeoutMs) + ), + ]); + + // Check Chrome lastError (set when no listener exists) + if (isChrome) { + const lastError = (chrome as { runtime: { lastError?: { message?: string } } }).runtime.lastError; + if (lastError) { + throw new Error(lastError.message ?? 'Chrome runtime error'); + } + } + + // Validate response is not undefined (Chrome returns undefined if service worker inactive) + if (response === undefined) { + if (attempt < retryDelays.length) { + // Wait before retry with exponential backoff + await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt])); + continue; + } + throw new Error('Service worker not responding - received undefined after retries'); + } + + return response; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + // Don't retry on timeout - it's already waited long enough + if (lastError.message === 'Message timeout') { + throw lastError; + } + + // Retry if we haven't exhausted attempts + if (attempt < retryDelays.length) { + await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt])); + continue; + } + + throw lastError; + } + } + + // Should never reach here, but TypeScript needs it + throw lastError ?? new Error('Message failed after retries'); } +/** + * Message listener type that works with both Firefox and Chrome. + * + * In Firefox: returning a Promise from the listener works natively. + * In Chrome MV3: we need to use sendResponse callback and return true + * for async responses. + * + * This type represents the raw listener signature expected by both browsers. + */ +export type MessageListener = ( + message: RuntimeMessage, + sender: browser.runtime.MessageSender, + sendResponse: (response: RuntimeResponse) => void +) => boolean | void; + /** * Create a message handler function for use with browser.runtime.onMessage - * Wraps handler in try-catch and ensures proper return types + * + * This wrapper handles the difference between Firefox and Chrome: + * - Firefox: supports returning Promise from listener + * - Chrome: requires sendResponse callback + returning true for async + * + * For maximum compatibility, we use the sendResponse pattern which works in both. */ export function createMessageHandler( handler: ( message: RuntimeMessage, sender: browser.runtime.MessageSender ) => RuntimeResponse | Promise -): ( - message: RuntimeMessage, - sender: browser.runtime.MessageSender -) => Promise | RuntimeResponse { - return (message: RuntimeMessage, sender: browser.runtime.MessageSender) => { - try { - return handler(message, sender); - } catch (error) { - logError('Message handler error:', error); - throw error; - } +): MessageListener { + return ( + message: RuntimeMessage, + sender: browser.runtime.MessageSender, + sendResponse: (response: RuntimeResponse) => void + ): boolean => { + // Handle the message asynchronously + void (async () => { + try { + const response = await handler(message, sender); + sendResponse(response); + } catch (error) { + logError('Message handler error:', error); + // Send error response so caller doesn't hang + sendResponse({ error: String(error) } as unknown as RuntimeResponse); + } + })(); + + // Return true to indicate we will send a response asynchronously + // This is required for Chrome to keep the message channel open + return true; }; } diff --git a/extension/src/shared/storage.ts b/extension/src/shared/storage.ts index f8f6041..882c4c2 100644 --- a/extension/src/shared/storage.ts +++ b/extension/src/shared/storage.ts @@ -1,14 +1,22 @@ /** - * LightSession for ChatGPT - Storage Utility + * LightSession Pro - Storage Utility * Settings persistence and validation */ +import browser from './browser-polyfill'; import type { LsSettings } from './types'; import { DEFAULT_SETTINGS, VALIDATION } from './constants'; import { logDebug, logError } from './logger'; export const STORAGE_KEY = 'ls_settings'; +/** + * localStorage key for page-script access. + * Page scripts can't access browser.storage, so we mirror settings here + * to avoid race conditions on page load. + */ +export const LOCAL_STORAGE_KEY = 'ls_config'; + /** * Validate and normalize settings object * Ensures all fields are present and values are in valid ranges @@ -26,6 +34,27 @@ export function validateSettings(input: Partial): LsSettings { ultraLean: input.ultraLean ?? DEFAULT_SETTINGS.ultraLean, }; } +/** + * Sync settings to localStorage for page-script access. + * Page scripts run in page context and can't access browser.storage, + * so we mirror the config they need (enabled, limit, debug) to localStorage. + * This eliminates race conditions on page load. + */ +export function syncToLocalStorage(settings: LsSettings): void { + try { + const config = { + enabled: settings.enabled, + limit: settings.keep, + debug: settings.debug, + }; + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(config)); + logDebug('Synced settings to localStorage:', config); + } catch (error) { + // localStorage might be unavailable (private browsing, etc.) + logError('Failed to sync to localStorage:', error); + } +} + /** * Load settings from browser.storage.local @@ -38,15 +67,21 @@ export async function loadSettings(): Promise { if (stored) { logDebug('Loaded settings from storage:', stored); - return validateSettings(stored); + const validated = validateSettings(stored); + syncToLocalStorage(validated); + return validated; } // No stored settings, return defaults logDebug('No stored settings found, using defaults'); - return validateSettings({}); + const defaults = validateSettings({}); + syncToLocalStorage(defaults); + return defaults; } catch (error) { logError('Failed to load settings:', error); - return validateSettings({}); + const defaults = validateSettings({}); + syncToLocalStorage(defaults); + return defaults; } } @@ -64,6 +99,7 @@ export async function updateSettings(updates: Partial { if (!result[STORAGE_KEY]) { await browser.storage.local.set({ [STORAGE_KEY]: DEFAULT_SETTINGS }); + syncToLocalStorage(DEFAULT_SETTINGS); logDebug('Initialized default settings'); } } catch (error) { diff --git a/package-lock.json b/package-lock.json index 2e774e6..2ba6c43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "light-session", - "version": "1.2.3", + "version": "1.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "light-session", - "version": "1.2.3", + "version": "1.6.1", "license": "MIT", "devDependencies": { "@eslint/js": "^9.39.2", + "@types/chrome": "^0.1.32", "@types/firefox-webext-browser": "^143.0.0", "esbuild": "^0.27.2", "eslint": "^9.39.2", @@ -1216,6 +1217,17 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/chrome": { + "version": "0.1.32", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.1.32.tgz", + "integrity": "sha512-n5Cqlh7zyAqRLQWLXkeV5K/1BgDZdVcO/dJSTa8x+7w+sx7m73UrDmduAptg4KorMtyTW2TNnPu8RGeaDMKNGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1230,6 +1242,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/filesystem": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", + "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filewriter": "*" + } + }, + "node_modules/@types/filewriter": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", + "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/firefox-webext-browser": { "version": "143.0.0", "resolved": "https://registry.npmjs.org/@types/firefox-webext-browser/-/firefox-webext-browser-143.0.0.tgz", @@ -1237,6 +1266,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/har-format": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", + "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", diff --git a/package.json b/package.json index 2a042db..bd81009 100644 --- a/package.json +++ b/package.json @@ -2,15 +2,20 @@ "name": "light-session", "version": "1.6.1", "type": "module", - "description": "LightSession for ChatGPT - Firefox extension to optimize long conversation performance", + "description": "LightSession Pro - Browser extension to optimize ChatGPT performance", "engines": { "node": ">=24.10.0" }, "scripts": { "build": "node build.cjs", + "build:firefox": "node build.cjs --target=firefox", + "build:chrome": "node build.cjs --target=chrome", "build:types": "tsc --noEmit", "build:prod": "rm -f extension/.dev && NODE_ENV=production node build.cjs", + "build:prod:firefox": "rm -f extension/.dev && NODE_ENV=production node build.cjs --target=firefox", + "build:prod:chrome": "rm -f extension/.dev && NODE_ENV=production node build.cjs --target=chrome", "watch": "node build.cjs --watch", + "watch:chrome": "node build.cjs --target=chrome --watch", "lint": "eslint extension/src", "lint:fix": "eslint extension/src --fix", "format": "prettier --write 'extension/src/**/*.ts'", @@ -18,9 +23,10 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "dev": "npm run build && web-ext run --source-dir=extension --firefox=firefoxdeveloperedition --start-url='https://chat.openai.com'", - "dev:stable": "npm run build && web-ext run --source-dir=extension --firefox=firefox --start-url='https://chat.openai.com'", - "package": "npm run build:prod && web-ext build --source-dir=extension --artifacts-dir=web-ext-artifacts", + "dev": "npm run build:firefox && web-ext run --source-dir=extension --firefox=firefoxdeveloperedition --start-url='https://chat.openai.com'", + "dev:stable": "npm run build:firefox && web-ext run --source-dir=extension --firefox=firefox --start-url='https://chat.openai.com'", + "package": "npm run build:prod:firefox && web-ext build --source-dir=extension --artifacts-dir=web-ext-artifacts", + "package:chrome": "npm run build:prod:chrome && cd extension && zip -r ../web-ext-artifacts/lightsession-chrome.zip . -x '*.ts' -x 'src/*' -x 'manifest.*.json'", "clean": "rm -rf extension/dist extension/popup/popup.js web-ext-artifacts" }, "keywords": [ @@ -44,6 +50,7 @@ "license": "MIT", "devDependencies": { "@eslint/js": "^9.39.2", + "@types/chrome": "^0.1.32", "@types/firefox-webext-browser": "^143.0.0", "esbuild": "^0.27.2", "eslint": "^9.39.2", diff --git a/tests/unit/browser-polyfill.test.ts b/tests/unit/browser-polyfill.test.ts new file mode 100644 index 0000000..8738def --- /dev/null +++ b/tests/unit/browser-polyfill.test.ts @@ -0,0 +1,147 @@ +/** + * Unit tests for browser-polyfill.ts - Cross-browser API compatibility + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +describe('browser-polyfill', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe('API detection', () => { + it('uses browser API when available (Firefox)', async () => { + const mockBrowser = { + runtime: { id: 'firefox-extension' }, + storage: { local: { get: vi.fn(), set: vi.fn() } }, + }; + + vi.stubGlobal('browser', mockBrowser); + vi.stubGlobal('chrome', undefined); + + const { default: api } = await import('../../extension/src/shared/browser-polyfill'); + + expect(api).toBe(mockBrowser); + }); + + it('falls back to chrome API when browser is undefined (Chrome)', async () => { + const mockChrome = { + runtime: { id: 'chrome-extension' }, + storage: { local: { get: vi.fn(), set: vi.fn() } }, + }; + + vi.stubGlobal('browser', undefined); + vi.stubGlobal('chrome', mockChrome); + + const { default: api } = await import('../../extension/src/shared/browser-polyfill'); + + expect(api).toBe(mockChrome); + }); + + it('prefers browser over chrome when both are available', async () => { + const mockBrowser = { + runtime: { id: 'browser-api' }, + storage: { local: { get: vi.fn(), set: vi.fn() } }, + }; + const mockChrome = { + runtime: { id: 'chrome-api' }, + storage: { local: { get: vi.fn(), set: vi.fn() } }, + }; + + vi.stubGlobal('browser', mockBrowser); + vi.stubGlobal('chrome', mockChrome); + + const { default: api } = await import('../../extension/src/shared/browser-polyfill'); + + expect(api).toBe(mockBrowser); + }); + }); + + describe('API functionality', () => { + it('exposes storage.local.get', async () => { + const mockGet = vi.fn().mockResolvedValue({ key: 'value' }); + const mockBrowser = { + storage: { local: { get: mockGet, set: vi.fn() } }, + }; + + vi.stubGlobal('browser', mockBrowser); + vi.stubGlobal('chrome', undefined); + + const { default: api } = await import('../../extension/src/shared/browser-polyfill'); + + const result = await api.storage.local.get('key'); + expect(mockGet).toHaveBeenCalledWith('key'); + expect(result).toEqual({ key: 'value' }); + }); + + it('exposes storage.local.set', async () => { + const mockSet = vi.fn().mockResolvedValue(undefined); + const mockBrowser = { + storage: { local: { get: vi.fn(), set: mockSet } }, + }; + + vi.stubGlobal('browser', mockBrowser); + vi.stubGlobal('chrome', undefined); + + const { default: api } = await import('../../extension/src/shared/browser-polyfill'); + + await api.storage.local.set({ key: 'value' }); + expect(mockSet).toHaveBeenCalledWith({ key: 'value' }); + }); + + it('exposes runtime.sendMessage', async () => { + const mockSendMessage = vi.fn().mockResolvedValue({ response: 'ok' }); + const mockBrowser = { + runtime: { sendMessage: mockSendMessage }, + storage: { local: { get: vi.fn(), set: vi.fn() } }, + }; + + vi.stubGlobal('browser', mockBrowser); + vi.stubGlobal('chrome', undefined); + + const { default: api } = await import('../../extension/src/shared/browser-polyfill'); + + const result = await api.runtime.sendMessage({ type: 'TEST' }); + expect(mockSendMessage).toHaveBeenCalledWith({ type: 'TEST' }); + expect(result).toEqual({ response: 'ok' }); + }); + + it('exposes runtime.getURL', async () => { + const mockGetURL = vi.fn().mockReturnValue('moz-extension://id/path'); + const mockBrowser = { + runtime: { getURL: mockGetURL }, + storage: { local: { get: vi.fn(), set: vi.fn() } }, + }; + + vi.stubGlobal('browser', mockBrowser); + vi.stubGlobal('chrome', undefined); + + const { default: api } = await import('../../extension/src/shared/browser-polyfill'); + + const result = api.runtime.getURL('path'); + expect(mockGetURL).toHaveBeenCalledWith('path'); + expect(result).toBe('moz-extension://id/path'); + }); + + it('exposes tabs.create', async () => { + const mockCreate = vi.fn().mockResolvedValue({ id: 1 }); + const mockBrowser = { + tabs: { create: mockCreate }, + storage: { local: { get: vi.fn(), set: vi.fn() } }, + }; + + vi.stubGlobal('browser', mockBrowser); + vi.stubGlobal('chrome', undefined); + + const { default: api } = await import('../../extension/src/shared/browser-polyfill'); + + const result = await api.tabs.create({ url: 'https://example.com' }); + expect(mockCreate).toHaveBeenCalledWith({ url: 'https://example.com' }); + expect(result).toEqual({ id: 1 }); + }); + }); +}); diff --git a/tests/unit/manifest.test.ts b/tests/unit/manifest.test.ts new file mode 100644 index 0000000..b3782cc --- /dev/null +++ b/tests/unit/manifest.test.ts @@ -0,0 +1,173 @@ +/** + * Unit tests for manifest files - Cross-browser compatibility validation + * + * These tests ensure that manifest files are correctly configured for each browser: + * - Firefox: requires background.scripts (array), does NOT support service_worker + * - Chrome: requires background.service_worker (string), does NOT support scripts + */ + +import { describe, it, expect } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; + +// Load manifest files +const extensionDir = path.resolve(__dirname, '../../extension'); +const firefoxManifest = JSON.parse( + fs.readFileSync(path.join(extensionDir, 'manifest.firefox.json'), 'utf-8') +); +const chromeManifest = JSON.parse( + fs.readFileSync(path.join(extensionDir, 'manifest.chrome.json'), 'utf-8') +); + +describe('Firefox manifest (manifest.firefox.json)', () => { + it('uses manifest_version 3', () => { + expect(firefoxManifest.manifest_version).toBe(3); + }); + + it('has background.scripts array (required for Firefox MV3)', () => { + expect(firefoxManifest.background).toBeDefined(); + expect(firefoxManifest.background.scripts).toBeDefined(); + expect(Array.isArray(firefoxManifest.background.scripts)).toBe(true); + expect(firefoxManifest.background.scripts.length).toBeGreaterThan(0); + }); + + it('does NOT have background.service_worker (Firefox does not support it)', () => { + expect(firefoxManifest.background.service_worker).toBeUndefined(); + }); + + it('has browser_specific_settings.gecko', () => { + expect(firefoxManifest.browser_specific_settings).toBeDefined(); + expect(firefoxManifest.browser_specific_settings.gecko).toBeDefined(); + expect(firefoxManifest.browser_specific_settings.gecko.id).toBeDefined(); + }); + + it('has required permissions', () => { + expect(firefoxManifest.permissions).toContain('storage'); + expect(firefoxManifest.permissions).toContain('tabs'); + }); + + it('has host_permissions for ChatGPT domains', () => { + expect(firefoxManifest.host_permissions).toBeDefined(); + const hosts = firefoxManifest.host_permissions.join(' '); + expect(hosts).toContain('chat.openai.com'); + expect(hosts).toContain('chatgpt.com'); + }); + + it('has content_scripts configured', () => { + expect(firefoxManifest.content_scripts).toBeDefined(); + expect(firefoxManifest.content_scripts.length).toBeGreaterThan(0); + }); + + it('has web_accessible_resources with page-script.js', () => { + expect(firefoxManifest.web_accessible_resources).toBeDefined(); + const resources = firefoxManifest.web_accessible_resources[0]?.resources || []; + expect(resources).toContain('dist/page-script.js'); + }); +}); + +describe('Chrome manifest (manifest.chrome.json)', () => { + it('uses manifest_version 3', () => { + expect(chromeManifest.manifest_version).toBe(3); + }); + + it('has background.service_worker string (required for Chrome MV3)', () => { + expect(chromeManifest.background).toBeDefined(); + expect(chromeManifest.background.service_worker).toBeDefined(); + expect(typeof chromeManifest.background.service_worker).toBe('string'); + }); + + it('does NOT have background.scripts (Chrome MV3 uses service_worker)', () => { + expect(chromeManifest.background.scripts).toBeUndefined(); + }); + + it('does NOT have browser_specific_settings (Chrome does not support it)', () => { + expect(chromeManifest.browser_specific_settings).toBeUndefined(); + }); + + it('has required permissions', () => { + expect(chromeManifest.permissions).toContain('storage'); + expect(chromeManifest.permissions).toContain('tabs'); + }); + + it('has host_permissions for ChatGPT domains', () => { + expect(chromeManifest.host_permissions).toBeDefined(); + const hosts = chromeManifest.host_permissions.join(' '); + expect(hosts).toContain('chat.openai.com'); + expect(hosts).toContain('chatgpt.com'); + }); + + it('has content_scripts configured', () => { + expect(chromeManifest.content_scripts).toBeDefined(); + expect(chromeManifest.content_scripts.length).toBeGreaterThan(0); + }); + + it('has web_accessible_resources with page-script.js', () => { + expect(chromeManifest.web_accessible_resources).toBeDefined(); + const resources = chromeManifest.web_accessible_resources[0]?.resources || []; + expect(resources).toContain('dist/page-script.js'); + }); +}); + +describe('manifest consistency', () => { + it('both manifests have the same version', () => { + expect(firefoxManifest.version).toBe(chromeManifest.version); + }); + + it('both manifests have the same name', () => { + expect(firefoxManifest.name).toBe(chromeManifest.name); + }); + + it('both manifests have the same description', () => { + expect(firefoxManifest.description).toBe(chromeManifest.description); + }); + + it('both manifests have the same permissions', () => { + expect(firefoxManifest.permissions.sort()).toEqual(chromeManifest.permissions.sort()); + }); + + it('both manifests have the same host_permissions', () => { + expect(firefoxManifest.host_permissions.sort()).toEqual( + chromeManifest.host_permissions.sort() + ); + }); + + it('both manifests target the same background script', () => { + const firefoxBg = firefoxManifest.background.scripts[0]; + const chromeBg = chromeManifest.background.service_worker; + expect(firefoxBg).toBe(chromeBg); + }); + + it('both manifests have the same content scripts', () => { + expect(firefoxManifest.content_scripts.length).toBe( + chromeManifest.content_scripts.length + ); + + for (let i = 0; i < firefoxManifest.content_scripts.length; i++) { + expect(firefoxManifest.content_scripts[i].js).toEqual( + chromeManifest.content_scripts[i].js + ); + expect(firefoxManifest.content_scripts[i].run_at).toBe( + chromeManifest.content_scripts[i].run_at + ); + } + }); + + it('both manifests have the same icons', () => { + expect(firefoxManifest.icons).toEqual(chromeManifest.icons); + }); +}); + +describe('background script configuration details', () => { + it('Firefox background.scripts is an array with one entry', () => { + expect(firefoxManifest.background.scripts).toHaveLength(1); + expect(firefoxManifest.background.scripts[0]).toBe('dist/background.js'); + }); + + it('Chrome background.service_worker points to background.js', () => { + expect(chromeManifest.background.service_worker).toBe('dist/background.js'); + }); + + it('Firefox does NOT have preferred_environment (removed for compatibility)', () => { + expect(firefoxManifest.background.preferred_environment).toBeUndefined(); + }); +}); diff --git a/tests/unit/messages.test.ts b/tests/unit/messages.test.ts new file mode 100644 index 0000000..7953aa8 --- /dev/null +++ b/tests/unit/messages.test.ts @@ -0,0 +1,272 @@ +/** + * Unit tests for messages.ts - Runtime message protocol + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Create mock functions for browser runtime +const mockSendMessage = vi.fn(); + +// Mock browser-polyfill BEFORE importing messages +vi.mock('../../extension/src/shared/browser-polyfill', () => ({ + default: { + runtime: { + sendMessage: (...args: unknown[]) => mockSendMessage(...args), + }, + }, +})); + +// Mock logger +vi.mock('../../extension/src/shared/logger', () => ({ + logError: vi.fn(), +})); + +// Import functions AFTER mocks are set up +import { sendMessageWithTimeout, createMessageHandler } from '../../extension/src/shared/messages'; +import type { RuntimeMessage, RuntimeResponse } from '../../extension/src/shared/types'; + +describe('sendMessageWithTimeout', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.useFakeTimers(); + }); + + it('returns response when message succeeds within timeout', async () => { + const expectedResponse = { settings: { enabled: true, keep: 10 } }; + mockSendMessage.mockResolvedValue(expectedResponse); + + const responsePromise = sendMessageWithTimeout({ type: 'GET_SETTINGS' }); + + // Fast-forward time but response should come before timeout + await vi.runAllTimersAsync(); + + const response = await responsePromise; + expect(response).toEqual(expectedResponse); + expect(mockSendMessage).toHaveBeenCalledWith({ type: 'GET_SETTINGS' }); + }); + + it('rejects with timeout error when message takes too long', async () => { + // Never resolve the promise + mockSendMessage.mockImplementation(() => new Promise(() => {})); + + const responsePromise = sendMessageWithTimeout({ type: 'GET_SETTINGS' }, 100); + + // Fast-forward past timeout + vi.advanceTimersByTime(150); + + await expect(responsePromise).rejects.toThrow('Message timeout'); + }); + + it('rejects when sendMessage fails', async () => { + vi.useRealTimers(); // Use real timers for this test + mockSendMessage.mockRejectedValue(new Error('Connection failed')); + + await expect( + sendMessageWithTimeout({ type: 'GET_SETTINGS' }) + ).rejects.toThrow('Connection failed'); + }); + + it('uses default timeout from TIMING constant', async () => { + mockSendMessage.mockImplementation(() => new Promise(() => {})); + + const responsePromise = sendMessageWithTimeout({ type: 'GET_SETTINGS' }); + + // Default timeout is MESSAGE_TIMEOUT_MS (500ms from constants) + vi.advanceTimersByTime(499); + + // Should not have timed out yet + expect(responsePromise).toBeInstanceOf(Promise); + + vi.advanceTimersByTime(10); + + await expect(responsePromise).rejects.toThrow('Message timeout'); + }); +}); + +describe('sendMessageWithTimeout retry logic', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.useRealTimers(); + }); + + it('retries on undefined response and succeeds after retry', async () => { + const expectedResponse = { settings: { enabled: true, keep: 10 } }; + // First call returns undefined, second succeeds + mockSendMessage + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(expectedResponse); + + const response = await sendMessageWithTimeout({ type: 'GET_SETTINGS' }); + + expect(response).toEqual(expectedResponse); + expect(mockSendMessage).toHaveBeenCalledTimes(2); + }); + + it('retries multiple times on undefined responses', async () => { + const expectedResponse = { settings: { enabled: true, keep: 10 } }; + // First two calls return undefined, third succeeds + mockSendMessage + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(expectedResponse); + + const response = await sendMessageWithTimeout({ type: 'GET_SETTINGS' }); + + expect(response).toEqual(expectedResponse); + expect(mockSendMessage).toHaveBeenCalledTimes(3); + }); + + it('throws after max retries exceeded on persistent undefined', async () => { + // All calls return undefined + mockSendMessage.mockResolvedValue(undefined); + + await expect( + sendMessageWithTimeout({ type: 'GET_SETTINGS' }) + ).rejects.toThrow('Service worker not responding - received undefined after retries'); + + // Initial attempt + 3 retries = 4 calls + expect(mockSendMessage).toHaveBeenCalledTimes(4); + }); + + it('does not retry on timeout error', async () => { + vi.useFakeTimers(); + // Never resolve - will timeout + mockSendMessage.mockImplementation(() => new Promise(() => {})); + + const responsePromise = sendMessageWithTimeout({ type: 'GET_SETTINGS' }, 100); + + // Fast-forward past timeout + vi.advanceTimersByTime(150); + + await expect(responsePromise).rejects.toThrow('Message timeout'); + // Should only try once - timeout doesn't trigger retry + expect(mockSendMessage).toHaveBeenCalledTimes(1); + }); + + it('retries on error and succeeds after retry', async () => { + const expectedResponse = { settings: { enabled: true, keep: 10 } }; + // First call throws error, second succeeds + mockSendMessage + .mockRejectedValueOnce(new Error('Connection failed')) + .mockResolvedValueOnce(expectedResponse); + + const response = await sendMessageWithTimeout({ type: 'GET_SETTINGS' }); + + expect(response).toEqual(expectedResponse); + expect(mockSendMessage).toHaveBeenCalledTimes(2); + }); +}); + +describe('createMessageHandler', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.useRealTimers(); + }); + + it('returns true to keep channel open for async response', () => { + const handler = vi.fn().mockResolvedValue({ ok: true }); + const messageHandler = createMessageHandler(handler); + const sendResponse = vi.fn(); + + const result = messageHandler( + { type: 'SET_SETTINGS', payload: {} } as RuntimeMessage, + {} as browser.runtime.MessageSender, + sendResponse + ); + + // Must return true for Chrome async response + expect(result).toBe(true); + }); + + it('calls handler with message and sender', async () => { + const handler = vi.fn().mockResolvedValue({ ok: true }); + const messageHandler = createMessageHandler(handler); + const sendResponse = vi.fn(); + const message = { type: 'SET_SETTINGS', payload: { enabled: false } } as RuntimeMessage; + const sender = { id: 'test-extension' } as browser.runtime.MessageSender; + + messageHandler(message, sender, sendResponse); + + // Wait for async handler to complete + await vi.waitFor(() => expect(handler).toHaveBeenCalled()); + + expect(handler).toHaveBeenCalledWith(message, sender); + }); + + it('calls sendResponse with handler result', async () => { + const expectedResponse = { settings: { enabled: true, keep: 10 } } as RuntimeResponse; + const handler = vi.fn().mockResolvedValue(expectedResponse); + const messageHandler = createMessageHandler(handler); + const sendResponse = vi.fn(); + + messageHandler( + { type: 'GET_SETTINGS' } as RuntimeMessage, + {} as browser.runtime.MessageSender, + sendResponse + ); + + // Wait for async handler to complete + await vi.waitFor(() => expect(sendResponse).toHaveBeenCalled()); + + expect(sendResponse).toHaveBeenCalledWith(expectedResponse); + }); + + it('calls sendResponse with error when handler throws', async () => { + const handler = vi.fn().mockRejectedValue(new Error('Handler failed')); + const messageHandler = createMessageHandler(handler); + const sendResponse = vi.fn(); + + messageHandler( + { type: 'GET_SETTINGS' } as RuntimeMessage, + {} as browser.runtime.MessageSender, + sendResponse + ); + + // Wait for async handler to complete + await vi.waitFor(() => expect(sendResponse).toHaveBeenCalled()); + + expect(sendResponse).toHaveBeenCalledWith( + expect.objectContaining({ error: expect.stringContaining('Handler failed') }) + ); + }); + + it('handles synchronous errors in handler', async () => { + const handler = vi.fn().mockImplementation(() => { + throw new Error('Sync error'); + }); + const messageHandler = createMessageHandler(handler); + const sendResponse = vi.fn(); + + messageHandler( + { type: 'GET_SETTINGS' } as RuntimeMessage, + {} as browser.runtime.MessageSender, + sendResponse + ); + + // Wait for async handler to complete + await vi.waitFor(() => expect(sendResponse).toHaveBeenCalled()); + + expect(sendResponse).toHaveBeenCalledWith( + expect.objectContaining({ error: expect.stringContaining('Sync error') }) + ); + }); + + it('works with sync handler that returns value directly', async () => { + const expectedResponse = { type: 'PONG', timestamp: 123 } as RuntimeResponse; + // Handler returns value directly (not a Promise) + const handler = vi.fn().mockReturnValue(expectedResponse); + const messageHandler = createMessageHandler(handler); + const sendResponse = vi.fn(); + + messageHandler( + { type: 'PING' } as RuntimeMessage, + {} as browser.runtime.MessageSender, + sendResponse + ); + + // Wait for response + await vi.waitFor(() => expect(sendResponse).toHaveBeenCalled()); + + expect(sendResponse).toHaveBeenCalledWith(expectedResponse); + }); +}); diff --git a/tests/unit/page-script.test.ts b/tests/unit/page-script.test.ts index 4134ece..964cdd2 100644 --- a/tests/unit/page-script.test.ts +++ b/tests/unit/page-script.test.ts @@ -233,6 +233,126 @@ describe('fetch interception with trimMapping', () => { // Config Tests // ============================================================================ +describe('config JSON parsing (cross-browser compatibility)', () => { + interface LsConfig { + enabled: boolean; + limit: number; + debug: boolean; + } + + const DEFAULT_CONFIG: LsConfig = { + enabled: true, + limit: 10, + debug: false, + }; + + /** + * Parse config from CustomEvent detail. + * Mirrors the logic in page-script.ts setupConfigListener() + */ + function parseConfigFromDetail(detail: unknown): LsConfig | null { + let config: LsConfig | null = null; + + if (typeof detail === 'string') { + try { + config = JSON.parse(detail) as LsConfig; + } catch { + return null; + } + } else if (detail && typeof detail === 'object') { + config = detail as LsConfig; + } + + if (config && typeof config === 'object') { + return { + enabled: config.enabled ?? DEFAULT_CONFIG.enabled, + limit: Math.max(1, config.limit ?? DEFAULT_CONFIG.limit), + debug: config.debug ?? DEFAULT_CONFIG.debug, + }; + } + + return null; + } + + it('parses JSON string correctly (Chrome compatibility)', () => { + const configObj = { enabled: false, limit: 3, debug: true }; + const jsonString = JSON.stringify(configObj); + + const result = parseConfigFromDetail(jsonString); + + expect(result).not.toBeNull(); + expect(result?.enabled).toBe(false); + expect(result?.limit).toBe(3); + expect(result?.debug).toBe(true); + }); + + it('handles object directly (backwards compatibility)', () => { + const configObj = { enabled: true, limit: 5, debug: false }; + + const result = parseConfigFromDetail(configObj); + + expect(result).not.toBeNull(); + expect(result?.enabled).toBe(true); + expect(result?.limit).toBe(5); + expect(result?.debug).toBe(false); + }); + + it('returns null for invalid JSON string', () => { + const invalidJson = 'not valid json {{{'; + + const result = parseConfigFromDetail(invalidJson); + + expect(result).toBeNull(); + }); + + it('returns null for empty string', () => { + const result = parseConfigFromDetail(''); + + expect(result).toBeNull(); + }); + + it('returns null for null detail', () => { + const result = parseConfigFromDetail(null); + + expect(result).toBeNull(); + }); + + it('returns null for undefined detail', () => { + const result = parseConfigFromDetail(undefined); + + expect(result).toBeNull(); + }); + + it('applies defaults for missing fields in JSON', () => { + const partialConfig = { limit: 7 }; // missing enabled and debug + const jsonString = JSON.stringify(partialConfig); + + const result = parseConfigFromDetail(jsonString); + + expect(result?.enabled).toBe(true); // default + expect(result?.limit).toBe(7); + expect(result?.debug).toBe(false); // default + }); + + it('enforces minimum limit of 1 from JSON', () => { + const configWithZeroLimit = { enabled: true, limit: 0, debug: false }; + const jsonString = JSON.stringify(configWithZeroLimit); + + const result = parseConfigFromDetail(jsonString); + + expect(result?.limit).toBe(1); + }); + + it('enforces minimum limit of 1 from negative value', () => { + const configWithNegativeLimit = { enabled: true, limit: -5, debug: false }; + const jsonString = JSON.stringify(configWithNegativeLimit); + + const result = parseConfigFromDetail(jsonString); + + expect(result?.limit).toBe(1); + }); +}); + describe('config handling', () => { it('uses default values when config is undefined', () => { const DEFAULT_CONFIG = { diff --git a/tests/unit/popup.test.ts b/tests/unit/popup.test.ts new file mode 100644 index 0000000..682e9fb --- /dev/null +++ b/tests/unit/popup.test.ts @@ -0,0 +1,357 @@ +/** + * Unit tests for popup.ts - Settings UI and tab management + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Create mock functions for browser APIs +const mockSendMessage = vi.fn(); +const mockTabsQuery = vi.fn(); +const mockTabsReload = vi.fn(); +const mockTabsCreate = vi.fn(); +const mockGetURL = vi.fn(); +const mockGetManifest = vi.fn(); + +// Mock browser-polyfill BEFORE importing +vi.mock('../../extension/src/shared/browser-polyfill', () => ({ + default: { + runtime: { + sendMessage: (...args: unknown[]) => mockSendMessage(...args), + getURL: (...args: unknown[]) => mockGetURL(...args), + getManifest: () => mockGetManifest(), + }, + tabs: { + query: (...args: unknown[]) => mockTabsQuery(...args), + reload: (...args: unknown[]) => mockTabsReload(...args), + create: (...args: unknown[]) => mockTabsCreate(...args), + }, + }, +})); + +// Mock messages module +vi.mock('../../extension/src/shared/messages', () => ({ + sendMessageWithTimeout: vi.fn(), +})); + +import { sendMessageWithTimeout } from '../../extension/src/shared/messages'; + +const mockedSendMessageWithTimeout = vi.mocked(sendMessageWithTimeout); + +describe('popup settings loading', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('loads settings successfully from background', async () => { + const expectedSettings = { + enabled: true, + keep: 15, + showStatusBar: true, + debug: false, + ultraLean: false, + version: 1, + }; + + mockedSendMessageWithTimeout.mockResolvedValue({ settings: expectedSettings }); + + const response = await sendMessageWithTimeout<{ settings: typeof expectedSettings }>({ + type: 'GET_SETTINGS', + }); + + expect(mockedSendMessageWithTimeout).toHaveBeenCalledWith({ type: 'GET_SETTINGS' }); + expect(response.settings).toEqual(expectedSettings); + expect(response.settings.keep).toBe(15); + }); + + it('handles settings load failure', async () => { + mockedSendMessageWithTimeout.mockRejectedValue(new Error('Message timeout')); + + await expect( + sendMessageWithTimeout({ type: 'GET_SETTINGS' }) + ).rejects.toThrow('Message timeout'); + }); + + it('handles background script not responding', async () => { + mockedSendMessageWithTimeout.mockRejectedValue( + new Error('Could not establish connection. Receiving end does not exist.') + ); + + await expect( + sendMessageWithTimeout({ type: 'GET_SETTINGS' }) + ).rejects.toThrow('Could not establish connection'); + }); +}); + +describe('popup settings saving', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('saves settings successfully', async () => { + mockedSendMessageWithTimeout.mockResolvedValue({ ok: true }); + + const response = await sendMessageWithTimeout({ + type: 'SET_SETTINGS', + payload: { keep: 5 }, + }); + + expect(mockedSendMessageWithTimeout).toHaveBeenCalledWith({ + type: 'SET_SETTINGS', + payload: { keep: 5 }, + }); + expect(response).toEqual({ ok: true }); + }); + + it('saves enabled state', async () => { + mockedSendMessageWithTimeout.mockResolvedValue({ ok: true }); + + await sendMessageWithTimeout({ + type: 'SET_SETTINGS', + payload: { enabled: false }, + }); + + expect(mockedSendMessageWithTimeout).toHaveBeenCalledWith({ + type: 'SET_SETTINGS', + payload: { enabled: false }, + }); + }); + + it('handles save failure', async () => { + mockedSendMessageWithTimeout.mockRejectedValue(new Error('Storage error')); + + await expect( + sendMessageWithTimeout({ + type: 'SET_SETTINGS', + payload: { keep: 10 }, + }) + ).rejects.toThrow('Storage error'); + }); +}); + +describe('tab reload functionality', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + /** + * Simulates the reloadActiveChatGPTTab logic from popup.ts + */ + async function reloadActiveChatGPTTab(): Promise { + try { + const tabs = await mockTabsQuery({ active: true, currentWindow: true }); + const activeTab = tabs[0]; + + if (activeTab?.id && activeTab.url) { + const isChatGPT = + activeTab.url.includes('chat.openai.com') || + activeTab.url.includes('chatgpt.com'); + + if (isChatGPT) { + await mockTabsReload(activeTab.id); + return true; + } + } + return false; + } catch { + return false; + } + } + + it('reloads ChatGPT tab on chatgpt.com', async () => { + mockTabsQuery.mockResolvedValue([ + { id: 123, url: 'https://chatgpt.com/c/abc123' }, + ]); + mockTabsReload.mockResolvedValue(undefined); + + const result = await reloadActiveChatGPTTab(); + + expect(mockTabsQuery).toHaveBeenCalledWith({ active: true, currentWindow: true }); + expect(mockTabsReload).toHaveBeenCalledWith(123); + expect(result).toBe(true); + }); + + it('reloads ChatGPT tab on chat.openai.com', async () => { + mockTabsQuery.mockResolvedValue([ + { id: 456, url: 'https://chat.openai.com/chat' }, + ]); + mockTabsReload.mockResolvedValue(undefined); + + const result = await reloadActiveChatGPTTab(); + + expect(mockTabsReload).toHaveBeenCalledWith(456); + expect(result).toBe(true); + }); + + it('does not reload non-ChatGPT tabs', async () => { + mockTabsQuery.mockResolvedValue([ + { id: 789, url: 'https://google.com' }, + ]); + + const result = await reloadActiveChatGPTTab(); + + expect(mockTabsReload).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it('handles no active tab', async () => { + mockTabsQuery.mockResolvedValue([]); + + const result = await reloadActiveChatGPTTab(); + + expect(mockTabsReload).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it('handles tab without URL', async () => { + mockTabsQuery.mockResolvedValue([{ id: 111 }]); // no url property + + const result = await reloadActiveChatGPTTab(); + + expect(mockTabsReload).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it('handles tabs.query error gracefully', async () => { + mockTabsQuery.mockRejectedValue(new Error('Permission denied')); + + const result = await reloadActiveChatGPTTab(); + + expect(result).toBe(false); + }); + + it('handles tabs.reload error gracefully', async () => { + mockTabsQuery.mockResolvedValue([ + { id: 123, url: 'https://chatgpt.com/' }, + ]); + mockTabsReload.mockRejectedValue(new Error('Tab was closed')); + + const result = await reloadActiveChatGPTTab(); + + expect(result).toBe(false); + }); +}); + +describe('dev mode detection', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('detects dev mode when .dev file exists', async () => { + // Simulate isDevMode logic + mockGetURL.mockReturnValue('chrome-extension://id/.dev'); + + // Mock global fetch for this test + const originalFetch = global.fetch; + global.fetch = vi.fn().mockResolvedValue({ ok: true }); + + try { + const response = await fetch(mockGetURL('.dev')); + const isDevMode = response.ok; + + expect(mockGetURL).toHaveBeenCalledWith('.dev'); + expect(isDevMode).toBe(true); + } finally { + global.fetch = originalFetch; + } + }); + + it('returns false when .dev file missing', async () => { + mockGetURL.mockReturnValue('chrome-extension://id/.dev'); + + const originalFetch = global.fetch; + global.fetch = vi.fn().mockResolvedValue({ ok: false }); + + try { + const response = await fetch(mockGetURL('.dev')); + const isDevMode = response.ok; + + expect(isDevMode).toBe(false); + } finally { + global.fetch = originalFetch; + } + }); + + it('returns false on fetch error', async () => { + mockGetURL.mockReturnValue('chrome-extension://id/.dev'); + + const originalFetch = global.fetch; + global.fetch = vi.fn().mockRejectedValue(new Error('Network error')); + + try { + let isDevMode = false; + try { + const response = await fetch(mockGetURL('.dev')); + isDevMode = response.ok; + } catch { + isDevMode = false; + } + + expect(isDevMode).toBe(false); + } finally { + global.fetch = originalFetch; + } + }); +}); + +describe('settings flow integration', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('complete flow: load settings -> change -> save -> reload', async () => { + // 1. Load initial settings + mockedSendMessageWithTimeout.mockResolvedValueOnce({ + settings: { enabled: true, keep: 10 }, + }); + + const loadResponse = await sendMessageWithTimeout({ type: 'GET_SETTINGS' }); + expect(loadResponse.settings.keep).toBe(10); + + // 2. Save new settings + mockedSendMessageWithTimeout.mockResolvedValueOnce({ ok: true }); + + await sendMessageWithTimeout({ + type: 'SET_SETTINGS', + payload: { keep: 5 }, + }); + + // 3. Reload tab + mockTabsQuery.mockResolvedValue([ + { id: 123, url: 'https://chatgpt.com/' }, + ]); + mockTabsReload.mockResolvedValue(undefined); + + const tabs = await mockTabsQuery({ active: true, currentWindow: true }); + if (tabs[0]?.url?.includes('chatgpt.com')) { + await mockTabsReload(tabs[0].id); + } + + expect(mockTabsReload).toHaveBeenCalledWith(123); + }); + + it('enable toggle flow: toggle -> save -> reload', async () => { + // Save enabled state + mockedSendMessageWithTimeout.mockResolvedValue({ ok: true }); + + await sendMessageWithTimeout({ + type: 'SET_SETTINGS', + payload: { enabled: false }, + }); + + expect(mockedSendMessageWithTimeout).toHaveBeenCalledWith({ + type: 'SET_SETTINGS', + payload: { enabled: false }, + }); + + // Reload tab + mockTabsQuery.mockResolvedValue([ + { id: 456, url: 'https://chatgpt.com/c/test' }, + ]); + mockTabsReload.mockResolvedValue(undefined); + + const tabs = await mockTabsQuery({ active: true, currentWindow: true }); + await mockTabsReload(tabs[0].id); + + expect(mockTabsReload).toHaveBeenCalledWith(456); + }); +}); diff --git a/tests/unit/storage.test.ts b/tests/unit/storage.test.ts index 9e2dc3b..7bb8e80 100644 --- a/tests/unit/storage.test.ts +++ b/tests/unit/storage.test.ts @@ -3,21 +3,39 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { - validateSettings, - loadSettings, - updateSettings, - initializeSettings, - STORAGE_KEY, -} from '../../extension/src/shared/storage'; import { DEFAULT_SETTINGS, VALIDATION } from '../../extension/src/shared/constants'; +// Create mock functions for browser storage +const mockStorageGet = vi.fn(); +const mockStorageSet = vi.fn(); + +// Mock browser-polyfill BEFORE importing storage +vi.mock('../../extension/src/shared/browser-polyfill', () => ({ + default: { + storage: { + local: { + get: (...args: unknown[]) => mockStorageGet(...args), + set: (...args: unknown[]) => mockStorageSet(...args), + }, + }, + }, +})); + // Mock logger to avoid console output in tests vi.mock('../../extension/src/shared/logger', () => ({ logDebug: vi.fn(), logError: vi.fn(), })); +// Import storage functions AFTER mocks are set up +import { + validateSettings, + loadSettings, + updateSettings, + initializeSettings, + STORAGE_KEY, +} from '../../extension/src/shared/storage'; + describe('validateSettings', () => { it('returns default settings when given empty object', () => { const result = validateSettings({}); @@ -97,10 +115,6 @@ describe('loadSettings', () => { vi.resetAllMocks(); }); - afterEach(() => { - vi.unstubAllGlobals(); - }); - it('returns stored settings when they exist', async () => { const storedSettings = { enabled: false, @@ -109,14 +123,7 @@ describe('loadSettings', () => { debug: true, }; - // Mock browser.storage.local.get - vi.stubGlobal('browser', { - storage: { - local: { - get: vi.fn().mockResolvedValue({ [STORAGE_KEY]: storedSettings }), - }, - }, - }); + mockStorageGet.mockResolvedValue({ [STORAGE_KEY]: storedSettings }); const result = await loadSettings(); @@ -128,13 +135,7 @@ describe('loadSettings', () => { }); it('returns default settings when storage is empty', async () => { - vi.stubGlobal('browser', { - storage: { - local: { - get: vi.fn().mockResolvedValue({}), - }, - }, - }); + mockStorageGet.mockResolvedValue({}); const result = await loadSettings(); @@ -142,13 +143,7 @@ describe('loadSettings', () => { }); it('returns default settings when storage.get throws', async () => { - vi.stubGlobal('browser', { - storage: { - local: { - get: vi.fn().mockRejectedValue(new Error('Storage unavailable')), - }, - }, - }); + mockStorageGet.mockRejectedValue(new Error('Storage unavailable')); const result = await loadSettings(); @@ -158,13 +153,7 @@ describe('loadSettings', () => { it('validates and clamps invalid stored values', async () => { // Store out-of-range keep value - vi.stubGlobal('browser', { - storage: { - local: { - get: vi.fn().mockResolvedValue({ [STORAGE_KEY]: { keep: 9999 } }), - }, - }, - }); + mockStorageGet.mockResolvedValue({ [STORAGE_KEY]: { keep: 9999 } }); const result = await loadSettings(); @@ -177,10 +166,6 @@ describe('updateSettings', () => { vi.resetAllMocks(); }); - afterEach(() => { - vi.unstubAllGlobals(); - }); - it('merges partial updates with existing settings', async () => { const existingSettings = { enabled: true, @@ -190,21 +175,13 @@ describe('updateSettings', () => { version: 1, }; - const mockSet = vi.fn().mockResolvedValue(undefined); - - vi.stubGlobal('browser', { - storage: { - local: { - get: vi.fn().mockResolvedValue({ [STORAGE_KEY]: existingSettings }), - set: mockSet, - }, - }, - }); + mockStorageGet.mockResolvedValue({ [STORAGE_KEY]: existingSettings }); + mockStorageSet.mockResolvedValue(undefined); await updateSettings({ keep: 30 }); // Verify set was called with merged settings - expect(mockSet).toHaveBeenCalledWith({ + expect(mockStorageSet).toHaveBeenCalledWith({ [STORAGE_KEY]: expect.objectContaining({ enabled: true, // preserved keep: 30, // updated @@ -215,20 +192,12 @@ describe('updateSettings', () => { }); it('validates updated values', async () => { - const mockSet = vi.fn().mockResolvedValue(undefined); - - vi.stubGlobal('browser', { - storage: { - local: { - get: vi.fn().mockResolvedValue({}), - set: mockSet, - }, - }, - }); + mockStorageGet.mockResolvedValue({}); + mockStorageSet.mockResolvedValue(undefined); await updateSettings({ keep: -100 }); // Invalid, should be clamped - expect(mockSet).toHaveBeenCalledWith({ + expect(mockStorageSet).toHaveBeenCalledWith({ [STORAGE_KEY]: expect.objectContaining({ keep: VALIDATION.MIN_KEEP, // Clamped }), @@ -236,27 +205,15 @@ describe('updateSettings', () => { }); it('throws error when storage.set fails', async () => { - vi.stubGlobal('browser', { - storage: { - local: { - get: vi.fn().mockResolvedValue({}), - set: vi.fn().mockRejectedValue(new Error('Write failed')), - }, - }, - }); + mockStorageGet.mockResolvedValue({}); + mockStorageSet.mockRejectedValue(new Error('Write failed')); await expect(updateSettings({ enabled: false })).rejects.toThrow('Write failed'); }); it('handles error in loadSettings during update', async () => { - vi.stubGlobal('browser', { - storage: { - local: { - get: vi.fn().mockRejectedValue(new Error('Read failed')), - set: vi.fn().mockResolvedValue(undefined), - }, - }, - }); + mockStorageGet.mockRejectedValue(new Error('Read failed')); + mockStorageSet.mockResolvedValue(undefined); // Should still work - loadSettings returns defaults on error await expect(updateSettings({ enabled: false })).resolves.toBeUndefined(); @@ -268,69 +225,36 @@ describe('initializeSettings', () => { vi.resetAllMocks(); }); - afterEach(() => { - vi.unstubAllGlobals(); - }); - it('sets default settings when storage is empty', async () => { - const mockSet = vi.fn().mockResolvedValue(undefined); - - vi.stubGlobal('browser', { - storage: { - local: { - get: vi.fn().mockResolvedValue({}), - set: mockSet, - }, - }, - }); + mockStorageGet.mockResolvedValue({}); + mockStorageSet.mockResolvedValue(undefined); await initializeSettings(); - expect(mockSet).toHaveBeenCalledWith({ + expect(mockStorageSet).toHaveBeenCalledWith({ [STORAGE_KEY]: DEFAULT_SETTINGS, }); }); it('does not overwrite existing settings', async () => { const existingSettings = { enabled: false, keep: 50 }; - const mockSet = vi.fn(); - - vi.stubGlobal('browser', { - storage: { - local: { - get: vi.fn().mockResolvedValue({ [STORAGE_KEY]: existingSettings }), - set: mockSet, - }, - }, - }); + mockStorageGet.mockResolvedValue({ [STORAGE_KEY]: existingSettings }); await initializeSettings(); - expect(mockSet).not.toHaveBeenCalled(); + expect(mockStorageSet).not.toHaveBeenCalled(); }); it('does not throw when storage.get fails', async () => { - vi.stubGlobal('browser', { - storage: { - local: { - get: vi.fn().mockRejectedValue(new Error('Storage error')), - }, - }, - }); + mockStorageGet.mockRejectedValue(new Error('Storage error')); // Should not throw await expect(initializeSettings()).resolves.toBeUndefined(); }); it('does not throw when storage.set fails', async () => { - vi.stubGlobal('browser', { - storage: { - local: { - get: vi.fn().mockResolvedValue({}), - set: vi.fn().mockRejectedValue(new Error('Write error')), - }, - }, - }); + mockStorageGet.mockResolvedValue({}); + mockStorageSet.mockRejectedValue(new Error('Write error')); // Should not throw (error is logged but swallowed) await expect(initializeSettings()).resolves.toBeUndefined(); diff --git a/tsconfig.json b/tsconfig.json index 95382ef..32a9dac 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,7 @@ "strict": true, "esModuleInterop": true, "skipLibCheck": true, - "types": ["firefox-webext-browser"], + "types": ["firefox-webext-browser", "chrome"], "declaration": true, "declarationMap": true, "sourceMap": true,