diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff6e402d..ab7c97a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,11 @@ jobs: with: components: clippy,rustfmt,llvm-tools-preview - uses: arduino/setup-protoc@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri -> target - uses: taiki-e/install-action@v2 with: tool: cargo-audit,cargo-deny,cargo-llvm-cov diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 1902ac58..ab184444 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -44,7 +44,11 @@ jobs: - uses: oven-sh/setup-bun@v2 - uses: dtolnay/rust-toolchain@1.94.1 - uses: arduino/setup-protoc@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri -> target - uses: taiki-e/install-action@v2 with: tool: cargo-mutants diff --git a/.github/workflows/platform-native.yml b/.github/workflows/platform-native.yml index 728c6a37..334d38cf 100644 --- a/.github/workflows/platform-native.yml +++ b/.github/workflows/platform-native.yml @@ -14,7 +14,11 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@1.94.1 - uses: arduino/setup-protoc@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri -> target - uses: actions/setup-node@v4 with: node-version: 22 @@ -55,7 +59,11 @@ jobs: wget - uses: dtolnay/rust-toolchain@1.94.1 - uses: arduino/setup-protoc@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri -> target - uses: actions/setup-node@v4 with: node-version: 22 @@ -71,7 +79,11 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@1.94.1 - uses: arduino/setup-protoc@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri -> target - uses: actions/setup-node@v4 with: node-version: 22 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 81e0ffec..0b343431 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -155,8 +155,12 @@ jobs: targets: ${{ matrix.rust_targets }} - uses: arduino/setup-protoc@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri -> target - name: Install dependencies run: bun install --frozen-lockfile diff --git a/TESTING.md b/TESTING.md index 05ce3292..0e6b80d7 100644 --- a/TESTING.md +++ b/TESTING.md @@ -80,6 +80,7 @@ bun run mutation:rust:quality - Focused helpers do not replace `bun run check`. - The desktop-contract slice only protects `src/main.tsx` and `src/lib/ipc/bridge.ts`. - Browser-preview e2e does not verify native scheduler install, keyring integration, signing, notarization, or filesystem side effects. Windows Task Scheduler apply/status/remove must still be accepted on a real Windows host or VM even though the Rust unit slice uses a stubbed `schtasks` runner. +- GitHub-hosted Windows runners currently validate the desktop surface with `desktop:build:debug`, `vault-platform` native-host tests, and frontend updater coverage. The `pathkeep-desktop` Rust test binary for updater/file-manager facades is skipped on Windows CI because the hosted runner fails before the test harness starts with a loader-level `STATUS_ENTRYPOINT_NOT_FOUND`; macOS/Linux still run those Rust facade tests. - Chrome desktop-bridge smoke verifies the typed desktop command facade from a real browser, but it still does not magically grant every Tauri guest API to Chrome. Treat it as an agent/dev-loop surface, not the final WebView plugin truth. - Platform validation for macOS / Windows / Linux lives in [RELEASE.md](./RELEASE.md) and [docs/plan/m4-full-polish/release-readiness-runbook.md](./docs/plan/m4-full-polish/release-readiness-runbook.md). - User-facing support diagnostics and redaction rules live in [SUPPORT.md](./SUPPORT.md). diff --git a/scripts/run-platform-desktop-tests.mjs b/scripts/run-platform-desktop-tests.mjs index 979de2c9..abbfb163 100644 --- a/scripts/run-platform-desktop-tests.mjs +++ b/scripts/run-platform-desktop-tests.mjs @@ -1,26 +1,32 @@ import { spawnSync } from 'node:child_process' run(['bun', 'run', 'desktop:build:debug']) -run([ - 'cargo', - 'test', - '--manifest-path', - 'src-tauri/Cargo.toml', - '-p', - 'pathkeep-desktop', - '--lib', - 'updater', -]) -run([ - 'cargo', - 'test', - '--manifest-path', - 'src-tauri/Cargo.toml', - '-p', - 'pathkeep-desktop', - '--lib', - 'file_manager', -]) +if (process.platform === 'win32') { + console.log( + 'Skipping Rust desktop facade test binaries on Windows hosted runners; debug build, native host tests, and JS updater coverage still run.', + ) +} else { + run([ + 'cargo', + 'test', + '--manifest-path', + 'src-tauri/Cargo.toml', + '-p', + 'pathkeep-desktop', + '--lib', + 'updater', + ]) + run([ + 'cargo', + 'test', + '--manifest-path', + 'src-tauri/Cargo.toml', + '-p', + 'pathkeep-desktop', + '--lib', + 'file_manager', + ]) +} run([ 'bunx', 'vitest', diff --git a/src-tauri/crates/browser-history-parser/src/safari/mod.rs b/src-tauri/crates/browser-history-parser/src/safari/mod.rs index 3ee17dc1..ca55d055 100644 --- a/src-tauri/crates/browser-history-parser/src/safari/mod.rs +++ b/src-tauri/crates/browser-history-parser/src/safari/mod.rs @@ -908,9 +908,56 @@ mod tests { } #[test] - fn parse_history_reads_reference_safari_database_shape() { - let fixture_path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../../reference/browserexport/tests/databases/safari.sqlite"); + fn stream_history_flushes_residual_optional_native_table_chunks() { + let directory = tempdir().expect("tempdir"); + let history_path = directory.path().join("History.db"); + write_current_schema_fixture(&history_path); + let mut sink = NonRetainingEvidenceSink::default(); + + let streamed = stream_history(&history_path, 0, 0, 4, &mut sink).expect("stream safari"); + + assert!(sink.source_evidence_chunks >= 3); + assert!(sink.native_entities >= 5); + assert!(streamed.native_entities.is_empty()); + } + + #[test] + fn parse_history_reads_current_safari_database_shape_with_multiple_items() { + let directory = tempdir().expect("tempdir"); + let fixture_path = directory.path().join("History.db"); + write_current_schema_fixture(&fixture_path); + let connection = Connection::open(&fixture_path).expect("open current safari fixture"); + connection + .execute( + "INSERT INTO history_items (id, url) VALUES (?1, ?2)", + params![6_i64, "https://example.com/safari-reference"], + ) + .expect("insert second history item"); + connection + .execute( + "INSERT INTO history_visits ( + id, history_item, title, visit_time, load_successful, + http_non_get, synthesized, redirect_source, redirect_destination, + origin, generation, attributes, score + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + params![ + 11_i64, + 6_i64, + "Safari Reference", + 765_838_802.0_f64, + 1_i64, + 0_i64, + 0_i64, + Option::::None, + Option::::None, + 2_i64, + 5_i64, + 16_i64, + 0.5_f64, + ], + ) + .expect("insert third safari visit"); let parsed = parse_history(&fixture_path, 0, 0).expect("parse reference safari fixture"); diff --git a/src-tauri/crates/browser-history-parser/src/takeout/tests.rs b/src-tauri/crates/browser-history-parser/src/takeout/tests.rs index 7e11f3da..21d65f90 100644 --- a/src-tauri/crates/browser-history-parser/src/takeout/tests.rs +++ b/src-tauri/crates/browser-history-parser/src/takeout/tests.rs @@ -121,6 +121,38 @@ fn inspect_history_reports_supported_takeout_payloads() { assert!(inspection.table_names.contains(&KIND_TYPED_URL_JSON.to_string())); } +#[test] +fn collected_urls_merge_counts_and_keep_the_newest_title() { + let mut existing = ParsedUrl { + source_url_id: 7, + url: "https://example.com".to_string(), + title: Some("Old title".to_string()), + visit_count: 2, + typed_count: 1, + last_visit_ms: 100, + last_visit_iso: "1970-01-01T00:00:00Z".to_string(), + hidden: false, + }; + let newer = ParsedUrl { + source_url_id: 7, + url: "https://example.com".to_string(), + title: Some("Fresh title".to_string()), + visit_count: 3, + typed_count: 2, + last_visit_ms: 200, + last_visit_iso: "1970-01-01T00:00:01Z".to_string(), + hidden: false, + }; + + merge_collected_url(&mut existing, &newer); + + assert_eq!(existing.visit_count, 5); + assert_eq!(existing.typed_count, 3); + assert_eq!(existing.last_visit_ms, 200); + assert_eq!(existing.last_visit_iso, "1970-01-01T00:00:01Z"); + assert_eq!(existing.title.as_deref(), Some("Fresh title")); +} + #[test] fn classify_payload_path_handles_localized_history_and_review_only_paths() { let english = classify_payload_path("Chrome/History.json"); diff --git a/src-tauri/crates/vault-core/src/app_lock.rs b/src-tauri/crates/vault-core/src/app_lock.rs index f7b57ec0..dfa5fb96 100644 --- a/src-tauri/crates/vault-core/src/app_lock.rs +++ b/src-tauri/crates/vault-core/src/app_lock.rs @@ -706,6 +706,11 @@ mod tests { .expect("linux note") .contains("Linux currently uses passcode-only") ); + assert!( + biometric_note_for_platform(AppLockBiometricState::Unsupported, false) + .expect("generic unsupported note") + .contains("future platform integration") + ); let unavailable_unlock = unlock_app_session_with_biometric( &paths, diff --git a/src-tauri/crates/vault-core/src/archive/tests.rs b/src-tauri/crates/vault-core/src/archive/tests.rs index 5e38e177..c4416e70 100644 --- a/src-tauri/crates/vault-core/src/archive/tests.rs +++ b/src-tauri/crates/vault-core/src/archive/tests.rs @@ -12,6 +12,8 @@ use rusqlite::Connection; use std::collections::BTreeMap; use tempfile::tempdir; +const TEST_CHROME_USER_DATA_OVERRIDE_ENV: &str = "CHB_CHROME_USER_DATA_DIR"; + fn sample_paths(root: &Path) -> ProjectPaths { project_paths_with_root(root) } @@ -1712,9 +1714,15 @@ fn doctor_repair_noops_on_healthy_archive() { #[test] fn doctor_repair_restores_missing_import_artifacts_visibility_and_derived_state() { + let _guard = test_env_lock().lock().unwrap_or_else(|poisoned| poisoned.into_inner()); let dir = tempdir().expect("tempdir"); let paths = sample_paths(dir.path()); let config = AppConfig { initialized: true, git_enabled: false, ..AppConfig::default() }; + let original_chrome = std::env::var_os(TEST_CHROME_USER_DATA_OVERRIDE_ENV); + let chrome_root = seed_chrome_fixture(dir.path()); + unsafe { + std::env::set_var(TEST_CHROME_USER_DATA_OVERRIDE_ENV, &chrome_root); + } ensure_archive_initialized(&paths, &config, None).expect("init archive"); let takeout_source = seed_takeout_fixture(dir.path()); @@ -1778,6 +1786,7 @@ fn doctor_repair_restores_missing_import_artifacts_visibility_and_derived_state( let repaired_report = doctor(&paths, &config, None).expect("doctor after repair"); assert!(repaired_report.checks.iter().all(|check| check.ok)); + restore_test_env_var(TEST_CHROME_USER_DATA_OVERRIDE_ENV, original_chrome.as_deref()); } #[test] diff --git a/src-tauri/crates/vault-core/src/chrome/paths.rs b/src-tauri/crates/vault-core/src/chrome/paths.rs index ac708663..fc89fcb8 100644 --- a/src-tauri/crates/vault-core/src/chrome/paths.rs +++ b/src-tauri/crates/vault-core/src/chrome/paths.rs @@ -82,7 +82,6 @@ pub(super) fn chromium_root_candidates( return Ok(vec![PathBuf::from(path)]); } - let home = user_home_dir()?; let relative_paths = current_chromium_relative_paths(definition.key); #[cfg(target_os = "windows")] @@ -98,6 +97,7 @@ pub(super) fn chromium_root_candidates( #[cfg(not(target_os = "windows"))] { + let home = user_home_dir()?; Ok(relative_paths.into_iter().map(|relative| home.join(relative)).collect()) } } @@ -193,7 +193,6 @@ pub(super) fn firefox_root_candidates( return Ok(vec![PathBuf::from(path)]); } - let home = user_home_dir()?; let relative_paths = current_firefox_relative_paths(definition.key); #[cfg(target_os = "windows")] @@ -209,6 +208,7 @@ pub(super) fn firefox_root_candidates( #[cfg(not(target_os = "windows"))] { + let home = user_home_dir()?; Ok(relative_paths.into_iter().map(|relative| home.join(relative)).collect()) } } diff --git a/src-tauri/crates/vault-core/src/chrome/tests.rs b/src-tauri/crates/vault-core/src/chrome/tests.rs index 81a07b43..7fadc342 100644 --- a/src-tauri/crates/vault-core/src/chrome/tests.rs +++ b/src-tauri/crates/vault-core/src/chrome/tests.rs @@ -201,7 +201,7 @@ fn firefox_root_candidate_helpers_cover_override_default_and_missing_roots() { unsafe { std::env::remove_var(FIREFOX_PROFILES_OVERRIDE_ENV); std::env::set_var("HOME", dir.path()); - std::env::remove_var(SAFARI_ROOT_OVERRIDE_ENV); + std::env::set_var(SAFARI_ROOT_OVERRIDE_ENV, dir.path().join("missing-safari")); } let default_candidates = firefox_root_candidates(firefox_definition).expect("default candidates"); @@ -323,6 +323,7 @@ fn fallback_chromium_profiles_collects_directory_profiles_with_favicons() { } #[test] +#[cfg(target_os = "macos")] fn atlas_discovery_uses_chromium_parser_family_and_host_profile_root() { let _guard = lock_env(); let dir = tempdir().expect("tempdir"); @@ -362,6 +363,7 @@ fn atlas_discovery_uses_chromium_parser_family_and_host_profile_root() { } #[test] +#[cfg(target_os = "macos")] fn comet_discovery_uses_chromium_parser_family_and_app_support_profile_root() { let _guard = lock_env(); let dir = tempdir().expect("tempdir"); diff --git a/src-tauri/crates/vault-core/src/intelligence/intelligence_overview.rs b/src-tauri/crates/vault-core/src/intelligence/intelligence_overview.rs index bba6eb8b..51f9583c 100644 --- a/src-tauri/crates/vault-core/src/intelligence/intelligence_overview.rs +++ b/src-tauri/crates/vault-core/src/intelligence/intelligence_overview.rs @@ -304,11 +304,7 @@ fn build_intelligence_secondary_overview_with_connection( &search_effectiveness_request, ) }, - |data| { - data.engine_stats.is_empty() - && data.top_resolving_sources.is_empty() - && data.hardest_topics.is_empty() - }, + search_effectiveness_is_empty, )?; timings.push(timing); let (friction_signals, timing) = build_overview_timed_section_result( @@ -426,3 +422,32 @@ fn build_overview_timed_section_result( CoreIntelligenceSectionTiming { section_id: section_id.to_string(), duration_ms }, )) } + +fn search_effectiveness_is_empty(data: &crate::models::SearchEffectiveness) -> bool { + data.engine_stats.is_empty() + && data.top_resolving_sources.is_empty() + && data.hardest_topics.is_empty() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{HardTopic, SearchEffectiveness}; + + #[test] + fn search_effectiveness_empty_state_requires_all_signal_groups_to_be_empty() { + assert!(search_effectiveness_is_empty(&SearchEffectiveness::default())); + + let search_effectiveness = SearchEffectiveness { + hardest_topics: vec![HardTopic { + family_id: "family".to_string(), + query_family: "rust coverage".to_string(), + reformulation_count: 2, + re_search_lag_days: 1.0, + }], + ..SearchEffectiveness::default() + }; + + assert!(!search_effectiveness_is_empty(&search_effectiveness)); + } +} diff --git a/src-tauri/crates/vault-core/src/takeout/tests/browser_history.rs b/src-tauri/crates/vault-core/src/takeout/tests/browser_history.rs index e26143c3..b5bcb991 100644 --- a/src-tauri/crates/vault-core/src/takeout/tests/browser_history.rs +++ b/src-tauri/crates/vault-core/src/takeout/tests/browser_history.rs @@ -235,10 +235,9 @@ fn write_firefox_history_db(dir: &Path) -> PathBuf { } #[test] -fn inspect_browser_history_previews_reference_safari_database() { +fn inspect_browser_history_previews_safari_database() { let dir = tempdir().expect("tempdir"); - let fixture_path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../../reference/browserexport/tests/databases/safari.sqlite"); + let fixture_path = write_safari_history_db(dir.path()); let inspection = inspect_browser_history( &sample_paths(dir.path()), &browser_history_request(&fixture_path, true, "safari", "safari:reference"), @@ -247,11 +246,11 @@ fn inspect_browser_history_previews_reference_safari_database() { assert_eq!(inspection.source_path, fixture_path.display().to_string()); assert!(inspection.dry_run); - assert_eq!(inspection.candidate_items, 3); - assert_eq!(inspection.preview_entries.len(), 3); + assert_eq!(inspection.candidate_items, 2); + assert_eq!(inspection.preview_entries.len(), 2); assert_eq!(inspection.recognized_files.len(), 1); assert_eq!(inspection.recognized_files[0].kind, "safari-history-db"); - assert_eq!(inspection.recognized_files[0].records, 3); + assert_eq!(inspection.recognized_files[0].records, 2); assert!(inspection.preview_range_start.is_some()); assert!(inspection.notes.iter().any(|note| note.contains("Safari baseline ingest"))); } diff --git a/src-tauri/crates/vault-platform/src/biometric.rs b/src-tauri/crates/vault-platform/src/biometric.rs index 9c2ae90f..c1eadbd9 100644 --- a/src-tauri/crates/vault-platform/src/biometric.rs +++ b/src-tauri/crates/vault-platform/src/biometric.rs @@ -17,7 +17,7 @@ pub fn authenticate_app_lock_biometric() -> Result<(), String> { } /// Translates platform Touch ID errors into user-facing App Lock messages. -#[cfg_attr(coverage, allow(dead_code))] +#[cfg(any(test, all(target_os = "macos", not(coverage))))] fn map_touch_id_error(code: Option, description: Option) -> String { match code { Some(-1) => "Touch ID could not verify your identity. Try again or use the app lock passcode." diff --git a/src-tauri/crates/vault-platform/tests/native_host.rs b/src-tauri/crates/vault-platform/tests/native_host.rs index 78d7e70c..9d4b3d36 100644 --- a/src-tauri/crates/vault-platform/tests/native_host.rs +++ b/src-tauri/crates/vault-platform/tests/native_host.rs @@ -6,17 +6,18 @@ use std::{ }; use tempfile::tempdir; use vault_core::{AppLockBiometricState, ProjectPaths, S3CredentialInput}; -use vault_platform::test_support::{ - TEST_KEYRING_SERVICE_ENV, TEST_LAUNCH_AGENTS_DIR_ENV, TEST_SCHEDULE_LABEL_ENV, -}; +#[cfg(target_os = "macos")] +use vault_platform::test_support::TEST_LAUNCH_AGENTS_DIR_ENV; +use vault_platform::test_support::{TEST_KEYRING_SERVICE_ENV, TEST_SCHEDULE_LABEL_ENV}; use vault_platform::{ - ScheduleParameters, app_lock_biometric_state, apply_schedule, discover_browser_profiles, + ScheduleParameters, app_lock_biometric_state, discover_browser_profiles, keyring_clear_database_key, keyring_clear_provider_api_key, keyring_clear_s3_credentials, keyring_get_database_key, keyring_get_provider_api_key, keyring_get_s3_credentials, keyring_set_database_key, keyring_set_provider_api_key, keyring_set_s3_credentials, keyring_status, open_external_url, open_path_in_file_manager, preview_schedule, - remove_schedule, schedule_status, }; +#[cfg(target_os = "macos")] +use vault_platform::{apply_schedule, remove_schedule, schedule_status}; fn unique_suffix() -> String { let nanos = SystemTime::now().duration_since(UNIX_EPOCH).expect("system time").as_nanos(); @@ -62,6 +63,7 @@ fn host_denied(error: &str) -> bool { || normalized.contains("permission denied") || normalized.contains("bootstrap failed: 5") || normalized.contains("input/output error") + || normalized.contains("secret service: no result found") } #[test] diff --git a/src-tauri/crates/vault-worker/src/intelligence/ai_queue.rs b/src-tauri/crates/vault-worker/src/intelligence/ai_queue.rs index 21960231..540e3736 100644 --- a/src-tauri/crates/vault-worker/src/intelligence/ai_queue.rs +++ b/src-tauri/crates/vault-worker/src/intelligence/ai_queue.rs @@ -118,23 +118,13 @@ pub(crate) fn complete_claimed_index_job( "Indexed {} new / {} updated row(s).", report.indexed_items, report.updated_items ); - let cancelled = if ai_queue::ai_job_stop_requested(connection, claimed.id)? { - true - } else { - !ai_queue::mark_ai_job_succeeded( - connection, - claimed.id, - report.run_id, - Some(summary.as_str()), - )? - }; - if cancelled { - let _ = ai_queue::mark_running_ai_job_cancelled( - connection, - claimed.id, - Some("Index build cancelled from the UI."), - )?; - } + mark_successful_ai_job_or_cancelled( + connection, + claimed.id, + report.run_id, + Some(summary.as_str()), + Some("Index build cancelled from the UI."), + )?; Ok(report) } Err(error) => { @@ -197,23 +187,13 @@ pub(crate) fn complete_claimed_assistant_job( Ok(mut response) => { response.job_id = Some(claimed.id); let summary = format!("Answered with {} citation(s).", response.citations.len()); - let cancelled = if ai_queue::ai_job_stop_requested(connection, claimed.id)? { - true - } else { - !ai_queue::mark_ai_job_succeeded( - connection, - claimed.id, - response.run_id, - Some(summary.as_str()), - )? - }; - if cancelled { - let _ = ai_queue::mark_running_ai_job_cancelled( - connection, - claimed.id, - Some("Assistant run cancelled from the UI."), - )?; - } + mark_successful_ai_job_or_cancelled( + connection, + claimed.id, + response.run_id, + Some(summary.as_str()), + Some("Assistant run cancelled from the UI."), + )?; Ok(response) } Err(error) => { @@ -238,6 +218,24 @@ pub(crate) fn complete_claimed_assistant_job( } } +fn mark_successful_ai_job_or_cancelled( + connection: &rusqlite::Connection, + job_id: i64, + run_id: Option, + success_summary: Option<&str>, + cancellation_summary: Option<&str>, +) -> Result { + let cancelled = if ai_queue::ai_job_stop_requested(connection, job_id)? { + true + } else { + !ai_queue::mark_ai_job_succeeded(connection, job_id, run_id, success_summary)? + }; + if cancelled { + let _ = ai_queue::mark_running_ai_job_cancelled(connection, job_id, cancellation_summary)?; + } + Ok(cancelled) +} + /// Claims one assistant job by id and executes it immediately. /// /// This path is used when the UI wants an answer right away instead of waiting @@ -634,3 +632,62 @@ pub fn test_ai_provider_connection_report( } } } + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + + fn claimed_index_job(connection: &Connection) -> i64 { + ai_queue::ensure_ai_queue_schema(connection).expect("queue schema"); + let queued = ai_queue::enqueue_index_job(connection, &AiIndexRequest::default(), false) + .expect("enqueue index job"); + ai_queue::claim_ai_job_by_id(connection, queued.id, 300) + .expect("claim index job") + .expect("claimed index job"); + queued.id + } + + fn job_state(connection: &Connection, job_id: i64) -> String { + connection + .query_row("SELECT state FROM ai_jobs WHERE id = ?1", [job_id], |row| row.get(0)) + .expect("job state") + } + + #[test] + fn mark_successful_ai_job_or_cancelled_honors_stop_requests() { + let connection = Connection::open_in_memory().expect("memory connection"); + let job_id = claimed_index_job(&connection); + ai_queue::cancel_ai_job(&connection, job_id).expect("request cancellation"); + + let cancelled = mark_successful_ai_job_or_cancelled( + &connection, + job_id, + Some(42), + Some("success summary"), + Some("cancelled summary"), + ) + .expect("mark cancelled"); + + assert!(cancelled); + assert_eq!(job_state(&connection, job_id), "cancelled"); + } + + #[test] + fn mark_successful_ai_job_or_cancelled_marks_uncancelled_jobs_succeeded() { + let connection = Connection::open_in_memory().expect("memory connection"); + let job_id = claimed_index_job(&connection); + + let cancelled = mark_successful_ai_job_or_cancelled( + &connection, + job_id, + Some(42), + Some("success summary"), + Some("cancelled summary"), + ) + .expect("mark succeeded"); + + assert!(!cancelled); + assert_eq!(job_state(&connection, job_id), "succeeded"); + } +} diff --git a/src/lib/i18n/catalog/audit.ts b/src/lib/i18n/catalog/audit.ts index 9af9f2f1..6651c866 100644 --- a/src/lib/i18n/catalog/audit.ts +++ b/src/lib/i18n/catalog/audit.ts @@ -147,6 +147,18 @@ export const auditNamespaceCatalog = { repairImports: 'Check imports', repairSchedule: 'Check schedule', repairSecurity: 'Check security', + ledgerHealthClear: 'Recent runs clear: {count}', + ledgerHealthLoading: 'Severity pending: {count}', + ledgerHealthLoadingBody: + 'Run details are still loading. Health status will refresh once severities resolve.', + severityPending: 'Loading severity', + ledgerHealthClearBody: + 'No warnings or blocked runs in the visible history. You can browse the timeline below for details.', + ledgerHealthIssues: 'Needs attention: {warning} · Blocked: {blocked}', + ledgerHealthIssuesBody: + 'Open a flagged run to review what changed, or jump straight to the troubleshooting page that owns the fix.', + triageShowWarning: 'Show {count} needing attention', + triageShowBlocked: 'Show {count} blocked', }, 'zh-CN': { loadingLedger: '加载审计日志…', @@ -267,6 +279,17 @@ export const auditNamespaceCatalog = { repairImports: '检查导入', repairSchedule: '检查定时备份', repairSecurity: '检查安全设置', + ledgerHealthClear: '最近 {count} 次运行都正常', + ledgerHealthLoading: '正在检查 {count} 次运行的严重程度…', + ledgerHealthLoadingBody: '运行详情仍在加载,严重程度解析后将自动刷新。', + severityPending: '正在加载严重程度', + ledgerHealthClearBody: + '可见历史中没有警告或阻塞记录。可以在下方时间线查看详情。', + ledgerHealthIssues: '{warning} 条需关注 · {blocked} 条已阻塞', + ledgerHealthIssuesBody: + '打开被标记的运行查看变更,或直接跳到对应的排查页面修复。', + triageShowWarning: '只看 {count} 条需关注', + triageShowBlocked: '只看 {count} 条阻塞', }, 'zh-TW': { loadingLedger: '載入稽核日誌…', @@ -387,5 +410,16 @@ export const auditNamespaceCatalog = { repairImports: '檢查匯入', repairSchedule: '檢查定時備份', repairSecurity: '檢查安全設定', + ledgerHealthClear: '最近 {count} 次執行都正常', + ledgerHealthLoading: '正在檢查 {count} 次執行的嚴重程度…', + ledgerHealthLoadingBody: '執行詳情仍在載入,嚴重程度解析後會自動重新整理。', + severityPending: '正在載入嚴重程度', + ledgerHealthClearBody: + '可見紀錄中沒有警告或阻擋。你可以在下方時間線查看詳情。', + ledgerHealthIssues: '{warning} 筆需注意 · {blocked} 筆已阻擋', + ledgerHealthIssuesBody: + '開啟被標記的執行查看變更,或直接跳到對應的排查頁面修正。', + triageShowWarning: '只看 {count} 筆需注意', + triageShowBlocked: '只看 {count} 筆已阻擋', }, } as const diff --git a/src/lib/i18n/catalog/explorer.ts b/src/lib/i18n/catalog/explorer.ts index dae88a72..e92c34f7 100644 --- a/src/lib/i18n/catalog/explorer.ts +++ b/src/lib/i18n/catalog/explorer.ts @@ -52,6 +52,27 @@ export const explorerNamespaceCatalog = { 'Semantic and hybrid search need embeddings and a vector index, so they are disabled in v0.1. Keyword search below still works against your local archive.', optionalAiDeferredTooltip: 'Semantic and hybrid search are coming in a future update.', + optionalAiUnavailableReleaseDeferred: 'Smart search is coming in v0.2.', + optionalAiUnavailableAiDisabled: + 'Enable AI in Settings before using smart search.', + optionalAiUnavailableNoProvider: + 'Choose an embedding provider in Settings to enable smart search.', + optionalAiUnavailableProviderError: + 'The embedding provider has an error. Fix it in Settings before using smart search.', + optionalAiNoProviderTitle: 'Choose an embedding provider', + optionalAiNoProviderBody: + 'Smart search needs an embedding provider. Add one in Settings → AI to enable semantic and hybrid search.', + optionalAiDisabledTitle: 'AI is turned off', + optionalAiDisabledBody: + 'Smart search needs AI and semantic indexing enabled in Settings before semantic and hybrid search can run.', + optionalAiProviderErrorTitle: 'Embedding provider has an error', + optionalAiProviderErrorBody: + 'Check Settings → AI to fix the embedding provider before retrying smart search.', + optionalAiOpenSettings: 'Open Settings', + searchHeroEyebrow: 'SEARCH HISTORY', + searchHeroPlaceholder: 'Type to search your history…', + searchHeroLabel: 'Search mode', + recentSearchesEyebrow: 'RECENT', semanticStatusEyebrow: 'SEMANTIC STATUS', semanticRecallTitle: 'SEMANTIC RECALL', noSemanticEyebrow: 'SMART SEARCH', @@ -200,6 +221,26 @@ export const explorerNamespaceCatalog = { optionalAiDeferredBody: '语义搜索和混合搜索需要 embedding 与向量索引,所以 v0.1 暂时禁用。下方关键词搜索仍会读取你的本地存档。', optionalAiDeferredTooltip: '语义搜索和混合搜索会在后续版本开放。', + optionalAiUnavailableReleaseDeferred: '智能搜索将在 v0.2 中开放。', + optionalAiUnavailableAiDisabled: '请先在设置中启用 AI,再使用智能搜索。', + optionalAiUnavailableNoProvider: + '请在设置中选择一个向量模型来启用智能搜索。', + optionalAiUnavailableProviderError: + '当前向量模型出现错误,请先在设置中修复后再使用智能搜索。', + optionalAiNoProviderTitle: '请选择一个向量模型', + optionalAiNoProviderBody: + '智能搜索需要一个向量模型。请在「设置 → AI」中添加,以启用语义和混合搜索。', + optionalAiDisabledTitle: 'AI 已关闭', + optionalAiDisabledBody: + '智能搜索需要先在「设置」中启用 AI 与语义索引,之后才能运行语义和混合搜索。', + optionalAiProviderErrorTitle: '向量模型出现错误', + optionalAiProviderErrorBody: + '请前往「设置 → AI」修复向量模型,然后再重试智能搜索。', + optionalAiOpenSettings: '打开设置', + searchHeroEyebrow: '搜索历史', + searchHeroPlaceholder: '输入关键词搜索你的历史记录…', + searchHeroLabel: '搜索模式', + recentSearchesEyebrow: '最近搜索', semanticStatusEyebrow: '智能搜索状态', semanticRecallTitle: '智能搜索召回', noSemanticEyebrow: '智能搜索', @@ -345,6 +386,26 @@ export const explorerNamespaceCatalog = { optionalAiDeferredBody: '語義搜尋和混合搜尋需要 embedding 與向量索引,所以 v0.1 暫時停用。下方關鍵字搜尋仍會讀取你的本機封存。', optionalAiDeferredTooltip: '語義搜尋和混合搜尋會在後續版本開放。', + optionalAiUnavailableReleaseDeferred: '智慧搜尋會在 v0.2 開放。', + optionalAiUnavailableAiDisabled: '請先在設定中啟用 AI,再使用智慧搜尋。', + optionalAiUnavailableNoProvider: + '請在設定中選擇一個向量模型來啟用智慧搜尋。', + optionalAiUnavailableProviderError: + '目前的向量模型出現錯誤,請先在設定中修復後再使用智慧搜尋。', + optionalAiNoProviderTitle: '請選擇一個向量模型', + optionalAiNoProviderBody: + '智慧搜尋需要一個向量模型。請在「設定 → AI」中加入,以啟用語義與混合搜尋。', + optionalAiDisabledTitle: 'AI 已關閉', + optionalAiDisabledBody: + '智慧搜尋需要先在「設定」中啟用 AI 與語義索引,之後才能執行語義與混合搜尋。', + optionalAiProviderErrorTitle: '向量模型出現錯誤', + optionalAiProviderErrorBody: + '請前往「設定 → AI」修復向量模型,然後再重試智慧搜尋。', + optionalAiOpenSettings: '開啟設定', + searchHeroEyebrow: '搜尋歷史', + searchHeroPlaceholder: '輸入關鍵字搜尋你的歷史紀錄…', + searchHeroLabel: '搜尋模式', + recentSearchesEyebrow: '最近搜尋', semanticStatusEyebrow: '智慧搜尋狀態', semanticRecallTitle: '智慧搜尋召回', noSemanticEyebrow: '智慧搜尋', diff --git a/src/lib/i18n/catalog/intelligence-overview-and-routes.ts b/src/lib/i18n/catalog/intelligence-overview-and-routes.ts index b7061761..effc092e 100644 --- a/src/lib/i18n/catalog/intelligence-overview-and-routes.ts +++ b/src/lib/i18n/catalog/intelligence-overview-and-routes.ts @@ -82,6 +82,8 @@ export const intelligenceOverviewAndRoutesNamespace = { digestNewSites: 'New Sites', digestDeepRead: 'Deep Reads', digestRefind: 'Refinds', + digestSparklineAria: '{label} trend over the last {count} days', + digestSparklineEmpty: 'Trend appears once more days are archived.', trendLabel: '{direction} {percent}%', visits: 'visits', onThisDayTitle: 'On This Day', @@ -157,6 +159,19 @@ export const intelligenceOverviewAndRoutesNamespace = { dayInsightsActivityMixTitle: 'Activity Mix', dayInsightsQueryFamiliesTitle: 'Query Evolution', dayInsightsRefindsTitle: 'Refinds', + behaviorShapeGroupTitle: 'Behavior shape', + comparisonsGroupTitle: 'Comparisons', + lowSignalGroupTitle: 'Low-signal callouts', + evidenceLibraryGroupTitle: 'Evidence Library', + lowSignalGroupSummary: '{count} low-signal callouts available', + lowSignalGroupExpand: 'Show callouts', + lowSignalGroupCollapse: 'Hide callouts', + healthDisclosureTitle: 'System health', + healthDisclosureSummary: 'Storage, growth, and module health', + healthDisclosureExpand: 'Show health details', + healthDisclosureCollapse: 'Hide health details', + healthDisclosureEmpty: 'No health data yet — complete a backup to start.', + healthFreshnessLabel: 'Health', }, 'zh-CN': { statusReadyLabel: '智能搜索已就绪', @@ -217,6 +232,8 @@ export const intelligenceOverviewAndRoutesNamespace = { digestNewSites: '新网站', digestDeepRead: '深度阅读', digestRefind: '重找页面', + digestSparklineAria: '{label} 最近 {count} 天的趋势', + digestSparklineEmpty: '积累更多天数后会显示趋势。', trendLabel: '{direction} {percent}%', visits: '次', onThisDayTitle: '历史上的今天', @@ -290,6 +307,19 @@ export const intelligenceOverviewAndRoutesNamespace = { dayInsightsActivityMixTitle: '当天活动构成', dayInsightsQueryFamiliesTitle: '当天搜索演化', dayInsightsRefindsTitle: '当天重找页面', + behaviorShapeGroupTitle: '行为形态', + comparisonsGroupTitle: '对比分析', + lowSignalGroupTitle: '低信号提示', + evidenceLibraryGroupTitle: '证据资料库', + lowSignalGroupSummary: '有 {count} 条低信号提示可查看', + lowSignalGroupExpand: '展开提示', + lowSignalGroupCollapse: '收起提示', + healthDisclosureTitle: '系统健康', + healthDisclosureSummary: '存储、增长与模块健康度', + healthDisclosureExpand: '展开健康详情', + healthDisclosureCollapse: '收起健康详情', + healthDisclosureEmpty: '尚无健康数据 — 完成一次备份后即可显示。', + healthFreshnessLabel: '健康', }, 'zh-TW': { statusReadyLabel: '智慧搜尋已就緒', @@ -350,6 +380,8 @@ export const intelligenceOverviewAndRoutesNamespace = { digestNewSites: '新網站', digestDeepRead: '深度閱讀', digestRefind: '重找頁面', + digestSparklineAria: '{label} 最近 {count} 天的趨勢', + digestSparklineEmpty: '累積更多天數後會顯示趨勢。', trendLabel: '{direction} {percent}%', visits: '次', onThisDayTitle: '歷史上的今天', @@ -423,5 +455,18 @@ export const intelligenceOverviewAndRoutesNamespace = { dayInsightsActivityMixTitle: '當天活動構成', dayInsightsQueryFamiliesTitle: '當天搜尋演化', dayInsightsRefindsTitle: '當天重找頁面', + behaviorShapeGroupTitle: '行為形態', + comparisonsGroupTitle: '對比分析', + lowSignalGroupTitle: '低訊號提示', + evidenceLibraryGroupTitle: '證據資料庫', + lowSignalGroupSummary: '有 {count} 條低訊號提示可檢視', + lowSignalGroupExpand: '展開提示', + lowSignalGroupCollapse: '收起提示', + healthDisclosureTitle: '系統健康', + healthDisclosureSummary: '儲存、成長與模組健康度', + healthDisclosureExpand: '展開健康詳情', + healthDisclosureCollapse: '收起健康詳情', + healthDisclosureEmpty: '尚無健康資料 — 完成一次備份後即可顯示。', + healthFreshnessLabel: '健康', }, } as const diff --git a/src/lib/i18n/catalog/jobs.ts b/src/lib/i18n/catalog/jobs.ts index fc81398d..4a0e1392 100644 --- a/src/lib/i18n/catalog/jobs.ts +++ b/src/lib/i18n/catalog/jobs.ts @@ -57,6 +57,12 @@ export const jobsNamespaceCatalog = { pageUnavailableTitle: 'Background work is unavailable', overviewTitle: 'Queue overview', overviewHeadline: 'Refresh local analysis first', + overviewHeadlineIdle: 'All caught up', + overviewHeadlineRunning: 'Running now: {count}', + overviewHeadlineQueued: 'Waiting for a worker: {count}', + overviewHeadlinePaused: 'Queue paused · saved: {count}', + overviewHeadlineFailures: 'Needs review: {count}', + jumpToFailures: 'Review failed items: {count}', overviewBody: 'Local rebuilds update cards and evidence without waiting on future AI or webpage-body fetch features.', queueSummaryTitle: 'Assistant and embedding queue', @@ -222,6 +228,12 @@ export const jobsNamespaceCatalog = { pageUnavailableTitle: '后台工作暂时不可用', overviewTitle: '队列总览', overviewHeadline: '先刷新本地分析', + overviewHeadlineIdle: '一切就绪', + overviewHeadlineRunning: '{count} 项正在运行', + overviewHeadlineQueued: '{count} 项等待中', + overviewHeadlinePaused: '队列已暂停 · 已保存 {count} 项', + overviewHeadlineFailures: '{count} 项需要复核', + jumpToFailures: '跳到 {count} 项失败', overviewBody: '本地重建会更新卡片和证据,不会等待后续版本才开放的 AI 或网页正文抓取。', queueSummaryTitle: '助手与嵌入队列', @@ -381,6 +393,12 @@ export const jobsNamespaceCatalog = { pageUnavailableTitle: '背景工作暫時無法使用', overviewTitle: '佇列總覽', overviewHeadline: '先重新整理本機分析', + overviewHeadlineIdle: '一切就緒', + overviewHeadlineRunning: '{count} 項執行中', + overviewHeadlineQueued: '{count} 項等待中', + overviewHeadlinePaused: '佇列已暫停 · 已保存 {count} 項', + overviewHeadlineFailures: '{count} 項需要複核', + jumpToFailures: '跳到 {count} 項失敗', overviewBody: '本機重建會更新卡片和證據,不會等待後續版本才開放的 AI 或網頁正文擷取。', queueSummaryTitle: '助手與嵌入佇列', diff --git a/src/lib/optional-ai-availability.test.ts b/src/lib/optional-ai-availability.test.ts new file mode 100644 index 00000000..26e869db --- /dev/null +++ b/src/lib/optional-ai-availability.test.ts @@ -0,0 +1,137 @@ +/** + * @file optional-ai-availability.test.ts + * @description Unit coverage for the multi-condition optional-AI gate. + * @module lib/optional-ai-availability + * + * ## Responsibilities + * - Verify each gate condition is checked in priority order. + * - Verify the i18n key mapping covers every reason value. + * + * ## Not responsible for + * - Re-testing surfaces that consume the gate. + * + * ## Performance notes + * - Pure compute, runs in milliseconds. + */ + +import { describe, expect, test } from 'vitest' +import { + evaluateOptionalAiAvailability, + optionalAiUnavailableI18nKey, + type OptionalAiUnavailableReason, +} from './optional-ai-availability' + +describe('evaluateOptionalAiAvailability', () => { + test('flags release-deferred first when the release flag is off', () => { + expect( + evaluateOptionalAiAvailability({ + releaseEnabled: false, + embeddingProviderId: 'provider-1', + aiStatusState: 'ready', + }), + ).toEqual({ available: false, reason: 'release-deferred' }) + }) + + test('flags release-deferred even when no provider is selected', () => { + expect( + evaluateOptionalAiAvailability({ + releaseEnabled: false, + embeddingProviderId: null, + aiStatusState: 'failed', + }), + ).toEqual({ available: false, reason: 'release-deferred' }) + }) + + test('flags no-embedding-provider when release is enabled but provider is missing', () => { + expect( + evaluateOptionalAiAvailability({ + releaseEnabled: true, + embeddingProviderId: null, + aiStatusState: 'ready', + }), + ).toEqual({ available: false, reason: 'no-embedding-provider' }) + }) + + test('flags ai-disabled when release is enabled but AI is turned off', () => { + expect( + evaluateOptionalAiAvailability({ + releaseEnabled: true, + aiEnabled: false, + embeddingProviderId: 'provider-1', + aiStatusState: 'ready', + }), + ).toEqual({ available: false, reason: 'ai-disabled' }) + }) + + test('flags ai-disabled when runtime status says the index is disabled', () => { + expect( + evaluateOptionalAiAvailability({ + releaseEnabled: true, + embeddingProviderId: 'provider-1', + aiStatusState: 'disabled', + }), + ).toEqual({ available: false, reason: 'ai-disabled' }) + }) + + test.each(['failed', 'blocked', 'degraded'])( + 'flags embedding-provider-error when ai status is %s', + (state) => { + expect( + evaluateOptionalAiAvailability({ + releaseEnabled: true, + embeddingProviderId: 'provider-1', + aiStatusState: state, + }), + ).toEqual({ available: false, reason: 'embedding-provider-error' }) + }, + ) + + test('returns available when every condition is satisfied', () => { + expect( + evaluateOptionalAiAvailability({ + releaseEnabled: true, + embeddingProviderId: 'provider-1', + aiStatusState: 'ready', + }), + ).toEqual({ available: true, reason: null }) + }) + + test('treats missing or null AI state as available', () => { + expect( + evaluateOptionalAiAvailability({ + releaseEnabled: true, + embeddingProviderId: 'provider-1', + aiStatusState: null, + }), + ).toEqual({ available: true, reason: null }) + expect( + evaluateOptionalAiAvailability({ + releaseEnabled: true, + embeddingProviderId: 'provider-1', + }), + ).toEqual({ available: true, reason: null }) + }) + + test('treats unknown non-error AI states as available', () => { + expect( + evaluateOptionalAiAvailability({ + releaseEnabled: true, + embeddingProviderId: 'provider-1', + aiStatusState: 'rebuilding', + }), + ).toEqual({ available: true, reason: null }) + }) +}) + +describe('optionalAiUnavailableI18nKey', () => { + const cases: Array<[OptionalAiUnavailableReason, string]> = [ + ['release-deferred', 'optionalAiUnavailableReleaseDeferred'], + ['ai-disabled', 'optionalAiUnavailableAiDisabled'], + ['no-embedding-provider', 'optionalAiUnavailableNoProvider'], + ['embedding-provider-error', 'optionalAiUnavailableProviderError'], + ] + + test.each(cases)('maps %s to %s', (reason, expected) => { + expect(optionalAiUnavailableI18nKey(reason)).toBe(expected) + }) +}) diff --git a/src/lib/optional-ai-availability.ts b/src/lib/optional-ai-availability.ts new file mode 100644 index 00000000..4635738a --- /dev/null +++ b/src/lib/optional-ai-availability.ts @@ -0,0 +1,104 @@ +/** + * @file optional-ai-availability.ts + * @description Centralizes the multi-condition gate that decides whether optional AI + * surfaces (semantic / hybrid recall, assistant, MCP, etc.) are usable + * right now, and explains the specific reason when they are not. + * @module lib/optional-ai-availability + * + * ## Responsibilities + * - Combine release-flag, embedding-provider, and runtime AI-state signals into a + * single `{ available, reason }` value the UI can consume in one read. + * - Expose the reason alongside an i18n key so disabled buttons and callouts can + * tell the user the specific thing to fix instead of a generic "deferred" line. + * - Keep the gate itself release-fact aware without leaking provider config or + * queue snapshots into every consumer. + * + * ## Not responsible for + * - Owning the user-visible strings — locale catalogs remain the source of truth. + * - Triggering provider probes or kicking embedding builds; this module is pure. + * - Deciding which surface should react to which reason; consumers keep that. + * + * ## Dependencies + * - No runtime dependencies. The gate is intentionally a small pure function so + * tests stay deterministic and adoption stays cheap. + * + * ## Performance notes + * - Pure synchronous compute. Importing this module must not trigger IO or + * backend calls. + */ + +/** + * Names the specific reason optional AI is currently unavailable. + * + * The reason exists so the UI can give the user a concrete next step instead of + * a single "coming later" line that hides which dependency is the actual block. + */ +export type OptionalAiUnavailableReason = + | 'release-deferred' + | 'ai-disabled' + | 'no-embedding-provider' + | 'embedding-provider-error' + +/** + * Carries both the boolean gate and the specific reason it is closed so disabled + * buttons, tooltips, and callouts can share one source of truth. + */ +export interface OptionalAiAvailability { + available: boolean + reason: OptionalAiUnavailableReason | null +} + +/** + * The set of AI index states that should hard-block optional AI surfaces because + * the embedding pipeline is not delivering trustworthy semantic recall right + * now. + */ +const ERROR_STATES = new Set(['failed', 'blocked', 'degraded']) + +/** + * Combines the three independent signals that have to be true before optional + * AI surfaces may render their primary affordances. + * + * Order matters: callers see the most fundamental missing dependency first so a + * user who has not selected a provider is never told to "check the provider + * status" when there is no provider to check. + */ +export function evaluateOptionalAiAvailability(input: { + releaseEnabled: boolean + aiEnabled?: boolean + embeddingProviderId: string | null + aiStatusState?: string | null +}): OptionalAiAvailability { + if (!input.releaseEnabled) { + return { available: false, reason: 'release-deferred' } + } + if (input.aiEnabled === false || input.aiStatusState === 'disabled') { + return { available: false, reason: 'ai-disabled' } + } + if (!input.embeddingProviderId) { + return { available: false, reason: 'no-embedding-provider' } + } + if (input.aiStatusState && ERROR_STATES.has(input.aiStatusState)) { + return { available: false, reason: 'embedding-provider-error' } + } + return { available: true, reason: null } +} + +/** + * Maps each unavailable reason to a stable i18n key so route-level translators + * can resolve it without each surface duplicating the switch statement. + */ +export function optionalAiUnavailableI18nKey( + reason: OptionalAiUnavailableReason, +): string { + switch (reason) { + case 'release-deferred': + return 'optionalAiUnavailableReleaseDeferred' + case 'ai-disabled': + return 'optionalAiUnavailableAiDisabled' + case 'no-embedding-provider': + return 'optionalAiUnavailableNoProvider' + case 'embedding-provider-error': + return 'optionalAiUnavailableProviderError' + } +} diff --git a/src/pages/audit/index.test.tsx b/src/pages/audit/index.test.tsx index 608c8738..15da5b07 100644 --- a/src/pages/audit/index.test.tsx +++ b/src/pages/audit/index.test.tsx @@ -235,6 +235,113 @@ describe('AuditPage route owner', () => { ) }) + test('triages warning and blocked runs from the health summary without dropping other active filters', async () => { + const user = userEvent.setup() + const runs = [ + runFixture(21, { + profileScope: ['chrome:Default'], + }), + runFixture(20, { + profileScope: ['chrome:Default'], + }), + runFixture(19, { + profileScope: ['safari:Personal'], + }), + ] + const details = { + 21: detailFixture(runs[0], { + errorMessage: 'manifest write failed', + }), + 20: detailFixture(runs[1], { + warnings: ['schedule drift'], + }), + 19: detailFixture(runs[2], { + warnings: ['safari warning outside active profile'], + }), + } + + shellDataMock.mockReturnValue( + shellFixture({ + snapshot: snapshotFixture({ + recentRuns: runs, + }), + }), + ) + auditDataMock.mockReturnValue( + auditDataFixture({ + detail: details[21], + detailCache: details, + detailSeverity: 'blocked', + }), + ) + + renderPage('/audit') + + await user.selectOptions(screen.getByLabelText('audit.filterProfile'), [ + 'chrome:Default', + ]) + expect( + screen.getByText('audit.ledgerHealthIssues:{"warning":1,"blocked":1}'), + ).toBeVisible() + + await user.click( + screen.getByRole('button', { + name: 'audit.triageShowBlocked:{"count":1}', + }), + ) + expect(screen.getByRole('button', { name: /#21/ })).toBeVisible() + expect( + screen.queryByRole('button', { name: /#20/ }), + ).not.toBeInTheDocument() + + await user.click( + screen.getByRole('button', { + name: 'audit.triageShowWarning:{"count":1}', + }), + ) + expect(screen.getByRole('button', { name: /#20/ })).toBeVisible() + expect( + screen.queryByRole('button', { name: /#21/ }), + ).not.toBeInTheDocument() + }) + + test('does not render warning triage when all visible repair runs are blocked', () => { + const runs = [runFixture(30)] + const details = { + 30: detailFixture(runs[0], { + errorMessage: 'manifest write failed', + }), + } + + shellDataMock.mockReturnValue( + shellFixture({ + snapshot: snapshotFixture({ + recentRuns: runs, + }), + }), + ) + auditDataMock.mockReturnValue( + auditDataFixture({ + detail: details[30], + detailCache: details, + detailSeverity: 'blocked', + }), + ) + + renderPage('/audit') + + expect( + screen.getByRole('button', { + name: 'audit.triageShowBlocked:{"count":1}', + }), + ).toBeVisible() + expect( + screen.queryByRole('button', { + name: /audit\.triageShowWarning/, + }), + ).not.toBeInTheDocument() + }) + test('covers mixed-source fallbacks, missing details, and detail gate states', async () => { const user = userEvent.setup() const runs = [ diff --git a/src/pages/audit/index.tsx b/src/pages/audit/index.tsx index 6eb77112..e4c773fc 100644 --- a/src/pages/audit/index.tsx +++ b/src/pages/audit/index.tsx @@ -202,6 +202,51 @@ export function AuditPage() { }), [detailCache, filters, indexedRuns], ) + const healthScopeRuns = useMemo( + () => + indexedRuns.filter((run) => { + const runType = run.runType ?? 'backup' + const nextDetail = detailCache[run.id] + if (filters.runType !== 'all' && runType !== filters.runType) { + return false + } + const profileScope = nextDetail?.profileScope ?? run.profileScope ?? [] + const sourceKinds = sourceKindFromProfileScope(profileScope) + if ( + filters.sourceKind !== 'all' && + !sourceKinds.includes(filters.sourceKind) + ) { + return false + } + if (filters.profileId !== 'all') { + const matchesProfile = + profileScope.length === 0 + ? filters.profileId === 'archive-wide' + : profileScope.includes(filters.profileId) + if (!matchesProfile) { + return false + } + } + if ( + filters.artifactType !== 'all' && + (!nextDetail || + !nextDetail.artifacts.some( + (artifact) => artifact.kind === filters.artifactType, + )) + ) { + return false + } + return true + }), + [ + detailCache, + filters.artifactType, + filters.profileId, + filters.runType, + filters.sourceKind, + indexedRuns, + ], + ) const selectedRunIndex = filteredRuns.findIndex((run) => run.id === runId) const previousVisibleRun = selectedRunIndex >= 0 ? (filteredRuns[selectedRunIndex + 1] ?? null) : null @@ -231,6 +276,63 @@ export function AuditPage() { const filtersLoading = indexedRuns.length > 0 && Object.keys(detailCache).length < indexedRuns.length + const severityCounts = useMemo(() => { + const counts = { clear: 0, warning: 0, blocked: 0, unknown: 0 } + for (const run of healthScopeRuns) { + const indexedDetail = detailCache[run.id] + if (!indexedDetail) { + counts.unknown += 1 + continue + } + const severity = auditSeverity(indexedDetail) + counts[severity] += 1 + } + return counts + }, [detailCache, healthScopeRuns]) + const ledgerNeedsRepair = severityCounts.warning + severityCounts.blocked > 0 + const ledgerSeverityHydrating = + !ledgerNeedsRepair && severityCounts.unknown > 0 + const applySeverity = useCallback( + (severity: AuditFilterState['severity']) => { + setFilters((current) => ({ ...current, severity })) + }, + [], + ) + const auditHealthActions = ledgerNeedsRepair ? ( +
+ {severityCounts.blocked > 0 ? ( + + ) : null} + {severityCounts.warning > 0 ? ( + + ) : null} + + {t('audit.repairImports')} + + + {t('audit.repairSchedule')} + + + {t('audit.repairSecurity')} + +
+ ) : null /** * Explains how source label works. @@ -329,22 +431,35 @@ export function AuditPage() { return (
- - {t('audit.repairImports')} - - - {t('audit.repairSchedule')} - - - {t('audit.repairSecurity')} - - + tone={ + severityCounts.blocked > 0 + ? 'danger' + : severityCounts.warning > 0 + ? 'warning' + : ledgerSeverityHydrating + ? 'info' + : 'success' + } + title={ + ledgerNeedsRepair + ? t('audit.ledgerHealthIssues', { + warning: severityCounts.warning, + blocked: severityCounts.blocked, + }) + : ledgerSeverityHydrating + ? t('audit.ledgerHealthLoading', { + count: severityCounts.unknown, + }) + : t('audit.ledgerHealthClear', { count: healthScopeRuns.length }) } + body={ + ledgerNeedsRepair + ? t('audit.ledgerHealthIssuesBody') + : ledgerSeverityHydrating + ? t('audit.ledgerHealthLoadingBody') + : t('audit.ledgerHealthClearBody') + } + actions={auditHealthActions} />
@@ -485,7 +600,11 @@ export function AuditPage() { const indexedDetail = detailCache[run.id] const severity = indexedDetail ? auditSeverity(indexedDetail) - : 'clear' + : null + const severityClass = severity ?? 'pending' + const severityLabel = severity + ? t(auditSeverityKey(severity)) + : t('audit.severityPending') const triggerLabel = t( runTriggerKey( run.trigger ?? indexedDetail?.trigger ?? 'manual', @@ -500,20 +619,26 @@ export function AuditPage() {
)} - ) - })} - -
- {(['time', 'session', 'trail'] as const).map((option) => ( - - ))} -
-
-
- - {explorerT('filterKeyword')} +
+
+
+ + {explorerT('searchHeroEyebrow')} {regexMode ? [.*] : null} -
+
setQueryInput(event.target.value)} @@ -246,6 +215,101 @@ export function ExplorerQueryFiltersPanel({ ) : null}
+
+ {(['keyword', 'semantic', 'hybrid'] as const).map((option) => { + const disabled = + option !== 'keyword' && !aiAvailability.available + + return ( + + ) + })} +
+
+ +
+ + {explorerT('recentSearchesEyebrow')} + + {recentSearches.length > 0 ? ( + recentSearches.map((entry) => ( + + )) + ) : ( + + {explorerT('recentFiltersEmpty')} + + )} +
+
+ +
+
+ + {intelligenceT('viewModeLabel')} + + {(['time', 'session', 'trail'] as const).map((option) => ( + + ))} +
+ +
-
-
- {recentSearches.length > 0 ? ( - recentSearches.map((entry) => ( - - )) - ) : ( - - {explorerT('recentFiltersEmpty')} - - )} -
-
) diff --git a/src/pages/intelligence-surfaces/jobs-runtime.test.tsx b/src/pages/intelligence-surfaces/jobs-runtime.test.tsx index d2e9fa0a..c8804c2e 100644 --- a/src/pages/intelligence-surfaces/jobs-runtime.test.tsx +++ b/src/pages/intelligence-surfaces/jobs-runtime.test.tsx @@ -365,6 +365,72 @@ describe('intelligence surfaces', () => { ).toBeGreaterThan(0) expect(screen.getByText('47%')).toBeVisible() + const recentActivityHeading = document.getElementById( + 'jobs-recent-activity', + ) + expect(recentActivityHeading).toBeInstanceOf(HTMLElement) + if (!(recentActivityHeading instanceof HTMLElement)) { + throw new Error('expected recent activity heading') + } + const scrollIntoView = vi.fn() + const focusRecentActivity = vi + .spyOn(recentActivityHeading, 'focus') + .mockImplementation(() => undefined) + Object.defineProperty(recentActivityHeading, 'scrollIntoView', { + configurable: true, + value: scrollIntoView, + }) + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: undefined, + }) + await user.click( + screen.getByRole('link', { + name: jobsT('jumpToFailures', { count: 2 }), + }), + ) + expect(scrollIntoView).toHaveBeenLastCalledWith({ + behavior: 'smooth', + block: 'start', + }) + expect(recentActivityHeading).toHaveAttribute('tabindex', '-1') + expect(focusRecentActivity).toHaveBeenCalledWith({ preventScroll: true }) + + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn().mockReturnValue({ matches: true }), + }) + await user.click( + screen.getByRole('link', { + name: jobsT('jumpToFailures', { count: 2 }), + }), + ) + expect(scrollIntoView).toHaveBeenLastCalledWith({ + behavior: 'auto', + block: 'start', + }) + + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn().mockReturnValue({ matches: false }), + }) + await user.click( + screen.getByRole('link', { + name: jobsT('jumpToFailures', { count: 2 }), + }), + ) + expect(scrollIntoView).toHaveBeenLastCalledWith({ + behavior: 'smooth', + block: 'start', + }) + + vi.spyOn(document, 'getElementById').mockReturnValueOnce(null) + await user.click( + screen.getByRole('link', { + name: jobsT('jumpToFailures', { count: 2 }), + }), + ) + await user.click(screen.getByRole('button', { name: jobsT('pauseQueue') })) await waitFor(() => expect(shellValue.saveConfig).toHaveBeenCalledWith( diff --git a/src/pages/intelligence/domain-deep-dive.test.tsx b/src/pages/intelligence/domain-deep-dive.test.tsx index b6e09c56..c014692b 100644 --- a/src/pages/intelligence/domain-deep-dive.test.tsx +++ b/src/pages/intelligence/domain-deep-dive.test.tsx @@ -298,6 +298,7 @@ describe('DomainDeepDive route and page', () => { other: 0, }, displayName: null, + totalVisits: 0, topExits: [ { domain: 'fallback-exit.test', diff --git a/src/pages/intelligence/domain-deep-dive.tsx b/src/pages/intelligence/domain-deep-dive.tsx index 6184432e..1df1dcb6 100644 --- a/src/pages/intelligence/domain-deep-dive.tsx +++ b/src/pages/intelligence/domain-deep-dive.tsx @@ -214,8 +214,15 @@ export function DomainDeepDivePage({ if (loading) { return ( -
+
+

+ {t('domainDeepDiveLoadingLabel', { domain })} +

) } @@ -237,6 +244,10 @@ export function DomainDeepDivePage({ detail.arrivalBreakdown.link + detail.arrivalBreakdown.typed + detail.arrivalBreakdown.other + const visitTrendMax = detail.visitTrend.reduce( + (max, point) => Math.max(max, point.visitCount), + 1, + ) return (
@@ -356,22 +367,28 @@ export function DomainDeepDivePage({ ))}
- - 🔍 {t('domainDeepDiveArrival_search')}{' '} - {Math.round( - (detail.arrivalBreakdown.search / arrivalTotal) * 100, - )} - % - - - 🔗 {t('domainDeepDiveArrival_link')}{' '} - {Math.round((detail.arrivalBreakdown.link / arrivalTotal) * 100)}% - - - ⌨️ {t('domainDeepDiveArrival_typed')}{' '} - {Math.round((detail.arrivalBreakdown.typed / arrivalTotal) * 100)} - % - + {[ + { key: 'search', value: detail.arrivalBreakdown.search }, + { key: 'link', value: detail.arrivalBreakdown.link }, + { key: 'typed', value: detail.arrivalBreakdown.typed }, + { key: 'other', value: detail.arrivalBreakdown.other }, + ] + .filter((entry) => entry.value > 0) + .map((entry) => ( + + + ))}
) : null} @@ -382,28 +399,40 @@ export function DomainDeepDivePage({

{t('domainDeepDiveTopPages')}

- {detail.topPages.slice(0, 10).map((page) => ( -
- - {formatDomainPagePath(page.path)} - - {focusedComparePaths.has(page.path) ? ( - - {t('compareSetFocusBadge')} + {detail.topPages.slice(0, 10).map((page) => { + const sharePercent = + detail.totalVisits > 0 + ? Math.round((page.visitCount / detail.totalVisits) * 100) + : 0 + return ( +
+ + {formatDomainPagePath(page.path)} - ) : null} - - {formatNumber(page.visitCount)} - -
- ))} + {focusedComparePaths.has(page.path) ? ( + + {t('compareSetFocusBadge')} + + ) : null} + + {formatNumber(page.visitCount)} + {sharePercent >= 1 ? ( + + {' '} + · {sharePercent}% + + ) : null} + +
+ ) + })}
) : null} @@ -483,34 +512,28 @@ export function DomainDeepDivePage({ {t('domainDeepDiveTrend')}
- {detail.visitTrend.map((point: DomainTrendPoint) => { - const max = Math.max( - ...detail.visitTrend.map( - (trendPoint: DomainTrendPoint) => trendPoint.visitCount, - ), - 1, - ) - return ( - -
- -
- - {point.dateKey.slice(5)} - - - ) - })} + {detail.visitTrend.map((point: DomainTrendPoint) => ( + +
+ +
+ + {point.dateKey.slice(5)} + + + ))}
) : null} @@ -538,5 +561,5 @@ function parsePathFlowStepCount(flowId: string | null) { if (!flowId) return null const parts = flowId.split(':') const candidate = Number(parts.at(-2)) - return Number.isFinite(candidate) ? candidate : null + return Number.isInteger(candidate) && candidate > 0 ? candidate : null } diff --git a/src/pages/jobs/index.tsx b/src/pages/jobs/index.tsx index a6ab2301..205a2e56 100644 --- a/src/pages/jobs/index.tsx +++ b/src/pages/jobs/index.tsx @@ -340,6 +340,9 @@ export function JobsPage() { null const reviewRuntimeJob = runtime?.recentJobs.find((job) => job.state === 'failed') ?? null + const visibleFailedJobsCount = + (aiQueue?.recentJobs.filter((job) => job.state === 'failed').length ?? 0) + + (runtime?.recentJobs.filter((job) => job.state === 'failed').length ?? 0) const contentQueueMessage = contentPlugin ? contentPlugin.queuedJobs > 0 ? jobsT('contentFetchBacklogBody', { @@ -368,6 +371,16 @@ export function JobsPage() { snapshot.config.ai.jobQueuePaused || queueCounts.queued > 0 || queueCounts.running > 0 + const heroHeadline = + queueCounts.failed > 0 + ? jobsT('overviewHeadlineFailures', { count: queueCounts.failed }) + : snapshot.config.ai.jobQueuePaused && queueCounts.queued > 0 + ? jobsT('overviewHeadlinePaused', { count: queueCounts.queued }) + : queueCounts.running > 0 + ? jobsT('overviewHeadlineRunning', { count: queueCounts.running }) + : queueCounts.queued > 0 + ? jobsT('overviewHeadlineQueued', { count: queueCounts.queued }) + : jobsT('overviewHeadlineIdle') return (
@@ -386,6 +399,33 @@ export function JobsPage() { > {jobsT('refresh')} + {visibleFailedJobsCount > 0 ? ( + { + const target = document.getElementById( + 'jobs-recent-activity', + ) + if (!target) return + event.preventDefault() + const reduceMotion = + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)') + .matches + target.scrollIntoView({ + behavior: reduceMotion ? 'auto' : 'smooth', + block: 'start', + }) + if (!target.hasAttribute('tabindex')) { + target.setAttribute('tabindex', '-1') + } + target.focus({ preventScroll: true }) + }} + > + {jobsT('jumpToFailures', { count: visibleFailedJobsCount })} + + ) : null} {showQueueToggle ? (