diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 391fed42..f7f5c6dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -246,7 +246,10 @@ jobs: - name: Build SPA (Vite -> static-dist/) working-directory: frontend run: npm ci && npm run build - - run: cargo build --release + # --features plugins so the e2e Playwright job (which sets + # OXICLOUD_ENABLE_PLUGINS) can exercise the admin Plugins tab. The api/webdav + # and unit jobs reuse this binary but leave plugins disabled at runtime. + - run: cargo build --release --features plugins - uses: actions/upload-artifact@v4 with: name: oxicloud-release @@ -331,20 +334,23 @@ jobs: run: npm ci # The release binary serves the SPA from ./static-dist on disk (not - # embedded), and the Build job builds it WITHOUT VITE_E2E so it lacks the - # `data-testid` hooks the specs target. Build it here with VITE_E2E=1 so - # the server actually serves the e2e SPA the scenarios drive. - - name: Build SPA for e2e (VITE_E2E keeps data-testid hooks) + # embedded). Build the instrumented SPA here with COVERAGE=1 (Istanbul, for + # the coverage report) and VITE_E2E=1 (keeps the `data-testid` hooks the + # specs target). start-server-spa.sh points OXICLOUD_STATIC_PATH here. + - name: Build instrumented SPA for e2e (COVERAGE + VITE_E2E) working-directory: frontend - run: npm ci && VITE_E2E=1 npm run build + run: npm ci && COVERAGE=1 VITE_E2E=1 npm run build - name: Install Playwright browsers working-directory: tests/e2e run: npx playwright install --with-deps - - name: Run Playwright tests (spawns DB via pretest hook) + # Drives this PR's SvelteKit SPA specs (tests/e2e/spa) via the coverage + # config + start-server-spa.sh. (The legacy `npm test` scenarios targeted + # the removed vanilla `static/` frontend and are no longer exercised.) + - name: Run SPA e2e coverage suite working-directory: tests/e2e - run: npm test + run: npm run test:coverage env: BUILD_TARGET: release diff --git a/examples/bench_blob_prefetch.rs b/examples/bench_blob_prefetch.rs index 92926151..fdb08699 100644 --- a/examples/bench_blob_prefetch.rs +++ b/examples/bench_blob_prefetch.rs @@ -47,13 +47,20 @@ use oxicloud::application::ports::blob_storage_ports::BlobStorageBackend; use oxicloud::infrastructure::services::local_blob_backend::LocalBlobBackend; fn env_or(key: &str, default: T) -> T { - env::var(key).ok().and_then(|v| v.parse().ok()).unwrap_or(default) + env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) } fn env_list_usize(key: &str, default: &[usize]) -> Vec { env::var(key) .ok() - .map(|s| s.split(',').filter_map(|x| x.trim().parse().ok()).collect::>()) + .map(|s| { + s.split(',') + .filter_map(|x| x.trim().parse().ok()) + .collect::>() + }) .filter(|v: &Vec| !v.is_empty()) .unwrap_or_else(|| default.to_vec()) } @@ -130,7 +137,11 @@ async fn run_once( // Coarse token-bucket: only sleep once the accumulated owed time clears a // 2 ms floor, so the throttle models a rate-limited socket without drowning // the measurement in sub-ms timer noise. - let per_byte_secs = if throttle_bps > 0.0 { 1.0 / throttle_bps } else { 0.0 }; + let per_byte_secs = if throttle_bps > 0.0 { + 1.0 / throttle_bps + } else { + 0.0 + }; let mut owed = Duration::ZERO; while let Some(item) = byte_stream.next().await { @@ -196,15 +207,18 @@ async fn main() { "# production LocalBlobBackend.read_prefetch() = {}", backend.read_prefetch() ); - println!("# reps/cell: {reps} (median MB/s reported) cold-cache: {}", { - if !COLD_SUPPORTED { - "unsupported (non-Linux) → warm only" - } else if want_cold { - "yes (posix_fadvise DONTNEED, best-effort)" - } else { - "disabled (BENCH_COLD=0)" + println!( + "# reps/cell: {reps} (median MB/s reported) cold-cache: {}", + { + if !COLD_SUPPORTED { + "unsupported (non-Linux) → warm only" + } else if want_cold { + "yes (posix_fadvise DONTNEED, best-effort)" + } else { + "disabled (BENCH_COLD=0)" + } } - }); + ); println!("# N=1 is current production ('antes'); higher N is the candidate ('después')"); println!("############################################################\n"); println!( diff --git a/examples/bench_pool_concurrency.rs b/examples/bench_pool_concurrency.rs index a858be40..ad70fc6a 100644 --- a/examples/bench_pool_concurrency.rs +++ b/examples/bench_pool_concurrency.rs @@ -29,7 +29,10 @@ use oxicloud::infrastructure::services::thumbnail_service::ThumbnailService; use tokio::sync::Semaphore; fn env_or(key: &str, default: T) -> T { - std::env::var(key).ok().and_then(|v| v.parse().ok()).unwrap_or(default) + std::env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) } #[cfg(target_os = "linux")] @@ -181,7 +184,9 @@ fn main() { ); println!( "# available_parallelism = {} effective_parallelism = {} (= the 'after' permit count)", - std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0), + std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(0), eff ); println!("# run under `taskset -c 0,1` to model a 2-core quota"); @@ -190,7 +195,10 @@ fn main() { "| {:>8} | {:>9} | {:>10} | {:>9} | {:>9} |", "permits", "renders", "renders/s", "p50 ms", "p99 ms" ); - println!("|{:-<10}|{:-<11}|{:-<12}|{:-<11}|{:-<11}|", "", "", "", "", ""); + println!( + "|{:-<10}|{:-<11}|{:-<12}|{:-<11}|{:-<11}|", + "", "", "", "", "" + ); // Warm up (also triggers corpus generation / codec init). let _ = bench_k(&rt, img.clone(), 2, producers, 1); @@ -206,7 +214,10 @@ fn main() { // ── Part B: peak RSS for K concurrent decodes (the real over-permit cost) ── println!("\n[B] Peak RSS with K concurrent decodes (one wave)\n"); - println!("| {:>8} | {:>14} | {:>12} |", "permits", "peak RSS MiB", "vs effective"); + println!( + "| {:>8} | {:>14} | {:>12} |", + "permits", "peak RSS MiB", "vs effective" + ); println!("|{:-<10}|{:-<16}|{:-<14}|", "", "", ""); let mut eff_rss: Option = None; for &k in &k_list { diff --git a/examples/bench_tokio_runtime.rs b/examples/bench_tokio_runtime.rs index 1b0c3052..e94643b6 100644 --- a/examples/bench_tokio_runtime.rs +++ b/examples/bench_tokio_runtime.rs @@ -35,7 +35,10 @@ use std::time::{Duration, Instant}; use oxicloud::common::runtime::{cgroup_cpu_quota, effective_parallelism, runtime_pool_sizes}; fn env_or(key: &str, default: T) -> T { - env::var(key).ok().and_then(|v| v.parse().ok()).unwrap_or(default) + env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) } #[cfg(target_os = "linux")] @@ -204,9 +207,7 @@ fn main() { // ── Part A ────────────────────────────────────────────────────────────── println!("\n[A] Worker over-subscription under CPU contention"); - println!( - " workload: {concurrency} concurrent requests, {burn_kb} KiB BLAKE3 each, {secs}s" - ); + println!(" workload: {concurrency} concurrent requests, {burn_kb} KiB BLAKE3 each, {secs}s"); println!(" (run under `taskset -c 0,1` to model a 2-core quota)\n"); println!( "| {:<26} | {:>8} | {:>10} | {:>8} | {:>8} |", @@ -227,7 +228,13 @@ fn main() { before.2, before.3 ); - let after = bench_workers(workers_after, def_max_blocking, concurrency, secs, burn_bytes); + let after = bench_workers( + workers_after, + def_max_blocking, + concurrency, + secs, + burn_bytes, + ); println!( "| {:<26} | {:>8.0} | {:>10} | {:>8} | {:>8} |", format!("after: {workers_after} workers"), @@ -259,7 +266,8 @@ fn main() { "| {:<26} | {:>12} | {:>12} |", "before: 512 (tokio default)", peak_def, "—" ); - let (peak_cap, _base_cap) = bench_blocking_rss(max_blocking_after, blocking_tasks, alloc_mb, hold_ms); + let (peak_cap, _base_cap) = + bench_blocking_rss(max_blocking_after, blocking_tasks, alloc_mb, hold_ms); let saved = peak_def as i64 - peak_cap as i64; println!( "| {:<26} | {:>12} | {:>12} |", diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index a4abeb7a..cb4a2633 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -699,17 +699,26 @@ }); }); - // viewer → URL: when closed from within (X / Esc / backdrop), drop the param - // (replaceState, so closing doesn't add a history entry). + // viewer → URL: when the user closes the viewer (X / Esc / backdrop), drop the + // `?file=` param (replaceState, so closing doesn't add a history entry). Only + // act on a genuine open→closed transition: on a cold deep link the viewer + // starts closed *with* the param while the listing is still loading, and + // stripping it there would race the URL→viewer effect above and the preview + // would never open. + let viewerWasOpen = false; $effect(() => { + const open = viewerOpen; const hasParam = page.url.searchParams.get('file') !== null; - if (!viewerOpen && hasParam) { - const url = new URL(page.url); - url.searchParams.delete('file'); - // Same-origin URL object (see note above); resolve() can't type it. - // eslint-disable-next-line svelte/no-navigation-without-resolve - void goto(url, { keepFocus: true, noScroll: true, replaceState: true }); - } + untrack(() => { + if (viewerWasOpen && !open && hasParam) { + const url = new URL(page.url); + url.searchParams.delete('file'); + // Same-origin URL object (see note above); resolve() can't type it. + // eslint-disable-next-line svelte/no-navigation-without-resolve + void goto(url, { keepFocus: true, noScroll: true, replaceState: true }); + } + viewerWasOpen = open; + }); }); /** @@ -1607,26 +1616,31 @@