diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f5673f..48df9d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Serialize Settings and popup writes in the worker, including first-run defaults + and confirmed resets, to preserve overlapping edits (#46). +- Keep delayed extraction, analysis, links, and saved chat scoped to the current + document/conversation; preserve current and independently restored summaries (#47, #48). +- Stop queued summary sections after a fatal failure; preserve daily cleanup + alarms across worker starts (#49, #50). +- Keep provider deadlines active through response bodies, and extract visible + text with block boundaries without changing the source DOM (#51, #52). +- Restore keyboard access to summary-length radios and expose task-tab selection, + panel relationships, and Arrow/Home/End navigation (#36). - Replace fictional contribution examples, consolidate setup and validation guidance, and exclude development configs from the npm package (#35). - Pin verified current CI action releases, select Ubuntu 26.04 explicitly, and @@ -30,6 +40,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- A redesigned popup and reading workspace with a persistent source panel, + neutral/teal light and dark themes, local SVG icons, clear task labels, + compact study prompts, and responsive layouts (#36). - A read-only CI guard rejects a stacked PR whose base is already contained in the default branch, with Git-history regression tests (issue #22). - Service-worker regression tests for startup failure, page context menus, diff --git a/README.md b/README.md index 48db2c5..e05cbe3 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,15 @@ Page text goes directly to that provider; there is no application server or tele The screenshot uses an intercepted test response; it demonstrates the interface, not live model quality. +The reading workspace keeps your source beside the tools, with light and dark +themes and a compact popup. Use **Study guide** or **Quiz me** in Chat to start +learning. Navigate task tabs with Left/Right, Home, and End; Tab moves into the +selected panel. Summary length works with native radio-keyboard controls. +See the [interface decisions and verification](docs/product/INTERFACE.md). +The combined interface and reliability delivery is tracked in +[PR #39](https://github.com/aaron-seq/GenAI-Browser-Tool/pull/39) and the +[current checkpoint](docs/product/PROJECT-STATUS.md). + ## Start here Clone or download this repository: @@ -26,14 +35,14 @@ git clone https://github.com/aaron-seq/GenAI-Browser-Tool.git Generate a summary or ask a question. 4. Choose **Open workspace** for a persistent reading tab. Import UTF-8 `.txt` or `.md` files there; importing alone sends no provider request. -5. In Chat, choose **Make a study guide** or **Quiz me**, ask follow-up questions, +5. In Chat, choose **Study guide** or **Quiz me**, ask follow-up questions, and expand the source passages to check the evidence. Installation is currently unpacked; there is no published Web Store release. ## What works -- **Summary:** four styles and four lengths; matching saved results avoid another +- **Summary:** four styles and three lengths; matching saved results avoid another provider call. - **Chat:** local keyword retrieval selects up to six passages across the document. Answers display source labels and excerpts. diff --git a/background.js b/background.js index cf7c8a2..62db309 100644 --- a/background.js +++ b/background.js @@ -110,7 +110,7 @@ class BackgroundService { ); chrome.commands.onCommand.addListener(this.handleCommand.bind(this)); chrome.alarms.onAlarm.addListener(this.handleAlarm.bind(this)); - chrome.alarms.create('cleanupOldData', { periodInMinutes: 1440 }); + this.ensureCleanupAlarm(); chrome.storage.onChanged.addListener((changes, area) => { const change = changes.user_preferences; if (area !== 'sync' || !change) return; @@ -123,6 +123,18 @@ class BackgroundService { }); } + async ensureCleanupAlarm() { + try { + // Replacing a named alarm restarts its delay on every worker wake. + if (!(await chrome.alarms.get('cleanupOldData'))) { + await chrome.alarms.create('cleanupOldData', { periodInMinutes: 1440 }); + } + } catch { + // Keep startup usable; a later worker start will check the alarm again. + this.logger.error('History cleanup alarm initialization failed'); + } + } + // ---------------------------------------------------------------- messaging /** @@ -220,6 +232,11 @@ class BackgroundService { case 'UPDATE_USER_PREFERENCES': return this.configManager.updateUserPreferences(payload); + case 'RESET_USER_PREFERENCES': + return this.configManager.setUserPreferences( + this.configManager.defaultSettings + ); + case 'GET_PROVIDER_STATUS': return this.configManager.getProviderStatus(); @@ -786,13 +803,21 @@ export async function mapWithConcurrency(items, limit, fn) { /** @type {R[]} */ const results = new Array(items.length); let next = 0; + let failed = false; const workers = Array.from( { length: Math.min(limit, items.length) }, async () => { - while (next < items.length) { + while (!failed && next < items.length) { const index = next++; - results[index] = await fn(/** @type {T} */ (items[index]), index); + try { + results[index] = await fn(/** @type {T} */ (items[index]), index); + } catch (error) { + // Siblings may finish, but no queued work can produce a usable result. + // eslint-disable-next-line require-atomic-updates -- This flag only transitions to true. + failed = true; + throw error; + } } } ); diff --git a/content.js b/content.js index 8bc4031..e218692 100644 --- a/content.js +++ b/content.js @@ -12,8 +12,9 @@ class PageContentExtractor { chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { this.handleMessage(request) .then(data => sendResponse({ success: true, data })) - .catch(/** @param {any} error */ error => - sendResponse({ success: false, error: error.message }) + .catch( + /** @param {any} error */ error => + sendResponse({ success: false, error: error.message }) ); return true; // async sendResponse }); @@ -49,24 +50,34 @@ class PageContentExtractor { headings: this.extractHeadings(), metadata: this.getPageMetadata(), language: document.documentElement.lang || 'unknown', - timestamp: Date.now() + timestamp: Date.now(), }; } getPageTitle() { - return document.title || document.querySelector('h1')?.textContent?.trim() || 'Untitled Page'; + return ( + document.title || + document.querySelector('h1')?.textContent?.trim() || + 'Untitled Page' + ); } /** - * Take the most specific container that looks like article content, then strip - * chrome (nav, ads, comments) from a clone so the live page is never touched. + * Read the most specific article container without changing the live page. + * A detached clone loses CSS visibility and innerText's block boundaries. * * @returns {string} */ getMainText() { const selectors = [ - 'article', '[role="main"]', 'main', '.post-content', '.entry-content', - '#content', '.story-body', 'body' + 'article', + '[role="main"]', + 'main', + '.post-content', + '.entry-content', + '#content', + '.story-body', + 'body', ]; const mainElement = selectors .map(selector => document.querySelector(selector)) @@ -74,25 +85,104 @@ class PageContentExtractor { if (!mainElement) return ''; - const clone = /** @type {HTMLElement} */ (mainElement.cloneNode(true)); const unwanted = [ - 'script', 'style', 'noscript', 'nav', 'header', 'footer', 'aside', - '.ad', '.sidebar', '.comments', '[role="navigation"]', '[role="banner"]' - ]; - unwanted.forEach(selector => { - clone.querySelectorAll(selector).forEach(element => element.remove()); - }); + 'script', + 'style', + 'noscript', + 'nav', + 'header', + 'footer', + 'aside', + '.ad', + '.sidebar', + '.comments', + '[role="navigation"]', + '[role="banner"]', + ].join(','); + /** @type {Map} */ + const styles = new Map(); + /** @param {Element} element */ + const styleFor = element => { + let style = styles.get(element); + if (!style) { + style = window.getComputedStyle(element); + styles.set(element, style); + } + return style; + }; + // A selected article can itself be inside a non-rendered container. + for ( + let parent = mainElement.parentElement; + parent; + parent = parent.parentElement + ) { + const style = styleFor(parent); + if (style.display === 'none' || style.contentVisibility === 'hidden') + return ''; + if (parent.tagName === 'DETAILS' && !parent.hasAttribute('open')) { + const summary = parent.querySelector(':scope > summary'); + if (!summary?.contains(mainElement)) return ''; + } + } - // innerText (not textContent) so hidden elements stay out and block-level - // structure survives as line breaks. - return (clone.innerText || clone.textContent || '').trim(); + /** @type {string[]} */ + const parts = []; + const lineBreak = (count = 1) => { + const last = parts.at(-1); + if (last?.endsWith('\n'.repeat(count))) return; + if (last) parts[parts.length - 1] = last.trimEnd(); + parts.push('\n'.repeat(count)); + }; + /** @param {Element} element @returns {void} */ + const visit = element => { + if (element.matches(unwanted)) return; + const style = styleFor(element); + if (style.display === 'none' || style.contentVisibility === 'hidden') + return; + + const visible = + style.visibility !== 'hidden' && style.visibility !== 'collapse'; + if (element.tagName === 'BR') { + if (visible) lineBreak(); + return; + } + const block = /^(block|flow-root|list-item|flex|grid|table)/.test( + style.display + ); + // Blank lines retain the paragraph boundaries consumed by summary chunking. + const breaks = element.tagName === 'P' ? 2 : 1; + if (block) lineBreak(breaks); + // Closed details render only their first direct summary, not the body. + const collapsed = + element.tagName === 'DETAILS' && !element.hasAttribute('open'); + const children = collapsed + ? [element.querySelector(':scope > summary')] + : element.childNodes; + for (const child of children) { + if (!child) continue; + if (child.nodeType === Node.ELEMENT_NODE) { + // visibility can be explicitly restored by a child of a hidden parent. + visit(/** @type {Element} */ (child)); + } else if (child.nodeType === Node.TEXT_NODE && visible) { + let text = child.textContent || ''; + if (!/^(pre|break-spaces)/.test(style.whiteSpace)) { + text = text.replace(/\s+/g, ' '); + if (/[\s]$/.test(parts.at(-1) || '')) text = text.trimStart(); + } + if (text) parts.push(text); + } + } + if (block) lineBreak(breaks); + }; + visit(mainElement); + return parts.join('').trim(); } extractHeadings() { return Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6')) .map(heading => ({ level: Number(heading.tagName[1]), - text: heading.textContent?.trim() || '' + text: heading.textContent?.trim() || '', })) .filter(heading => heading.text); } @@ -121,7 +211,7 @@ class PageContentExtractor { return { href: link.href, text: link.textContent?.trim() || '', - isExternal: link.hostname !== window.location.hostname + isExternal: link.hostname !== window.location.hostname, }; }) .filter(link => link.href.startsWith('http')); @@ -133,7 +223,7 @@ class PageContentExtractor { src: image.src, alt: image.alt, width: image.naturalWidth, - height: image.naturalHeight + height: image.naturalHeight, })) .filter(image => image.src && !image.src.startsWith('data:')); } diff --git a/core/configuration-manager.js b/core/configuration-manager.js index a89c10d..dcc8070 100644 --- a/core/configuration-manager.js +++ b/core/configuration-manager.js @@ -25,6 +25,8 @@ const STORAGE_KEY = 'user_preferences'; */ export class ConfigurationManager { constructor() { + /** @type {Promise} */ + this.writes = Promise.resolve(); this.defaultSettings = { preferredProvider: 'anthropic', /** @type {Record} */ @@ -53,10 +55,12 @@ export class ConfigurationManager { * distinguish a first run from a later one. */ async initialize() { - const stored = await chrome.storage.sync.get([STORAGE_KEY]); - if (!stored[STORAGE_KEY]) { - await this.setUserPreferences(this.defaultSettings); - } + return this.serializeWrite(async () => { + const stored = await chrome.storage.sync.get([STORAGE_KEY]); + if (!stored[STORAGE_KEY]) { + await chrome.storage.sync.set({ [STORAGE_KEY]: this.defaultSettings }); + } + }); } /** @@ -102,22 +106,41 @@ export class ConfigurationManager { * @returns {Promise} */ async updateUserPreferences(newSettings) { - const current = await this.getUserPreferences(); - const updated = { - ...current, - ...newSettings, - apiKeys: { ...current.apiKeys, ...(newSettings.apiKeys || {}) }, - models: { ...current.models, ...(newSettings.models || {}) }, - features: { ...current.features, ...(newSettings.features || {}) }, - updatedAt: Date.now(), - }; - await chrome.storage.sync.set({ [STORAGE_KEY]: updated }); - return updated; + return this.serializeWrite(async () => { + const current = await this.getUserPreferences(); + const updated = { + ...current, + ...newSettings, + apiKeys: { ...current.apiKeys, ...(newSettings.apiKeys || {}) }, + models: { ...current.models, ...(newSettings.models || {}) }, + features: { ...current.features, ...(newSettings.features || {}) }, + updatedAt: Date.now(), + }; + await chrome.storage.sync.set({ [STORAGE_KEY]: updated }); + return updated; + }); } /** @param {any} settings */ async setUserPreferences(settings) { - await chrome.storage.sync.set({ [STORAGE_KEY]: settings }); + return this.serializeWrite(async () => { + await chrome.storage.sync.set({ [STORAGE_KEY]: settings }); + return this.withDefaults(settings); + }); + } + + /** + * All settings writes run through the worker's manager. Read-modify-write, + * first-run seeding, and replacement/reset must share the same ordering. + * A failed operation reaches its caller without blocking later writes. + * @template T + * @param {() => Promise} write + * @returns {Promise} + */ + serializeWrite(write) { + const operation = this.writes.then(write, write); + this.writes = operation; + return operation; } /** diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 52e37b1..5457c02 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -22,7 +22,8 @@ flowchart LR A summary extracts page text, checks the saved-result key, then either makes one request or summarizes up to eight 24,000-character sections (three concurrently) and merges the notes. Any failed section fails the task; partial results are not -presented as complete. Errors retain their code and message. +presented as complete. A failed section stops queued sections from starting; +already-started requests may settle. Errors retain their code and message. ## Source map @@ -58,6 +59,7 @@ and this extension's sender ID; the [router](../background.js) rejects unknown a | `EXTRACT_KEY_INSIGHTS`, `GENERATE_SMART_TAGS`, `EXTRACT_ENTITIES` | `text`; requested analysis | | `EXTRACT_PAGE_CONTENT` | `tabId`, falling back to sender tab; extraction | | `GET_USER_PREFERENCES`, `UPDATE_USER_PREFERENCES` | Read settings or merge a settings payload | +| `RESET_USER_PREFERENCES` | Confirmed reset; replace settings, including API keys, with defaults | | `GET_PROVIDER_STATUS` | Selected provider and configured-key status | | `TEST_PROVIDER_CONNECTION` | Entered `provider`, `apiKey`, optional `model`; 16-token request, 10s timeout, no retries; never saves key | | `GET_DOCUMENT_HISTORY` | `url`, `content`; summary, up to 30 exchanges, `memoryEnabled` | @@ -74,6 +76,9 @@ The content script separately accepts `{ action: 'extractContent' }`. `ConfigurationManager` owns `chrome.storage.sync.user_preferences`: keys, models, provider, summary/language/theme preferences, and feature flags. +Options and popup send mutations to one worker manager. Initialization, updates, +and confirmed resets share a recoverable queue; reads in Options remain local. +This orders this extension's writes, not updates from other synced devices. Keys are plaintext at the API level, sync with the browser profile, and are excluded from exports. Unreadable preferences cannot authorize optional effects or replacement updates. See [security and data handling](SECURITY.md). @@ -84,6 +89,7 @@ or replacement updates. See [security and data handling](SECURITY.md). worker, including clear-chat and cleanup. A failed mutation read propagates instead of overwriting history with an empty list; read-only access retains its logged fallback. +Worker startup preserves an existing cleanup alarm instead of resetting its delay. ### Document retrieval, cache, and memory @@ -111,6 +117,11 @@ warning, without another provider request. popup startup queries the active tab. User-selected UTF-8 text/Markdown imports validate 8 MB and 2,000,000-character limits. Their identity uses `local-document:`, filename, and content hash. Extraction/import sends no AI request. +Extraction reads visible DOM text without changing the source page. Pending +extraction, analysis, and links cannot populate a subsequently imported document. +Chat restoration cannot undo a successful clear or a new exchange; saved summaries +restore independently without replacing a fresh result. Provider deadlines cover +response headers and body consumption; fatal section failures stop queued work. ## Local validation diff --git a/docs/product/INTERFACE.md b/docs/product/INTERFACE.md new file mode 100644 index 0000000..f4853bd --- /dev/null +++ b/docs/product/INTERFACE.md @@ -0,0 +1,84 @@ +# Reading interface: decisions and verification + +The [#36 milestone](https://github.com/aaron-seq/GenAI-Browser-Tool/issues/36) +replaces the gradient/emoji interface with a quiet reading desk. The source stays +beside the five tools on wide screens and above them on narrow screens. Task +labels describe actions; provider, memory, coverage, and source excerpts stay visible. + +Current combined UI/reliability validation is recorded in the +[reliability evidence](../reliability-evidence.md); dated results below describe +the original interface revisions. + +![Summary workspace](images/reading-summary.png) + +## Design decisions + +- Neutral surfaces, a restrained teal accent, system fonts, and local inline SVG + icons. No font requests, runtime dependency, or permission was added. +- Main content has a bounded reading width. The chat composer fits the tested + 1280 × 720 desktop view; conversation history scrolls separately. +- Short study labels retain their full prompts in `data-prompt`. Import stays + available while using another task. Existing history and provider routes are reused. +- Native radios remain focusable, with a visible group focus indicator. Tabs + expose selected/hidden states and follow the [WAI tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/). + Focus remains in the tab list; Tab enters the selected panel. +- [Visible focus](https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html), + reduced motion, forced colors, and light/dark tokens have explicit treatment. + Calculated muted-text contrast against the page is 5.22:1 light / 7.55:1 dark; + input-boundary contrast is 3.21:1 / 4.41:1. This is a token check, not a complete + accessibility conformance audit or a screen-reader test. + +The [Comet product page](https://www.perplexity.ai/comet) provides context for +page-based assistance and learning. It was inspected as a product reference; +no comparative usability, live-model quality, or latency test was performed. + +## Verification — 23 October 2026 + +Local Windows / Node 22.18.0 / npm 11.6.2 results: + +| Command / check | Observed result | +| ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `npm run verify` on main b584f4e | Lint/typecheck and 297 tests pass before changes. | +| `npx vitest run tests/popup/interface.test.js -t 'selected tab\|complete study prompt'` on test-only commit f6f9e2b | Both new cases fail before implementation. | +| `npx playwright test tests/e2e/reading-interface.spec.js` before implementation | Cannot locate the hidden summary radio through its accessible role; fails. | +| `npm run lint -- --max-warnings 0`; `npm run typecheck` | Pass. | +| `npm run test:coverage` | 299 tests pass; 94.53% statements, 83.73% branches, 94.71% functions, 96.70% lines. Floors unchanged. | +| `npm run test:e2e` | 19 real-Chromium tests pass; provider responses are intercepted. | +| Final affected popup tests and two workspace/interface browser tests | Pass after copy/contrast/layout refinements. | +| `npm audit --audit-level=high` | Zero vulnerabilities reported. | + +The [unit tests](../../tests/popup/interface.test.js) verify tab semantics/focus, +full study prompts, empty/result transitions, and existing task behavior. The +[browser test](../../tests/e2e/reading-interface.spec.js) checks native radio +keyboard operation, panel focus order, all five tabs at 768/400/320px widths, +theme switching, source extraction in the 400px popup, and horizontal overflow. +Screenshots were generated by these browser tests and visually inspected. Opening a source +excerpt scrolls it into view within the conversation. A new browser assertion failed +before that adjustment (viewport intersection 0) and passes with the excerpt fully visible. + +Global branch coverage is 83.73% versus the prior published 84.05%; the added UI +branches are not all exercised by the unit suite. The source-scroll callback is +verified in the real browser test. Existing tests and thresholds are preserved. + +![Dark conversation workspace](images/reading-dark.png) + +No provider speed improvement is claimed. Native toolbar activation, real +screen readers, Edge, OS notifications, real browser zoom, and live-model quality +were not tested. The settings/onboarding design remains in +[#37](https://github.com/aaron-seq/GenAI-Browser-Tool/issues/37); the separately +reproduced notification read-failure gap is in +[#38](https://github.com/aaron-seq/GenAI-Browser-Tool/issues/38). + +## Integration verification — 28 October 2026 + +Integrated current main [bd0e0f7](https://github.com/aaron-seq/GenAI-Browser-Tool/commit/bd0e0f7e6a648117a801aea38a161ee2fad9f8ab) +into the interface branch and resolved the README, changelog, and product-brief +conflicts. The newer settings-failure fix from [#42](https://github.com/aaron-seq/GenAI-Browser-Tool/pull/42) +is retained; the #38 limitation above describes the original October 23 revision. + +On Windows, Node 22.18.0 / npm 11.6.2: lint with zero warnings, typecheck, +313 unit/integration tests, and 21 real-Chromium tests pass. Coverage is 94.68% +statements, 84.11% branches, 94.75% functions, and 96.84% lines, with the existing +floors unchanged. The npm dry run contains 25 files and excludes development +configs, tests, and docs. Provider responses remain intercepted; the native-entry, +screen-reader, OS, and live-provider limits recorded above still apply. diff --git a/docs/product/PRODUCT.md b/docs/product/PRODUCT.md index d4a9c1a..e573d65 100644 --- a/docs/product/PRODUCT.md +++ b/docs/product/PRODUCT.md @@ -1,6 +1,6 @@ # Reading and learning workspace -Updated 27 October 2026 for version 5.3.0. This is the maintained product brief; +Updated 2 November 2026 for the unreleased interface/reliability revision. This is the maintained product brief; the [supplied engineering brief](ENGINEERING-BRIEF.md) is preserved separately. ## Objective @@ -19,6 +19,9 @@ repeat work fast without representing sampled evidence as complete understanding keyed by content, provider, model, style, and length. - Persisted language selection, explicit coverage notices, and optional provider connection tests using the entered configuration. +- A restrained reading interface, persistent source panel, keyboard-accessible + tabs and summary lengths, and short study actions. See the + [interface decisions and evidence](INTERFACE.md). [README](../../README.md) explains installation and use. [Browser tests](../../tests/e2e/learning-workspace.spec.js) demonstrate these flows with intercepted provider responses. They do not prove @@ -39,9 +42,10 @@ This comparison is based on its documentation, not a hands-on latency or quality ## Next steps and material gaps -[Cleanup #35](https://github.com/aaron-seq/GenAI-Browser-Tool/issues/35) corrects -contribution examples and consolidates documentation/packaging. Current delivery -and validation are linked from the [checkpoint](PROJECT-STATUS.md). +[Cleanup #35](https://github.com/aaron-seq/GenAI-Browser-Tool/issues/35) merged through +[#44](https://github.com/aaron-seq/GenAI-Browser-Tool/pull/44), correcting contribution +examples and consolidating documentation/packaging. Current delivery and validation +are linked from the [checkpoint](PROJECT-STATUS.md). The [settings-failure milestone](https://github.com/aaron-seq/GenAI-Browser-Tool/milestone/1) protects saved privacy choices and recovery without adding a backend or permission. @@ -49,8 +53,12 @@ Its implementation merged through [PR #42](https://github.com/aaron-seq/GenAI-Br see the [checkpoint](PROJECT-STATUS.md) and [verification record](../security-testing-evidence.md). The separate [reading-interface draft #39](https://github.com/aaron-seq/GenAI-Browser-Tool/pull/39) -also awaits review. The [private dashboard](https://github.com/users/aaron-seq/projects/6) -tracks both without counting an issue and its PR as separate outcomes. +now also carries the seven reliability fixes under +[milestone 3](https://github.com/aaron-seq/GenAI-Browser-Tool/milestone/3). +The [bug board](https://github.com/users/aaron-seq/projects/10) tracks #46–#52; +the [delivery dashboard](https://github.com/users/aaron-seq/projects/6) retains +the remaining verification and tooling work, including the new published +[dependency-advisory follow-up #54](https://github.com/aaron-seq/GenAI-Browser-Tool/issues/54). CI action and runner validation is delivered through [PR #43 / issue #32](https://github.com/aaron-seq/GenAI-Browser-Tool/issues/32), diff --git a/docs/product/PROJECT-STATUS.md b/docs/product/PROJECT-STATUS.md index 6793301..4f715c4 100644 --- a/docs/product/PROJECT-STATUS.md +++ b/docs/product/PROJECT-STATUS.md @@ -1,36 +1,42 @@ # Project status and continuation -Checkpoint: 27 October 2026 (Asia/Calcutta). -[GitHub issues](https://github.com/aaron-seq/GenAI-Browser-Tool/issues) · -[private dashboard](https://github.com/users/aaron-seq/projects/6) · -[product / roadmap](PRODUCT.md). - -## Current state - -- Cleanup branch: `codex/cleanup-docs-and-package`, based on main - [`6e82885`](https://github.com/aaron-seq/GenAI-Browser-Tool/commit/6e8288550ef4935684a6a44c87cbbed77b818d14). - [#35](https://github.com/aaron-seq/GenAI-Browser-Tool/issues/35) tracks corrected - contribution examples, consolidated docs, package contents, and current validation. -- [#42](https://github.com/aaron-seq/GenAI-Browser-Tool/pull/42) (settings failures) - and [#43](https://github.com/aaron-seq/GenAI-Browser-Tool/pull/43) (CI actions) - merged. The [post-merge CI run](https://github.com/aaron-seq/GenAI-Browser-Tool/actions/runs/40081266808) - passed; historical details remain in [settings](../security-testing-evidence.md) - and [CI evidence](../ci-modernization-evidence.md). No release/deployment is implied. -- [UI draft #39](https://github.com/aaron-seq/GenAI-Browser-Tool/pull/39) remains - separate; rebase and combined validation are required before integration. -- Restricted triage remains open under the owner's instruction. Its access-controlled - GitHub record/private Project item is authoritative; no details belong here. +Checkpoint: 2 November 2026 (Asia/Calcutta). +[Delivery dashboard](https://github.com/users/aaron-seq/projects/6) · +[Bug board](https://github.com/users/aaron-seq/projects/10) · [Product roadmap](PRODUCT.md). + +## Current delivery + +[Draft PR #39](https://github.com/aaron-seq/GenAI-Browser-Tool/pull/39) combines the +reading-interface work (#36) with the seven verified reliability fixes (#46–#52). +Community [PR #53](https://github.com/aaron-seq/GenAI-Browser-Tool/pull/53), which +addresses the same #49 failure, is incorporated with its contributor history. +[Milestone 3](https://github.com/aaron-seq/GenAI-Browser-Tool/milestone/3) requires +preserved settings edits, document/chat response isolation, stopped queued work +after failure, stable cleanup scheduling, bounded response bodies, and visible-text +extraction. Combined local checks and fresh agent review pass; use the PR's +exact head for hosted CI and [evidence](../reliability-evidence.md) for results/limits. +Open issues are not counted as delivered before acceptance/merge. + +Main remains [bd0e0f7](https://github.com/aaron-seq/GenAI-Browser-Tool/commit/bd0e0f7e6a648117a801aea38a161ee2fad9f8ab): +settings failure handling #42, CI modernization #43, and documentation/package +cleanup #44 are already merged. No release or deployment is implied. + +## Remaining work + +- [#40 native entry/OS verification](https://github.com/aaron-seq/GenAI-Browser-Tool/issues/40), + [#41 scanning setup](https://github.com/aaron-seq/GenAI-Browser-Tool/issues/41), + [#45 optional Codecov authentication](https://github.com/aaron-seq/GenAI-Browser-Tool/issues/45), + and [#54 development dependency advisory](https://github.com/aaron-seq/GenAI-Browser-Tool/issues/54). +- [#37 Settings design](https://github.com/aaron-seq/GenAI-Browser-Tool/issues/37), + PDF/OCR, semantic RAG, long translation/analysis, and user-data import retain + their existing [roadmap issues](PRODUCT.md#next-steps-and-material-gaps). +- Live-provider quality, real-book recall/latency/cost, minimum-browser/macOS, + screen readers, and production recovery remain unverified. Restricted triage + stays open under the owner's instruction; its access-controlled record is authoritative. ## Resume here -Check current Git status, main, linked issue/PR heads, comments, and CI before work. -Use [development validation](../DEVELOPMENT.md#local-validation); record actual -results and limits on the issue/PR rather than duplicating them in this checkpoint. -Dashboard statuses are manual. Draft review feedback stays unpublished until authorized. - -Next: review #39 against current main; verify [native entry/OS behavior (#40)](https://github.com/aaron-seq/GenAI-Browser-Tool/issues/40); -propose [scanner setup (#41)](https://github.com/aaron-seq/GenAI-Browser-Tool/issues/41). -The optional [coverage-upload failure (#45)](https://github.com/aaron-seq/GenAI-Browser-Tool/issues/45) -remains separate from passing local coverage gates. -Live-provider quality, minimum-browser/macOS support, real-document recall/latency/cost, -and production recovery remain unverified. Long-document work stays in the product roadmap. +Check worktree status, remote heads, PR comments, and current-head CI before work. +Use [development validation](../DEVELOPMENT.md#local-validation). Keep issue/project +statuses accurate, retain the supplied engineering brief unchanged, and keep draft +review comments unpublished until requested. Merge and publication are separate decisions. diff --git a/docs/product/images/learning-workspace.png b/docs/product/images/learning-workspace.png index e430e89..bf1ddb6 100644 Binary files a/docs/product/images/learning-workspace.png and b/docs/product/images/learning-workspace.png differ diff --git a/docs/product/images/reading-dark.png b/docs/product/images/reading-dark.png new file mode 100644 index 0000000..6923030 Binary files /dev/null and b/docs/product/images/reading-dark.png differ diff --git a/docs/product/images/reading-summary.png b/docs/product/images/reading-summary.png new file mode 100644 index 0000000..9adddde Binary files /dev/null and b/docs/product/images/reading-summary.png differ diff --git a/docs/reliability-evidence.md b/docs/reliability-evidence.md new file mode 100644 index 0000000..96cad7a --- /dev/null +++ b/docs/reliability-evidence.md @@ -0,0 +1,73 @@ +# Reading workspace reliability evidence + +Delivery: [PR #39](https://github.com/aaron-seq/GenAI-Browser-Tool/pull/39), +[milestone 3](https://github.com/aaron-seq/GenAI-Browser-Tool/milestone/3). +Baseline: [d544b8e](https://github.com/aaron-seq/GenAI-Browser-Tool/commit/d544b8e7936e9e1959eb71bcc9ebbe6da2dce7b1), +which already includes main bd0e0f7 and the reading-interface changes. +The [checkpoint](product/PROJECT-STATUS.md) records remaining work. + +## Requirements and regression evidence + +| Issue | Behavior and regression oracle | +| ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [#46](https://github.com/aaron-seq/GenAI-Browser-Tool/issues/46) | One worker writer queues initialize/update/reset; overlapping field/map edits survive. [Core](../tests/core/configuration-manager.test.js), [Options](../tests/options/settings.test.js), [router](../tests/integration/extension-workflow.test.js), and [two-context browser tests](../tests/e2e/settings-concurrency.spec.js) check ordering, failure recovery, final native storage and writer ownership. Popup changes save only the edited language; explicit swap saves both. | +| [#47](https://github.com/aaron-seq/GenAI-Browser-Tool/issues/47) | Delayed extraction/analysis/links successes and errors cannot populate an imported document. [Popup](../tests/popup/interface.test.js) and [browser races](../tests/e2e/workspace-races.spec.js) check the selected title, result panels and document context. | +| [#48](https://github.com/aaron-seq/GenAI-Browser-Tool/issues/48) | Old history cannot undo successful clear/new chat. Same-document summary restoration remains independent, and fresh summaries win. Tests check DOM, subsequent prompt history and actual cleared storage; a failed clear still permits restoration. | +| [#49](https://github.com/aaron-seq/GenAI-Browser-Tool/issues/49) | [Mapper](../tests/core/concurrency.test.js)/worker tests hold sibling requests, fail the first, then release siblings: three calls stay three, with no merge/history save. Already-started calls may finish. | +| [#50](https://github.com/aaron-seq/GenAI-Browser-Tool/issues/50) | Worker lifecycle tests preserve an existing scheduled time, create missing alarms and contain get/create failures while registering listeners synchronously. Timing/restarts are simulated, not a real 24-hour run. | +| [#51](https://github.com/aaron-seq/GenAI-Browser-Tool/issues/51) | [Provider tests](../tests/providers/ai-client.test.js) delay 200/503/401 bodies past the deadline: terminal TIMEOUT and one call. Malformed success JSON is not retried as a network error. | +| [#52](https://github.com/aaron-seq/GenAI-Browser-Tool/issues/52) | [Extraction unit tests](../tests/content/extraction.test.js) and [real Chromium](../tests/e2e/extension-loading.spec.js) check hidden text, collapsed disclosures/ancestors, visible blocks/inline words and unchanged source HTML. Extraction-to-chunking preserves all 30 fixture paragraphs across two chunks. | + +The implementation sessions ran new failing regressions before their respective +fixes. Primary-session checks independently reproduced the UI races and reran +provider/worker suites. Fresh agent review caught shared-response summary restoration, +stale popup language payloads, collapsed disclosures and paragraph chunking gaps. +Each was reproduced, corrected, and rechecked, including actual Chromium diagnostics. +Agent review is not independent human approval. + +The final open-PR check also found community +[PR #53](https://github.com/aaron-seq/GenAI-Browser-Tool/pull/53) at +[4d31f67](https://github.com/imshah-id/GenAI-Browser-Tool/commit/4d31f6773f58b4803f30b80ccfe7dd73369a32fc), +which implements the same #49 stop flag. Its commit is incorporated into this +delivery with its scheduling documentation. Duplicate tests are consolidated into +the existing mapper/worker regressions (three calls after failure, original error, +no partial merge/history). Runtime source and the validated tests remain unchanged. + +## Validation — 2 November 2026 + +The primary session ran combined checks on source revision +[9c3db4b](https://github.com/aaron-seq/GenAI-Browser-Tool/commit/9c3db4b6c3793bc756d4ec6d2211dd5c520419c5), +followed by documentation-only updates. Environment: Windows, Node 22.18.0, +npm 11.6.2. Current-head hosted CI and ZIP inspection are recorded in the PR body. + +| Command/check | Observed result | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `npm run verify` | Lint/typecheck and 361 tests in 13 files pass. | +| `npm run test:coverage` | 361 pass; statements 95.01%, branches 85.16%, functions 95.60%, lines 97.02%. Existing floors retained. | +| `npm run test:e2e` | 33 real-Chromium cases pass, including UI keyboard/layout, settings, extraction and delayed-response flows. | +| `npm audit --json --audit-level=high` | Exit 0; five moderate affected development packages, zero high/critical. | +| `npm pack --dry-run --json --ignore-scripts` | 25 files; no tests/docs/tooling/dependencies. Read-only reference check resolves 26 manifest/HTML/JS targets within that inventory. | +| Visual inspection | Generated 320px light workspace, desktop dark Chat and populated source-excerpt views inspected; no clipping observed in those views. | + +A primary-session native Node fetch diagnostic used a loopback server that sent +headers and an incomplete JSON body. With a 150ms deadline, statuses 200/503/401 +returned TIMEOUT in 168/164/155ms, signal aborted, one request each despite retries +being enabled. Only the destination was substituted; no real provider/key was used. +This diagnostic is distinct from the committed fake-timer regressions and does not +establish live-provider quality or latency. + +The fresh baseline audit reports five moderate affected development packages, +zero high/critical; [#54](https://github.com/aaron-seq/GenAI-Browser-Tool/issues/54) +tracks the published minimatch advisory. Passing `--audit-level=high` is not a clean +audit. This delivery changes no dependency, scanner configuration, permission, +provider/model default, storage schema, or deployed service. + +Browser tests load the unpacked extension and intercept fixture/provider traffic; +the race harness delays delivery after the real worker responds. Two-context +settings tests hold writes but use native Chrome reads/persistence. No live-provider, +native-toolbar/OS notification, minimum-version, screen-reader, or real-book +quality/performance claim follows. The original interface verification and visual +decisions remain in [INTERFACE.md](product/INTERFACE.md). + +Rollback reverts the combined PR and reloads the extension; no migration is needed. +That restores the defects, so it is a recovery option rather than a permanent fix. diff --git a/options.js b/options.js index 77f7868..cef1622 100644 --- a/options.js +++ b/options.js @@ -137,7 +137,12 @@ class OptionsPage { if (!update) return; try { - this.settings = await this.configManager.updateUserPreferences(update); + const response = await chrome.runtime.sendMessage({ + actionType: 'UPDATE_USER_PREFERENCES', + payload: update, + }); + if (!response.success) throw new Error(response.error); + this.settings = response.data; this.showSaveIndicator(); } catch (error) { /** @type {any} */ @@ -149,12 +154,12 @@ class OptionsPage { async resetSettings() { if (!confirm('Reset all settings, including saved API keys?')) return; try { - await this.configManager.setUserPreferences( - this.configManager.defaultSettings - ); - this.settings = this.configManager.withDefaults( - this.configManager.defaultSettings - ); + const response = await chrome.runtime.sendMessage({ + actionType: 'RESET_USER_PREFERENCES', + payload: {}, + }); + if (!response.success) throw new Error(response.error); + this.settings = response.data; this.render(); this.showSaveIndicator('Settings reset to defaults'); } catch { diff --git a/popup.html b/popup.html index 1958aab..3cc3d47 100644 --- a/popup.html +++ b/popup.html @@ -3,66 +3,125 @@ - GenAI Browser Assistant - + GenAI · Reading workspace
-
-
-
-
🤖
-

GenAI Assistant

-
-
- - - -
+ + +

GenAI Reading workspace

-
+
- - Initializing... -
-
- Loading... + Initializing…
+ + + +
+
+ + - - -
- -
-
-
-

- Read a page, understand the ideas, and keep learning. -

- -

- Import stays in this tab. Text is sent to your selected provider - only when you request an AI action. Up to 2 million characters. -

+
+
+
+ The big picture +

Summarize this document.

+

+ A focused summary of your source, at the level of detail you need. +

+
+
+
+
-
-

Content Summary

-
- Short + + - - - - - -
- - - -
-
- -
- -
- - +
+
-

Summary Result

-
- - -
-
-
+ + + +

Your summary starts here.

+

+ Your summary will appear here, with the provider and source + coverage. You can copy it or save it as a file. +

+ Choose a format above, then generate. +
+
- - -
+ - - -
-
-
-

Translation Tools

-
- -
-
-
- - -
-
- -
-
- - -
+ - - -
-
-
-

Content Analysis

-
- -
-
-
-

📊 Sentiment Analysis

- -
-
-
Click analyze to start
-
+
-
- - -
-
-
-

Productivity Tools

-
- -
-
-
📤
-

Export Data

-

- Export your settings, saved summaries, and chat history as - JSON -

- +

+ Find the topics that connect this source. +

+
+ No result yet
- -
-
📊
-

Page Stats

-

Get detailed statistics about this page

-
- -
-
🔗
-

Extract Links

-

Extract and categorize all page links

-
-
- -
+
- - -
-
-
Processing...
+
+ +
Processing…
+

Your source is being processed. This can take a moment.

- - -
+
- diff --git a/providers/ai-client.js b/providers/ai-client.js index 09b24bd..1736c3a 100644 --- a/providers/ai-client.js +++ b/providers/ai-client.js @@ -211,6 +211,29 @@ export class AIClient { body, signal: controller.signal, }); + + if (!response.ok) { + const retryable = RETRYABLE_STATUSES.has(response.status); + const result = { + ok: /** @type {false} */ (false), + retryable, + error: new AIError( + response.status === 401 || response.status === 403 + ? 'AUTH_ERROR' + : 'PROVIDER_ERROR', + // The provider's own message is the useful part; it never contains the key. + `${spec.label} returned ${response.status}: ${await readErrorMessage(response)}` + ), + }; + const retryAfterMs = parseRetryAfter( + response.headers?.get('retry-after') + ); + return retryAfterMs === undefined + ? result + : { ...result, retryAfterMs }; + } + + return { ok: true, text: spec.parse(await response.json()).trim() }; } catch (error) { /** @type {any} */ const err = error; @@ -226,6 +249,8 @@ export class AIClient { ), }; } + // A received response that cannot be parsed is not a retryable fetch failure. + if (response) throw error; return { ok: false, retryable: true, @@ -237,27 +262,6 @@ export class AIClient { } finally { clearTimeout(timer); } - - if (!response.ok) { - const retryable = RETRYABLE_STATUSES.has(response.status); - const result = { - ok: /** @type {false} */ (false), - retryable, - error: new AIError( - response.status === 401 || response.status === 403 - ? 'AUTH_ERROR' - : 'PROVIDER_ERROR', - // The provider's own message is the useful part; it never contains the key. - `${spec.label} returned ${response.status}: ${await readErrorMessage(response)}` - ), - }; - const retryAfterMs = parseRetryAfter( - response.headers?.get('retry-after') - ); - return retryAfterMs === undefined ? result : { ...result, retryAfterMs }; - } - - return { ok: true, text: spec.parse(await response.json()).trim() }; } } @@ -297,8 +301,8 @@ export function parseRetryAfter(value) { } /** - * Pull a human-readable message out of an error response without ever throwing - * a second time — a malformed error body must not mask the original failure. + * Pull a human-readable message out of an error response. Malformed bodies must + * not mask the original failure; aborts propagate to enforce the request deadline. * * @param {Response} response * @returns {Promise} @@ -312,7 +316,8 @@ async function readErrorMessage(response) { response.statusText || 'unknown error' ); - } catch { + } catch (error) { + if (/** @type {Error} */ (error)?.name === 'AbortError') throw error; return response.statusText || 'unknown error'; } } diff --git a/scripts/popup-main.js b/scripts/popup-main.js index c52cf9b..0f77a7d 100644 --- a/scripts/popup-main.js +++ b/scripts/popup-main.js @@ -21,6 +21,9 @@ class PopupInterface { /** @type {any} */ this.preferences = null; this.warnedAboutChatTruncation = false; + this.documentGeneration = 0; + this.chatGeneration = 0; + this.chatHistoryPending = false; /** @type {number | null} */ this.sourceTabId = null; const params = new URLSearchParams(window.location.search); @@ -31,17 +34,20 @@ class PopupInterface { } async initialize() { + const generation = this.documentGeneration; this.setupEventListeners(); try { await this.applyPreferences(); await this.updateProviderStatus(); - await this.extractCurrentPageContent(); + if (this.documentGeneration !== generation) return; + await this.extractCurrentPageContent(generation); } catch (error) { const err = /** @type {Error} */ (error); + if (this.documentGeneration !== generation) return; this.setPageInfo('Could not read this document', err.message); this.showToast(err.message, 'error'); } finally { - this.hideLoading(); + if (!this.isProcessing) this.hideLoading(); } } @@ -90,6 +96,26 @@ class PopupInterface { .tab; if (tab) this.switchToTab(tab); }); + button.addEventListener('keydown', event => { + const e = /** @type {KeyboardEvent} */ (event); + const tabs = Array.from(document.querySelectorAll('.tab-button')); + const current = tabs.indexOf(button); + const next = + e.key === 'Home' + ? 0 + : e.key === 'End' + ? tabs.length - 1 + : e.key === 'ArrowRight' + ? (current + 1) % tabs.length + : e.key === 'ArrowLeft' + ? (current + tabs.length - 1) % tabs.length + : -1; + if (next < 0) return; + e.preventDefault(); + const target = /** @type {HTMLElement} */ (tabs[next]); + if (target.dataset.tab) this.switchToTab(target.dataset.tab); + target.focus(); + }); }); on('settings-btn', 'click', () => chrome.runtime.openOptionsPage()); @@ -116,7 +142,9 @@ class PopupInterface { chip.addEventListener('click', event => { setValue( 'chat-input', - /** @type {HTMLElement} */ (event.target).textContent || '' + /** @type {HTMLElement} */ (event.currentTarget).dataset.prompt || + /** @type {HTMLElement} */ (event.currentTarget).textContent || + '' ); this.sendChatMessage(); }); @@ -124,8 +152,12 @@ class PopupInterface { on('translate-btn', 'click', () => this.translateContent()); on('swap-languages', 'click', () => this.swapLanguages()); - on('source-language', 'change', () => this.persistLanguages()); - on('target-language', 'change', () => this.persistLanguages()); + on('source-language', 'change', () => + this.persistLanguages('sourceLanguage') + ); + on('target-language', 'change', () => + this.persistLanguages('targetLanguage') + ); on('copy-translation-btn', 'click', () => this.copyToClipboard('translation-content') ); @@ -146,19 +178,24 @@ class PopupInterface { /** @param {string} tabName */ switchToTab(tabName) { - document - .querySelectorAll('.tab-button.active, .tab-pane.active') - .forEach(el => el.classList.remove('active')); - document - .querySelector(`.tab-button[data-tab="${tabName}"]`) - ?.classList.add('active'); - document.getElementById(`${tabName}-tab`)?.classList.add('active'); - if (tabName === 'chat') document.getElementById('chat-input')?.focus(); + document.querySelectorAll('.tab-button').forEach(button => { + const tab = /** @type {HTMLElement} */ (button); + const selected = tab.dataset.tab === tabName; + tab.classList.toggle('active', selected); + tab.setAttribute('aria-selected', String(selected)); + tab.tabIndex = selected ? 0 : -1; + const panel = document.getElementById(`${tab.dataset.tab}-tab`); + if (panel) { + panel.classList.toggle('active', selected); + panel.hidden = !selected; + } + }); } // ------------------------------------------------------------------ content - async extractCurrentPageContent() { + /** @param {number} [generation] */ + async extractCurrentPageContent(generation = this.documentGeneration) { const requestedTab = Number( new URLSearchParams(window.location.search).get('tabId') ); @@ -167,6 +204,8 @@ class PopupInterface { ? await chrome.tabs.get(requestedTab) : (await chrome.tabs.query({ active: true, currentWindow: true }))[0]; + if (this.documentGeneration !== generation) return; + if (!activeTab?.id || (activeTab.url && !/^https?:/.test(activeTab.url))) { this.setPageInfo( 'This page cannot be read', @@ -181,17 +220,22 @@ class PopupInterface { const response = await this.send('EXTRACT_PAGE_CONTENT', { tabId: activeTab.id, }); + if (this.documentGeneration !== generation) return; if (!response.success) { this.setPageInfo(activeTab.title || 'Untitled', response.error); return; } - await this.acceptDocument(response.data); + await this.acceptDocument(response.data, generation); } - /** @param {any} content */ - async acceptDocument(content) { + /** @param {any} content @param {number} [generation] */ + async acceptDocument(content, generation) { validateDocument(content.mainText); + if (generation === undefined) generation = ++this.documentGeneration; + if (generation !== this.documentGeneration) return; + const chatGeneration = ++this.chatGeneration; + this.chatHistoryPending = true; this.pageContent = content; this.conversationHistory = []; this.lastSummary = null; @@ -199,6 +243,7 @@ class PopupInterface { this.warnedAboutChatTruncation = false; if (content.url?.startsWith('local-document:')) this.sourceTabId = null; hide('summary-results'); + show('summary-empty', 'flex'); hide('translation-results'); hide('tools-results'); document.querySelectorAll('.analysis-result').forEach(result => { @@ -225,6 +270,10 @@ class PopupInterface { content: content.mainText, }); if (this.pageContent !== content) return; + if (response.success && response.data.summaries?.[0] && !this.lastSummary) + this.renderSummary(response.data.summaries[0], true); + if (this.chatGeneration !== chatGeneration) return; + this.chatHistoryPending = false; const conversations = response.success ? response.data.conversations || [] : []; @@ -237,8 +286,6 @@ class PopupInterface { { role: 'assistant', content: exchange.answer } ); } - if (response.success && response.data.summaries?.[0]) - this.renderSummary(response.data.summaries[0], true); setText( 'chat-memory-status', !response.success @@ -291,17 +338,26 @@ class PopupInterface { content: this.pageContent.mainText, }); if (!response.success) throw new Error(response.error); + this.chatGeneration += 1; + this.chatHistoryPending = false; this.conversationHistory = []; setHtml('chat-history', ''); setText('chat-memory-status', 'Chat cleared for this document'); }); } - async persistLanguages() { - const response = await this.send('UPDATE_USER_PREFERENCES', { + /** @param {'sourceLanguage' | 'targetLanguage'} [changed] */ + async persistLanguages(changed) { + const languages = { targetLanguage: value('target-language'), sourceLanguage: value('source-language'), - }); + }; + // A single control change must not overwrite a sibling edited in Settings. + // An explicit swap changes both controls and therefore persists both. + const response = await this.send( + 'UPDATE_USER_PREFERENCES', + changed ? { [changed]: languages[changed] } : languages + ); if (!response.success) this.showToast(response.error, 'error'); } @@ -335,6 +391,7 @@ class PopupInterface { `${cached ? 'Saved summary · ' : ''}${describeCoverage(data)}` ); show('summary-results'); + hide('summary-empty'); if (data.historyWarning) { setText('summary-confidence', `${describeCoverage(data)} · Not saved`); this.showToast(data.historyWarning, 'warning'); @@ -346,6 +403,11 @@ class PopupInterface { const question = value('chat-input').trim(); if (!question || !this.requireContent()) return; + this.chatGeneration += 1; + if (this.chatHistoryPending) { + this.chatHistoryPending = false; + setText('chat-memory-status', 'Current conversation'); + } setValue('chat-input', ''); this.addChatMessage('user', question); @@ -419,6 +481,7 @@ class PopupInterface { /** @param {string} type */ async runAnalysis(type) { if (!this.requireContent()) return; + const content = this.pageContent; const target = document.getElementById(`${type}-result`); if (target) target.innerHTML = '
'; @@ -443,8 +506,9 @@ class PopupInterface { if (!action) throw new Error(`No analysis available for "${type}"`); const response = await this.send(action, { - text: this.pageContent.mainText, + text: content.mainText, }); + if (this.pageContent !== content) return; if (!response.success) throw new Error(response.error); if (!target) return; @@ -465,7 +529,8 @@ class PopupInterface { } catch (error) { /** @type {any} */ const err = error; - if (target) target.textContent = err.message; + if (this.pageContent === content && target) + target.textContent = err.message; } } @@ -485,6 +550,7 @@ class PopupInterface { } async extractPageLinks() { + const content = this.pageContent; if (!this.sourceTabId) { this.showToolsResult( '

Links are available for a source web page. Imported text has no live page links.

' @@ -496,6 +562,7 @@ class PopupInterface { const response = await chrome.tabs.sendMessage(this.sourceTabId, { action: 'extractLinks', }); + if (this.pageContent !== content) return; if (!response?.success) throw new Error(response?.error || 'Could not read links'); @@ -511,7 +578,8 @@ class PopupInterface { } catch (error) { /** @type {any} */ const err = error; - this.showToolsResult(`

${escapeHtml(err.message)}

`); + if (this.pageContent === content) + this.showToolsResult(`

${escapeHtml(err.message)}

`); } } @@ -623,7 +691,7 @@ class PopupInterface { const avatar = document.createElement('div'); avatar.className = `${role}-avatar`; - avatar.textContent = role === 'user' ? '👤' : '🤖'; + avatar.textContent = role === 'user' ? 'You' : 'GenAI'; const body = document.createElement('div'); body.className = 'message-content'; @@ -639,6 +707,9 @@ class PopupInterface { if (!Array.isArray(sources) || !sources.length) return; const details = document.createElement('details'); details.className = 'chat-sources'; + details.addEventListener('toggle', () => { + if (details.open) details.scrollIntoView?.({ block: 'nearest' }); + }); const summary = document.createElement('summary'); summary.textContent = retrieval ? `${sources.length} of ${retrieval.total} passages used · ${retrieval.mode === 'overview' ? 'representative overview' : 'matched to your question'}` @@ -703,10 +774,12 @@ class PopupInterface { /** @param {string} message */ showLoading(message) { setText('loading-text', message); + document.getElementById('main-content')?.setAttribute('aria-busy', 'true'); show('loading-overlay', 'flex'); } hideLoading() { + document.getElementById('main-content')?.setAttribute('aria-busy', 'false'); hide('loading-overlay'); } } diff --git a/styles/popup.css b/styles/popup.css index c638fe2..aa8774a 100644 --- a/styles/popup.css +++ b/styles/popup.css @@ -1,1017 +1,1178 @@ -/** - * @file styles/popup.css - * @description Modern styling for GenAI Browser Assistant popup - */ - -/* CSS Custom Properties */ +/* Reading desk: local system fonts, shared color tokens, no runtime assets. */ :root { - /* Colors */ - --primary-color: #4f46e5; - --primary-hover: #4338ca; - --secondary-color: #6b7280; - --success-color: #10b981; - --warning-color: #f59e0b; - --error-color: #ef4444; - --background-color: #ffffff; - --surface-color: #f9fafb; - --border-color: #e5e7eb; - --text-primary: #111827; - --text-secondary: #6b7280; - --text-inverse: #ffffff; - - /* Dark theme colors */ - --dark-background: #1f2937; - --dark-surface: #374151; - --dark-border: #4b5563; - --dark-text-primary: #f9fafb; - --dark-text-secondary: #d1d5db; - - /* Spacing */ - --spacing-xs: 0.25rem; - --spacing-sm: 0.5rem; - --spacing-md: 1rem; - --spacing-lg: 1.5rem; - --spacing-xl: 2rem; - - /* Border radius */ - --radius-sm: 0.375rem; - --radius-md: 0.5rem; - --radius-lg: 0.75rem; - - /* Shadows */ - --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); - --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); - --shadow-lg: - 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); - - /* Transitions */ - --transition-fast: 150ms ease-in-out; - --transition-normal: 250ms ease-in-out; -} - -/* Dark Theme */ + color-scheme: light; + --canvas: #f5f6f4; + --paper: #ffffff; + --subtle: #f8f9f7; + --ink: #202b28; + --muted: #60706a; + --line: #dce2de; + --control-line: #81948a; + --accent: #176354; + --accent-hover: #104b40; + --accent-soft: #e8f2ed; + --on-accent: #ffffff; + --danger: #ad3535; + --warning: #8b5d10; + --focus: #167562; +} [data-theme='dark'] { - --background-color: var(--dark-background); - --surface-color: var(--dark-surface); - --border-color: var(--dark-border); - --text-primary: var(--dark-text-primary); - --text-secondary: var(--dark-text-secondary); -} - -/* Base Styles */ + color-scheme: dark; + --canvas: #151c1a; + --paper: #1b2421; + --subtle: #202c27; + --ink: #e7eee9; + --muted: #a5b7ad; + --line: #3b4b43; + --control-line: #768c7f; + --accent: #93d5ba; + --accent-hover: #b0e4ce; + --accent-soft: #2a4035; + --on-accent: #142a20; + --danger: #ffaaa5; + --warning: #edc47d; + --focus: #a1e5c9; +} * { box-sizing: border-box; - margin: 0; - padding: 0; } - body { - font-family: - 'Inter', + margin: 0; + width: 400px; + max-width: 100%; + height: 600px; + font: + 14px/1.55 'Segoe UI', -apple-system, BlinkMacSystemFont, - 'Segoe UI', - Roboto, sans-serif; - font-size: 14px; - line-height: 1.5; - color: var(--text-primary); - background-color: var(--background-color); - width: 400px; - max-height: 600px; - overflow: hidden; -} - -/* App Container */ -.workspace-only { - display: none; -} - -.page-status { - margin-top: 8px; - font-size: 12px; - color: #fff; -} -.document-overview { - padding: 12px; - margin-bottom: 16px; - border: 1px solid var(--border-color); - border-radius: 12px; - background: var(--surface-color); -} -.document-import { - margin-top: 12px; - font-weight: 600; -} -.document-import input { - display: block; - margin-top: 8px; - max-width: 100%; -} -.helper-text, -.coverage-note { - font-size: 12px; - color: var(--text-secondary); - margin-top: 8px; + background: var(--canvas); + color: var(--ink); +} +button, +input, +select, +textarea { + font: inherit; +} +button, +select, +summary, +input[type='file'] { + cursor: pointer; } -.chat-memory-controls { - display: flex; - gap: 8px; - align-items: center; - justify-content: space-between; - padding: 10px 16px; - font-size: 12px; +button { + color: inherit; } -.chat-sources { - margin: 8px 0 16px 40px; - padding: 10px; - background: var(--surface-color); - border: 1px solid var(--border-color); - border-radius: 8px; - font-size: 12px; +button { + transition: + background-color 120ms ease, + border-color 120ms ease; } -.chat-sources summary { - cursor: pointer; - font-weight: 600; -} -.chat-sources p { - margin-top: 10px; - white-space: pre-wrap; - overflow-wrap: anywhere; - max-height: 280px; - overflow-y: auto; +button:disabled { + opacity: 0.55; + cursor: wait; } button:focus-visible, select:focus-visible, +textarea:focus-visible, input:focus-visible, -textarea:focus-visible { - outline: 2px solid var(--primary-color); +summary:focus-visible, +[tabindex='0']:focus-visible { + outline: 2px solid var(--focus); outline-offset: 3px; } - -body.workspace { - width: 100%; - max-height: none; - overflow: auto; -} -.workspace .app-container { - max-width: 1200px; - margin: 0 auto; - min-height: 100vh; - max-height: none; -} -.workspace .workspace-only { - display: block; -} -.workspace #open-workspace-btn { - display: none; -} -.workspace .page-title { - max-width: 70vw; -} -.workspace .content-section { - width: 100%; - max-width: 960px; - margin: 0 auto; -} -.workspace .chat-container { - height: calc(100vh - 250px); - min-height: 380px; - max-width: 1000px; - margin: 0 auto; - width: 100%; +h1, +h2, +h3, +p { + margin: 0; } -.workspace .tab-navigation { +svg { flex-shrink: 0; + vertical-align: middle; +} +[hidden] { + display: none !important; } - .app-container { + position: relative; display: flex; flex-direction: column; - height: 100vh; - max-height: 600px; + height: 100%; + background: var(--paper); +} +.workspace-only { + display: none; } - -/* Header */ .app-header { - background: linear-gradient( - 135deg, - var(--primary-color), - var(--primary-hover) - ); - color: var(--text-inverse); - padding: var(--spacing-md); - border-bottom: 1px solid var(--border-color); -} - -.header-content { display: flex; + flex-shrink: 0; justify-content: space-between; align-items: center; - margin-bottom: var(--spacing-sm); + gap: 12px; + min-height: 64px; + padding: 12px 16px; + border-bottom: 1px solid var(--line); } - -.logo-section { +.logo-section, +.header-actions, +.provider-status { display: flex; align-items: center; - gap: var(--spacing-sm); } - -.logo-icon { - font-size: 1.5em; +.logo-section { + gap: 9px; + min-width: 0; +} +.brand-mark { + display: inline-flex; + color: var(--accent); +} +.brand-mark svg { + width: 26px; + height: 26px; } - .app-title { - font-size: 1.1em; - font-weight: 600; - margin: 0; + font-size: 17px; + font-weight: 650; + letter-spacing: -0.5px; + white-space: nowrap; +} +.app-title span { + display: none; } - .header-actions { - display: flex; - gap: var(--spacing-xs); + gap: 6px; +} +.provider-status { + gap: 6px; + font-size: 11px; + color: var(--muted); +} +.status-indicator { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--muted); +} +.status-indicator.active { + background: var(--accent); +} +.status-indicator.error { + background: var(--danger); +} +.status-indicator.warning { + background: var(--warning); } - .icon-button { - background: rgba(255, 255, 255, 0.1); - border: none; - border-radius: var(--radius-sm); - padding: var(--spacing-xs); - color: var(--text-inverse); - cursor: pointer; - transition: background-color var(--transition-fast); - display: flex; + display: inline-flex; align-items: center; justify-content: center; + width: 32px; + height: 32px; + padding: 5px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--muted); } - .icon-button:hover { - background: rgba(255, 255, 255, 0.2); + background: var(--subtle); + border-color: var(--line); + color: var(--ink); } - -.status-bar { - display: flex; - justify-content: space-between; +.action-button { + display: inline-flex; align-items: center; - font-size: 0.875em; - opacity: 0.9; + justify-content: center; + gap: 6px; + padding: 6px 10px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--paper); + font-size: 12px; + font-weight: 500; } - -.provider-status { +.action-button:hover { + background: var(--subtle); + border-color: var(--muted); +} +#open-workspace-btn { + font-size: 0; + width: 32px; + padding: 5px; +} +#open-workspace-btn svg { + width: 17px; + height: 17px; +} +.document-panel { + padding: 14px 18px; + background: var(--subtle); + border-bottom: 1px solid var(--line); +} +.document-heading { display: flex; align-items: center; - gap: var(--spacing-xs); + justify-content: space-between; + margin-bottom: 7px; } - -.status-indicator { - width: 8px; - height: 8px; - border-radius: 50%; - background-color: var(--success-color); +.eyebrow { + display: block; + font-size: 10px; + font-weight: 650; + letter-spacing: 1.5px; + text-transform: uppercase; + color: var(--muted); } - -.status-indicator.error { - background-color: var(--error-color); +.document-symbol { + color: var(--muted); } - -.status-indicator.warning { - background-color: var(--warning-color); +.document-symbol svg { + width: 16px; + height: 16px; +} +#current-page-title { + font-size: 16px; + font-weight: 600; + letter-spacing: -0.3px; + overflow-wrap: anywhere; + line-height: 1.4; +} +.page-status { + margin-top: 5px; + font-size: 11px; + color: var(--muted); + overflow-wrap: anywhere; } - -.page-title { - max-width: 200px; +.document-overview { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + font-size: 12px; + color: var(--muted); + margin-top: 10px; +} +.document-import { + margin-top: 28px; + border-top: 1px solid var(--line); + padding-top: 18px; +} +.document-import summary { + font-size: 13px; + font-weight: 600; + color: var(--accent); + margin-bottom: 14px; +} +.document-import summary svg { + width: 15px; + height: 15px; + margin-right: 4px; +} +.document-import label { + display: block; + font-size: 11px; + color: var(--muted); + margin-bottom: 8px; +} +.document-import input { + max-width: 100%; + font-size: 11px; +} +.document-import input::file-selector-button { + font: inherit; + color: var(--ink); + border: 1px solid var(--line); + background: var(--paper); + border-radius: 5px; + padding: 7px 8px; + margin-right: 6px; + cursor: pointer; +} +.source-note { + margin-top: 40px; + border-top: 1px solid var(--line); + padding-top: 20px; + color: var(--muted); + font-size: 12px; +} +.source-note p { + margin-top: 10px; } - -/* Navigation Tabs */ .tab-navigation { display: flex; - background-color: var(--surface-color); - border-bottom: 1px solid var(--border-color); - overflow-x: auto; - scrollbar-width: none; - -ms-overflow-style: none; -} - -.tab-navigation::-webkit-scrollbar { - display: none; + flex-shrink: 0; + gap: 4px; + min-height: 52px; + padding: 0 10px; + border-bottom: 1px solid var(--line); } - .tab-button { + display: flex; flex: 1; min-width: 0; - background: none; - border: none; - padding: var(--spacing-sm); - cursor: pointer; - transition: all var(--transition-fast); - color: var(--text-secondary); + gap: 6px; + justify-content: center; + align-items: center; + padding: 13px 4px; + border: 0; border-bottom: 2px solid transparent; - font-size: 0.75em; + background: transparent; + color: var(--muted); + font-size: 11px; + font-weight: 500; +} +.tab-button svg { + width: 15px; + height: 15px; } - .tab-button:hover { - background-color: rgba(0, 0, 0, 0.05); - color: var(--text-primary); + color: var(--ink); + background: var(--subtle); } - .tab-button.active { - color: var(--primary-color); - border-bottom-color: var(--primary-color); - background-color: var(--background-color); + color: var(--accent); + border-bottom-color: var(--accent); } - -.tab-icon { - display: block; - font-size: 1.2em; - margin-bottom: 2px; -} - -.tab-label { - display: block; - font-weight: 500; -} - -/* Main Content */ .main-content { flex: 1; + min-height: 0; overflow-y: auto; scrollbar-width: thin; - scrollbar-color: var(--border-color) transparent; -} - -.main-content::-webkit-scrollbar { - width: 6px; -} - -.main-content::-webkit-scrollbar-track { - background: transparent; -} - -.main-content::-webkit-scrollbar-thumb { - background-color: var(--border-color); - border-radius: 3px; + scrollbar-color: var(--line) transparent; } - .tab-pane { - display: none; - padding: var(--spacing-md); + padding: 22px 18px; } - -.tab-pane.active { - display: block; -} - -/* Content Sections */ -.content-section { - margin-bottom: var(--spacing-lg); +.tab-pane:not(.active) { + display: none; } - .section-header { - margin-bottom: var(--spacing-md); + margin-bottom: 24px; } - .section-header h2 { - font-size: 1.1em; + margin: 7px 0; + font-size: 23px; + letter-spacing: -0.8px; font-weight: 600; - color: var(--text-primary); - margin-bottom: var(--spacing-sm); + line-height: 1.25; } - -/* Summary Controls */ -.summary-controls { +.section-header p { + font-size: 13px; + color: var(--muted); + max-width: 55ch; +} +.summary-toolbar { display: flex; - flex-direction: column; - gap: var(--spacing-sm); + flex-wrap: wrap; + gap: 16px; + align-items: end; + padding-bottom: 24px; + border-bottom: 1px solid var(--line); +} +.summary-controls { + flex: 1 1 130px; + min-width: 0; +} +.field-label { + display: block; + color: var(--muted); + font-size: 11px; + font-weight: 600; + margin-bottom: 7px; } - .modern-select { width: 100%; - padding: var(--spacing-sm); - border: 1px solid var(--border-color); - border-radius: var(--radius-sm); - background-color: var(--background-color); - color: var(--text-primary); - font-size: 0.875em; - transition: border-color var(--transition-fast); -} - -.modern-select:focus { - outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); -} - + min-width: 0; + min-height: 38px; + padding: 8px; + border: 1px solid var(--control-line); + border-radius: 6px; + background: var(--paper); + color: var(--ink); + font-size: 12px; +} +.length-control { + flex: 1 1 175px; + min-width: 0; + border: none; + margin: 0; + padding: 0; +} +.length-control legend { + padding: 0; +} .length-selector { display: flex; - gap: var(--spacing-sm); + min-height: 38px; + padding: 3px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--subtle); } - .radio-label { + position: relative; flex: 1; display: flex; align-items: center; justify-content: center; - padding: var(--spacing-sm); - border: 1px solid var(--border-color); - border-radius: var(--radius-sm); cursor: pointer; - transition: all var(--transition-fast); } - -.radio-label:hover { - background-color: var(--surface-color); +.radio-label input { + position: absolute; + opacity: 0; + width: 1px; + height: 1px; } - -.radio-label input[type='radio'] { - display: none; +.radio-text { + display: block; + width: 100%; + text-align: center; + padding: 4px 6px; + font-size: 11px; + color: var(--muted); + border-radius: 3px; } - -.radio-label input[type='radio']:checked + .radio-text { - color: var(--primary-color); - font-weight: 500; +.radio-label input:checked + .radio-text { + color: var(--accent); + background: var(--paper); + box-shadow: 0 0 0 1px var(--accent); + font-weight: 600; } - -.radio-label:has(input[type='radio']:checked) { - background-color: rgba(79, 70, 229, 0.1); - border-color: var(--primary-color); +.radio-label:has(input:focus-visible) { + outline: 2px solid var(--focus); + outline-offset: 3px; + border-radius: 3px; } - -.radio-text { - font-size: 0.875em; +.primary-button { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 20px; + border: 1px solid transparent; + border-radius: 6px; + min-height: 39px; + padding: 9px 14px; + background: var(--accent); + color: var(--on-accent); + font-size: 12px; + font-weight: 600; } - -/* Buttons */ -.action-buttons { - display: flex; - gap: var(--spacing-sm); - margin-bottom: var(--spacing-md); +.primary-button:hover, +.send-button:hover { + background: var(--accent-hover); } - -.primary-button, -.secondary-button, -.tool-button { +.summary-toolbar .primary-button { + width: 100%; +} +.summary-empty { display: flex; + flex-direction: column; align-items: center; justify-content: center; - gap: var(--spacing-xs); - padding: var(--spacing-sm) var(--spacing-md); - border: none; - border-radius: var(--radius-md); - font-size: 0.875em; + text-align: center; + padding: 36px 18px; + min-height: 250px; + color: var(--muted); +} +.empty-icon { + width: 32px; + height: 32px; + margin-bottom: 16px; + color: var(--accent); +} +.summary-empty h3, +.chat-empty h3 { + font-size: 17px; + color: var(--ink); font-weight: 500; - cursor: pointer; - transition: all var(--transition-fast); - text-decoration: none; + letter-spacing: -0.3px; + margin-bottom: 7px; } - -.primary-button { - background-color: var(--primary-color); - color: var(--text-inverse); - flex: 1; +.summary-empty p, +.chat-empty p { + font-size: 13px; + max-width: 40ch; +} +.empty-caption { + font-size: 11px; + margin-top: 22px; } - -.primary-button:hover { - background-color: var(--primary-hover); - transform: translateY(-1px); - box-shadow: var(--shadow-md); -} - -.secondary-button { - background-color: var(--surface-color); - color: var(--text-primary); - border: 1px solid var(--border-color); -} - -.secondary-button:hover { - background-color: var(--border-color); -} - -.button-icon { - font-size: 1em; -} - -/* Results Container */ -.results-container { - background-color: var(--surface-color); - border: 1px solid var(--border-color); - border-radius: var(--radius-lg); - padding: var(--spacing-md); - margin-top: var(--spacing-md); -} - -.result-header { +.results-container, +.tools-results { + margin-top: 28px; +} +.result-header, +.card-header { display: flex; - justify-content: space-between; + flex-wrap: wrap; align-items: center; - margin-bottom: var(--spacing-sm); + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; } - .result-header h3 { - font-size: 1em; + font-size: 15px; font-weight: 600; - color: var(--text-primary); } - +.result-actions { + display: flex; + gap: 6px; +} .result-meta { display: flex; - gap: var(--spacing-xs); + flex-wrap: wrap; + gap: 6px 12px; + color: var(--muted); + font-size: 11px; + padding-bottom: 18px; + border-bottom: 1px solid var(--line); +} +.result-meta span:empty { + display: none; } - .provider-badge, -.confidence-score, .language-badge { - padding: 2px var(--spacing-xs); - background-color: var(--primary-color); - color: var(--text-inverse); - border-radius: var(--radius-sm); - font-size: 0.75em; - font-weight: 500; + color: var(--accent); + font-weight: 600; } - .result-content { - margin-bottom: var(--spacing-md); - line-height: 1.6; - color: var(--text-primary); + padding-top: 22px; + font-size: 15px; + line-height: 1.85; + overflow-wrap: anywhere; } - -.result-actions { - display: flex; - gap: var(--spacing-xs); +.result-content p, +.message-content p { + margin-bottom: 12px; } - -.action-button { - padding: var(--spacing-xs) var(--spacing-sm); - background-color: var(--background-color); - border: 1px solid var(--border-color); - border-radius: var(--radius-sm); - color: var(--text-primary); - font-size: 0.75em; - cursor: pointer; - transition: all var(--transition-fast); +.result-content ul, +.result-content ol, +.message-content ul, +.message-content ol { + padding-left: 22px; } - -.action-button:hover { - background-color: var(--surface-color); - transform: translateY(-1px); +.result-content li { + margin-bottom: 8px; +} +.result-content h2, +.result-content h3 { + margin: 20px 0 10px; + line-height: 1.4; +} +code { + padding: 2px 4px; + background: var(--subtle); + border: 1px solid var(--line); + border-radius: 4px; + font-size: 0.9em; + overflow-wrap: anywhere; +} +pre { + white-space: pre-wrap; +} +.chat-memory-controls { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 8px; + padding-bottom: 14px; + color: var(--muted); + font-size: 11px; + border-bottom: 1px solid var(--line); } - -/* Chat Styles */ .chat-container { display: flex; flex-direction: column; - height: 400px; + min-height: 330px; } - .chat-history { flex: 1; + min-height: 160px; + padding: 24px 0 6px; overflow-y: auto; - padding: var(--spacing-sm); scrollbar-width: thin; - scrollbar-color: var(--border-color) transparent; } - -.welcome-message, -.chat-message { - display: flex; - gap: var(--spacing-sm); - margin-bottom: var(--spacing-md); +.chat-history:empty { + display: none; } - -.assistant-avatar, -.user-avatar { - flex-shrink: 0; - width: 32px; - height: 32px; - border-radius: 50%; +.chat-empty { + display: none; +} +.chat-history:empty + .chat-empty { display: flex; - align-items: center; + flex: 1; + flex-direction: column; justify-content: center; - font-size: 1.2em; + align-items: center; + text-align: center; + color: var(--muted); + padding: 35px 12px; +} +.chat-message { + display: flex; + gap: 12px; + margin-bottom: 20px; + align-items: flex-start; } - +.user-avatar, .assistant-avatar { - background-color: var(--primary-color); - color: var(--text-inverse); + flex: 0 0 36px; + font-size: 10px; + font-weight: 650; + color: var(--muted); + padding-top: 11px; } - -.user-avatar { - background-color: var(--surface-color); - color: var(--text-primary); +.assistant-avatar { + color: var(--accent); } - .message-content { flex: 1; - background-color: var(--surface-color); - border-radius: var(--radius-md); - padding: var(--spacing-sm); - line-height: 1.5; + min-width: 0; + font-size: 14px; + line-height: 1.7; + padding: 9px 0; + overflow-wrap: anywhere; +} +.message-content > :last-child { + margin-bottom: 0; } - .chat-message.user .message-content { - background-color: var(--primary-color); - color: var(--text-inverse); + background: var(--accent-soft); + border-radius: 7px; + padding: 10px 14px; +} +.chat-sources { + margin: 0 0 24px 48px; + padding: 12px 14px; + border-left: 2px solid var(--line); + background: var(--subtle); + font-size: 12px; + color: var(--muted); +} +.chat-sources summary { + color: var(--accent); + font-weight: 600; +} +.chat-sources p { + margin-top: 12px; + white-space: pre-wrap; + overflow-wrap: anywhere; + max-height: 240px; + overflow-y: auto; } - .chat-input-container { - border-top: 1px solid var(--border-color); - padding: var(--spacing-sm); + padding-top: 18px; + border-top: 1px solid var(--line); } - .input-wrapper { display: flex; - gap: var(--spacing-xs); - margin-bottom: var(--spacing-sm); + align-items: end; + gap: 8px; + padding: 9px; + border: 1px solid var(--control-line); + border-radius: 8px; + background: var(--paper); +} +.input-wrapper:focus-within { + border-color: var(--focus); + box-shadow: 0 0 0 1px var(--focus); } - #chat-input { flex: 1; - padding: var(--spacing-sm); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - resize: none; - font-family: inherit; - font-size: 0.875em; - line-height: 1.4; - min-height: 36px; - max-height: 100px; + min-width: 0; + padding: 3px; + resize: vertical; + min-height: 48px; + max-height: 150px; + border: 0; + background: transparent; + color: var(--ink); + font-size: 13px; + line-height: 1.6; +} +#chat-input::placeholder { + color: var(--muted); } - -#chat-input:focus { - outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); +#chat-input:focus-visible { + outline-offset: 1px; } - .send-button { - padding: var(--spacing-sm); - background-color: var(--primary-color); - color: var(--text-inverse); - border: none; - border-radius: var(--radius-md); - cursor: pointer; - transition: background-color var(--transition-fast); display: flex; + flex-shrink: 0; align-items: center; justify-content: center; + width: 34px; + height: 34px; + padding: 7px; + border: 0; + border-radius: 5px; + background: var(--accent); + color: var(--on-accent); } - -.send-button:hover { - background-color: var(--primary-hover); -} - -.send-button:disabled { - opacity: 0.5; - cursor: not-allowed; -} - .input-suggestions { display: flex; - gap: var(--spacing-xs); + gap: 6px; flex-wrap: wrap; + margin-top: 10px; } - .suggestion-chip { - padding: var(--spacing-xs) var(--spacing-sm); - background-color: var(--surface-color); - border: 1px solid var(--border-color); - border-radius: var(--radius-lg); - font-size: 0.75em; - color: var(--text-secondary); - cursor: pointer; - transition: all var(--transition-fast); + border: 1px solid var(--line); + background: var(--subtle); + color: var(--ink); + font-size: 11px; + padding: 5px 9px; + border-radius: 5px; } - .suggestion-chip:hover { - background-color: var(--border-color); - color: var(--text-primary); + background: var(--accent-soft); + border-color: var(--accent); +} +.helper-text, +.coverage-note { + font-size: 11px; + line-height: 1.6; + color: var(--muted); + margin-top: 9px; } - -/* Translation Styles */ .translation-controls { - margin-bottom: var(--spacing-md); + padding: 20px; + background: var(--subtle); + border: 1px solid var(--line); + border-radius: 8px; + margin-bottom: 20px; } - .language-selector { display: flex; - align-items: center; - gap: var(--spacing-sm); - margin-bottom: var(--spacing-md); + gap: 10px; + align-items: end; + margin-bottom: 18px; } - .language-input { flex: 1; + min-width: 0; } - -.language-input label { - display: block; - font-size: 0.75em; - font-weight: 500; - color: var(--text-secondary); - margin-bottom: var(--spacing-xs); -} - .language-swap { - padding: var(--spacing-sm) 0; + margin-bottom: 3px; + font-size: 20px; } - -.translation-options { - display: flex; - flex-direction: column; - gap: var(--spacing-sm); -} - .checkbox-label { display: flex; - align-items: center; - gap: var(--spacing-sm); - cursor: pointer; -} - -.checkbox-label input[type='checkbox'] { - width: 16px; - height: 16px; - accent-color: var(--primary-color); + align-items: baseline; + gap: 8px; + font-size: 12px; } - -.checkbox-text { - font-size: 0.875em; - color: var(--text-primary); +input[type='checkbox'] { + accent-color: var(--accent); } - -/* Analysis Styles */ -.analysis-grid { +.analysis-grid, +.tools-grid { display: grid; grid-template-columns: 1fr; - gap: var(--spacing-md); + gap: 16px; } - -.analysis-card { - background-color: var(--surface-color); - border: 1px solid var(--border-color); - border-radius: var(--radius-lg); - padding: var(--spacing-md); -} - -.card-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: var(--spacing-sm); +.analysis-card, +.tool-card { + min-width: 0; + border: 1px solid var(--line); + border-radius: 8px; + padding: 20px; } - -.card-header h3 { - font-size: 0.875em; +.card-header h3, +.tool-card h3 { + font-size: 15px; font-weight: 600; - color: var(--text-primary); } - +.card-description, +.tool-card p { + font-size: 12px; + color: var(--muted); +} .analyze-btn { - padding: var(--spacing-xs) var(--spacing-sm); - background-color: var(--primary-color); - color: var(--text-inverse); - border: none; - border-radius: var(--radius-sm); - font-size: 0.75em; - cursor: pointer; - transition: background-color var(--transition-fast); + padding: 5px 10px; + background: var(--accent-soft); + border: 1px solid transparent; + border-radius: 5px; + color: var(--accent); + font-size: 12px; } - .analyze-btn:hover { - background-color: var(--primary-hover); + border-color: var(--accent); } - .analysis-result { - min-height: 60px; - display: flex; - align-items: center; - justify-content: center; + border-top: 1px solid var(--line); + margin-top: 16px; + padding-top: 14px; + overflow-wrap: anywhere; + font-size: 13px; } - .placeholder { - color: var(--text-secondary); - font-size: 0.875em; - font-style: italic; -} - -/* Tools Styles */ -.tools-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: var(--spacing-md); + color: var(--muted); + font-size: 12px; } - .tool-card { - background-color: var(--surface-color); - border: 1px solid var(--border-color); - border-radius: var(--radius-lg); - padding: var(--spacing-md); - text-align: center; - transition: all var(--transition-fast); -} - -.tool-card:hover { - box-shadow: var(--shadow-md); - transform: translateY(-2px); -} - -.tool-icon { - font-size: 2em; - margin-bottom: var(--spacing-sm); + display: flex; + flex-direction: column; + align-items: start; } - -.tool-card h3 { - font-size: 0.875em; - font-weight: 600; - color: var(--text-primary); - margin-bottom: var(--spacing-xs); +.tool-card > svg { + margin-bottom: 16px; + color: var(--accent); } - .tool-card p { - font-size: 0.75em; - color: var(--text-secondary); - margin-bottom: var(--spacing-md); - line-height: 1.4; + margin-top: 8px; + flex: 1; } - .tool-button { - width: 100%; - background-color: var(--primary-color); - color: var(--text-inverse); - font-size: 0.75em; -} - -.tools-results { - margin-top: var(--spacing-lg); - background-color: var(--surface-color); - border: 1px solid var(--border-color); - border-radius: var(--radius-lg); - padding: var(--spacing-md); -} - -.results-header { display: flex; - justify-content: space-between; align-items: center; - margin-bottom: var(--spacing-md); + justify-content: space-between; + width: 100%; + margin-top: 22px; + padding: 10px 0 0; + font-size: 12px; + color: var(--accent); + background: transparent; + border: 0; + border-top: 1px solid var(--line); } - -.close-results { - background: none; - border: none; - font-size: 1.5em; - color: var(--text-secondary); - cursor: pointer; - padding: 0; - width: 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; +.tool-button:hover { + color: var(--ink); } - -.close-results:hover { - color: var(--text-primary); +.results-content { + overflow-wrap: anywhere; } - -/* Loading Overlay */ .loading-overlay { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(255, 255, 255, 0.95); + position: fixed; + inset: 0; + z-index: 10; display: none; flex-direction: column; align-items: center; justify-content: center; - z-index: 1000; + background: var(--paper); + opacity: 0.97; + padding: 24px; + text-align: center; } - -[data-theme='dark'] .loading-overlay { - background-color: rgba(31, 41, 55, 0.95); +.loading-overlay p { + margin-top: 8px; + color: var(--muted); + font-size: 12px; +} +.loading-text { + margin-top: 16px; + font-size: 14px; + font-weight: 600; } - .loading-spinner { - width: 40px; - height: 40px; - border: 3px solid var(--border-color); - border-top: 3px solid var(--primary-color); + width: 24px; + height: 24px; + border: 2px solid var(--line); + border-top-color: var(--accent); border-radius: 50%; - animation: spin 1s linear infinite; - margin-bottom: var(--spacing-md); + animation: spin 0.9s linear infinite; } - @keyframes spin { - 0% { - transform: rotate(0deg); - } - 100% { + to { transform: rotate(360deg); } } - -.loading-text { - color: var(--text-primary); - font-size: 0.875em; - font-weight: 500; -} - -/* Toast Notifications */ .toast-container { position: fixed; - top: var(--spacing-md); - right: var(--spacing-md); - z-index: 1001; + z-index: 11; + bottom: 16px; + right: 16px; + left: 16px; display: flex; + align-items: end; flex-direction: column; - gap: var(--spacing-xs); + gap: 8px; + pointer-events: none; } - .toast { - background-color: var(--background-color); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - padding: var(--spacing-sm) var(--spacing-md); - box-shadow: var(--shadow-lg); - min-width: 300px; - max-width: 400px; - animation: slideInRight 0.3s ease-out; -} - -.toast.success { - border-left: 4px solid var(--success-color); -} - + max-width: 420px; + padding: 12px 16px; + border: 1px solid var(--line); + border-left: 3px solid var(--accent); + border-radius: 6px; + background: var(--paper); + color: var(--ink); + font-size: 12px; + box-shadow: 0 5px 20px #00000018; +} .toast.error { - border-left: 4px solid var(--error-color); + border-left-color: var(--danger); } - .toast.warning { - border-left: 4px solid var(--warning-color); + border-left-color: var(--warning); +} +body.workspace { + width: 100%; + height: auto; + min-height: 100vh; } - -@keyframes slideInRight { - from { - transform: translateX(100%); - opacity: 0; +.workspace .app-container { + display: grid; + grid-template-columns: 248px minmax(0, 1fr); + grid-template-rows: 72px 64px 1fr; + max-width: 1440px; + margin: 0 auto; + min-height: 100vh; + height: auto; + border-inline: 1px solid var(--line); +} +.workspace .workspace-only { + display: block; +} +.workspace .app-header { + grid-column: 1 / -1; + padding: 16px 30px; +} +.workspace .app-title span { + display: inline; + margin-left: 16px; + padding-left: 16px; + border-left: 1px solid var(--line); + font-size: 12px; + font-weight: 400; + color: var(--muted); + letter-spacing: 0; +} +.workspace .header-actions { + gap: 12px; +} +.workspace .provider-status { + padding: 5px 10px; + border: 1px solid var(--line); + border-radius: 5px; + font-size: 12px; +} +.workspace #open-workspace-btn { + display: none; +} +.workspace .document-panel { + grid-row: 2 / 4; + grid-column: 1; + padding: 30px 24px; + border-bottom: none; + border-right: 1px solid var(--line); +} +.workspace #current-page-title { + font-size: 21px; + letter-spacing: -0.5px; + margin-top: 16px; +} +.workspace .page-status { + font-size: 12px; + margin-top: 10px; +} +.workspace .document-overview { + display: block; + margin-top: 22px; +} +.workspace .tab-navigation { + grid-column: 2; + padding: 0 38px; + gap: 28px; +} +.workspace .tab-button { + flex: 0 1 auto; + padding-inline: 2px; + font-size: 13px; + gap: 9px; +} +.workspace .tab-button svg { + width: 17px; + height: 17px; +} +.workspace .main-content { + grid-column: 2; + overflow: visible; + padding: 0 38px; +} +.workspace .tab-pane { + max-width: 880px; + padding: 34px 0 40px; + margin: 0 auto; +} +.workspace .section-header h2 { + font-size: 30px; + letter-spacing: -1px; + margin-top: 9px; +} +.workspace .section-header p { + font-size: 14px; +} +.workspace .summary-toolbar { + flex-wrap: nowrap; + gap: 18px; + padding-block: 12px 26px; +} +.workspace .summary-controls { + flex-basis: 160px; +} +.workspace .length-control { + flex-basis: 190px; +} +.workspace .summary-toolbar .primary-button { + width: auto; + flex-shrink: 0; +} +.workspace .summary-empty { + min-height: 340px; +} +.workspace .result-content { + font-size: 16px; + max-width: 72ch; +} +.workspace .chat-container { + height: calc(100vh - 374px); + min-height: 340px; +} +.workspace .analysis-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} +.workspace .tools-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} +@media (max-width: 1000px) { + .workspace .app-container { + grid-template-columns: 210px minmax(0, 1fr); } - to { - transform: translateX(0); - opacity: 1; + .workspace .main-content { + padding-inline: 24px; } -} - -/* Responsive Design */ -@media (max-width: 350px) { - body { - width: 350px; + .workspace .document-panel { + padding-inline: 18px; + } + .workspace .tab-navigation { + padding-inline: 24px; + gap: 18px; } - - .tools-grid { + .workspace .summary-toolbar { + flex-wrap: wrap; + } + .workspace .summary-toolbar .primary-button { + width: 100%; + } + .workspace .tools-grid { grid-template-columns: 1fr; } - - .language-selector { - flex-direction: column; +} +@media (max-width: 700px) { + .workspace .app-container { + display: flex; + min-height: 100vh; } - - .language-swap { - align-self: center; + .workspace .app-header { + padding: 14px 18px; + } + .workspace .app-title span { + display: none; + } + .workspace .header-actions { + gap: 6px; + } + .workspace .provider-status { + border: 0; padding: 0; + font-size: 11px; + } + .workspace .document-panel { + border-right: none; + border-bottom: 1px solid var(--line); + padding: 18px; + } + .workspace #current-page-title { + margin-top: 7px; + font-size: 18px; + } + .workspace .document-overview { + margin-top: 10px; + } + .workspace .source-note { + display: none; + } + .workspace .document-import { + margin-top: 18px; + padding-top: 14px; + } + .workspace .tab-navigation { + padding-inline: 10px; + gap: 4px; + } + .workspace .tab-button { + flex: 1; + font-size: 11px; + gap: 6px; + } + .workspace .tab-button svg { + width: 15px; + height: 15px; + } + .workspace .main-content { + padding: 0 18px; + overflow: visible; + flex: auto; + } + .workspace .section-header h2 { + font-size: 25px; + } + .workspace .summary-toolbar { + padding-top: 0; + } + .workspace .chat-container { + height: auto; + min-height: 400px; + } + .workspace .analysis-grid { + grid-template-columns: 1fr; + } +} +@media (max-width: 360px) { + .tab-button svg { + display: none; + } + .workspace .tab-button svg { + display: none; + } + .header-actions { + gap: 3px; + } + .provider-status { + font-size: 10px; + } + .workspace .provider-status { + font-size: 10px; + } + .workspace .tab-pane { + padding-top: 24px; + } + .workspace .header-actions { + gap: 3px; } } - -/* Accessibility */ @media (prefers-reduced-motion: reduce) { - * { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + scroll-behavior: auto !important; } } - -/* Focus styles for keyboard navigation */ -button:focus-visible, -select:focus-visible, -textarea:focus-visible, -input:focus-visible { - outline: 2px solid var(--primary-color); - outline-offset: 2px; -} - -/* High contrast mode support */ -@media (prefers-contrast: high) { - :root { - --border-color: #000000; - --text-secondary: #000000; - } - - [data-theme='dark'] { - --border-color: #ffffff; - --text-secondary: #ffffff; +@media (forced-colors: active) { + .tab-button.active { + border-bottom-color: Highlight; + } + .radio-label input:checked + .radio-text { + outline: 1px solid Highlight; + } + .status-indicator { + background: CanvasText; } } diff --git a/tests/content/extraction.test.js b/tests/content/extraction.test.js index ace7f27..e25b4f6 100644 --- a/tests/content/extraction.test.js +++ b/tests/content/extraction.test.js @@ -6,8 +6,8 @@ import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; * Chrome does — import once for the side effect, capture the listener, and send * it messages — which is also how `tests/integration` drives `background.js`. * - * Note that jsdom does not implement `innerText`, so these exercise the - * `|| textContent` fallback in `getMainText()` rather than the innerText path. + * These test text traversal and CSS filtering, not layout. The real-Chromium + * extraction regression verifies visibility, block boundaries, and a stable DOM. */ /** @type {(request: any, sender: any, sendResponse: (r: any) => void) => boolean} */ @@ -38,7 +38,8 @@ beforeEach(() => { describe('extractContent', () => { it('returns the page title, url, and main text', async () => { - document.body.innerHTML = '

The body of the article.

'; + document.body.innerHTML = + '

The body of the article.

'; const { success, data } = await send({ action: 'extractContent' }); @@ -71,6 +72,109 @@ describe('extractContent', () => { expect(data.mainText).toContain('bare paragraph'); }); + it('separates adjacent blocks while preserving inline words and whitespace', async () => { + document.body.innerHTML = + '

First visible paragraph.

Second paragraph.

Cooperate with inline text.

'; + + const { data } = await send({ action: 'extractContent' }); + + expect(data.mainText).toBe( + 'First visible paragraph.\n\nSecond paragraph.\n\nCooperate with inline text.' + ); + }); + + it('omits non-rendered descendants and respects CSS block and inline display', async () => { + document.head.innerHTML = + ''; + document.body.innerHTML = + '
First block.Second block.

Inline

text.
'; + + const { data } = await send({ action: 'extractContent' }); + + expect(data.mainText).toBe('First block.\nSecond block.\nInline text.'); + }); + + it.each([ + '
Hidden article.
', + '
Hidden article.
', + '
Hidden content.
', + ])('omits content in a non-rendered container: %s', async markup => { + document.body.innerHTML = markup; + + const { data } = await send({ action: 'extractContent' }); + + expect(data.mainText).toBe(''); + }); + + it('allows a descendant to restore visibility without including hidden siblings', async () => { + document.body.innerHTML = + '
Hidden Visible child.Hidden sibling.
Collapsed text.
'; + + const { data } = await send({ action: 'extractContent' }); + + expect(data.mainText).toBe('Visible child.'); + }); + + it('keeps explicit line breaks and preformatted internal spacing', async () => { + document.body.innerHTML = + '

First line.
Second line.

one  two\n  three
'; + + const { data } = await send({ action: 'extractContent' }); + + expect(data.mainText).toBe( + 'First line.\nSecond line.\n\none two\n three' + ); + }); + + it.each([ + { + label: 'closed, open, and nested details', + markup: + '
Closed summary.

Hidden closed body.

Hidden second summary.
Hidden nested summary.

Hidden nested body.

Open summary.

Open body.

Nested closed summary.

Hidden nested closed body.

', + expected: + 'Closed summary.\nOpen summary.\n\nOpen body.\n\nNested closed summary.', + }, + { + label: 'a selected article inside a closed details body', + markup: + '
Visible summary.
Hidden selected article.
', + expected: '', + }, + { + label: 'a selected article inside the first summary', + markup: + '
Visible selected article.

Hidden body.

', + expected: 'Visible selected article.', + }, + { + label: 'a selected article inside a later summary', + markup: + '
First summary.
Hidden selected article.
', + expected: '', + }, + { + label: + 'a selected article inside nested open details under a closed ancestor', + markup: + '
Outer summary.
Inner summary.
Hidden selected article.
', + expected: '', + }, + { + label: 'closed details with no summary', + markup: + '
Hidden text.

Hidden body.

', + expected: '', + }, + ])('respects $label', async ({ markup, expected }) => { + document.body.innerHTML = markup; + const before = document.documentElement.outerHTML; + + const { data } = await send({ action: 'extractContent' }); + + expect(data.mainText).toBe(expected); + expect(document.documentElement.outerHTML).toBe(before); + }); + it('strips navigation, scripts, ads, and comments from the extracted text', async () => { document.body.innerHTML = `
@@ -86,18 +190,26 @@ describe('extractContent', () => { const { data } = await send({ action: 'extractContent' }); expect(data.mainText).toContain('the actual article'); - for (const noise of ['skip to content', 'var tracking', 'buy this', 'first!', 'related links']) { + for (const noise of [ + 'skip to content', + 'var tracking', + 'buy this', + 'first!', + 'related links', + ]) { expect(data.mainText).not.toContain(noise); } }); - // The script is read-only by design; stripping happens on a clone so the page - // the user is looking at is never altered. - it('does not modify the live page while stripping', async () => { - document.body.innerHTML = '

text

'; + // The script is read-only by design; excluded nodes remain in the source DOM. + it('does not modify the live page while excluding navigation', async () => { + document.body.innerHTML = + '

text

'; + const before = document.documentElement.outerHTML; await send({ action: 'extractContent' }); + expect(document.documentElement.outerHTML).toBe(before); expect(document.getElementById('nav')).not.toBeNull(); }); @@ -108,7 +220,7 @@ describe('extractContent', () => { expect(data.headings).toEqual([ { level: 1, text: 'Title' }, - { level: 3, text: 'Sub' } + { level: 3, text: 'Sub' }, ]); }); @@ -139,7 +251,10 @@ describe('getPageMetadata', () => { const { data } = await send({ action: 'getPageMetadata' }); - expect(data).toMatchObject({ description: 'a description', 'og:title': 'social title' }); + expect(data).toMatchObject({ + description: 'a description', + 'og:title': 'social title', + }); expect(Object.values(data)).not.toContain('orphan with no name'); }); }); @@ -179,10 +294,12 @@ describe('extractImages', () => { const { data } = await send({ action: 'extractImages' }); - expect(data).toEqual([expect.objectContaining({ - src: 'https://example.com/a.png', - alt: 'A picture' - })]); + expect(data).toEqual([ + expect.objectContaining({ + src: 'https://example.com/a.png', + alt: 'A picture', + }), + ]); }); }); diff --git a/tests/core/concurrency.test.js b/tests/core/concurrency.test.js index 99b2c94..a1fec2b 100644 --- a/tests/core/concurrency.test.js +++ b/tests/core/concurrency.test.js @@ -19,12 +19,16 @@ describe('mapWithConcurrency', () => { let inFlight = 0; let peak = 0; - await mapWithConcurrency(Array.from({ length: 8 }, (_, i) => i), 3, async () => { - inFlight += 1; - peak = Math.max(peak, inFlight); - await sleep(5); - inFlight -= 1; - }); + await mapWithConcurrency( + Array.from({ length: 8 }, (_, i) => i), + 3, + async () => { + inFlight += 1; + peak = Math.max(peak, inFlight); + await sleep(5); + inFlight -= 1; + } + ); // The whole point: eight sections must not become eight simultaneous // requests, which is a reliable way to trip a per-minute rate limit. @@ -33,8 +37,10 @@ describe('mapWithConcurrency', () => { }); it('passes the index through', async () => { - const seen = await mapWithConcurrency(['a', 'b', 'c'], 2, async (item, index) => - `${index}:${item}` + const seen = await mapWithConcurrency( + ['a', 'b', 'c'], + 2, + async (item, index) => `${index}:${item}` ); expect(seen).toEqual(['0:a', '1:b', '2:c']); }); @@ -44,7 +50,9 @@ describe('mapWithConcurrency', () => { }); it('handles fewer items than the limit', async () => { - await expect(mapWithConcurrency([1, 2], 8, async n => n * 2)).resolves.toEqual([2, 4]); + await expect( + mapWithConcurrency([1, 2], 8, async n => n * 2) + ).resolves.toEqual([2, 4]); }); it('propagates a rejection', async () => { @@ -55,4 +63,30 @@ describe('mapWithConcurrency', () => { }) ).rejects.toThrow('section failed'); }); + + it('does not start queued work when in-flight siblings finish after a failure', async () => { + let rejectFirst; + let finishSiblings; + const first = new Promise((_resolve, reject) => { + rejectFirst = reject; + }); + const siblings = new Promise(resolve => { + finishSiblings = resolve; + }); + const started = []; + const work = mapWithConcurrency([0, 1, 2, 3, 4, 5, 6, 7], 3, item => { + started.push(item); + return item === 0 ? first : siblings.then(() => item); + }); + const failure = new Error('section failed'); + const rejected = expect(work).rejects.toBe(failure); + + expect(started).toEqual([0, 1, 2]); + rejectFirst(failure); + await rejected; + finishSiblings(); + await sleep(0); + + expect(started).toEqual([0, 1, 2]); + }); }); diff --git a/tests/core/configuration-manager.test.js b/tests/core/configuration-manager.test.js index db92b12..db73c0b 100644 --- a/tests/core/configuration-manager.test.js +++ b/tests/core/configuration-manager.test.js @@ -2,6 +2,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ConfigurationManager } from '../../core/configuration-manager.js'; import { AIError } from '../../providers/ai-client.js'; +/** Back reads and writes with cloned data, as Chrome storage does. */ +function givenStorage(preferences) { + const stored = preferences + ? { user_preferences: structuredClone(preferences) } + : {}; + chrome.storage.sync.get.mockImplementation(async () => + structuredClone(stored) + ); + chrome.storage.sync.set.mockImplementation(async values => { + Object.assign(stored, structuredClone(values)); + }); + return stored; +} + describe('ConfigurationManager', () => { /** @type {ConfigurationManager} */ let configManager; @@ -10,6 +24,147 @@ describe('ConfigurationManager', () => { configManager = new ConfigurationManager(); vi.clearAllMocks(); chrome.storage.sync.get.mockResolvedValue({}); + chrome.storage.sync.set.mockReset().mockResolvedValue(undefined); + }); + + describe('serialized settings writes', () => { + it('keeps overlapping top-level and nested edits', async () => { + const stored = givenStorage({ apiKeys: { anthropic: 'fixture-key' } }); + await Promise.all([ + configManager.updateUserPreferences({ theme: 'dark' }), + configManager.updateUserPreferences({ summaryType: 'tldr' }), + configManager.updateUserPreferences({ + models: { openai: 'fixture-openai' }, + }), + configManager.updateUserPreferences({ + models: { gemini: 'fixture-gemini' }, + }), + configManager.updateUserPreferences({ + features: { notifications: false }, + }), + configManager.updateUserPreferences({ + features: { saveHistory: false }, + }), + configManager.updateUserPreferences({ + apiKeys: { openai: 'fixture-other-key' }, + }), + ]); + expect(stored.user_preferences).toMatchObject({ + theme: 'dark', + summaryType: 'tldr', + models: { openai: 'fixture-openai', gemini: 'fixture-gemini' }, + features: { notifications: false, saveHistory: false }, + apiKeys: { anthropic: 'fixture-key', openai: 'fixture-other-key' }, + }); + }); + + it('finishes first-run initialization before applying an overlapping edit', async () => { + const stored = givenStorage(); + let finishRead; + chrome.storage.sync.get.mockImplementationOnce( + () => + new Promise(resolve => { + finishRead = () => resolve({}); + }) + ); + const initialization = configManager.initialize(); + await vi.waitFor(() => + expect(chrome.storage.sync.get).toHaveBeenCalledOnce() + ); + const update = configManager.updateUserPreferences({ theme: 'dark' }); + await new Promise(resolve => setTimeout(resolve, 0)); + finishRead(); + await Promise.all([initialization, update]); + expect(stored.user_preferences.theme).toBe('dark'); + }); + + it('orders a reset between overlapping edits without restoring cleared keys', async () => { + const original = { + apiKeys: { openai: 'fixture-old-key' }, + theme: 'light', + }; + const stored = givenStorage(original); + let finishRead; + chrome.storage.sync.get.mockImplementationOnce( + () => + new Promise(resolve => { + finishRead = () => + resolve({ user_preferences: structuredClone(original) }); + }) + ); + const first = configManager.updateUserPreferences({ + features: { notifications: false }, + }); + await vi.waitFor(() => + expect(chrome.storage.sync.get).toHaveBeenCalledOnce() + ); + const reset = configManager.setUserPreferences( + configManager.defaultSettings + ); + const last = configManager.updateUserPreferences({ theme: 'dark' }); + await new Promise(resolve => setTimeout(resolve, 0)); + finishRead(); + await Promise.all([first, reset, last]); + expect(stored.user_preferences).toMatchObject({ + apiKeys: {}, + theme: 'dark', + features: { notifications: true }, + }); + expect(stored.user_preferences.apiKeys).toEqual({}); + }); + + it.each(['read', 'write'])( + 'recovers queued edits after a failed %s', + async failure => { + const stored = givenStorage({ theme: 'light' }); + if (failure === 'read') + chrome.storage.sync.get.mockRejectedValueOnce( + new Error('fixture failure') + ); + else + chrome.storage.sync.set.mockRejectedValueOnce( + new Error('fixture failure') + ); + const first = configManager.updateUserPreferences({ + summaryType: 'tldr', + }); + const rejected = expect(first).rejects.toMatchObject({ + message: + failure === 'read' + ? 'Saved settings are unavailable. Try again.' + : 'fixture failure', + }); + const later = configManager.updateUserPreferences({ theme: 'dark' }); + await Promise.all([rejected, later]); + expect(stored.user_preferences.theme).toBe('dark'); + expect(stored.user_preferences.summaryType).toBe('key-points'); + } + ); + + it('recovers queued edits after a failed initialization or replacement', async () => { + const stored = givenStorage({ apiKeys: { openai: 'fixture-key' } }); + chrome.storage.sync.get.mockRejectedValueOnce( + new Error('initialization failed') + ); + const initialization = configManager.initialize(); + const rejectedInitialization = expect(initialization).rejects.toThrow( + 'initialization failed' + ); + chrome.storage.sync.set.mockRejectedValueOnce( + new Error('replacement failed') + ); + const replacement = configManager.setUserPreferences( + configManager.defaultSettings + ); + const rejectedReplacement = + expect(replacement).rejects.toThrow('replacement failed'); + const later = configManager.updateUserPreferences({ theme: 'dark' }); + await Promise.all([rejectedInitialization, rejectedReplacement, later]); + expect(stored.user_preferences).toMatchObject({ + theme: 'dark', + apiKeys: { openai: 'fixture-key' }, + }); + }); }); describe('defaults', () => { diff --git a/tests/e2e/extension-loading.spec.js b/tests/e2e/extension-loading.spec.js index cd4ab37..40be910 100644 --- a/tests/e2e/extension-loading.spec.js +++ b/tests/e2e/extension-loading.spec.js @@ -6,6 +6,7 @@ import { seedPreferences, activeTabId, } from './fixtures.js'; +import { chunkContent } from '../../core/tasks.js'; /** * Real-browser checks: this repository is loaded into Chrome as an unpacked @@ -104,6 +105,156 @@ test.describe('options page', () => { }); test.describe('content extraction', () => { + test('keeps extracted paragraphs intact across summary chunk boundaries', async ({ + context, + extensionId, + }) => { + const paragraphs = Array.from( + { length: 30 }, + (_, index) => + `Paragraph ${index}. ${'word '.repeat(230)}End paragraph ${index}.` + ); + const fixtureUrl = `${FIXTURE_URL}?paragraph-chunks`; + await context.route(fixtureUrl, route => + route.fulfill({ + contentType: 'text/html', + body: `
${paragraphs.map(text => `

${text}

`).join('\n\n')}
`, + }) + ); + const contentPage = await context.newPage(); + await contentPage.goto(fixtureUrl); + const before = await contentPage + .locator('html') + .evaluate(root => root.outerHTML); + const extensionPage = await context.newPage(); + await extensionPage.goto(`chrome-extension://${extensionId}/options.html`); + await contentPage.bringToFront(); + const tabId = await activeTabId(extensionPage); + const response = await extensionPage.evaluate( + id => + chrome.runtime.sendMessage({ + actionType: 'EXTRACT_PAGE_CONTENT', + payload: { tabId: id }, + }), + tabId + ); + + expect(response.success).toBe(true); + const { chunks, droppedChars } = chunkContent(response.data.mainText); + expect(chunks).toHaveLength(2); + expect(droppedChars).toBe(0); + for (const chunk of chunks) { + expect(chunk.slice(0, 60)).toMatch(/^Paragraph \d+\./); + expect(chunk.slice(-80)).toMatch(/End paragraph \d+\.$/); + } + expect(chunks.join('\n\n')).toBe(paragraphs.join('\n\n')); + expect( + await contentPage.locator('html').evaluate(root => root.outerHTML) + ).toBe(before); + }); + + for (const { label, body, expected } of [ + { + label: 'closed, open, and nested details', + body: '
Closed summary.

Hidden closed body.

Hidden second summary.
Hidden nested summary.

Hidden nested body.

Open summary.

Open body.

Nested closed summary.

Hidden nested closed body.

', + expected: + 'Closed summary.\nOpen summary.\n\nOpen body.\n\nNested closed summary.', + }, + { + label: 'a selected article inside a closed details body', + body: '
Visible summary.
Hidden selected article.
', + expected: '', + }, + { + label: 'a selected article inside the first summary', + body: '
Visible selected article.

Hidden body.

', + expected: 'Visible selected article.', + }, + { + label: + 'a selected article inside nested open details under a closed ancestor', + body: '
Outer summary.
Inner summary.
Hidden selected article.
', + expected: '', + }, + ]) { + test(`respects ${label} without changing the source DOM`, async ({ + context, + extensionId, + }) => { + const fixtureUrl = `${FIXTURE_URL}?details`; + await context.route(fixtureUrl, route => + route.fulfill({ + contentType: 'text/html', + body: `${body}`, + }) + ); + const contentPage = await context.newPage(); + await contentPage.goto(fixtureUrl); + const before = await contentPage + .locator('html') + .evaluate(root => root.outerHTML); + const extensionPage = await context.newPage(); + await extensionPage.goto( + `chrome-extension://${extensionId}/options.html` + ); + await contentPage.bringToFront(); + const tabId = await activeTabId(extensionPage); + const response = await extensionPage.evaluate( + id => + chrome.runtime.sendMessage({ + actionType: 'EXTRACT_PAGE_CONTENT', + payload: { tabId: id }, + }), + tabId + ); + + expect(response.success).toBe(true); + expect(response.data.mainText).toBe(expected); + expect( + await contentPage.locator('html').evaluate(root => root.outerHTML) + ).toBe(before); + }); + } + + test('extracts visible blocks and inline text without changing the source DOM', async ({ + context, + extensionId, + }) => { + const fixtureUrl = `${FIXTURE_URL}?visible-text`; + await context.route(fixtureUrl, route => + route.fulfill({ + contentType: 'text/html', + body: `

First visible paragraph.

Second visible paragraph.

Inline text stays together.

`, + }) + ); + const contentPage = await context.newPage(); + await contentPage.goto(fixtureUrl); + const before = await contentPage + .locator('html') + .evaluate(root => root.outerHTML); + + const extensionPage = await context.newPage(); + await extensionPage.goto(`chrome-extension://${extensionId}/options.html`); + await contentPage.bringToFront(); + const tabId = await activeTabId(extensionPage); + const response = await extensionPage.evaluate( + id => + chrome.runtime.sendMessage({ + actionType: 'EXTRACT_PAGE_CONTENT', + payload: { tabId: id }, + }), + tabId + ); + + expect(response.success).toBe(true); + expect(response.data.mainText).toBe( + 'First visible paragraph.\n\nSecond visible paragraph.\n\nInline text stays together.' + ); + expect( + await contentPage.locator('html').evaluate(root => root.outerHTML) + ).toBe(before); + }); + test('content script extracts article text and drops navigation chrome', async ({ context, extensionId, diff --git a/tests/e2e/learning-workspace.spec.js b/tests/e2e/learning-workspace.spec.js index f258918..dc084a5 100644 --- a/tests/e2e/learning-workspace.spec.js +++ b/tests/e2e/learning-workspace.spec.js @@ -58,6 +58,7 @@ test('book questions find late evidence, remember the thread, and clear it', asy expect(requests).toHaveLength(1); expect(requests[0].messages[0].content).toContain('73 amber crystals'); await page.locator('.chat-sources summary').click(); + await expect(page.locator('.chat-sources p')).toBeInViewport({ ratio: 1 }); await page.screenshot({ path: testInfo.outputPath('learning-workspace.png'), fullPage: true, diff --git a/tests/e2e/reading-interface.spec.js b/tests/e2e/reading-interface.spec.js new file mode 100644 index 0000000..9675b54 --- /dev/null +++ b/tests/e2e/reading-interface.spec.js @@ -0,0 +1,110 @@ +import { + test, + expect, + CONFIGURED_PREFERENCES, + seedPreferences, + FIXTURE_URL, + activeTabId, +} from './fixtures.js'; + +test('reading controls work by keyboard and fit compact and wide layouts', async ({ + context, + extensionId, +}, testInfo) => { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/popup.html?workspace=1`); + await seedPreferences(page, CONFIGURED_PREFERENCES); + await page.reload(); + await page.locator('#document-file').setInputFiles({ + name: 'Reading-notes.txt', + mimeType: 'text/plain', + buffer: Buffer.from( + 'Photosynthesis converts sunlight into chemical energy. Plants store this energy in sugars.' + ), + }); + await expect(page.locator('#current-page-status')).toContainText('Ready'); + const short = page.getByRole('radio', { name: 'Short', exact: true }); + await short.focus(); + await expect(short).toBeFocused(); + await short.press('ArrowRight'); + await expect( + page.getByRole('radio', { name: 'Medium', exact: true }) + ).toBeChecked(); + const summary = page.getByRole('tab', { name: 'Summary', exact: true }); + await summary.focus(); + await summary.press('ArrowRight'); + await expect( + page.getByRole('tab', { name: 'Chat', exact: true }) + ).toBeFocused(); + await expect( + page.getByRole('tabpanel', { name: 'Chat', exact: true }) + ).toBeVisible(); + await expect(page.locator('#send-chat-btn')).toBeInViewport(); + await page.keyboard.press('Tab'); + await expect( + page.getByRole('tabpanel', { name: 'Chat', exact: true }) + ).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(page.locator('#clear-chat-btn')).toBeFocused(); + await page.locator('#theme-toggle').click(); + await expect(page.locator('body')).toHaveAttribute('data-theme', 'dark'); + await page.screenshot({ + path: testInfo.outputPath('workspace-dark.png'), + fullPage: true, + animations: 'disabled', + }); + await page.locator('#theme-toggle').click(); + await summary.click(); + await page.screenshot({ + path: testInfo.outputPath('workspace-light.png'), + fullPage: true, + animations: 'disabled', + }); + for (const width of [768, 400, 320]) { + await page.setViewportSize({ width, height: 800 }); + await expect(page.locator('#current-page-title')).toBeVisible(); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth + ) + ).toBe(true); + for (const name of ['Chat', 'Translate', 'Analyze', 'Tools', 'Summary']) { + await page.getByRole('tab', { name, exact: true }).click(); + await expect( + page.getByRole('tabpanel', { name, exact: true }) + ).toBeVisible(); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth + ) + ).toBe(true); + } + await page.screenshot({ + path: testInfo.outputPath(`workspace-${width}.png`), + fullPage: true, + animations: 'disabled', + }); + } + const source = await context.newPage(); + await source.goto(FIXTURE_URL); + await source.bringToFront(); + const tabId = await activeTabId(page); + await page.goto( + `chrome-extension://${extensionId}/popup.html?tabId=${tabId}` + ); + await page.setViewportSize({ width: 400, height: 600 }); + await expect(page.locator('#loading-overlay')).toBeHidden(); + await expect(page.locator('#current-page-title')).toHaveText( + 'Understanding Service Workers' + ); + await page.screenshot({ + path: testInfo.outputPath('popup.png'), + fullPage: true, + animations: 'disabled', + }); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth + ) + ).toBe(true); +}); diff --git a/tests/e2e/settings-concurrency.spec.js b/tests/e2e/settings-concurrency.spec.js new file mode 100644 index 0000000..9ec0925 --- /dev/null +++ b/tests/e2e/settings-concurrency.spec.js @@ -0,0 +1,165 @@ +import { + test, + expect, + CONFIGURED_PREFERENCES, + seedPreferences, +} from './fixtures.js'; + +test('a stale popup language control preserves a newer Settings edit', async ({ + context, + extensionId, +}) => { + const options = await context.newPage(); + await options.goto(`chrome-extension://${extensionId}/options.html`); + await seedPreferences(options, { + ...CONFIGURED_PREFERENCES, + sourceLanguage: 'auto', + targetLanguage: 'en', + }); + await options.reload(); + const popup = await context.newPage(); + await popup.goto(`chrome-extension://${extensionId}/popup.html?workspace=1`); + await popup.locator('[data-tab="translate"]').click(); + await expect(popup.locator('#source-language')).toHaveValue('auto'); + await options.locator('[data-section="features"]').click(); + await options.locator('#source-language-pref').selectOption('fr'); + await expect(options.locator('#save-indicator .text')).toHaveText( + 'Settings saved' + ); + await popup.locator('#target-language').selectOption('es'); + await expect + .poll( + async () => + ( + await popup.evaluate( + async () => + (await chrome.storage.sync.get('user_preferences')) + .user_preferences + ) + ).targetLanguage + ) + .toBe('es'); + const saved = await options.evaluate( + async () => + (await chrome.storage.sync.get('user_preferences')).user_preferences + ); + expect(saved).toMatchObject({ sourceLanguage: 'fr', targetLanguage: 'es' }); +}); + +/** Hold writes without replacing Chrome's reads or final persistence. */ +async function holdWrites(surface) { + await surface.evaluate(() => { + const write = chrome.storage.sync.set.bind(chrome.storage.sync); + let release; + const gate = new Promise(resolve => { + release = resolve; + }); + globalThis.settingsWrites = { started: 0, release }; + chrome.storage.sync.set = async values => { + globalThis.settingsWrites.started++; + await gate; + return write(values); + }; + }); +} + +for (const scenario of [ + { + name: 'top-level and feature edits', + changes: [ + { id: 'summary-type-pref', value: 'tldr' }, + { id: 'notifications', checked: false }, + ], + expected: { + summaryType: 'tldr', + features: { notifications: false, saveHistory: false }, + }, + }, + { + name: 'two entries in the same nested map', + changes: [ + { id: 'anthropic-model', value: 'fixture-anthropic' }, + { id: 'openai-model', value: 'fixture-openai' }, + ], + expected: { + models: { anthropic: 'fixture-anthropic', openai: 'fixture-openai' }, + }, + }, +]) { + test(`two extension contexts preserve overlapping ${scenario.name}`, async ({ + context, + extensionId, + }) => { + const first = await context.newPage(); + const second = await context.newPage(); + const url = `chrome-extension://${extensionId}/options.html`; + await first.goto(url); + const preferences = { + ...CONFIGURED_PREFERENCES, + features: { + contextMenus: false, + notifications: true, + saveHistory: false, + }, + }; + await seedPreferences(first, preferences); + await Promise.all([first.reload(), second.goto(url)]); + for (const page of [first, second]) { + await expect(page.locator('#anthropic-key')).toHaveValue( + preferences.apiKeys.anthropic + ); + } + const worker = context.serviceWorkers()[0]; + const surfaces = [first, second, worker]; + await Promise.all(surfaces.map(holdWrites)); + + try { + await Promise.all( + [first, second].map((page, index) => + page.evaluate(change => { + const input = document.getElementById(change.id); + if ('checked' in change) input.checked = change.checked; + else input.value = change.value; + input.dispatchEvent(new Event('change', { bubbles: true })); + }, scenario.changes[index]) + ) + ); + // With legacy page writers, both stale snapshots reach the held writes. + // With one worker writer, only its first write can enter before release. + await expect + .poll(async () => { + const [pageA, pageB, background] = await Promise.all( + surfaces.map(surface => + surface.evaluate(() => globalThis.settingsWrites.started) + ) + ); + return background > 0 || pageA + pageB === 2; + }) + .toBe(true); + } finally { + await Promise.all( + surfaces.map(surface => + surface.evaluate(() => globalThis.settingsWrites.release()) + ) + ); + } + + for (const page of [first, second]) { + await expect(page.locator('#save-indicator .text')).toHaveText( + 'Settings saved' + ); + } + const saved = await first.evaluate( + async () => + (await chrome.storage.sync.get('user_preferences')).user_preferences + ); + expect(saved).toMatchObject(scenario.expected); + expect(saved.apiKeys).toEqual(preferences.apiKeys); + const counts = await Promise.all( + surfaces.map(surface => + surface.evaluate(() => globalThis.settingsWrites.started) + ) + ); + expect(counts).toEqual([0, 0, 2]); + }); +} diff --git a/tests/e2e/settings-failure.spec.js b/tests/e2e/settings-failure.spec.js index a82b046..b2aa3b1 100644 --- a/tests/e2e/settings-failure.spec.js +++ b/tests/e2e/settings-failure.spec.js @@ -20,7 +20,8 @@ test('failed option reads preserve stored settings, report errors, and recover o }; await seedPreferences(page, prefs); await page.reload(); - await page.evaluate(() => { + const worker = context.serviceWorkers()[0]; + await worker.evaluate(() => { const read = chrome.storage.sync.get.bind(chrome.storage.sync); chrome.storage.sync.get = () => { chrome.storage.sync.get = read; diff --git a/tests/e2e/workspace-races.spec.js b/tests/e2e/workspace-races.spec.js new file mode 100644 index 0000000..e7727c0 --- /dev/null +++ b/tests/e2e/workspace-races.spec.js @@ -0,0 +1,156 @@ +import { + test, + expect, + CONFIGURED_PREFERENCES, + seedPreferences, + FIXTURE_URL, + activeTabId, +} from './fixtures.js'; + +// Delay delivery after the real worker has produced its response. Storage and +// provider handling still run normally; only the UI message timing is controlled. +async function delayResponse(page, action) { + await page.addInitScript(actionType => { + const send = chrome.runtime.sendMessage.bind(chrome.runtime); + chrome.runtime.sendMessage = async message => { + const response = await send(message); + if (message.actionType === actionType) { + await new Promise(resolve => { + window.releaseResponse = resolve; + }); + window.responseReleased = true; + } + return response; + }; + }, action); +} + +async function releaseResponse(page) { + await page.evaluate(() => window.releaseResponse()); + await page.waitForFunction(() => window.responseReleased); + // Let the controller's awaiting message continuation finish before asserting + // that no stale UI or conversation state was applied. + await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 0))); +} + +async function importBook(page, name = 'book-B.txt') { + await page.locator('#document-file').setInputFiles({ + name, + mimeType: 'text/plain', + buffer: Buffer.from('Book B is about bananas.'), + }); + await expect(page.locator('#current-page-title')).toHaveText(name); + await expect(page.locator('#loading-overlay')).toBeHidden(); +} + +test('a late source extraction cannot replace an imported book', async ({ + context, + extensionId, +}) => { + const setup = await context.newPage(); + await setup.goto(`chrome-extension://${extensionId}/options.html`); + await seedPreferences(setup, CONFIGURED_PREFERENCES); + const source = await context.newPage(); + await source.goto(FIXTURE_URL); + const tabId = await activeTabId(setup); + const page = await context.newPage(); + await delayResponse(page, 'EXTRACT_PAGE_CONTENT'); + await page.goto( + `chrome-extension://${extensionId}/popup.html?workspace=1&tabId=${tabId}` + ); + await page.waitForFunction(() => Boolean(window.releaseResponse)); + await importBook(page); + await releaseResponse(page); + await expect(page.locator('#current-page-title')).toHaveText('book-B.txt'); + await expect(page.locator('#current-page-status')).toContainText('Ready'); + await page.locator('[data-tab="tools"]').click(); + await page.locator('#page-stats-btn').click(); + await expect(page.locator('#tools-results')).toContainText('5 words'); +}); + +test('a late analysis result cannot populate the next book', async ({ + context, + extensionId, +}) => { + await context.route('https://api.anthropic.com/**', route => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + content: [{ type: 'text', text: 'Old book insight' }], + }), + }) + ); + const page = await context.newPage(); + await delayResponse(page, 'EXTRACT_KEY_INSIGHTS'); + await page.goto(`chrome-extension://${extensionId}/popup.html?workspace=1`); + await seedPreferences(page, CONFIGURED_PREFERENCES); + await importBook(page, 'book-A.txt'); + await page.locator('[data-tab="analyze"]').click(); + await page.locator('[data-type="insights"]').click(); + await page.waitForFunction(() => Boolean(window.releaseResponse)); + await importBook(page); + await releaseResponse(page); + await expect(page.locator('#insights-result')).toHaveText( + 'Run this analysis for this document.' + ); + await expect(page.locator('#current-page-title')).toHaveText('book-B.txt'); +}); + +test('cleared chat stays cleared when an earlier history response arrives', async ({ + context, + extensionId, +}) => { + const requests = []; + await context.route('https://api.anthropic.com/**', route => { + requests.push(route.request().postDataJSON()); + return route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + content: [{ type: 'text', text: 'A saved answer' }], + }), + }); + }); + const setup = await context.newPage(); + await setup.goto(`chrome-extension://${extensionId}/options.html`); + await seedPreferences(setup, CONFIGURED_PREFERENCES); + const source = await context.newPage(); + await source.goto(FIXTURE_URL); + const tabId = await activeTabId(setup); + const page = await context.newPage(); + await page.goto( + `chrome-extension://${extensionId}/popup.html?workspace=1&tabId=${tabId}` + ); + await expect(page.locator('#current-page-status')).toContainText('Ready'); + await page.locator('[data-tab="chat"]').click(); + await page.locator('#chat-input').fill('Remember this old question'); + await page.locator('#send-chat-btn').click(); + await expect(page.locator('#chat-history')).toContainText('A saved answer'); + await expect(page.locator('#loading-overlay')).toBeHidden(); + await delayResponse(page, 'GET_DOCUMENT_HISTORY'); + await page.reload(); + await page.waitForFunction(() => Boolean(window.releaseResponse)); + await page.locator('[data-tab="chat"]').click(); + await page.locator('#clear-chat-btn').click(); + await expect(page.locator('#chat-memory-status')).toContainText( + 'Chat cleared' + ); + await releaseResponse(page); + await expect(page.locator('#chat-history')).toBeEmpty(); + await expect(page.locator('#chat-memory-status')).toContainText( + 'Chat cleared' + ); + expect( + await page.evaluate( + async () => + (await chrome.storage.local.get('genai_conversation_history')) + .genai_conversation_history + ) + ).toEqual([]); + await page.locator('#chat-input').fill('A fresh question'); + await page.locator('#send-chat-btn').click(); + await expect(page.locator('#chat-history')).toContainText('A saved answer'); + expect(requests).toHaveLength(2); + expect(JSON.stringify(requests[1])).not.toContain( + 'Remember this old question' + ); +}); diff --git a/tests/integration/extension-workflow.test.js b/tests/integration/extension-workflow.test.js index 6a15522..59f322b 100644 --- a/tests/integration/extension-workflow.test.js +++ b/tests/integration/extension-workflow.test.js @@ -70,6 +70,8 @@ describe('Extension workflow', () => { chrome.storage.sync.get.mockResolvedValue(CONFIGURED); chrome.storage.local.get.mockResolvedValue({}); chrome.storage.local.set.mockReset().mockResolvedValue(undefined); + chrome.alarms.get.mockReset().mockResolvedValue(undefined); + chrome.alarms.create.mockReset().mockResolvedValue(undefined); await import('../../background.js'); await vi.waitFor(() => { @@ -161,6 +163,37 @@ describe('Extension workflow', () => { expect(global.fetch).toHaveBeenCalledTimes(1); }); + it('stops queued sections after a fatal failure without merging or saving partial notes', async () => { + let finishSiblings; + const siblings = new Promise(resolve => { + finishSiblings = resolve; + }); + global.fetch + .mockResolvedValueOnce({ + ok: false, + status: 400, + headers: { get: () => null }, + json: async () => ({ error: { message: 'Rejected section' } }), + }) + .mockReturnValue(siblings); + + const response = await dispatch('GENERATE_CONTENT_SUMMARY', { + content: 'x'.repeat(190000), + }); + + expect(response).toMatchObject({ + success: false, + errorCode: 'PROVIDER_ERROR', + error: 'Anthropic Claude returned 400: Rejected section', + }); + expect(global.fetch).toHaveBeenCalledTimes(3); + finishSiblings(CLAUDE_REPLY); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(global.fetch).toHaveBeenCalledTimes(3); + expect(chrome.storage.local.set).not.toHaveBeenCalled(); + }); + it('rejects empty content before spending an API call', async () => { global.fetch.mockResolvedValue(CLAUDE_REPLY); @@ -977,7 +1010,195 @@ describe('Extension workflow', () => { }); }); + describe('settings writes', () => { + let stored; + beforeEach(() => { + stored = structuredClone(CONFIGURED); + chrome.storage.sync.get.mockImplementation(async () => + structuredClone(stored) + ); + chrome.storage.sync.set.mockImplementation(async values => { + Object.assign(stored, structuredClone(values)); + }); + }); + + it('preserves overlapping edits received by the shared worker writer', async () => { + const results = await Promise.all([ + dispatch('UPDATE_USER_PREFERENCES', { theme: 'dark' }), + dispatch('UPDATE_USER_PREFERENCES', { + features: { notifications: false }, + }), + dispatch('UPDATE_USER_PREFERENCES', { + features: { saveHistory: false }, + }), + dispatch('UPDATE_USER_PREFERENCES', { + models: { openai: 'fixture-model' }, + }), + ]); + expect(results.every(response => response.success)).toBe(true); + expect(stored.user_preferences).toMatchObject({ + theme: 'dark', + features: { notifications: false, saveHistory: false }, + models: { openai: 'fixture-model' }, + apiKeys: CONFIGURED.user_preferences.apiKeys, + }); + }); + + it('replaces preferences on explicit reset without requiring another read', async () => { + chrome.storage.sync.get.mockRejectedValue( + new Error('storage unavailable') + ); + const result = await dispatch('RESET_USER_PREFERENCES', {}); + expect(result.success).toBe(true); + expect(result.data.apiKeys).toEqual({}); + expect(result.data.preferredProvider).toBe('anthropic'); + expect(stored.user_preferences.apiKeys).toEqual({}); + expect(stored.user_preferences.models).toEqual({}); + }); + + it('orders reset before a later edit and recovers after a failed reset', async () => { + const [reset, update] = await Promise.all([ + dispatch('RESET_USER_PREFERENCES', {}), + dispatch('UPDATE_USER_PREFERENCES', { theme: 'dark' }), + ]); + expect(reset.success).toBe(true); + expect(update.success).toBe(true); + expect(stored.user_preferences).toMatchObject({ + apiKeys: {}, + theme: 'dark', + }); + expect(stored.user_preferences.apiKeys).toEqual({}); + + chrome.storage.sync.set.mockRejectedValueOnce( + new Error('fixture write failure') + ); + const [failedReset, later] = await Promise.all([ + dispatch('RESET_USER_PREFERENCES', {}), + dispatch('UPDATE_USER_PREFERENCES', { summaryType: 'tldr' }), + ]); + expect(failedReset).toMatchObject({ + success: false, + error: 'fixture write failure', + }); + expect(later.success).toBe(true); + expect(stored.user_preferences).toMatchObject({ + theme: 'dark', + summaryType: 'tldr', + }); + }); + }); + describe('worker lifecycle', () => { + it('preserves the cleanup alarm scheduled time across worker restarts', async () => { + const scheduledTime = Date.now() + 30 * 60 * 1000; + let alarm = { + name: 'cleanupOldData', + periodInMinutes: 1440, + scheduledTime, + }; + chrome.alarms.get.mockImplementation(async () => structuredClone(alarm)); + chrome.alarms.create.mockImplementation(async (name, info) => { + alarm = { name, ...info, scheduledTime: Date.now() + 86400000 }; + }); + vi.clearAllMocks(); + + for (let wake = 0; wake < 2; wake++) { + vi.resetModules(); + await import('../../background.js'); + } + + expect(alarm.scheduledTime).toBe(scheduledTime); + expect(chrome.alarms.create).not.toHaveBeenCalled(); + expect(chrome.alarms.get).toHaveBeenCalledTimes(2); + expect(chrome.alarms.get).toHaveBeenCalledWith('cleanupOldData'); + }); + + it('creates a missing cleanup alarm at startup', async () => { + vi.resetModules(); + vi.clearAllMocks(); + await import('../../background.js'); + + await vi.waitFor(() => { + expect(chrome.alarms.create).toHaveBeenCalledOnce(); + }); + expect(chrome.alarms.get).toHaveBeenCalledWith('cleanupOldData'); + expect(chrome.alarms.create).toHaveBeenCalledWith('cleanupOldData', { + periodInMinutes: 1440, + }); + }); + + it('registers listeners and routes messages while the alarm read is pending', async () => { + let finishRead; + chrome.alarms.get.mockReturnValueOnce( + new Promise(resolve => { + finishRead = resolve; + }) + ); + vi.resetModules(); + vi.clearAllMocks(); + await import('../../background.js'); + + try { + expect(chrome.runtime.onMessage.addListener).toHaveBeenCalledOnce(); + expect(chrome.alarms.onAlarm.addListener).toHaveBeenCalledOnce(); + expect(chrome.storage.onChanged.addListener).toHaveBeenCalledOnce(); + expect(chrome.alarms.create).not.toHaveBeenCalled(); + expect(await dispatch('GET_HISTORY', {})).toMatchObject({ + success: true, + data: { summaries: [], conversations: [] }, + }); + } finally { + finishRead(undefined); + } + await vi.waitFor(() => { + expect(chrome.alarms.create).toHaveBeenCalledOnce(); + }); + }); + + it.each(['get', 'create'])( + 'handles a rejected alarm %s and retries at the next worker start', + async method => { + const errorLog = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + chrome.alarms[method].mockRejectedValueOnce( + new Error('fixture-private-alarm-detail') + ); + vi.resetModules(); + vi.clearAllMocks(); + + try { + await import('../../background.js'); + await vi.waitFor(() => { + expect(errorLog).toHaveBeenCalledWith( + expect.stringContaining( + 'History cleanup alarm initialization failed' + ), + '' + ); + }); + expect(await dispatch('GET_HISTORY', {})).toMatchObject({ + success: true, + }); + if (method === 'get') + expect(chrome.alarms.create).not.toHaveBeenCalled(); + expect(JSON.stringify(errorLog.mock.calls)).not.toContain( + 'fixture-private-alarm-detail' + ); + + vi.resetModules(); + await import('../../background.js'); + await vi.waitFor(() => { + expect(chrome.alarms.create).toHaveBeenCalledTimes( + method === 'create' ? 2 : 1 + ); + }); + } finally { + errorLog.mockRestore(); + } + } + ); + it.each(['install', 'update'])( 'handles unavailable settings during %s without skipping first-install options', async reason => { @@ -1057,6 +1278,7 @@ describe('Extension workflow', () => { ).toHaveBeenCalledOnce(); expect(chrome.runtime.onInstalled.addListener).toHaveBeenCalledOnce(); expect(chrome.alarms.onAlarm.addListener).toHaveBeenCalledOnce(); + expect(chrome.alarms.create).toHaveBeenCalledOnce(); const response = await dispatch('GET_HISTORY', {}); expect(response).toMatchObject({ success: true, diff --git a/tests/options/settings.test.js b/tests/options/settings.test.js index ed3d577..6d50902 100644 --- a/tests/options/settings.test.js +++ b/tests/options/settings.test.js @@ -2,8 +2,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { readFileSync } from 'node:fs'; /** - * The options page is the only writer of `user_preferences`. What matters is - * that every control maps to the field the background service later reads — + * Options controls send settings edits to the worker's shared writer. Every + * control must map to the field the background service later reads — * a mismatch here is invisible in the UI and shows up as "changing the setting * does nothing", which is exactly the class of bug 5.1.0 had to fix. * @@ -55,6 +55,14 @@ function lastSaved() { return calls.at(-1)[0].user_preferences; } +/** Route extension messages through the shipped worker. */ +function forwardToWorker(message) { + const handler = chrome.runtime.onMessage.addListener.mock.calls.at(-1)[0]; + return new Promise(resolve => { + handler(message, { id: 'mock-extension-id' }, resolve); + }); +} + /** * @param {string} id * @param {string} value @@ -68,11 +76,19 @@ function change(id, value) { /** @param {Record} stored */ async function loadPage(stored = {}) { document.body.innerHTML = OPTIONS_MARKUP; - chrome.storage.sync.get.mockResolvedValue( - Object.keys(stored).length ? { user_preferences: stored } : {} + const contents = Object.keys(stored).length + ? { user_preferences: structuredClone(stored) } + : {}; + chrome.storage.sync.get.mockImplementation(async () => + structuredClone(contents) ); + chrome.storage.sync.set.mockImplementation(async values => { + Object.assign(contents, structuredClone(values)); + }); vi.resetModules(); + await import('../../background.js'); + chrome.runtime.sendMessage.mockImplementation(forwardToWorker); await import('../../options.js'); document.dispatchEvent(new Event('DOMContentLoaded')); @@ -147,6 +163,7 @@ describe('options page', () => { ).toMatch(/Failed to fetch/) ); expect(chrome.runtime.sendMessage).toHaveBeenCalledTimes(1); + chrome.runtime.sendMessage.mockImplementation(forwardToWorker); change('anthropic-key', 'replacement-key'); await vi.waitFor(() => expect(lastSaved().apiKeys.anthropic).toBe('replacement-key') @@ -161,7 +178,8 @@ describe('options page', () => { }); beforeEach(() => { vi.clearAllMocks(); - chrome.storage.sync.set.mockResolvedValue(undefined); + chrome.storage.sync.set.mockReset().mockResolvedValue(undefined); + chrome.runtime.sendMessage.mockReset(); }); describe('rendering saved settings', () => { @@ -220,6 +238,10 @@ describe('options page', () => { expect(chrome.storage.sync.set).toHaveBeenCalled() ); expect(lastSaved().apiKeys.openai).toBe('sk-typed'); + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ + actionType: 'UPDATE_USER_PREFERENCES', + payload: { apiKeys: { openai: 'sk-typed' } }, + }); }); it('stores a model override under models.', async () => { @@ -324,6 +346,7 @@ describe('options page', () => { await Promise.resolve(); expect(chrome.storage.sync.set).not.toHaveBeenCalled(); + expect(chrome.runtime.sendMessage).not.toHaveBeenCalled(); }); it('clears stored keys once confirmed', async () => { @@ -337,6 +360,10 @@ describe('options page', () => { ); expect(lastSaved().apiKeys).toEqual({}); expect(lastSaved().preferredProvider).toBe('anthropic'); + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ + actionType: 'RESET_USER_PREFERENCES', + payload: {}, + }); }); it('finishes a confirmed reset even if subsequent reads are unavailable', async () => { diff --git a/tests/popup/interface.test.js b/tests/popup/interface.test.js index f58aeeb..43cf3c8 100644 --- a/tests/popup/interface.test.js +++ b/tests/popup/interface.test.js @@ -47,6 +47,7 @@ async function openPopup({ preferences = {}, providerStatus = {}, overrides = {}, + waitForReady = true, } = {}) { responses = { GET_USER_PREFERENCES: { @@ -89,17 +90,346 @@ async function openPopup({ await import('../../scripts/popup-main.js'); document.dispatchEvent(new Event('DOMContentLoaded')); - await vi.waitFor(() => - expect(el('loading-overlay').style.display).toBe('none') - ); + if (waitForReady) + await vi.waitFor(() => + expect(el('loading-overlay').style.display).toBe('none') + ); +} + +function deferred() { + let resolve; + const promise = new Promise(done => { + resolve = done; + }); + return { promise, resolve }; +} + +async function importBook() { + const input = el('document-file'); + Object.defineProperty(input, 'files', { + configurable: true, + value: [ + { + name: 'book-B.txt', + size: 24, + text: async () => 'Book B is about bananas.', + }, + ], + }); + input.dispatchEvent(new Event('change')); + await vi.waitFor(() => { + expect(text('current-page-title')).toBe('book-B.txt'); + expect(el('loading-overlay').style.display).toBe('none'); + }); } +// Drain the message continuation chain before asserting that stale work stayed inert. +const settleResponses = () => new Promise(done => setTimeout(done, 0)); + describe('popup', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('learning workspace', () => { + it.each([true, false])( + 'ignores old source links after import (success=%s)', + async success => { + const pending = deferred(); + await openPopup(); + chrome.tabs.sendMessage.mockReturnValue(pending.promise); + el('extract-links-btn').click(); + await vi.waitFor(() => + expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(7, { + action: 'extractLinks', + }) + ); + await importBook(); + pending.resolve( + success + ? { + success: true, + data: [ + { href: 'https://old.example/', text: 'Old source link' }, + ], + } + : { success: false, error: 'Old source error' } + ); + await settleResponses(); + expect(el('tools-results').style.display).toBe('none'); + expect(text('tools-results')).not.toMatch(/Old source/); + } + ); + + it.each([true, false])( + 'ignores a previous document analysis response after import (success=%s)', + async success => { + const pending = deferred(); + await openPopup({ + overrides: { EXTRACT_KEY_INSIGHTS: pending.promise }, + }); + document.querySelector('[data-type="insights"]').click(); + await vi.waitFor(() => + expect(sentPayload('EXTRACT_KEY_INSIGHTS')?.text).toBe(PAGE.mainText) + ); + await importBook(); + pending.resolve( + success + ? { success: true, data: { text: 'Old article result' } } + : { success: false, error: 'Old article failure' } + ); + await settleResponses(); + expect(text('current-page-title')).toBe('book-B.txt'); + expect(text('insights-result')).toBe( + 'Run this analysis for this document.' + ); + } + ); + + it.each([true, false])( + 'keeps an imported document when startup extraction finishes late (success=%s)', + async success => { + const pending = deferred(); + await openPopup({ + waitForReady: false, + overrides: { EXTRACT_PAGE_CONTENT: pending.promise }, + }); + await vi.waitFor(() => + expect(sentPayload('EXTRACT_PAGE_CONTENT')).toEqual({ tabId: 7 }) + ); + await importBook(); + pending.resolve( + success + ? { success: true, data: PAGE } + : { success: false, error: 'Old extraction failed' } + ); + await settleResponses(); + expect(text('current-page-title')).toBe('book-B.txt'); + expect(text('current-page-status')).toContain('Ready'); + responses.ANSWER_CONTEXTUAL_QUESTION = { + success: true, + data: { answer: 'Bananas.' }, + }; + el('chat-input').value = 'What fruit?'; + el('send-chat-btn').click(); + await vi.waitFor(() => + expect(sentPayload('ANSWER_CONTEXTUAL_QUESTION')?.context).toBe( + 'Book B is about bananas.' + ) + ); + } + ); + + it('keeps an imported document when preferences finish loading before extraction', async () => { + const pending = deferred(); + await openPopup({ + waitForReady: false, + overrides: { GET_USER_PREFERENCES: pending.promise }, + }); + await vi.waitFor(() => + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ actionType: 'GET_USER_PREFERENCES' }) + ) + ); + await importBook(); + pending.resolve({ success: true, data: { theme: 'light' } }); + await settleResponses(); + expect(text('current-page-title')).toBe('book-B.txt'); + expect(sentPayload('EXTRACT_PAGE_CONTENT')).toBeUndefined(); + }); + + it('does not restore an old history snapshot after a successful clear', async () => { + const pending = deferred(); + await openPopup({ + waitForReady: false, + overrides: { + GET_DOCUMENT_HISTORY: pending.promise, + CLEAR_DOCUMENT_CHAT: { success: true, data: { removed: 1 } }, + }, + }); + await vi.waitFor(() => + expect(sentPayload('GET_DOCUMENT_HISTORY')?.url).toBe(PAGE.url) + ); + el('clear-chat-btn').click(); + await vi.waitFor(() => + expect(text('chat-memory-status')).toContain('Chat cleared') + ); + pending.resolve({ + success: true, + data: { + conversations: [{ question: 'Old question', answer: 'Old answer' }], + memoryEnabled: true, + }, + }); + await settleResponses(); + expect(text('chat-history')).toBe(''); + expect(text('chat-memory-status')).toContain('Chat cleared'); + responses.ANSWER_CONTEXTUAL_QUESTION = { + success: true, + data: { answer: 'Fresh answer' }, + }; + el('chat-input').value = 'Fresh question'; + el('send-chat-btn').click(); + await vi.waitFor(() => + expect(sentPayload('ANSWER_CONTEXTUAL_QUESTION')).toBeDefined() + ); + expect( + JSON.stringify( + sentPayload('ANSWER_CONTEXTUAL_QUESTION').conversationHistory + ) + ).not.toContain('Old'); + }); + + it.each(['send', 'clear'])( + 'restores the saved summary when %s invalidates only pending chat history', + async action => { + const pending = deferred(); + await openPopup({ + waitForReady: false, + overrides: { + GET_DOCUMENT_HISTORY: pending.promise, + CLEAR_DOCUMENT_CHAT: { success: true, data: { removed: 1 } }, + ANSWER_CONTEXTUAL_QUESTION: { + success: true, + data: { answer: 'Fresh answer' }, + }, + }, + }); + await vi.waitFor(() => + expect(sentPayload('GET_DOCUMENT_HISTORY')).toBeDefined() + ); + if (action === 'send') { + el('chat-input').value = 'Fresh question'; + el('send-chat-btn').click(); + await vi.waitFor(() => + expect(text('chat-history')).toContain('Fresh answer') + ); + } else { + el('clear-chat-btn').click(); + await vi.waitFor(() => + expect(text('chat-memory-status')).toContain('Chat cleared') + ); + } + pending.resolve({ + success: true, + data: { + summaries: [ + { summary: 'Saved summary', provider: 'test', model: 'fixture' }, + ], + conversations: [{ question: 'Old question', answer: 'Old answer' }], + memoryEnabled: true, + }, + }); + await vi.waitFor(() => + expect(text('summary-content')).toContain('Saved summary') + ); + expect(text('chat-history')).not.toContain('Old answer'); + } + ); + + it('keeps a freshly generated summary when an older history snapshot arrives', async () => { + const pending = deferred(); + await openPopup({ + waitForReady: false, + overrides: { + GET_DOCUMENT_HISTORY: pending.promise, + GENERATE_CONTENT_SUMMARY: { + success: true, + data: { + summary: 'Fresh summary', + provider: 'test', + model: 'fixture', + }, + }, + }, + }); + await vi.waitFor(() => + expect(sentPayload('GET_DOCUMENT_HISTORY')).toBeDefined() + ); + el('generate-summary-btn').click(); + await vi.waitFor(() => + expect(text('summary-content')).toContain('Fresh summary') + ); + pending.resolve({ + success: true, + data: { + summaries: [ + { summary: 'Old summary', provider: 'test', model: 'fixture' }, + ], + conversations: [], + memoryEnabled: true, + }, + }); + await settleResponses(); + expect(text('summary-content')).toBe('Fresh summary'); + }); + + it('allows pending saved history to restore after clearing fails', async () => { + const pending = deferred(); + await openPopup({ + waitForReady: false, + overrides: { + GET_DOCUMENT_HISTORY: pending.promise, + CLEAR_DOCUMENT_CHAT: { + success: false, + error: 'Could not clear saved chat', + }, + }, + }); + await vi.waitFor(() => + expect(sentPayload('GET_DOCUMENT_HISTORY')).toBeDefined() + ); + el('clear-chat-btn').click(); + await vi.waitFor(() => + expect(text('toast-container')).toContain('Could not clear saved chat') + ); + pending.resolve({ + success: true, + data: { + conversations: [ + { question: 'Saved question', answer: 'Saved answer' }, + ], + memoryEnabled: true, + }, + }); + await vi.waitFor(() => + expect(text('chat-history')).toContain('Saved answer') + ); + expect(text('chat-memory-status')).toContain('Restored 1'); + }); + + it('keeps a new exchange when an older restoration snapshot arrives', async () => { + const pending = deferred(); + await openPopup({ + waitForReady: false, + overrides: { + GET_DOCUMENT_HISTORY: pending.promise, + ANSWER_CONTEXTUAL_QUESTION: { + success: true, + data: { answer: 'Fresh answer' }, + }, + }, + }); + await vi.waitFor(() => + expect(sentPayload('GET_DOCUMENT_HISTORY')).toBeDefined() + ); + el('chat-input').value = 'Fresh question'; + el('send-chat-btn').click(); + await vi.waitFor(() => + expect(text('chat-history')).toContain('Fresh answer') + ); + pending.resolve({ + success: true, + data: { + conversations: [{ question: 'Old question', answer: 'Old answer' }], + memoryEnabled: true, + }, + }); + await settleResponses(); + expect(text('chat-history')).toContain('Fresh answer'); + expect(text('chat-history')).not.toContain('Old answer'); + }); + it('keeps a generated summary visible when it could not be saved', async () => { await openPopup({ overrides: { @@ -378,6 +708,7 @@ describe('popup', () => { await vi.waitFor(() => expect(text('current-page-title')).toBe('new.txt') ); + expect(el('summary-empty').style.display).toBe('flex'); expect(el('translation-results').style.display).toBe('none'); expect(text('insights-result')).not.toContain('old article'); el('extract-links-btn').click(); @@ -387,16 +718,34 @@ describe('popup', () => { expect(chrome.tabs.sendMessage).not.toHaveBeenCalled(); }); - it('persists translation preferences on change', async () => { + it.each([ + ['target-language', 'targetLanguage'], + ['source-language', 'sourceLanguage'], + ])( + 'persists only the changed translation preference: %s', + async (id, field) => { + await openPopup({ + overrides: { UPDATE_USER_PREFERENCES: { success: true } }, + }); + el(id).value = 'fr'; + el(id).dispatchEvent(new Event('change')); + await vi.waitFor(() => + expect(sentPayload('UPDATE_USER_PREFERENCES')).toEqual({ + [field]: 'fr', + }) + ); + } + ); + + it('persists both languages after an explicit swap', async () => { await openPopup({ - overrides: { UPDATE_USER_PREFERENCES: { success: true } }, + preferences: { sourceLanguage: 'fr', targetLanguage: 'en' }, }); - el('target-language').value = 'fr'; - el('target-language').dispatchEvent(new Event('change')); + el('swap-languages').click(); await vi.waitFor(() => expect(sentPayload('UPDATE_USER_PREFERENCES')).toEqual({ + sourceLanguage: 'en', targetLanguage: 'fr', - sourceLanguage: 'auto', }) ); }); @@ -543,6 +892,7 @@ describe('popup', () => { expect(el('summary-results').style.display).toBe('block') ); expect(el('summary-content').innerHTML).toContain('
  • point one
  • '); + expect(el('summary-empty').style.display).toBe('none'); expect(text('summary-provider')).toBe('anthropic · claude-opus-5'); expect(text('summary-confidence')).toBe( 'Whole page, summarized across 3 sections' @@ -902,6 +1252,63 @@ describe('popup', () => { }); describe('tabs', () => { + it('exposes the selected tab and its panel and keeps keyboard focus on the tabs', async () => { + await openPopup(); + const summary = document.querySelector('[data-tab="summary"]'); + const chat = document.querySelector('[data-tab="chat"]'); + expect(summary.getAttribute('role')).toBe('tab'); + expect(summary.getAttribute('aria-selected')).toBe('true'); + expect(chat.tabIndex).toBe(-1); + summary.focus(); + summary.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }) + ); + expect(document.activeElement).toBe(chat); + expect(chat.getAttribute('aria-selected')).toBe('true'); + expect(chat.tabIndex).toBe(0); + expect(el('summary-tab').hidden).toBe(true); + expect(el('chat-tab').hidden).toBe(false); + expect(el('chat-tab').getAttribute('aria-labelledby')).toBe(chat.id); + chat.dispatchEvent( + new KeyboardEvent('keydown', { key: 'End', bubbles: true }) + ); + expect(document.activeElement.dataset.tab).toBe('tools'); + document.activeElement.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }) + ); + expect(document.activeElement).toBe(summary); + summary.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }) + ); + expect(document.activeElement.dataset.tab).toBe('tools'); + document.activeElement.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Home', bubbles: true }) + ); + expect(document.activeElement).toBe(summary); + }); + + it('preserves the complete study prompt behind a short action label', async () => { + await openPopup({ + overrides: { + ANSWER_CONTEXTUAL_QUESTION: { + success: true, + data: { answer: 'Study notes' }, + }, + }, + }); + document.querySelector('[data-tab="chat"]').click(); + const chip = [...document.querySelectorAll('.suggestion-chip')].find( + button => button.textContent.trim() === 'Study guide' + ); + expect(chip).toBeDefined(); + chip.click(); + await vi.waitFor(() => + expect(sentPayload('ANSWER_CONTEXTUAL_QUESTION')?.question).toBe( + 'Make a study guide with key concepts and examples from this document' + ) + ); + }); + it('activates the pane for the clicked tab', async () => { await openPopup(); diff --git a/tests/providers/ai-client.test.js b/tests/providers/ai-client.test.js index 1816041..ceaa7d7 100644 --- a/tests/providers/ai-client.test.js +++ b/tests/providers/ai-client.test.js @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { AIClient, AIError, @@ -255,6 +255,73 @@ describe('AIClient', () => { }); }); + describe('response body deadlines', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it.each([ + { label: 'successful', status: 200, body: ANTHROPIC_OK }, + { + label: 'retryable error', + status: 503, + body: { error: { message: 'overloaded' } }, + }, + { + label: 'authentication error', + status: 401, + body: { error: { message: 'invalid key' } }, + }, + ])( + 'times out a delayed $label body without retrying', + async ({ status, body }) => { + const response = mockResponse(body, { ok: status === 200, status }); + global.fetch.mockImplementation((_url, { signal }) => { + response.json.mockImplementation( + () => + new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve(body), 200); + signal.addEventListener( + 'abort', + () => { + clearTimeout(timer); + reject(new DOMException('Body read aborted', 'AbortError')); + }, + { once: true } + ); + }) + ); + return Promise.resolve(response); + }); + + let outcome; + const completion = fastClient({ timeoutMs: 50 }) + .complete('s', 'u') + .then( + text => { + outcome = { text }; + }, + error => { + outcome = { error }; + } + ); + await vi.advanceTimersByTimeAsync(49); + expect(response.json).toHaveBeenCalledTimes(1); + expect(outcome).toBeUndefined(); + + await vi.advanceTimersByTimeAsync(1); + expect(outcome).toMatchObject({ + error: { code: 'TIMEOUT', message: expect.stringContaining('50ms') }, + }); + await completion; + expect(outcome.error).toBeInstanceOf(AIError); + expect(global.fetch).toHaveBeenCalledTimes(1); + } + ); + }); + describe('backoff', () => { it('grows exponentially and stays bounded', () => { const first = backoffDelay(0); @@ -373,6 +440,17 @@ describe('AIClient', () => { }); }); + it('does not retry malformed successful JSON as a network failure', async () => { + const response = mockResponse({}); + response.json.mockRejectedValue(new SyntaxError('not json')); + global.fetch.mockResolvedValue(response); + + await expect(fastClient().complete('s', 'u')).rejects.toBeInstanceOf( + SyntaxError + ); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + it('reports a network failure without masking the cause', async () => { global.fetch.mockRejectedValue(new TypeError('Failed to fetch')); const client = new AIClient({ diff --git a/tests/setup.js b/tests/setup.js index 056383f..4509241 100644 --- a/tests/setup.js +++ b/tests/setup.js @@ -71,6 +71,7 @@ const mockChrome = { commands: { onCommand: { addListener: vi.fn() } }, notifications: { create: vi.fn(), clear: vi.fn() }, alarms: { + get: vi.fn().mockResolvedValue(undefined), create: vi.fn(), clear: vi.fn(), onAlarm: { addListener: vi.fn() },