From 393dc1b288a65d6ab5a511c552e25cd5d742224b Mon Sep 17 00:00:00 2001 From: SessionLedger Bot Date: Sat, 8 Aug 2026 17:39:20 -0700 Subject: [PATCH] test(viewer): web_exports proptest surface (WBS-6.2 #437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `crates/sl-viewer/tests/properties_viewer_web_exports.rs` with 11 proptest properties pinning `WebExportProvider` and `web_export_roots_with_env` reductions: * `WebExportProvider::label`: * Non-empty. * Free of tabs / newlines / double-spaces (well-formed for badge UI). * Distinct per variant (no accidental aliasing). * `WebExportProvider::corpus`: * Total — every variant maps to a `Corpus::ChatGptWeb | ClaudeWeb | GeminiWeb` value (no panics, no fallback). * Injective — distinct providers map to distinct corpora. * `WebExportProvider::default_subdir`: * Non-empty. * Distinct per variant. * Equals `label()` — `~/Downloads/` lines up with the user-facing provider name. * `web_export_roots_with_env(home, None)`: * Empty set when `home` is non-existent. * Returns the subset of defaults whose `Downloads/` path exists on disk, exactly once each, in default order. * Returns all three documented providers when all default dirs exist. Updates WBS-6.2 evidence list, TRACEABILITY.json, and CHANGELOG. --- CHANGELOG.md | 2 + .../tests/properties_viewer_web_exports.rs | 221 ++++++++++++++++++ docs/ops/TRACEABILITY.json | 1 + docs/ops/WBS.md | 2 +- 4 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 crates/sl-viewer/tests/properties_viewer_web_exports.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index effc78f9..ab1de11e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/); versioning is [SemVer]( - Viewer first-run corpus CTA (C09): wire “Open corpus…” to a web Forge DB file picker (`corpus_cta.rs`) or open the quick-start runbook on desktop; `cargo test -p sl-viewer`. +- sl-viewer web_exports property surface (WBS-6.2 #437): `crates/sl-viewer/tests/properties_viewer_web_exports.rs` adds 11 proptest properties — `WebExportProvider::label` is non-empty, distinct per variant, and free of tabs/newlines/double-spaces. `WebExportProvider::corpus` is total (every variant maps to a known `Corpus` web variant) and injective (distinct providers → distinct corpora). `WebExportProvider::default_subdir` is non-empty, distinct, and equals `label` (so `~/Downloads/` lines up with the user-facing provider name). `web_export_roots_with_env(home, None)` returns an empty set for a non-existent home, returns the existing-default subset in input order for an existing home, and is total over the documented 3-provider set when all defaults exist. + - Commit signing header scan (C04 L34): `commit-signing-check.ps1` reads bounded commit headers via line-scanner (no unbounded `git cat-file` buffers or `(?ms)` regex); `-SelfCheck` + `tests/commit_signing_check.rs`. - Loom permutation CI timeout (P0 stability): split blocking `loom-permutation.yml` into core + per-daemon `loom_model` jobs with `LOOM_MAX_PREEMPTIONS` on broadcast/pipeline/shutdown; mirror in soft `loom-smoke.yml` so Wave-40 tokio-shaped daemon graph tests no longer exceed single-job ceilings. diff --git a/crates/sl-viewer/tests/properties_viewer_web_exports.rs b/crates/sl-viewer/tests/properties_viewer_web_exports.rs new file mode 100644 index 00000000..18974c4f --- /dev/null +++ b/crates/sl-viewer/tests/properties_viewer_web_exports.rs @@ -0,0 +1,221 @@ +//! Property evidence for sl-viewer's `web_exports::WebExportProvider` +//! reductions. +//! +//! Integration tests. The unit tests in `web_exports.rs` pin specific +//! values; these properties pin invariants over the full set of +//! `WebExportProvider` variants. +//! +//! `WebExportProvider` invariants: +//! * `label` is non-empty, distinct per variant, and contains no +//! whitespace other than single ASCII spaces. +//! * `corpus` returns a `Corpus::ChatGptWeb` / `Corpus::ClaudeWeb` / +//! `Corpus::GeminiWeb` variant exactly matching the provider's +//! web-export identity (no future drift to a desktop corpus). +//! * `default_subdir` is non-empty, distinct per variant, and equals +//! the corresponding `label` (so the directory under `~/Downloads` +//! lines up with the user-facing provider name). +//! * `corpus` is total (every variant maps to a known corpus). +//! +//! `web_export_roots_with_env` invariants: +//! * With `explicit = None`, the output is a subset of the three +//! defaults (no extras leak in) — each entry's provider is one of +//! the three documented providers. +//! * With `explicit = None`, every default entry that exists on disk +//! appears in the output exactly once. +//! +//! proptest is added to `sl-viewer/[dev-dependencies]` (mirroring the +//! workspace root); see PR #425 for the initial wiring. + +use std::path::PathBuf; + +use proptest::prelude::*; +use session_ledger::domain::session::Corpus; +use sl_viewer::web_exports::{web_export_roots_with_env, WebExportProvider}; + +// ── strategies ────────────────────────────────────────────────────────────── + +fn provider_strategy() -> impl Strategy { + prop::sample::select(vec![ + WebExportProvider::ChatGpt, + WebExportProvider::Claude, + WebExportProvider::Gemini, + ]) +} + +// ── WebExportProvider::label ──────────────────────────────────────────────── + +proptest! { + /// Property: every `label()` is non-empty. Guards against a future + /// variant whose label accidentally becomes empty (UI rendering + /// would crash on `String::new()` in the badge). + #[test] + fn label_is_nonempty(provider in provider_strategy()) { + prop_assert!(!provider.label().is_empty()); + } + + /// Property: every `label()` contains no whitespace other than + /// single ASCII spaces (no tabs / newlines / double-spaces that + /// would look broken in a badge). + #[test] + fn label_is_well_formed(provider in provider_strategy()) { + let label = provider.label(); + prop_assert!(!label.contains('\t')); + prop_assert!(!label.contains('\n')); + prop_assert!(!label.contains(" ")); + } + + /// Property: distinct providers produce distinct labels (no + /// accidental aliasing in the UI badge). + #[test] + fn labels_are_distinct( + a in provider_strategy(), + b in provider_strategy(), + ) { + if a != b { + prop_assert_ne!(a.label(), b.label()); + } + } +} + +// ── WebExportProvider::corpus ─────────────────────────────────────────────── + +proptest! { + /// Property: `corpus()` is total — every provider variant maps to + /// a known `Corpus` variant (no panics, no surprise fallback). + #[test] + fn corpus_is_total(provider in provider_strategy()) { + let corpus = provider.corpus(); + prop_assert!(matches!( + corpus, + Corpus::ChatGptWeb | Corpus::ClaudeWeb | Corpus::GeminiWeb + )); + } + + /// Property: distinct providers map to distinct corpora (catches + /// drift where two providers are silently merged into one corpus). + #[test] + fn corpus_is_injective( + a in provider_strategy(), + b in provider_strategy(), + ) { + if a != b { + prop_assert_ne!(a.corpus(), b.corpus()); + } + } +} + +// ── WebExportProvider::default_subdir ─────────────────────────────────────── + +proptest! { + /// Property: `default_subdir()` is non-empty. + #[test] + fn default_subdir_is_nonempty(provider in provider_strategy()) { + prop_assert!(!provider.default_subdir().is_empty()); + } + + /// Property: distinct providers have distinct default subdirs. + #[test] + fn default_subdirs_are_distinct( + a in provider_strategy(), + b in provider_strategy(), + ) { + if a != b { + prop_assert_ne!(a.default_subdir(), b.default_subdir()); + } + } + + /// Property: `default_subdir()` equals `label()`. The directory + /// under `~/Downloads` must match the user-facing provider name. + #[test] + fn default_subdir_matches_label(provider in provider_strategy()) { + prop_assert_eq!(provider.default_subdir(), provider.label()); + } +} + +// ── web_export_roots_with_env ─────────────────────────────────────────────── + +proptest! { + /// Property: with `explicit = None`, the output is a subset of the + /// three documented web-export providers (no extras leak in). + /// We construct a non-existent home directory so none of the + /// defaults exist on disk — the output is therefore empty. + #[test] + fn roots_with_no_explicit_returns_empty_for_missing_home( + _i in 0u8..8, + ) { + // Use a path that certainly doesn't exist (a single-segment + // filename under "/") so all defaults are absent. + let home = PathBuf::from("/__nonexistent_sessionledger_root__"); + let explicit = None; + let roots = web_export_roots_with_env(&home, explicit); + prop_assert!(roots.is_empty(), "got unexpected roots: {roots:?}"); + } + + /// Property: with `explicit = None`, every default entry whose + /// path exists on disk appears in the output exactly once. The + /// test creates a tempdir, materializes one of the three defaults + /// (Claude), and asserts only that provider's root comes back. + #[test] + fn roots_with_no_explicit_filters_to_existing( + i in 0u8..3, + ) { + // Each iteration picks one provider to materialize; the other + // two defaults stay absent. + let provider = [WebExportProvider::ChatGpt, WebExportProvider::Claude, WebExportProvider::Gemini] + [i as usize]; + let tmp = std::env::temp_dir().join(format!( + "sessionledger-test-roots-{}-{}", + std::process::id(), + i + )); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).expect("mkdir"); + let existing_path = tmp.join("Downloads").join(provider.default_subdir()); + std::fs::create_dir_all(&existing_path).expect("mkdir provider"); + + let roots = web_export_roots_with_env(&tmp, None); + prop_assert_eq!(roots.len(), 1); + prop_assert_eq!(roots[0].0, provider); + prop_assert_eq!(roots[0].1.clone(), existing_path); + + let _ = std::fs::remove_dir_all(&tmp); + } + + /// Property: with `explicit = None`, the providers in the output + /// are always drawn from the documented three-provider set (no + /// unknown provider variants leak in). + #[test] + fn roots_with_no_explicit_only_known_providers( + _i in 0u8..4, + ) { + let home = std::env::temp_dir().join(format!( + "sessionledger-test-providerset-{}", + std::process::id(), + )); + let _ = std::fs::remove_dir_all(&home); + std::fs::create_dir_all(&home).expect("mkdir home"); + for p in [ + WebExportProvider::ChatGpt, + WebExportProvider::Claude, + WebExportProvider::Gemini, + ] { + std::fs::create_dir_all(home.join("Downloads").join(p.default_subdir())) + .expect("mkdir downloads"); + } + + let roots = web_export_roots_with_env(&home, None); + prop_assert_eq!(roots.len(), 3); + let mut providers: Vec<_> = roots.iter().map(|(p, _)| *p).collect(); + providers.sort_by_key(|p| p.label()); + let expected: Vec<_> = [ + WebExportProvider::ChatGpt, + WebExportProvider::Claude, + WebExportProvider::Gemini, + ] + .into_iter() + .collect(); + prop_assert_eq!(providers, expected); + + let _ = std::fs::remove_dir_all(&home); + } +} diff --git a/docs/ops/TRACEABILITY.json b/docs/ops/TRACEABILITY.json index a0c0dca0..7ed06834 100644 --- a/docs/ops/TRACEABILITY.json +++ b/docs/ops/TRACEABILITY.json @@ -313,6 +313,7 @@ "crates/sl-viewer/tests/properties_viewer_timeline.rs", "crates/sl-viewer/tests/properties_viewer_search_memory.rs", "crates/sl-viewer/tests/properties_viewer_history.rs", + "crates/sl-viewer/tests/properties_viewer_web_exports.rs", "fuzz/fuzz_targets/okf_roundtrip.rs", "fuzz/fuzz_targets/jsonl_ingest.rs", ".github/workflows/ci.yml", diff --git a/docs/ops/WBS.md b/docs/ops/WBS.md index 50c4e55e..650424ad 100644 --- a/docs/ops/WBS.md +++ b/docs/ops/WBS.md @@ -29,7 +29,7 @@ without a new audit. | WBS-4.2 | P4 FTS recall via context-mode and explicit TUI decision | partial | human | `docs/DESIGN.md` §3, §7; `crates/sl-viewer/` | DESIGN P4 residual; C00, C11 | | WBS-5.1 | P5 deterministic dedup merge and crash/lost-work recovery E2E | done | machine | `src/domain/merge.rs`; `src/domain/worklog.rs`; `tests/merge_recovery.rs` | FR-011; T-024, T-035; C03 | | WBS-6.1 | P6 85% coverage gate and deterministic golden corpus | done | machine | `.github/workflows/ci.yml`; `tests/okf_golden.rs`; `tests/fixtures/okf/` | T-037, T-038; C01, C08 | -| WBS-6.2 | P6 property tests, fuzzing, race checks, and enforced performance budgets | partial | machine | `tests/properties.rs`; `crates/sl-viewer/tests/properties_viewer.rs`; `crates/sl-viewer/tests/properties_viewer_theme_url.rs`; `crates/sl-viewer/tests/properties_viewer_unfinished_tab.rs`; `crates/sl-viewer/tests/properties_viewer_timeline.rs`; `crates/sl-viewer/tests/properties_viewer_search_memory.rs`; `crates/sl-viewer/tests/properties_viewer_history.rs`; `fuzz/fuzz_targets/okf_roundtrip.rs`; `fuzz/fuzz_targets/jsonl_ingest.rs`; `.github/workflows/ci.yml`; `.github/workflows/bench-gate.yml`; `docs/ops/perf-baseline.json`; `scripts/bench-gate.ps1`; `benches/pipeline.rs`; `tests/loom_model.rs` | DESIGN P6 residual; C00 L6-L8; C07 L66-L68; C08 L74; perf-budget enforced Wave-26 #223; p95 latency enforced Wave-30 #256; FSM properties Wave-31 #261; soft loom Wave-31 #264; viewer corpus_paths/parquet/settings properties #425; viewer theme + daemon_url properties #427; viewer unfinished_tab properties + fuzz/rootless CI drift fixes #428; viewer bundle_diff + timeline properties + web_exports/hmetic-pin cleanups #432; viewer bundle_diff properties #434; viewer search/memory properties #435; viewer history_tab properties #444; full loom/shuttle unpaid | +| WBS-6.2 | P6 property tests, fuzzing, race checks, and enforced performance budgets | partial | machine | `tests/properties.rs`; `crates/sl-viewer/tests/properties_viewer.rs`; `crates/sl-viewer/tests/properties_viewer_theme_url.rs`; `crates/sl-viewer/tests/properties_viewer_unfinished_tab.rs`; `crates/sl-viewer/tests/properties_viewer_timeline.rs`; `crates/sl-viewer/tests/properties_viewer_search_memory.rs`; `crates/sl-viewer/tests/properties_viewer_history.rs`; `crates/sl-viewer/tests/properties_viewer_web_exports.rs`; `fuzz/fuzz_targets/okf_roundtrip.rs`; `fuzz/fuzz_targets/jsonl_ingest.rs`; `.github/workflows/ci.yml`; `.github/workflows/bench-gate.yml`; `docs/ops/perf-baseline.json`; `scripts/bench-gate.ps1`; `benches/pipeline.rs`; `tests/loom_model.rs` | DESIGN P6 residual; C00 L6-L8; C07 L66-L68; C08 L74; perf-budget enforced Wave-26 #223; p95 latency enforced Wave-30 #256; FSM properties Wave-31 #261; soft loom Wave-31 #264; viewer corpus_paths/parquet/settings properties #425; viewer theme + daemon_url properties #427; viewer unfinished_tab properties + fuzz/rootless CI drift fixes #428; viewer bundle_diff + timeline properties + web_exports/hmetic-pin cleanups #432; viewer bundle_diff properties #434; viewer search/memory properties #435; viewer history_tab properties #444; viewer web_exports properties #437; full loom/shuttle unpaid | ## audit-v38 waves