Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
36 changes: 25 additions & 11 deletions examples/bench_blob_prefetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,20 @@ use oxicloud::application::ports::blob_storage_ports::BlobStorageBackend;
use oxicloud::infrastructure::services::local_blob_backend::LocalBlobBackend;

fn env_or<T: std::str::FromStr>(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<usize> {
env::var(key)
.ok()
.map(|s| s.split(',').filter_map(|x| x.trim().parse().ok()).collect::<Vec<_>>())
.map(|s| {
s.split(',')
.filter_map(|x| x.trim().parse().ok())
.collect::<Vec<_>>()
})
.filter(|v: &Vec<usize>| !v.is_empty())
.unwrap_or_else(|| default.to_vec())
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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!(
Expand Down
19 changes: 15 additions & 4 deletions examples/bench_pool_concurrency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ use oxicloud::infrastructure::services::thumbnail_service::ThumbnailService;
use tokio::sync::Semaphore;

fn env_or<T: std::str::FromStr>(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")]
Expand Down Expand Up @@ -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");
Expand All @@ -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);
Expand All @@ -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<u64> = None;
for &k in &k_list {
Expand Down
20 changes: 14 additions & 6 deletions examples/bench_tokio_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ use std::time::{Duration, Instant};
use oxicloud::common::runtime::{cgroup_cpu_quota, effective_parallelism, runtime_pool_sizes};

fn env_or<T: std::str::FromStr>(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")]
Expand Down Expand Up @@ -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} |",
Expand All @@ -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"),
Expand Down Expand Up @@ -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} |",
Expand Down
54 changes: 34 additions & 20 deletions frontend/src/routes/files/[...path]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
});

/**
Expand Down Expand Up @@ -1607,26 +1616,31 @@
</ListToolbar>

<nav class="breadcrumb" aria-label="Breadcrumb">
<!-- Persistent home link → the root listing (bare /files canonicalizes to
the user's drive root). `buildCrumbs` returns only the path folders,
so this is the single always-present "go home" affordance. -->
<a
href={resolve('/files')}
class="breadcrumb-item breadcrumb-home breadcrumb-link"
title={t('breadcrumb.home', 'Home')}
data-testid="files-breadcrumb-home-link"
ondragover={(e) => e.dataTransfer?.types.includes(DRAG_TYPE) && e.preventDefault()}
ondrop={(e) => session.homeFolderId && onCrumbDrop(e, session.homeFolderId)}
>
<Icon name={rootIcon} />
</a>
{#each crumbs as c, i (c.id)}
{#if i > 0}
<span class="breadcrumb-separator">&gt;</span>
{/if}
<span class="breadcrumb-separator">&gt;</span>
{#if i === crumbs.length - 1}
<span class="breadcrumb-item breadcrumb-current" class:breadcrumb-home={i === 0}>
{#if i === 0}<Icon name={rootIcon} />{/if}
{c.name}
</span>
<span class="breadcrumb-item breadcrumb-current">{c.name}</span>
{:else}
<a
href={resolve(`/files/${pathSegments.slice(0, i + 1).join('/')}`)}
class="breadcrumb-item breadcrumb-link"
class:breadcrumb-home={i === 0}
title={i === 0 ? t('breadcrumb.home', 'Home') : undefined}
data-testid={i === 0 ? 'files-breadcrumb-home-link' : `files-breadcrumb-${c.id}`}
data-testid={`files-breadcrumb-${c.id}`}
ondragover={(e) => e.dataTransfer?.types.includes(DRAG_TYPE) && e.preventDefault()}
ondrop={(e) => onCrumbDrop(e, c.id)}
>
{#if i === 0}<Icon name={rootIcon} />{/if}
{c.name}
</a>
{/if}
Expand Down
4 changes: 2 additions & 2 deletions tests/e2e/playwright.coverage.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export default defineConfig({
globalTeardown: require.resolve('./global-teardown'),

use: {
baseURL: 'http://localhost:8088',
baseURL: 'http://127.0.0.1:8088',
trace: 'on-first-retry',
headless: true,
screenshot: 'only-on-failure',
Expand All @@ -50,7 +50,7 @@ export default defineConfig({
command: process.env.BUILD_TARGET
? `bash "${startScript}" "${workspace}/target/${process.env.BUILD_TARGET}/oxicloud"`
: `bash "${startScript}" cargo run --features plugins`,
url: 'http://localhost:8088',
url: 'http://127.0.0.1:8088/ready',
timeout: 600_000,
reuseExistingServer: false,
cwd: '../..',
Expand Down
6 changes: 4 additions & 2 deletions tests/e2e/spa/files-extra.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,9 +202,11 @@ test('breadcrumb navigates back to home', async ({ page }) => {
const folderName = uniq('Crumb');
const folder = await apiCreateFolder(page, folderName);
await page.goto(`/files/${folder.id}`);
// Breadcrumb home link returns to the root listing.
// Breadcrumb home link leaves the subfolder for the root listing. Bare /files
// canonicalizes to the user's drive root, where the just-created folder lives.
await page.getByTestId('files-breadcrumb-home-link').click();
await expect(page).toHaveURL(/\/files\/?$/);
await expect(page).not.toHaveURL(new RegExp(folder.id));
await expect(page.getByTestId(folderName)).toBeVisible({ timeout: 15_000 });
});

test('open an image in the viewer and use the zoom controls', async ({ page }) => {
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/spa/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@ export default async function globalSetup() {
fs.rmSync(nycDir, { recursive: true, force: true });
fs.mkdirSync(nycDir, { recursive: true });

await seedAdmin('http://localhost:8088');
await seedAdmin('http://127.0.0.1:8088');
}
19 changes: 18 additions & 1 deletion tests/e2e/start-server-spa.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,30 @@ set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
SPA_STORAGE_PATH="$REPO_ROOT/tests/e2e/storage-spa"

# Mirror markers + the server's stdout/stderr to a log file as well as the
# console; CI surfaces it via the "Print server startup log" step (Playwright's
# own webServer capture isn't always shown there). The final `exec "$@"`
# inherits these fds, so the server's output is tee'd while it still replaces
# this shell (Playwright tracks the PID for teardown).
SERVER_LOG="$REPO_ROOT/tests/e2e/server-startup.log"
exec > >(tee "$SERVER_LOG") 2>&1

mark() { echo "[start-server-spa $(date -u +%H:%M:%S)] $*"; }
mark "repo_root=$REPO_ROOT server args: $*"
if [[ -n "${1:-}" && "$1" != "cargo" ]]; then
ls -la "$1" 2>&1 || mark "WARNING: server binary '$1' not found"
fi
mark "DATABASE_URL=${DATABASE_URL:-<unset>} PORT=${OXICLOUD_SERVER_PORT:-<unset>} STATIC=${OXICLOUD_STATIC_PATH:-<unset>}"

# ensure storage is empty before starting
echo "Wipe $SPA_STORAGE_PATH to ensure clean startup"
mark "wiping $SPA_STORAGE_PATH to ensure clean startup"
rm -rf "$SPA_STORAGE_PATH"
mkdir -p "$SPA_STORAGE_PATH"

# Spawn database (idempotent — reuses the running test postgres if present).
mark "spawning test database…"
bash "$REPO_ROOT/tests/common/spawn-db.sh"
mark "database ready; starting server…"

# Replace the shell with the server process so Playwright's PID tracking works.
exec "$@"
Loading