From 6372ce0505fd6f9e5266e529f3b8aace7b0416ed Mon Sep 17 00:00:00 2001 From: Levy Tate <78818969+iLevyTate@users.noreply.github.com> Date: Thu, 12 Nov 2026 09:17:10 -0500 Subject: [PATCH] fix: follow-up batch (learning, small-GPU loading, chat history, CI cost, e2e layout) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Atlas: "Smart folder…" acts on the current selection. - Chat: Regenerate and Retry remove the discarded answer from saved history. - Learning: manual moves teach, undo retracts the matching pattern, and auto-organize records feedback once, after the move succeeds. - Search index: show a warning when saving the index keeps failing; it clears on the next successful save. - Small GPUs: reserve vision runtime memory atomically, return the embedding model to the GPU after vision work, and stop zero-byte CPU-beside residents from counting against the budget. - Remove the unused watcher reanalyze route (channel, handler, schema). - officeparser 8.6.0 -> 8.7.0. - CI: pull requests run Ubuntu + Windows; macOS and e2e-smoke run on push. - E2E: specs grouped into feature folders; same 387 tests in 40 files. --- .github/WORKFLOW_README.md | 11 +- .github/workflows/ci.yml | 27 +- CHANGELOG.md | 14 + CLAUDE.md | 8 +- docs/TESTING.md | 4 + docs/commercial-use-inventory.json | 4 +- package-lock.json | 8 +- package.json | 4 +- playwright.config.js | 5 +- src/main/ipc/files/fileOperationHandlers.js | 26 +- src/main/ipc/semantic.js | 146 ----------- src/main/ipc/validationSchemas.js | 9 - src/main/services/ChatHistoryStore.js | 27 ++ src/main/services/ChatService.js | 28 +- src/main/services/LlamaService.js | 74 +++++- src/main/services/ModelMemoryManager.js | 75 ++++-- src/main/services/OramaVectorService.js | 38 +++ src/main/services/SmartFolderWatcher.js | 93 +------ src/main/services/VisionService.js | 32 ++- .../autoOrganize/AutoOrganizeServiceCore.js | 3 +- .../services/autoOrganize/batchProcessor.js | 69 +---- .../services/autoOrganize/fileProcessor.js | 13 +- src/main/services/chat/SessionMemory.js | 61 +++-- src/main/services/liveLogActivity.js | 6 +- .../services/organization/patternMatcher.js | 66 ++++- src/main/services/organization/persistence.js | 4 +- .../services/watcher/WatcherQueueStore.js | 9 +- src/preload/preload.js | 3 - .../components/search/AtlasTabContent.jsx | 22 +- .../components/search/atlasMapUtils.js | 43 +++ src/shared/constants.js | 1 - test/AutoOrganizeService.test.js | 28 +- test/ChatHistoryStore.validation.test.js | 75 ++++++ test/ChatService.sessions.test.js | 120 ++++++++- test/LlamaService.inference.test.js | 65 +++++ test/LlamaService.visionBatchLane.test.js | 80 ++++++ ...delMemoryManager.zeroByteResidents.test.js | 180 +++++++++++++ ...ramaVectorService.saveFailedBanner.test.js | 166 ++++++++++++ test/PatternPersistence.test.js | 23 ++ test/SessionMemory.test.js | 59 ++++- test/SmartFolderWatcher.coverage.test.js | 17 +- test/SmartFolderWatcher.migration.test.js | 6 +- ...martFolderWatcher.queuePersistence.test.js | 43 +-- test/VisionService.test.js | 101 +++++++ test/WatcherQueueStore.test.js | 17 -- test/a11y/uiComponents.a11y.test.js | 4 +- test/autoOrganize-batchProcessor.edge.test.js | 2 - test/autoOrganize-batchProcessor.test.js | 84 ++---- test/autoOrganize-fileProcessor.test.js | 3 +- test/autoOrganize.learnsOnceAfterMove.test.js | 168 ++++++++++++ test/e2e/README.md | 246 +++++++++++++----- test/e2e/{ => app}/accessibility.spec.js | 12 +- test/e2e/{ => app}/app-launch.spec.js | 2 +- test/e2e/{ => app}/error-handling.spec.js | 2 +- .../e2e/{ => app}/keyboard-navigation.spec.js | 8 +- test/e2e/{ => app}/menu-shortcuts.spec.js | 8 +- test/e2e/{ => app}/navigation.spec.js | 6 +- test/e2e/{ => app}/notifications.spec.js | 4 +- .../e2e/{ => app}/session-persistence.spec.js | 2 +- test/e2e/{ => app}/theme-persistence.spec.js | 4 +- test/e2e/{ => app}/window-state.spec.js | 7 +- test/e2e/{ => discover}/analysis-flow.spec.js | 8 +- .../{ => discover}/analysis-history.spec.js | 4 +- .../downloads-triage-api.spec.js | 2 +- test/e2e/{ => discover}/drag-drop.spec.js | 6 +- test/e2e/{ => discover}/file-import.spec.js | 6 +- .../{ => discover}/last-browsed-path.spec.js | 6 +- .../{ => discover}/naming-strategy.spec.js | 6 +- .../vision-ocr-analysis.spec.js | 4 +- .../whisper-variant-loader.spec.js | 6 +- test/e2e/helpers/electronApp.js | 2 +- test/e2e/{ => live-llm}/full-journey.spec.js | 6 +- test/e2e/{ => live-llm}/live-llm-chat.spec.js | 8 +- .../{ => live-llm}/live-llm-vision.spec.js | 8 +- .../e2e/{ => manual}/qa-runtime-repro.spec.js | 12 +- test/e2e/{ => manual}/soak.spec.js | 8 +- .../e2e/{ => organize}/organize-phase.spec.js | 8 +- test/e2e/{ => organize}/undo-redo.spec.js | 4 +- test/e2e/{ => search}/atlas-tab.spec.js | 4 +- test/e2e/{ => search}/chat-understand.spec.js | 4 +- test/e2e/{ => search}/knowledge-graph.spec.js | 11 +- test/e2e/{ => search}/search-modal.spec.js | 6 +- test/e2e/{ => settings}/ai-provider.spec.js | 2 +- .../cloud-consent-theme.spec.js | 4 +- .../e2e/{ => settings}/mcp-onboarding.spec.js | 6 +- .../{ => settings}/model-switching.spec.js | 6 +- .../settings-backup-restore.spec.js | 6 +- .../settings-subcategories-smoke.spec.js | 6 +- test/e2e/{ => setup}/smart-folder-add.spec.js | 6 +- test/e2e/{ => setup}/smart-folders.spec.js | 6 +- .../e2e/{ => workflows}/beta-workflow.spec.js | 10 +- .../e2e/{ => workflows}/full-pipeline.spec.js | 8 +- test/e2eIsolatedProfileSeed.test.js | 5 +- test/e2eSpecsHaveAssertions.test.js | 180 ++++++------- test/fileOperationHandlers.test.js | 55 ++++ .../learningFeedback.patternRoundTrip.test.js | 40 +++ test/main/services/liveLogActivity.test.js | 10 +- test/patternMatcher.test.js | 92 +++++++ test/unit/atlasMapUtils.test.js | 49 +++- 99 files changed, 2217 insertions(+), 878 deletions(-) create mode 100644 test/ModelMemoryManager.zeroByteResidents.test.js create mode 100644 test/OramaVectorService.saveFailedBanner.test.js create mode 100644 test/autoOrganize.learnsOnceAfterMove.test.js rename test/e2e/{ => app}/accessibility.spec.js (95%) rename test/e2e/{ => app}/app-launch.spec.js (98%) rename test/e2e/{ => app}/error-handling.spec.js (96%) rename test/e2e/{ => app}/keyboard-navigation.spec.js (98%) rename test/e2e/{ => app}/menu-shortcuts.spec.js (98%) rename test/e2e/{ => app}/navigation.spec.js (98%) rename test/e2e/{ => app}/notifications.spec.js (98%) rename test/e2e/{ => app}/session-persistence.spec.js (99%) rename test/e2e/{ => app}/theme-persistence.spec.js (98%) rename test/e2e/{ => app}/window-state.spec.js (99%) rename test/e2e/{ => discover}/analysis-flow.spec.js (94%) rename test/e2e/{ => discover}/analysis-history.spec.js (98%) rename test/e2e/{ => discover}/downloads-triage-api.spec.js (91%) rename test/e2e/{ => discover}/drag-drop.spec.js (98%) rename test/e2e/{ => discover}/file-import.spec.js (97%) rename test/e2e/{ => discover}/last-browsed-path.spec.js (98%) rename test/e2e/{ => discover}/naming-strategy.spec.js (98%) rename test/e2e/{ => discover}/vision-ocr-analysis.spec.js (96%) rename test/e2e/{ => discover}/whisper-variant-loader.spec.js (97%) rename test/e2e/{ => live-llm}/full-journey.spec.js (96%) rename test/e2e/{ => live-llm}/live-llm-chat.spec.js (94%) rename test/e2e/{ => live-llm}/live-llm-vision.spec.js (88%) rename test/e2e/{ => manual}/qa-runtime-repro.spec.js (98%) rename test/e2e/{ => manual}/soak.spec.js (97%) rename test/e2e/{ => organize}/organize-phase.spec.js (97%) rename test/e2e/{ => organize}/undo-redo.spec.js (98%) rename test/e2e/{ => search}/atlas-tab.spec.js (97%) rename test/e2e/{ => search}/chat-understand.spec.js (97%) rename test/e2e/{ => search}/knowledge-graph.spec.js (97%) rename test/e2e/{ => search}/search-modal.spec.js (99%) rename test/e2e/{ => settings}/ai-provider.spec.js (98%) rename test/e2e/{ => settings}/cloud-consent-theme.spec.js (98%) rename test/e2e/{ => settings}/mcp-onboarding.spec.js (96%) rename test/e2e/{ => settings}/model-switching.spec.js (97%) rename test/e2e/{ => settings}/settings-backup-restore.spec.js (96%) rename test/e2e/{ => settings}/settings-subcategories-smoke.spec.js (97%) rename test/e2e/{ => setup}/smart-folder-add.spec.js (99%) rename test/e2e/{ => setup}/smart-folders.spec.js (99%) rename test/e2e/{ => workflows}/beta-workflow.spec.js (98%) rename test/e2e/{ => workflows}/full-pipeline.spec.js (97%) diff --git a/.github/WORKFLOW_README.md b/.github/WORKFLOW_README.md index bb1b533..2250f36 100644 --- a/.github/WORKFLOW_README.md +++ b/.github/WORKFLOW_README.md @@ -32,11 +32,12 @@ This repository is configured with automated dependency updates and release buil ### 3. CI (`.github/workflows/ci.yml`) -- **Job (`ci`)**: on pull requests, `npm ci` then **`npm run ci`** on Ubuntu, Windows, and macOS - (format, lint, typecheck, production audit, commercial compliance verify, `test:coverage`, - `generate:channels:check`, the full `verify:*` suite, and `build` — see `package.json` for the - exact step list). -- **Job (`e2e-smoke`)**: Playwright smoke specs on Ubuntu and Windows. +- **Job (`ci`)**: `npm ci` then **`npm run ci`** (format, lint, typecheck, production audit, + commercial compliance verify, `test:coverage`, `generate:channels:check`, the full `verify:*` + suite, and `build` — see `package.json` for the exact step list). Pull requests run it on Ubuntu + and Windows; pushes add macOS. +- **Job (`e2e-smoke`)**: Playwright smoke specs on Ubuntu and Windows, on pushes only. macOS and the + smoke are push-only to keep Actions minutes down, so run the smoke specs locally before pushing. - **Stricter local gate** (includes Electron-linked native check): `npm run local:gate` (= `verify:installed-electron` + `npm run ci` + `verify:native-modules`). - Triggered on pull requests and pushes to main/master/develop/RegressionFixes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d80ce4..460f75a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,16 @@ # Pull requests: run the same gate as packaged in `npm run ci` (format, lint, audit, -# compliance verify, tests + coverage, IPC contracts, production webpack build). -# PR CI also verifies Electron-linked native modules load on all three OSes. The -# macos-latest runner builds x64 while production ships arm64 — the x64 run still -# exercises the rebuild toolchain; arm64 ABI alignment is verified pre-release via -# `npm run local:gate` (see the inline note on the rebuild step below). +# compliance verify, tests + coverage, IPC contracts, production webpack build) on +# Ubuntu and Windows, and verify Electron-linked native modules load there. # -# Push to long-lived branches (main/develop/RegressionFixes) runs the same gate to -# catch regressions that land via non-PR pushes (e.g. hotfix commits direct-to-branch). +# Push to long-lived branches (main/develop/RegressionFixes) runs the same gate on +# all three OSes plus the E2E smoke. macOS and the E2E smoke are push-only to keep +# Actions minutes down (macOS bills at ten times the Linux rate), so they report +# after a merge, not before it: the ruleset "Main: required checks" requires only +# `ci (ubuntu-latest)`, `ci (windows-latest)` and `CodeQL`. Run `npm run local:gate` +# and the smoke specs locally before pushing. The macos-latest runner builds x64 +# while production ships arm64 — the x64 run still exercises the rebuild toolchain; +# arm64 ABI alignment is verified pre-release via `npm run local:gate` (see the +# inline note on the rebuild step below). name: CI @@ -33,7 +37,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + os: ${{ fromJSON(github.event_name == 'pull_request' && '["ubuntu-latest","windows-latest"]' || '["ubuntu-latest","windows-latest","macos-latest"]') }} timeout-minutes: 60 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -99,13 +103,14 @@ jobs: # The full `npm run test:e2e` suite (including @live-llm specs) is run manually # before releases; there is no nightly E2E workflow. e2e-smoke: + if: github.event_name != 'pull_request' runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] # 45, not 20: each spec relaunches the app (~17 s/test on windows-latest), - # so the 91-test smoke list alone needs ~27 min plus ~5 min of npm ci + + # so the 101-test smoke list alone needs ~29 min plus ~5 min of npm ci + # native rebuild + dev builds before Playwright even starts. timeout-minutes: 45 steps: @@ -126,7 +131,7 @@ jobs: run: npm run build:dev - name: Run xvfb-friendly E2E smoke if: runner.os == 'Linux' - run: xvfb-run --auto-servernum -- npx playwright test test/e2e/app-launch.spec.js test/e2e/whisper-variant-loader.spec.js test/e2e/session-persistence.spec.js test/e2e/undo-redo.spec.js test/e2e/organize-phase.spec.js test/e2e/search-modal.spec.js test/e2e/settings-backup-restore.spec.js test/e2e/accessibility.spec.js --reporter=list + run: xvfb-run --auto-servernum -- npx playwright test test/e2e/app/app-launch.spec.js test/e2e/discover/whisper-variant-loader.spec.js test/e2e/app/session-persistence.spec.js test/e2e/organize/undo-redo.spec.js test/e2e/organize/organize-phase.spec.js test/e2e/search/search-modal.spec.js test/e2e/settings/settings-backup-restore.spec.js test/e2e/app/accessibility.spec.js --reporter=list env: CI: 'true' # pw:browser* forwards the Electron child's stdout/stderr from spawn @@ -135,7 +140,7 @@ jobs: DEBUG: 'pw:browser*' - name: Run E2E smoke (Windows) if: runner.os == 'Windows' - run: npx playwright test test/e2e/app-launch.spec.js test/e2e/whisper-variant-loader.spec.js test/e2e/session-persistence.spec.js test/e2e/undo-redo.spec.js test/e2e/organize-phase.spec.js test/e2e/search-modal.spec.js test/e2e/settings-backup-restore.spec.js test/e2e/accessibility.spec.js --reporter=list + run: npx playwright test test/e2e/app/app-launch.spec.js test/e2e/discover/whisper-variant-loader.spec.js test/e2e/app/session-persistence.spec.js test/e2e/organize/undo-redo.spec.js test/e2e/organize/organize-phase.spec.js test/e2e/search/search-modal.spec.js test/e2e/settings/settings-backup-restore.spec.js test/e2e/app/accessibility.spec.js --reporter=list env: CI: 'true' - name: Upload E2E artifacts on failure diff --git a/CHANGELOG.md b/CHANGELOG.md index ed3bcf1..0e3f3a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,20 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed + +- **An image that arrives just as a text model starts loading no longer starts the vision runtime + beside it.** The image looked for a loading model before the load began and found none. The vision + runtime now checks the memory budget again at the moment it starts; if a load began meanwhile, the + image waits for it, makes room and is sent once more. Such a refusal does not count toward turning + vision off. +- **An embedding model put on the CPU beside a text model returns to the GPU when there is room.** + After a text-model load that failed, or once the text model was unloaded, it stayed on the CPU + until some other load replaced it. +- **A text model larger than the memory budget no longer unloads models that hold no GPU memory.** + An embedding model on the CPU and Whisper on the CPU were unloaded to make room, which freed + nothing, and were loaded again for the next file. + ## [5.2.0] - 2026-11-10 Cloud analysis now covers audio and video, and video frames are sent only with your consent and the diff --git a/CLAUDE.md b/CLAUDE.md index 02f96d3..0045550 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,7 +60,7 @@ npm run test:e2e # Playwright E2E tests npm run lint # ESLint check npm run format:check # Prettier check npm run ci # format:check + lint + typecheck + audit:production + compliance:commercial:verify + test:coverage + verify:ipc-contracts + build, plus the full verify:* suite (channels, ipc handlers/schemas/enforcement, mcp + ai-provider contracts, analysis routing, electron fuses, env docs, version tag) — package.json is the authoritative list -npm run local:gate # verify:installed-electron + ci + verify:native-modules (one-shot pre-push convenience). GitHub Actions `CI` is configured for Windows + Ubuntu + macOS when Actions are enabled for the repo. Cloud CI has been off at the org/repo setting at times — `dependabot.yml` notes local gate as the only guaranteed gate. The e2e-smoke job is restricted to Windows + Ubuntu. +npm run local:gate # verify:installed-electron + ci + verify:native-modules (one-shot pre-push convenience). GitHub Actions `CI` runs Ubuntu + Windows on pull requests and adds macOS + the e2e-smoke job on pushes to main (trimmed 2026-11-11 to cut Actions minutes; required checks are `ci (ubuntu-latest)`, `ci (windows-latest)` and `CodeQL`), when Actions are enabled for the repo. Cloud CI has been off at the org/repo setting at times — `dependabot.yml` notes local gate as the only guaranteed gate. The e2e-smoke job is restricted to Windows + Ubuntu. npm run dist:win # Build Windows installer ``` @@ -188,8 +188,10 @@ These are tracked gaps that the project slash commands help address: counts are the output of [`.github/workflows/ci.yml`](.github/workflows/ci.yml) — treat CI logs as source of truth rather than inline numbers here. - ~~Coverage not enforced in CI~~ - CI now runs `npm run test:coverage` (enforces thresholds). - Cross-platform matrix (Windows, macOS, Linux). Coverage artifacts uploaded. -- ~~Single-platform CI~~ - CI runs on `windows-latest`, `ubuntu-latest`, and `macos-latest`. + Cross-platform matrix (Windows and Linux on pull requests; macOS added on pushes to main). + Coverage artifacts uploaded. +- ~~Single-platform CI~~ - CI runs on `windows-latest` and `ubuntu-latest` for pull requests, plus + `macos-latest` on pushes to main. - ~~No IPC handler coverage check~~ - `npm run verify:ipc-handlers` statically verifies 100% of `IPC_CHANNELS` have registered handlers. `npm run ci` (run by every CI job) transitively invokes `npm run verify:ipc-contracts` — there is no standalone `ipc-contract-check` job. diff --git a/docs/TESTING.md b/docs/TESTING.md index 9aa5307..3552dff 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -290,6 +290,10 @@ Notes: E2E automation notes: +- Specs are grouped by product area under `test/e2e//` (`app`, `setup`, `discover`, + `organize`, `search`, `settings`, `workflows`, `live-llm`, `manual`). + [`test/e2e/README.md`](../test/e2e/README.md) lists every spec, the CI smoke set and what each + spec needs. Run one area with `npx playwright test test/e2e/`. - Every `launchApp()` profile is isolated and seeded with `modelAutoDownloadSkipped: true` (`E2E_SEED_SETTINGS` in `test/e2e/helpers/electronApp.js`), so a run never starts a model download and needs no network. A spec that needs the first-run model wizard keeps the seed and calls diff --git a/docs/commercial-use-inventory.json b/docs/commercial-use-inventory.json index 9050974..93cd7ff 100644 --- a/docs/commercial-use-inventory.json +++ b/docs/commercial-use-inventory.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-11-10T23:39:10.960Z", + "generatedAt": "2026-11-12T01:40:35.862Z", "projectLicense": { "riskLevel": "allowed", "reason": "Project license is PolyForm Noncommercial 1.0.0 for personal/noncommercial use. Commercial rights are sold separately; third-party dependency obligations remain governed by their own licenses.", @@ -18725,7 +18725,7 @@ }, { "packageName": "officeparser", - "version": "8.6.0", + "version": "8.7.0", "license": "MIT", "riskLevel": "allowed", "directRuntime": true, diff --git a/package-lock.json b/package-lock.json index 3a0c459..6488199 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,7 +34,7 @@ "mammoth": "^1.13.0", "minimatch": "^11.0.5", "node-llama-cpp": "^3.22.3", - "officeparser": "^8.6.0", + "officeparser": "^8.7.0", "p-queue": "^10.0.0", "pdfjs-dist": "6.5.438", "pino": "^10.4.0", @@ -26462,9 +26462,9 @@ } }, "node_modules/officeparser": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/officeparser/-/officeparser-8.6.0.tgz", - "integrity": "sha512-r5sKSkNef8dn/DATfLMyPwWBShfJlvbr9p6l7nKPEbp0OaK3ZZSadXPtde0nx1NwKU2bYJR0y7ixV+LxozyhWw==", + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/officeparser/-/officeparser-8.7.0.tgz", + "integrity": "sha512-b7ewTsconjBwJVMAXNaki816b+2v3m4bJMsQi5Wzmu3VZlHXOvdWEdE0l4BfVMkXHB7mdAs3dr4e6zZKH5++Ng==", "license": "MIT", "dependencies": { "@xmldom/xmldom": "^0.9.12", diff --git a/package.json b/package.json index d2d9068..655349a 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,7 @@ "tour:record:studio": "cross-env STRATOSORT_REDACT_PATHS=1 node scripts/record-product-tour.js --topic=studio", "tour:compose": "node scripts/compose-walkthrough-media.js", "tour:compose:lessons": "node scripts/compose-walkthrough-media.js --topic=lessons", - "test:soak": "cross-env SOAK=1 SOAK_MINUTES=12 npx playwright test test/e2e/soak.spec.js --timeout=1200000", + "test:soak": "cross-env SOAK=1 SOAK_MINUTES=12 npx playwright test test/e2e/manual/soak.spec.js --timeout=1200000", "models:check": "node scripts/setup-models.js --check", "models:download": "node scripts/setup-models.js --download", "dist": "npm run build && npm run generate:nsis && npm run setup:vision-runtime && npm run setup:vision-runtime:check && npm run setup:ffmpeg-runtime && npm run setup:ffmpeg-runtime:check && npm run setup:tessdata-runtime && npm run setup:tessdata-runtime:check && npm run setup:vc-redist -- --windows-host-only && npm run setup:vc-redist:check -- --windows-host-only && electron-builder --publish never --config electron-builder.json", @@ -228,7 +228,7 @@ "mammoth": "^1.13.0", "minimatch": "^11.0.5", "node-llama-cpp": "^3.22.3", - "officeparser": "^8.6.0", + "officeparser": "^8.7.0", "p-queue": "^10.0.0", "pdfjs-dist": "6.5.438", "pino": "^10.4.0", diff --git a/playwright.config.js b/playwright.config.js index bcf7d0d..1a7f23c 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -19,10 +19,11 @@ const { defineConfig } = require('@playwright/test'); const path = require('path'); module.exports = defineConfig({ - // Directory containing E2E test files + // Directory containing E2E test files. Specs are grouped by product area in + // subfolders (app/, setup/, discover/, ...); see test/e2e/README.md for the index. testDir: './test/e2e', - // Test file pattern + // Test file pattern (recursive, so every area folder is picked up) testMatch: '**/*.spec.js', // Maximum time one test can run (3 minutes for analysis tests) diff --git a/src/main/ipc/files/fileOperationHandlers.js b/src/main/ipc/files/fileOperationHandlers.js index cb575d5..c1feab6 100644 --- a/src/main/ipc/files/fileOperationHandlers.js +++ b/src/main/ipc/files/fileOperationHandlers.js @@ -249,6 +249,28 @@ function getAnalysisHistoryService() { return null; } +/** + * Stored analysis of a file that was just moved, so learning can key the move + * on the file's category. The history entry follows the file to `destPath`; + * `sourcePath` covers a path update that did not land. + * @param {string} sourcePath + * @param {string} destPath + * @returns {Promise|null>} Analysis, or null when the file was never analyzed + */ +async function getMovedFileAnalysis(sourcePath, destPath) { + const historyService = getAnalysisHistoryService(); + if (!historyService?.getAnalysisByPath) return null; + try { + const entry = + (await historyService.getAnalysisByPath(destPath)) || + (await historyService.getAnalysisByPath(sourcePath)); + return entry?.analysis || null; + } catch (error) { + logger.debug('[FILE-OPS] Analysis lookup for learning failed:', error?.message); + return null; + } +} + /** * @param {{ sourcePath: string|null|undefined, destPath: string, log: import('../ipcWrappers').LoggerLike, context: string }} args */ @@ -700,7 +722,9 @@ async function moveOneFile( await learningService.recordFileMove( moveValidation.source, moveValidation.destination, - null, // No analysis available for manual moves + // Without the category the pattern is `:unknown:`, which + // an analyzed file's lookup never matches. + await getMovedFileAnalysis(moveValidation.source, moveValidation.destination), FEEDBACK_SOURCES.MANUAL_MOVE ); } diff --git a/src/main/ipc/semantic.js b/src/main/ipc/semantic.js index 7bd754d..6e87627 100644 --- a/src/main/ipc/semantic.js +++ b/src/main/ipc/semantic.js @@ -37,7 +37,6 @@ const { createHandler, safeHandle, safeSend, z } = require('./ipcWrappers'); const { whenFirstPaintGateReleased } = require('../core/firstPaintGate'); const { schemas: validationSchemas } = require('./validationSchemas'); const { cosineSimilarity, padOrTruncateVector } = require('../../shared/vectorMath'); -const { validateFileOperationPath } = require('../../shared/pathSanitization'); const { categorizeModel } = require('../../shared/modelCategorization'); const { chunkText } = require('../utils/textChunking'); const { normalizeText } = require('../../shared/normalization'); @@ -2090,136 +2089,6 @@ async function handleGetStats({ safeEnsureInit, logger, getCustomFolders, getSer } } -/** - * @param {SemanticHandlerDeps} deps - * @param {IpcMainInvokeEvent} _event - * @param {string|Record} [payload] - */ -async function handleReanalyzeFile({ logger, getServiceIntegration }, _event, payload = {}) { - const filePath = typeof payload === 'string' ? payload : payload?.filePath; - const applyNaming = /** @type {Record} */ (payload)?.applyNaming; - - if (!filePath) { - return { - success: false, - error: 'filePath is required', - errorCode: 'MISSING_FILE_PATH' - }; - } - - const validation = await validateFileOperationPath(filePath, { checkSymlinks: true }); - if (!validation.valid) { - return { - success: false, - error: validation.error, - errorCode: 'INVALID_PATH' - }; - } - - const serviceIntegration = - typeof getServiceIntegration === 'function' ? getServiceIntegration() : null; - const smartFolderWatcher = serviceIntegration?.smartFolderWatcher; - if (!smartFolderWatcher) { - return { - success: false, - error: 'Smart folder watcher not available. Configure smart folders first.', - errorCode: 'WATCHER_NOT_AVAILABLE' - }; - } - - if (!smartFolderWatcher.isRunning) { - const started = await smartFolderWatcher.start(); - if (!started) { - return { - success: false, - error: 'Failed to start smart folder watcher. Check smart folder configuration.', - errorCode: 'WATCHER_START_FAILED' - }; - } - } - - // SECURITY: scope creep guard for force:true reanalysis. - // - // The previous implementation always passed `force: true`, which let - // any renderer-supplied path (that passed generic sanitation) be - // pulled through extraction → LLM analysis → embedding → vector DB - // storage. That is effectively an on-demand file exfiltration - // primitive scoped only to "not a dangerous system directory". - // - // We restrict force:true to two obviously-in-scope roots: - // 1. Any currently watched smart folder (user explicitly opted in - // to indexing these paths). - // 2. The app's userData directory (our own state, e.g. already- - // imported files or fixtures). - // - // For anything else we pass `force: false`, which means - // reanalyzeFile will reject with NOT_IN_SMART_FOLDER — exactly the - // behavior a user-initiated "reanalyze" button should have when - // pointed outside the configured smart folders. - const normalized = validation.normalizedPath; - let inScope = false; - try { - const watchedPaths = smartFolderWatcher.watchedPaths; - if (watchedPaths && typeof watchedPaths[Symbol.iterator] === 'function') { - for (const rootRaw of watchedPaths) { - if (!rootRaw) continue; - const root = path.resolve(rootRaw); - const rel = path.relative(root, normalized); - if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) { - inScope = true; - break; - } - if (normalized === root) { - inScope = true; - break; - } - } - } - } catch (scopeErr) { - // Fail closed: if we can't determine scope, treat as out-of-scope. - inScope = false; - logger.warn?.('[EMB:REANALYZE] Smart-folder scope check failed', { - error: scopeErr?.message || String(scopeErr) - }); - } - - if (!inScope) { - try { - const { app } = require('electron'); - const userData = app?.getPath?.('userData'); - if (userData) { - const rel = path.relative(path.resolve(userData), normalized); - if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) { - inScope = true; - } - } - } catch { - // Electron unavailable (e.g. in test harness without app). - } - } - - const result = await smartFolderWatcher.reanalyzeFile(validation.normalizedPath, { - applyNaming, - // Only allow the "outside smart folders" escape hatch when the - // path is demonstrably under a configured root or userData. - force: inScope - }); - if (!result?.queued) { - return { - success: false, - error: result?.error || 'File not eligible for reanalysis', - errorCode: result?.errorCode || 'REANALYZE_FILE_SKIPPED' - }; - } - - return { - success: true, - queued: true, - filePath: validation.normalizedPath, - message: `Queued ${path.basename(validation.normalizedPath)} for reanalysis.` - }; -} - /** * Re-analyzes watched files and re-embeds the rest from history; never clears * the store. @@ -4365,21 +4234,6 @@ function registerEmbeddingsIpc(servicesOrParams) { }) ); - /** - * Reanalyze File: Forces re-analysis of a single file in a smart folder. - */ - safeHandle( - ipcMain, - IPC_CHANNELS.EMBEDDINGS.REANALYZE_FILE, - createHandler({ - logger, - context, - schema: validationSchemas?.embeddingsReanalyzeFile, - handler: (_event, payload) => - handleReanalyzeFile({ logger, getServiceIntegration }, _event, payload) - }) - ); - // New endpoint for getting vector DB statistics safeHandle( ipcMain, diff --git a/src/main/ipc/validationSchemas.js b/src/main/ipc/validationSchemas.js index a5b87d9..4836d62 100644 --- a/src/main/ipc/validationSchemas.js +++ b/src/main/ipc/validationSchemas.js @@ -1526,14 +1526,6 @@ if (!z) { }) .optional(); - const embeddingsReanalyzeFileSchema = z.union([ - z.string().min(1), - z.object({ - filePath: z.string().min(1), - applyNaming: z.boolean().optional() - }) - ]); - const embeddingsEmptyPayloadSchema = z.object({}).optional(); const embeddingsDiagnoseSearchSchema = z @@ -1637,7 +1629,6 @@ if (!z) { embeddingsFullRebuild: embeddingsFullRebuildSchema, embeddingsReanalyzeAll: embeddingsReanalyzeAllSchema, rebuildFilesOptions: rebuildFilesOptionsSchema, - embeddingsReanalyzeFile: embeddingsReanalyzeFileSchema, embeddingsEmptyPayload: embeddingsEmptyPayloadSchema, embeddingsDiagnoseSearch: embeddingsDiagnoseSearchSchema, embeddingsFindMultiHop: embeddingsFindMultiHopSchema, diff --git a/src/main/services/ChatHistoryStore.js b/src/main/services/ChatHistoryStore.js index 69a00f6..7f75610 100644 --- a/src/main/services/ChatHistoryStore.js +++ b/src/main/services/ChatHistoryStore.js @@ -623,6 +623,33 @@ class ChatHistoryStore { return { id, ...message }; } + /** + * Remove the assistant rows that follow a conversation's last user row. + * Regenerate and Retry answer a question that is already stored; unless + * the answer it had is removed first, both answers show after a reload. + * @param {string} conversationId + * @returns {number} How many rows were removed. + */ + deleteAssistantMessagesAfterLastUser(conversationId) { + this._checkInit(); + /** @type {Array<{ id: string, role: string }>} */ + const messages = this.getConversation(conversationId)?.messages || []; + let lastUserIndex = messages.length - 1; + while (lastUserIndex >= 0 && messages[lastUserIndex].role !== 'user') lastUserIndex -= 1; + if (lastUserIndex < 0) return 0; + const staleIds = messages.slice(lastUserIndex + 1).map((message) => message.id); + if (staleIds.length === 0) return 0; + + this._db + .prepare(`DELETE FROM messages WHERE id IN (${staleIds.map(() => '?').join(', ')})`) + .run(...staleIds); + this._db + .prepare("UPDATE conversations SET updated_at = datetime('now') WHERE id = ?") + .run(conversationId); + this._invalidateConversationCache(conversationId); + return staleIds.length; + } + deleteConversation(/** @type {any} */ id) { this._checkInit(); this._db.prepare('DELETE FROM conversations WHERE id = ?').run(id); diff --git a/src/main/services/ChatService.js b/src/main/services/ChatService.js index 63ab5f1..05d2baa 100644 --- a/src/main/services/ChatService.js +++ b/src/main/services/ChatService.js @@ -609,7 +609,9 @@ Now respond as JSON:`; if (conversationalEarly) { const memory = await this._getSessionMemory(sessionId); memory._documentScope = documentScopeItems || []; - const history = await this._getHistoryText(memory); + const history = await this._getHistoryText(memory, { + regeneratedInput: skipSaveUserTurn ? cleanQuery : null + }); const prompt = `You are StratoSort, a friendly local AI assistant. You help users search, explore, and understand the files on their machine. Everything runs on-device — no data leaves their computer. @@ -714,7 +716,8 @@ Now respond as JSON:`; chunkWeight, contextFileIds, strictScope, - responseMode + responseMode, + regeneratedInput: skipSaveUserTurn ? cleanQuery : null }); memory._documentScope = documentScopeItems || []; @@ -882,12 +885,12 @@ Now respond as JSON:`; memory.sessionId ); } - if (!skipSaveUserTurn) { - this.chatHistoryStore.addMessage(memory.sessionId, { - role: 'user', - text: cleanQuery - }); - } + // On a regenerate or retry this drops the answer (or the earlier + // error row) the question already has, so only this attempt's + // outcome is stored. + this._sessions.saveUserMessage(conv, memory.sessionId, cleanQuery, { + skipUserMessage: skipSaveUserTurn + }); this.chatHistoryStore.addMessage(memory.sessionId, { role: 'assistant', text: '', @@ -1148,6 +1151,8 @@ Now respond as JSON:`; /** * `memoryStore` is the SessionMemory that history is read from and citation * ordinals are registered on; defaults to the in-app store (`_sessions`). + * `regeneratedInput` is the question of a regenerate/retry, whose stored + * turn is left out of the prompt history. * @param {any} params */ async _runRetrieval({ @@ -1160,6 +1165,7 @@ Now respond as JSON:`; contextFileIds, strictScope = false, responseMode, + regeneratedInput = null, memoryStore = this._sessions }) { logger.info('[ChatService] Retrieval started', { @@ -1173,7 +1179,7 @@ Now respond as JSON:`; }); const memory = await memoryStore.getSessionMemory(sessionId); - const history = await memoryStore.getHistoryText(memory); + const history = await memoryStore.getHistoryText(memory, { regeneratedInput }); const holisticIntent = this._isHolisticSynthesisQuery(cleanQuery); const correctionIntent = this._isCorrectionFeedback(cleanQuery); const comparisonIntent = this._isComparisonQuery(cleanQuery); @@ -1335,8 +1341,8 @@ Now respond as JSON:`; return this._sessions.createMemory(sessionKey); } - async _getHistoryText(/** @type {any} */ memory) { - return this._sessions.getHistoryText(memory); + async _getHistoryText(/** @type {any} */ memory, options = {}) { + return this._sessions.getHistoryText(memory, options); } async _saveMemoryTurn( diff --git a/src/main/services/LlamaService.js b/src/main/services/LlamaService.js index ba66da1..3c88fde 100644 --- a/src/main/services/LlamaService.js +++ b/src/main/services/LlamaService.js @@ -1731,7 +1731,7 @@ class LlamaService extends EventEmitter { } if (modelType === 'embedding') { - await this._returnEmbeddingToGpuAfterBatch(selfRef); + await this._returnEmbeddingToGpu(selfRef); } // Stale-swap: a config change selected a different model for this type @@ -2188,6 +2188,38 @@ class LlamaService extends EventEmitter { await this._releaseMemoryForVisionRuntime('batch'); } + /** + * Send one image to the vision runtime, making room once more when the + * runtime refuses to start. + * + * analyzeImage frees memory before it gets here, but a text load can begin + * after that: nothing was loading when the image looked, so nothing was + * freed. The runtime checks the budget again just before it spawns + * (VisionService._reserveRuntimeMemory) and refuses to start beside that + * load. Memory is then freed the same way as before the first attempt, + * which waits for the load, and the image is sent once more. A second + * refusal is given to the caller. + * @param {{ analyzeImage: (request: any) => Promise }} visionService + * @param {any} request + * @param {AbortSignal} [signal] + * @returns {Promise} + * @private + */ + async _analyzeOnVisionRuntime(visionService, request, signal) { + try { + return await visionService.analyzeImage(request); + } catch (error) { + if (/** @type {any} */ (error)?.code !== 'MODEL_MEMORY_BUDGET_EXCEEDED') throw error; + logger.info('[LlamaService] Vision runtime start refused for memory; making room again'); + if (this._visionBatchMode) { + await this._releaseMemoryForBatchVisionRespawn(signal); + } else { + await this._releaseMemoryForVisionRuntime('single'); + } + return visionService.analyzeImage(request); + } + } + async _waitForIdleOperations( /** @type {any} */ reason, timeoutMs = 30000, @@ -4236,26 +4268,34 @@ class LlamaService extends EventEmitter { } /** - * After a vision batch, put an embedding model that was loaded CPU-only for - * it back on the GPU. Runs under the embedding load lock. Never waits for - * other holders: if an embedding batch still uses the CPU model, this call - * is served by it and the next load tries again. + * Put an embedding model that runs on the CPU back on the GPU, by + * unloading it so the load that follows picks the GPU: after a vision + * batch it was loaded CPU-only for, or once the GPU has room again beside + * a text model (see _loadOptionsBesideText). That text model's load can + * fail and leave the GPU empty; the embedding model then stayed on the + * CPU until something else unloaded it. Runs under the embedding load + * lock. Never waits for other holders: if an embedding batch still uses + * the CPU model, this call is served by it and the next load tries again. * @param {boolean} selfRef - our caller holds one ref on 'embedding' * @returns {Promise} * @private */ - async _returnEmbeddingToGpuAfterBatch(selfRef) { - if (!this._embeddingCpuForBatch || this._visionRuntimePinned) return; + async _returnEmbeddingToGpu(selfRef) { + if (this._visionRuntimePinned) return; + const memory = this._modelMemoryManager; + const roomBesideText = + memory?.isOnCpuBeside?.('embedding') === true && memory.canLoadModel?.('embedding') === true; + if (!this._embeddingCpuForBatch && !roomBesideText) return; try { - const unloaded = await this._modelMemoryManager?.unloadModel?.('embedding', { + const unloaded = await memory?.unloadModel?.('embedding', { allowedRefs: selfRef ? 1 : 0, waitMs: 0 }); if (unloaded === false) return; this._embeddingCpuForBatch = false; - logger.info('[LlamaService] Unloaded the CPU embedding model from the vision batch'); + logger.info('[LlamaService] Unloaded the CPU embedding model; the next load uses the GPU'); } catch (error) { - logger.debug('[LlamaService] Could not unload the batch CPU embedding model', { + logger.debug('[LlamaService] Could not unload the CPU embedding model', { error: /** @type {any} */ (error)?.message }); } @@ -4287,7 +4327,7 @@ class LlamaService extends EventEmitter { * file evicted the text model to load the ~200 MB embedding model and then * reloaded the text model, about 4-5 s per file. The embedding model runs * on the CPU beside it instead, without the "running on CPU" warning; it - * moves to the GPU again the next time it loads with room to spare. + * moves to the GPU again once there is room for it (_returnEmbeddingToGpu). * @private * @param {string} modelType * @returns {Record | undefined} @@ -4510,7 +4550,11 @@ class LlamaService extends EventEmitter { let result; try { - result = await visionService.analyzeImage(buildVisionRequest(baseRuntimeProfile)); + result = await this._analyzeOnVisionRuntime( + visionService, + buildVisionRequest(baseRuntimeProfile), + signal + ); } catch (visionError) { const shouldRetryDegraded = this._shouldUseConservativeVisionProfile() && @@ -4532,7 +4576,11 @@ class LlamaService extends EventEmitter { error: visionError?.message } ); - result = await visionService.analyzeImage(buildVisionRequest(degradedProfile)); + result = await this._analyzeOnVisionRuntime( + visionService, + buildVisionRequest(degradedProfile), + signal + ); } const visionDuration = Date.now() - startTime; diff --git a/src/main/services/ModelMemoryManager.js b/src/main/services/ModelMemoryManager.js index 9463b50..1e9a0e8 100644 --- a/src/main/services/ModelMemoryManager.js +++ b/src/main/services/ModelMemoryManager.js @@ -228,6 +228,35 @@ class ModelMemoryManager { return Boolean(entry && !entry.disposing) || this._pendingLoads.has(modelType); } + /** + * Whether a model runs on the CPU beside another model that holds the GPU + * (see ensureModelLoaded). Its caller may want it back on the GPU once + * there is room. Asking does not count as using the model. + * @param {string} modelType + * @returns {boolean} + */ + isOnCpuBeside(modelType) { + const entry = this._loadedModels.get(modelType); + return Boolean(entry && !entry.disposing && entry.cpuBeside); + } + + /** + * Residents that take memory from the budget. One that takes none (a + * model on the CPU beside the one that holds the GPU, Whisper on the CPU) + * is never unloaded to make room and is not something a load waits for: + * a text model larger than the budget unloaded them on every load and + * freed nothing. + * @param {string} [excludeType] + * @returns {number} + */ + _budgetedResidentCount(excludeType) { + let count = 0; + for (const [type, entry] of this._loadedModels) { + if (type !== excludeType && entry.sizeBytes > 0) count += 1; + } + return count; + } + /** * Check if we can load a model of given type */ @@ -335,7 +364,7 @@ class ModelMemoryManager { this._refreshMemoryBudget(); const excluded = excludeType ? this._loadedModels.get(excludeType) : null; const residentCount = - this._loadedModels.size - (excluded ? 1 : 0) + this._pendingLoadEntries(excludeType).length; + this._budgetedResidentCount(excludeType) + this._pendingLoadEntries(excludeType).length; if (residentCount <= 0) return true; const usage = this._currentMemoryUsage - @@ -366,7 +395,10 @@ class ModelMemoryManager { continue; } const evictable = [...this._loadedModels.entries()] - .filter(([type]) => type !== excludeType && (this._activeRefs.get(type) || 0) === 0) + .filter( + ([type, entry]) => + type !== excludeType && entry.sizeBytes > 0 && (this._activeRefs.get(type) || 0) === 0 + ) .sort((a, b) => (a[1].lastUsed || 0) - (b[1].lastUsed || 0)); if (evictable.length === 0) return false; const [type] = evictable[0]; @@ -382,9 +414,10 @@ class ModelMemoryManager { /** * Whether optional background work may use a model without forcing a load. - * Returns true when the model is the only thing in memory (a call on a - * loaded context loads nothing), when it is resident with headroom left - * beside the others, or when a fresh load fits within the current budget. Unlike ensureModelLoaded(), this never proceeds + * Returns true when the model is the only thing that takes memory from the + * budget (a call on a loaded context loads nothing), when it is resident + * with headroom left beside the others, or when a fresh load fits within + * the current budget. Unlike ensureModelLoaded(), this never proceeds * when the budget is exceeded — callers use it to skip non-critical inference * (e.g. graph cluster label refinement) instead of risking native OOM. * @@ -399,7 +432,9 @@ class ModelMemoryManager { // A model whose estimate is above the budget on its own (7B on a 6 GB // card) never has headroom, so this said no for the rest of the session // while the model sat idle in memory. - if (this._loadedModels.size === 1 && this._pendingLoadEntries().length === 0) return true; + if (this._budgetedResidentCount(modelType) === 0 && this._pendingLoadEntries().length === 0) { + return true; + } const remaining = Math.max( 0, this._maxMemoryUsage - this._currentMemoryUsage - this._pendingLoadBytes() @@ -475,10 +510,11 @@ class ModelMemoryManager { this._maxMemoryUsage : this.canLoadModel(modelType); }; - // Residents, or loads in progress that will become residents. - const othersHoldMemory = () => this._loadedModels.size > 0 || this._pendingLoads.size > 0; + // Residents that take memory from the budget, or loads in progress that + // will become residents. + const othersHoldMemory = () => this._budgetedResidentCount() > 0 || this._pendingLoads.size > 0; - while (!fits() && this._loadedModels.size > 0) { + while (!fits() && this._budgetedResidentCount() > 0) { const sizeBefore = this._loadedModels.size; await this._unloadLeastRecentlyUsed(); if (this._loadedModels.size === sizeBefore) { @@ -506,7 +542,7 @@ class ModelMemoryManager { while (!fits() && othersHoldMemory() && Date.now() < deadline) { await delay(this._pinnedResidentPollMs); this._refreshMemoryBudget(); - while (!fits() && this._loadedModels.size > 0) { + while (!fits() && this._budgetedResidentCount() > 0) { const sizeBefore = this._loadedModels.size; // Quiet: the pass above already logged that nothing can be // evicted; repeating it every poll wrote a warning every 2 s for @@ -538,9 +574,10 @@ class ModelMemoryManager { }); throw budgetError; } - // Nothing else is resident: the single model's conservative estimate - // exceeds the budget on its own. Proceed — the runtime can still - // succeed (GPU offload, mmap), and failing here would permanently + // Nothing else takes memory from the budget: the single model's + // conservative estimate exceeds the budget on its own. Proceed — the + // runtime can still succeed (GPU offload, mmap), and failing here + // would permanently // brick the feature on low-RAM machines. logger.warn('[Memory] Budget exceeded by single model estimate, attempting load anyway', { requestedType: modelType, @@ -565,11 +602,15 @@ class ModelMemoryManager { // The load callback may have refined the estimate from the real file // size (updateModelSizeEstimate); record what this model actually // costs so later budget decisions are not held to the fixed guess. - const recordedSize = this._modelSizeEstimates[modelType] || estimatedSize; + // A model on the CPU beside the one that holds the GPU takes none of + // the memory the budget stands for, as Whisper on the CPU does not + // (WhisperService registers that at 0 bytes). + const recordedSize = cpuBeside ? 0 : this._modelSizeEstimates[modelType] || estimatedSize; this._loadedModels.set(modelType, { context, lastUsed: Date.now(), - sizeBytes: recordedSize + sizeBytes: recordedSize, + cpuBeside }); this._currentMemoryUsage += recordedSize; @@ -585,7 +626,8 @@ class ModelMemoryManager { } /** - * Unload least recently used model, skipping models with active references. + * Unload least recently used model, skipping models with active references + * and models that take nothing from the budget. * @param {{ quiet?: boolean }} [options] quiet: no warning when nothing can be evicted */ async _unloadLeastRecentlyUsed({ quiet = false } = {}) { @@ -595,6 +637,7 @@ class ModelMemoryManager { for (const [type, entry] of this._loadedModels) { // Never evict a model that is currently in-use for inference if ((this._activeRefs.get(type) || 0) > 0) continue; + if (!(entry.sizeBytes > 0)) continue; if (entry.lastUsed < oldestTime) { oldest = type; diff --git a/src/main/services/OramaVectorService.js b/src/main/services/OramaVectorService.js index b12dd0d..3484e49 100644 --- a/src/main/services/OramaVectorService.js +++ b/src/main/services/OramaVectorService.js @@ -250,6 +250,10 @@ function getDegradedStateTrackerSafe() { // clear on its own. const STORAGE_FULL_RETRY_MS = 60_000; +// Degraded flag raised when saving gives up for a reason other than a full +// disk (see _raiseSaveFailed). +const SAVE_FAILED_CODE = 'orama.save-failed'; + // Without the lz4-napi native binding the codec passes bytes through // unchanged, so compressing would put plain JSON under the .lz4 name. Save to // the plain .json path instead; the .lz4 files are left alone (see @@ -2776,6 +2780,37 @@ class OramaVectorService extends EventEmitter { } } + /** + * Tell the user saving has stopped for a reason other than a full disk + * (a locked file, an I/O error). Raised only when a round of retries gives + * up, so one transient failure shows nothing. Nothing retries on a timer + * here: the next write starts a fresh round, and the first save that + * works clears the flag. + * @private + * @param {any} error + */ + _raiseSaveFailed(error) { + const tracker = getDegradedStateTrackerSafe(); + if (!tracker) return; + tracker.raise({ + code: SAVE_FAILED_CODE, + subsystem: 'vector-db', + severity: 'error', + message: + 'StratoSort could not save its search index, so recent changes to it may be lost when the app closes. It will try again the next time the index changes, and this message goes away once a save works.', + detail: { errno: error?.code || null, error: error?.message || null } + }); + this._saveFailedRaised = true; + } + + /** @private */ + _clearSaveFailed() { + if (!this._saveFailedRaised) return; + this._saveFailedRaised = false; + logger.info('[OramaVectorService] Saved again after repeated save failures'); + getDegradedStateTrackerSafe()?.clear?.(SAVE_FAILED_CODE); + } + /** * Tell the user a collection has outgrown what one save file can hold. * Logged once per occurrence and raised as a degraded flag, so the size @@ -3213,6 +3248,7 @@ class OramaVectorService extends EventEmitter { } ); this._clearStorageFull(); + this._clearSaveFailed(); return { success: true, ...(oversized.size > 0 ? { tooLargeToSave: [...oversized] } : {}), @@ -3247,7 +3283,9 @@ class OramaVectorService extends EventEmitter { attachErrorCode(error, ERROR_CODES.VECTOR_DB_PERSIST_FAILED) ); this._persistPending = false; + // The storage-full banner already covers the disk-full case. if (keepSlowRetry) this._scheduleStorageFullRetry(maxRetries); + else this._raiseSaveFailed(error); } else { this._persistPending = true; logger.error( diff --git a/src/main/services/SmartFolderWatcher.js b/src/main/services/SmartFolderWatcher.js index 7ac3956..9c2931a 100644 --- a/src/main/services/SmartFolderWatcher.js +++ b/src/main/services/SmartFolderWatcher.js @@ -162,7 +162,6 @@ function keepAnalysisRequest(replaced, replacement) { if (replaced.eventType !== 'reanalyze' || replacement.eventType === 'reanalyze') return; replacement.eventType = 'reanalyze'; replacement.applyNaming = replaced.applyNaming; - if (replaced.bulk) replacement.bulk = true; } /** @@ -1302,25 +1301,6 @@ class SmartFolderWatcher { } } - /** - * The single queue item the exclusion rules do NOT apply to: one file the - * user hand-picked for reanalysis. That is an explicit request, so it wins - * over a standing rule. Bulk reanalysis is not exempt — "reanalyze all" - * means "all of what you still watch", and a rule is how the user says - * what that leaves out. - * - * Every gate that consults the rules asks this one question, so the - * exemption cannot drift between the live path (`_analyzeFile`) and the - * restored one (`_restoreQueue`): an item the gate would let through is an - * item restore must keep. - * @private - * @param {any} item - * @returns {boolean} - */ - _isExclusionExempt(item) { - return item?.eventType === 'reanalyze' && !item?.bulk; - } - _handleFileEvent( /** @type {any} */ eventType, /** @type {any} */ filePath, @@ -1930,10 +1910,7 @@ class SmartFolderWatcher { ) ); let skippedDuplicate = 0; - // Rules added since the queue was written apply to what comes back — - // on exactly the terms _analyzeFile's gate uses, so a hand-picked - // single-file reanalyze survives the restart it was interrupted by - // instead of dying silently. + // Rules added since the queue was written apply to what comes back. let droppedExcluded = 0; /** @type {any[]} */ @@ -1946,7 +1923,7 @@ class SmartFolderWatcher { discardedOutsideConfigured++; continue; } - if (!this._isExclusionExempt(item) && this._matchUserExclusion(item.filePath)) { + if (this._matchUserExclusion(item.filePath)) { droppedExcluded++; continue; } @@ -2735,7 +2712,7 @@ class SmartFolderWatcher { try { // An embedding retry reuses the analysis it already has. if (item.cachedAnalysis) return false; - if (!this._isExclusionExempt(item) && this._matchUserExclusion(item.filePath)) return false; + if (this._matchUserExclusion(item.filePath)) return false; if (item.eventType === 'reanalyze') return true; if (isUserAnalysisInFlight(item.filePath)) return false; let stats; @@ -2906,18 +2883,14 @@ class SmartFolderWatcher { // Exclusion rules apply at the one point every queue path shares: live // events, the startup and folder-add scans, items restored from disk and - // bulk reanalysis all land here. A single file the user asked to - // reanalyze by hand is the one explicit request that goes through - // (_isExclusionExempt owns that rule for every gate). - if (!this._isExclusionExempt(item)) { - const excludedBy = this._matchUserExclusion(filePath); - if (excludedBy) { - logger.debug('[SMART-FOLDER-WATCHER] Skipping queued file excluded by user rule:', { - filePath, - pattern: excludedBy - }); - return; - } + // "reanalyze all" all land here. + const excludedBy = this._matchUserExclusion(filePath); + if (excludedBy) { + logger.debug('[SMART-FOLDER-WATCHER] Skipping queued file excluded by user rule:', { + filePath, + pattern: excludedBy + }); + return; } // Use the canonical dedup key so a case-variant of an in-flight file @@ -4589,43 +4562,6 @@ class SmartFolderWatcher { return bestMatch; } - /** - * Queue a single file for reanalysis. - * @param {string} filePath - * @param {any} [options] - * @returns {Promise} - */ - async reanalyzeFile(filePath, /** @type {any} */ options = {}) { - if (!filePath) { - return { queued: false, error: 'filePath is required', errorCode: 'MISSING_FILE_PATH' }; - } - - if (!options.force && !this._isInWatchedPath(filePath)) { - return { - queued: false, - error: 'File is not inside a watched smart folder', - errorCode: 'NOT_IN_SMART_FOLDER' - }; - } - - const stats = await fs.stat(filePath); - const mtime = stats?.mtime ? new Date(stats.mtime).getTime() : Date.now(); - - this._enqueueAnalysisItem({ - filePath, - mtime, - eventType: 'reanalyze', - applyNaming: options.applyNaming, - queuedAt: Date.now() - }); - - logger.info('[SMART-FOLDER-WATCHER] Queued single file for reanalysis', { - filePath - }); - - return { queued: true }; - } - /** * Force reanalysis of ALL files in smart folders, regardless of existing analysis. * Use this when changing AI models to regenerate all analysis with the new model. @@ -4675,7 +4611,6 @@ class SmartFolderWatcher { filePath: file, mtime, eventType: 'reanalyze', - bulk: true, queuedAt: Date.now(), applyNaming: options.applyNaming }); @@ -4706,12 +4641,6 @@ class SmartFolderWatcher { filePath, mtime, eventType: 'reanalyze', - // Same flag as the watched-folder leg above: these are part of a - // bulk run, not a hand-picked file, so _analyzeFile's gate must - // check them against the exclusion rules. Without it a rule added - // between this collection and the analysis (or one the collector - // could not read) would be skipped for exactly these files. - bulk: true, queuedAt: Date.now(), applyNaming: options.applyNaming }); diff --git a/src/main/services/VisionService.js b/src/main/services/VisionService.js index 9091a41..3d7365e 100644 --- a/src/main/services/VisionService.js +++ b/src/main/services/VisionService.js @@ -1759,21 +1759,46 @@ class VisionService { } /** + * Reserve the runtime's memory, unless another load took it first. + * + * The caller made room before it asked for the runtime (LlamaService + * _releaseMemoryForVisionRuntime), but the binary lookup and the estimate + * come between that and this reservation. A text model that began to load + * in between was not seen, and the two loaded together onto a GPU with + * room for one. The check and the reservation are one step here, with + * nothing awaited between them, so the start is refused instead; the + * caller makes room again and retries. * @param {any} config * @returns {Promise<() => void>} ends the reservation + * @throws {Error & { code: 'MODEL_MEMORY_BUDGET_EXCEEDED' }} */ async _reserveRuntimeMemory(config) { const manager = this._getMemoryManager(); if (!manager || typeof manager.reserveLoad !== 'function') return () => {}; + let sizeBytes; try { - const sizeBytes = await this.estimateRuntimeMemoryBytes(config); - return manager.reserveLoad(VISION_RUNTIME_MEMORY_TYPE, sizeBytes); + sizeBytes = await this.estimateRuntimeMemoryBytes(config); } catch (error) { logger.debug('[VisionService] Memory reservation failed', { error: /** @type {any} */ (error)?.message }); return () => {}; } + if ( + manager.canFitExternalLoad?.(sizeBytes, { excludeType: VISION_RUNTIME_MEMORY_TYPE }) === false + ) { + const budgetError = /** @type {Error & { code?: string }} */ ( + new Error( + 'Cannot start the vision runtime: memory budget exceeded by a model that loaded meanwhile' + ) + ); + budgetError.code = 'MODEL_MEMORY_BUDGET_EXCEEDED'; + logger.info('[VisionService] Vision runtime start refused: another load took the memory', { + estimateMB: Math.round(sizeBytes / 1024 / 1024) + }); + throw budgetError; + } + return manager.reserveLoad(VISION_RUNTIME_MEMORY_TYPE, sizeBytes); } async _spawnServer(/** @type {any} */ config) { @@ -2151,6 +2176,9 @@ class VisionService { try { await this._ensureServer(config); } catch (err) { + // Refused for memory another model holds for now (_reserveRuntimeMemory): + // the runtime is not broken, and the caller retries once there is room. + if (/** @type {any} */ (err)?.code === 'MODEL_MEMORY_BUDGET_EXCEEDED') throw err; // _ensureServer wraps spawn / HTTP-probe / GPU-detection failures. // Surface these as a subsystem-down signal so users see "vision // analysis is offline" instead of every per-image call returning a diff --git a/src/main/services/autoOrganize/AutoOrganizeServiceCore.js b/src/main/services/autoOrganize/AutoOrganizeServiceCore.js index c88ce49..4ca589d 100644 --- a/src/main/services/autoOrganize/AutoOrganizeServiceCore.js +++ b/src/main/services/autoOrganize/AutoOrganizeServiceCore.js @@ -239,12 +239,11 @@ class AutoOrganizeServiceCore { } // Process batch results - await processBatchResults( + processBatchResults( batchSuggestions, batch, { confidenceThreshold: effectiveThreshold, defaultLocation, preserveNames }, results, - this.suggestionService, smartFolders ); diff --git a/src/main/services/autoOrganize/batchProcessor.js b/src/main/services/autoOrganize/batchProcessor.js index 78c4338..bb1faed 100644 --- a/src/main/services/autoOrganize/batchProcessor.js +++ b/src/main/services/autoOrganize/batchProcessor.js @@ -9,8 +9,6 @@ const path = require('path'); const { createLogger } = require('../../../shared/logger'); -const { TIMEOUTS } = require('../../../shared/performanceConstants'); -const { withTimeout } = require('../../../shared/promiseUtils'); const { sanitizeFile } = require('./fileTypeUtils'); const { getFallbackDestination, buildDestinationPath } = require('./folderOperations'); const { safeSuggestion, resolveSuggestionToSmartFolder } = require('./pathUtils'); @@ -27,28 +25,22 @@ const CONFIDENCE_THRESHOLDS = { /** * Process batch suggestion results + * + * Plans moves only. Learning is recorded where a move completes + * (batchOrganizeHandler, moveOneFile), never here: a plan may not be executed, + * and a record made here would be made again after the move. * @param {Record} batchSuggestions - Batch suggestions from suggestion service * @param {Array} files - Files being processed * @param {Record} options - Processing options (includes confidenceThreshold) * @param {Record} results - Results object to populate - * @param {Record} suggestionService - Suggestion service for feedback * @param {Array} smartFolders - Smart folders */ -async function processBatchResults( - batchSuggestions, - files, - options, - results, - suggestionService, - smartFolders -) { +function processBatchResults(batchSuggestions, files, options, results, smartFolders) { const { confidenceThreshold, defaultLocation, preserveNames } = options; const effectiveThreshold = Number.isFinite(confidenceThreshold) ? confidenceThreshold : CONFIDENCE_THRESHOLDS.DEFAULT; - const pendingFeedback = []; - // Track planned destinations within batch to prevent overwrite collisions const plannedDestinations = new Map(); @@ -120,29 +112,6 @@ async function processBatchResults( source: sourcePath, destination }); - - // Record feedback for learning (non-blocking with error handling) - // Promise.resolve().then(): recordFeedback must never throw out of this - // loop. A synchronous throw (or a non-promise return, which withTimeout - // treats as a function and whose .catch then throws) aborted the batch - // after its first file, and the caller re-ran the whole batch, so files - // already planned for a move were planned twice. - pendingFeedback.push( - /** @type {Promise} */ ( - withTimeout( - Promise.resolve().then(() => - suggestionService.recordFeedback(file, canonicalSuggestion, true) - ), - TIMEOUTS.API_REQUEST, - 'AutoOrganize feedback' - ) - ).catch((/** @type {any} */ err) => { - logger.warn('[AutoOrganize] Failed to record feedback (non-critical):', { - file: sourcePath, - error: err.message - }); - }) - ); } else if (confidence >= effectiveThreshold && !canonicalSuggestion) { results.needsReview.push({ file: sanitizeFile(file), @@ -164,14 +133,12 @@ async function processBatchResults( } } } - - if (pendingFeedback.length > 0) { - await Promise.allSettled(pendingFeedback); - } } /** * Batch organize with automatic confidence-based filtering + * + * Plans moves only; like processBatchResults, it records no learning. * @param {Array} files - Files to organize * @param {Array} smartFolders - Smart folders * @param {Record} options - Options (includes confidenceThreshold) @@ -208,8 +175,6 @@ async function batchOrganize( skipped: [], failed: [] }; - /** @type {any[]} */ - const pendingFeedback = []; // Track planned destinations across all groups in the batch to prevent cross-group collisions const plannedDestinations = new Map(); @@ -293,23 +258,6 @@ async function batchOrganize( source: file.path, destination }); - - if (file.suggestion) { - pendingFeedback.push( - /** @type {Promise} */ ( - withTimeout( - suggestionService.recordFeedback(file, file.suggestion, true), - TIMEOUTS.API_REQUEST, - 'AutoOrganize feedback' - ) - ).catch((/** @type {any} */ feedbackError) => { - logger.warn('[AutoOrganize] Failed to record feedback for file:', { - file: file.path, - error: feedbackError.message - }); - }) - ); - } } catch (fileError) { const errorDetails = { filePath: file.path, @@ -391,9 +339,6 @@ async function batchOrganize( skippedCount: results.skipped.length }); - if (pendingFeedback.length > 0) { - await Promise.allSettled(pendingFeedback); - } return results; } diff --git a/src/main/services/autoOrganize/fileProcessor.js b/src/main/services/autoOrganize/fileProcessor.js index 69f3286..8baf32f 100644 --- a/src/main/services/autoOrganize/fileProcessor.js +++ b/src/main/services/autoOrganize/fileProcessor.js @@ -133,6 +133,9 @@ async function processFilesWithoutAnalysis(files, smartFolders, _defaultLocation /** * Process files individually as fallback + * + * Plans moves only; learning is recorded where a move completes (see + * batchProcessor.processBatchResults). * @param {Array} files - Files to process * @param {Array} smartFolders - Smart folders * @param {Record} options - Processing options (includes confidenceThreshold) @@ -208,16 +211,6 @@ async function processFilesIndividually(files, smartFolders, options, results, s source: file.path, destination }); - - // Record feedback with proper error handling - try { - await suggestionService.recordFeedback(file, canonicalPrimary, true); - } catch (feedbackError) { - logger.warn('[AutoOrganize] Failed to record feedback (non-critical):', { - file: file.path, - error: feedbackError.message - }); - } } else if (confidence >= effectiveThreshold && !canonicalPrimary) { // High confidence but unresolved folder => review only (never auto-create unknown destinations) results.needsReview.push({ diff --git a/src/main/services/chat/SessionMemory.js b/src/main/services/chat/SessionMemory.js index 5414369..cc337e3 100644 --- a/src/main/services/chat/SessionMemory.js +++ b/src/main/services/chat/SessionMemory.js @@ -124,14 +124,26 @@ class SessionMemory { return memory; } - async getHistoryText(/** @type {any} */ memory) { + /** + * @param {any} memory + * @param {{ regeneratedInput?: string | null }} [options] `regeneratedInput` + * is the question of a regenerate/retry. Its stored turn is left out, so + * the model answers it afresh instead of reading the discarded answer. + */ + async getHistoryText(memory, { regeneratedInput = null } = {}) { if (this.chatHistoryStore && memory?.sessionId) { try { const conv = this.chatHistoryStore.getConversation(memory.sessionId); // No stored conversation: a turn that could not be persisted lives in // the in-memory buffer, so follow-ups still read their history there. if (!conv) return this._getInMemoryHistoryText(memory); - const messages = conv.messages.slice(-(this.memoryWindow * 2)); + const regeneratedTurnIndex = + typeof regeneratedInput === 'string' + ? this._findLastUserMessageIndex(conv, regeneratedInput) + : -1; + const priorMessages = + regeneratedTurnIndex >= 0 ? conv.messages.slice(0, regeneratedTurnIndex) : conv.messages; + const messages = priorMessages.slice(-(this.memoryWindow * 2)); return messages .map((/** @type {any} */ m) => { const role = m.role === 'user' ? 'User' : 'Assistant'; @@ -213,17 +225,7 @@ class SessionMemory { } } - // Persist user turn — unless this is a regenerate, where the original - // user row already exists and re-inserting duplicates it on reload. - // Retry after a failed request sends the same flag although nothing - // was saved, so the skip is honoured only when the row is really there. - const userText = typeof input === 'string' ? input : ''; - if (!skipUserMessage || !this._isLastUserMessage(conv, userText)) { - this.chatHistoryStore.addMessage(memory.sessionId, { - role: 'user', - text: userText - }); - } + this.saveUserMessage(conv, memory.sessionId, input, { skipUserMessage }); /** @type {Record} */ const assistantMessage = { @@ -271,19 +273,42 @@ class SessionMemory { } /** - * Whether the conversation's most recent user row is this question. + * Persist the user row of a turn — unless this is a regenerate, where the + * original user row already exists and re-inserting duplicates it on + * reload. The answer that row already has is removed instead, so the one + * saved next replaces it. Retry after a failed request sends the same flag + * although nothing may have been saved, so the skip is honoured only when + * the row is really there. + * @param {any} conv Conversation as read before this turn, or null when it + * was only just created. + * @param {string} sessionId + * @param {any} input + * @param {{ skipUserMessage?: boolean }} [options] + */ + saveUserMessage(conv, sessionId, input, { skipUserMessage = false } = {}) { + const userText = typeof input === 'string' ? input : ''; + if (skipUserMessage && this._findLastUserMessageIndex(conv, userText) >= 0) { + this.chatHistoryStore.deleteAssistantMessagesAfterLastUser(sessionId); + return; + } + this.chatHistoryStore.addMessage(sessionId, { role: 'user', text: userText }); + } + + /** + * Index of the conversation's most recent user row when it is this + * question, otherwise -1. * @private * @param {any} conv Stored conversation, or null when none exists yet. * @param {string} userText - * @returns {boolean} + * @returns {number} */ - _isLastUserMessage(conv, userText) { + _findLastUserMessageIndex(conv, userText) { const messages = Array.isArray(conv?.messages) ? conv.messages : []; for (let i = messages.length - 1; i >= 0; i -= 1) { if (messages[i]?.role !== 'user') continue; - return String(messages[i].text ?? '').trim() === userText.trim(); + return String(messages[i].text ?? '').trim() === userText.trim() ? i : -1; } - return false; + return -1; } /** diff --git a/src/main/services/liveLogActivity.js b/src/main/services/liveLogActivity.js index 78940a2..a1bb0cd 100644 --- a/src/main/services/liveLogActivity.js +++ b/src/main/services/liveLogActivity.js @@ -560,10 +560,10 @@ const RULES = [ return activity('model', 'cpu', 'info', type ? `Unloading ${type} model` : 'Unloading a model'); }), prefixRule( - 'embedding-unloaded-for-vision', + 'embedding-returning-to-gpu', LLAMA, - '[LlamaService] Unloaded the CPU embedding model from the vision batch', - () => activity('model', 'cpu', 'info', 'Embedding model unloaded for the vision batch') + '[LlamaService] Unloaded the CPU embedding model; the next load uses the GPU', + () => activity('model', 'cpu', 'info', 'Embedding model moving back to the GPU') ), prefixRule( 'embeddings-flushed', diff --git a/src/main/services/organization/patternMatcher.js b/src/main/services/organization/patternMatcher.js index b0d9fce..376311f 100644 --- a/src/main/services/organization/patternMatcher.js +++ b/src/main/services/organization/patternMatcher.js @@ -11,6 +11,27 @@ const { createLogger } = require('../../../shared/logger'); const logger = createLogger('Organization:PatternMatcher'); + +/** + * Fold two saved entries for the same pattern into one: the uses add up, and + * the folder comes from whichever was used last. + * @param {Record} a + * @param {Record} b + * @returns {Record} + */ +function mergePatternData(a, b) { + const [older, newer] = (a.lastUsed || 0) <= (b.lastUsed || 0) ? [a, b] : [b, a]; + const created = [a.createdAt, b.createdAt].filter(Number.isFinite); + return { + ...older, + ...newer, + count: (a.count || 0) + (b.count || 0), + confidence: Math.max(a.confidence || 0, b.confidence || 0), + lastUsed: Math.max(a.lastUsed || 0, b.lastUsed || 0), + createdAt: created.length > 0 ? Math.min(...created) : undefined + }; +} + /** * Pattern Matcher class for learning from user decisions */ @@ -41,7 +62,16 @@ class PatternMatcher { loadPatterns(stored) { if (stored.patterns && Array.isArray(stored.patterns)) { try { - this.userPatterns = new Map(stored.patterns); + // Keys saved before extractPattern dropped the extension's dot + // (`.pdf:finance:finance`) are rewritten to the current form, so + // they keep matching and keep counting with their undotted twin. + const patterns = new Map(); + for (const [key, data] of stored.patterns) { + const pattern = String(key).replace(/^\./, ''); + const existing = patterns.get(pattern); + patterns.set(pattern, existing ? mergePatternData(existing, data) : data); + } + this.userPatterns = patterns; logger.info(`[PatternMatcher] Loaded ${this.userPatterns.size} user patterns`); } catch (e) { logger.warn('[PatternMatcher] Failed to load patterns, starting fresh:', e.message); @@ -128,19 +158,24 @@ class PatternMatcher { } } + // The row keeps the key it changed: retractFeedback reverses that key, + // since its caller (undo) no longer has the category this was recorded with. + const pattern = suggestion ? this.extractPattern(file, suggestion) : undefined; + // Add new feedback this.feedbackHistory.push({ timestamp: now, file: { name: file.name, type: file.extension }, suggestion, - accepted + accepted, + pattern }); - if (suggestion) { + if (pattern) { if (accepted) { - this._updatePattern(file, suggestion, now); + this._updatePattern(pattern, suggestion, now); } else { - this._penalizePattern(file, suggestion); + this._penalizePattern(pattern); } } @@ -156,9 +191,11 @@ class PatternMatcher { * Update pattern from accepted suggestion * @private */ - _updatePattern(/** @type {any} */ file, /** @type {any} */ suggestion, /** @type {any} */ now) { - const pattern = this.extractPattern(file, suggestion); - + _updatePattern( + /** @type {string} */ pattern, + /** @type {any} */ suggestion, + /** @type {any} */ now + ) { if (!this.userPatterns.has(pattern)) { // Check memory periodically this.patternCount++; @@ -194,8 +231,7 @@ class PatternMatcher { * Removes the pattern entirely if confidence drops below a usable threshold. * @private */ - _penalizePattern(/** @type {any} */ file, /** @type {any} */ suggestion) { - const pattern = this.extractPattern(file, suggestion); + _penalizePattern(/** @type {string} */ pattern) { const data = this.userPatterns.get(pattern); if (!data) return; @@ -331,7 +367,9 @@ class PatternMatcher { */ extractPattern(file, suggestion = null) { const parts = [ - file.extension, + // No leading dot: callers pass `.pdf` (path.extname) and `pdf` (learning + // feedback) for the same file type. + String(file.extension || '').replace(/^\./, ''), // Either shape: analysis-bearing files, and the flat metadata learning // feedback records. Reading only `analysis.category` stored every // learned pattern as `:unknown:`, which a lookup for an @@ -383,6 +421,8 @@ class PatternMatcher { * the most recent matching history row is removed and the pattern's * count/confidence step is reversed (pattern deleted when it reaches 0). * This is a retraction, not a rejection — no penalty is applied. + * The pattern reversed is the one the row recorded; rows saved before rows + * carried their key fall back to the key `file` + `suggestion` produce. * @param {any} file * @param {any} suggestion * @returns {{ retracted: boolean }} @@ -404,9 +444,9 @@ class PatternMatcher { } } if (historyIndex === -1) return { retracted: false }; - this.feedbackHistory.splice(historyIndex, 1); + const [row] = this.feedbackHistory.splice(historyIndex, 1); - const pattern = this.extractPattern(file, suggestion); + const pattern = row.pattern || this.extractPattern(file, suggestion); const data = this.userPatterns.get(pattern); if (data) { data.count = Math.max(0, (data.count || 0) - 1); diff --git a/src/main/services/organization/persistence.js b/src/main/services/organization/persistence.js index 6ef13bb..a1f9014 100644 --- a/src/main/services/organization/persistence.js +++ b/src/main/services/organization/persistence.js @@ -59,7 +59,9 @@ const feedbackEntrySchema = z.object({ type: z.string().optional() }) .optional(), - suggestion: z.object({}).passthrough().optional() + suggestion: z.object({}).passthrough().optional(), + // Key the row changed in userPatterns; absent on rows saved before it existed. + pattern: z.string().optional() }); const patternsSchema = z.object({ diff --git a/src/main/services/watcher/WatcherQueueStore.js b/src/main/services/watcher/WatcherQueueStore.js index 1611ce0..19e44bf 100644 --- a/src/main/services/watcher/WatcherQueueStore.js +++ b/src/main/services/watcher/WatcherQueueStore.js @@ -27,16 +27,11 @@ const PERSISTED_FIELDS = [ 'queuedAt', 'retryCount', 'applyNaming', - 'dedupKey', - // forceReanalyzeAll's mark. The watcher's exclusion gates exempt a - // hand-picked single-file reanalyze from the user's rules but not a bulk - // one; without this on disk, every row of an interrupted bulk run came - // back exempt after the restart. - 'bulk' + 'dedupKey' ]; /** - * @typedef {{ filePath: string, mtime?: number, eventType?: string, queuedAt?: number, retryCount?: number, applyNaming?: boolean, dedupKey?: string, bulk?: boolean }} PersistedQueueItem + * @typedef {{ filePath: string, mtime?: number, eventType?: string, queuedAt?: number, retryCount?: number, applyNaming?: boolean, dedupKey?: string }} PersistedQueueItem */ /** diff --git a/src/preload/preload.js b/src/preload/preload.js index c836b3a..3c9f41d 100644 --- a/src/preload/preload.js +++ b/src/preload/preload.js @@ -149,7 +149,6 @@ const IPC_CHANNELS = { REBUILD_FILES: 'embeddings:rebuild-files', FULL_REBUILD: 'embeddings:full-rebuild', REANALYZE_ALL: 'embeddings:reanalyze-all', - REANALYZE_FILE: 'embeddings:reanalyze-file', GET_STATS: 'embeddings:get-stats', SEARCH: 'embeddings:search', SCORE_FILES: 'embeddings:score-files', @@ -1983,8 +1982,6 @@ contextBridge.exposeInMainWorld('electronAPI', { secureIPC.safeInvoke(IPC_CHANNELS.EMBEDDINGS.FULL_REBUILD, options), reanalyzeAll: (/** @type {any} */ options) => secureIPC.safeInvoke(IPC_CHANNELS.EMBEDDINGS.REANALYZE_ALL, options), - reanalyzeFile: (/** @type {any} */ filePath, options = {}) => - secureIPC.safeInvoke(IPC_CHANNELS.EMBEDDINGS.REANALYZE_FILE, { filePath, ...options }), getStats: (/** @type {any} */ options = undefined) => options === undefined ? secureIPC.safeInvoke(IPC_CHANNELS.EMBEDDINGS.GET_STATS) diff --git a/src/renderer/components/search/AtlasTabContent.jsx b/src/renderer/components/search/AtlasTabContent.jsx index 6ac52a1..3126cc6 100644 --- a/src/renderer/components/search/AtlasTabContent.jsx +++ b/src/renderer/components/search/AtlasTabContent.jsx @@ -20,7 +20,8 @@ import { pickNextPointInDirection, ARROW_DIRECTIONS, ATLAS_SEARCH_TOP_K, - atlasIpcFailureMessage + atlasIpcFailureMessage, + describeSelectionForFolder } from './atlasMapUtils'; import { buildEmbeddingData, nearestPointIndex, indicesInLasso } from './atlasEmbeddingData'; @@ -890,17 +891,28 @@ function AtlasTabContent({ isActive, openFile, revealFile, setError, simpleMode const files = /** @type {any} */ (window).electronAPI?.files; const dirResult = await files?.selectDirectory?.({ purpose: 'destination' }); if (!dirResult?.success || !dirResult?.path) return; + // The folder is described by what the selected files have in common, + // so files like them are suggested for it. The files stay where they + // are; Move… is the control that moves a selection. + const metaMap = await fetchMetaBatch(selectedFileIds); + const { description, keywords } = describeSelectionForFolder( + selectedFileIds.map((id) => metaMap.get(id)) + ); const res = await /** @type {any} */ (window).electronAPI?.smartFolders?.add?.({ name, path: joinPath(dirResult.path, name), - description: `Created from an Atlas selection of ${selectedFileIds.length} files`, - keywords: [], + description, + keywords, category: 'General' }); if (res?.success === false) { throw new Error(atlasIpcFailureMessage(res, 'Smart folder')); } - setBulkStatus(`Smart folder "${name}" created.`); + setBulkStatus( + keywords.length + ? `Smart folder "${name}" created from the selection's topics. Use Move… to put these files in it.` + : `Smart folder "${name}" created. Use Move… to put these files in it.` + ); setFolderDraft(null); } catch (err) { setBothErrors(atlasIpcFailureMessage(err, 'Smart folder')); @@ -908,7 +920,7 @@ function AtlasTabContent({ isActive, openFile, revealFile, setError, simpleMode setIsBulkWorking(false); } }, - [folderDraft, selectedFileIds, setBothErrors] + [folderDraft, selectedFileIds, fetchMetaBatch, setBothErrors] ); const focusSimilarItem = useCallback( diff --git a/src/renderer/components/search/atlasMapUtils.js b/src/renderer/components/search/atlasMapUtils.js index e6c3e88..f5b1ff9 100644 --- a/src/renderer/components/search/atlasMapUtils.js +++ b/src/renderer/components/search/atlasMapUtils.js @@ -169,3 +169,46 @@ export function atlasIpcFailureMessage(value, context = 'Atlas') { } return `${context} failed`; } + +/** How many of a selection's commonest topics a new smart folder is seeded with. */ +export const ATLAS_FOLDER_KEYWORD_LIMIT = 12; + +/** + * Describe a smart folder made from an Atlas selection, so the folder knows + * what its files have in common: the keywords and tags the selected files + * share most often become the folder's keywords and are named in its + * description. Files without metadata still count towards the total. + * + * @param {Array<{ keywords?: unknown, tags?: unknown } | null | undefined>} metas + * @returns {{ description: string, keywords: string[] }} + */ +export function describeSelectionForFolder(metas) { + const list = Array.isArray(metas) ? metas : []; + /** @type {Map} */ + const topics = new Map(); + for (const meta of list) { + const seen = new Set(); + for (const source of [meta?.keywords, meta?.tags]) { + for (const raw of Array.isArray(source) ? source : []) { + const label = typeof raw === 'string' ? raw.trim() : ''; + const key = label.toLowerCase(); + if (!key || seen.has(key)) continue; + seen.add(key); + const topic = topics.get(key); + if (topic) topic.count += 1; + else topics.set(key, { label, count: 1 }); + } + } + } + // Map order is first-seen order and the sort is stable, so ties keep it. + const keywords = [...topics.values()] + .sort((a, b) => b.count - a.count) + .slice(0, ATLAS_FOLDER_KEYWORD_LIMIT) + .map((topic) => topic.label); + const count = list.length; + const base = `Created from an Atlas selection of ${count} file${count === 1 ? '' : 's'}`; + return { + description: keywords.length ? `${base}. Common topics: ${keywords.join(', ')}.` : base, + keywords + }; +} diff --git a/src/shared/constants.js b/src/shared/constants.js index 7238b13..4b544a7 100644 --- a/src/shared/constants.js +++ b/src/shared/constants.js @@ -111,7 +111,6 @@ const IPC_CHANNELS = { REBUILD_FILES: 'embeddings:rebuild-files', FULL_REBUILD: 'embeddings:full-rebuild', REANALYZE_ALL: 'embeddings:reanalyze-all', - REANALYZE_FILE: 'embeddings:reanalyze-file', GET_STATS: 'embeddings:get-stats', SEARCH: 'embeddings:search', SCORE_FILES: 'embeddings:score-files', diff --git a/test/AutoOrganizeService.test.js b/test/AutoOrganizeService.test.js index 3bcb538..7243a2d 100644 --- a/test/AutoOrganizeService.test.js +++ b/test/AutoOrganizeService.test.js @@ -158,7 +158,7 @@ describe('AutoOrganizeService', () => { }); }); - test('records positive feedback for automatic organization', async () => { + test('plans automatic organization without recording feedback', async () => { const file = { name: 'document.pdf', path: '/downloads/document.pdf', @@ -173,18 +173,11 @@ describe('AutoOrganizeService', () => { mockSuggestionService.getSuggestionsForFile.mockResolvedValue(suggestion); - await service.organizeFiles([file], mockSmartFolders); + const result = await service.organizeFiles([file], mockSmartFolders); - expect(mockSuggestionService.recordFeedback).toHaveBeenCalledWith( - file, - expect.objectContaining({ - folder: 'Documents', - path: '/base/Documents', - isSmartFolder: true, - folderId: 'folder-1' - }), - true - ); + // Learning belongs to the completed move (batchOrganizeHandler) + expect(result.operations).toHaveLength(1); + expect(mockSuggestionService.recordFeedback).not.toHaveBeenCalled(); }); }); @@ -513,7 +506,7 @@ describe('AutoOrganizeService', () => { expect(result.operations).toHaveLength(0); }); - test('records positive feedback for batch operations', async () => { + test('plans batch operations without recording feedback', async () => { const file = { name: 'doc1.pdf', path: '/downloads/doc1.pdf', @@ -533,13 +526,10 @@ describe('AutoOrganizeService', () => { ] }); - await service.batchOrganize([file], mockSmartFolders); + const result = await service.batchOrganize([file], mockSmartFolders); - expect(mockSuggestionService.recordFeedback).toHaveBeenCalledWith( - file, - file.suggestion, - true - ); + expect(result.operations).toHaveLength(1); + expect(mockSuggestionService.recordFeedback).not.toHaveBeenCalled(); }); }); diff --git a/test/ChatHistoryStore.validation.test.js b/test/ChatHistoryStore.validation.test.js index 681e263..bce5294 100644 --- a/test/ChatHistoryStore.validation.test.js +++ b/test/ChatHistoryStore.validation.test.js @@ -225,3 +225,78 @@ describe('ChatHistoryStore — input size validation', () => { }); }); }); + +describe('ChatHistoryStore — deleteAssistantMessagesAfterLastUser', () => { + /** + * Store over a fake db that keeps one conversation's message rows in memory + * and answers the statements this method and getConversation issue. + */ + function buildStoreWithMessages(roles) { + let rows = roles.map((role, i) => ({ + id: `m${i + 1}`, + conversation_id: 'conv-1', + role, + text: `${role} ${i + 1}`, + sort_order: i + 1 + })); + const touched = jest.fn(); + const store = new ChatHistoryStore(); + store._db = { + prepare(sql) { + const trimmed = String(sql).replace(/\s+/g, ' ').trim(); + return { + get: (id) => + trimmed.startsWith('SELECT * FROM conversations WHERE id') && id === 'conv-1' + ? { id, title: 'Chat', document_scope: '[]', updated_at: 't0' } + : undefined, + all: () => rows, + run: (...args) => { + if (trimmed.startsWith('DELETE FROM messages WHERE id IN')) { + rows = rows.filter((row) => !args.includes(row.id)); + } else if (trimmed.startsWith('UPDATE conversations SET updated_at')) { + touched(...args); + } else { + throw new Error(`Unhandled SQL in fake db: ${trimmed}`); + } + } + }; + } + }; + store._initGuard.isInitialized = () => true; + store._hasMetaColumn = true; + store._hasSortOrderColumn = true; + return { store, touched, remainingIds: () => rows.map((row) => row.id) }; + } + + test('removes every assistant row after the last user row', () => { + const { store, touched, remainingIds } = buildStoreWithMessages([ + 'user', + 'assistant', + 'user', + 'assistant', + 'assistant' + ]); + // Prime the read cache: the delete must not leave the old rows in it. + expect(store.getConversation('conv-1').messages).toHaveLength(5); + + expect(store.deleteAssistantMessagesAfterLastUser('conv-1')).toBe(2); + + expect(remainingIds()).toEqual(['m1', 'm2', 'm3']); + expect(touched).toHaveBeenCalledWith('conv-1'); + expect(store.getConversation('conv-1').messages.map((m) => m.id)).toEqual(['m1', 'm2', 'm3']); + }); + + test('leaves the conversation alone when the last row is the user row', () => { + const { store, touched, remainingIds } = buildStoreWithMessages(['user', 'assistant', 'user']); + expect(store.deleteAssistantMessagesAfterLastUser('conv-1')).toBe(0); + expect(remainingIds()).toEqual(['m1', 'm2', 'm3']); + expect(touched).not.toHaveBeenCalled(); + }); + + test('removes nothing without a user row or without the conversation', () => { + const { store, remainingIds } = buildStoreWithMessages(['assistant', 'assistant']); + expect(store.deleteAssistantMessagesAfterLastUser('conv-1')).toBe(0); + expect(store.deleteAssistantMessagesAfterLastUser('missing')).toBe(0); + expect(remainingIds()).toEqual(['m1', 'm2']); + }); +}); diff --git a/test/ChatService.sessions.test.js b/test/ChatService.sessions.test.js index 69fe99e..5950cc9 100644 --- a/test/ChatService.sessions.test.js +++ b/test/ChatService.sessions.test.js @@ -134,7 +134,8 @@ describe('ChatService session management', () => { ] })), createConversation: jest.fn(), - addMessage: jest.fn() + addMessage: jest.fn(), + deleteAssistantMessagesAfterLastUser: jest.fn() }; const service = new ChatService({ llamaService: { analyzeText: jest.fn().mockResolvedValue({ success: true, response: '{}' }) }, @@ -151,6 +152,8 @@ describe('ChatService session management', () => { const roles = store.addMessage.mock.calls.map(([, msg]) => msg.role); expect(roles).toEqual(['assistant']); + // The answer being regenerated is removed, not kept next to the new one. + expect(store.deleteAssistantMessagesAfterLastUser).toHaveBeenCalledWith('regen'); }); test('reuses existing session instead of creating new', async () => { @@ -178,6 +181,121 @@ describe('ChatService session management', () => { }); }); +describe('ChatService regenerate and retry (skipSaveUserTurn)', () => { + const QUESTION = 'what does the lease say about pets?'; + + /** In-memory stand-in for ChatHistoryStore holding one conversation's rows. */ + function createHistoryStore(messages) { + const conversations = new Map(); + if (messages) conversations.set('chat', { id: 'chat', title: 'Chat', messages }); + return { + conversations, + initialize: jest.fn().mockResolvedValue(undefined), + getConversation: jest.fn((id) => conversations.get(id) || null), + createConversation: jest.fn((title, documentScope, id) => { + conversations.set(id, { id, title, documentScope, messages: [] }); + }), + addMessage: jest.fn((id, message) => { + conversations.get(id).messages.push(message); + }), + updateTitle: jest.fn(), + deleteAssistantMessagesAfterLastUser: jest.fn((id) => { + const rows = conversations.get(id).messages; + while (rows.length > 0 && rows[rows.length - 1].role === 'assistant') rows.pop(); + }) + }; + } + + function createStreamingService(store, generateTextStreaming) { + const service = new ChatService({ + llamaService: { generateTextStreaming, analyzeText: jest.fn() }, + chatHistoryStore: store + }); + jest.spyOn(service, '_resolveGenerateBackend').mockReturnValue(null); + jest.spyOn(service, '_retrieveSources').mockResolvedValue({ sources: [], meta: {} }); + return service; + } + + const rolesOf = (store) => store.conversations.get('chat').messages.map((m) => m.role); + + test('regenerate replaces the stored answer and keeps it out of the prompt', async () => { + const store = createHistoryStore([ + { role: 'user', text: 'who signed the lease?' }, + { role: 'assistant', text: 'EARLIER-ANSWER' }, + { role: 'user', text: QUESTION }, + { role: 'assistant', text: 'DISCARDED-ANSWER' } + ]); + const generateTextStreaming = jest.fn(async () => ({ response: 'One cat is allowed.' })); + const service = createStreamingService(store, generateTextStreaming); + const events = []; + + await service.queryStreaming({ + sessionId: 'chat', + requestId: 'r1', + query: QUESTION, + skipSaveUserTurn: true, + onEvent: (evt) => events.push(evt) + }); + + expect(events.some((evt) => evt.type === 'done')).toBe(true); + const { prompt } = generateTextStreaming.mock.calls[0][0]; + expect(prompt).toContain('EARLIER-ANSWER'); + expect(prompt).not.toContain('DISCARDED-ANSWER'); + expect(rolesOf(store)).toEqual(['user', 'assistant', 'user', 'assistant']); + expect(JSON.stringify(store.conversations.get('chat').messages)).not.toContain( + 'DISCARDED-ANSWER' + ); + }); + + test('a retry that fails again leaves one error row, not two', async () => { + const store = createHistoryStore([ + { role: 'user', text: QUESTION }, + { role: 'assistant', text: '', modelAnswer: [{ text: 'Error: first failure.' }] } + ]); + const service = createStreamingService( + store, + jest.fn().mockRejectedValue(new Error('second failure')) + ); + const events = []; + + await service.queryStreaming({ + sessionId: 'chat', + requestId: 'r2', + query: QUESTION, + skipSaveUserTurn: true, + onEvent: (evt) => events.push(evt) + }); + + expect(events.some((evt) => evt.type === 'error')).toBe(true); + const { messages } = store.conversations.get('chat'); + expect(messages.map((m) => m.role)).toEqual(['user', 'assistant']); + expect(messages[1].modelAnswer[0].text).toContain('second failure'); + }); + + test('a failed retry saves the question when no earlier attempt stored it', async () => { + const store = createHistoryStore(null); + const service = createStreamingService( + store, + jest.fn().mockRejectedValue(new Error('still failing')) + ); + + await service.queryStreaming({ + sessionId: 'chat', + requestId: 'r3', + query: QUESTION, + skipSaveUserTurn: true, + onEvent: () => {} + }); + + const { messages } = store.conversations.get('chat'); + expect(messages.map((m) => [m.role, m.text])).toEqual([ + ['user', QUESTION], + ['assistant', ''] + ]); + expect(store.deleteAssistantMessagesAfterLastUser).not.toHaveBeenCalled(); + }); +}); + describe('ChatService _isConversational', () => { let service; diff --git a/test/LlamaService.inference.test.js b/test/LlamaService.inference.test.js index e800650..6be557b 100644 --- a/test/LlamaService.inference.test.js +++ b/test/LlamaService.inference.test.js @@ -507,6 +507,71 @@ describe('LlamaService – inference methods', () => { expect(mockModelMemoryManager.unloadModel).not.toHaveBeenCalled(); }); + describe('a text load that began after the image checked for room', () => { + // Nothing was loading when the image looked, so it freed nothing; + // the runtime then refuses to start beside the load. + const startRefused = () => + Object.assign(new Error('Cannot start the vision runtime: memory budget exceeded'), { + code: 'MODEL_MEMORY_BUDGET_EXCEEDED' + }); + + test('the image makes room and is sent once more', async () => { + const service = batchImageService({ textResident: false, visionAlive: false }); + mockVisionService.analyzeImage + .mockRejectedValueOnce(startRefused()) + .mockResolvedValue({ response: 'a cat' }); + + await expect( + service.analyzeImage({ imagePath: '/test.jpg', prompt: 'describe' }) + ).resolves.toEqual({ response: 'a cat' }); + + expect(service._releaseMemoryForVisionRuntime).toHaveBeenCalledTimes(1); + expect(service._releaseMemoryForVisionRuntime).toHaveBeenCalledWith('batch'); + expect(mockVisionService.analyzeImage).toHaveBeenCalledTimes(2); + }); + + test('outside a batch the image makes room the single-image way', async () => { + const service = batchImageService({ textResident: false, visionAlive: false }); + service._visionBatchMode = false; + mockVisionService.analyzeImage + .mockRejectedValueOnce(startRefused()) + .mockResolvedValue({ response: 'a cat' }); + + await expect( + service.analyzeImage({ imagePath: '/test.jpg', prompt: 'describe' }) + ).resolves.toEqual({ response: 'a cat' }); + + expect(service._releaseMemoryForVisionRuntime.mock.calls).toEqual([ + ['single'], + ['single'] + ]); + expect(mockVisionService.analyzeImage).toHaveBeenCalledTimes(2); + }); + + test('a second refusal is given back, not retried again', async () => { + const service = batchImageService({ textResident: false, visionAlive: false }); + mockVisionService.analyzeImage.mockRejectedValue(startRefused()); + + await expect( + service.analyzeImage({ imagePath: '/test.jpg', prompt: 'describe' }) + ).rejects.toMatchObject({ code: 'MODEL_MEMORY_BUDGET_EXCEEDED' }); + + expect(mockVisionService.analyzeImage).toHaveBeenCalledTimes(2); + }); + + test('a failure that is not about memory is not retried', async () => { + const service = batchImageService({ textResident: false, visionAlive: false }); + mockVisionService.analyzeImage.mockRejectedValue(new Error('spawn failed')); + + await expect( + service.analyzeImage({ imagePath: '/test.jpg', prompt: 'describe' }) + ).rejects.toThrow('spawn failed'); + + expect(mockVisionService.analyzeImage).toHaveBeenCalledTimes(1); + expect(service._releaseMemoryForVisionRuntime).not.toHaveBeenCalled(); + }); + }); + test('a text load in progress does not disturb a runtime that is up', async () => { const service = batchImageService({ textResident: false, diff --git a/test/LlamaService.visionBatchLane.test.js b/test/LlamaService.visionBatchLane.test.js index 77bd737..8f9168f 100644 --- a/test/LlamaService.visionBatchLane.test.js +++ b/test/LlamaService.visionBatchLane.test.js @@ -309,6 +309,86 @@ describe('LlamaService: the batch pin and the GPU embedding model', () => { }); }); +describe('LlamaService: the embedding model on the CPU beside a text model', () => { + // A 7B text model on a 6 GB card: the embedding model loads on the CPU + // while the text model loads. When that text load failed, the embedding + // model stayed on the CPU, with the GPU empty, until something unloaded it. + const MiB = 1024 * 1024; + let service; + let manager; + + beforeEach(() => { + jest.clearAllMocks(); + service = new LlamaService(); + service._initialized = true; + service._gpuBackend = 'cuda'; + service._loadModel = jest.fn(async (type) => ({ ctx: type })); + manager = new ModelMemoryManager(service); + jest.spyOn(manager, '_refreshMemoryBudget').mockImplementation(() => {}); + jest.spyOn(manager, '_disposeModelFn').mockResolvedValue(undefined); + manager._maxMemoryUsage = 5 * GiB; + manager._modelSizeEstimates.text = 5806 * MiB; + manager._modelSizeEstimates.embedding = 181 * MiB; + manager._pinnedResidentWaitMs = 0; + service._modelMemoryManager = manager; + }); + + const embed = async () => { + manager.acquireRef('embedding'); + try { + return await service._ensureModelLoaded('embedding', { selfRef: true }); + } finally { + manager.releaseRef('embedding'); + } + }; + + test('goes back to the GPU on the next embedding once the text load has failed', async () => { + const endTextLoad = manager.reserveLoad('text', 5806 * MiB); + await embed(); + expect(service._loadModel).toHaveBeenLastCalledWith( + 'embedding', + expect.objectContaining({ gpuLayersOverride: 0 }) + ); + endTextLoad(); + + await embed(); + + expect(manager._disposeModelFn).toHaveBeenCalledWith('embedding'); + expect(service._loadModel).toHaveBeenCalledTimes(2); + expect(service._loadModel).toHaveBeenLastCalledWith('embedding', {}); + expect(manager.isOnCpuBeside('embedding')).toBe(false); + }); + + test('stays on the CPU while the text model is loading or loaded', async () => { + const endTextLoad = manager.reserveLoad('text', 5806 * MiB); + await embed(); + await embed(); + expect(service._loadModel).toHaveBeenCalledTimes(1); + + // The text load succeeded: the model is resident and still fills the GPU. + manager._loadedModels.set('text', { context: {}, lastUsed: 1, sizeBytes: 5806 * MiB }); + manager._currentMemoryUsage += 5806 * MiB; + endTextLoad(); + await embed(); + + expect(service._loadModel).toHaveBeenCalledTimes(1); + expect(manager._disposeModelFn).not.toHaveBeenCalled(); + expect(manager.isOnCpuBeside('embedding')).toBe(true); + }); + + test('is served from the CPU, without a wait, while another embedding uses it', async () => { + const endTextLoad = manager.reserveLoad('text', 5806 * MiB); + await embed(); + endTextLoad(); + manager.acquireRef('embedding'); + + await embed(); + + expect(service._loadModel).toHaveBeenCalledTimes(1); + expect(manager.isOnCpuBeside('embedding')).toBe(true); + }); +}); + describe('LlamaService: the text preload after a batch', () => { let service; let manager; diff --git a/test/ModelMemoryManager.zeroByteResidents.test.js b/test/ModelMemoryManager.zeroByteResidents.test.js new file mode 100644 index 0000000..c4a8707 --- /dev/null +++ b/test/ModelMemoryManager.zeroByteResidents.test.js @@ -0,0 +1,180 @@ +/** + * ModelMemoryManager — residents that take nothing from the budget. + * + * A text model larger than the budget (Qwen2.5-7B, 5.8 GiB, against 4.8 GiB + * on a 6 GB card) never fits, so its load evicted every idle resident. Two + * of them hold no GPU memory: the embedding model that was loaded on the CPU + * beside the text model or the vision runtime, and Whisper running on the + * CPU (registered at 0 bytes). Unloading them freed nothing, and the next + * file loaded them again. + */ + +jest.mock('os', () => ({ + totalmem: jest.fn(() => 16 * 1024 * 1024 * 1024), + freemem: jest.fn(() => 8 * 1024 * 1024 * 1024) +})); + +jest.mock('../src/shared/logger', () => ({ + createLogger: () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn() + }) +})); + +const { ModelMemoryManager } = require('../src/main/services/ModelMemoryManager'); + +const GiB = 1024 * 1024 * 1024; + +function makeManager() { + const llamaService = { + _models: { text: null, vision: null, embedding: null }, + _contexts: { text: null, vision: null, embedding: null }, + _loadModel: jest.fn(async (type) => ({ ctx: type })) + }; + const manager = new ModelMemoryManager(llamaService); + manager._maxMemoryUsage = 4.8 * GiB; + manager._modelSizeEstimates.text = 5.8 * GiB; + manager._modelSizeEstimates.embedding = 0.2 * GiB; + manager._pinnedResidentWaitMs = 0; + jest.spyOn(manager, '_refreshMemoryBudget').mockImplementation(() => {}); + jest.spyOn(manager, '_disposeModelFn').mockResolvedValue(undefined); + return { manager, llamaService }; +} + +/** The embedding model on the CPU beside a text load that then failed. */ +async function withCpuEmbedding(manager) { + const endTextLoad = manager.reserveLoad('text', 5.8 * GiB); + await manager.ensureModelLoaded('embedding', { + gpuLayersOverride: 0, + besideResident: 'text', + keepBeside: true + }); + endTextLoad(); +} + +function registerCpuWhisper(manager) { + const dispose = jest.fn().mockResolvedValue(undefined); + manager.registerExternalConsumer({ type: 'audio', sizeBytes: 0, dispose }); + return dispose; +} + +describe('a model loaded on the CPU beside another', () => { + test('takes nothing from the budget and says where it runs', async () => { + const { manager } = makeManager(); + await withCpuEmbedding(manager); + + expect(manager._currentMemoryUsage).toBe(0); + expect(manager.isOnCpuBeside('embedding')).toBe(true); + }); + + test('a model loaded on the GPU is counted in full', async () => { + const { manager } = makeManager(); + await manager.ensureModelLoaded('embedding'); + + expect(manager._currentMemoryUsage).toBe(0.2 * GiB); + expect(manager.isOnCpuBeside('embedding')).toBe(false); + expect(manager.isOnCpuBeside('text')).toBe(false); + }); + + test('unloading it leaves the budget where it was', async () => { + const { manager } = makeManager(); + await withCpuEmbedding(manager); + + await expect(manager.unloadModel('embedding')).resolves.toBe(true); + + expect(manager._currentMemoryUsage).toBe(0); + expect(manager.isOnCpuBeside('embedding')).toBe(false); + }); +}); + +describe('a text model larger than the budget', () => { + test('does not unload the embedding model that runs on the CPU', async () => { + const { manager, llamaService } = makeManager(); + await withCpuEmbedding(manager); + + await manager.ensureModelLoaded('text'); + + expect(llamaService._loadModel).toHaveBeenCalledWith('text', expect.anything()); + expect(manager._loadedModels.has('embedding')).toBe(true); + expect(manager._disposeModelFn).not.toHaveBeenCalled(); + }); + + test('does not unload Whisper running on the CPU', async () => { + const { manager } = makeManager(); + const dispose = registerCpuWhisper(manager); + + await manager.ensureModelLoaded('text'); + + expect(dispose).not.toHaveBeenCalled(); + expect(manager._loadedModels.has('audio')).toBe(true); + expect(manager._loadedModels.has('text')).toBe(true); + }); + + test('loads at once beside a CPU model that is in use', async () => { + const { manager, llamaService } = makeManager(); + manager._pinnedResidentWaitMs = 5000; + await withCpuEmbedding(manager); + manager.acquireRef('embedding'); + + const startedAt = Date.now(); + await manager.ensureModelLoaded('text'); + + expect(Date.now() - startedAt).toBeLessThan(1000); + expect(llamaService._loadModel).toHaveBeenCalledWith('text', expect.anything()); + }); + + test('still unloads an idle model that holds GPU memory', async () => { + const { manager } = makeManager(); + await manager.ensureModelLoaded('embedding'); + registerCpuWhisper(manager); + + await manager.ensureModelLoaded('text'); + + expect(manager._loadedModels.has('embedding')).toBe(false); + expect(manager._loadedModels.has('audio')).toBe(true); + }); + + test('is still refused while a model that holds GPU memory is in use', async () => { + const { manager, llamaService } = makeManager(); + await manager.ensureModelLoaded('embedding'); + manager.acquireRef('embedding'); + + await expect(manager.ensureModelLoaded('text')).rejects.toMatchObject({ + code: 'MODEL_MEMORY_BUDGET_EXCEEDED' + }); + expect(llamaService._loadModel).not.toHaveBeenCalledWith('text', expect.anything()); + }); + + test('optional work may use it while only CPU models sit beside it', async () => { + const { manager } = makeManager(); + await withCpuEmbedding(manager); + await manager.ensureModelLoaded('text'); + + expect(manager.canRunOptionalLoad('text')).toBe(true); + }); +}); + +describe('an external load (the vision runtime) larger than the budget', () => { + test('fits beside residents that take nothing from the budget', async () => { + const { manager } = makeManager(); + await withCpuEmbedding(manager); + registerCpuWhisper(manager); + + expect(manager.canFitExternalLoad(6 * GiB)).toBe(true); + }); + + test('making room for it leaves them loaded', async () => { + const { manager } = makeManager(); + await withCpuEmbedding(manager); + const dispose = registerCpuWhisper(manager); + await manager.ensureModelLoaded('text'); + + await expect(manager.makeRoomForExternalLoad(6 * GiB)).resolves.toBe(true); + + expect(manager._loadedModels.has('text')).toBe(false); + expect(manager._loadedModels.has('embedding')).toBe(true); + expect(dispose).not.toHaveBeenCalled(); + }); +}); diff --git a/test/OramaVectorService.saveFailedBanner.test.js b/test/OramaVectorService.saveFailedBanner.test.js new file mode 100644 index 0000000..5275b9f --- /dev/null +++ b/test/OramaVectorService.saveFailedBanner.test.js @@ -0,0 +1,166 @@ +/** + * A search-index save that keeps failing for a reason other than a full disk + * (a locked file, an I/O error) tells the user. + * + * Only disk-full raised a banner. Any other failure was logged, retries + * stopped after five tries, and the user never learned that recent index + * changes were not on disk. + */ + +const path = require('path'); +const fsp = require('fs').promises; + +jest.mock('electron', () => ({ app: { getPath: jest.fn() } })); +jest.mock('../src/shared/logger', () => { + const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }; + return { createLogger: () => logger, logger }; +}); +jest.mock('../src/shared/singletonFactory', () => ({ + createSingletonHelpers: () => ({ + getInstance: jest.fn(), + createInstance: jest.fn(), + registerWithContainer: jest.fn(), + resetInstance: jest.fn() + }) +})); +jest.mock('@orama/plugin-data-persistence', () => ({ + persist: jest.fn(async () => '{}'), + restore: jest.fn(async () => { + throw new Error('restore not available in test'); + }) +})); +jest.mock('../src/main/llamaUtils', () => ({ + getEmbeddingModel: jest.fn(() => 'nomic-embed-text-v1.5-Q8_0.gguf'), + loadLlamaConfig: jest.fn().mockResolvedValue({ + selectedEmbeddingModel: 'nomic-embed-text-v1.5-Q8_0.gguf' + }) +})); + +const { app } = require('electron'); +const tracker = require('../src/main/services/DegradedStateTracker'); +const { OramaVectorService } = require('../src/main/services/OramaVectorService'); + +const SAVE_FAILED_CODE = 'orama.save-failed'; +const MAX_RETRIES = 5; +const TEMP_DIR = path.join(__dirname, 'temp-orama-save-failed'); + +describe('OramaVectorService when saving keeps failing', () => { + /** @type {any} */ + let service; + /** @type {any} */ + let writeSpy; + + beforeAll(async () => { + await fsp.mkdir(TEMP_DIR, { recursive: true }); + }); + afterAll(async () => { + await fsp.rm(TEMP_DIR, { recursive: true, force: true }).catch(() => {}); + }); + + beforeEach(async () => { + tracker.getInstance()._reset(); + app.getPath.mockReturnValue(TEMP_DIR); + service = new OramaVectorService(); + await service.initialize(); + }); + afterEach(async () => { + writeSpy?.mockRestore(); + if (service?.shutdown) await service.shutdown(); + }); + + function failWrites(code) { + writeSpy?.mockRestore(); + writeSpy = jest + .spyOn(fsp, 'writeFile') + .mockRejectedValue(Object.assign(new Error(`${code}: write failed`), { code })); + } + + function writesWork() { + writeSpy?.mockRestore(); + writeSpy = null; + } + + async function persistOnce() { + service._persistPending = true; + return service._doPersist(); + } + + const saveFailedFlag = () => + tracker + .getInstance() + .list() + .find((flag) => flag.code === SAVE_FAILED_CODE); + + test('failures short of the retry limit show nothing', async () => { + failWrites('EBUSY'); + for (let i = 0; i < MAX_RETRIES - 1; i++) await persistOnce(); + expect(tracker.getInstance().list()).toEqual([]); + }); + + test('when retries give up, the banner says the index was not saved', async () => { + failWrites('EBUSY'); + for (let i = 0; i < MAX_RETRIES; i++) await persistOnce(); + + const flag = saveFailedFlag(); + expect(flag).toMatchObject({ severity: 'error', dismissed: false }); + expect(flag.message).toMatch(/could not save its search index/i); + expect(flag.message).toMatch(/may be lost/i); + expect(flag.detail).toMatchObject({ errno: 'EBUSY' }); + // Not a full disk: no storage-full banner, no once-a-minute retry. + expect(tracker.isStorageFull()).toBe(false); + expect(service._storageFullRetryTimer).toBeFalsy(); + }); + + test('the next save that works clears it', async () => { + failWrites('EPERM'); + for (let i = 0; i < MAX_RETRIES; i++) await persistOnce(); + expect(saveFailedFlag()).toBeTruthy(); + + writesWork(); + expect(await persistOnce()).toMatchObject({ success: true }); + expect(saveFailedFlag()).toBeUndefined(); + }); + + // Retries stop at the limit, so the flag would never clear if a later + // write did not start saving again. + test('a later write starts saving again, with a fresh round of retries', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + try { + failWrites('EBUSY'); + for (let i = 0; i < MAX_RETRIES; i++) await persistOnce(); + expect(saveFailedFlag()).toBeTruthy(); + expect(service._persistRetryCount).toBe(0); + + writesWork(); + const persisted = jest.spyOn(service, '_doPersist'); + service._schedulePersist(); + jest.advanceTimersByTime(service._persistGapMs()); + expect(persisted).toHaveBeenCalledTimes(1); + await service._currentPersistPromise; + expect(saveFailedFlag()).toBeUndefined(); + } finally { + jest.useRealTimers(); + } + }); + + test('a full disk raises the storage-full banner instead, as before', async () => { + failWrites('ENOSPC'); + for (let i = 0; i < MAX_RETRIES; i++) await persistOnce(); + + expect(tracker.isStorageFull()).toBe(true); + expect(saveFailedFlag()).toBeUndefined(); + expect(service._storageFullRetryTimer).toBeTruthy(); + }); + + // Disk full, then space freed but the file is locked: the storage-full + // banner and its retry already cover it, so no second banner goes up. + test('a lock while the storage-full banner is up does not add a second banner', async () => { + failWrites('ENOSPC'); + await persistOnce(); + failWrites('EBUSY'); + for (let i = 1; i < MAX_RETRIES; i++) await persistOnce(); + + expect(tracker.isStorageFull()).toBe(true); + expect(saveFailedFlag()).toBeUndefined(); + }); +}); diff --git a/test/PatternPersistence.test.js b/test/PatternPersistence.test.js index 2a5e5d2..5642089 100644 --- a/test/PatternPersistence.test.js +++ b/test/PatternPersistence.test.js @@ -61,6 +61,29 @@ describe('PatternPersistence', () => { expect(fs.rename).toHaveBeenCalledTimes(1); }); + it('keeps the pattern key on feedback rows across a load, and accepts rows without one', async () => { + const withKey = { + timestamp: 2, + accepted: true, + file: { name: 'q1.pdf', type: 'pdf' }, + suggestion: { folder: 'Finance' }, + pattern: 'pdf:invoices:finance' + }; + const withoutKey = { + timestamp: 1, + accepted: true, + file: { name: 'old.pdf', type: 'pdf' }, + suggestion: { folder: 'Finance' } + }; + fs.readFile.mockResolvedValueOnce( + JSON.stringify({ patterns: [], feedbackHistory: [withKey, withoutKey], folderUsageStats: [] }) + ); + + const loaded = await new PatternPersistence().load(); + + expect(loaded.feedbackHistory).toEqual([withKey, withoutKey]); + }); + it('awaits deferred throttled save completion', async () => { jest.useFakeTimers(); try { diff --git a/test/SessionMemory.test.js b/test/SessionMemory.test.js index 3d07e79..a1e193f 100644 --- a/test/SessionMemory.test.js +++ b/test/SessionMemory.test.js @@ -108,6 +108,12 @@ describe('SessionMemory', () => { }), addMessage: jest.fn((id, message) => { conversations.get(id).messages.push(message); + }), + deleteAssistantMessagesAfterLastUser: jest.fn((id) => { + const { messages } = conversations.get(id); + while (messages.length > 0 && messages[messages.length - 1].role === 'assistant') { + messages.pop(); + } }) }; } @@ -168,7 +174,58 @@ describe('SessionMemory', () => { ); const roles = store.conversations.get('regen').messages.map((m) => m.role); - expect(roles).toEqual(['user', 'assistant', 'assistant']); + expect(roles.filter((role) => role === 'user')).toEqual(['user']); + }); + + test('regenerate replaces the saved answer instead of adding a second one', async () => { + const store = createFakeStore(); + const memory = new SessionMemory({ chatHistoryStore: store }); + const session = await memory.getSessionMemory('regen-replace'); + await memory.saveMemoryTurn(session, 'Earlier question', { text: 'Earlier answer' }); + await memory.saveMemoryTurn(session, 'Same question', { text: 'First answer' }); + + await memory.saveMemoryTurn( + session, + 'Same question', + { text: 'Regenerated answer' }, + { skipUserMessage: true } + ); + + const rows = store.conversations.get('regen-replace').messages.map((m) => [m.role, m.text]); + expect(rows).toEqual([ + ['user', 'Earlier question'], + ['assistant', 'Earlier answer'], + ['user', 'Same question'], + ['assistant', 'Regenerated answer'] + ]); + }); + + test('a new question keeps the previous answer', async () => { + const store = createFakeStore(); + const memory = new SessionMemory({ chatHistoryStore: store }); + const session = await memory.getSessionMemory('no-delete'); + await memory.saveMemoryTurn(session, 'First question', { text: 'First answer' }); + await memory.saveMemoryTurn(session, 'Second question', { text: 'Second answer' }); + + expect(store.deleteAssistantMessagesAfterLastUser).not.toHaveBeenCalled(); + expect(store.conversations.get('no-delete').messages).toHaveLength(4); + }); + + test('getHistoryText leaves the turn being regenerated out of the prompt history', async () => { + const store = createFakeStore(); + const memory = new SessionMemory({ chatHistoryStore: store }); + const session = await memory.getSessionMemory('regen-history'); + await memory.saveMemoryTurn(session, 'Earlier question', { text: 'Earlier answer' }); + await memory.saveMemoryTurn(session, 'Same question', { text: 'Discarded answer' }); + + const history = await memory.getHistoryText(session, { regeneratedInput: 'Same question' }); + + expect(history).toBe('User: Earlier question\nAssistant: Earlier answer'); + // A different question is not a regenerate: the whole history is kept. + expect(await memory.getHistoryText(session, { regeneratedInput: 'Another one' })).toContain( + 'Assistant: Discarded answer' + ); + expect(await memory.getHistoryText(session)).toContain('Assistant: Discarded answer'); }); test('follow-ups keep their history when a turn could not be saved', async () => { diff --git a/test/SmartFolderWatcher.coverage.test.js b/test/SmartFolderWatcher.coverage.test.js index 9a9fe1f..4720185 100644 --- a/test/SmartFolderWatcher.coverage.test.js +++ b/test/SmartFolderWatcher.coverage.test.js @@ -943,7 +943,7 @@ describe('SmartFolderWatcher', () => { expect(files).toEqual([path.join('/docs', 'report.pdf')]); }); - test('a queued item that matches a rule is dropped before analysis, a hand-picked reanalyze is not', async () => { + test('a queued item that matches a rule is dropped before analysis', async () => { mockDeps.settingsService.getAll.mockReturnValue({ downloadsTriageExclusions: ['*.psd'] }); watcher._embedAnalyzedFile = jest.fn().mockResolvedValue(); @@ -955,15 +955,11 @@ describe('SmartFolderWatcher', () => { }); await watcher._analyzeFile({ filePath: '/images/mockup.psd', - eventType: 'reanalyze', - bulk: true + eventType: 'reanalyze' }); expect(fs.stat).not.toHaveBeenCalledWith('/images/mockup.psd'); expect(mockDeps.analyzeImageFile).not.toHaveBeenCalled(); expect(watcher.processingFiles.size).toBe(0); - - await watcher._analyzeFile({ filePath: '/images/mockup.psd', eventType: 'reanalyze' }); - expect(fs.stat).toHaveBeenCalledWith('/images/mockup.psd'); }); }); @@ -1141,10 +1137,8 @@ describe('SmartFolderWatcher', () => { /** * The F-223 leg of "reanalyze all" adds every file the app ever analyzed * that lives OUTSIDE the watched folders (Discover-analyzed files, files - * organized into non-smart folders). It used to push those items with no - * `bulk` flag, which is exactly the shape _analyzeFile's gate exempts, so - * the exclusion rules were skipped for precisely those files — and the - * preview still counted them. + * organized into non-smart folders). The exclusion rules used to be + * skipped for precisely those files — and the preview still counted them. */ beforeEach(() => { watcher.watchedPaths = new Set(['/docs']); @@ -1179,7 +1173,7 @@ describe('SmartFolderWatcher', () => { expect(preview.scanned).toBe(1); }); - test('outside-watched items are flagged bulk, so a rule added after queueing still stops them', async () => { + test('a rule added after queueing still stops an outside-watched item', async () => { // No rules at collection time: both files are queued. mockDeps.settingsService.getAll.mockReturnValue({}); await watcher.forceReanalyzeAll({ applyNaming: false }); @@ -1189,7 +1183,6 @@ describe('SmartFolderWatcher', () => { '/elsewhere/mockup.psd', '/elsewhere/report.pdf' ]); - expect(queued.every((item) => item.bulk === true)).toBe(true); // The user adds the rule while the run is still draining. mockDeps.settingsService.getAll.mockReturnValue({ downloadsTriageExclusions: ['*.psd'] }); diff --git a/test/SmartFolderWatcher.migration.test.js b/test/SmartFolderWatcher.migration.test.js index 8be50f1..f40476b 100644 --- a/test/SmartFolderWatcher.migration.test.js +++ b/test/SmartFolderWatcher.migration.test.js @@ -361,8 +361,7 @@ describe('SmartFolderWatcher - Migration Tests', () => { filePath: '/file1.pdf', mtime: 1000, eventType: 'reanalyze', - applyNaming: true, - bulk: true + applyNaming: true }); watcher._enqueueAnalysisItem({ filePath: '/file1.pdf', mtime: 2000, eventType: 'change' }); @@ -371,8 +370,7 @@ describe('SmartFolderWatcher - Migration Tests', () => { expect.objectContaining({ mtime: 2000, eventType: 'reanalyze', - applyNaming: true, - bulk: true + applyNaming: true }) ); }); diff --git a/test/SmartFolderWatcher.queuePersistence.test.js b/test/SmartFolderWatcher.queuePersistence.test.js index 88f3262..f1a787c 100644 --- a/test/SmartFolderWatcher.queuePersistence.test.js +++ b/test/SmartFolderWatcher.queuePersistence.test.js @@ -193,16 +193,8 @@ jest.mock('../src/main/analysis/embeddingModelOptions', () => ({ resolveEmbeddingModelOptions: jest.fn().mockResolvedValue({}) })); -// The real WatcherQueueStore is loaded only for its field filter -// (toPersisted); its durable write pulls in securityConfig, which the -// trimmed constants mock above cannot satisfy. -jest.mock('../src/shared/atomicFileOperations', () => ({ - durableWriteJson: jest.fn().mockResolvedValue(undefined) -})); - const fs = require('fs').promises; const SmartFolderWatcher = require('../src/main/services/SmartFolderWatcher'); -const { toPersisted } = require('../src/main/services/watcher/WatcherQueueStore'); describe('SmartFolderWatcher queue persistence (K-07)', () => { const WATCH_ROOT = 'C:\\w'; @@ -304,7 +296,8 @@ describe('SmartFolderWatcher queue persistence (K-07)', () => { test('restoreQueue drops items that a rule added since the queue was written now excludes', async () => { const queueStore = makeStore([ { filePath: 'C:\\w\\keep.txt', mtime: 1, eventType: 'add', queuedAt: 1 }, - { filePath: 'C:\\w\\mockup.psd', mtime: 1, eventType: 'add', queuedAt: 1 } + { filePath: 'C:\\w\\mockup.psd', mtime: 1, eventType: 'add', queuedAt: 1 }, + { filePath: 'C:\\w\\poster.psd', mtime: 1, eventType: 'reanalyze', queuedAt: 1 } ]); baseOptions.settingsService.getAll.mockReturnValue({ downloadsTriageExclusions: ['*.psd'] }); const watcher = makeWatcher(queueStore); @@ -316,38 +309,6 @@ describe('SmartFolderWatcher queue persistence (K-07)', () => { expect(watcher.analysisQueue.map((i) => i.filePath)).toEqual(['C:\\w\\keep.txt']); }); - test('restoreQueue keeps the hand-picked single-file reanalyze the analysis gate exempts', async () => { - // _analyzeFile deliberately lets one hand-picked `reanalyze` through the - // exclusion rules — an explicit user request beats a standing rule. The - // restore path dropped it anyway, so the request died silently across the - // very restart it was waiting on. Bulk reanalysis is NOT exempt. - // - // The rows go through the real store's field filter, not the verbatim - // mock: the bulk flag only protects the restart if it survives the trip - // to disk, and a mock that hands items back unchanged cannot tell. - const queueStore = makeStore( - [ - { filePath: 'C:\\w\\picked.psd', mtime: 1, eventType: 'reanalyze', queuedAt: 1 }, - { - filePath: 'C:\\w\\bulk.psd', - mtime: 1, - eventType: 'reanalyze', - bulk: true, - queuedAt: 1 - }, - { filePath: 'C:\\w\\live.psd', mtime: 1, eventType: 'add', queuedAt: 1 } - ].map(toPersisted) - ); - baseOptions.settingsService.getAll.mockReturnValue({ downloadsTriageExclusions: ['*.psd'] }); - const watcher = makeWatcher(queueStore); - watcher._processQueue = jest.fn().mockResolvedValue(undefined); - - const restored = await watcher._restoreQueue(); - - expect(restored).toBe(1); - expect(watcher.analysisQueue.map((i) => i.filePath)).toEqual(['C:\\w\\picked.psd']); - }); - test('works with no store injected', async () => { const watcher = new SmartFolderWatcher({ ...baseOptions }); watcher._processQueue = jest.fn().mockResolvedValue(undefined); diff --git a/test/VisionService.test.js b/test/VisionService.test.js index 9aeec56..f41ccd6 100644 --- a/test/VisionService.test.js +++ b/test/VisionService.test.js @@ -375,6 +375,93 @@ describe('VisionService', () => { expect(service._isReady).toBe(true); }); + describe('a model load that began after the caller checked for room', () => { + // The caller frees memory, then the runtime binary is looked up, then + // the memory is reserved. A text model that began to load in between + // was not seen: the reservation was made without asking, and both + // loaded onto a GPU with room for one. + const { ModelMemoryManager } = require('../src/main/services/ModelMemoryManager'); + + function realManager() { + const real = new ModelMemoryManager({ _models: {}, _contexts: {}, _loadModel: jest.fn() }); + jest.spyOn(real, '_refreshMemoryBudget').mockImplementation(() => {}); + real._maxMemoryUsage = 4.8 * GiB; + service._memoryManagerOverride = real; + return real; + } + + function binaryLookupInProgress() { + let finish; + // The first lookup is held open; _spawnServer's gets the cached path. + service._ensureBinary = jest + .fn() + .mockResolvedValue('/mock/llama-server') + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = () => resolve('/mock/llama-server'); + }) + ); + return () => finish(); + } + + test('the runtime does not start beside it', async () => { + const real = realManager(); + const { spawn } = require('child_process'); + const binaryFound = binaryLookupInProgress(); + + const starting = service._startServer({ modelPath: '/models/vision.gguf' }); + real.reserveLoad('text', 5.8 * GiB); + binaryFound(); + + await expect(starting).rejects.toMatchObject({ + code: 'MODEL_MEMORY_BUDGET_EXCEEDED', + message: expect.stringContaining('memory budget exceeded') + }); + expect(spawn).not.toHaveBeenCalled(); + expect(real.getMemoryStatus().pendingLoads).toEqual(['text']); + }); + + test('with nothing else loading, the runtime reserves its memory and starts', async () => { + const real = realManager(); + const { spawn } = require('child_process'); + spawn.mockReturnValue(createFakeChild()); + service._waitForHealth = jest.fn().mockResolvedValue(undefined); + const binaryFound = binaryLookupInProgress(); + + const starting = service._startServer({ modelPath: '/models/vision.gguf' }); + binaryFound(); + await starting; + + expect(spawn).toHaveBeenCalledTimes(1); + expect(real.getMemoryStatus().loadedModels).toEqual(['vision-runtime']); + }); + + test('a restart is not refused over the runtime it replaces', async () => { + const real = realManager(); + real.registerExternalConsumer({ + type: 'vision-runtime', + sizeBytes: 5 * GiB, + dispose: jest.fn() + }); + + const endReservation = await service._reserveRuntimeMemory({ modelPath: '/m.gguf' }); + + expect(real.getMemoryStatus().pendingLoads).toEqual(['vision-runtime']); + endReservation(); + }); + + test('an estimate that fails still lets the runtime start', async () => { + const real = realManager(); + real.reserveLoad('text', 5.8 * GiB); + service.estimateRuntimeMemoryBytes = jest.fn().mockRejectedValue(new Error('stat failed')); + + await expect(service._reserveRuntimeMemory({ modelPath: '/m.gguf' })).resolves.toEqual( + expect.any(Function) + ); + }); + }); + test('a child exit unregisters; shutdown unregisters', async () => { const child = await startReadyServer(); child.emit('exit', 1, null); @@ -766,6 +853,20 @@ describe('VisionService', () => { expect(service._startupFailureAt).toBeGreaterThan(0); }); + test('a start refused for memory is not a startup failure', async () => { + // Another model holds the memory for now; the runtime is not broken, + // and the next image must not be turned away by the cooldown. + const refused = Object.assign(new Error('memory budget exceeded'), { + code: 'MODEL_MEMORY_BUDGET_EXCEEDED' + }); + service._ensureServer = jest.fn().mockRejectedValue(refused); + + await expect(service.analyzeImage({ config: validConfig })).rejects.toBe(refused); + + expect(service._startupFailureAt).toBe(0); + expect(service._startupFailureStreak).toBe(0); + }); + test('an exited-after-startup failure sets the latch', async () => { service._ensureServer = jest.fn().mockResolvedValue(undefined); // _process/_port stay null -> "server exited after startup" branch diff --git a/test/WatcherQueueStore.test.js b/test/WatcherQueueStore.test.js index 7bbc33a..91f7d63 100644 --- a/test/WatcherQueueStore.test.js +++ b/test/WatcherQueueStore.test.js @@ -103,23 +103,6 @@ describe('WatcherQueueStore', () => { ]); }); - // forceReanalyzeAll marks every item `bulk: true` and the watcher's - // exclusion gates read that flag: a hand-picked single-file reanalyze is - // exempt from the user's exclusion rules, a bulk one is not. If the flag - // is dropped on the way to disk, every row of an interrupted bulk run comes - // back exempt after the restart and excluded files get an LLM pass. - test('round-trips the bulk flag so a restored bulk reanalyze is still not exempt', async () => { - const store = createWatcherQueueStore(path.join(dir, 'queue.json')); - await store.save([ - { filePath: 'C:\\w\\bulk.psd', mtime: 1, eventType: 'reanalyze', bulk: true, queuedAt: 2 }, - { filePath: 'C:\\w\\picked.psd', mtime: 1, eventType: 'reanalyze', queuedAt: 3 } - ]); - expect(await store.load()).toEqual([ - { filePath: 'C:\\w\\bulk.psd', mtime: 1, eventType: 'reanalyze', bulk: true, queuedAt: 2 }, - { filePath: 'C:\\w\\picked.psd', mtime: 1, eventType: 'reanalyze', queuedAt: 3 } - ]); - }); - test('load returns [] when the file is missing or corrupt', async () => { const p = path.join(dir, 'queue.json'); const store = createWatcherQueueStore(p); diff --git a/test/a11y/uiComponents.a11y.test.js b/test/a11y/uiComponents.a11y.test.js index 94f2de3..73c9691 100644 --- a/test/a11y/uiComponents.a11y.test.js +++ b/test/a11y/uiComponents.a11y.test.js @@ -7,7 +7,7 @@ * sibling primitives), so axe inspects the real DOM the app ships. Fully-mocked * phase screens (Discover/Organize) are intentionally NOT audited here — mocking * the ui layer away makes an axe pass a vanity check. Whole-screen audits live in - * the Playwright E2E pass (test/e2e/accessibility.spec.js) where the real layout, + * the Playwright E2E pass (test/e2e/app/accessibility.spec.js) where the real layout, * focus order, and color contrast can be measured. * * jsdom limitation: axe's color-contrast rule is reported "incomplete" (no layout @@ -33,7 +33,7 @@ expect.extend(toHaveNoViolations); // Page-composition rules (region/landmark) only make sense for a whole page. // These are isolated component fragments with no surrounding
, so those // rules would always fire — they're enforced instead by the full-page E2E pass -// (test/e2e/accessibility.spec.js). Everything else (WCAG name/role/label +// (test/e2e/app/accessibility.spec.js). Everything else (WCAG name/role/label // conformance) stays on. const FRAGMENT_AXE_OPTIONS = { rules: { diff --git a/test/autoOrganize-batchProcessor.edge.test.js b/test/autoOrganize-batchProcessor.edge.test.js index a916cec..3ee5c9c 100644 --- a/test/autoOrganize-batchProcessor.edge.test.js +++ b/test/autoOrganize-batchProcessor.edge.test.js @@ -44,7 +44,6 @@ describe('autoOrganize batchProcessor edge cases', () => { preserveNames: false }, results, - { recordFeedback: jest.fn().mockResolvedValue() }, [ { name: 'FolderA', path: '/dest/A', isDefault: true }, { name: 'FolderB', path: '/dest/B' } @@ -71,7 +70,6 @@ describe('autoOrganize batchProcessor edge cases', () => { preserveNames: false }, results, - { recordFeedback: jest.fn().mockResolvedValue() }, [] ); expect(results.operations).toHaveLength(0); diff --git a/test/autoOrganize-batchProcessor.test.js b/test/autoOrganize-batchProcessor.test.js index f777176..948954d 100644 --- a/test/autoOrganize-batchProcessor.test.js +++ b/test/autoOrganize-batchProcessor.test.js @@ -104,14 +104,7 @@ describe('AutoOrganize Batch Processor', () => { failed: [] }; - await processBatchResults( - batchSuggestions, - files, - options, - results, - mockSuggestionService, - smartFolders - ); + processBatchResults(batchSuggestions, files, options, results, smartFolders); expect(results.organized).toHaveLength(1); expect(results.organized[0].method).toBe('batch-automatic'); @@ -149,14 +142,7 @@ describe('AutoOrganize Batch Processor', () => { failed: [] }; - await processBatchResults( - batchSuggestions, - files, - options, - results, - mockSuggestionService, - smartFolders - ); + processBatchResults(batchSuggestions, files, options, results, smartFolders); expect(results.organized).toHaveLength(0); expect(results.needsReview).toHaveLength(1); @@ -192,14 +178,7 @@ describe('AutoOrganize Batch Processor', () => { failed: [] }; - await processBatchResults( - batchSuggestions, - files, - options, - results, - mockSuggestionService, - smartFolders - ); + processBatchResults(batchSuggestions, files, options, results, smartFolders); // D2: held for review with the engine's pick; never moved to the default folder. expect(results.organized).toHaveLength(0); @@ -238,14 +217,7 @@ describe('AutoOrganize Batch Processor', () => { failed: [] }; - await processBatchResults( - batchSuggestions, - files, - options, - results, - mockSuggestionService, - smartFolders - ); + processBatchResults(batchSuggestions, files, options, results, smartFolders); expect(results.organized).toHaveLength(1); expect(results.organized[0].method).toBe('batch-automatic'); @@ -284,14 +256,7 @@ describe('AutoOrganize Batch Processor', () => { failed: [] }; - await processBatchResults( - batchSuggestions, - files, - options, - results, - mockSuggestionService, - smartFolders - ); + processBatchResults(batchSuggestions, files, options, results, smartFolders); // D2: low confidence files are held for review even when a default folder exists. expect(results.organized).toHaveLength(0); @@ -330,14 +295,7 @@ describe('AutoOrganize Batch Processor', () => { failed: [] }; - await processBatchResults( - batchSuggestions, - files, - options, - results, - mockSuggestionService, - smartFolders - ); + processBatchResults(batchSuggestions, files, options, results, smartFolders); // D2: the rule-based folder is offered for review instead of moved into. expect(results.organized).toHaveLength(0); @@ -348,7 +306,7 @@ describe('AutoOrganize Batch Processor', () => { ); }); - test('records feedback for high confidence suggestions', async () => { + test('plans a high confidence move without recording feedback', async () => { const batchSuggestions = { groups: [ { @@ -379,18 +337,11 @@ describe('AutoOrganize Batch Processor', () => { failed: [] }; - await processBatchResults( - batchSuggestions, - files, - options, - results, - mockSuggestionService, - smartFolders - ); + processBatchResults(batchSuggestions, files, options, results, smartFolders); - // Feedback is recorded asynchronously (void promise) - await new Promise((r) => setTimeout(r, 10)); - expect(mockSuggestionService.recordFeedback).toHaveBeenCalled(); + // Learning belongs to the completed move (batchOrganizeHandler) + expect(results.operations).toHaveLength(1); + expect(mockSuggestionService.recordFeedback).not.toHaveBeenCalled(); }); }); @@ -546,7 +497,7 @@ describe('AutoOrganize Batch Processor', () => { expect(result.failed[0].error).toBe('Build failed'); }); - test('records feedback for processed files', async () => { + test('plans processed files without recording feedback', async () => { mockSuggestionService.getBatchSuggestions.mockResolvedValueOnce({ success: true, groups: [ @@ -573,9 +524,16 @@ describe('AutoOrganize Batch Processor', () => { const thresholds = { autoApprove: 0.9 }; const smartFolders = [{ name: 'Documents', path: '/docs', isDefault: true }]; - await batchOrganize(files, smartFolders, options, mockSuggestionService, thresholds); + const result = await batchOrganize( + files, + smartFolders, + options, + mockSuggestionService, + thresholds + ); - expect(mockSuggestionService.recordFeedback).toHaveBeenCalled(); + expect(result.operations).toHaveLength(1); + expect(mockSuggestionService.recordFeedback).not.toHaveBeenCalled(); }); }); }); diff --git a/test/autoOrganize-fileProcessor.test.js b/test/autoOrganize-fileProcessor.test.js index 2598aed..9c88eed 100644 --- a/test/autoOrganize-fileProcessor.test.js +++ b/test/autoOrganize-fileProcessor.test.js @@ -169,7 +169,8 @@ describe('AutoOrganize File Processor', () => { expect(results.organized).toHaveLength(1); expect(results.organized[0].method).toBe('automatic'); - expect(mockSuggestionService.recordFeedback).toHaveBeenCalled(); + // Learning belongs to the completed move, not the plan + expect(mockSuggestionService.recordFeedback).not.toHaveBeenCalled(); }); test('adds files to needsReview for medium confidence', async () => { diff --git a/test/autoOrganize.learnsOnceAfterMove.test.js b/test/autoOrganize.learnsOnceAfterMove.test.js new file mode 100644 index 0000000..757c2ad --- /dev/null +++ b/test/autoOrganize.learnsOnceAfterMove.test.js @@ -0,0 +1,168 @@ +/** + * Auto-organize learns a placement once, and only from a move that happened. + * + * The planners (organizeFiles / batchOrganize) used to record accepted + * feedback as they chose a destination. The batch organize handler records the + * same placement again once the file has moved, so a planned and executed file + * counted twice, a plan nobody executed still taught the matcher, and undo + * (which retracts one record per reverted move) left the other behind. + * + * These run the real planners, PatternMatcher and LearningFeedbackService; the + * move itself is stood in for by the call the handler makes after it. + */ + +jest.mock('../src/shared/logger', () => { + const l = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }; + return { logger: { ...l, setContext: jest.fn() }, createLogger: () => l }; +}); + +jest.mock('electron', () => ({ + app: { getPath: jest.fn().mockReturnValue('/mock/documents') } +})); + +const path = require('path'); +const { PatternMatcher } = require('../src/main/services/organization/patternMatcher'); +const { + LearningFeedbackService, + FEEDBACK_SOURCES +} = require('../src/main/services/organization/learningFeedback'); +const AutoOrganizeServiceCore = require('../src/main/services/autoOrganize/AutoOrganizeServiceCore'); + +const finance = { id: 'f1', name: 'Finance', path: path.resolve('/library/Finance') }; +const smartFolders = [finance]; +const invoice = { + name: 'invoice.pdf', + path: path.resolve('/inbox/invoice.pdf'), + extension: '.pdf', + analysis: { category: 'Finance' } +}; +const financeSuggestion = { folder: 'Finance', path: finance.path }; + +/** + * @param {{ batch?: boolean }} [options] - batch: false makes the batch + * suggestion call come back empty, which sends files down the per-file path + */ +function setup({ batch = true } = {}) { + const matcher = new PatternMatcher(); + const suggestionService = { + patternMatcher: matcher, + getBatchSuggestions: jest.fn(async (files) => + batch + ? { + success: true, + groups: [ + { + folder: 'Finance', + path: finance.path, + confidence: 0.9, + files: files.map((/** @type {any} */ f) => ({ + ...f, + suggestion: financeSuggestion, + confidence: 0.9 + })) + } + ] + } + : { success: false, groups: [] } + ), + getSuggestionsForFile: jest.fn(async () => ({ + success: true, + primary: financeSuggestion, + confidence: 0.9 + })), + recordFeedback: jest.fn(async (file, suggestion, accepted) => + matcher.recordFeedback(file, suggestion, accepted) + ), + retractFeedback: jest.fn(async (file, suggestion) => matcher.retractFeedback(file, suggestion)) + }; + const service = new AutoOrganizeServiceCore({ + suggestionService, + undoRedoService: { recordAction: jest.fn() } + }); + const learning = new LearningFeedbackService({ + suggestionService, + getSmartFolders: () => smartFolders + }); + return { matcher, service, learning }; +} + +/** What the matcher has learned, in the terms the bug showed up in. */ +function learned(/** @type {PatternMatcher} */ matcher) { + return { + patterns: [...matcher.userPatterns.values()].map((p) => p.count), + history: matcher.feedbackHistory.length, + folderUsage: matcher.getFolderUsage(finance.id) + }; +} + +const NOTHING = { patterns: [], history: 0, folderUsage: 0 }; + +/** The handler's record for a completed move (batchOrganizeHandler). */ +function recordCompletedMove( + /** @type {LearningFeedbackService} */ learning, + /** @type {{ source: string, destination: string }} */ op +) { + return learning.recordFileMove( + op.source, + op.destination, + { category: 'Finance' }, + FEEDBACK_SOURCES.AUTO_ORGANIZE_CONFIRMED + ); +} + +describe('auto-organize learning happens once, after the move', () => { + describe.each([ + ['organizeFiles (batch suggestions)', { batch: true }], + ['organizeFiles (per-file fallback)', { batch: false }] + ])('%s', (_label, options) => { + test('a plan that is never executed, or whose move fails, teaches nothing', async () => { + const { matcher, service } = setup(options); + + const plan = await service.organizeFiles([invoice], smartFolders, { + confidenceThreshold: 0.75 + }); + + expect(plan.operations).toHaveLength(1); + expect(learned(matcher)).toEqual(NOTHING); + }); + + test('a planned file that is then moved counts once', async () => { + const { matcher, service, learning } = setup(options); + const plan = await service.organizeFiles([invoice], smartFolders, { + confidenceThreshold: 0.75 + }); + + expect(await recordCompletedMove(learning, plan.operations[0])).toBe(true); + + expect(learned(matcher)).toEqual({ patterns: [1], history: 1, folderUsage: 1 }); + }); + + test('undo returns learning to where it was before the move', async () => { + const { matcher, service, learning } = setup(options); + const plan = await service.organizeFiles([invoice], smartFolders, { + confidenceThreshold: 0.75 + }); + await recordCompletedMove(learning, plan.operations[0]); + + await learning.retractFileFeedback(plan.operations[0].destination); + + expect(learned(matcher)).toEqual(NOTHING); + }); + }); + + test('batchOrganize plans without teaching; the move counts once and undo clears it', async () => { + const { matcher, service, learning } = setup(); + + const plan = await service.batchOrganize([invoice], smartFolders, { + confidenceThreshold: 0.75 + }); + expect(plan.operations).toHaveLength(1); + expect(learned(matcher)).toEqual(NOTHING); + + await recordCompletedMove(learning, plan.operations[0]); + expect(learned(matcher)).toEqual({ patterns: [1], history: 1, folderUsage: 1 }); + + await learning.retractFileFeedback(plan.operations[0].destination); + expect(learned(matcher)).toEqual(NOTHING); + }); +}); diff --git a/test/e2e/README.md b/test/e2e/README.md index 540d24e..2c171d9 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -4,20 +4,127 @@ End-to-end tests for the StratoSort Electron application using Playwright. ## Overview -This test suite covers full user flows through the application UI, including: - -- **App Launch** (`app-launch.spec.js`): Verifies the application starts correctly -- **Navigation** (`navigation.spec.js`): Tests phase transitions and navigation -- **File Import** (`file-import.spec.js`): Tests file selection and import flows -- **Analysis Flow** (`analysis-flow.spec.js`): Tests document analysis with the in-process AI engine -- **Error Handling** (`error-handling.spec.js`): Verifies graceful error handling +The specs drive the real built app through its UI and its preload API (`window.electronAPI`). They +are grouped by product area, one folder per area, with shared code in `helpers/`. Playwright picks +up every `*.spec.js` under `test/e2e/`, so a new spec only needs to be put in the right folder. + +| Folder | What it covers | Specs | +| ------------ | --------------------------------------------------------------------------- | ----- | +| `app/` | App shell: launch, window, menus, navigation, keyboard, toasts, persistence | 10 | +| `setup/` | Setup phase: smart folders | 2 | +| `discover/` | Discover phase: importing files and analyzing them | 9 | +| `organize/` | Organize phase and undo/redo | 2 | +| `search/` | Knowledge OS modal: search, chat, graph, Atlas | 4 | +| `settings/` | Settings panel: backup, models, AI providers, MCP | 6 | +| `workflows/` | Journeys that cross several phases, run on seeded data (no model needed) | 2 | +| `live-llm/` | `@live-llm` specs that need real local models | 3 | +| `manual/` | Opt-in runs that are never part of CI: soak test and a QA repro session | 2 | +| `helpers/` | Launch helpers, page objects, fixtures, global setup/teardown (not specs) | - | + +### Spec index + +**CI smoke** marks the eight specs that the `e2e-smoke` job in `.github/workflows/ci.yml` runs on +pushes (Ubuntu and Windows). Everything else runs only when you run it. Unless the **Needs** column +says otherwise, a spec needs no models and no network: every launch gets an isolated profile seeded +to skip the model download. + +#### `app/` - app shell + +| Spec | Purpose | CI smoke | Needs | +| ----------------------------- | ------------------------------------------------------------------------------------- | -------- | ----------------------------------------------- | +| `app-launch.spec.js` | The app starts, the window opens, the UI renders and the core APIs exist | yes | | +| `navigation.spec.js` | Moving between phases, and the rules for when a phase is open | | | +| `window-state.spec.js` | Window size and position, fullscreen, DevTools, the 800x600 minimum | | `--headed` for the fullscreen test (else skips) | +| `menu-shortcuts.spec.js` | Menu actions and their keyboard shortcuts (select files, select folder, search) | | | +| `keyboard-navigation.spec.js` | Tab order, Enter/Space/Escape, global shortcuts, focus rings | | | +| `notifications.spec.js` | Toast notifications: types, auto-dismiss, manual dismiss, stacking, ARIA | | | +| `error-handling.spec.js` | Invalid input is rejected and errors do not crash the app | | | +| `session-persistence.spec.js` | Smart folders, settings, undo state and history survive an app restart | yes | | +| `theme-persistence.spec.js` | The chosen theme is restored on reopen with no flash of the default theme | | | +| `accessibility.spec.js` | axe audit of each main screen in the dark and light themes, plus the first-run wizard | yes | `A11Y_VERBOSE=1` (optional) logs minor findings | + +#### `setup/` - smart folders + +| Spec | Purpose | CI smoke | Needs | +| -------------------------- | --------------------------------------------------------------- | -------- | ----- | +| `smart-folders.spec.js` | Listing, creating, editing, deleting and scanning smart folders | | | +| `smart-folder-add.spec.js` | "Add Smart Folder" really creates the folder on disk | | | + +#### `discover/` - import and analysis + +| Spec | Purpose | CI smoke | Needs | +| -------------------------------- | ------------------------------------------------------------------------------- | -------- | -------------------------------------------------- | +| `file-import.spec.js` | File and folder selection and the file list in Discover | | | +| `drag-drop.spec.js` | The drop zone, the file-processing API behind it, and file type filtering | | | +| `last-browsed-path.spec.js` | File dialogs reopen at the last folder used | | | +| `naming-strategy.spec.js` | Naming convention controls, preview and persistence | | | +| `analysis-flow.spec.js` | The analysis API and the Discover analyze flow | | One test skips without `STRATOSORT_E2E_MODELS_DIR` | +| `analysis-history.spec.js` | Analysis history: read, search, statistics, export, clear | | | +| `vision-ocr-analysis.spec.js` | Image and PDF analysis API surface and file type support | | | +| `whisper-variant-loader.spec.js` | Guards the bundled Whisper loader (audio analysis) against a webpack regression | yes | Passes with or without the Whisper model | +| `downloads-triage-api.spec.js` | The Downloads triage API is exposed to the renderer | | | + +#### `organize/` - organize and undo + +| Spec | Purpose | CI smoke | Needs | +| ------------------------ | ------------------------------------------------------------------- | -------- | ----- | +| `organize-phase.spec.js` | Organize phase: file list, selection, toolbar, modals, organize API | yes | | +| `undo-redo.spec.js` | Undo/redo API, state, shortcuts, menu items and history | yes | | + +#### `search/` - Knowledge OS (search, chat, graph, Atlas) + +| Spec | Purpose | CI smoke | Needs | +| ------------------------- | ----------------------------------------------------------------------------- | -------- | ----- | +| `search-modal.spec.js` | Search modal: open/close, modes, results, bulk actions, graph tab, duplicates | yes | | +| `knowledge-graph.spec.js` | Relate tab: graph controls, clusters, graph API | | | +| `chat-understand.spec.js` | Understand tab: chat API surface and chat UI | | | +| `atlas-tab.spec.js` | Atlas tab loads under the strict CSP with no eval errors and passes axe | | | + +#### `settings/` - settings panel + +| Spec | Purpose | CI smoke | Needs | +| -------------------------------------- | ----------------------------------------------------------------------- | -------- | ----- | +| `settings-backup-restore.spec.js` | Settings backup, export, import and restore round trip | yes | | +| `settings-subcategories-smoke.spec.js` | Each settings section opens and its switches save | | | +| `model-switching.spec.js` | Changing the text model in Settings and keeping the choice | | | +| `ai-provider.spec.js` | Cloud AI provider keys, default provider and routing (no network calls) | | | +| `cloud-consent-theme.spec.js` | The Cloud AI consent dialog is readable in every theme | | | +| `mcp-onboarding.spec.js` | Enabling the MCP server in Settings and testing the connection | | | + +#### `workflows/` - cross-phase journeys on seeded data + +| Spec | Purpose | CI smoke | Needs | +| ----------------------- | ------------------------------------------------------------------------- | -------- | ----- | +| `beta-workflow.spec.js` | The Beta Tester Guide path: Setup, Discover, Organize, Search, Settings | | | +| `full-pipeline.spec.js` | Analysis results through Organize to completion, and smart folder routing | | | + +#### `live-llm/` - real local models + +All three are tagged `@live-llm` and run with `npm run test:e2e:live-llm`. Set +`STRATOSORT_E2E_MODELS_DIR` to a folder of `.gguf` models first; without it they skip. + +| Spec | Purpose | CI smoke | Needs | +| ------------------------- | ----------------------------------------------------------------------- | -------- | ------------------------------------------ | +| `full-journey.spec.js` | One file through import, analysis, organize and search on the real APIs | | `STRATOSORT_E2E_MODELS_DIR` (text model) | +| `live-llm-chat.spec.js` | Analyze a document, then stream a grounded chat answer | | `STRATOSORT_E2E_MODELS_DIR` (text model) | +| `live-llm-vision.spec.js` | Vision model analysis of a PNG fixture | | `STRATOSORT_E2E_MODELS_DIR` (vision model) | + +#### `manual/` - opt-in, never in CI + +Both skip themselves in a plain `npm run test:e2e` run when their requirement is missing. + +| Spec | Purpose | CI smoke | Needs | +| -------------------------- | -------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------- | +| `soak.spec.js` | 10-15 minute stability loop with memory sampling | | `SOAK=1` (use `npm run test:soak`); optional `SOAK_MINUTES` | +| `qa-runtime-repro.spec.js` | QA repro session for backlog items (settings, chat streaming, graph edges) | | Windows, with the real models in `%APPDATA%\StratoSort Stack\models` | ## Prerequisites 1. **Node.js 24.11+** - Required (`package.json` engines; `.nvmrc` pins `24.22.1`) -2. **Build the renderer** - Run `npm run build:dev` before testing -3. **Models downloaded (optional)** - Required for AI analysis tests, but tests handle missing - models gracefully +2. **Build the renderer** - Run `npm run build:dev` before testing. Global setup rebuilds the main + and preload bundles (`npm run build:main:dev`) itself when they are missing or stale. +3. **Models (optional)** - Only the `live-llm/` specs, one `analysis-flow` test and + `manual/qa-runtime-repro.spec.js` use real models. They skip when the models are missing. ## Running Tests @@ -38,22 +145,31 @@ npm run test:e2e:ui # View test report npm run test:e2e:report + +# Run the @live-llm specs (set STRATOSORT_E2E_MODELS_DIR first) +npm run test:e2e:live-llm + +# Run the soak test (about 12 minutes) +npm run test:soak ``` ### Running Specific Tests ```bash -# Run only app launch tests -npx playwright test --grep "App Launch" - -# Run only navigation tests -npx playwright test --grep "Navigation" +# Run one folder +npx playwright test test/e2e/search # Run a specific test file -npx playwright test test/e2e/app-launch.spec.js +npx playwright test test/e2e/app/app-launch.spec.js + +# Run by file name only (matches in any folder) +npx playwright test app-launch + +# Run by describe or test title +npx playwright test --grep "App Launch" -# Run tests with specific tag -npx playwright test --grep "@smoke" +# Run the CI smoke set locally (same specs, same order as ci.yml) +npx playwright test test/e2e/app/app-launch.spec.js test/e2e/discover/whisper-variant-loader.spec.js test/e2e/app/session-persistence.spec.js test/e2e/organize/undo-redo.spec.js test/e2e/organize/organize-phase.spec.js test/e2e/search/search-modal.spec.js test/e2e/settings/settings-backup-restore.spec.js test/e2e/app/accessibility.spec.js --reporter=list ``` ### Debug Options @@ -64,9 +180,6 @@ npx playwright test --trace on # Run with video recording npx playwright test --video on - -# Run with slow motion (500ms between actions) -npx playwright test --slow-mo 500 ``` ## Test Structure @@ -74,44 +187,45 @@ npx playwright test --slow-mo 500 ``` test/e2e/ ├── helpers/ -│ ├── electronApp.js # Electron app launch/control utilities -│ ├── testFixtures.js # Test data and fixtures -│ ├── pageObjects.js # Page object models for UI -│ ├── globalSetup.js # Runs once before all tests -│ └── globalTeardown.js # Runs once after all tests -├── app-launch.spec.js # App startup tests -├── analysis-flow.spec.js # Analysis workflow tests -├── analysis-history.spec.js -├── drag-drop.spec.js -├── error-handling.spec.js # Error handling tests -├── file-import.spec.js # File import tests -├── keyboard-navigation.spec.js -├── last-browsed-path.spec.js -├── menu-shortcuts.spec.js -├── naming-strategy.spec.js -├── navigation.spec.js # Navigation tests -├── notifications.spec.js -├── organize-phase.spec.js -├── search-modal.spec.js -├── smart-folder-add.spec.js -├── smart-folders.spec.js -├── soak.spec.js -├── undo-redo.spec.js -├── window-state.spec.js -├── ... # (representative listing; see the directory for the full spec set, -│ # e.g. full-journey, chat-understand, knowledge-graph, live-llm-*, -│ # vision-ocr-analysis, settings-*, mcp-onboarding, ai-provider) +│ ├── electronApp.js # Launch/close the app, isolated profiles, modal helpers +│ ├── pageObjects.js # Page objects (NavigationPage, DiscoverPage, ...) +│ ├── testFixtures.js # Phases, selectors, timeouts, fixture files, temp dirs +│ ├── seedState.js # Seed analyzed files so specs need no live model +│ ├── liveLlm.js # Waits and assertions for specs that use real models +│ ├── e2eSettings.js # Open Settings, expand sections, switch experience mode +│ ├── e2eShortcuts.js # Keyboard shortcut helpers (open search) +│ ├── globalSetup.js # Runs once before all tests (rebuilds stale main/preload bundles) +│ └── globalTeardown.js # Runs once after all tests (kills orphaned Electron processes) +├── app/ # App shell +├── setup/ # Smart folders +├── discover/ # Import and analysis +├── organize/ # Organize and undo/redo +├── search/ # Knowledge OS: search, chat, graph, Atlas +├── settings/ # Settings panel +├── workflows/ # Cross-phase journeys on seeded data +├── live-llm/ # @live-llm specs (real models) +├── manual/ # Soak test and QA repro (opt-in) └── README.md # This file ``` +Two Jest tests read these folders and run with `npm test`: + +- `test/e2eSpecsHaveAssertions.test.js` fails when a `test()` block has no assertion. Its allowlist + keys are full spec paths (`test/e2e//.spec.js::`), so update them when you + move or rename a spec. +- `test/e2eIsolatedProfileSeed.test.js` fails when a spec opts out of the offline profile seed. + ## Writing New Tests +Put the spec in the folder for the area it tests. Specs sit one level below `test/e2e/`, so helper +imports start with `../helpers/`. + ### Basic Test Template ```javascript const { test, expect } = require('@playwright/test'); -const { launchApp, closeApp, waitForAppReady } = require('./helpers/electronApp'); -const { NavigationPage } = require('./helpers/pageObjects'); +const { launchApp, closeApp, waitForAppReady } = require('../helpers/electronApp'); +const { NavigationPage } = require('../helpers/pageObjects'); test.describe('My Feature', () => { let app; @@ -145,7 +259,7 @@ test.describe('My Feature', () => { ### Using Page Objects ```javascript -const { NavigationPage, DiscoverPage, OrganizePage } = require('./helpers/pageObjects'); +const { NavigationPage, DiscoverPage, OrganizePage } = require('../helpers/pageObjects'); test('workflow test', async () => { const nav = new NavigationPage(window); @@ -163,7 +277,7 @@ test('workflow test', async () => { ### Using Test Fixtures ```javascript -const { TEST_FILES, setupTestFiles, cleanupTempDir } = require('./helpers/testFixtures'); +const { TEST_FILES, setupTestFiles, cleanupTempDir } = require('../helpers/testFixtures'); test('test with files', async () => { // Create temp directory with test files @@ -209,14 +323,12 @@ The tests are designed to run in CI environments: 3. Screenshots/videos captured on failure 4. HTML report generated for review -### GitHub Actions Example +### GitHub Actions -```yaml -- name: Run E2E tests - run: | - npm run build:dev - npm run test:e2e -``` +The `e2e-smoke` job in `.github/workflows/ci.yml` runs the eight specs marked **CI smoke** in the +spec index, on pushes, on Ubuntu (under xvfb) and Windows. The spec paths are listed twice in that +job, once per OS; keep both lists and the "CI smoke set" command above in step when a smoke spec +moves. The full suite and the `@live-llm` specs are run by hand before releases. ## Troubleshooting @@ -236,15 +348,14 @@ npm run build:dev ### AI model-related test failures -These are expected if models are not available. Tests should pass with warnings. To run with local -models: +Every launch uses an isolated, empty profile, so the models installed for your own copy of the app +are not visible to the tests. Specs that need a model skip when there is none. To run them, point +`STRATOSORT_E2E_MODELS_DIR` at a folder of `.gguf` models: ```bash -# Ensure models are downloaded -npm run setup:models - -# Then run tests -npm run test:e2e +# PowerShell: $env:STRATOSORT_E2E_MODELS_DIR = 'C:path omodels' +export STRATOSORT_E2E_MODELS_DIR=/path/to/models +npm run test:e2e:live-llm ``` ### Windows-specific issues @@ -257,7 +368,8 @@ npm run test:e2e When adding new tests: -1. Follow the existing file naming convention (`*.spec.js`) +1. Put the spec in the folder for its product area and follow the file naming convention + (`*.spec.js`); add it to the spec index at the top of this file 2. Use page objects for UI interactions 3. Add appropriate test descriptions 4. Handle model unavailability gracefully diff --git a/test/e2e/accessibility.spec.js b/test/e2e/app/accessibility.spec.js similarity index 95% rename from test/e2e/accessibility.spec.js rename to test/e2e/app/accessibility.spec.js index 0f42caa..32cb780 100644 --- a/test/e2e/accessibility.spec.js +++ b/test/e2e/app/accessibility.spec.js @@ -16,7 +16,7 @@ * so it is eligible to join the `e2e-smoke` job's explicit spec list in * .github/workflows/ci.yml once it has gone green once on CI. * - * Run: npm run test:e2e -- test/e2e/accessibility.spec.js + * Run: npm run test:e2e -- test/e2e/app/accessibility.spec.js */ const { test, expect } = require('@playwright/test'); @@ -30,11 +30,11 @@ const { readAppSessionLogs, BACKGROUND_DOWNLOAD_DECLINED_LOG, BACKGROUND_DOWNLOAD_STARTED_LOG -} = require('./helpers/electronApp'); -const { NavigationPage } = require('./helpers/pageObjects'); -const { PHASES } = require('./helpers/testFixtures'); -const { pressOpenSearchShortcut } = require('./helpers/e2eShortcuts'); -const { createSyntheticFiles, seedAnalyzedFiles } = require('./helpers/seedState'); +} = require('../helpers/electronApp'); +const { NavigationPage } = require('../helpers/pageObjects'); +const { PHASES } = require('../helpers/testFixtures'); +const { pressOpenSearchShortcut } = require('../helpers/e2eShortcuts'); +const { createSyntheticFiles, seedAnalyzedFiles } = require('../helpers/seedState'); // Impacts that FAIL the test. Ratcheted critical → critical+serious on // 2026-07-02 when every audited screen (incl. dark/light themes, search modal, diff --git a/test/e2e/app-launch.spec.js b/test/e2e/app/app-launch.spec.js similarity index 98% rename from test/e2e/app-launch.spec.js rename to test/e2e/app/app-launch.spec.js index 843c81f..6e1e173 100644 --- a/test/e2e/app-launch.spec.js +++ b/test/e2e/app/app-launch.spec.js @@ -8,7 +8,7 @@ */ const { test, expect } = require('@playwright/test'); -const { launchApp, closeApp, waitForAppReady, getAppInfo } = require('./helpers/electronApp'); +const { launchApp, closeApp, waitForAppReady, getAppInfo } = require('../helpers/electronApp'); test.describe('App Launch', () => { let app; diff --git a/test/e2e/error-handling.spec.js b/test/e2e/app/error-handling.spec.js similarity index 96% rename from test/e2e/error-handling.spec.js rename to test/e2e/app/error-handling.spec.js index 17acd7a..8422fbc 100644 --- a/test/e2e/error-handling.spec.js +++ b/test/e2e/app/error-handling.spec.js @@ -8,7 +8,7 @@ */ const { test, expect } = require('@playwright/test'); -const { launchApp, closeApp, waitForAppReady } = require('./helpers/electronApp'); +const { launchApp, closeApp, waitForAppReady } = require('../helpers/electronApp'); test.describe('Error Handling', () => { let app; diff --git a/test/e2e/keyboard-navigation.spec.js b/test/e2e/app/keyboard-navigation.spec.js similarity index 98% rename from test/e2e/keyboard-navigation.spec.js rename to test/e2e/app/keyboard-navigation.spec.js index 5111b04..0ac52c4 100644 --- a/test/e2e/keyboard-navigation.spec.js +++ b/test/e2e/app/keyboard-navigation.spec.js @@ -13,10 +13,10 @@ const { closeApp, waitForAppReady, dismissModelSetupWizard -} = require('./helpers/electronApp'); -const { pressOpenSearchShortcut } = require('./helpers/e2eShortcuts'); -const { NavigationPage } = require('./helpers/pageObjects'); -const { PHASES } = require('./helpers/testFixtures'); +} = require('../helpers/electronApp'); +const { pressOpenSearchShortcut } = require('../helpers/e2eShortcuts'); +const { NavigationPage } = require('../helpers/pageObjects'); +const { PHASES } = require('../helpers/testFixtures'); test.describe('Keyboard Navigation - Tab Navigation', () => { let app; diff --git a/test/e2e/menu-shortcuts.spec.js b/test/e2e/app/menu-shortcuts.spec.js similarity index 98% rename from test/e2e/menu-shortcuts.spec.js rename to test/e2e/app/menu-shortcuts.spec.js index 6bc0be1..07a638a 100644 --- a/test/e2e/menu-shortcuts.spec.js +++ b/test/e2e/app/menu-shortcuts.spec.js @@ -14,10 +14,10 @@ const { waitForAppReady, safeWaitForTimeout, expandDiscoverGuideIfCollapsed -} = require('./helpers/electronApp'); -const { pressOpenSearchShortcut } = require('./helpers/e2eShortcuts'); -const { NavigationPage } = require('./helpers/pageObjects'); -const { PHASES, STRATO_TEST_FILES, STRATO_TEST_FILES_DIR } = require('./helpers/testFixtures'); +} = require('../helpers/electronApp'); +const { pressOpenSearchShortcut } = require('../helpers/e2eShortcuts'); +const { NavigationPage } = require('../helpers/pageObjects'); +const { PHASES, STRATO_TEST_FILES, STRATO_TEST_FILES_DIR } = require('../helpers/testFixtures'); test.describe('Menu Shortcuts', () => { let app; diff --git a/test/e2e/navigation.spec.js b/test/e2e/app/navigation.spec.js similarity index 98% rename from test/e2e/navigation.spec.js rename to test/e2e/app/navigation.spec.js index 6ca1136..1419279 100644 --- a/test/e2e/navigation.spec.js +++ b/test/e2e/app/navigation.spec.js @@ -16,9 +16,9 @@ const { closeApp, waitForAppReady, dismissModelSetupWizard -} = require('./helpers/electronApp'); -const { NavigationPage } = require('./helpers/pageObjects'); -const { PHASES, PHASE_NAV_LABELS } = require('./helpers/testFixtures'); +} = require('../helpers/electronApp'); +const { NavigationPage } = require('../helpers/pageObjects'); +const { PHASES, PHASE_NAV_LABELS } = require('../helpers/testFixtures'); test.describe('Navigation', () => { let app; diff --git a/test/e2e/notifications.spec.js b/test/e2e/app/notifications.spec.js similarity index 98% rename from test/e2e/notifications.spec.js rename to test/e2e/app/notifications.spec.js index 0829eab..b00650d 100644 --- a/test/e2e/notifications.spec.js +++ b/test/e2e/app/notifications.spec.js @@ -8,8 +8,8 @@ */ const { test, expect } = require('@playwright/test'); -const { launchApp, closeApp, waitForAppReady } = require('./helpers/electronApp'); -const { NavigationPage } = require('./helpers/pageObjects'); +const { launchApp, closeApp, waitForAppReady } = require('../helpers/electronApp'); +const { NavigationPage } = require('../helpers/pageObjects'); test.describe('Notifications - Display System', () => { let app; diff --git a/test/e2e/session-persistence.spec.js b/test/e2e/app/session-persistence.spec.js similarity index 99% rename from test/e2e/session-persistence.spec.js rename to test/e2e/app/session-persistence.spec.js index 7c5d13c..5f0067c 100644 --- a/test/e2e/session-persistence.spec.js +++ b/test/e2e/app/session-persistence.spec.js @@ -15,7 +15,7 @@ const { waitForAppReady, createIsolatedE2eUserDataDir, cleanupE2eUserDataDir -} = require('./helpers/electronApp'); +} = require('../helpers/electronApp'); // Cross-restart tests must share one userData profile: launchApp() creates a // fresh isolated dir per launch, so without an explicit userDataDir session 2 diff --git a/test/e2e/theme-persistence.spec.js b/test/e2e/app/theme-persistence.spec.js similarity index 98% rename from test/e2e/theme-persistence.spec.js rename to test/e2e/app/theme-persistence.spec.js index 80e57f7..b6b92ee 100644 --- a/test/e2e/theme-persistence.spec.js +++ b/test/e2e/app/theme-persistence.spec.js @@ -29,8 +29,8 @@ const { createIsolatedE2eUserDataDir, cleanupE2eUserDataDir, evaluateInMain -} = require('./helpers/electronApp'); -const { NavigationPage } = require('./helpers/pageObjects'); +} = require('../helpers/electronApp'); +const { NavigationPage } = require('../helpers/pageObjects'); // --surface-muted per theme (source of truth: src/renderer/styles.css). This is // the color the html/body + splash surface paints with, so it is the "reload diff --git a/test/e2e/window-state.spec.js b/test/e2e/app/window-state.spec.js similarity index 99% rename from test/e2e/window-state.spec.js rename to test/e2e/app/window-state.spec.js index 53ad92c..eb4e3b4 100644 --- a/test/e2e/window-state.spec.js +++ b/test/e2e/app/window-state.spec.js @@ -8,7 +8,12 @@ */ const { test, expect } = require('@playwright/test'); -const { launchApp, launchesHeadless, closeApp, waitForAppReady } = require('./helpers/electronApp'); +const { + launchApp, + launchesHeadless, + closeApp, + waitForAppReady +} = require('../helpers/electronApp'); /** * DevTools helpers for the main renderer webContents. diff --git a/test/e2e/analysis-flow.spec.js b/test/e2e/discover/analysis-flow.spec.js similarity index 94% rename from test/e2e/analysis-flow.spec.js rename to test/e2e/discover/analysis-flow.spec.js index dcb1672..5aae47a 100644 --- a/test/e2e/analysis-flow.spec.js +++ b/test/e2e/discover/analysis-flow.spec.js @@ -9,10 +9,10 @@ */ const { test, expect } = require('@playwright/test'); -const { launchApp, closeApp, waitForAppReady } = require('./helpers/electronApp'); -const { NavigationPage, DiscoverPage } = require('./helpers/pageObjects'); -const { PHASES, setupTestFiles, cleanupTempDir, TIMEOUTS } = require('./helpers/testFixtures'); -const { waitForLoadedTextModel, normalizeAnalysisPayload } = require('./helpers/liveLlm'); +const { launchApp, closeApp, waitForAppReady } = require('../helpers/electronApp'); +const { NavigationPage, DiscoverPage } = require('../helpers/pageObjects'); +const { PHASES, setupTestFiles, cleanupTempDir, TIMEOUTS } = require('../helpers/testFixtures'); +const { waitForLoadedTextModel, normalizeAnalysisPayload } = require('../helpers/liveLlm'); test.describe('Analysis Flow - API', () => { let app; diff --git a/test/e2e/analysis-history.spec.js b/test/e2e/discover/analysis-history.spec.js similarity index 98% rename from test/e2e/analysis-history.spec.js rename to test/e2e/discover/analysis-history.spec.js index 59c2442..fed2a62 100644 --- a/test/e2e/analysis-history.spec.js +++ b/test/e2e/discover/analysis-history.spec.js @@ -20,8 +20,8 @@ */ const { test, expect } = require('@playwright/test'); -const { launchApp, closeApp, waitForAppReady } = require('./helpers/electronApp'); -const { NavigationPage } = require('./helpers/pageObjects'); +const { launchApp, closeApp, waitForAppReady } = require('../helpers/electronApp'); +const { NavigationPage } = require('../helpers/pageObjects'); test.describe('Analysis History - API Availability', () => { let app; diff --git a/test/e2e/downloads-triage-api.spec.js b/test/e2e/discover/downloads-triage-api.spec.js similarity index 91% rename from test/e2e/downloads-triage-api.spec.js rename to test/e2e/discover/downloads-triage-api.spec.js index 48dcdb2..a5f5a28 100644 --- a/test/e2e/downloads-triage-api.spec.js +++ b/test/e2e/discover/downloads-triage-api.spec.js @@ -2,7 +2,7 @@ * Smoke: Downloads triage IPC exposed to renderer (no full triage UI walkthrough). */ const { test, expect } = require('@playwright/test'); -const { launchApp, closeApp, waitForAppReady } = require('./helpers/electronApp'); +const { launchApp, closeApp, waitForAppReady } = require('../helpers/electronApp'); test.describe('Downloads triage API', () => { let app; diff --git a/test/e2e/drag-drop.spec.js b/test/e2e/discover/drag-drop.spec.js similarity index 98% rename from test/e2e/drag-drop.spec.js rename to test/e2e/discover/drag-drop.spec.js index 367189a..bf3b5b8 100644 --- a/test/e2e/drag-drop.spec.js +++ b/test/e2e/discover/drag-drop.spec.js @@ -14,9 +14,9 @@ const { closeApp, waitForAppReady, safeWaitForTimeout -} = require('./helpers/electronApp'); -const { NavigationPage } = require('./helpers/pageObjects'); -const { PHASES, STRATO_TEST_FILES } = require('./helpers/testFixtures'); +} = require('../helpers/electronApp'); +const { NavigationPage } = require('../helpers/pageObjects'); +const { PHASES, STRATO_TEST_FILES } = require('../helpers/testFixtures'); const path = require('path'); test.describe('Drag Drop - UI Elements', () => { diff --git a/test/e2e/file-import.spec.js b/test/e2e/discover/file-import.spec.js similarity index 97% rename from test/e2e/file-import.spec.js rename to test/e2e/discover/file-import.spec.js index 3f001e4..b7e2d5c 100644 --- a/test/e2e/file-import.spec.js +++ b/test/e2e/discover/file-import.spec.js @@ -8,9 +8,9 @@ */ const { test, expect } = require('@playwright/test'); -const { launchApp, closeApp, waitForAppReady } = require('./helpers/electronApp'); -const { NavigationPage, DiscoverPage } = require('./helpers/pageObjects'); -const { PHASES, setupTestFiles, cleanupTempDir } = require('./helpers/testFixtures'); +const { launchApp, closeApp, waitForAppReady } = require('../helpers/electronApp'); +const { NavigationPage, DiscoverPage } = require('../helpers/pageObjects'); +const { PHASES, setupTestFiles, cleanupTempDir } = require('../helpers/testFixtures'); test.describe('File Import', () => { let app; diff --git a/test/e2e/last-browsed-path.spec.js b/test/e2e/discover/last-browsed-path.spec.js similarity index 98% rename from test/e2e/last-browsed-path.spec.js rename to test/e2e/discover/last-browsed-path.spec.js index 5355630..f68b9e1 100644 --- a/test/e2e/last-browsed-path.spec.js +++ b/test/e2e/discover/last-browsed-path.spec.js @@ -8,9 +8,9 @@ */ const { test, expect } = require('@playwright/test'); -const { launchApp, closeApp, waitForAppReady } = require('./helpers/electronApp'); -const { NavigationPage } = require('./helpers/pageObjects'); -const { PHASES, STRATO_TEST_FILES_DIR } = require('./helpers/testFixtures'); +const { launchApp, closeApp, waitForAppReady } = require('../helpers/electronApp'); +const { NavigationPage } = require('../helpers/pageObjects'); +const { PHASES, STRATO_TEST_FILES_DIR } = require('../helpers/testFixtures'); test.describe('Last Browsed Path', () => { let app; diff --git a/test/e2e/naming-strategy.spec.js b/test/e2e/discover/naming-strategy.spec.js similarity index 98% rename from test/e2e/naming-strategy.spec.js rename to test/e2e/discover/naming-strategy.spec.js index 17562fe..96c9d53 100644 --- a/test/e2e/naming-strategy.spec.js +++ b/test/e2e/discover/naming-strategy.spec.js @@ -20,9 +20,9 @@ const { waitForAppReady, safeWaitForTimeout, expandDiscoverGuideIfCollapsed -} = require('./helpers/electronApp'); -const { NavigationPage } = require('./helpers/pageObjects'); -const { PHASES } = require('./helpers/testFixtures'); +} = require('../helpers/electronApp'); +const { NavigationPage } = require('../helpers/pageObjects'); +const { PHASES } = require('../helpers/testFixtures'); test.describe('Naming Strategy - Configuration Modal', () => { let app; diff --git a/test/e2e/vision-ocr-analysis.spec.js b/test/e2e/discover/vision-ocr-analysis.spec.js similarity index 96% rename from test/e2e/vision-ocr-analysis.spec.js rename to test/e2e/discover/vision-ocr-analysis.spec.js index a814684..9562d7d 100644 --- a/test/e2e/vision-ocr-analysis.spec.js +++ b/test/e2e/discover/vision-ocr-analysis.spec.js @@ -9,8 +9,8 @@ */ const { test, expect } = require('@playwright/test'); -const { launchApp, closeApp, waitForAppReady } = require('./helpers/electronApp'); -const { STRATO_TEST_FILES, TIMEOUTS } = require('./helpers/testFixtures'); +const { launchApp, closeApp, waitForAppReady } = require('../helpers/electronApp'); +const { STRATO_TEST_FILES, TIMEOUTS } = require('../helpers/testFixtures'); test.describe('Vision & OCR — API Surface', () => { let app; diff --git a/test/e2e/whisper-variant-loader.spec.js b/test/e2e/discover/whisper-variant-loader.spec.js similarity index 97% rename from test/e2e/whisper-variant-loader.spec.js rename to test/e2e/discover/whisper-variant-loader.spec.js index bdf0ea8..5404311 100644 --- a/test/e2e/whisper-variant-loader.spec.js +++ b/test/e2e/discover/whisper-variant-loader.spec.js @@ -29,7 +29,7 @@ * - error.code === 'MODULE_NOT_FOUND' with a whisper specifier in * the message * - * Run: `npx playwright test test/e2e/whisper-variant-loader.spec.js` + * Run: `npx playwright test test/e2e/discover/whisper-variant-loader.spec.js` * * History: introduced after commit `be73c255` fixed the regression. The * earlier `7e3a8540` source-level static guard catches a *reverter* @@ -38,8 +38,8 @@ */ const { test, expect } = require('@playwright/test'); -const { launchApp, closeApp, waitForAppReady } = require('./helpers/electronApp'); -const { setupTestFiles, cleanupTempDir } = require('./helpers/testFixtures'); +const { launchApp, closeApp, waitForAppReady } = require('../helpers/electronApp'); +const { setupTestFiles, cleanupTempDir } = require('../helpers/testFixtures'); // Substrings that indicate the regression. If the error message or stack // contains any of these, the loader was reached AND failed in the diff --git a/test/e2e/helpers/electronApp.js b/test/e2e/helpers/electronApp.js index 1cab2c2..a39712d 100644 --- a/test/e2e/helpers/electronApp.js +++ b/test/e2e/helpers/electronApp.js @@ -5,7 +5,7 @@ * Uses Playwright's Electron support via the _electron fixture. * * Usage: - * const { launchApp, closeApp, getMainWindow } = require('./helpers/electronApp'); + * const { launchApp, closeApp, getMainWindow } = require('../helpers/electronApp'); * * test('my test', async () => { * const { app, window } = await launchApp(); diff --git a/test/e2e/full-journey.spec.js b/test/e2e/live-llm/full-journey.spec.js similarity index 96% rename from test/e2e/full-journey.spec.js rename to test/e2e/live-llm/full-journey.spec.js index 781084d..99bae2c 100644 --- a/test/e2e/full-journey.spec.js +++ b/test/e2e/live-llm/full-journey.spec.js @@ -23,13 +23,13 @@ const { test, expect } = require('@playwright/test'); const path = require('path'); const fs = require('fs/promises'); -const { launchApp, closeApp, waitForAppReady } = require('./helpers/electronApp'); -const { createTempDir, cleanupTempDir, TIMEOUTS } = require('./helpers/testFixtures'); +const { launchApp, closeApp, waitForAppReady } = require('../helpers/electronApp'); +const { createTempDir, cleanupTempDir, TIMEOUTS } = require('../helpers/testFixtures'); const { waitForLoadedTextModel, normalizeAnalysisPayload, waitForFileInSearch -} = require('./helpers/liveLlm'); +} = require('../helpers/liveLlm'); const SAMPLE_CONTENT = [ 'StratoSort E2E smoke — do not modify.', diff --git a/test/e2e/live-llm-chat.spec.js b/test/e2e/live-llm/live-llm-chat.spec.js similarity index 94% rename from test/e2e/live-llm-chat.spec.js rename to test/e2e/live-llm/live-llm-chat.spec.js index a7886bb..ea47900 100644 --- a/test/e2e/live-llm-chat.spec.js +++ b/test/e2e/live-llm/live-llm-chat.spec.js @@ -1,7 +1,7 @@ /** * @live-llm — Chat RAG streaming against real local models. * - * Run: npm run test:e2e:live-llm -- test/e2e/live-llm-chat.spec.js + * Run: npm run test:e2e:live-llm -- test/e2e/live-llm/live-llm-chat.spec.js * with STRATOSORT_E2E_MODELS_DIR set to a folder of .gguf models; without it * the isolated profile has no models and the spec skips. */ @@ -9,14 +9,14 @@ const { test, expect } = require('@playwright/test'); const path = require('path'); const fs = require('fs/promises'); -const { launchApp, closeApp, waitForAppReady } = require('./helpers/electronApp'); -const { createTempDir, cleanupTempDir, TIMEOUTS } = require('./helpers/testFixtures'); +const { launchApp, closeApp, waitForAppReady } = require('../helpers/electronApp'); +const { createTempDir, cleanupTempDir, TIMEOUTS } = require('../helpers/testFixtures'); const { waitForLoadedTextModel, normalizeAnalysisPayload, assertAnalysisRoutingSignal, waitForFileInSearch -} = require('./helpers/liveLlm'); +} = require('../helpers/liveLlm'); const SAMPLE_CONTENT = [ 'StratoSort live-llm chat smoke.', diff --git a/test/e2e/live-llm-vision.spec.js b/test/e2e/live-llm/live-llm-vision.spec.js similarity index 88% rename from test/e2e/live-llm-vision.spec.js rename to test/e2e/live-llm/live-llm-vision.spec.js index c630107..c477933 100644 --- a/test/e2e/live-llm-vision.spec.js +++ b/test/e2e/live-llm/live-llm-vision.spec.js @@ -1,20 +1,20 @@ /** * @live-llm — Vision model analysis on a real image fixture. * - * Run: npm run test:e2e:live-llm -- test/e2e/live-llm-vision.spec.js + * Run: npm run test:e2e:live-llm -- test/e2e/live-llm/live-llm-vision.spec.js * with STRATOSORT_E2E_MODELS_DIR set to a folder of .gguf models; without it * the isolated profile has no models and the spec skips. */ const { test, expect } = require('@playwright/test'); const fs = require('fs/promises'); -const { launchApp, closeApp, waitForAppReady } = require('./helpers/electronApp'); -const { STRATO_TEST_FILES, TIMEOUTS } = require('./helpers/testFixtures'); +const { launchApp, closeApp, waitForAppReady } = require('../helpers/electronApp'); +const { STRATO_TEST_FILES, TIMEOUTS } = require('../helpers/testFixtures'); const { waitForLoadedVisionModel, normalizeAnalysisPayload, assertAnalysisRoutingSignal -} = require('./helpers/liveLlm'); +} = require('../helpers/liveLlm'); const IMAGE_FIXTURE = STRATO_TEST_FILES.pngImage; diff --git a/test/e2e/qa-runtime-repro.spec.js b/test/e2e/manual/qa-runtime-repro.spec.js similarity index 98% rename from test/e2e/qa-runtime-repro.spec.js rename to test/e2e/manual/qa-runtime-repro.spec.js index 8361831..9861a9c 100644 --- a/test/e2e/qa-runtime-repro.spec.js +++ b/test/e2e/manual/qa-runtime-repro.spec.js @@ -10,7 +10,7 @@ * FI-04 filename vs semantic search discrepancy * * Run: SOAK-free, on the .nvmrc Node: - * fnm exec -- node node_modules/@playwright/test/cli.js test test/e2e/qa-runtime-repro.spec.js + * fnm exec -- node node_modules/@playwright/test/cli.js test test/e2e/manual/qa-runtime-repro.spec.js */ const { test, expect } = require('@playwright/test'); @@ -24,11 +24,11 @@ const { waitForAppReady, dismissGraphTour, createIsolatedE2eUserDataDir -} = require('./helpers/electronApp'); -const { NavigationPage } = require('./helpers/pageObjects'); -const { pressOpenSearchShortcut } = require('./helpers/e2eShortcuts'); -const { ensureAdvancedExperienceMode } = require('./helpers/e2eSettings'); -const { waitForLoadedTextModel, normalizeAnalysisPayload } = require('./helpers/liveLlm'); +} = require('../helpers/electronApp'); +const { NavigationPage } = require('../helpers/pageObjects'); +const { pressOpenSearchShortcut } = require('../helpers/e2eShortcuts'); +const { ensureAdvancedExperienceMode } = require('../helpers/e2eSettings'); +const { waitForLoadedTextModel, normalizeAnalysisPayload } = require('../helpers/liveLlm'); const REAL_MODELS_DIR = path.join(process.env.APPDATA || '', 'StratoSort Stack', 'models'); diff --git a/test/e2e/soak.spec.js b/test/e2e/manual/soak.spec.js similarity index 97% rename from test/e2e/soak.spec.js rename to test/e2e/manual/soak.spec.js index 1fdd9af..fd92883 100644 --- a/test/e2e/soak.spec.js +++ b/test/e2e/manual/soak.spec.js @@ -23,10 +23,10 @@ const { test, expect } = require('@playwright/test'); const path = require('path'); const fs = require('fs').promises; -const { launchApp, closeApp, waitForAppReady, dismissModals } = require('./helpers/electronApp'); -const { pressOpenSearchShortcut } = require('./helpers/e2eShortcuts'); -const { NavigationPage } = require('./helpers/pageObjects'); -const { createTempDir, cleanupTempDir, PHASES } = require('./helpers/testFixtures'); +const { launchApp, closeApp, waitForAppReady, dismissModals } = require('../helpers/electronApp'); +const { pressOpenSearchShortcut } = require('../helpers/e2eShortcuts'); +const { NavigationPage } = require('../helpers/pageObjects'); +const { createTempDir, cleanupTempDir, PHASES } = require('../helpers/testFixtures'); function clampNumber(n, fallback = 0) { return Number.isFinite(n) ? n : fallback; diff --git a/test/e2e/organize-phase.spec.js b/test/e2e/organize/organize-phase.spec.js similarity index 97% rename from test/e2e/organize-phase.spec.js rename to test/e2e/organize/organize-phase.spec.js index 94d2c19..cbe08f6 100644 --- a/test/e2e/organize-phase.spec.js +++ b/test/e2e/organize/organize-phase.spec.js @@ -18,10 +18,10 @@ */ const { test, expect } = require('@playwright/test'); -const { launchApp, closeApp, waitForAppReady } = require('./helpers/electronApp'); -const { NavigationPage } = require('./helpers/pageObjects'); -const { PHASES } = require('./helpers/testFixtures'); -const { createSyntheticFiles, seedAnalyzedFiles } = require('./helpers/seedState'); +const { launchApp, closeApp, waitForAppReady } = require('../helpers/electronApp'); +const { NavigationPage } = require('../helpers/pageObjects'); +const { PHASES } = require('../helpers/testFixtures'); +const { createSyntheticFiles, seedAnalyzedFiles } = require('../helpers/seedState'); const SEEDED_FILE_NAME = 'quarterly-budget.txt'; diff --git a/test/e2e/undo-redo.spec.js b/test/e2e/organize/undo-redo.spec.js similarity index 98% rename from test/e2e/undo-redo.spec.js rename to test/e2e/organize/undo-redo.spec.js index a09be05..32a24f5 100644 --- a/test/e2e/undo-redo.spec.js +++ b/test/e2e/organize/undo-redo.spec.js @@ -14,8 +14,8 @@ const { waitForAppReady, focusAppForKeyboardShortcuts, safeWaitForTimeout -} = require('./helpers/electronApp'); -const { NavigationPage } = require('./helpers/pageObjects'); +} = require('../helpers/electronApp'); +const { NavigationPage } = require('../helpers/pageObjects'); test.describe('Undo Redo - API Availability', () => { let app; diff --git a/test/e2e/atlas-tab.spec.js b/test/e2e/search/atlas-tab.spec.js similarity index 97% rename from test/e2e/atlas-tab.spec.js rename to test/e2e/search/atlas-tab.spec.js index d2dd2fa..9a851a9 100644 --- a/test/e2e/atlas-tab.spec.js +++ b/test/e2e/search/atlas-tab.spec.js @@ -17,8 +17,8 @@ const { launchApp, cleanupE2eUserDataDir, dismissModelSetupWizard -} = require('./helpers/electronApp'); -const { pressOpenSearchShortcut } = require('./helpers/e2eShortcuts'); +} = require('../helpers/electronApp'); +const { pressOpenSearchShortcut } = require('../helpers/e2eShortcuts'); // Mirrors the severity gate in accessibility.spec.js. const HARD_FAIL_IMPACTS = new Set(['critical', 'serious']); diff --git a/test/e2e/chat-understand.spec.js b/test/e2e/search/chat-understand.spec.js similarity index 97% rename from test/e2e/chat-understand.spec.js rename to test/e2e/search/chat-understand.spec.js index 58a2b2c..4fca050 100644 --- a/test/e2e/chat-understand.spec.js +++ b/test/e2e/search/chat-understand.spec.js @@ -9,8 +9,8 @@ */ const { test, expect } = require('@playwright/test'); -const { launchApp, closeApp, waitForAppReady } = require('./helpers/electronApp'); -const { pressOpenSearchShortcut } = require('./helpers/e2eShortcuts'); +const { launchApp, closeApp, waitForAppReady } = require('../helpers/electronApp'); +const { pressOpenSearchShortcut } = require('../helpers/e2eShortcuts'); test.describe('Chat Understand — API Surface', () => { let app; diff --git a/test/e2e/knowledge-graph.spec.js b/test/e2e/search/knowledge-graph.spec.js similarity index 97% rename from test/e2e/knowledge-graph.spec.js rename to test/e2e/search/knowledge-graph.spec.js index 30ff570..b8c6f4e 100644 --- a/test/e2e/knowledge-graph.spec.js +++ b/test/e2e/search/knowledge-graph.spec.js @@ -9,9 +9,14 @@ */ const { test, expect } = require('@playwright/test'); -const { launchApp, closeApp, waitForAppReady, dismissGraphTour } = require('./helpers/electronApp'); -const { ensureAdvancedExperienceMode } = require('./helpers/e2eSettings'); -const { pressOpenSearchShortcut } = require('./helpers/e2eShortcuts'); +const { + launchApp, + closeApp, + waitForAppReady, + dismissGraphTour +} = require('../helpers/electronApp'); +const { ensureAdvancedExperienceMode } = require('../helpers/e2eSettings'); +const { pressOpenSearchShortcut } = require('../helpers/e2eShortcuts'); test.describe('Knowledge Graph — Relate Tab UI', () => { let app; diff --git a/test/e2e/search-modal.spec.js b/test/e2e/search/search-modal.spec.js similarity index 99% rename from test/e2e/search-modal.spec.js rename to test/e2e/search/search-modal.spec.js index 7f13f07..0d9470c 100644 --- a/test/e2e/search-modal.spec.js +++ b/test/e2e/search/search-modal.spec.js @@ -14,9 +14,9 @@ const { waitForAppReady, safeWaitForTimeout, dismissGraphTour -} = require('./helpers/electronApp'); -const { ensureAdvancedExperienceMode } = require('./helpers/e2eSettings'); -const { pressOpenSearchShortcut } = require('./helpers/e2eShortcuts'); +} = require('../helpers/electronApp'); +const { ensureAdvancedExperienceMode } = require('../helpers/e2eSettings'); +const { pressOpenSearchShortcut } = require('../helpers/e2eShortcuts'); const SEARCH_INPUT_SELECTOR = 'input[type="search"], input[placeholder*="Search"], input[placeholder*="Describe"], [role="combobox"][aria-label*="Search"]'; diff --git a/test/e2e/ai-provider.spec.js b/test/e2e/settings/ai-provider.spec.js similarity index 98% rename from test/e2e/ai-provider.spec.js rename to test/e2e/settings/ai-provider.spec.js index efe35bd..529ee0a 100644 --- a/test/e2e/ai-provider.spec.js +++ b/test/e2e/settings/ai-provider.spec.js @@ -15,7 +15,7 @@ */ const { test, expect } = require('@playwright/test'); -const { launchApp, closeApp, waitForAppReady } = require('./helpers/electronApp'); +const { launchApp, closeApp, waitForAppReady } = require('../helpers/electronApp'); test.describe('AI Provider — IPC bridge', () => { let app; diff --git a/test/e2e/cloud-consent-theme.spec.js b/test/e2e/settings/cloud-consent-theme.spec.js similarity index 98% rename from test/e2e/cloud-consent-theme.spec.js rename to test/e2e/settings/cloud-consent-theme.spec.js index bc66c09..f5cfe3b 100644 --- a/test/e2e/cloud-consent-theme.spec.js +++ b/test/e2e/settings/cloud-consent-theme.spec.js @@ -29,8 +29,8 @@ const { dismissModelSetupWizard, createIsolatedE2eUserDataDir, cleanupE2eUserDataDir -} = require('./helpers/electronApp'); -const { NavigationPage } = require('./helpers/pageObjects'); +} = require('../helpers/electronApp'); +const { NavigationPage } = require('../helpers/pageObjects'); /** * Opens Settings and clicks the Anthropic default-provider radio. On a fresh diff --git a/test/e2e/mcp-onboarding.spec.js b/test/e2e/settings/mcp-onboarding.spec.js similarity index 96% rename from test/e2e/mcp-onboarding.spec.js rename to test/e2e/settings/mcp-onboarding.spec.js index 0394a8c..ea52b6d 100644 --- a/test/e2e/mcp-onboarding.spec.js +++ b/test/e2e/settings/mcp-onboarding.spec.js @@ -15,9 +15,9 @@ const { waitForAppReady, dismissModelSetupWizard, safeWaitForTimeout -} = require('./helpers/electronApp'); -const { NavigationPage } = require('./helpers/pageObjects'); -const { expandSettingsCollapsibleByHeading } = require('./helpers/e2eSettings'); +} = require('../helpers/electronApp'); +const { NavigationPage } = require('../helpers/pageObjects'); +const { expandSettingsCollapsibleByHeading } = require('../helpers/e2eSettings'); // Hand the app a port the OS just confirmed is bindable. The MCP server's // default (53827) sits inside Windows' WinNAT/Hyper-V excluded dynamic-port diff --git a/test/e2e/model-switching.spec.js b/test/e2e/settings/model-switching.spec.js similarity index 97% rename from test/e2e/model-switching.spec.js rename to test/e2e/settings/model-switching.spec.js index d135ca2..aca2d0d 100644 --- a/test/e2e/model-switching.spec.js +++ b/test/e2e/settings/model-switching.spec.js @@ -9,12 +9,12 @@ */ const { test, expect } = require('@playwright/test'); -const { launchApp, closeApp, waitForAppReady } = require('./helpers/electronApp'); -const { NavigationPage } = require('./helpers/pageObjects'); +const { launchApp, closeApp, waitForAppReady } = require('../helpers/electronApp'); +const { NavigationPage } = require('../helpers/pageObjects'); const { openSettingsAdvancedUI, expandSettingsCollapsibleByHeading -} = require('./helpers/e2eSettings'); +} = require('../helpers/e2eSettings'); test.describe('Model Switching — API', () => { let app; diff --git a/test/e2e/settings-backup-restore.spec.js b/test/e2e/settings/settings-backup-restore.spec.js similarity index 96% rename from test/e2e/settings-backup-restore.spec.js rename to test/e2e/settings/settings-backup-restore.spec.js index 97d699f..14aa002 100644 --- a/test/e2e/settings-backup-restore.spec.js +++ b/test/e2e/settings/settings-backup-restore.spec.js @@ -9,12 +9,12 @@ */ const { test, expect } = require('@playwright/test'); -const { launchApp, closeApp, waitForAppReady } = require('./helpers/electronApp'); -const { NavigationPage } = require('./helpers/pageObjects'); +const { launchApp, closeApp, waitForAppReady } = require('../helpers/electronApp'); +const { NavigationPage } = require('../helpers/pageObjects'); const { openSettingsAdvancedUI, expandSettingsCollapsibleByHeading -} = require('./helpers/e2eSettings'); +} = require('../helpers/e2eSettings'); test.describe('Settings Backup & Restore — API', () => { let app; diff --git a/test/e2e/settings-subcategories-smoke.spec.js b/test/e2e/settings/settings-subcategories-smoke.spec.js similarity index 97% rename from test/e2e/settings-subcategories-smoke.spec.js rename to test/e2e/settings/settings-subcategories-smoke.spec.js index 7a6660a..a06b015 100644 --- a/test/e2e/settings-subcategories-smoke.spec.js +++ b/test/e2e/settings/settings-subcategories-smoke.spec.js @@ -1,7 +1,7 @@ const { test, expect } = require('@playwright/test'); -const { launchApp, closeApp, waitForAppReady } = require('./helpers/electronApp'); -const { NavigationPage } = require('./helpers/pageObjects'); -const { ensureAdvancedExperienceMode } = require('./helpers/e2eSettings'); +const { launchApp, closeApp, waitForAppReady } = require('../helpers/electronApp'); +const { NavigationPage } = require('../helpers/pageObjects'); +const { ensureAdvancedExperienceMode } = require('../helpers/e2eSettings'); function toBool(value, fallback = false) { return typeof value === 'boolean' ? value : fallback; diff --git a/test/e2e/smart-folder-add.spec.js b/test/e2e/setup/smart-folder-add.spec.js similarity index 99% rename from test/e2e/smart-folder-add.spec.js rename to test/e2e/setup/smart-folder-add.spec.js index 1cca874..821814f 100644 --- a/test/e2e/smart-folder-add.spec.js +++ b/test/e2e/setup/smart-folder-add.spec.js @@ -12,9 +12,9 @@ const { closeApp, waitForAppReady, safeWaitForTimeout -} = require('./helpers/electronApp'); -const { NavigationPage } = require('./helpers/pageObjects'); -const { PHASES } = require('./helpers/testFixtures'); +} = require('../helpers/electronApp'); +const { NavigationPage } = require('../helpers/pageObjects'); +const { PHASES } = require('../helpers/testFixtures'); test.describe('Smart Folder Add - API Verification', () => { let app; diff --git a/test/e2e/smart-folders.spec.js b/test/e2e/setup/smart-folders.spec.js similarity index 99% rename from test/e2e/smart-folders.spec.js rename to test/e2e/setup/smart-folders.spec.js index ea8eb7f..b6a23fd 100644 --- a/test/e2e/smart-folders.spec.js +++ b/test/e2e/setup/smart-folders.spec.js @@ -13,9 +13,9 @@ const { closeApp, waitForAppReady, safeWaitForTimeout -} = require('./helpers/electronApp'); -const { NavigationPage } = require('./helpers/pageObjects'); -const { PHASES } = require('./helpers/testFixtures'); +} = require('../helpers/electronApp'); +const { NavigationPage } = require('../helpers/pageObjects'); +const { PHASES } = require('../helpers/testFixtures'); test.describe('Smart Folders - Setup Phase Navigation', () => { let app; diff --git a/test/e2e/beta-workflow.spec.js b/test/e2e/workflows/beta-workflow.spec.js similarity index 98% rename from test/e2e/beta-workflow.spec.js rename to test/e2e/workflows/beta-workflow.spec.js index 08fb66e..ca99b56 100644 --- a/test/e2e/beta-workflow.spec.js +++ b/test/e2e/workflows/beta-workflow.spec.js @@ -13,11 +13,11 @@ */ const { test, expect } = require('@playwright/test'); -const { launchApp, closeApp, waitForAppReady } = require('./helpers/electronApp'); -const { pressOpenSearchShortcut } = require('./helpers/e2eShortcuts'); -const { NavigationPage } = require('./helpers/pageObjects'); -const { createSyntheticFiles, seedAnalyzedFiles } = require('./helpers/seedState'); -const { PHASES, SELECTORS, TIMEOUTS } = require('./helpers/testFixtures'); +const { launchApp, closeApp, waitForAppReady } = require('../helpers/electronApp'); +const { pressOpenSearchShortcut } = require('../helpers/e2eShortcuts'); +const { NavigationPage } = require('../helpers/pageObjects'); +const { createSyntheticFiles, seedAnalyzedFiles } = require('../helpers/seedState'); +const { PHASES, SELECTORS, TIMEOUTS } = require('../helpers/testFixtures'); test.describe('Beta Workflow — Setup Phase', () => { let app; diff --git a/test/e2e/full-pipeline.spec.js b/test/e2e/workflows/full-pipeline.spec.js similarity index 97% rename from test/e2e/full-pipeline.spec.js rename to test/e2e/workflows/full-pipeline.spec.js index 1c40c86..f9edd6a 100644 --- a/test/e2e/full-pipeline.spec.js +++ b/test/e2e/workflows/full-pipeline.spec.js @@ -15,16 +15,16 @@ const { closeApp, waitForAppReady, safeWaitForTimeout -} = require('./helpers/electronApp'); -const { NavigationPage } = require('./helpers/pageObjects'); -const { createSyntheticFiles, seedAnalyzedFiles } = require('./helpers/seedState'); +} = require('../helpers/electronApp'); +const { NavigationPage } = require('../helpers/pageObjects'); +const { createSyntheticFiles, seedAnalyzedFiles } = require('../helpers/seedState'); const { PHASES, SELECTORS, TIMEOUTS, createTempDir, cleanupTempDir -} = require('./helpers/testFixtures'); +} = require('../helpers/testFixtures'); test.describe('Full Pipeline — Analysis to Organize', () => { let app; diff --git a/test/e2eIsolatedProfileSeed.test.js b/test/e2eIsolatedProfileSeed.test.js index 19f7f02..2a9436f 100644 --- a/test/e2eIsolatedProfileSeed.test.js +++ b/test/e2eIsolatedProfileSeed.test.js @@ -226,9 +226,12 @@ describe('openModelSetupWizardOffline', () => { describe('E2E specs stay network-free', () => { const E2E_DIR = path.join(__dirname, 'e2e'); + // Specs live in per-area subfolders (test/e2e/<area>/*.spec.js), so scan recursively. const specSources = fs - .readdirSync(E2E_DIR) + .readdirSync(E2E_DIR, { recursive: true }) + .map((name) => String(name).replace(/\\/g, '/')) .filter((name) => name.endsWith('.spec.js')) + .sort() .map((name) => [name, fs.readFileSync(path.join(E2E_DIR, name), 'utf8')]); // A spec auditing the wizard used to launch with allowModelAutoDownload, diff --git a/test/e2eSpecsHaveAssertions.test.js b/test/e2eSpecsHaveAssertions.test.js index a6e8f34..0c4baec 100644 --- a/test/e2eSpecsHaveAssertions.test.js +++ b/test/e2eSpecsHaveAssertions.test.js @@ -30,96 +30,96 @@ const ASSERTION_HELPERS = ['auditCurrentScreen', 'expectNoA11yViolations']; * Prefer adding assertions to new specs rather than growing this list. */ const ASSERTIONLESS_ALLOWLIST = new Set([ - 'test/e2e/analysis-history.spec.js::should have history access in settings', - 'test/e2e/analysis-history.spec.js::should have history access from Discover phase', - 'test/e2e/beta-workflow.spec.js::should close search modal with Escape', - 'test/e2e/beta-workflow.spec.js::should close settings panel', - 'test/e2e/chat-understand.spec.js::should close Knowledge Graph chat with Escape', - 'test/e2e/file-import.spec.js::should show file selection instructions', - 'test/e2e/file-import.spec.js::should display supported file types information', - 'test/e2e/file-import.spec.js::should have folder selection option', - 'test/e2e/file-import.spec.js::should show analyze button (may be disabled initially)', - 'test/e2e/file-import.spec.js::should show file count indicator', - 'test/e2e/file-import.spec.js::should have selection controls', - 'test/e2e/keyboard-navigation.spec.js::should navigate backwards with Shift+Tab', - 'test/e2e/keyboard-navigation.spec.js::should show visible focus indicators', - 'test/e2e/keyboard-navigation.spec.js::should activate buttons with Enter key', - 'test/e2e/keyboard-navigation.spec.js::should activate buttons with Space key', - 'test/e2e/keyboard-navigation.spec.js::should close search modal with Escape', - 'test/e2e/keyboard-navigation.spec.js::should close dropdowns with Escape', - 'test/e2e/keyboard-navigation.spec.js::should move forward with Alt+Right', - 'test/e2e/keyboard-navigation.spec.js::should open search with Ctrl+K', - 'test/e2e/keyboard-navigation.spec.js::should check canUndo state', - 'test/e2e/keyboard-navigation.spec.js::should check canRedo state', - 'test/e2e/keyboard-navigation.spec.js::should support Ctrl+A in text inputs', - 'test/e2e/keyboard-navigation.spec.js::should navigate lists with arrow keys', - 'test/e2e/keyboard-navigation.spec.js::should support Home/End in lists', - 'test/e2e/knowledge-graph.spec.js::should have clusters toggle button', - 'test/e2e/last-browsed-path.spec.js::should have folder selection option available', - 'test/e2e/last-browsed-path.spec.js::should validate lastBrowsedPath as string type', - 'test/e2e/last-browsed-path.spec.js::should handle very long paths', - 'test/e2e/menu-shortcuts.spec.js::should have search shortcut Ctrl+K', - 'test/e2e/notifications.spec.js::should have notification container in DOM', - 'test/e2e/notifications.spec.js::should have notification API available', - 'test/e2e/notifications.spec.js::should display notification container when triggered', - 'test/e2e/notifications.spec.js::should support info notification style', - 'test/e2e/notifications.spec.js::should support success notification style', - 'test/e2e/notifications.spec.js::should support warning notification style', - 'test/e2e/notifications.spec.js::should support error notification style', - 'test/e2e/notifications.spec.js::should auto-dismiss notifications after timeout', - 'test/e2e/notifications.spec.js::should have dismiss button on notifications', - 'test/e2e/notifications.spec.js::should stack multiple notifications', - 'test/e2e/notifications.spec.js::should position notifications correctly', - 'test/e2e/notifications.spec.js::should show error notification on API failure', - 'test/e2e/notifications.spec.js::should show notification on connection status change', - 'test/e2e/notifications.spec.js::should show notification on settings save success', - 'test/e2e/notifications.spec.js::should have proper ARIA role on notifications', - 'test/e2e/notifications.spec.js::should be announced to screen readers', - 'test/e2e/search-modal.spec.js::should close search with Escape', - 'test/e2e/search-modal.spec.js::should have search API available', - 'test/e2e/search-modal.spec.js::should clear search on modal close', - 'test/e2e/search-modal.spec.js::should have semantic search mode option', - 'test/e2e/search-modal.spec.js::should have full-text search mode option', - 'test/e2e/search-modal.spec.js::should have hybrid search mode option', - 'test/e2e/search-modal.spec.js::should have results container', - 'test/e2e/search-modal.spec.js::should show similarity scores in results', - 'test/e2e/search-modal.spec.js::should support clicking on search results', - 'test/e2e/search-modal.spec.js::should show indexed files count', - 'test/e2e/search-modal.spec.js::should be able to get embedding stats', - 'test/e2e/search-modal.spec.js::should navigate results with arrow keys', - 'test/e2e/search-modal.spec.js::should select result with Enter key', - 'test/e2e/search-modal.spec.js::should support Tab navigation within modal', - 'test/e2e/search-modal.spec.js::should display graph container when tab is active', - 'test/e2e/search-modal.spec.js::should have graph controls when in graph view', - 'test/e2e/search-modal.spec.js::should be able to perform a search', - 'test/e2e/search-modal.spec.js::should have refresh button for rebuilding index', - 'test/e2e/search-modal.spec.js::should have Select All button in search results', - 'test/e2e/search-modal.spec.js::should have Copy Selected Paths button', - 'test/e2e/search-modal.spec.js::should have Move Selected Files option', - 'test/e2e/search-modal.spec.js::should have checkbox UI for result selection', - 'test/e2e/search-modal.spec.js::should display match reason in search results', - 'test/e2e/search-modal.spec.js::should show search mode indicator', - 'test/e2e/search-modal.spec.js::should have cluster visualization in graph view', - 'test/e2e/search-modal.spec.js::should have Create Smart Folder option for clusters', - 'test/e2e/search-modal.spec.js::should have action buttons in graph node UI', - 'test/e2e/smart-folders.spec.js::should open add folder modal', - 'test/e2e/smart-folders.spec.js::should have folder name input in modal', - 'test/e2e/smart-folders.spec.js::should have folder path input or browse button', - 'test/e2e/smart-folders.spec.js::should have keywords input', - 'test/e2e/smart-folders.spec.js::should have description input', - 'test/e2e/smart-folders.spec.js::should have save and cancel buttons', - 'test/e2e/smart-folders.spec.js::should have edit button on folder items', - 'test/e2e/smart-folders.spec.js::should open edit modal when clicking edit', - 'test/e2e/smart-folders.spec.js::should have delete button on folder items', - 'test/e2e/smart-folders.spec.js::should show confirmation before delete', - 'test/e2e/smart-folders.spec.js::should have Continue button', - 'test/e2e/undo-redo.spec.js::should check canUndo state', - 'test/e2e/undo-redo.spec.js::should check canRedo state', - 'test/e2e/undo-redo.spec.js::should get empty history initially', - 'test/e2e/undo-redo.spec.js::should have undo menu item in Edit menu', - 'test/e2e/undo-redo.spec.js::should have redo menu item in Edit menu', - 'test/e2e/undo-redo.spec.js::should have history viewer option', - 'test/e2e/window-state.spec.js::should toggle fullscreen with F11' + 'test/e2e/discover/analysis-history.spec.js::should have history access in settings', + 'test/e2e/discover/analysis-history.spec.js::should have history access from Discover phase', + 'test/e2e/workflows/beta-workflow.spec.js::should close search modal with Escape', + 'test/e2e/workflows/beta-workflow.spec.js::should close settings panel', + 'test/e2e/search/chat-understand.spec.js::should close Knowledge Graph chat with Escape', + 'test/e2e/discover/file-import.spec.js::should show file selection instructions', + 'test/e2e/discover/file-import.spec.js::should display supported file types information', + 'test/e2e/discover/file-import.spec.js::should have folder selection option', + 'test/e2e/discover/file-import.spec.js::should show analyze button (may be disabled initially)', + 'test/e2e/discover/file-import.spec.js::should show file count indicator', + 'test/e2e/discover/file-import.spec.js::should have selection controls', + 'test/e2e/app/keyboard-navigation.spec.js::should navigate backwards with Shift+Tab', + 'test/e2e/app/keyboard-navigation.spec.js::should show visible focus indicators', + 'test/e2e/app/keyboard-navigation.spec.js::should activate buttons with Enter key', + 'test/e2e/app/keyboard-navigation.spec.js::should activate buttons with Space key', + 'test/e2e/app/keyboard-navigation.spec.js::should close search modal with Escape', + 'test/e2e/app/keyboard-navigation.spec.js::should close dropdowns with Escape', + 'test/e2e/app/keyboard-navigation.spec.js::should move forward with Alt+Right', + 'test/e2e/app/keyboard-navigation.spec.js::should open search with Ctrl+K', + 'test/e2e/app/keyboard-navigation.spec.js::should check canUndo state', + 'test/e2e/app/keyboard-navigation.spec.js::should check canRedo state', + 'test/e2e/app/keyboard-navigation.spec.js::should support Ctrl+A in text inputs', + 'test/e2e/app/keyboard-navigation.spec.js::should navigate lists with arrow keys', + 'test/e2e/app/keyboard-navigation.spec.js::should support Home/End in lists', + 'test/e2e/search/knowledge-graph.spec.js::should have clusters toggle button', + 'test/e2e/discover/last-browsed-path.spec.js::should have folder selection option available', + 'test/e2e/discover/last-browsed-path.spec.js::should validate lastBrowsedPath as string type', + 'test/e2e/discover/last-browsed-path.spec.js::should handle very long paths', + 'test/e2e/app/menu-shortcuts.spec.js::should have search shortcut Ctrl+K', + 'test/e2e/app/notifications.spec.js::should have notification container in DOM', + 'test/e2e/app/notifications.spec.js::should have notification API available', + 'test/e2e/app/notifications.spec.js::should display notification container when triggered', + 'test/e2e/app/notifications.spec.js::should support info notification style', + 'test/e2e/app/notifications.spec.js::should support success notification style', + 'test/e2e/app/notifications.spec.js::should support warning notification style', + 'test/e2e/app/notifications.spec.js::should support error notification style', + 'test/e2e/app/notifications.spec.js::should auto-dismiss notifications after timeout', + 'test/e2e/app/notifications.spec.js::should have dismiss button on notifications', + 'test/e2e/app/notifications.spec.js::should stack multiple notifications', + 'test/e2e/app/notifications.spec.js::should position notifications correctly', + 'test/e2e/app/notifications.spec.js::should show error notification on API failure', + 'test/e2e/app/notifications.spec.js::should show notification on connection status change', + 'test/e2e/app/notifications.spec.js::should show notification on settings save success', + 'test/e2e/app/notifications.spec.js::should have proper ARIA role on notifications', + 'test/e2e/app/notifications.spec.js::should be announced to screen readers', + 'test/e2e/search/search-modal.spec.js::should close search with Escape', + 'test/e2e/search/search-modal.spec.js::should have search API available', + 'test/e2e/search/search-modal.spec.js::should clear search on modal close', + 'test/e2e/search/search-modal.spec.js::should have semantic search mode option', + 'test/e2e/search/search-modal.spec.js::should have full-text search mode option', + 'test/e2e/search/search-modal.spec.js::should have hybrid search mode option', + 'test/e2e/search/search-modal.spec.js::should have results container', + 'test/e2e/search/search-modal.spec.js::should show similarity scores in results', + 'test/e2e/search/search-modal.spec.js::should support clicking on search results', + 'test/e2e/search/search-modal.spec.js::should show indexed files count', + 'test/e2e/search/search-modal.spec.js::should be able to get embedding stats', + 'test/e2e/search/search-modal.spec.js::should navigate results with arrow keys', + 'test/e2e/search/search-modal.spec.js::should select result with Enter key', + 'test/e2e/search/search-modal.spec.js::should support Tab navigation within modal', + 'test/e2e/search/search-modal.spec.js::should display graph container when tab is active', + 'test/e2e/search/search-modal.spec.js::should have graph controls when in graph view', + 'test/e2e/search/search-modal.spec.js::should be able to perform a search', + 'test/e2e/search/search-modal.spec.js::should have refresh button for rebuilding index', + 'test/e2e/search/search-modal.spec.js::should have Select All button in search results', + 'test/e2e/search/search-modal.spec.js::should have Copy Selected Paths button', + 'test/e2e/search/search-modal.spec.js::should have Move Selected Files option', + 'test/e2e/search/search-modal.spec.js::should have checkbox UI for result selection', + 'test/e2e/search/search-modal.spec.js::should display match reason in search results', + 'test/e2e/search/search-modal.spec.js::should show search mode indicator', + 'test/e2e/search/search-modal.spec.js::should have cluster visualization in graph view', + 'test/e2e/search/search-modal.spec.js::should have Create Smart Folder option for clusters', + 'test/e2e/search/search-modal.spec.js::should have action buttons in graph node UI', + 'test/e2e/setup/smart-folders.spec.js::should open add folder modal', + 'test/e2e/setup/smart-folders.spec.js::should have folder name input in modal', + 'test/e2e/setup/smart-folders.spec.js::should have folder path input or browse button', + 'test/e2e/setup/smart-folders.spec.js::should have keywords input', + 'test/e2e/setup/smart-folders.spec.js::should have description input', + 'test/e2e/setup/smart-folders.spec.js::should have save and cancel buttons', + 'test/e2e/setup/smart-folders.spec.js::should have edit button on folder items', + 'test/e2e/setup/smart-folders.spec.js::should open edit modal when clicking edit', + 'test/e2e/setup/smart-folders.spec.js::should have delete button on folder items', + 'test/e2e/setup/smart-folders.spec.js::should show confirmation before delete', + 'test/e2e/setup/smart-folders.spec.js::should have Continue button', + 'test/e2e/organize/undo-redo.spec.js::should check canUndo state', + 'test/e2e/organize/undo-redo.spec.js::should check canRedo state', + 'test/e2e/organize/undo-redo.spec.js::should get empty history initially', + 'test/e2e/organize/undo-redo.spec.js::should have undo menu item in Edit menu', + 'test/e2e/organize/undo-redo.spec.js::should have redo menu item in Edit menu', + 'test/e2e/organize/undo-redo.spec.js::should have history viewer option', + 'test/e2e/app/window-state.spec.js::should toggle fullscreen with F11' ]); function findMatchingParen(s, openIdx) { diff --git a/test/fileOperationHandlers.test.js b/test/fileOperationHandlers.test.js index 58f1a92..c83b000 100644 --- a/test/fileOperationHandlers.test.js +++ b/test/fileOperationHandlers.test.js @@ -219,6 +219,61 @@ describe('File Operation Handlers', () => { expect(mockServiceIntegration.undoRedo.recordAction).toHaveBeenCalled(); }); + // A manual move is learned under the category the file was analyzed as; + // without it the pattern is `<ext>:unknown:<folder>` and never matches an + // analyzed file's lookup. + test('a manual move teaches learning with the moved file`s stored analysis', async () => { + const handler = getHandler(IPC_CHANNELS.FILES.PERFORM_OPERATION); + const learning = { recordFileMove: jest.fn().mockResolvedValue(true) }; + require('../src/main/services/organization/learningFeedback').getInstance.mockReturnValue( + learning + ); + const analysis = { category: 'Finance', subject: 'Q1 report' }; + const history = { + getAnalysisByPath: jest.fn(async (p) => (p === '/src/doc.pdf' ? { analysis } : null)) + }; + const { container } = require('../src/main/services/ServiceContainer'); + container.resolve.mockImplementation((id) => + id === 'analysisHistory' ? history : mockFilePathCoordinator + ); + + const result = await handler( + {}, + { type: 'move', source: '/src/doc.pdf', destination: '/dest/doc.pdf' } + ); + + expect(result.success).toBe(true); + expect(history.getAnalysisByPath.mock.calls).toEqual([['/dest/doc.pdf'], ['/src/doc.pdf']]); + expect(learning.recordFileMove).toHaveBeenCalledWith( + '/src/doc.pdf', + '/dest/doc.pdf', + analysis, + 'manual_move' + ); + }); + + test('a manual move of a file with no stored analysis still teaches learning', async () => { + const handler = getHandler(IPC_CHANNELS.FILES.PERFORM_OPERATION); + const learning = { recordFileMove: jest.fn().mockResolvedValue(true) }; + require('../src/main/services/organization/learningFeedback').getInstance.mockReturnValue( + learning + ); + const history = { getAnalysisByPath: jest.fn().mockRejectedValue(new Error('history down')) }; + const { container } = require('../src/main/services/ServiceContainer'); + container.resolve.mockImplementation((id) => + id === 'analysisHistory' ? history : mockFilePathCoordinator + ); + + await handler({}, { type: 'move', source: '/src/doc.pdf', destination: '/dest/doc.pdf' }); + + expect(learning.recordFileMove).toHaveBeenCalledWith( + '/src/doc.pdf', + '/dest/doc.pdf', + null, + 'manual_move' + ); + }); + test('batch_move moves every file and records ONE MOVE_BATCH undo entry (UR-04)', async () => { const handler = getHandler(IPC_CHANNELS.FILES.PERFORM_OPERATION); fs.rename.mockImplementation(async (src) => { diff --git a/test/learningFeedback.patternRoundTrip.test.js b/test/learningFeedback.patternRoundTrip.test.js index 7e1047d..88611d8 100644 --- a/test/learningFeedback.patternRoundTrip.test.js +++ b/test/learningFeedback.patternRoundTrip.test.js @@ -58,6 +58,46 @@ describe('learned placements round-trip into pattern suggestions', () => { expect(suggestions).toEqual([]); }); + // Suggestion lookups carry the extension as path.extname returns it. + test('a manual move of an analyzed file suggests that folder for a `.pdf` lookup', () => { + const matcher = new PatternMatcher(); + matcher.recordFeedback( + buildFileMetadata('/library/Finance/q1-report.pdf', { category: 'Finance' }), + buildFolderSuggestion(finance, 1), + true + ); + + const suggestions = matcher.getPatternBasedSuggestions({ + name: 'q2-report.pdf', + extension: '.pdf', + analysis: { category: 'Finance' } + }); + + expect(suggestions.map((s) => s.folder)).toEqual(['Finance']); + }); + + // Batch organize records with the file's category; undo retracts knowing + // only the path the file landed at. + test('undo takes back the pattern a batch organize recorded with a category', () => { + const matcher = new PatternMatcher(); + const landed = '/library/Finance/q1-report.pdf'; + matcher.recordFeedback( + buildFileMetadata(landed, { category: 'Invoices' }), + buildFolderSuggestion(finance, 0.9), + true + ); + expect(matcher.userPatterns.size).toBe(1); + + const result = matcher.retractFeedback( + buildFileMetadata(landed, null), + buildFolderSuggestion(finance, 1) + ); + + expect(result).toEqual({ retracted: true }); + expect(matcher.userPatterns.size).toBe(0); + expect(matcher.feedbackHistory).toHaveLength(0); + }); + test('flat feedback metadata and analysis-bearing files produce the same key', () => { const matcher = new PatternMatcher(); expect(matcher.extractPattern({ extension: 'pdf', category: 'Finance' })).toBe( diff --git a/test/main/services/liveLogActivity.test.js b/test/main/services/liveLogActivity.test.js index 77bda89..eba9488 100644 --- a/test/main/services/liveLogActivity.test.js +++ b/test/main/services/liveLogActivity.test.js @@ -299,17 +299,17 @@ const CASES = [ expected: { kind: 'model', icon: 'cpu', tone: 'info', text: 'Unloading embedding model' } }, { - rule: 'embedding-unloaded-for-vision', + rule: 'embedding-returning-to-gpu', record: { level: 'info', context: 'LlamaService', - msg: '[LlamaService] Unloaded the CPU embedding model from the vision batch' + msg: '[LlamaService] Unloaded the CPU embedding model; the next load uses the GPU' }, expected: { kind: 'model', icon: 'cpu', tone: 'info', - text: 'Embedding model unloaded for the vision batch' + text: 'Embedding model moving back to the GPU' } }, { @@ -853,9 +853,9 @@ const SOURCES = { literals: ["'[Memory] Unloading model'"], context: 'ModelMemoryManager' }, - 'embedding-unloaded-for-vision': { + 'embedding-returning-to-gpu': { file: 'src/main/services/LlamaService.js', - literals: ["'[LlamaService] Unloaded the CPU embedding model from the vision batch'"], + literals: ["'[LlamaService] Unloaded the CPU embedding model; the next load uses the GPU'"], context: 'LlamaService' }, 'embeddings-flushed': { diff --git a/test/patternMatcher.test.js b/test/patternMatcher.test.js index 3957485..dc27d29 100644 --- a/test/patternMatcher.test.js +++ b/test/patternMatcher.test.js @@ -439,5 +439,97 @@ describe('PatternMatcher', () => { expect(matcher.retractFeedback(file, suggestion)).toEqual({ retracted: false }); expect(matcher.retractFeedback(null, suggestion)).toEqual({ retracted: false }); }); + + // Undo only knows the path the file landed at, so it retracts without the + // category the organize recorded. The history row carries the key. + test('reverses the recorded pattern when the retract call has no category', () => { + matcher.recordFeedback(file, suggestion, true); + const pattern = matcher.extractPattern(file, suggestion); + expect(matcher.feedbackHistory[0].pattern).toBe(pattern); + + const result = matcher.retractFeedback({ name: 'q1.pdf', extension: 'pdf' }, suggestion); + + expect(result).toEqual({ retracted: true }); + expect(matcher.userPatterns.has(pattern)).toBe(false); + expect(matcher.feedbackHistory).toHaveLength(0); + }); + + test('a history row saved without a key still retracts through extractPattern', () => { + const pattern = matcher.extractPattern(file, suggestion); + matcher.loadPatterns({ + patterns: [[pattern, { folder: 'Finance', count: 1, confidence: 0.6 }]], + feedbackHistory: [ + { + timestamp: Date.now(), + file: { name: 'q1.pdf', type: '.pdf' }, + suggestion, + accepted: true + } + ] + }); + + expect(matcher.retractFeedback(file, suggestion)).toEqual({ retracted: true }); + expect(matcher.userPatterns.has(pattern)).toBe(false); + }); + }); + + // Callers disagree on the extension's shape: analysis and suggestion files + // carry `.pdf`, learning feedback records `pdf`. The key has one form. + describe('extension form in pattern keys', () => { + const suggestion = { folder: 'Finance', path: '/docs/Finance' }; + + test('dotted and undotted extensions produce the same key', () => { + expect(matcher.extractPattern({ extension: '.PDF', category: 'Finance' }, suggestion)).toBe( + 'pdf:finance:finance' + ); + expect(matcher.extractPattern({ extension: 'pdf', category: 'Finance' }, suggestion)).toBe( + 'pdf:finance:finance' + ); + }); + + test('a pattern recorded without the dot matches a lookup that has it', () => { + matcher.recordFeedback( + { name: 'q1.pdf', extension: 'pdf', category: 'Finance' }, + suggestion, + true + ); + + const suggestions = matcher.getPatternBasedSuggestions({ + name: 'q2.pdf', + extension: '.pdf', + analysis: { category: 'Finance' } + }); + + expect(suggestions.map((s) => s.folder)).toEqual(['Finance']); + }); + + test('loadPatterns rewrites saved dotted keys and merges them into the undotted twin', () => { + matcher.loadPatterns({ + patterns: [ + [ + '.pdf:finance:finance', + { folder: 'Finance', count: 2, confidence: 0.7, lastUsed: 200, createdAt: 50 } + ], + [ + 'pdf:finance:finance', + { folder: 'Finance', count: 3, confidence: 0.8, lastUsed: 100, createdAt: 80 } + ], + ['.jpg:photos:pics', { folder: 'Pics', count: 1, confidence: 0.6 }] + ] + }); + + expect(Array.from(matcher.userPatterns.keys()).sort()).toEqual([ + 'jpg:photos:pics', + 'pdf:finance:finance' + ]); + expect(matcher.userPatterns.get('pdf:finance:finance')).toEqual({ + folder: 'Finance', + count: 5, + confidence: 0.8, + lastUsed: 200, + createdAt: 50 + }); + expect(matcher.userPatterns.get('jpg:photos:pics').count).toBe(1); + }); }); }); diff --git a/test/unit/atlasMapUtils.test.js b/test/unit/atlasMapUtils.test.js index 43ddd9e..0aa1be7 100644 --- a/test/unit/atlasMapUtils.test.js +++ b/test/unit/atlasMapUtils.test.js @@ -7,7 +7,9 @@ const { ATLAS_OTHER_COLOR, ATLAS_KIND_COLORS, ATLAS_SEARCH_TOP_K, - atlasIpcFailureMessage + atlasIpcFailureMessage, + ATLAS_FOLDER_KEYWORD_LIMIT, + describeSelectionForFolder } = require('../../src/renderer/components/search/atlasMapUtils'); describe('atlasMapUtils', () => { @@ -167,4 +169,49 @@ describe('atlasMapUtils', () => { ); }); }); + + // "Smart folder…" on a lasso selection used to create a folder that knew + // nothing about the files it was made from. + describe('describeSelectionForFolder', () => { + test('seeds the folder with the topics the selected files share most', () => { + const result = describeSelectionForFolder([ + { keywords: ['Invoice', 'tax'], tags: ['2025'] }, + { keywords: ['invoice', 'receipt'], tags: [] }, + { keywords: ['Invoice'], tags: ['2025'] } + ]); + + expect(result.keywords).toEqual(['Invoice', '2025', 'tax', 'receipt']); + expect(result.description).toBe( + 'Created from an Atlas selection of 3 files. Common topics: Invoice, 2025, tax, receipt.' + ); + }); + + test('counts a topic once per file, whether it is a keyword, a tag or both', () => { + const result = describeSelectionForFolder([ + { keywords: ['alpha', 'Alpha'], tags: ['ALPHA'] }, + { keywords: ['beta'], tags: ['beta'] }, + { keywords: ['beta'] } + ]); + + expect(result.keywords).toEqual(['beta', 'alpha']); + }); + + test('keeps no more keywords than the limit', () => { + const many = Array.from({ length: ATLAS_FOLDER_KEYWORD_LIMIT + 5 }, (_, i) => `topic-${i}`); + + expect(describeSelectionForFolder([{ keywords: many }]).keywords).toHaveLength( + ATLAS_FOLDER_KEYWORD_LIMIT + ); + }); + + test('files without metadata still count, and yield no keywords', () => { + expect(describeSelectionForFolder([null, undefined, { keywords: 'not a list' }])).toEqual({ + description: 'Created from an Atlas selection of 3 files', + keywords: [] + }); + expect(describeSelectionForFolder([{}]).description).toBe( + 'Created from an Atlas selection of 1 file' + ); + }); + }); });