From e3823ce47051e8cd29250f485a3c5770b22acfe9 Mon Sep 17 00:00:00 2001 From: Bradley Nelson Date: Sun, 21 Jun 2026 20:03:32 -0600 Subject: [PATCH] test(e2e): Playwright + Vitest coverage harness and test instrumentation Add an end-to-end and unit test suite for the SvelteKit frontend: - Playwright e2e specs (tests/e2e/spa) with a throwaway container stack, codegen scenarios, and an Istanbul-based coverage report pipeline. - Vitest unit tests across API endpoints, components, stores and composables. - `data-testid` hooks on interactive elements (AppShell, FileViewer, ShareDialog, search, photos, files breadcrumbs, login/Nextcloud flows, public share pages) so the e2e suite can target them deterministically. - Serve the SPA app-shell CSP from a policy (svelte.config.js) plus a middleware that skips the CSP header on HTML; move the Nextcloud Login Flow v2 grant page to the SvelteKit /nextcloud/login route. - `just front-codegen` recipe and start-server-spa.sh harness. Make the test environment robust and consistent: - Install a deterministic in-memory localStorage/sessionStorage in the Vitest setup so storage behaves identically across Node versions (Node 26 ships a native Web Storage global that otherwise shadows jsdom's). - Pin devenv to Node 26 + PostgreSQL 18 and pin every CI job to Node 26.3.0 so the dev shell and CI run the same toolchain versions. Repair the API/WebDAV (hurl) suite, which had drifted from the backend: - Migrate the removed `/api/folders/{id}/listing` endpoint to `/resources` (cursor-paginated `{items:[{resource_type,resource}]}` shape) across the batch-copy, grants, nested-group, and WebDAV NC tests + the dav_helpers wipe routine. - Stop photos_etag from uploading the dedup-tracked fixture so the dedup blob-lifecycle test can own its content-addressed blob exclusively. - dedup_create now asserts the idempotent same-content re-upload (201 + existing file id) instead of the stale 409 expectation. Generated coverage reports, nyc output and the e2e server runtime data dir are gitignored rather than committed. --- .claude/scheduled_tasks.lock | 1 + .dockerignore | 21 + .github/workflows/ci.yml | 18 +- Dockerfile | 67 +- devenv.nix | 9 +- docker-compose.yml | 8 + frontend/.gitignore | 3 + frontend/.prettierignore | 1 + frontend/package-lock.json | 753 +++- frontend/package.json | 5 +- frontend/src/app.html | 10 +- frontend/src/lib/api/client.test.ts | 13 + frontend/src/lib/api/endpoints/admin.test.ts | 126 + frontend/src/lib/api/endpoints/auth.test.ts | 44 + frontend/src/lib/api/endpoints/batch.test.ts | 39 + .../src/lib/api/endpoints/deltaUpload.test.ts | 95 +- frontend/src/lib/api/endpoints/device.test.ts | 16 + .../src/lib/api/endpoints/favorites.test.ts | 67 + frontend/src/lib/api/endpoints/files.test.ts | 34 + frontend/src/lib/api/endpoints/grants.test.ts | 67 + frontend/src/lib/api/endpoints/music.test.ts | 28 + frontend/src/lib/api/endpoints/people.test.ts | 20 + frontend/src/lib/api/endpoints/photos.test.ts | 20 + .../src/lib/api/endpoints/profile.test.ts | 28 + frontend/src/lib/api/endpoints/recent.test.ts | 19 + .../src/lib/api/endpoints/recipients.test.ts | 37 + frontend/src/lib/api/endpoints/search.test.ts | 25 + frontend/src/lib/api/endpoints/search.ts | 4 +- frontend/src/lib/api/endpoints/share.test.ts | 92 + frontend/src/lib/api/endpoints/shares.test.ts | 20 + frontend/src/lib/api/endpoints/trash.test.ts | 67 + frontend/src/lib/api/endpoints/wopi.test.ts | 64 + frontend/src/lib/components/AppShell.svelte | 81 +- frontend/src/lib/components/AppShell.test.ts | 71 + frontend/src/lib/components/Button.svelte | 5 +- .../src/lib/components/CommandPalette.svelte | 3 + .../src/lib/components/CommandPalette.test.ts | 47 + frontend/src/lib/components/DialogHost.svelte | 17 +- .../src/lib/components/DialogHost.test.ts | 41 + frontend/src/lib/components/FileViewer.svelte | 19 +- .../src/lib/components/FileViewer.test.ts | 97 + .../src/lib/components/ListToolbar.svelte | 7 +- frontend/src/lib/components/Modal.svelte | 8 +- frontend/src/lib/components/Modal.test.ts | 34 + frontend/src/lib/components/MoveDialog.svelte | 102 +- .../src/lib/components/MoveDialog.test.ts | 82 + .../src/lib/components/PeopleView.test.ts | 82 + .../src/lib/components/PhotoLightbox.svelte | 10 +- .../src/lib/components/PhotoLightbox.test.ts | 50 + .../src/lib/components/ResourceList.svelte | 35 +- .../src/lib/components/ShareDialog.svelte | 411 +- .../src/lib/components/ShareDialog.test.ts | 68 + frontend/src/lib/components/Toaster.svelte | 3 +- frontend/src/lib/components/WopiEditor.svelte | 29 +- .../src/lib/components/WopiEditor.test.ts | 56 + .../src/lib/composables/useOwnerCache.test.ts | 19 + .../src/lib/composables/useSelection.test.ts | 33 + .../lib/composables/useVirtualWindow.test.ts | 31 + frontend/src/lib/stores/dialogs.test.ts | 68 + frontend/src/lib/stores/files.test.ts | 43 + frontend/src/lib/stores/theme.test.ts | 29 + frontend/src/lib/utils/display.test.ts | 34 + frontend/src/lib/utils/errors.test.ts | 30 + frontend/src/lib/utils/grid.test.ts | 35 + frontend/src/lib/utils/media.test.ts | 20 + frontend/src/lib/utils/time.test.ts | 39 + frontend/src/routes/admin/+page.svelte | 352 +- frontend/src/routes/admin/page.test.ts | 212 + frontend/src/routes/device/+page.svelte | 25 +- frontend/src/routes/device/page.test.ts | 60 + frontend/src/routes/favorites/+page.svelte | 12 +- frontend/src/routes/favorites/page.test.ts | 92 + .../src/routes/files/[...path]/+page.svelte | 88 +- frontend/src/routes/files/page.test.ts | 195 + frontend/src/routes/groups/+page.svelte | 40 +- frontend/src/routes/groups/page.test.ts | 56 + frontend/src/routes/login/+page.svelte | 101 +- frontend/src/routes/login/page.test.ts | 157 + frontend/src/routes/music/+page.svelte | 80 +- frontend/src/routes/music/page.test.ts | 119 + .../src/routes/nextcloud/error/+page.svelte | 7 +- .../src/routes/nextcloud/login/+page.svelte | 20 +- .../src/routes/nextcloud/success/+page.svelte | 7 +- frontend/src/routes/photos/+page.svelte | 26 +- frontend/src/routes/photos/page.test.ts | 88 + frontend/src/routes/profile/+page.svelte | 105 +- frontend/src/routes/profile/page.test.ts | 128 + frontend/src/routes/recent/+page.svelte | 16 +- frontend/src/routes/recent/page.test.ts | 105 + frontend/src/routes/s/[token]/+page.svelte | 43 +- frontend/src/routes/search/+page.svelte | 36 +- frontend/src/routes/search/page.test.ts | 43 + frontend/src/routes/shared/+page.svelte | 54 +- frontend/src/routes/shared/page.test.ts | 112 + frontend/src/routes/trash/+page.svelte | 10 +- frontend/strip-testid.js | 119 + frontend/svelte.config.js | 74 +- frontend/vite.config.ts | 29 +- frontend/vitest-coverage.ts | 25 + frontend/vitest-setup.ts | 55 + justfile | 40 + src/interfaces/nextcloud/login_v2_handler.rs | 32 +- src/main.rs | 65 +- tests/api/batch_folder_copy.hurl | 16 +- tests/api/dedup_create.hurl | 19 +- tests/api/grants.hurl | 4 +- tests/api/grants_nested_groups.hurl | 4 +- tests/api/photos_etag.hurl | 8 +- tests/e2e/.gitignore | 15 + tests/e2e/coverage-report.cjs | 204 + tests/e2e/fixtures/oxicloud-stack.ts | 102 + tests/e2e/fixtures/plugin-hello.zip | Bin 0 -> 58121 bytes tests/e2e/fixtures/tone.wav | Bin 0 -> 192080 bytes tests/e2e/global-setup.ts | 24 +- tests/e2e/load-env.ts | 32 + tests/e2e/package-lock.json | 3851 ++++++++++++++++- tests/e2e/package.json | 13 +- tests/e2e/playwright.codegen.config.ts | 17 + tests/e2e/playwright.config.ts | 31 +- tests/e2e/playwright.containers.config.ts | 58 + tests/e2e/playwright.coverage.config.ts | 70 + .../scenarios/TextFilesShowContents.spec.ts | 17 + tests/e2e/scenarios/codegen/README.md | 70 + tests/e2e/scenarios/codegen/anon.spec.ts | 15 + .../scenarios/codegen/authed-files.spec.ts | 30 + .../scenarios/codegen/authed-trash.spec.ts | 16 + tests/e2e/scenarios/codegen/authed.spec.ts | 20 + tests/e2e/scenarios/codegen/login.spec.ts | 13 + tests/e2e/scenarios/example.template.ts | 29 + tests/e2e/scenarios/helpers.ts | 323 +- tests/e2e/scripts/codegen.sh | 105 + tests/e2e/scripts/finish-codegen.mjs | 120 + tests/e2e/spa/admin.spec.ts | 307 ++ tests/e2e/spa/appshell.spec.ts | 40 + tests/e2e/spa/auth.spec.ts | 53 + tests/e2e/spa/batch.spec.ts | 69 + tests/e2e/spa/command-palette.spec.ts | 37 + tests/e2e/spa/coverage-helpers.ts | 51 + tests/e2e/spa/device.spec.ts | 30 + tests/e2e/spa/errors.spec.ts | 49 + tests/e2e/spa/favorites.spec.ts | 62 + tests/e2e/spa/files-extra.spec.ts | 250 ++ tests/e2e/spa/files.spec.ts | 104 + tests/e2e/spa/global-setup.ts | 16 + tests/e2e/spa/groups.spec.ts | 76 + tests/e2e/spa/login.spec.ts | 70 + tests/e2e/spa/move.spec.ts | 65 + tests/e2e/spa/music.spec.ts | 157 + tests/e2e/spa/navigation.spec.ts | 54 + tests/e2e/spa/photos.spec.ts | 80 + tests/e2e/spa/profile.spec.ts | 85 + tests/e2e/spa/recent.spec.ts | 60 + tests/e2e/spa/search.spec.ts | 34 + tests/e2e/spa/share-users.spec.ts | 103 + tests/e2e/spa/shared.spec.ts | 65 + tests/e2e/spa/sharing.spec.ts | 89 + tests/e2e/spa/trash.spec.ts | 61 + tests/e2e/start-server-spa.sh | 23 + tests/e2e/start-server.sh | 23 +- tests/webdav/lib/dav_helpers.sh | 14 +- .../test_nc_chunked_upload_assembly_blake3.sh | 9 +- tests/webdav/test_nc_put_mkcol_blake3.sh | 17 +- 162 files changed, 13213 insertions(+), 554 deletions(-) create mode 100644 .claude/scheduled_tasks.lock create mode 100644 frontend/src/lib/api/endpoints/admin.test.ts create mode 100644 frontend/src/lib/api/endpoints/auth.test.ts create mode 100644 frontend/src/lib/api/endpoints/batch.test.ts create mode 100644 frontend/src/lib/api/endpoints/device.test.ts create mode 100644 frontend/src/lib/api/endpoints/favorites.test.ts create mode 100644 frontend/src/lib/api/endpoints/files.test.ts create mode 100644 frontend/src/lib/api/endpoints/grants.test.ts create mode 100644 frontend/src/lib/api/endpoints/music.test.ts create mode 100644 frontend/src/lib/api/endpoints/people.test.ts create mode 100644 frontend/src/lib/api/endpoints/photos.test.ts create mode 100644 frontend/src/lib/api/endpoints/profile.test.ts create mode 100644 frontend/src/lib/api/endpoints/recent.test.ts create mode 100644 frontend/src/lib/api/endpoints/recipients.test.ts create mode 100644 frontend/src/lib/api/endpoints/search.test.ts create mode 100644 frontend/src/lib/api/endpoints/share.test.ts create mode 100644 frontend/src/lib/api/endpoints/shares.test.ts create mode 100644 frontend/src/lib/api/endpoints/trash.test.ts create mode 100644 frontend/src/lib/api/endpoints/wopi.test.ts create mode 100644 frontend/src/lib/components/AppShell.test.ts create mode 100644 frontend/src/lib/components/CommandPalette.test.ts create mode 100644 frontend/src/lib/components/DialogHost.test.ts create mode 100644 frontend/src/lib/components/FileViewer.test.ts create mode 100644 frontend/src/lib/components/Modal.test.ts create mode 100644 frontend/src/lib/components/MoveDialog.test.ts create mode 100644 frontend/src/lib/components/PeopleView.test.ts create mode 100644 frontend/src/lib/components/PhotoLightbox.test.ts create mode 100644 frontend/src/lib/components/ShareDialog.test.ts create mode 100644 frontend/src/lib/components/WopiEditor.test.ts create mode 100644 frontend/src/lib/composables/useOwnerCache.test.ts create mode 100644 frontend/src/lib/composables/useSelection.test.ts create mode 100644 frontend/src/lib/composables/useVirtualWindow.test.ts create mode 100644 frontend/src/lib/stores/dialogs.test.ts create mode 100644 frontend/src/lib/stores/files.test.ts create mode 100644 frontend/src/lib/stores/theme.test.ts create mode 100644 frontend/src/lib/utils/display.test.ts create mode 100644 frontend/src/lib/utils/errors.test.ts create mode 100644 frontend/src/lib/utils/grid.test.ts create mode 100644 frontend/src/lib/utils/media.test.ts create mode 100644 frontend/src/lib/utils/time.test.ts create mode 100644 frontend/src/routes/admin/page.test.ts create mode 100644 frontend/src/routes/device/page.test.ts create mode 100644 frontend/src/routes/favorites/page.test.ts create mode 100644 frontend/src/routes/files/page.test.ts create mode 100644 frontend/src/routes/groups/page.test.ts create mode 100644 frontend/src/routes/login/page.test.ts create mode 100644 frontend/src/routes/music/page.test.ts create mode 100644 frontend/src/routes/photos/page.test.ts create mode 100644 frontend/src/routes/profile/page.test.ts create mode 100644 frontend/src/routes/recent/page.test.ts create mode 100644 frontend/src/routes/search/page.test.ts create mode 100644 frontend/src/routes/shared/page.test.ts create mode 100644 frontend/strip-testid.js create mode 100644 frontend/vitest-coverage.ts create mode 100644 tests/e2e/.gitignore create mode 100644 tests/e2e/coverage-report.cjs create mode 100644 tests/e2e/fixtures/oxicloud-stack.ts create mode 100644 tests/e2e/fixtures/plugin-hello.zip create mode 100644 tests/e2e/fixtures/tone.wav create mode 100644 tests/e2e/load-env.ts create mode 100644 tests/e2e/playwright.codegen.config.ts create mode 100644 tests/e2e/playwright.containers.config.ts create mode 100644 tests/e2e/playwright.coverage.config.ts create mode 100644 tests/e2e/scenarios/TextFilesShowContents.spec.ts create mode 100644 tests/e2e/scenarios/codegen/README.md create mode 100644 tests/e2e/scenarios/codegen/anon.spec.ts create mode 100644 tests/e2e/scenarios/codegen/authed-files.spec.ts create mode 100644 tests/e2e/scenarios/codegen/authed-trash.spec.ts create mode 100644 tests/e2e/scenarios/codegen/authed.spec.ts create mode 100644 tests/e2e/scenarios/codegen/login.spec.ts create mode 100644 tests/e2e/scenarios/example.template.ts create mode 100755 tests/e2e/scripts/codegen.sh create mode 100644 tests/e2e/scripts/finish-codegen.mjs create mode 100644 tests/e2e/spa/admin.spec.ts create mode 100644 tests/e2e/spa/appshell.spec.ts create mode 100644 tests/e2e/spa/auth.spec.ts create mode 100644 tests/e2e/spa/batch.spec.ts create mode 100644 tests/e2e/spa/command-palette.spec.ts create mode 100644 tests/e2e/spa/coverage-helpers.ts create mode 100644 tests/e2e/spa/device.spec.ts create mode 100644 tests/e2e/spa/errors.spec.ts create mode 100644 tests/e2e/spa/favorites.spec.ts create mode 100644 tests/e2e/spa/files-extra.spec.ts create mode 100644 tests/e2e/spa/files.spec.ts create mode 100644 tests/e2e/spa/global-setup.ts create mode 100644 tests/e2e/spa/groups.spec.ts create mode 100644 tests/e2e/spa/login.spec.ts create mode 100644 tests/e2e/spa/move.spec.ts create mode 100644 tests/e2e/spa/music.spec.ts create mode 100644 tests/e2e/spa/navigation.spec.ts create mode 100644 tests/e2e/spa/photos.spec.ts create mode 100644 tests/e2e/spa/profile.spec.ts create mode 100644 tests/e2e/spa/recent.spec.ts create mode 100644 tests/e2e/spa/search.spec.ts create mode 100644 tests/e2e/spa/share-users.spec.ts create mode 100644 tests/e2e/spa/shared.spec.ts create mode 100644 tests/e2e/spa/sharing.spec.ts create mode 100644 tests/e2e/spa/trash.spec.ts create mode 100644 tests/e2e/start-server-spa.sh diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 00000000..8cba0caa --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"82cd2c6b-7874-4cfa-9d00-297f91d81b98","pid":2450899,"procStart":"26101142","acquiredAt":1781931933331} \ No newline at end of file diff --git a/.dockerignore b/.dockerignore index 0f75f156..90b20bc8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,27 @@ # Build artifacts target/ +# Dependency / tooling directories — NEVER needed in the build context. +# These dominate the context by file count (frontend/node_modules alone is +# ~11k tiny files); shipping them makes the Testcontainers build (which tars +# the context in pure JS, matching every path against this file) crawl and can +# blow past the e2e stack-setup timeout. The frontend stage runs its own +# `npm ci`, so host node_modules is both unused and would clobber it via the +# later `COPY frontend/ ./`. .devenv/.direnv are the Nix dev shell. +**/node_modules/ +.devenv/ +.direnv/ + +# Generated SPA output — the image builds the SPA fresh in the `frontend` stage +# and pulls it via `COPY --from=frontend /static-dist`; the host copy is unused. +static-dist/ +frontend/build/ +dist/ + +# Standalone wasm sub-workspaces — not a path dependency of the server crate +# (see Cargo.toml), so `cargo build` in the image never reads them. +wasm/ + # Git .git/ .gitignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e97c6217..391fed42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,7 +64,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: 26.3.1 + node-version: 26.3.0 cache: npm cache-dependency-path: frontend/package-lock.json @@ -240,7 +240,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: 26.3.1 + node-version: 26.3.0 cache: npm cache-dependency-path: frontend/package-lock.json - name: Build SPA (Vite -> static-dist/) @@ -324,12 +324,20 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: lts/* + node-version: 26.3.0 - name: Install Node dependencies working-directory: tests/e2e 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) + working-directory: frontend + run: npm ci && VITE_E2E=1 npm run build + - name: Install Playwright browsers working-directory: tests/e2e run: npx playwright install --with-deps @@ -340,6 +348,10 @@ jobs: env: BUILD_TARGET: release + - name: Print server startup log + if: always() + run: cat tests/e2e/server-startup.log || echo "no server-startup.log produced" + - uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} with: diff --git a/Dockerfile b/Dockerfile index e05fcd3e..8dc52c7f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,13 @@ +# syntax=docker/dockerfile:1.7 +# Selects which builder stage assembles the runtime image. Defaults reproduce +# the CI/release path exactly (the `builder` stage; binaries under +# target/release). The e2e image build overrides these to +# BUILDER=builder-cache / BIN_DIR=/app/bin to use the BuildKit cache-mount +# builder. Declared in the global scope because FROM (unlike COPY --from) can +# expand a build arg in a stage reference. +ARG BUILDER=builder +ARG BIN_DIR=/app/target/release + # ─── Stage 1: Shared build base (avoids duplicate apk install) ──────────────── FROM rust:1.96-alpine3.24 AS base # sqlx's postgres driver speaks the wire protocol in pure Rust (no pq-sys in @@ -12,8 +22,15 @@ RUN apk --no-cache upgrade && \ FROM node:26.3.1-alpine3.24 AS frontend WORKDIR /frontend COPY frontend/package.json frontend/package-lock.json ./ -RUN npm ci +# Cache mount for npm's package store: when the lockfile changes (busting the +# layer) npm ci still reuses already-downloaded tarballs instead of refetching +# them. Persists in the local BuildKit cache; ignored harmlessly when absent. +RUN --mount=type=cache,target=/root/.npm npm ci COPY frontend/ ./ +# VITE_E2E=1 keeps the test-only `data-testid` attributes in the build (set by +# the e2e image build); unset for release images, which strip them entirely. +ARG VITE_E2E +ENV VITE_E2E=${VITE_E2E} RUN npm run build # ─── Stage 2: Cache dependencies ───────────────────────────────────────────── @@ -54,6 +71,48 @@ RUN DATABASE_URL="${DATABASE_URL}" cargo build --release --bin oxicloud --bin ge # below (build.rs has no asset pipeline — it only injects git metadata). COPY --from=frontend /static-dist ./static-dist +# ─── Stage 3b: Cache-mount builder (local e2e fast incremental rebuilds) ────── +# Built ONLY when BUILDER=builder-cache is passed (the Testcontainers e2e build, +# which calls .withBuildkit()). BuildKit cache mounts persist the cargo registry +# and target/ in the local BuildKit cache across runs, so a one-line src change +# recompiles just the changed crate instead of the whole dependency graph. CI +# never sets this arg, so this stage is absent from CI's build graph and CI +# behaviour/caching is unchanged. +# +# NOTE: target/ is a cache mount, so it is NOT part of the image layer once the +# RUN finishes — the two shipped binaries MUST be cp'd out within the same RUN. +# TARGETARCH scopes the target/ mount per-arch so it is never shared across +# architectures (object files are arch-specific). +FROM base AS builder-cache +WORKDIR /app +COPY Cargo.toml Cargo.lock build.rs ./ +COPY src src +COPY static static +COPY migrations migrations +COPY templates templates +COPY --from=frontend /static-dist ./static-dist +ARG DATABASE_URL="postgres://postgres:postgres@localhost/oxicloud" +ARG TARGETARCH +RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry,sharing=shared \ + --mount=type=cache,id=cargo-git,target=/usr/local/cargo/git,sharing=shared \ + --mount=type=cache,id=oxicloud-target-${TARGETARCH},target=/app/target,sharing=locked \ + DATABASE_URL="${DATABASE_URL}" cargo build --release && \ + mkdir -p /app/bin && \ + cp target/release/oxicloud /app/bin/oxicloud && \ + cp target/release/migrate-nfc-filenames /app/bin/migrate-nfc-filenames + +# ─── Stage 3c: Select the builder & normalise the binary path ───────────────── +# FROM expands the global ${BUILDER} arg to alias the chosen builder stage +# (`builder` for CI/release, `builder-cache` for the e2e image). It then copies +# the two shipped binaries from the builder-specific ${BIN_DIR} into a single +# stable path (/app/release) so the runtime stage's COPYs are independent of +# which builder ran. `static-dist` already lives at /app/static-dist in both +# builders, so it needs no normalisation. +FROM ${BUILDER} AS app +ARG BIN_DIR +RUN mkdir -p /app/release && \ + cp "${BIN_DIR}/oxicloud" "${BIN_DIR}/migrate-nfc-filenames" /app/release/ + # ─── Stage 4: Minimal runtime image ────────────────────────────────────────── FROM alpine:3.24.0 @@ -76,19 +135,19 @@ RUN apk --no-cache upgrade && \ adduser -u 1001 -S oxicloud -G oxicloud # Copy the compiled binary and entrypoint (--chmod avoids extra RUN chmod layers) -COPY --from=builder --chmod=755 /app/target/release/oxicloud /usr/local/bin/ +COPY --from=app --chmod=755 /app/release/oxicloud /usr/local/bin/ # Ship the NFC filename migration binary alongside the server so # operators can run it inside the container without a separate Rust # toolchain — `docker exec migrate-nfc-filenames --dry-run` # to preview, drop `--dry-run` to execute. One-shot tool, safe to # ship; it only mutates `storage.files` rows whose name ≠ NFC(name). -COPY --from=builder --chmod=755 /app/target/release/migrate-nfc-filenames /usr/local/bin/ +COPY --from=app --chmod=755 /app/release/migrate-nfc-filenames /usr/local/bin/ COPY entrypoint.sh /usr/local/bin/entrypoint.sh RUN sed -i 's/\r//' /usr/local/bin/entrypoint.sh && \ chmod 755 /usr/local/bin/entrypoint.sh # Copy the built SPA (produced by the Vite frontend stage) -COPY --from=builder --chown=oxicloud:oxicloud /app/static-dist /app/static +COPY --from=app --chown=oxicloud:oxicloud /app/static-dist /app/static # Create storage directory with proper permissions RUN mkdir -p /app/storage && chown -R oxicloud:oxicloud /app/storage diff --git a/devenv.nix b/devenv.nix index e73ce889..4f837545 100644 --- a/devenv.nix +++ b/devenv.nix @@ -21,8 +21,11 @@ just cargo-audit - # frontend tooling (no root package.json — these are expected as global bins) - nodejs_24 + # frontend tooling (no root package.json — these are expected as global bins). + # Pinned to Node 26 to match CI (.github/workflows/ci.yml). Newer Node ships + # a native global Web Storage API, so a skew here vs CI silently changes + # jsdom/localStorage behaviour in the Vitest suite. + nodejs_26 biome typescript # provides `tsc` stylelint @@ -39,6 +42,8 @@ # (postgres://postgres:postgres@localhost:5432/oxicloud). services.postgres = { enable = true; + # Pinned to PostgreSQL 18 to match CI (postgres:18-alpine in ci.yml). + package = pkgs.postgresql_18; listen_addresses = "127.0.0.1"; port = 5432; initialDatabases = [ { name = "oxicloud"; } ]; diff --git a/docker-compose.yml b/docker-compose.yml index f140be85..dc8908dc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,14 @@ services: build: context: . dockerfile: Dockerfile + # Route `docker compose up --build` / `docker compose build` through the + # cache-mount builder so repeat local builds recompile only changed crates + # (true incremental). Compose v2 uses BuildKit by default, which the + # Dockerfile already requires. The image is functionally identical to the + # default `builder` stage CI uses; only the build path differs. + args: + BUILDER: builder-cache + BIN_DIR: /app/bin ports: - "8086:8086" networks: diff --git a/frontend/.gitignore b/frontend/.gitignore index 5b512e6e..fb6b1ef8 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -7,3 +7,6 @@ node_modules/ *.local vite.config.ts.timestamp-* vite.config.js.timestamp-* + +# Vitest/istanbul coverage output +/coverage/ diff --git a/frontend/.prettierignore b/frontend/.prettierignore index d199756b..ea92f6fa 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -16,3 +16,4 @@ static/vendors/ static/workers/ static/basemaps/ static/geo/ +coverage/ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 97b7a79f..08687942 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,6 +20,7 @@ "eslint-plugin-svelte": "^3.19.0", "globals": "^17.6.0", "jsdom": "^29.1.1", + "parse5": "^7.3.0", "postcss-html": "^1.8.1", "prettier": "^3.8.4", "prettier-plugin-svelte": "^4.1.1", @@ -30,6 +31,7 @@ "typescript": "^6.0.3", "typescript-eslint": "^8.61.1", "vite": "^6.4.3", + "vite-plugin-istanbul": "^8.0.0", "vitest": "^4.1.9" }, "engines": { @@ -109,6 +111,163 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", @@ -119,6 +278,46 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/runtime": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", @@ -129,6 +328,54 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -676,6 +923,123 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1364,6 +1728,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1893,6 +2267,19 @@ "node": "18 || 20 || >=22" } }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -1929,6 +2316,40 @@ "node": ">=8" } }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/cacheable": { "version": "2.3.5", "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.5.tgz", @@ -1963,6 +2384,37 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -2281,6 +2733,13 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/electron-to-chromium": { + "version": "1.5.376", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz", + "integrity": "sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==", + "dev": true, + "license": "ISC" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -2289,13 +2748,13 @@ "license": "MIT" }, "node_modules/entities": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", - "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, "license": "BSD-2-Clause", "engines": { - "node": ">=20.19.0" + "node": ">=0.12" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" @@ -2328,6 +2787,16 @@ "dev": true, "license": "MIT" }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -2547,6 +3016,20 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -2798,6 +3281,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-east-asian-width": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", @@ -2811,6 +3304,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3160,6 +3681,33 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3231,6 +3779,45 @@ } } }, + "node_modules/jsdom/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -3259,6 +3846,19 @@ "dev": true, "license": "MIT" }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3468,6 +4068,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -3521,6 +4131,16 @@ "dev": true, "license": "MIT" }, + "node_modules/node-releases": { + "version": "2.0.48", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", + "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -3595,6 +4215,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3628,13 +4258,13 @@ } }, "node_modules/parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", - "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, "license": "MIT", "dependencies": { - "entities": "^8.0.0" + "entities": "^6.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" @@ -3660,6 +4290,23 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -4233,6 +4880,16 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4243,6 +4900,13 @@ "node": ">=0.10.0" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -4771,6 +5435,21 @@ "dev": true, "license": "MIT" }, + "node_modules/test-exclude": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", + "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^13.0.6", + "minimatch": "^10.2.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -5009,6 +5688,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -5101,6 +5811,26 @@ } } }, + "node_modules/vite-plugin-istanbul": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/vite-plugin-istanbul/-/vite-plugin-istanbul-8.0.0.tgz", + "integrity": "sha512-r6L7cg2iwPqNnY/rWFyemWeDTIKRZjekEWS90e2FsTjDYH4UdTS6hvW1nEX1B++PKPCnqCaj5BJTDn5Cy5jYoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.29.1", + "@istanbuljs/load-nyc-config": "^1.1.0", + "@types/babel__generator": "7.27.0", + "espree": "^11.2.0", + "istanbul-lib-instrument": "^6.0.3", + "picocolors": "^1.1.1", + "source-map": "^0.7.6", + "test-exclude": "^8.0.0" + }, + "peerDependencies": { + "vite": ">=4" + } + }, "node_modules/vite/node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -5809,6 +6539,13 @@ "dev": true, "license": "MIT" }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 79d28463..4b01215f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,7 +15,8 @@ "lint": "eslint .", "format": "prettier --write .", "test:unit": "vitest run", - "test:unit:watch": "vitest" + "test:unit:watch": "vitest", + "test:unit:coverage": "rm -rf ../tests/e2e/.nyc_output_unit && COVERAGE=1 vitest run" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -30,6 +31,7 @@ "eslint-plugin-svelte": "^3.19.0", "globals": "^17.6.0", "jsdom": "^29.1.1", + "parse5": "^7.3.0", "postcss-html": "^1.8.1", "prettier": "^3.8.4", "prettier-plugin-svelte": "^4.1.1", @@ -40,6 +42,7 @@ "typescript": "^6.0.3", "typescript-eslint": "^8.61.1", "vite": "^6.4.3", + "vite-plugin-istanbul": "^8.0.0", "vitest": "^4.1.9" } } diff --git a/frontend/src/app.html b/frontend/src/app.html index eb5e0e1f..505b1b20 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -5,13 +5,20 @@ + %sveltekit.head% - - diff --git a/frontend/src/lib/components/CommandPalette.svelte b/frontend/src/lib/components/CommandPalette.svelte index 2c34c151..1f197cb2 100644 --- a/frontend/src/lib/components/CommandPalette.svelte +++ b/frontend/src/lib/components/CommandPalette.svelte @@ -277,6 +277,7 @@ role="dialog" aria-modal="true" aria-label={t('cmdk.title', 'Command palette')} + data-testid="command-palette-panel" > {#if filtered.length === 0} @@ -301,6 +303,7 @@ class:active={i === index} role="option" aria-selected={i === index} + data-testid={`command-palette-${cmd.id}-item`} onmouseenter={() => (index = i)} onclick={cmd.run} > diff --git a/frontend/src/lib/components/CommandPalette.test.ts b/frontend/src/lib/components/CommandPalette.test.ts new file mode 100644 index 00000000..d13c40f5 --- /dev/null +++ b/frontend/src/lib/components/CommandPalette.test.ts @@ -0,0 +1,47 @@ +import { it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; + +const { goto } = vi.hoisted(() => ({ goto: vi.fn() })); +vi.mock('$app/navigation', () => ({ goto })); +vi.mock('$lib/api/endpoints/auth', () => ({ logout: vi.fn() })); +vi.mock('$lib/api/endpoints/search', () => ({ searchFiles: vi.fn(async () => ({ items: [] })) })); +vi.mock('$lib/api/endpoints/files', () => ({ fileInlineUrl: () => '/in' })); +vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog: vi.fn() })); + +import { searchFiles } from '$lib/api/endpoints/search'; +import { session } from '$lib/stores/session.svelte'; +import CommandPalette from './CommandPalette.svelte'; + +const m = (fn: unknown) => fn as ReturnType; + +function openPalette() { + return fireEvent.keyDown(window, { key: 'k', ctrlKey: true }); +} + +beforeEach(() => { + vi.clearAllMocks(); + session.user = { id: '1', username: 'admin', role: 'admin', is_external: false } as never; +}); + +it('opens on Ctrl+K and shows the command panel', async () => { + render(CommandPalette); + await openPalette(); + await screen.findByTestId('command-palette-panel'); + expect(screen.getByTestId('command-palette-input')).toBeTruthy(); +}); + +it('runs a navigation command', async () => { + render(CommandPalette); + await openPalette(); + await fireEvent.click(await screen.findByTestId('command-palette-files-item')); + await waitFor(() => expect(goto).toHaveBeenCalledWith('/files')); +}); + +it('searches files as the query is typed', async () => { + render(CommandPalette); + await openPalette(); + const input = await screen.findByTestId('command-palette-input'); + await fireEvent.input(input, { target: { value: 'report' } }); + await waitFor(() => expect(searchFiles).toHaveBeenCalled()); + expect(m(searchFiles).mock.calls[0][0]).toBe('report'); +}); diff --git a/frontend/src/lib/components/DialogHost.svelte b/frontend/src/lib/components/DialogHost.svelte index c92a9a9a..e2dd4636 100644 --- a/frontend/src/lib/components/DialogHost.svelte +++ b/frontend/src/lib/components/DialogHost.svelte @@ -53,6 +53,7 @@ {#if c.opts.message}

{c.opts.message}

{/if} dialogs.cancel()}> + {#if c.kind === 'prompt'} - {/if} - diff --git a/frontend/src/lib/components/FileViewer.test.ts b/frontend/src/lib/components/FileViewer.test.ts new file mode 100644 index 00000000..1fc3974a --- /dev/null +++ b/frontend/src/lib/components/FileViewer.test.ts @@ -0,0 +1,97 @@ +import { it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn() })); +vi.mock('$lib/api/endpoints/files', () => ({ + fileDownloadUrl: () => '/dl', + fileInlineUrl: () => '/in' +})); +vi.mock('$lib/api/endpoints/wopi', () => ({ + canEditWithWopi: vi.fn(), + // WopiEditor's $effect calls this on mount when an editor opens; provide it + // so the async handshake resolves instead of throwing an unhandled error + // (vitest 4 fails the whole run on unhandled errors). + getEditorUrlWithFallback: vi.fn(async () => ({ + editor_url: 'about:blank', + access_token: 't', + access_token_ttl: 0 + })) +})); +import { apiFetch } from '$lib/api/client'; +import { canEditWithWopi } from '$lib/api/endpoints/wopi'; +import FileViewer from './FileViewer.svelte'; +const af = apiFetch as unknown as ReturnType; +const cw = canEditWithWopi as unknown as ReturnType; +function file(over: Record = {}) { + return { + id: 'i', + name: 'pic.png', + mime_type: 'image/png', + category: 'Image', + folder_id: '', + owner_id: '', + path: '', + size: 1, + modified_at: 0, + created_at: 0, + sort_date: 0, + icon_class: '', + icon_special_class: '', + size_formatted: '1 B', + etag: '', + content_hash: '', + ...over + } as never; +} +beforeEach(() => { + vi.clearAllMocks(); + cw.mockResolvedValue(false); +}); +it('renders an image with working zoom controls and closes', async () => { + render(FileViewer, { props: { open: true, file: file() } }); + expect(await screen.findByTestId('file-viewer-dialog')).toBeTruthy(); + await fireEvent.click(screen.getByTestId('file-viewer-zoom-in-btn')); + await fireEvent.click(screen.getByTestId('file-viewer-zoom-out-btn')); + await fireEvent.click(screen.getByTestId('file-viewer-zoom-reset-btn')); + await fireEvent.click(screen.getByTestId('file-viewer-close-btn')); +}); +it('fetches text content for a text file', async () => { + af.mockResolvedValue({ ok: true, text: async () => 'hello world' }); + render(FileViewer, { + props: { open: true, file: file({ name: 'n.txt', mime_type: 'text/plain', category: 'Text' }) } + }); + expect(await screen.findByTestId('file-viewer-dialog')).toBeTruthy(); + await waitFor(() => expect(af).toHaveBeenCalled()); +}); +it('renders nothing when closed', () => { + render(FileViewer, { props: { open: false, file: file() } }); + expect(screen.queryByTestId('file-viewer-dialog')).toBeNull(); +}); +it('shows an Edit button for a WOPI-editable document', async () => { + cw.mockResolvedValue(true); + render(FileViewer, { + props: { + open: true, + file: file({ + name: 'report.docx', + mime_type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + category: 'Document' + }) + } + }); + await screen.findByTestId('file-viewer-dialog'); + await waitFor(() => expect(screen.getByTestId('file-viewer-edit-btn')).toBeTruthy()); +}); +it('exposes download and open-in-new-tab links', async () => { + render(FileViewer, { props: { open: true, file: file() } }); + await screen.findByTestId('file-viewer-dialog'); + expect(screen.getByTestId('file-viewer-download-link').getAttribute('href')).toBe('/dl'); + expect(screen.getByTestId('file-viewer-open-new-tab-link').getAttribute('href')).toBe('/in'); +}); +it('handles a failed text fetch without crashing', async () => { + af.mockResolvedValue({ ok: false, status: 500, text: async () => '' }); + render(FileViewer, { + props: { open: true, file: file({ name: 'n.txt', mime_type: 'text/plain', category: 'Text' }) } + }); + await screen.findByTestId('file-viewer-dialog'); + await waitFor(() => expect(af).toHaveBeenCalled()); +}); diff --git a/frontend/src/lib/components/ListToolbar.svelte b/frontend/src/lib/components/ListToolbar.svelte index 0a1d9254..89e85882 100644 --- a/frontend/src/lib/components/ListToolbar.svelte +++ b/frontend/src/lib/components/ListToolbar.svelte @@ -67,12 +67,13 @@ {#if groups?.length || showViewToggle}
{#if groups?.length} -
+
{/if} diff --git a/frontend/src/lib/components/Modal.svelte b/frontend/src/lib/components/Modal.svelte index 6b7592b4..e12eea0a 100644 --- a/frontend/src/lib/components/Modal.svelte +++ b/frontend/src/lib/components/Modal.svelte @@ -86,13 +86,17 @@ aria-modal="true" aria-label={title} tabindex="-1" + data-testid="modal" bind:this={dialogEl} > {#if title} {/if} diff --git a/frontend/src/lib/components/Modal.test.ts b/frontend/src/lib/components/Modal.test.ts new file mode 100644 index 00000000..8052e208 --- /dev/null +++ b/frontend/src/lib/components/Modal.test.ts @@ -0,0 +1,34 @@ +import { it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import { createRawSnippet } from 'svelte'; +import Modal from './Modal.svelte'; + +const children = createRawSnippet(() => ({ + render: () => '
content
' +})); + +it('renders the title and children when open', () => { + render(Modal, { props: { open: true, title: 'My dialog', children } }); + expect(screen.getByTestId('modal')).toBeTruthy(); + expect(screen.getByText('My dialog')).toBeTruthy(); + expect(screen.getByTestId('modal-body')).toBeTruthy(); +}); + +it('does not render when closed', () => { + render(Modal, { props: { open: false, title: 'Hidden', children } }); + expect(screen.queryByTestId('modal')).toBeNull(); +}); + +it('invokes onclose when the close button is clicked', async () => { + const onclose = vi.fn(); + render(Modal, { props: { open: true, title: 'X', onclose, children } }); + await fireEvent.click(screen.getByTestId('modal-close-btn')); + expect(onclose).toHaveBeenCalled(); +}); + +it('closes on Escape', async () => { + const onclose = vi.fn(); + render(Modal, { props: { open: true, title: 'X', onclose, children } }); + await fireEvent.keyDown(window, { key: 'Escape' }); + expect(onclose).toHaveBeenCalled(); +}); diff --git a/frontend/src/lib/components/MoveDialog.svelte b/frontend/src/lib/components/MoveDialog.svelte index 42436d75..3b7fa193 100644 --- a/frontend/src/lib/components/MoveDialog.svelte +++ b/frontend/src/lib/components/MoveDialog.svelte @@ -123,51 +123,73 @@ -
- - - +
+
+ + + +
+ + {#if loading} +

{t('common.loading', 'Loading…')}

+ {:else if folders.length === 0} +

{t('files.no_subfolders', 'No subfolders here.')}

+ {:else} +
    + {#each folders as f (f.id)} +
  • + +
  • + {/each} +
+ {/if}
- {#if loading} -

{t('common.loading', 'Loading…')}

- {:else if folders.length === 0} -

{t('files.no_subfolders', 'No subfolders here.')}

- {:else} -
    - {#each folders as f (f.id)} -
  • - -
  • - {/each} -
- {/if} - {#snippet footer()} - - {/snippet} diff --git a/frontend/src/lib/components/MoveDialog.test.ts b/frontend/src/lib/components/MoveDialog.test.ts new file mode 100644 index 00000000..5247c3c5 --- /dev/null +++ b/frontend/src/lib/components/MoveDialog.test.ts @@ -0,0 +1,82 @@ +import { it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; + +const { session, ui } = vi.hoisted(() => ({ + session: { loadHomeFolder: vi.fn(async () => 'home'), homeFolderName: 'Files' }, + ui: { notify: vi.fn() } +})); +vi.mock('$lib/stores/session.svelte', () => ({ session })); +vi.mock('$lib/stores/ui.svelte', () => ({ ui })); +vi.mock('$lib/utils/errors', () => ({ errorToast: vi.fn() })); +vi.mock('$lib/api/endpoints/folders', () => ({ listFolder: vi.fn(), moveFolder: vi.fn() })); +vi.mock('$lib/api/endpoints/files', () => ({ moveFile: vi.fn() })); +vi.mock('$lib/api/endpoints/batch', () => ({ copyFiles: vi.fn(), copyFolders: vi.fn() })); + +import { listFolder, moveFolder } from '$lib/api/endpoints/folders'; +import { moveFile } from '$lib/api/endpoints/files'; +import { copyFiles } from '$lib/api/endpoints/batch'; +import MoveDialog from './MoveDialog.svelte'; + +const m = (fn: unknown) => fn as ReturnType; +const item = { id: 'f1', name: 'doc.txt', kind: 'file' as const }; + +function folder(id: string, name: string) { + return { + category: 'Folder', + created_at: 0, + icon_class: 'fa-folder', + icon_special_class: '', + id, + is_root: false, + modified_at: 0, + name, + owner_id: 'me', + parent_id: 'home', + path: '/' + name, + etag: 'e' + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + m(listFolder).mockResolvedValue({ + folders: [folder('sub1', 'Sub')], + files: [], + favoriteIds: [], + sharedIds: [] + }); +}); + +it('loads the home folder when opened', async () => { + render(MoveDialog, { props: { open: true, item } }); + await waitFor(() => expect(listFolder).toHaveBeenCalledWith('home')); + await screen.findByTestId('move-dialog'); +}); + +it('moves the item into the current folder on confirm', async () => { + m(moveFile).mockResolvedValue(undefined); + const onmoved = vi.fn(); + render(MoveDialog, { props: { open: true, item, onmoved } }); + await screen.findByTestId('move-dialog-confirm-btn'); + await fireEvent.click(screen.getByTestId('move-dialog-confirm-btn')); + await waitFor(() => expect(moveFile).toHaveBeenCalledWith('f1', 'home')); + await waitFor(() => expect(onmoved).toHaveBeenCalled()); +}); + +it('navigates into a subfolder before confirming', async () => { + m(moveFolder).mockResolvedValue(undefined); + const folderItem = { id: 'fold9', name: 'Dir', kind: 'folder' as const }; + render(MoveDialog, { props: { open: true, item: folderItem } }); + await fireEvent.click(await screen.findByTestId('move-dialog-folder-sub1')); + await waitFor(() => expect(listFolder).toHaveBeenCalledWith('sub1')); + await fireEvent.click(screen.getByTestId('move-dialog-confirm-btn')); + await waitFor(() => expect(moveFolder).toHaveBeenCalledWith('fold9', 'sub1')); +}); + +it('copies the item in copy mode', async () => { + m(copyFiles).mockResolvedValue(undefined); + render(MoveDialog, { props: { open: true, item, mode: 'copy' } }); + await screen.findByTestId('move-dialog-confirm-btn'); + await fireEvent.click(screen.getByTestId('move-dialog-confirm-btn')); + await waitFor(() => expect(copyFiles).toHaveBeenCalledWith(['f1'], 'home')); +}); diff --git a/frontend/src/lib/components/PeopleView.test.ts b/frontend/src/lib/components/PeopleView.test.ts new file mode 100644 index 00000000..049288e9 --- /dev/null +++ b/frontend/src/lib/components/PeopleView.test.ts @@ -0,0 +1,82 @@ +import { it, expect, vi, beforeEach } from 'vitest'; +import { render, fireEvent, screen, waitFor } from '@testing-library/svelte'; + +vi.mock('$lib/api/endpoints/people', () => ({ + fetchPeople: vi.fn(), + fetchPersonPhotos: vi.fn(), + renamePerson: vi.fn() +})); +vi.mock('$lib/api/endpoints/files', () => ({ fileThumbnailUrl: () => '/thumb.png' })); +vi.mock('$lib/stores/dialogs.svelte', () => ({ promptDialog: vi.fn() })); + +import { fetchPeople, fetchPersonPhotos, renamePerson } from '$lib/api/endpoints/people'; +import { promptDialog } from '$lib/stores/dialogs.svelte'; +import PeopleView from './PeopleView.svelte'; + +const fp = fetchPeople as unknown as ReturnType; +const fpp = fetchPersonPhotos as unknown as ReturnType; +const rn = renamePerson as unknown as ReturnType; +const pd = promptDialog as unknown as ReturnType; + +beforeEach(() => vi.clearAllMocks()); + +it('renders the people grid (named + unnamed)', async () => { + fp.mockResolvedValue([ + { id: 'p1', name: 'Alice', face_count: 3, cover_file_id: 'c1' }, + { id: 'p2', name: '', face_count: 1, cover_file_id: null } + ]); + render(PeopleView); + expect(await screen.findByText('Alice')).toBeTruthy(); + expect(screen.getByText('Unnamed')).toBeTruthy(); +}); + +it('shows an empty state when there are no people', async () => { + fp.mockResolvedValue([]); + render(PeopleView); + expect(await screen.findByText('No people yet')).toBeTruthy(); +}); + +it('shows the disabled state when the list errors', async () => { + fp.mockRejectedValue(new Error('off')); + render(PeopleView); + expect(await screen.findByText('Face recognition is disabled')).toBeTruthy(); +}); + +it('drills into a person and back to the list', async () => { + fp.mockResolvedValue([{ id: 'p1', name: 'Alice', face_count: 2, cover_file_id: null }]); + fpp.mockResolvedValue(['ph1', 'ph2']); + render(PeopleView); + const btn = (await screen.findByText('Alice')).closest('button')!; + await fireEvent.click(btn); + await waitFor(() => expect(fpp).toHaveBeenCalledWith('p1')); + await fireEvent.click(screen.getByLabelText('Back')); + expect(await screen.findByText('Alice')).toBeTruthy(); +}); + +it('renames the current person', async () => { + fp.mockResolvedValue([{ id: 'p1', name: 'Alice', face_count: 1, cover_file_id: null }]); + fpp.mockResolvedValue([]); + pd.mockResolvedValue('Bob'); + rn.mockResolvedValue(undefined); + render(PeopleView); + await fireEvent.click((await screen.findByText('Alice')).closest('button')!); + await waitFor(() => screen.getByLabelText('Name this person')); + await fireEvent.click(screen.getByLabelText('Name this person')); + await waitFor(() => expect(rn).toHaveBeenCalledWith('p1', 'Bob')); +}); + +it("opens a person's photo in the lightbox", async () => { + fp.mockResolvedValue([{ id: 'p1', name: 'Alice', face_count: 2, cover_file_id: null }]); + fpp.mockResolvedValue(['ph1', 'ph2']); + const { container } = render(PeopleView); + await fireEvent.click((await screen.findByText('Alice')).closest('button')!); + await waitFor(() => expect(fpp).toHaveBeenCalledWith('p1')); + const tiles = await waitFor(() => { + const found = container.querySelectorAll('.photos__open'); + if (found.length === 0) throw new Error('no tiles yet'); + return found; + }); + expect(tiles.length).toBe(2); + await fireEvent.click(tiles[0]); + expect(await screen.findByTestId('photo-lightbox')).toBeTruthy(); +}); diff --git a/frontend/src/lib/components/PhotoLightbox.svelte b/frontend/src/lib/components/PhotoLightbox.svelte index 97da2276..7f2ab861 100644 --- a/frontend/src/lib/components/PhotoLightbox.svelte +++ b/frontend/src/lib/components/PhotoLightbox.svelte @@ -180,6 +180,7 @@ aria-modal="true" aria-label={item.name} tabindex="-1" + data-testid="photo-lightbox" onclick={(e) => e.target === e.currentTarget && close()} >
@@ -187,12 +188,18 @@
{meta}
- + + {/if} @@ -470,6 +489,7 @@ @@ -492,12 +512,19 @@ onclick={closeContext} oncontextmenu={(e) => e.preventDefault()} >
- {#snippet footer()} - {/snippet} diff --git a/frontend/src/lib/components/ShareDialog.test.ts b/frontend/src/lib/components/ShareDialog.test.ts new file mode 100644 index 00000000..0530cac7 --- /dev/null +++ b/frontend/src/lib/components/ShareDialog.test.ts @@ -0,0 +1,68 @@ +import { it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; + +const { ui } = vi.hoisted(() => ({ ui: { notify: vi.fn() } })); +vi.mock('$lib/stores/ui.svelte', () => ({ ui })); +vi.mock('$lib/utils/errors', () => ({ errorToast: vi.fn() })); +vi.mock('$lib/api/endpoints/shares', () => ({ + copyShareLink: vi.fn(), + createShare: vi.fn(), + deleteShare: vi.fn(), + listSharesForItem: vi.fn(), + updateShare: vi.fn() +})); +vi.mock('$lib/api/endpoints/grants', () => ({ + createGrant: vi.fn(), + expiryToIso: (v: string | null) => v, + displayRole: (r: string) => r, + fetchGrantsForResource: vi.fn(), + notifyGrantRecipient: vi.fn(), + revokeGrant: vi.fn(), + updateGrantRole: vi.fn() +})); +vi.mock('$lib/api/endpoints/recipients', () => ({ + ensureResolvers: vi.fn(), + isDirectoryAvailable: () => true, + resolveRecipient: (_t: string, id: string) => ({ id, label: id }), + searchRecipients: vi.fn(async () => []) +})); + +import { createShare, listSharesForItem } from '$lib/api/endpoints/shares'; +import { fetchGrantsForResource } from '$lib/api/endpoints/grants'; +import ShareDialog from './ShareDialog.svelte'; + +const m = (fn: unknown) => fn as ReturnType; +const item = { id: 'f1', name: 'doc.txt', kind: 'file' as const }; + +beforeEach(() => { + vi.clearAllMocks(); + m(fetchGrantsForResource).mockResolvedValue([]); + m(listSharesForItem).mockResolvedValue([]); +}); + +it('loads grants and shares when opened', async () => { + render(ShareDialog, { props: { open: true, item } }); + await screen.findByTestId('share-dialog'); + await waitFor(() => expect(fetchGrantsForResource).toHaveBeenCalledWith('file', 'f1')); + await waitFor(() => expect(listSharesForItem).toHaveBeenCalledWith('f1', 'file')); +}); + +it('switches to the link tab and creates a public link', async () => { + m(createShare).mockResolvedValue({ id: 's1', token: 'abc', has_password: false }); + render(ShareDialog, { props: { open: true, item } }); + await fireEvent.click(await screen.findByTestId('share-dialog-link-tab')); + await fireEvent.input(screen.getByTestId('share-dialog-link-name-input'), { + target: { value: 'My link' } + }); + await fireEvent.click(screen.getByTestId('share-dialog-create-btn')); + await waitFor(() => + expect(createShare).toHaveBeenCalledWith( + expect.objectContaining({ itemId: 'f1', itemName: 'My link', itemType: 'file' }) + ) + ); +}); + +it('does not load when closed', () => { + render(ShareDialog, { props: { open: false, item } }); + expect(fetchGrantsForResource).not.toHaveBeenCalled(); +}); diff --git a/frontend/src/lib/components/Toaster.svelte b/frontend/src/lib/components/Toaster.svelte index 236fc1ae..ed13e130 100644 --- a/frontend/src/lib/components/Toaster.svelte +++ b/frontend/src/lib/components/Toaster.svelte @@ -10,10 +10,11 @@ aria-label={t('notifications.title', 'Notifications')} > {#each ui.toasts as toast (toast.id)} -
+
{toast.message} @@ -97,8 +108,18 @@ target="wopi_frame" class="wopi__form" > - - + + {/if}