Merge pull request #494 from BCNelson/bcn/e2eTesting

This commit is contained in:
Dionisio Pozo
2026-06-22 08:09:24 +02:00
committed by GitHub
162 changed files with 13213 additions and 554 deletions
+1
View File
@@ -0,0 +1 @@
{"sessionId":"82cd2c6b-7874-4cfa-9d00-297f91d81b98","pid":2450899,"procStart":"26101142","acquiredAt":1781931933331}
+21
View File
@@ -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
+15 -3
View File
@@ -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:
+63 -4
View File
@@ -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 <container> 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
+7 -2
View File
@@ -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"; } ];
+8
View File
@@ -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:
+3
View File
@@ -7,3 +7,6 @@ node_modules/
*.local
vite.config.ts.timestamp-*
vite.config.js.timestamp-*
# Vitest/istanbul coverage output
/coverage/
+1
View File
@@ -16,3 +16,4 @@ static/vendors/
static/workers/
static/basemaps/
static/geo/
coverage/
+745 -8
View File
@@ -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",
+4 -1
View File
@@ -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"
}
}
+8 -2
View File
@@ -5,13 +5,20 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<meta name="color-scheme" content="light dark" />
%sveltekit.head%
<!--
Anti-FOUC theme init — runs synchronously before first paint.
Ported from static/js/core/theme-init.js. Keeps the legacy
`oxicloud_theme` localStorage key and `data-color-scheme` attribute
so existing users keep their preference across the migration.
Placed AFTER %sveltekit.head% so it follows the CSP <meta> SvelteKit
injects there, hence it IS governed by that policy. svelte.config.js
parses this file, finds this <script> by id="theme-init", and adds its
SHA-256 to script-src automatically — so it's allowed inline (zero extra
request, no 'unsafe-inline') and the hash stays in sync if you edit it.
-->
<script>
<script id="theme-init">
(function () {
try {
var s = localStorage.getItem('oxicloud_theme');
@@ -64,7 +71,6 @@
}
}
</style>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div id="app-splash" role="status" aria-label="Loading">
+13
View File
@@ -128,3 +128,16 @@ describe('createApiFetch — 401 refresh/retry parity', () => {
expect(rawFetch).toHaveBeenCalledTimes(3);
});
});
describe('ApiError + apiJson', () => {
it('ApiError carries status, statusText, and a descriptive message', async () => {
const { ApiError } = await import('./client');
const e = new ApiError(404, 'Not Found', '/api/files/x');
expect(e.status).toBe(404);
expect(e.statusText).toBe('Not Found');
expect(e.name).toBe('ApiError');
expect(e.message).toContain('404');
expect(e.message).toContain('/api/files/x');
expect(e).toBeInstanceOf(Error);
});
});
@@ -0,0 +1,126 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({ 'x-csrf-token': 't' }) }));
import { apiFetch, apiJson } from '$lib/api/client';
import * as admin from './admin';
const okRes = (body: unknown = {}) =>
({ ok: true, status: 200, json: async () => body }) as unknown as Response;
const errRes = (status = 400, body: unknown = { message: 'nope' }) =>
({ ok: false, status, json: async () => body }) as unknown as Response;
const fetchMock = apiFetch as unknown as ReturnType<typeof vi.fn>;
const jsonMock = apiJson as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
fetchMock.mockResolvedValue(okRes());
jsonMock.mockResolvedValue({});
});
describe('admin mutate-based endpoints', () => {
it('resolve on success and call the expected URL/method', async () => {
await admin.createUser({
username: 'u',
password: 'p',
email: null,
role: 'user',
quota_bytes: 0
});
expect(fetchMock).toHaveBeenCalledWith(
'/api/admin/users',
expect.objectContaining({ method: 'POST' })
);
await admin.setUserRole('1', 'admin');
await admin.setUserActive('1', false);
await admin.setUserQuota('1', 100);
await admin.resetUserPassword('1', 'newpw');
await admin.deleteUser('1');
await admin.setRegistrationEnabled(true);
await admin.saveOidc({ enabled: true });
await admin.saveStorage({ backend: 'local' });
await admin.savePluginRetention('id', { retention_days: 30, max_bytes: 100 });
await admin.clearPluginLogs('id');
await admin.setPluginEnabled('id', true);
await admin.deletePlugin('id');
await admin.migrationAction('start');
await admin.migrationAction('pause');
expect(fetchMock).toHaveBeenCalled();
});
it('throw the server message on failure', async () => {
fetchMock.mockResolvedValue(errRes(409, { message: 'conflict' }));
await expect(admin.deleteUser('1')).rejects.toThrow('conflict');
});
it('throw a generic message when the error body has none', async () => {
fetchMock.mockResolvedValue(errRes(500, {}));
await expect(admin.setUserActive('1', true)).rejects.toThrow(/failed: 500/);
});
});
describe('admin read endpoints', () => {
it('call apiJson for the listing/settings reads', async () => {
await admin.listUsers(25, 0);
expect(jsonMock).toHaveBeenCalledWith(
expect.stringContaining('/api/admin/users?limit=25&offset=0'),
expect.anything()
);
await admin.getDashboard();
await admin.getSmtpInfo();
await admin.getOidcSettings();
await admin.getStorageSettings();
await admin.getMigration();
await admin.listPlugins();
await admin.getPluginLogs('id', { limit: 50, offset: 0 });
expect(jsonMock).toHaveBeenCalled();
});
it('getPluginRetention returns null when the request is not ok', async () => {
fetchMock.mockResolvedValueOnce(errRes(404, {}));
await expect(admin.getPluginRetention('id')).resolves.toBeNull();
fetchMock.mockResolvedValueOnce(okRes({ max_age_days: 7, max_entries: 50 }));
await expect(admin.getPluginRetention('id')).resolves.toMatchObject({ max_age_days: 7 });
});
});
describe('admin test/probe endpoints', () => {
it('sendSmtpTest maps 503 to an unconfigured message', async () => {
fetchMock.mockResolvedValue({ status: 503, json: async () => ({}) } as unknown as Response);
await expect(admin.sendSmtpTest('to@x.test')).resolves.toMatchObject({ success: false });
});
it('sendSmtpTest returns the parsed result otherwise', async () => {
fetchMock.mockResolvedValue(okRes({ success: true }));
await expect(admin.sendSmtpTest('to@x.test')).resolves.toMatchObject({ success: true });
});
it('testOidc / testStorage return parsed results', async () => {
fetchMock.mockResolvedValue(okRes({ success: true }));
await expect(admin.testOidc('https://idp')).resolves.toBeTruthy();
fetchMock.mockResolvedValue(okRes({ connected: true }));
await expect(admin.testStorage({ backend: 's3' })).resolves.toMatchObject({ connected: true });
});
it('verifyMigration fills defaults and throws on error', async () => {
fetchMock.mockResolvedValue(okRes({ passed: true }));
await expect(admin.verifyMigration(10)).resolves.toMatchObject({
passed: true,
sample_checked: 0
});
fetchMock.mockResolvedValue(errRes(500, {}));
await expect(admin.verifyMigration()).rejects.toThrow(/verify failed/);
});
it('installPlugin posts a FormData bundle', async () => {
fetchMock.mockResolvedValue(okRes({ id: 'com.example.hello' }));
const file = new File([new Uint8Array([1, 2, 3])], 'p.zip', { type: 'application/zip' });
await admin.installPlugin(file);
expect(fetchMock).toHaveBeenCalledWith(
'/api/admin/plugins',
expect.objectContaining({ method: 'POST' })
);
});
});
@@ -0,0 +1,44 @@
import { it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch, apiJson } from '$lib/api/client';
import * as auth from './auth';
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
// Several auth probes use the raw global fetch (NOT apiFetch) on purpose.
const okRes = { ok: true, status: 200, json: async () => ({}) };
beforeEach(() => {
vi.clearAllMocks();
f.mockResolvedValue(okRes);
j.mockResolvedValue({});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okRes));
});
afterEach(() => vi.unstubAllGlobals());
it('exercises the auth endpoints (success paths)', async () => {
await auth.fetchMe().catch(() => {});
await auth.tryRefresh().catch(() => {});
await auth.login('u', 'p').catch(() => {});
await auth.getOidcProviders().catch(() => {});
await auth.getAuthStatus().catch(() => {});
await auth.setupAdmin('e@x.test', 'p').catch(() => {});
await auth.exchangeOidcCode('code').catch(() => {});
await auth.register('u', 'e@x.test', 'p').catch(() => {});
await auth.sendMagicLink('e@x.test').catch(() => {});
await auth.logout().catch(() => {});
const fc = (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mock.calls.length;
expect(fc + f.mock.calls.length).toBeGreaterThan(3);
});
it('fetchMe returns null when the probe is not ok', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) })
);
await expect(auth.fetchMe()).resolves.toBeNull();
});
it('tryRefresh returns false when the refresh fails', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) })
);
await expect(auth.tryRefresh()).resolves.toBe(false);
});
@@ -0,0 +1,39 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch } from '$lib/api/client';
import { copyFiles, copyFolders } from './batch';
const fetchMock = apiFetch as unknown as ReturnType<typeof vi.fn>;
describe('batch copy', () => {
beforeEach(() => {
vi.clearAllMocks();
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
});
it('short-circuits on empty input', async () => {
await copyFiles([], null);
await copyFolders([], 'x');
expect(fetchMock).not.toHaveBeenCalled();
});
it('posts copy requests for files and folders', async () => {
await copyFiles(['a'], 't');
await copyFolders(['b'], null);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenCalledWith(
'/api/batch/files/copy',
expect.objectContaining({ method: 'POST' })
);
});
it('throws the server error/message on failure', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 400, json: async () => ({ error: 'bad' }) });
await expect(copyFiles(['a'], 't')).rejects.toThrow('bad');
fetchMock.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) });
await expect(copyFolders(['b'], 't')).rejects.toThrow(/failed: 500/);
});
});
@@ -1,4 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('$lib/api/csrf', () => ({ getCsrfToken: () => 'tok' }));
// vi.mock is hoisted; build the spies with vi.hoisted so the factories can use them.
const { blake3Mock, byHashMock, batchMock } = vi.hoisted(() => ({
@@ -13,7 +15,96 @@ vi.mock('$lib/api/endpoints/files', () => ({
dedupCheckBatch: batchMock
}));
import { DELTA_UPLOAD_MIN_SIZE, instantUploadOwned, resolveOwnedHashes } from './deltaUpload';
import {
DELTA_UPLOAD_MIN_SIZE,
instantUploadOwned,
resolveOwnedHashes,
tryDeltaUpload
} from './deltaUpload';
describe('tryDeltaUpload (delta worker)', () => {
type Handler = ((e: { data: unknown }) => void) | null;
let lastWorker: FakeWorker | null = null;
class FakeWorker {
onmessage: Handler = null;
onerror: (() => void) | null = null;
posted: unknown[] = [];
terminated = false;
constructor() {
// Capture the instance the code-under-test creates so the test can drive its events.
// eslint-disable-next-line @typescript-eslint/no-this-alias
lastWorker = this;
}
postMessage(msg: unknown) {
this.posted.push(msg);
}
terminate() {
this.terminated = true;
}
emit(data: unknown) {
this.onmessage?.({ data });
}
}
function bigFile(): File {
const f = new File(['x'], 'big.bin');
Object.defineProperty(f, 'size', { value: DELTA_UPLOAD_MIN_SIZE + 1 });
return f;
}
beforeEach(() => {
lastWorker = null;
vi.stubGlobal('Worker', FakeWorker as unknown as typeof Worker);
});
afterEach(() => vi.unstubAllGlobals());
it('skips delta for a small file', async () => {
const small = new File(['x'], 's.txt');
expect(await tryDeltaUpload(small, 'folder')).toBeNull();
});
it('skips delta when there is no folder', async () => {
expect(await tryDeltaUpload(bigFile(), null)).toBeNull();
});
it('resolves ok on a 201 done message and reports progress', async () => {
const onProgress = vi.fn();
const p = tryDeltaUpload(bigFile(), 'folder', onProgress);
await Promise.resolve();
expect(lastWorker).toBeTruthy();
lastWorker!.emit({ type: 'progress', reusedBytes: 50, uploadedBytes: 50, totalBytes: 100 });
expect(onProgress).toHaveBeenCalledWith(99);
lastWorker!.emit({ type: 'done', status: 201, body: { message: 'ok' } });
const res = await p;
expect(res?.ok).toBe(true);
expect(res?.savedBytes).toBe(50);
expect(lastWorker!.terminated).toBe(true);
});
it('falls back to null on a fallback message', async () => {
const p = tryDeltaUpload(bigFile(), 'folder');
await Promise.resolve();
lastWorker!.emit({ type: 'fallback', reason: 'no wasm' });
expect(await p).toBeNull();
});
it('reports a quota error on a 507 done message', async () => {
const p = tryDeltaUpload(bigFile(), 'folder');
await Promise.resolve();
lastWorker!.emit({ type: 'done', status: 507, body: { error: 'over quota' } });
const res = await p;
expect(res?.isQuotaError).toBe(true);
expect(res?.errorMsg).toBe('over quota');
});
it('resolves null on a worker error', async () => {
const p = tryDeltaUpload(bigFile(), 'folder');
await Promise.resolve();
lastWorker!.onerror?.();
expect(await p).toBeNull();
});
});
const fakeFile = (size: number, name = 'x.bin') => ({ size, name }) as unknown as File;
const hashOf = (name: string) => name.padEnd(64, '0');
@@ -0,0 +1,16 @@
import { it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch } from '$lib/api/client';
import { lookupDeviceCode, decideDevice } from './device';
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
f.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
});
it('looks up and decides device codes', async () => {
await lookupDeviceCode('ABCD').catch(() => {});
await decideDevice('ABCD', 'approve').catch(() => {});
await decideDevice('ABCD', 'deny').catch(() => {});
expect(f).toHaveBeenCalled();
});
@@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch, apiJson } from '$lib/api/client';
import { dateBucket, sizeBucket, typeLabel, addFavorite, removeFavorite } from './favorites';
const fetchMock = apiFetch as unknown as ReturnType<typeof vi.fn>;
const jsonMock = apiJson as unknown as ReturnType<typeof vi.fn>;
describe('dateBucket', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-06-15T12:00:00Z'));
});
afterEach(() => vi.useRealTimers());
it('buckets by recency and falls back to the year', () => {
expect(dateBucket(Date.now())).toBeTruthy();
expect(dateBucket(Date.now() - 3 * 86_400_000)).toBeTruthy();
expect(dateBucket(Date.now() - 20 * 86_400_000)).toBeTruthy();
expect(dateBucket('2019-06-15')).toBe('2019'); // mid-year is timezone-safe
});
it('returns null for null/invalid', () => {
expect(dateBucket(null)).toBeNull();
expect(dateBucket(undefined)).toBeNull();
expect(dateBucket('not a date')).toBeNull();
});
});
describe('sizeBucket', () => {
it('maps byte ranges to distinct labels', () => {
const labels = [
sizeBucket(null),
sizeBucket(0),
sizeBucket(500),
sizeBucket(50 * 1_048_576),
sizeBucket(500 * 1_048_576),
sizeBucket(2 * 1_073_741_824),
sizeBucket(10 * 1_073_741_824)
];
expect(new Set(labels).size).toBe(labels.length); // all distinct
labels.forEach((l) => expect(l).toBeTruthy());
});
});
describe('typeLabel', () => {
it('maps known categories and passes through unknown ones', () => {
expect(typeLabel('PDF')).toBe('PDF');
expect(typeLabel('Image')).toBeTruthy();
expect(typeLabel('Weird')).toBe('Weird');
});
});
describe('favorites mutations', () => {
beforeEach(() => {
vi.clearAllMocks();
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
jsonMock.mockResolvedValue({});
});
it('addFavorite / removeFavorite call the API', async () => {
await addFavorite('file', 'id1');
await removeFavorite('file', 'id1');
expect(fetchMock).toHaveBeenCalled();
});
});
@@ -0,0 +1,34 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch } from '$lib/api/client';
import {
uploadFile,
renameFile,
moveFile,
deleteFile,
fileDownloadUrl,
fileInlineUrl
} from './files';
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
describe('files endpoint URL builders', () => {
it('build download/inline URLs', () => {
expect(fileDownloadUrl('id1')).toContain('id1');
expect(fileDownloadUrl('id1')).toContain('/api/files/');
expect(fileInlineUrl('id1')).toContain('id1');
});
});
describe('files endpoint mutations', () => {
beforeEach(() => {
vi.clearAllMocks();
f.mockResolvedValue({ ok: true, status: 200, json: async () => ({ id: 'x' }) });
});
it('call the API for upload/rename/move/delete', async () => {
const file = new File([new Uint8Array([1])], 'f.txt', { type: 'text/plain' });
await uploadFile('fid', file).catch(() => {});
await renameFile('id', 'new').catch(() => {});
await moveFile('id', 'dest').catch(() => {});
await deleteFile('id').catch(() => {});
expect(f).toHaveBeenCalled();
});
});
@@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch, apiJson } from '$lib/api/client';
import {
displayRole,
expiryToIso,
createGrant,
updateGrantRole,
revokeGrant,
notifyGrantRecipient
} from './grants';
const fetchMock = apiFetch as unknown as ReturnType<typeof vi.fn>;
const jsonMock = apiJson as unknown as ReturnType<typeof vi.fn>;
describe('displayRole', () => {
it('passes through canonical roles', () => {
expect(displayRole('owner')).toBe('owner');
expect(displayRole('editor')).toBe('editor');
expect(displayRole('viewer')).toBe('viewer');
});
it('maps legacy roles and defaults to viewer', () => {
expect(displayRole('contributor')).toBe('editor');
expect(displayRole('commenter')).toBe('viewer');
expect(displayRole('mystery')).toBe('viewer');
expect(displayRole(undefined)).toBe('viewer');
});
});
describe('expiryToIso', () => {
it('converts a date to an ISO string at UTC midnight', () => {
expect(expiryToIso('2030-01-02')).toBe('2030-01-02T00:00:00.000Z');
});
it('returns null for empty input', () => {
expect(expiryToIso(null)).toBeNull();
expect(expiryToIso(undefined)).toBeNull();
expect(expiryToIso('')).toBeNull();
});
});
describe('grant mutations', () => {
beforeEach(() => {
vi.clearAllMocks();
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
jsonMock.mockResolvedValue({});
});
it('call the API for create/update/revoke/notify', async () => {
await createGrant(
{ type: 'user', id: 'u1' },
{ type: 'folder', id: 'rid' },
'viewer',
null
).catch(() => {});
await updateGrantRole(
{ type: 'user', id: 'u1' },
{ type: 'folder', id: 'rid' },
'editor',
null
).catch(() => {});
await revokeGrant('g1').catch(() => {});
await notifyGrantRecipient('g1').catch(() => {});
expect(fetchMock).toHaveBeenCalled();
});
});
@@ -0,0 +1,28 @@
import { it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch, apiJson } from '$lib/api/client';
import * as music from './music';
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
f.mockResolvedValue({ ok: true, status: 200, json: async () => ({}), text: async () => 'fid' });
j.mockResolvedValue([]);
});
it('exercises the music endpoints', async () => {
await music.listPlaylists().catch(() => {});
await music.listTracks('p').catch(() => {});
await music.createPlaylist('n').catch(() => {});
await music.updatePlaylist('p', { name: 'x' } as never).catch(() => {});
await music.renamePlaylist('p', 'n').catch(() => {});
await music.deletePlaylist('p').catch(() => {});
await music.addTracks('p', ['f']).catch(() => {});
await music.removeTrack('p', 'f').catch(() => {});
await music.reorderTracks('p', ['a', 'b']).catch(() => {});
await music.listShares('p').catch(() => {});
await music.removeShare('p', 'u').catch(() => {});
const file = new File([new Uint8Array([1])], 'c.png', { type: 'image/png' });
await music.uploadCoverImage(file).catch(() => {});
expect(f.mock.calls.length + j.mock.calls.length).toBeGreaterThan(3);
});
@@ -0,0 +1,20 @@
import { it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch, apiJson } from '$lib/api/client';
import * as people from './people';
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
f.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
j.mockResolvedValue([]);
});
it('exercises the people endpoints', async () => {
await people.fetchPeople().catch(() => {});
await people.peopleEnabled().catch(() => {});
await people.fetchPersonPhotos('p').catch(() => {});
await people.renamePerson('p', 'Alice').catch(() => {});
await people.renamePerson('p', null).catch(() => {});
expect(f.mock.calls.length + j.mock.calls.length).toBeGreaterThan(0);
});
@@ -0,0 +1,20 @@
import { it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch, apiJson } from '$lib/api/client';
import * as photos from './photos';
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
f.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
j.mockResolvedValue({ photos: [], clusters: [] });
});
it('exercises the photos endpoints', async () => {
await photos.fetchPhotosGeo('0,0,1,1', 5).catch(() => {});
await photos.fetchPhotos(60).catch(() => {});
await photos.fetchFileMetadata('fid').catch(() => {});
await photos.batchTrash(['a', 'b']).catch(() => {});
await photos.batchTrash([]).catch(() => {});
expect(f.mock.calls.length + j.mock.calls.length).toBeGreaterThan(0);
});
@@ -0,0 +1,28 @@
import { it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch, apiJson } from '$lib/api/client';
import * as profile from './profile';
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
f.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
j.mockResolvedValue([]);
});
it('isAutoAppPassword flags generated labels', () => {
const r1 = profile.isAutoAppPassword({ label: 'Device login (auto)' });
const r2 = profile.isAutoAppPassword({ label: 'my token' });
expect(typeof r1).toBe('boolean');
expect(typeof r2).toBe('boolean');
});
it('exercises the profile endpoints', async () => {
await profile.updateProfile({ given_name: 'A' } as never).catch(() => {});
await profile.changePassword('old', 'new').catch(() => {});
await profile.updateAvatar('data:image/png;base64,AAAA').catch(() => {});
await profile.updateAvatar(null).catch(() => {});
await profile.listAppPasswords().catch(() => {});
await profile.createAppPassword('label').catch(() => {});
await profile.revokeAppPassword('id').catch(() => {});
expect(f.mock.calls.length + j.mock.calls.length).toBeGreaterThan(2);
});
@@ -0,0 +1,19 @@
import { it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch } from '$lib/api/client';
import { fetchRecentPage, clearRecent } from './recent';
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
f.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ items: [], next_cursor: null })
});
});
it('fetches and clears recent', async () => {
await fetchRecentPage({}).catch(() => {});
await clearRecent().catch(() => {});
expect(f).toHaveBeenCalled();
});
@@ -0,0 +1,37 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch } from '$lib/api/client';
import {
isDirectoryAvailable,
resolveLabel,
resolveRecipient,
searchRecipients
} from './recipients';
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
describe('recipients pure helpers', () => {
it('isDirectoryAvailable defaults to true', () => {
expect(isDirectoryAvailable()).toBe(true);
});
it('resolveLabel falls back to the id when uncached', () => {
expect(resolveLabel('group', 'g1')).toBe('g1');
expect(resolveLabel('user', 'u1')).toBe('u1');
});
it('resolveRecipient builds a recipient object', () => {
expect(resolveRecipient('group', 'g1')).toMatchObject({ type: 'group', id: 'g1', label: 'g1' });
expect(resolveRecipient('user', 'u1')).toMatchObject({ type: 'user', id: 'u1' });
});
});
describe('searchRecipients', () => {
beforeEach(() => {
vi.clearAllMocks();
// system contacts + groups both return arrays from .json()
f.mockResolvedValue({ ok: true, status: 200, json: async () => [] });
});
it('returns an array of recipients', async () => {
const r = await searchRecipients('alice').catch(() => []);
expect(Array.isArray(r)).toBe(true);
const e = await searchRecipients('a@b.test').catch(() => []);
expect(Array.isArray(e)).toBe(true);
});
});
@@ -0,0 +1,25 @@
import { it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch, apiJson } from '$lib/api/client';
import { searchFiles, searchSuggest, clearSearchCache } from './search';
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
f.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
j.mockResolvedValue({ files: [], folders: [] });
});
it('builds search requests including filters', async () => {
await searchFiles('q', {
recursive: true,
fileTypes: ['mp3', 'wav'],
minSize: 1,
maxSize: 9,
sortBy: 'date'
}).catch(() => {});
expect(j).toHaveBeenCalledWith(expect.stringContaining('type=mp3%2Cwav'), expect.anything());
await searchSuggest('q').catch(() => {});
await clearSearchCache().catch(() => {});
expect(f.mock.calls.length + j.mock.calls.length).toBeGreaterThan(1);
});
+3 -1
View File
@@ -26,7 +26,9 @@ export function searchFiles(query: string, opts: SearchOptions = {}): Promise<Se
params.append('query', query);
if (opts.folderId) params.append('folder_id', opts.folderId);
if (opts.recursive !== undefined) params.append('recursive', String(opts.recursive));
for (const ft of opts.fileTypes ?? []) params.append('type', ft);
// The backend expects a single comma-separated `type` param (it splits on
// ','); appending one param per type yields a "duplicate field" 400.
if (opts.fileTypes?.length) params.append('type', opts.fileTypes.join(','));
if (opts.minSize != null) params.append('min_size', String(opts.minSize));
if (opts.maxSize != null) params.append('max_size', String(opts.maxSize));
if (opts.createdAfter != null) params.append('created_after', String(opts.createdAfter));
@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch, apiJson } from '$lib/api/client';
import {
shareDownloadUrl,
shareFileUrl,
shareZipUrl,
getShareMeta,
verifySharePassword,
getShareContents
} from './share';
const fetchMock = apiFetch as unknown as ReturnType<typeof vi.fn>;
const jsonMock = apiJson as unknown as ReturnType<typeof vi.fn>;
describe('share URL builders', () => {
it('build encoded share URLs', () => {
expect(shareDownloadUrl('tok en')).toBe('/api/s/tok%20en/download');
expect(shareFileUrl('t', 'f/1')).toBe('/api/s/t/file/f%2F1');
expect(shareZipUrl('t')).toBe('/api/s/t/zip');
expect(shareZipUrl('t', 'fid')).toBe('/api/s/t/zip/fid');
});
});
describe('share API calls', () => {
beforeEach(() => {
vi.clearAllMocks();
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
jsonMock.mockResolvedValue({});
});
it('hit the API for meta / verify / contents', async () => {
await getShareMeta('t').catch(() => {});
await verifySharePassword('t', 'pw').catch(() => {});
await getShareContents('t').catch(() => {});
expect(fetchMock.mock.calls.length + jsonMock.mock.calls.length).toBeGreaterThan(0);
});
});
describe('share status branches', () => {
const resp = (over: Record<string, unknown>) => ({
ok: false,
status: 200,
json: async () => ({}),
...over
});
beforeEach(() => vi.clearAllMocks());
it('returns ok meta on 200', async () => {
fetchMock.mockResolvedValue(
resp({ ok: true, json: async () => ({ item_type: 'folder', item_name: 'Docs' }) })
);
expect(await getShareMeta('t')).toEqual({
status: 'ok',
data: { item_type: 'folder', item_name: 'Docs' }
});
});
it('maps 401+requiresPassword to a password prompt', async () => {
fetchMock.mockResolvedValue(
resp({ status: 401, json: async () => ({ requiresPassword: true }) })
);
expect(await getShareMeta('t')).toEqual({ status: 'password' });
});
it('maps meta 410 to expired and 404 to invalid', async () => {
fetchMock.mockResolvedValueOnce(resp({ status: 410 }));
expect(await getShareMeta('t')).toEqual({ status: 'expired' });
fetchMock.mockResolvedValueOnce(resp({ status: 404 }));
expect(await getShareMeta('t')).toEqual({ status: 'invalid' });
});
it('verifies a password: true on ok, false on 401', async () => {
fetchMock.mockResolvedValueOnce(resp({ ok: true }));
expect(await verifySharePassword('t', 'pw')).toBe(true);
fetchMock.mockResolvedValueOnce(resp({ status: 401 }));
expect(await verifySharePassword('t', 'bad')).toBe(false);
});
it('lists contents and maps 401→password, 410→expired', async () => {
fetchMock.mockResolvedValueOnce(
resp({ ok: true, json: async () => ({ folders: [], files: [] }) })
);
expect((await getShareContents('t')).status).toBe('ok');
fetchMock.mockResolvedValueOnce(resp({ status: 401 }));
expect((await getShareContents('t')).status).toBe('password');
fetchMock.mockResolvedValueOnce(resp({ status: 410 }));
expect((await getShareContents('t', 'fid')).status).toBe('expired');
});
});
@@ -0,0 +1,20 @@
import { it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch, apiJson } from '$lib/api/client';
import * as shares from './shares';
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
f.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
j.mockResolvedValue({});
});
it('exercises the shares endpoints', async () => {
await shares.createShare({ item_id: 'i', item_type: 'folder' } as never).catch(() => {});
await shares.listSharesForItem('i', 'folder' as never).catch(() => {});
await shares.getShareById('s').catch(() => {});
await shares.updateShare('s', {} as never).catch(() => {});
await shares.deleteShare('s').catch(() => {});
expect(f.mock.calls.length + j.mock.calls.length).toBeGreaterThan(0);
});
@@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch } from '$lib/api/client';
import {
expiryChip,
remainingDaysBucket,
restoreTrashItem,
deleteTrashItem,
emptyTrash
} from './trash';
const fetchMock = apiFetch as unknown as ReturnType<typeof vi.fn>;
const DAY = 86_400_000;
describe('expiryChip', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-06-15T12:00:00Z'));
});
afterEach(() => vi.useRealTimers());
it('returns the matching tier for each time horizon', () => {
expect(expiryChip(null).tier).toBe('never');
expect(expiryChip(Date.now() - DAY).tier).toBe('expired');
expect(expiryChip(Date.now() + DAY / 2).tier).toBe('urgent'); // today
expect(expiryChip(Date.now() + 1.5 * DAY).tier).toBe('urgent'); // tomorrow
expect(expiryChip(Date.now() + 4 * DAY).tier).toBe('soon');
expect(expiryChip(Date.now() + 20 * DAY).tier).toBe('caution');
expect(expiryChip(Date.now() + 100 * DAY).tier).toBe('normal');
});
it('renders an unparseable value as a normal chip', () => {
expect(expiryChip('garbage').tier).toBe('normal');
});
});
describe('remainingDaysBucket', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-06-15T12:00:00Z'));
});
afterEach(() => vi.useRealTimers());
it('produces a label for no-expiry, expired, and future', () => {
expect(remainingDaysBucket(null)).toBeTruthy();
expect(remainingDaysBucket(Date.now() - DAY)).toBeTruthy();
expect(remainingDaysBucket(Date.now() + 10 * DAY)).toBeTruthy();
});
});
describe('trash mutations', () => {
beforeEach(() => {
vi.clearAllMocks();
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
});
it('call the API for restore/delete/empty', async () => {
await restoreTrashItem('t1').catch(() => {});
await deleteTrashItem('t1').catch(() => {});
await emptyTrash().catch(() => {});
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('emptyTrash throws on a failed response', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) });
await expect(emptyTrash()).rejects.toThrow();
});
});
@@ -0,0 +1,64 @@
import { it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
import { apiFetch, apiJson } from '$lib/api/client';
import {
getSupportedExtensions,
canEditWithWopi,
getEditorUrl,
getEditorUrlWithFallback
} from './wopi';
const af = apiFetch as unknown as ReturnType<typeof vi.fn>;
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
j.mockResolvedValue({ extensions: ['docx', 'xlsx'] });
});
it('reports WOPI edit support based on extension', async () => {
await getSupportedExtensions().catch(() => {});
const editable = await canEditWithWopi('report.docx').catch(() => false);
const notEditable = await canEditWithWopi('photo.png').catch(() => false);
expect(typeof editable).toBe('boolean');
expect(typeof notEditable).toBe('boolean');
});
it('fetches an editor URL for a file', async () => {
af.mockResolvedValue({
ok: true,
json: async () => ({
editor_url: 'https://wopi/edit',
access_token: 'tok',
access_token_ttl: 9
})
});
const data = await getEditorUrl('f1', 'edit');
expect(data.editor_url).toBe('https://wopi/edit');
expect(af).toHaveBeenCalledWith(
expect.stringContaining('file_id=f1'),
expect.objectContaining({ credentials: 'same-origin' })
);
});
it('throws when the editor URL request fails', async () => {
af.mockResolvedValue({ ok: false, status: 500, text: async () => 'boom' });
await expect(getEditorUrl('f1')).rejects.toThrow('500');
});
it('falls back to view mode when an edit request 422s on a PDF', async () => {
af.mockResolvedValueOnce({
ok: false,
status: 422,
text: async () => 'not editable'
}).mockResolvedValueOnce({
ok: true,
json: async () => ({ editor_url: 'https://wopi/view', access_token: 't', access_token_ttl: 1 })
});
const data = await getEditorUrlWithFallback('f1', 'doc.pdf', 'edit');
expect(data.editor_url).toBe('https://wopi/view');
expect(af).toHaveBeenCalledTimes(2);
});
it('does not fall back for non-PDF edit failures', async () => {
af.mockResolvedValue({ ok: false, status: 500, text: async () => 'server error' });
await expect(getEditorUrlWithFallback('f1', 'sheet.xlsx', 'edit')).rejects.toThrow('500');
expect(af).toHaveBeenCalledTimes(1);
});
+69 -12
View File
@@ -268,7 +268,7 @@
></div>
<div class="sidebar" class:open={sidebarOpen}>
<a href={resolve('/files')} class="logo-container">
<a href={resolve('/files')} class="logo-container" data-testid="appshell-logo-link">
<div class="logo">
<svg viewBox="95 67 320 320" aria-hidden="true">
<path
@@ -286,6 +286,7 @@
class:active={active(link.href)}
href={resolve(link.href)}
data-section={link.section}
data-testid={`appshell-nav-${link.href.replace(/^\//, '')}-link`}
onclick={() => (sidebarOpen = false)}
>
<Icon name={link.icon} />
@@ -324,6 +325,7 @@
class="sidebar-toggle"
aria-label={t('nav.toggle', 'Toggle navigation menu')}
aria-expanded={sidebarOpen}
data-testid="appshell-sidebar-toggle-btn"
onclick={() => (sidebarOpen = !sidebarOpen)}
>
<Icon name="bars" />
@@ -334,6 +336,7 @@
class="search-toggle-btn"
id="search-toggle-btn"
aria-label={t('actions.search_btn', 'Search')}
data-testid="appshell-search-toggle-btn"
onclick={openMobileSearch}
>
<Icon name="search" />
@@ -343,6 +346,7 @@
<button
class="search-back-btn"
aria-label={t('common.close', 'Close')}
data-testid="appshell-search-back-btn"
onclick={closeMobileSearch}
>
<Icon name="arrow-left" />
@@ -355,6 +359,7 @@
type="text"
bind:this={searchInputEl}
bind:value={searchQuery}
data-testid="appshell-search-input"
oninput={onSearchInput}
onfocus={() => (suggestOpen = suggestions.length > 0)}
onblur={() => setTimeout(() => (suggestOpen = false), 150)}
@@ -367,6 +372,7 @@
type="button"
title={t('common.clear', 'Clear')}
aria-label={t('common.clear', 'Clear')}
data-testid="appshell-search-clear-btn"
onclick={clearSearch}
>
<Icon name="times" />
@@ -377,6 +383,7 @@
type="submit"
title={t('actions.search_btn', 'Search')}
aria-label={t('actions.search_btn', 'Search')}
data-testid="appshell-search-submit-btn"
>
<Icon name="search" />
</button>
@@ -385,7 +392,12 @@
<ul class="suggest">
{#each suggestions as s (s.kind + s.item.id)}
<li>
<button class="suggest__item" type="button" onmousedown={() => pickSuggestion(s)}>
<button
class="suggest__item"
type="button"
data-testid={`appshell-search-suggestion-${s.kind}-${s.item.id}-item`}
onmousedown={() => pickSuggestion(s)}
>
<span class="suggest__icon">
{#if s.kind === 'folder'}
<Icon name="folder" />
@@ -398,7 +410,12 @@
</li>
{/each}
<li>
<button class="suggest__all" type="button" onmousedown={goToResults}>
<button
class="suggest__all"
type="button"
data-testid="appshell-search-see-all-btn"
onmousedown={goToResults}
>
{t('search.see_all', 'See all results')}
</button>
</li>
@@ -411,13 +428,14 @@
<div class="user-controls">
<!-- Notifications -->
<div class="notif-wrapper" class:open={notifOpen}>
<div class="notif-wrapper" class:open={notifOpen} data-testid="appshell-notif-menu">
<button
class="notif-bell-btn"
class:active={notifOpen}
class:ring={bellRinging}
aria-label={t('notifications.title', 'Notifications')}
aria-haspopup="true"
data-testid="appshell-notif-bell-btn"
onclick={(e) => {
e.stopPropagation();
notifOpen = !notifOpen;
@@ -436,6 +454,7 @@
class="notif-clear-btn"
title={t('notifications.clear', 'Clear all')}
aria-label={t('notifications.clear', 'Clear all')}
data-testid="appshell-notif-clear-btn"
onclick={(e) => {
e.stopPropagation();
ui.clearNotifications();
@@ -494,11 +513,12 @@
</div>
<!-- User menu -->
<div class="user-menu-wrapper" class:open={menuOpen}>
<div class="user-menu-wrapper" class:open={menuOpen} data-testid="appshell-user-menu">
<button
class="user-avatar-btn"
aria-label={t('user_menu.title', 'User menu')}
aria-haspopup="true"
data-testid="appshell-user-menu-btn"
onclick={(e) => {
e.stopPropagation();
menuOpen = !menuOpen;
@@ -555,15 +575,30 @@
<div class="user-menu-divider"></div>
{#if isAdmin}
<a class="user-menu-item" href={resolve('/admin')} onclick={() => (menuOpen = false)}>
<a
class="user-menu-item"
href={resolve('/admin')}
data-testid="appshell-user-menu-admin-item"
onclick={() => (menuOpen = false)}
>
<Icon name="cogs" /> <span>{t('user_menu.admin_panel', 'Admin panel')}</span>
</a>
<a class="user-menu-item" href={resolve('/groups')} onclick={() => (menuOpen = false)}>
<a
class="user-menu-item"
href={resolve('/groups')}
data-testid="appshell-user-menu-groups-item"
onclick={() => (menuOpen = false)}
>
<Icon name="user-group" />
<span>{t('user_menu.manage_groups', 'Manage groups')}</span>
</a>
{/if}
<a class="user-menu-item" href={resolve('/profile')} onclick={() => (menuOpen = false)}>
<a
class="user-menu-item"
href={resolve('/profile')}
data-testid="appshell-user-menu-profile-item"
onclick={() => (menuOpen = false)}
>
<Icon name="user-circle" /> <span>{t('user_menu.profile', 'My profile')}</span>
</a>
@@ -572,12 +607,17 @@
<div class="user-menu-item user-menu-item--lang">
<Icon name="globe" />
<span>{t('settings.language', 'Language')}</span>
<div class="lang-selector" class:lang-selector--open={langOpen}>
<div
class="lang-selector"
class:lang-selector--open={langOpen}
data-testid="appshell-lang-menu"
>
<button
type="button"
class="lang-selector__toggle"
aria-haspopup="listbox"
aria-expanded={langOpen}
data-testid="appshell-lang-toggle-btn"
onclick={(e) => {
e.stopPropagation();
langOpen = !langOpen;
@@ -597,6 +637,7 @@
class:lang-option--active={lang.code === i18n.locale}
role="option"
aria-selected={lang.code === i18n.locale}
data-testid={`appshell-lang-${lang.code}-option`}
onclick={(e) => {
e.stopPropagation();
chooseLocale(lang.code);
@@ -622,6 +663,7 @@
class="theme-segmented"
role="radiogroup"
aria-label={t('user_menu.appearance', 'Appearance')}
data-testid="appshell-theme-toggle"
>
{#each THEMES as th (th.mode)}
<button
@@ -632,6 +674,7 @@
aria-checked={theme.current === th.mode}
title={th.label}
aria-label={th.label}
data-testid={`appshell-theme-${th.mode}-option`}
onclick={(e) => {
e.stopPropagation();
theme.set(th.mode);
@@ -643,13 +686,21 @@
</div>
</div>
<button class="user-menu-item" onclick={openAbout}>
<button
class="user-menu-item"
data-testid="appshell-user-menu-about-item"
onclick={openAbout}
>
<Icon name="info-circle" /> <span>{t('user_menu.about', 'About OxiCloud')}</span>
</button>
<div class="user-menu-divider"></div>
<button class="user-menu-item user-menu-logout" onclick={onLogout}>
<button
class="user-menu-item user-menu-logout"
data-testid="appshell-user-menu-logout-btn"
onclick={onLogout}
>
<Icon name="sign-out-alt" /> <span>{t('actions.logout', 'Log out')}</span>
</button>
</div>
@@ -708,6 +759,7 @@
href="https://github.com/AtalayaLabs/OxiCloud/"
target="_blank"
rel="noopener"
data-testid="appshell-about-github-link"
>
<Icon name="github" /> GitHub
</a>
@@ -716,12 +768,17 @@
href="https://github.com/AtalayaLabs/OxiCloud/blob/main/LICENSE"
target="_blank"
rel="noopener"
data-testid="appshell-about-license-link"
>
<Icon name="file-alt" />
{t('user_menu.mit_license', 'MIT License')}
</a>
</div>
<button class="about-modal__close" onclick={() => (aboutOpen = false)}>
<button
class="about-modal__close"
data-testid="appshell-about-close-btn"
onclick={() => (aboutOpen = false)}
>
{t('actions.close', 'Close')}
</button>
</div>
@@ -0,0 +1,71 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { createRawSnippet } from 'svelte';
const { goto, pageState } = vi.hoisted(() => ({
goto: vi.fn(),
pageState: { url: new URL('http://localhost/files'), route: { id: '/files/[...path]' } }
}));
vi.mock('$app/navigation', () => ({ goto }));
vi.mock('$app/state', () => ({ page: pageState }));
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' }));
import { logout } from '$lib/api/endpoints/auth';
import { session } from '$lib/stores/session.svelte';
import AppShell from './AppShell.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
const children = createRawSnippet(() => ({
render: () => '<div data-testid="shell-child">hi</div>'
}));
beforeEach(() => {
vi.clearAllMocks();
pageState.url = new URL('http://localhost/files');
session.user = {
id: '1',
username: 'admin',
email: 'a@x.test',
given_name: 'A',
family_name: 'B',
role: 'admin',
storage_used_bytes: 10,
storage_quota_bytes: 100,
is_external: false
} as never;
});
it('renders the shell chrome and its children', async () => {
render(AppShell, { props: { children } });
expect(screen.getByTestId('shell-child')).toBeTruthy();
expect(screen.getByTestId('appshell-user-menu-btn')).toBeTruthy();
});
it('opens the user menu, exposing profile and admin links', async () => {
render(AppShell, { props: { children } });
await fireEvent.click(screen.getByTestId('appshell-user-menu-btn'));
const profile = await screen.findByTestId('appshell-user-menu-profile-item');
expect(profile.getAttribute('href')).toBe('/profile');
// Admin link only shows for admin users (session.user.role === 'admin').
expect(screen.getByTestId('appshell-user-menu-admin-item')).toBeTruthy();
});
it('logs out: clears the session and redirects to /login', async () => {
m(logout).mockResolvedValue(undefined);
render(AppShell, { props: { children } });
await fireEvent.click(screen.getByTestId('appshell-user-menu-btn'));
await fireEvent.click(await screen.findByTestId('appshell-user-menu-logout-btn'));
await waitFor(() => expect(logout).toHaveBeenCalled());
await waitFor(() => expect(goto).toHaveBeenCalledWith('/login'));
expect(session.user).toBeNull();
});
it('submits a search and routes to /search', async () => {
render(AppShell, { props: { children } });
const input = screen.getByTestId('appshell-search-input');
await fireEvent.input(input, { target: { value: 'report' } });
await fireEvent.click(screen.getByTestId('appshell-search-submit-btn'));
await waitFor(() => expect(goto).toHaveBeenCalledWith('/search?q=report'));
});
+4 -1
View File
@@ -17,6 +17,8 @@
onclick?: (e: MouseEvent) => void;
/** Extra classes appended after the base `.btn` classes. */
class?: string;
/** e2e test hook forwarded to the underlying `<button>`. */
'data-testid'?: string;
children?: Snippet;
}
@@ -29,6 +31,7 @@
title,
onclick,
class: cls = '',
'data-testid': testid,
children
}: Props = $props();
@@ -37,7 +40,7 @@
);
</script>
<button class={className} {type} {disabled} {title} {onclick}>
<button class={className} {type} {disabled} {title} data-testid={testid} {onclick}>
{#if icon}<Icon name={icon} />{/if}
{@render children?.()}
</button>
@@ -277,6 +277,7 @@
role="dialog"
aria-modal="true"
aria-label={t('cmdk.title', 'Command palette')}
data-testid="command-palette-panel"
>
<div class="cmdk__search">
<Icon name="search" />
@@ -288,6 +289,7 @@
placeholder={t('cmdk.placeholder', 'Type a command or search…')}
autocomplete="off"
autofocus
data-testid="command-palette-input"
/>
</div>
{#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}
>
@@ -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<typeof vi.fn>;
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');
});
+15 -2
View File
@@ -53,6 +53,7 @@
{#if c.opts.message}<p class="dlg-msg">{c.opts.message}</p>{/if}
<input
class="dlg-input"
data-testid="dialog-host-prompt-input"
type="text"
bind:this={inputEl}
bind:value
@@ -70,11 +71,22 @@
{/if}
{#snippet footer()}
<button class="btn btn-secondary" disabled={dialogs.busy} onclick={() => dialogs.cancel()}>
<button
class="btn btn-secondary"
data-testid="dialog-host-cancel-btn"
disabled={dialogs.busy}
onclick={() => dialogs.cancel()}
>
{c.opts.cancelText ?? t('common.cancel', 'Cancel')}
</button>
{#if c.kind === 'prompt'}
<button class="btn btn-primary" type="submit" form="dialog-form" disabled={dialogs.busy}>
<button
class="btn btn-primary"
data-testid="dialog-host-submit-btn"
type="submit"
form="dialog-form"
disabled={dialogs.busy}
>
{dialogs.busy
? t('common.loading', 'Loading…')
: (c.opts.confirmText ?? t('common.ok', 'OK'))}
@@ -82,6 +94,7 @@
{:else}
<button
class="btn {c.opts.danger ? 'btn-danger' : 'btn-primary'}"
data-testid="dialog-host-confirm-btn"
disabled={dialogs.busy}
onclick={() => dialogs.resolve(true)}
>
@@ -0,0 +1,41 @@
import { it, expect, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { dialogs, confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
import DialogHost from './DialogHost.svelte';
beforeEach(() => {
while (dialogs.current && !dialogs.busy) dialogs.cancel();
});
it('confirms a dialog and resolves true', async () => {
render(DialogHost);
const p = confirmDialog({ title: 'Delete?', message: 'Sure?' });
await screen.findByTestId('dialog-host-confirm-btn');
await fireEvent.click(screen.getByTestId('dialog-host-confirm-btn'));
await expect(p).resolves.toBe(true);
});
it('cancels a dialog and resolves false', async () => {
render(DialogHost);
const p = confirmDialog({ title: 'Delete?', message: 'Sure?' });
await screen.findByTestId('dialog-host-cancel-btn');
await fireEvent.click(screen.getByTestId('dialog-host-cancel-btn'));
await expect(p).resolves.toBe(false);
});
it('submits a prompt dialog with the typed value', async () => {
render(DialogHost);
const p = promptDialog({ title: 'Rename', defaultValue: 'old' });
const input = await screen.findByTestId('dialog-host-prompt-input');
await fireEvent.input(input, { target: { value: 'new-name' } });
await fireEvent.click(screen.getByTestId('dialog-host-submit-btn'));
await expect(p).resolves.toBe('new-name');
});
it('prefills the prompt input with the default value', async () => {
render(DialogHost);
void promptDialog({ title: 'Rename', defaultValue: 'preset.txt' });
const input = (await screen.findByTestId('dialog-host-prompt-input')) as HTMLInputElement;
await waitFor(() => expect(input.value).toBe('preset.txt'));
dialogs.cancel();
});
+17 -2
View File
@@ -153,6 +153,7 @@
<div
class="fv"
role="dialog"
data-testid="file-viewer-dialog"
aria-modal="true"
aria-label={file.name}
tabindex="-1"
@@ -166,6 +167,7 @@
<div class="fv__zoom" role="group" aria-label={t('viewer.zoom', 'Zoom')}>
<button
class="fv__zoom-btn"
data-testid="file-viewer-zoom-out-btn"
title={t('viewer.zoom_out', 'Zoom out')}
aria-label={t('viewer.zoom_out', 'Zoom out')}
onclick={() => zoomBy(0.8)}
@@ -174,6 +176,7 @@
</button>
<button
class="fv__zoom-btn"
data-testid="file-viewer-zoom-reset-btn"
title={t('viewer.zoom_reset', 'Reset zoom')}
aria-label={t('viewer.zoom_reset', 'Reset zoom')}
onclick={resetZoom}
@@ -182,6 +185,7 @@
</button>
<button
class="fv__zoom-btn"
data-testid="file-viewer-zoom-in-btn"
title={t('viewer.zoom_in', 'Zoom in')}
aria-label={t('viewer.zoom_in', 'Zoom in')}
onclick={() => zoomBy(1.2)}
@@ -191,13 +195,18 @@
</div>
{/if}
{#if canEdit}
<button class="btn btn-primary btn-sm" onclick={() => (wopiOpen = true)}>
<button
class="btn btn-primary btn-sm"
data-testid="file-viewer-edit-btn"
onclick={() => (wopiOpen = true)}
>
<Icon name="pen" />
{t('files.edit', 'Edit')}
</button>
{/if}
<a
class="btn btn-secondary btn-sm"
data-testid="file-viewer-download-link"
href={fileDownloadUrl(file.id)}
download
rel="external"
@@ -207,13 +216,19 @@
</a>
<a
class="btn btn-secondary btn-sm"
data-testid="file-viewer-open-new-tab-link"
href={fileInlineUrl(file.id)}
target="_blank"
rel="external noreferrer"
>
<Icon name="external-link-alt" />
</a>
<button class="fv__close" aria-label={t('common.close', 'Close')} onclick={close}>
<button
class="fv__close"
data-testid="file-viewer-close-btn"
aria-label={t('common.close', 'Close')}
onclick={close}
>
<Icon name="times" />
</button>
</div>
@@ -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<typeof vi.fn>;
const cw = canEditWithWopi as unknown as ReturnType<typeof vi.fn>;
function file(over: Record<string, unknown> = {}) {
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());
});
@@ -67,12 +67,13 @@
{#if groups?.length || showViewToggle}
<div class="view-toggle" role="group" aria-label={t('view.label', 'View options')}>
{#if groups?.length}
<div class="group-by-selector">
<div class="group-by-selector" data-testid="list-toolbar-groupby-menu">
<button
class="toggle-btn group-by-btn active"
title={t('groupby.title', 'Group by')}
aria-haspopup="true"
aria-expanded={menuOpen}
data-testid="list-toolbar-groupby-btn"
onclick={() => (menuOpen = !menuOpen)}
>
<Icon name={active?.icon ?? 'layer-group'} />
@@ -83,6 +84,7 @@
class:active={reversed}
title={t('sortdir.title', 'Sort direction')}
aria-label={t('sort.direction', 'Sort direction')}
data-testid="list-toolbar-sort-direction-btn"
onclick={() => ondirection?.()}
>
<Icon name="arrow-up" />
@@ -93,6 +95,7 @@
<button
class="group-by-option"
class:active={groupBy === g.key}
data-testid={`list-toolbar-groupby-${g.key}-item`}
onclick={() => pick(g.key)}
>
<Icon name={g.icon ?? 'layer-group'} />
@@ -110,6 +113,7 @@
class:active={filesStore.viewMode === 'grid'}
title={t('view.grid', 'Grid view')}
aria-pressed={filesStore.viewMode === 'grid'}
data-testid="list-toolbar-view-grid-btn"
onclick={() => filesStore.setViewMode('grid')}><Icon name="th" /></button
>
<button
@@ -117,6 +121,7 @@
class:active={filesStore.viewMode === 'list'}
title={t('view.list', 'List view')}
aria-pressed={filesStore.viewMode === 'list'}
data-testid="list-toolbar-view-list-btn"
onclick={() => filesStore.setViewMode('list')}><Icon name="list" /></button
>
{/if}
+6 -2
View File
@@ -86,13 +86,17 @@
aria-modal="true"
aria-label={title}
tabindex="-1"
data-testid="modal"
bind:this={dialogEl}
>
{#if title}
<header class="modal__header">
<h2 class="modal__title">{title}</h2>
<button class="modal__close" aria-label={t('common.close', 'Close')} onclick={close}
>×</button
<button
class="modal__close"
aria-label={t('common.close', 'Close')}
data-testid="modal-close-btn"
onclick={close}>×</button
>
</header>
{/if}
+34
View File
@@ -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: () => '<div data-testid="modal-body">content</div>'
}));
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();
});
+62 -40
View File
@@ -123,51 +123,73 @@
</script>
<Modal bind:open title={moveTitle}>
<div class="mv-nav">
<button
class="mv-nav-btn"
title={t('breadcrumb.home', 'Home')}
aria-label={t('breadcrumb.home', 'Home')}
disabled={atHome}
onclick={goHome}><Icon name="home" /></button
>
<button
class="mv-nav-btn"
title={t('dialogs.go_to_parent', 'Go to parent')}
aria-label={t('dialogs.go_to_parent', 'Go to parent')}
disabled={atHome}
onclick={goParent}><Icon name="level-up-alt" /></button
>
<nav class="mv-crumbs" aria-label="Breadcrumb">
{#each crumbs as c, i (c.id)}
{#if i > 0}<span class="mv-sep">/</span>{/if}
<button class="mv-crumb" onclick={() => gotoCrumb(i)}>{c.name}</button>
{/each}
</nav>
<div data-testid="move-dialog">
<div class="mv-nav">
<button
class="mv-nav-btn"
data-testid="move-dialog-home-btn"
title={t('breadcrumb.home', 'Home')}
aria-label={t('breadcrumb.home', 'Home')}
disabled={atHome}
onclick={goHome}><Icon name="home" /></button
>
<button
class="mv-nav-btn"
data-testid="move-dialog-parent-btn"
title={t('dialogs.go_to_parent', 'Go to parent')}
aria-label={t('dialogs.go_to_parent', 'Go to parent')}
disabled={atHome}
onclick={goParent}><Icon name="level-up-alt" /></button
>
<nav class="mv-crumbs" aria-label="Breadcrumb">
{#each crumbs as c, i (c.id)}
{#if i > 0}<span class="mv-sep">/</span>{/if}
<button
class="mv-crumb"
data-testid={`move-dialog-crumb-${c.id}`}
onclick={() => gotoCrumb(i)}>{c.name}</button
>
{/each}
</nav>
</div>
{#if loading}
<p class="mv-status">{t('common.loading', 'Loading…')}</p>
{:else if folders.length === 0}
<p class="mv-status">{t('files.no_subfolders', 'No subfolders here.')}</p>
{:else}
<ul class="mv-list">
{#each folders as f (f.id)}
<li>
<button
class="mv-folder"
data-testid={`move-dialog-folder-${f.id}`}
disabled={targetIds.has(f.id)}
onclick={() => enter(f)}
>
<Icon name="folder" /> <span>{f.name}</span>
<Icon name="chevron-right" class="mv-enter" />
</button>
</li>
{/each}
</ul>
{/if}
</div>
{#if loading}
<p class="mv-status">{t('common.loading', 'Loading…')}</p>
{:else if folders.length === 0}
<p class="mv-status">{t('files.no_subfolders', 'No subfolders here.')}</p>
{:else}
<ul class="mv-list">
{#each folders as f (f.id)}
<li>
<button class="mv-folder" disabled={targetIds.has(f.id)} onclick={() => enter(f)}>
<Icon name="folder" /> <span>{f.name}</span>
<Icon name="chevron-right" class="mv-enter" />
</button>
</li>
{/each}
</ul>
{/if}
{#snippet footer()}
<button class="btn btn-secondary" onclick={() => (open = false)}>
<button
class="btn btn-secondary"
data-testid="move-dialog-cancel-btn"
onclick={() => (open = false)}
>
{t('common.cancel', 'Cancel')}
</button>
<button class="btn btn-primary" disabled={working || !currentId} onclick={confirmMove}>
<button
class="btn btn-primary"
data-testid="move-dialog-confirm-btn"
disabled={working || !currentId}
onclick={confirmMove}
>
{mode === 'copy' ? t('files.copy_here', 'Copy here') : t('files.move_here', 'Move here')}
</button>
{/snippet}
@@ -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<typeof vi.fn>;
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'));
});
@@ -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<typeof vi.fn>;
const fpp = fetchPersonPhotos as unknown as ReturnType<typeof vi.fn>;
const rn = renamePerson as unknown as ReturnType<typeof vi.fn>;
const pd = promptDialog as unknown as ReturnType<typeof vi.fn>;
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();
});
@@ -180,6 +180,7 @@
aria-modal="true"
aria-label={item.name}
tabindex="-1"
data-testid="photo-lightbox"
onclick={(e) => e.target === e.currentTarget && close()}
>
<div class="lb__info">
@@ -187,12 +188,18 @@
<div class="lb__meta">{meta}</div>
</div>
<button class="lb__close" aria-label={t('common.close', 'Close')} onclick={close}>×</button>
<button
class="lb__close"
aria-label={t('common.close', 'Close')}
data-testid="photo-lightbox-close-btn"
onclick={close}>×</button
>
<button
class="lb__nav lb__nav--prev"
aria-label={t('common.previous', 'Previous')}
disabled={index === 0}
data-testid="photo-lightbox-prev-btn"
onclick={(e) => {
e.stopPropagation();
prev();
@@ -221,6 +228,7 @@
class="lb__nav lb__nav--next"
aria-label={t('common.next', 'Next')}
disabled={index === items.length - 1}
data-testid="photo-lightbox-next-btn"
onclick={(e) => {
e.stopPropagation();
next();
@@ -0,0 +1,50 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
vi.mock('$lib/api/endpoints/files', () => ({
deleteFile: vi.fn(),
fileDownloadUrl: () => '/d',
fileInlineUrl: () => '/i',
fileThumbnailUrl: () => '/t'
}));
vi.mock('$lib/api/endpoints/favorites', () => ({ addFavorite: vi.fn() }));
vi.mock('$lib/api/endpoints/photos', () => ({ fetchFileMetadata: vi.fn() }));
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog: vi.fn() }));
vi.mock('$lib/utils/errors', () => ({ errorToast: vi.fn() }));
import { fetchFileMetadata } from '$lib/api/endpoints/photos';
import PhotoLightbox from './PhotoLightbox.svelte';
const fm = fetchFileMetadata as unknown as ReturnType<typeof vi.fn>;
function item(id: string) {
return {
id,
name: `${id}.jpg`,
mime_type: 'image/jpeg',
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: ''
} as never;
}
beforeEach(() => {
vi.clearAllMocks();
fm.mockResolvedValue(null);
});
it('opens at an index, navigates next/prev, and closes', async () => {
render(PhotoLightbox, { props: { items: [item('a'), item('b')], index: 0 } });
expect(await screen.findByTestId('photo-lightbox')).toBeTruthy();
await fireEvent.click(screen.getByTestId('photo-lightbox-next-btn'));
await fireEvent.click(screen.getByTestId('photo-lightbox-prev-btn'));
await fireEvent.click(screen.getByTestId('photo-lightbox-close-btn'));
});
it('renders nothing at index -1', () => {
render(PhotoLightbox, { props: { items: [item('a')], index: -1 } });
expect(screen.queryByTestId('photo-lightbox')).toBeNull();
});
@@ -309,6 +309,8 @@
class:file-item--selected={selectable && selected.has(entry.id)}
role={onopen ? 'button' : undefined}
tabindex={onopen ? 0 : undefined}
aria-label={onopen ? entry.name : undefined}
data-testid={entry.name}
title={showOwner ? ownerTitle(entry) : undefined}
onclick={onopen ? () => onopen(entry) : undefined}
onkeydown={onopen ? (e) => e.key === 'Enter' && onopen(entry) : undefined}
@@ -319,6 +321,7 @@
<input
type="checkbox"
aria-label={t('common.select', 'Select')}
data-testid={`resource-list-select-${entry.id}-checkbox`}
checked={selected.has(entry.id)}
onchange={() => toggleSelected(entry.id)}
/>
@@ -360,6 +363,7 @@
<button
class="rl-star"
class:rl-star--on={entry.isFavorite}
data-testid={`resource-list-favorite-${entry.id}-btn`}
title={entry.isFavorite
? t('files.unfavorite', 'Remove favorite')
: t('files.favorite', 'Add favorite')}
@@ -393,8 +397,18 @@
</div>
{#if selectable && selected.size > 0 && batchToolbar}
<div class="rl-batch" role="region" aria-label={t('files.selection', 'Selection')}>
<button class="rl-batch__close" title={t('common.clear', 'Clear')} onclick={clearSelection}>
<div
class="rl-batch"
role="region"
aria-label={t('files.selection', 'Selection')}
data-testid="resource-list-batch-toolbar"
>
<button
class="rl-batch__close"
title={t('common.clear', 'Clear')}
data-testid="resource-list-batch-close-btn"
onclick={clearSelection}
>
<Icon name="times" />
</button>
<span class="rl-batch__count"
@@ -454,7 +468,12 @@
{/if}
{#if hasMore}
<button class="btn btn-secondary rl-more" onclick={onloadmore} disabled={loading}>
<button
class="btn btn-secondary rl-more"
data-testid="resource-list-load-more-btn"
onclick={onloadmore}
disabled={loading}
>
{loading ? t('common.loading', 'Loading…') : t('common.load_more', 'Load more')}
</button>
{/if}
@@ -470,6 +489,7 @@
<input
type="checkbox"
aria-label={t('common.select_all', 'Select all')}
data-testid="resource-list-select-all-checkbox"
checked={allSelected}
onchange={toggleSelectAll}
/>
@@ -492,12 +512,19 @@
onclick={closeContext}
oncontextmenu={(e) => e.preventDefault()}
></div>
<div class="rl-ctx-menu" style:left="{ctxX}px" style:top="{ctxY}px" role="menu">
<div
class="rl-ctx-menu"
style:left="{ctxX}px"
style:top="{ctxY}px"
role="menu"
data-testid="resource-list-context-menu"
>
{#each contextActions as action (action.key)}
<button
class="rl-ctx-item"
class:rl-ctx-item--danger={action.danger}
role="menuitem"
data-testid={`resource-list-context-${action.key}-item`}
onclick={() => {
const e = ctxEntry!;
closeContext();
+233 -178
View File
@@ -377,194 +377,249 @@
{/snippet}
<Modal bind:open title={t('share.dialog_title', { name: item?.name ?? '' }, 'Share “{{name}}”')}>
<div class="tabs" role="tablist">
<button role="tab" aria-selected={tab === 'people'} onclick={() => (tab = 'people')}>
{t('share.people', 'People')}
</button>
<button role="tab" aria-selected={tab === 'link'} onclick={() => (tab = 'link')}>
{t('share.public_link', 'Public link')}
</button>
</div>
<div data-testid="share-dialog">
<div class="tabs" role="tablist">
<button
role="tab"
data-testid="share-dialog-people-tab"
aria-selected={tab === 'people'}
onclick={() => (tab = 'people')}
>
{t('share.people', 'People')}
</button>
<button
role="tab"
data-testid="share-dialog-link-tab"
aria-selected={tab === 'link'}
onclick={() => (tab = 'link')}
>
{t('share.public_link', 'Public link')}
</button>
</div>
{#if tab === 'people'}
{#if !directoryAvailable && !grantsLoading}
<p class="status status--note">
{t('share.directoryUnavailable', 'User directory unavailable')}
</p>
{:else}
<div class="add-row">
<div class="search">
<input
placeholder={t('share.add_people', 'Add people, groups, or email…')}
bind:value={query}
oninput={onQueryInput}
autocomplete="off"
/>
{#if results.length > 0}
<ul class="results">
{#each results as r (r.type + r.id)}
<li>
<button class="result" onclick={() => addRecipient(r)}>
<Icon
name={r.type === 'group'
? 'user-group'
: r.type === 'email'
? 'envelope'
: 'user'}
{#if tab === 'people'}
{#if !directoryAvailable && !grantsLoading}
<p class="status status--note">
{t('share.directoryUnavailable', 'User directory unavailable')}
</p>
{:else}
<div class="add-row">
<div class="search">
<input
data-testid="share-dialog-search-input"
placeholder={t('share.add_people', 'Add people, groups, or email…')}
bind:value={query}
oninput={onQueryInput}
autocomplete="off"
/>
{#if results.length > 0}
<ul class="results">
{#each results as r (r.type + r.id)}
<li>
<button
class="result"
data-testid={`share-dialog-result-${r.type}-${r.id}`}
onclick={() => addRecipient(r)}
>
<Icon
name={r.type === 'group'
? 'user-group'
: r.type === 'email'
? 'envelope'
: 'user'}
/>
<span class="result__label">{r.label}</span>
{#if r.type === 'email'}
<span class="result__sub"
>{t('share.inviteByEmail', 'Invite by email')}</span
>
{:else if r.sublabel}
<span class="result__sub">{r.sublabel}</span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
<select
class="role-select"
data-testid="share-dialog-new-role-select"
bind:value={newRole}
aria-label={t('share.role_label', 'Role')}
>
{#each ROLES as r (r.v)}<option value={r.v}>{r.l}</option>{/each}
</select>
{@render expiryChip(newExpiry, (v) => (newExpiry = v))}
</div>
{/if}
{#if grantsLoading}
<div class="skeleton" aria-hidden="true">
<div class="skeleton__line skeleton__line--short"></div>
<div class="skeleton__line skeleton__line--medium"></div>
<div class="skeleton__line"></div>
</div>
{:else if members.length === 0}
<p class="status">{t('share.no_people', 'Not shared with anyone yet.')}</p>
{:else}
{#each memberGroups as group (group.role)}
<div class="member-group">
<div class="member-group__header">
<Icon name={roleIcon(group.role)} />
<span>{roleLabel(group.role)}</span>
<span class="member-group__badge">{group.members.length}</span>
</div>
<ul class="members">
{#each group.members as m (m.subject.type + m.subject.id)}
<li
class="member"
class:member--expired={m.expiry && new Date(m.expiry) < new Date()}
>
{#if m.subject.type === 'user'}
<UserVignette
userId={m.subject.id}
fallbackLabel={m.recipient.label}
fallbackSublabel={m.recipient.sublabel}
/>
<span class="result__label">{r.label}</span>
{#if r.type === 'email'}
<span class="result__sub">{t('share.inviteByEmail', 'Invite by email')}</span>
{:else if r.sublabel}
<span class="result__sub">{r.sublabel}</span>
{/if}
</button>
{:else}
<Icon name="user-group" />
<span class="member__label">
{m.recipient.label}
{#if m.recipient.sublabel}<span class="member__sub"
>{m.recipient.sublabel}</span
>{/if}
</span>
{/if}
{@render expiryChip(m.expiry, (v) => changeMemberExpiry(m, v))}
<select
class="role-select"
data-testid={`share-dialog-member-role-${m.subject.type}-${m.subject.id}`}
value={m.role}
onchange={(e) => changeRole(m, e.currentTarget.value as ShareRole)}
>
{#each ROLES as r (r.v)}<option value={r.v}>{r.l}</option>{/each}
</select>
<button
class="btn-action"
data-testid={`share-dialog-member-notify-${m.subject.type}-${m.subject.id}`}
title={t('share.notifyByEmail', 'Notify by email')}
onclick={() => notifyMember(m)}><Icon name="paper-plane" /></button
>
<button
class="btn-action btn-action--delete"
data-testid={`share-dialog-member-remove-${m.subject.type}-${m.subject.id}`}
title={t('share.revoke', 'Remove')}
onclick={() => removeMember(m)}><Icon name="user-xmark" /></button
>
</li>
{/each}
</ul>
{/if}
</div>
<select class="role-select" bind:value={newRole} aria-label={t('share.role_label', 'Role')}>
{#each ROLES as r (r.v)}<option value={r.v}>{r.l}</option>{/each}
</select>
{@render expiryChip(newExpiry, (v) => (newExpiry = v))}
</div>
{/if}
{#if grantsLoading}
<div class="skeleton" aria-hidden="true">
<div class="skeleton__line skeleton__line--short"></div>
<div class="skeleton__line skeleton__line--medium"></div>
<div class="skeleton__line"></div>
</div>
{:else if members.length === 0}
<p class="status">{t('share.no_people', 'Not shared with anyone yet.')}</p>
{:else}
{#each memberGroups as group (group.role)}
<div class="member-group">
<div class="member-group__header">
<Icon name={roleIcon(group.role)} />
<span>{roleLabel(group.role)}</span>
<span class="member-group__badge">{group.members.length}</span>
</div>
<ul class="members">
{#each group.members as m (m.subject.type + m.subject.id)}
<li
class="member"
class:member--expired={m.expiry && new Date(m.expiry) < new Date()}
>
{#if m.subject.type === 'user'}
<UserVignette
userId={m.subject.id}
fallbackLabel={m.recipient.label}
fallbackSublabel={m.recipient.sublabel}
/>
{:else}
<Icon name="user-group" />
<span class="member__label">
{m.recipient.label}
{#if m.recipient.sublabel}<span class="member__sub">{m.recipient.sublabel}</span
>{/if}
</span>
{/if}
{@render expiryChip(m.expiry, (v) => changeMemberExpiry(m, v))}
<select
class="role-select"
value={m.role}
onchange={(e) => changeRole(m, e.currentTarget.value as ShareRole)}
>
{#each ROLES as r (r.v)}<option value={r.v}>{r.l}</option>{/each}
</select>
<button
class="btn-action"
title={t('share.notifyByEmail', 'Notify by email')}
onclick={() => notifyMember(m)}><Icon name="paper-plane" /></button
>
<button
class="btn-action btn-action--delete"
title={t('share.revoke', 'Remove')}
onclick={() => removeMember(m)}><Icon name="user-xmark" /></button
>
</li>
{/each}
</ul>
</div>
{/each}
{/if}
{:else}
<section class="sh-create">
<div class="sh-fields">
<label>
<span>{t('share.link_name', 'Link name (optional)')}</span>
<input type="text" bind:value={newLinkName} autocomplete="off" />
</label>
<label>
<span>{t('share.password_optional', 'Password (optional)')}</span>
<input type="text" bind:value={password} autocomplete="off" />
</label>
<label>
<span>{t('share.expires_optional', 'Expires (optional)')}</span>
<input
type="date"
value={expiresAt ?? ''}
onchange={(e) => (expiresAt = e.currentTarget.value || null)}
/>
</label>
</div>
<button class="btn btn-primary" disabled={creating} onclick={createLink}>
{t('share.create_link', 'Create link')}
</button>
</section>
{#if linkLoading}
<div class="skeleton" aria-hidden="true">
<div class="skeleton__line skeleton__line--medium"></div>
<div class="skeleton__line"></div>
</div>
{:else if shares.length === 0}
<p class="status">{t('share.none', 'No public links yet.')}</p>
{:else}
<ul class="links">
{#each shares as s (s.id)}
<li class="link-row">
<span class="link-row__title">
<Icon name={s.has_password ? 'lock' : 'link'} />
<span class="link-row__name"
>{s.item_name || t('share.sharedLink', 'Shared link')}</span
>
</span>
{@render expiryChip(shareExpiryIso(s), (v) => editLinkExpiry(s, v))}
<button
class="btn-action"
class:btn-action--on={s.has_password}
title={s.has_password
? t('share.changePassword', 'Change password')
: t('share.addPassword', 'Add password')}
onclick={() => {
const pw = window.prompt(
s.has_password
? t('share.passwordPrompt_clear', 'New password (blank to remove):')
: t('share.passwordPrompt', 'Set a password:')
);
if (pw !== null) editLinkPassword(s, pw || null);
}}><Icon name={s.has_password ? 'lock' : 'lock-open'} /></button
>
<button class="btn-action" title={t('share.copy', 'Copy')} onclick={() => copy(s.url)}>
<Icon name="copy" />
</button>
<button
class="btn-action btn-action--delete"
title={t('common.delete', 'Delete')}
onclick={() => removeLink(s)}><Icon name="trash" /></button
>
</li>
{/each}
</ul>
{/if}
{:else}
<section class="sh-create">
<div class="sh-fields">
<label>
<span>{t('share.link_name', 'Link name (optional)')}</span>
<input
type="text"
data-testid="share-dialog-link-name-input"
bind:value={newLinkName}
autocomplete="off"
/>
</label>
<label>
<span>{t('share.password_optional', 'Password (optional)')}</span>
<input
type="text"
data-testid="share-dialog-link-password-input"
bind:value={password}
autocomplete="off"
/>
</label>
<label>
<span>{t('share.expires_optional', 'Expires (optional)')}</span>
<input
type="date"
data-testid="share-dialog-link-expires-input"
value={expiresAt ?? ''}
onchange={(e) => (expiresAt = e.currentTarget.value || null)}
/>
</label>
</div>
<button
class="btn btn-primary"
data-testid="share-dialog-create-btn"
disabled={creating}
onclick={createLink}
>
{t('share.create_link', 'Create link')}
</button>
</section>
{#if linkLoading}
<div class="skeleton" aria-hidden="true">
<div class="skeleton__line skeleton__line--medium"></div>
<div class="skeleton__line"></div>
</div>
{:else if shares.length === 0}
<p class="status">{t('share.none', 'No public links yet.')}</p>
{:else}
<ul class="links">
{#each shares as s (s.id)}
<li class="link-row">
<span class="link-row__title">
<Icon name={s.has_password ? 'lock' : 'link'} />
<span class="link-row__name"
>{s.item_name || t('share.sharedLink', 'Shared link')}</span
>
</span>
{@render expiryChip(shareExpiryIso(s), (v) => editLinkExpiry(s, v))}
<button
class="btn-action"
class:btn-action--on={s.has_password}
data-testid={`share-dialog-link-password-btn-${s.id}`}
title={s.has_password
? t('share.changePassword', 'Change password')
: t('share.addPassword', 'Add password')}
onclick={() => {
const pw = window.prompt(
s.has_password
? t('share.passwordPrompt_clear', 'New password (blank to remove):')
: t('share.passwordPrompt', 'Set a password:')
);
if (pw !== null) editLinkPassword(s, pw || null);
}}><Icon name={s.has_password ? 'lock' : 'lock-open'} /></button
>
<button
class="btn-action"
data-testid={`share-dialog-link-copy-btn-${s.id}`}
title={t('share.copy', 'Copy')}
onclick={() => copy(s.url)}
>
<Icon name="copy" />
</button>
<button
class="btn-action btn-action--delete"
data-testid={`share-dialog-link-delete-btn-${s.id}`}
title={t('common.delete', 'Delete')}
onclick={() => removeLink(s)}><Icon name="trash" /></button
>
</li>
{/each}
</ul>
{/if}
{/if}
{/if}
</div>
{#snippet footer()}
<button class="btn btn-secondary" onclick={() => (open = false)}>
<button
class="btn btn-secondary"
data-testid="share-dialog-close-btn"
onclick={() => (open = false)}
>
{t('common.close', 'Close')}
</button>
{/snippet}
@@ -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<typeof vi.fn>;
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();
});
+2 -1
View File
@@ -10,10 +10,11 @@
aria-label={t('notifications.title', 'Notifications')}
>
{#each ui.toasts as toast (toast.id)}
<div class="toast toast--{toast.kind}" role="status">
<div class="toast toast--{toast.kind}" role="status" data-testid={`toaster-toast-${toast.id}`}>
<span class="toast__msg">{toast.message}</span>
<button
class="toast__close"
data-testid={`toaster-dismiss-btn-${toast.id}`}
aria-label={t('common.dismiss', 'Dismiss')}
onclick={() => ui.dismiss(toast.id)}
>
+25 -4
View File
@@ -78,10 +78,21 @@
<svelte:window onkeydown={onKeydown} onmessage={onMessage} />
{#if open}
<div class="wopi" role="dialog" aria-modal="true" aria-label={fileName}>
<div
class="wopi"
role="dialog"
data-testid="wopi-editor-dialog"
aria-modal="true"
aria-label={fileName}
>
<header class="wopi__bar">
<span class="wopi__title">{fileName}</span>
<button class="wopi__close" aria-label={t('common.close', 'Close')} onclick={close}>
<button
class="wopi__close"
data-testid="wopi-editor-close-btn"
aria-label={t('common.close', 'Close')}
onclick={close}
>
<Icon name="times" />
</button>
</header>
@@ -97,8 +108,18 @@
target="wopi_frame"
class="wopi__form"
>
<input type="hidden" name="access_token" value={token} />
<input type="hidden" name="access_token_ttl" value={tokenTtl} />
<input
type="hidden"
name="access_token"
value={token}
data-testid="wopi-editor-access-token-input"
/>
<input
type="hidden"
name="access_token_ttl"
value={tokenTtl}
data-testid="wopi-editor-access-token-ttl-input"
/>
</form>
{/if}
<iframe
@@ -0,0 +1,56 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/svelte';
vi.mock('$lib/api/endpoints/wopi', () => ({ getEditorUrlWithFallback: vi.fn() }));
vi.mock('$lib/utils/errors', () => ({ errorToast: vi.fn() }));
import { getEditorUrlWithFallback } from '$lib/api/endpoints/wopi';
import { errorToast } from '$lib/utils/errors';
import WopiEditor from './WopiEditor.svelte';
const g = getEditorUrlWithFallback as unknown as ReturnType<typeof vi.fn>;
const et = errorToast as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => vi.clearAllMocks());
it('opens, loads the editor URL, and renders the dialog', async () => {
g.mockResolvedValue({
editor_url: 'https://wopi/edit',
access_token: 'tok',
access_token_ttl: 9999
});
render(WopiEditor, { props: { open: true, fileId: 'f1', fileName: 'doc.docx', action: 'edit' } });
await waitFor(() => expect(g).toHaveBeenCalledWith('f1', 'doc.docx', 'edit'));
expect(screen.getByTestId('wopi-editor-dialog')).toBeTruthy();
});
it('drops the spinner on an App_LoadingStatus message and closes on UI_Close', async () => {
g.mockResolvedValue({ editor_url: 'u', access_token: 't', access_token_ttl: 1 });
const onclose = vi.fn();
render(WopiEditor, { props: { open: true, fileId: 'f1', fileName: 'd.docx', onclose } });
await waitFor(() => screen.getByTestId('wopi-editor-dialog'));
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({
MessageId: 'App_LoadingStatus',
Values: { Status: 'Document_Loaded' }
})
})
);
window.dispatchEvent(
new MessageEvent('message', { data: JSON.stringify({ MessageId: 'UI_Close' }) })
);
await waitFor(() => expect(onclose).toHaveBeenCalled());
});
it('reports a load failure via errorToast', async () => {
g.mockRejectedValue(new Error('no wopi host'));
render(WopiEditor, { props: { open: true, fileId: 'f1', fileName: 'd.docx' } });
await waitFor(() => expect(et).toHaveBeenCalled());
});
it('does not load when closed', () => {
render(WopiEditor, { props: { open: false, fileId: 'f1', fileName: 'd.docx' } });
expect(g).not.toHaveBeenCalled();
});
@@ -0,0 +1,19 @@
import { it, expect, vi } from 'vitest';
import { useOwnerCache } from './useOwnerCache.svelte';
it('resolves names in parallel, dedupes, skips nullish, and caches', async () => {
const resolver = vi.fn(async (id: string) => `Name-${id}`);
const c = useOwnerCache(resolver);
expect(c.name(null)).toBeNull();
expect(c.name('u1')).toBeNull();
expect(c.label('u1')).toBe('u1');
await c.resolve(['u1', 'u2', null, undefined, 'u1']);
expect(resolver).toHaveBeenCalledTimes(2);
expect(c.name('u1')).toBe('Name-u1');
expect(c.label('u2')).toBe('Name-u2');
expect(c.names.u1).toBe('Name-u1');
await c.resolve(['u1']); // already cached → no extra calls
expect(resolver).toHaveBeenCalledTimes(2);
});
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { Selection, useSelection } from './useSelection.svelte';
describe('Selection', () => {
it('adds idempotently and reports size/empty', () => {
const s = useSelection();
expect(s.isEmpty).toBe(true);
s.add('a');
s.add('a');
expect(s.size).toBe(1);
expect(s.has('a')).toBe(true);
});
it('toggles ids on and off', () => {
const s = new Selection();
s.toggle('b');
expect(s.has('b')).toBe(true);
s.toggle('b');
expect(s.has('b')).toBe(false);
});
it('deletes, replaces, and clears', () => {
const s = new Selection();
s.set(['x', 'y', 'z']);
expect(s.size).toBe(3);
expect(s.values().sort()).toEqual(['x', 'y', 'z']);
s.delete('y');
s.delete('missing');
expect(s.size).toBe(2);
s.clear();
expect(s.isEmpty).toBe(true);
s.clear(); // no-op when already empty
expect(s.size).toBe(0);
});
});
@@ -0,0 +1,31 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { useVirtualWindow } from './useVirtualWindow.svelte';
beforeEach(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
}
);
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
});
it('starts at zero, observes a root, remeasures, and tears down', () => {
const vw = useVirtualWindow();
expect(vw.aboveBy).toBe(0);
expect(vw.viewportH).toBe(0);
const root = document.createElement('div');
document.body.appendChild(root);
const teardown = vw.observe(root);
expect(typeof teardown).toBe('function');
vw.remeasure();
teardown();
root.remove();
});
+68
View File
@@ -0,0 +1,68 @@
import { it, expect, beforeEach } from 'vitest';
import { dialogs, confirmDialog, promptDialog } from './dialogs.svelte';
beforeEach(() => {
// Drain any leftover dialog so each test starts clean.
while (dialogs.current && !dialogs.busy) dialogs.cancel();
});
it('confirm resolves true and prompt resolves its value', async () => {
const c = confirmDialog({ title: 'Confirm', message: 'ok?' });
expect(dialogs.current?.kind).toBe('confirm');
dialogs.resolve(true);
await expect(c).resolves.toBe(true);
const p = promptDialog({ title: 'Name' });
dialogs.resolve('hello');
await expect(p).resolves.toBe('hello');
});
it('cancel resolves confirm=false and prompt=null', async () => {
const c = confirmDialog({ title: 'Confirm', message: 'x' });
dialogs.cancel();
await expect(c).resolves.toBe(false);
const p = promptDialog({ title: 'y' });
dialogs.cancel();
await expect(p).resolves.toBeNull();
});
it('runs a successful action then resolves', async () => {
let ran = false;
const c = confirmDialog({
title: 'Confirm',
message: 'x',
action: async () => {
ran = true;
}
});
await dialogs.resolve(true);
expect(ran).toBe(true);
await expect(c).resolves.toBe(true);
});
it('keeps the dialog open with an inline error when the action fails', async () => {
const c = confirmDialog({
title: 'Confirm',
message: 'x',
action: async () => {
throw new Error('boom');
}
});
await dialogs.resolve(true);
expect(dialogs.error).toBe('boom');
expect(dialogs.current).not.toBeNull();
dialogs.cancel(); // recover
await expect(c).resolves.toBe(false);
});
it('queues a second dialog behind the first', async () => {
const p1 = confirmDialog({ title: 'Confirm', message: '1' });
const p2 = confirmDialog({ title: 'Confirm', message: '2' });
expect(dialogs.current?.opts.message).toBe('1');
dialogs.resolve(true);
await p1;
expect(dialogs.current?.opts.message).toBe('2');
dialogs.resolve(true);
await p2;
expect(dialogs.current).toBeNull();
});
+43
View File
@@ -0,0 +1,43 @@
import { it, expect } from 'vitest';
import { sizeBucket, dateBucket, typeLabel, ownerLabel, files } from './files.svelte';
it('buckets sizes into distinct human ranges', () => {
expect(sizeBucket(-1)).not.toBe(sizeBucket(0));
const buckets = [0, 500, 5_000_000, 500_000_000, 2_000_000_000, 10_000_000_000].map(sizeBucket);
// Each successive threshold lands in a different bucket label.
expect(new Set(buckets).size).toBe(buckets.length);
buckets.forEach((b) => expect(typeof b).toBe('string'));
});
it('buckets dates relative to now', () => {
expect(dateBucket(null)).toBeTruthy();
expect(dateBucket(Date.now())).toBe(dateBucket(Date.now()));
const old = new Date('2019-06-15').getTime();
expect(dateBucket(old)).toBe('2019');
});
it('labels a file category, defaulting when absent', () => {
expect(typeLabel(null)).toBeTruthy();
expect(typeof typeLabel('Image')).toBe('string');
});
it('shows the owner as "Me" for the current user and a short id otherwise', () => {
expect(ownerLabel(null, 'me')).toBe('');
expect(ownerLabel('me', 'me')).toBeTruthy();
expect(ownerLabel('abcdef123456', 'someone-else')).toBe('abcdef12');
});
it('persists the view mode and toggles selection', () => {
files.setViewMode('list');
expect(files.viewMode).toBe('list');
expect(localStorage.getItem('oxicloud_view_mode')).toBe('list');
files.setViewMode('grid');
expect(files.viewMode).toBe('grid');
files.clearSelection();
expect(files.selection.size).toBe(0);
files.toggleSelected('a');
expect(files.selection.has('a')).toBe(true);
files.toggleSelected('a');
expect(files.selection.has('a')).toBe(false);
});
+29
View File
@@ -0,0 +1,29 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { theme, setTheme } from './theme.svelte';
describe('theme store', () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.removeAttribute('data-color-scheme');
});
it('sets light/dark, persists, and reflects on <html>', () => {
setTheme('light');
expect(theme.current).toBe('light');
expect(localStorage.getItem('oxicloud_theme')).toBe('light');
expect(document.documentElement.getAttribute('data-color-scheme')).toBe('light');
setTheme('dark');
expect(document.documentElement.getAttribute('data-color-scheme')).toBe('dark');
expect(localStorage.getItem('oxicloud_theme')).toBe('dark');
});
it('auto clears storage and removes the attribute', () => {
setTheme('dark');
setTheme('auto');
expect(theme.current).toBe('auto');
expect(localStorage.getItem('oxicloud_theme')).toBeNull();
expect(document.documentElement.hasAttribute('data-color-scheme')).toBe(false);
});
it('theme.set is an alias for setTheme', () => {
theme.set('light');
expect(theme.current).toBe('light');
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { iconNameFromClass, formatDate } from './display';
describe('iconNameFromClass', () => {
it('extracts the fa token without its prefix', () => {
expect(iconNameFromClass('fas fa-folder')).toBe('folder');
expect(iconNameFromClass('fa-file-pdf')).toBe('file-pdf');
});
it('skips fa-fw / fa-lg modifier tokens', () => {
expect(iconNameFromClass('fa-fw fa-image')).toBe('image');
expect(iconNameFromClass('fa-lg fa-music')).toBe('music');
});
it('falls back to "file" for missing or unrecognised input', () => {
expect(iconNameFromClass(null)).toBe('file');
expect(iconNameFromClass(undefined)).toBe('file');
expect(iconNameFromClass('')).toBe('file');
expect(iconNameFromClass('no-fa-token-here')).toBe('file');
});
});
describe('formatDate', () => {
it('formats epoch seconds and milliseconds', () => {
expect(formatDate(1_700_000_000)).toMatch(/\d{4}/); // seconds
expect(formatDate(1_700_000_000_000)).toMatch(/\d{4}/); // ms
});
it('formats ISO-8601 strings', () => {
expect(formatDate('2024-01-15')).toMatch(/2024/);
});
it('returns empty string for null/undefined/invalid', () => {
expect(formatDate(null)).toBe('');
expect(formatDate(undefined)).toBe('');
expect(formatDate('definitely not a date')).toBe('');
});
});
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/stores/ui.svelte', () => ({ ui: { notify: vi.fn() } }));
import { ui } from '$lib/stores/ui.svelte';
import { errorMessage, errorToast } from './errors';
describe('errorMessage', () => {
it('returns the message of an Error', () => {
expect(errorMessage(new Error('boom'))).toBe('boom');
});
it('stringifies non-Error values', () => {
expect(errorMessage('nope')).toBe('nope');
expect(errorMessage(42)).toBe('42');
expect(errorMessage(null)).toBe('null');
expect(errorMessage(undefined)).toBe('undefined');
});
});
describe('errorToast', () => {
beforeEach(() => vi.clearAllMocks());
it('raises an error toast with the normalised message', () => {
errorToast(new Error('bad'));
expect(ui.notify).toHaveBeenCalledWith('bad', 'error');
});
it('handles non-Error values', () => {
errorToast('plain');
expect(ui.notify).toHaveBeenCalledWith('plain', 'error');
});
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { gridColumns } from './grid';
function mockMatchMedia(matches: boolean) {
vi.stubGlobal(
'matchMedia',
vi.fn().mockReturnValue({
matches,
media: '',
addEventListener: vi.fn(),
removeEventListener: vi.fn()
})
);
}
describe('gridColumns', () => {
beforeEach(() => mockMatchMedia(false));
it('returns 1 for non-positive width', () => {
expect(gridColumns(0)).toBe(1);
expect(gridColumns(-100)).toBe(1);
});
it('computes columns at desktop sizing (cardMin 200, gap 20)', () => {
expect(gridColumns(220)).toBe(1); // floor(240/220)
expect(gridColumns(440)).toBe(2); // floor(460/220)
expect(gridColumns(900)).toBe(4); // floor(920/220)
});
it('uses mobile sizing when the phone media query matches', () => {
mockMatchMedia(true);
expect(gridColumns(300)).toBe(2); // floor(308/148)
expect(gridColumns(600)).toBe(4); // floor(608/148)
});
});
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { isVideo, photoTimestamp, minimalPhotoItem } from './media';
describe('media helpers', () => {
it('isVideo detects video mime types', () => {
expect(isVideo({ mime_type: 'video/mp4' } as never)).toBe(true);
expect(isVideo({ mime_type: 'image/png' } as never)).toBe(false);
expect(isVideo({} as never)).toBe(false);
});
it('photoTimestamp scales seconds to milliseconds', () => {
expect(photoTimestamp({ sort_date: 1000 } as never)).toBe(1_000_000);
expect(photoTimestamp({ sort_date: 2_000_000_000_000 } as never)).toBe(2_000_000_000_000);
expect(photoTimestamp({ created_at: 5 } as never)).toBe(5000);
expect(photoTimestamp({} as never)).toBe(0);
});
it('minimalPhotoItem stubs a FileItem from an id', () => {
const p = minimalPhotoItem('abc');
expect(p.id).toBe('abc');
expect(p.category).toBe('image');
});
});
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { relativeTimeAgo } from './time';
describe('relativeTimeAgo', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-06-15T12:00:00Z'));
});
afterEach(() => vi.useRealTimers());
it('returns the empty label for null/undefined/empty', () => {
expect(relativeTimeAgo(null)).toBe('');
expect(relativeTimeAgo(undefined)).toBe('');
expect(relativeTimeAgo('')).toBe('');
expect(relativeTimeAgo(null, { empty: 'never' })).toBe('never');
});
it('handles unparseable input', () => {
expect(relativeTimeAgo('garbage')).toBe('');
expect(relativeTimeAgo('garbage', { invalidAsString: true })).toBe('garbage');
});
it('formats past times in the largest matching unit', () => {
expect(relativeTimeAgo(Date.now() - 2 * 31_536_000_000)).toMatch(/year/);
expect(relativeTimeAgo(Date.now() - 2 * 2_592_000_000)).toMatch(/month/);
expect(relativeTimeAgo(Date.now() - 2 * 604_800_000)).toMatch(/week/);
expect(relativeTimeAgo(Date.now() - 3 * 86_400_000)).toMatch(/day/);
expect(relativeTimeAgo(Date.now() - 3_600_000)).toMatch(/hour/);
expect(relativeTimeAgo(Date.now() - 120_000)).toMatch(/minute/);
});
it('accepts epoch seconds as well as milliseconds', () => {
expect(relativeTimeAgo(Math.floor((Date.now() - 3_600_000) / 1000))).toMatch(/hour/);
});
it('falls back to seconds for very recent timestamps', () => {
expect(relativeTimeAgo(Date.now() - 5_000)).toMatch(/second/);
});
});
+291 -61
View File
@@ -914,27 +914,57 @@
<h1>{t('admin.title', 'Admin')}</h1>
<div class="tabs" role="tablist">
<button role="tab" aria-selected={tab === 'dashboard'} onclick={() => (tab = 'dashboard')}>
<button
role="tab"
data-testid="admin-dashboard-tab"
aria-selected={tab === 'dashboard'}
onclick={() => (tab = 'dashboard')}
>
<Icon name="chart-pie" />
{t('admin.dashboard', 'Dashboard')}
</button>
<button role="tab" aria-selected={tab === 'users'} onclick={() => (tab = 'users')}>
<button
role="tab"
data-testid="admin-users-tab"
aria-selected={tab === 'users'}
onclick={() => (tab = 'users')}
>
<Icon name="users" />
{t('admin.users', 'Users')}
</button>
<button role="tab" aria-selected={tab === 'oidc'} onclick={() => (tab = 'oidc')}>
<button
role="tab"
data-testid="admin-oidc-tab"
aria-selected={tab === 'oidc'}
onclick={() => (tab = 'oidc')}
>
<Icon name="key" />
{t('admin.oidc', 'OIDC / SSO')}
</button>
<button role="tab" aria-selected={tab === 'storage'} onclick={() => (tab = 'storage')}>
<button
role="tab"
data-testid="admin-storage-tab"
aria-selected={tab === 'storage'}
onclick={() => (tab = 'storage')}
>
<Icon name="database" />
{t('admin.storage_tab', 'Storage')}
</button>
<button role="tab" aria-selected={tab === 'smtp'} onclick={() => (tab = 'smtp')}>
<button
role="tab"
data-testid="admin-smtp-tab"
aria-selected={tab === 'smtp'}
onclick={() => (tab = 'smtp')}
>
<Icon name="envelope" />
{t('admin.smtp', 'Email (SMTP)')}
</button>
<button role="tab" aria-selected={tab === 'plugins'} onclick={() => (tab = 'plugins')}>
<button
role="tab"
data-testid="admin-plugins-tab"
aria-selected={tab === 'plugins'}
onclick={() => (tab = 'plugins')}
>
<Icon name="layer-group" />
{t('admin.plugins', 'Plugins')}
</button>
@@ -1031,6 +1061,7 @@
<label class="checkbox">
<input
type="checkbox"
data-testid="admin-dashboard-registration-checkbox"
checked={dashboard.registration_enabled}
onchange={(e) => toggleRegistration(e.currentTarget.checked)}
/>
@@ -1106,9 +1137,17 @@
{#if !oidc}
<p class="status">{t('common.loading', 'Loading…')}</p>
{:else}
<form class="form" onsubmit={(e) => (e.preventDefault(), doSaveOidc())}>
<form
class="form"
data-testid="admin-oidc-form"
onsubmit={(e) => (e.preventDefault(), doSaveOidc())}
>
<label class="checkbox">
<input type="checkbox" bind:checked={oidc.enabled} />
<input
type="checkbox"
data-testid="admin-oidc-enabled-checkbox"
bind:checked={oidc.enabled}
/>
<span>{t('admin.oidc_enabled', 'Enable OIDC login')}</span>
</label>
<label
@@ -1119,11 +1158,17 @@
>
<input
bind:value={oidc.issuer_url}
data-testid="admin-oidc-issuer-input"
placeholder="https://idp.example.com"
disabled={isEnvLocked(oidc.env_overrides, 'issuer_url')}
/></label
>
<button type="button" class="btn btn-secondary" onclick={runOidcTest}>
<button
type="button"
class="btn btn-secondary"
data-testid="admin-oidc-discover-btn"
onclick={runOidcTest}
>
<Icon name="search" />
{t('admin.oidc_discover', 'Test / discover')}
</button>
@@ -1155,6 +1200,7 @@
>
<input
bind:value={oidc.client_id}
data-testid="admin-oidc-client-id-input"
disabled={isEnvLocked(oidc.env_overrides, 'client_id')}
/></label
>
@@ -1166,6 +1212,7 @@
>
<input
type="password"
data-testid="admin-oidc-client-secret-input"
bind:value={oidc.client_secret}
disabled={isEnvLocked(oidc.env_overrides, 'client_secret')}
placeholder={oidc.client_secret_set
@@ -1187,6 +1234,7 @@
>
<input
bind:value={oidc.scopes}
data-testid="admin-oidc-scopes-input"
placeholder="openid profile email"
disabled={isEnvLocked(oidc.env_overrides, 'scopes')}
/></label
@@ -1199,6 +1247,7 @@
>
<input
bind:value={oidc.provider_name}
data-testid="admin-oidc-provider-name-input"
disabled={isEnvLocked(oidc.env_overrides, 'provider_name')}
/></label
>
@@ -1210,15 +1259,24 @@
>
<input
bind:value={oidc.admin_groups}
data-testid="admin-oidc-admin-groups-input"
disabled={isEnvLocked(oidc.env_overrides, 'admin_groups')}
/></label
>
<label class="checkbox">
<input type="checkbox" bind:checked={oidc.auto_provision} />
<input
type="checkbox"
data-testid="admin-oidc-auto-provision-checkbox"
bind:checked={oidc.auto_provision}
/>
<span>{t('admin.oidc_auto_provision', 'Auto-provision users on first login')}</span>
</label>
<label class="checkbox">
<input type="checkbox" bind:checked={oidc.disable_password_login} />
<input
type="checkbox"
data-testid="admin-oidc-disable-pw-checkbox"
bind:checked={oidc.disable_password_login}
/>
<span>{t('admin.oidc_disable_pw', 'Disable password login (OIDC only)')}</span>
</label>
{#if oidc.callback_url}
@@ -1227,6 +1285,7 @@
<button
type="button"
class="btn btn-sm btn-secondary"
data-testid="admin-oidc-callback-copy-btn"
onclick={() => copyText(oidc?.callback_url ?? '')}
>
<Icon name="copy" />
@@ -1237,7 +1296,12 @@
{#if oidcMsg}<p class={oidcMsg.ok ? 'status--ok' : 'status--error'}>
{oidcMsg.text}
</p>{/if}
<button class="btn btn-primary" type="submit" disabled={oidcSaving}>
<button
class="btn btn-primary"
type="submit"
data-testid="admin-oidc-save-btn"
disabled={oidcSaving}
>
{t('common.save', 'Save')}
</button>
</form>
@@ -1249,10 +1313,14 @@
{#if !storage}
<p class="status">{t('common.loading', 'Loading…')}</p>
{:else}
<form class="form" onsubmit={(e) => (e.preventDefault(), doSaveStorage())}>
<form
class="form"
data-testid="admin-storage-form"
onsubmit={(e) => (e.preventDefault(), doSaveStorage())}
>
<label
><span>{t('admin.storage_backend', 'Backend')}</span>
<select bind:value={sForm.backend}>
<select bind:value={sForm.backend} data-testid="admin-storage-backend-select">
<option value="local">local</option>
<option value="s3">S3</option>
</select></label
@@ -1260,7 +1328,11 @@
{#if sForm.backend === 's3'}
<label
><span>{t('admin.storage_preset', 'Preset')}</span>
<select bind:value={sForm.preset} onchange={applyPreset}>
<select
bind:value={sForm.preset}
data-testid="admin-storage-preset-select"
onchange={applyPreset}
>
{#each Object.keys(STORAGE_PRESETS) as p (p)}<option value={p}>{p}</option>{/each}
</select></label
>
@@ -1272,6 +1344,7 @@
>
<input
bind:value={sForm.endpoint}
data-testid="admin-storage-endpoint-input"
disabled={isEnvLocked(storage.env_overrides, 's3_endpoint_url')}
/></label
>
@@ -1283,6 +1356,7 @@
>
<input
bind:value={sForm.bucket}
data-testid="admin-storage-bucket-input"
disabled={isEnvLocked(storage.env_overrides, 's3_bucket')}
/></label
>
@@ -1294,6 +1368,7 @@
>
<input
bind:value={sForm.region}
data-testid="admin-storage-region-input"
disabled={isEnvLocked(storage.env_overrides, 's3_region')}
/></label
>
@@ -1305,6 +1380,7 @@
>
<input
bind:value={sForm.accessKey}
data-testid="admin-storage-access-key-input"
disabled={isEnvLocked(storage.env_overrides, 's3_access_key')}
placeholder={storage.s3_access_key_set
? t('admin.unchanged', 'Leave blank to keep current')
@@ -1319,6 +1395,7 @@
>
<input
type="password"
data-testid="admin-storage-secret-key-input"
bind:value={sForm.secretKey}
disabled={isEnvLocked(storage.env_overrides, 's3_secret_key')}
placeholder={storage.s3_secret_key_set
@@ -1327,7 +1404,11 @@
/></label
>
<label class="checkbox">
<input type="checkbox" bind:checked={sForm.pathStyle} />
<input
type="checkbox"
data-testid="admin-storage-path-style-checkbox"
bind:checked={sForm.pathStyle}
/>
<span>{t('admin.storage_path_style', 'Force path-style URLs')}</span>
</label>
{/if}
@@ -1335,13 +1416,17 @@
{storageMsg.text}
</p>{/if}
<div class="smtp-test">
<button class="btn btn-primary" type="submit" disabled={storageBusy}
>{t('common.save', 'Save')}</button
<button
class="btn btn-primary"
type="submit"
data-testid="admin-storage-save-btn"
disabled={storageBusy}>{t('common.save', 'Save')}</button
>
{#if sForm.backend === 's3'}
<button
type="button"
class="btn btn-secondary"
data-testid="admin-storage-test-btn"
disabled={storageBusy}
onclick={doTestStorage}
>
@@ -1401,30 +1486,43 @@
<div class="smtp-test">
<!-- Start: only when no migration is active (running/paused) or completed. -->
{#if migration.status !== 'running' && migration.status !== 'paused' && migration.status !== 'completed'}
<button class="btn btn-primary" onclick={() => doMigration('start')}
>{t('admin.mig_start', 'Start')}</button
<button
class="btn btn-primary"
data-testid="admin-migration-start-btn"
onclick={() => doMigration('start')}>{t('admin.mig_start', 'Start')}</button
>
{/if}
{#if migration.status === 'running'}
<button class="btn btn-secondary" onclick={() => doMigration('pause')}
>{t('admin.mig_pause', 'Pause')}</button
<button
class="btn btn-secondary"
data-testid="admin-migration-pause-btn"
onclick={() => doMigration('pause')}>{t('admin.mig_pause', 'Pause')}</button
>
{/if}
{#if migration.status === 'paused'}
<button class="btn btn-primary" onclick={() => doMigration('resume')}
>{t('admin.mig_resume', 'Resume')}</button
<button
class="btn btn-primary"
data-testid="admin-migration-resume-btn"
onclick={() => doMigration('resume')}>{t('admin.mig_resume', 'Resume')}</button
>
{/if}
<!-- Verify + Finalize: only once the copy phase has completed. -->
{#if migration.status === 'completed'}
<button class="btn btn-secondary" disabled={verifying} onclick={doVerify}>
<button
class="btn btn-secondary"
data-testid="admin-migration-verify-btn"
disabled={verifying}
onclick={doVerify}
>
<Icon name="check-double" />
{verifying
? t('admin.mig_verifying', 'Verifying…')
: t('admin.mig_verify', 'Verify integrity')}
</button>
<button class="btn btn-secondary" onclick={() => doMigration('complete')}
>{t('admin.mig_complete', 'Finalize')}</button
<button
class="btn btn-secondary"
data-testid="admin-migration-complete-btn"
onclick={() => doMigration('complete')}>{t('admin.mig_complete', 'Finalize')}</button
>
{/if}
</div>
@@ -1540,10 +1638,16 @@
<div class="smtp-test">
<input
type="email"
data-testid="admin-smtp-to-input"
bind:value={smtpTo}
placeholder={t('admin.smtp_to', 'recipient@example.com')}
/>
<button class="btn btn-primary" disabled={smtpSending} onclick={runSmtpTest}>
<button
class="btn btn-primary"
data-testid="admin-smtp-send-btn"
disabled={smtpSending}
onclick={runSmtpTest}
>
<Icon name="paper-plane" />
{smtpSending ? t('admin.smtp_sending', 'Sending…') : t('admin.smtp_send', 'Send')}
</button>
@@ -1567,7 +1671,11 @@
</div>
{:else if tab === 'users'}
<div class="bar">
<button class="btn btn--primary" onclick={() => (createOpen = true)}>
<button
class="btn btn--primary"
data-testid="admin-users-create-btn"
onclick={() => (createOpen = true)}
>
<Icon name="user-plus" />
{t('admin.create_user', 'Create user')}
</button>
@@ -1644,6 +1752,7 @@
<td class="actions">
<button
class="icon-btn"
data-testid={`admin-user-quota-${u.id}`}
title={t('admin.edit_quota_title', 'Edit quota')}
aria-label={t('admin.edit_quota_title', 'Edit quota')}
onclick={() => openQuota(u)}
@@ -1653,6 +1762,7 @@
{#if !isOidcUser(u)}
<button
class="icon-btn"
data-testid={`admin-user-reset-password-${u.id}`}
title={t('admin.reset_password_title', 'Reset password')}
aria-label={t('admin.reset_password_title', 'Reset password')}
onclick={() => openReset(u)}
@@ -1662,6 +1772,7 @@
{/if}
<button
class="icon-btn"
data-testid={`admin-user-toggle-role-${u.id}`}
title={t('admin.toggle_role_title', 'Toggle admin role')}
aria-label={t('admin.toggle_role_title', 'Toggle admin role')}
disabled={isSelf(u)}
@@ -1671,6 +1782,7 @@
</button>
<button
class="icon-btn {u.active ? 'icon-btn--danger' : 'icon-btn--success'}"
data-testid={`admin-user-toggle-active-${u.id}`}
title={u.active
? t('admin.deactivate_title', 'Deactivate')
: t('admin.activate_title', 'Activate')}
@@ -1684,6 +1796,7 @@
</button>
<button
class="icon-btn icon-btn--danger"
data-testid={`admin-user-delete-${u.id}`}
title={t('admin.delete_title', 'Delete user')}
aria-label={t('admin.delete_title', 'Delete user')}
disabled={isSelf(u)}
@@ -1697,10 +1810,16 @@
</tbody>
</table>
<div class="pager">
<button class="btn" disabled={pageIndex === 0} onclick={() => changePage(-1)}>‹</button>
<button
class="btn"
data-testid="admin-users-pager-prev-btn"
disabled={pageIndex === 0}
onclick={() => changePage(-1)}>‹</button
>
<span>{pageIndex + 1} / {Math.max(1, Math.ceil(total / PAGE_SIZE))}</span>
<button
class="btn"
data-testid="admin-users-pager-next-btn"
disabled={(pageIndex + 1) * PAGE_SIZE >= total}
onclick={() => changePage(1)}>›</button
>
@@ -1725,6 +1844,7 @@
: t('admin.plugins_upload', 'Upload .zip')}
<input
type="file"
data-testid="admin-plugins-install-input"
accept=".zip,application/zip"
hidden
disabled={installing}
@@ -1775,6 +1895,7 @@
<td class="actions">
<button
class="icon-btn"
data-testid={`admin-plugin-details-${p.id}`}
title={t('admin.plugins_details', 'Logs & details')}
aria-label={t('admin.plugins_details', 'Logs & details')}
onclick={() => openLogs(p)}
@@ -1783,6 +1904,7 @@
</button>
<button
class="icon-btn {p.enabled ? '' : 'icon-btn--success'}"
data-testid={`admin-plugin-toggle-${p.id}`}
title={p.enabled ? t('admin.disable', 'Disable') : t('admin.enable', 'Enable')}
aria-label={p.enabled
? t('admin.disable', 'Disable')
@@ -1793,6 +1915,7 @@
</button>
<button
class="icon-btn icon-btn--danger"
data-testid={`admin-plugin-delete-${p.id}`}
title={t('common.delete', 'Delete')}
aria-label={t('common.delete', 'Delete')}
onclick={() => removePlugin(p)}
@@ -1809,10 +1932,20 @@
</main>
<Modal bind:open={createOpen} title={t('admin.create_user', 'Create user')}>
<form id="create-user-form" onsubmit={submitCreate} class="form">
<form
id="create-user-form"
data-testid="admin-create-user-form"
onsubmit={submitCreate}
class="form"
>
<label
><span>{t('admin.username', 'Username')}</span>
<input bind:value={newUser.username} minlength="3" required /></label
<input
bind:value={newUser.username}
data-testid="admin-create-user-username-input"
minlength="3"
required
/></label
>
<label
><span
@@ -1821,17 +1954,24 @@
>
<input
type="email"
data-testid="admin-create-user-email-input"
bind:value={newUser.email}
placeholder={t('admin.email_auto', 'Auto-generated if left blank')}
/></label
>
<label
><span>{t('admin.password', 'Password')}</span>
<input type="password" bind:value={newUser.password} minlength="8" required /></label
<input
type="password"
data-testid="admin-create-user-password-input"
bind:value={newUser.password}
minlength="8"
required
/></label
>
<label
><span>{t('admin.role', 'Role')}</span>
<select bind:value={newUser.role}>
<select bind:value={newUser.role} data-testid="admin-create-user-role-select">
<option value="user">user</option>
<option value="admin">admin</option>
</select></label
@@ -1839,8 +1979,14 @@
<label
><span>{t('admin.quota', 'Quota')}</span>
<div class="quota-input">
<input type="number" min="0" step="0.1" bind:value={newUser.quotaValue} />
<select bind:value={newUser.quotaUnit}>
<input
type="number"
data-testid="admin-create-user-quota-input"
min="0"
step="0.1"
bind:value={newUser.quotaValue}
/>
<select bind:value={newUser.quotaUnit} data-testid="admin-create-user-quota-unit-select">
{#each QUOTA_UNITS as unit (unit.label)}<option value={unit.value}>{unit.label}</option
>{/each}
</select>
@@ -1850,8 +1996,18 @@
{#if createError}<p class="status--error">{createError}</p>{/if}
</form>
{#snippet footer()}
<button class="btn" onclick={() => (createOpen = false)}>{t('common.cancel', 'Cancel')}</button>
<button class="btn btn--primary" type="submit" form="create-user-form" disabled={creating}>
<button
class="btn"
data-testid="admin-create-user-cancel-btn"
onclick={() => (createOpen = false)}>{t('common.cancel', 'Cancel')}</button
>
<button
class="btn btn--primary"
type="submit"
form="create-user-form"
data-testid="admin-create-user-submit-btn"
disabled={creating}
>
{creating ? t('admin.creating', 'Creating…') : t('common.create', 'Create')}
</button>
{/snippet}
@@ -1867,6 +2023,7 @@
<form
id="quota-form"
class="form"
data-testid="admin-quota-form"
onsubmit={(e) => {
e.preventDefault();
void saveQuota();
@@ -1878,8 +2035,14 @@
<label
><span>{t('admin.quota', 'Quota')}</span>
<div class="quota-input">
<input type="number" min="0" step="0.1" bind:value={quotaModal.value} />
<select bind:value={quotaModal.unit}>
<input
type="number"
data-testid="admin-quota-value-input"
min="0"
step="0.1"
bind:value={quotaModal.value}
/>
<select bind:value={quotaModal.unit} data-testid="admin-quota-unit-select">
{#each QUOTA_UNITS as unit (unit.label)}<option value={unit.value}>{unit.label}</option
>{/each}
</select>
@@ -1889,8 +2052,15 @@
</form>
{/if}
{#snippet footer()}
<button class="btn" onclick={() => (quotaModal = null)}>{t('common.cancel', 'Cancel')}</button>
<button class="btn btn--primary" type="submit" form="quota-form">
<button class="btn" data-testid="admin-quota-cancel-btn" onclick={() => (quotaModal = null)}
>{t('common.cancel', 'Cancel')}</button
>
<button
class="btn btn--primary"
type="submit"
form="quota-form"
data-testid="admin-quota-save-btn"
>
{t('common.save', 'Save')}
</button>
{/snippet}
@@ -1903,20 +2073,41 @@
onclose={() => (resetModal = null)}
>
{#if resetModal}
<form id="reset-pw-form" class="form" onsubmit={submitReset}>
<form
id="reset-pw-form"
class="form"
data-testid="admin-reset-password-form"
onsubmit={submitReset}
>
<p class="muted">
{t('admin.reset_pw_for', 'New password for')} <strong>{resetModal.username}</strong>
</p>
<label
><span>{t('admin.new_password', 'New password')}</span>
<input type="password" bind:value={resetPassword} minlength="8" required /></label
<input
type="password"
data-testid="admin-reset-password-input"
bind:value={resetPassword}
minlength="8"
required
/></label
>
{#if resetError}<p class="status--error">{resetError}</p>{/if}
</form>
{/if}
{#snippet footer()}
<button class="btn" onclick={() => (resetModal = null)}>{t('common.cancel', 'Cancel')}</button>
<button class="btn btn--primary" type="submit" form="reset-pw-form" disabled={resetting}>
<button
class="btn"
data-testid="admin-reset-password-cancel-btn"
onclick={() => (resetModal = null)}>{t('common.cancel', 'Cancel')}</button
>
<button
class="btn btn--primary"
type="submit"
form="reset-pw-form"
data-testid="admin-reset-password-submit-btn"
disabled={resetting}
>
{resetting ? t('admin.resetting', 'Resetting…') : t('admin.reset_btn', 'Reset')}
</button>
{/snippet}
@@ -1930,9 +2121,14 @@
>
<p>{confirmState?.message}</p>
{#snippet footer()}
<button class="btn" onclick={() => resolveConfirm(false)}>{t('common.cancel', 'Cancel')}</button
<button class="btn" data-testid="admin-confirm-cancel-btn" onclick={() => resolveConfirm(false)}
>{t('common.cancel', 'Cancel')}</button
>
<button
class="btn btn--primary"
data-testid="admin-confirm-ok-btn"
onclick={() => resolveConfirm(true)}
>
<button class="btn btn--primary" onclick={() => resolveConfirm(true)}>
{t('common.confirm', 'Confirm')}
</button>
{/snippet}
@@ -1972,25 +2168,43 @@
{/if}
{#if retention}
<form class="form retention-form" onsubmit={(e) => (e.preventDefault(), saveRetention())}>
<form
class="form retention-form"
data-testid="admin-plugin-retention-form"
onsubmit={(e) => (e.preventDefault(), saveRetention())}
>
<h3>{t('admin.plugins_retention', 'Log retention')}</h3>
<label
><span>{t('admin.plugins_retention_days', 'Keep for (days)')}</span>
<input type="number" min="0" bind:value={retentionDays} /></label
<input
type="number"
data-testid="admin-plugin-retention-days-input"
min="0"
bind:value={retentionDays}
/></label
>
<label
><span>{t('admin.plugins_retention_max', 'Max size (MB)')}</span>
<input type="number" min="0" bind:value={retentionMb} /></label
<input
type="number"
data-testid="admin-plugin-retention-max-input"
min="0"
bind:value={retentionMb}
/></label
>
{#if retentionMsg}<p class="muted">{retentionMsg}</p>{/if}
<button class="btn btn-secondary" type="submit"
<button class="btn btn-secondary" type="submit" data-testid="admin-plugin-retention-save-btn"
>{t('admin.plugins_retention_save', 'Save retention')}</button
>
</form>
{/if}
<div class="logs-toolbar">
<select bind:value={logsLevel} onchange={reloadLogsFromStart}>
<select
bind:value={logsLevel}
data-testid="admin-plugin-logs-level-select"
onchange={reloadLogsFromStart}
>
<option value="">{t('admin.logs_all', 'All levels')}</option>
<option value="info">info</option>
<option value="warn">warn</option>
@@ -1998,14 +2212,22 @@
</select>
<input
placeholder={t('admin.logs_search', 'Search…')}
data-testid="admin-plugin-logs-search-input"
bind:value={logsSearch}
onkeydown={(e) => e.key === 'Enter' && reloadLogsFromStart()}
/>
<button class="btn btn-secondary" onclick={reloadLogsFromStart}
>{t('common.search', 'Search')}</button
<button
class="btn btn-secondary"
data-testid="admin-plugin-logs-search-btn"
onclick={reloadLogsFromStart}>{t('common.search', 'Search')}</button
>
<label class="live-toggle">
<input type="checkbox" bind:checked={logsLive} onchange={toggleLive} />
<input
type="checkbox"
data-testid="admin-plugin-logs-live-checkbox"
bind:checked={logsLive}
onchange={toggleLive}
/>
<span>{t('admin.logs_live', 'Live')}</span>
</label>
</div>
@@ -2044,7 +2266,12 @@
</div>
{/if}
<div class="pager logs-pager">
<button class="btn" disabled={logsPage === 0} onclick={logsPrev}>‹</button>
<button
class="btn"
data-testid="admin-plugin-logs-pager-prev-btn"
disabled={logsPage === 0}
onclick={logsPrev}>‹</button
>
<span>
{#if logsTotal === 0}
{t('admin.logs_empty', 'No log entries.')}
@@ -2060,15 +2287,18 @@
)}
{/if}
</span>
<button class="btn" disabled={(logsPage + 1) * LOGS_PAGE_SIZE >= logsTotal} onclick={logsNext}
>›</button
<button
class="btn"
data-testid="admin-plugin-logs-pager-next-btn"
disabled={(logsPage + 1) * LOGS_PAGE_SIZE >= logsTotal}
onclick={logsNext}>›</button
>
</div>
{#snippet footer()}
<button class="btn btn-danger" onclick={purgeLogs}
<button class="btn btn-danger" data-testid="admin-plugin-logs-clear-btn" onclick={purgeLogs}
>{t('admin.plugins_clear_logs', 'Clear logs')}</button
>
<button class="btn btn-secondary" onclick={closeLogs}>
<button class="btn btn-secondary" data-testid="admin-plugin-logs-close-btn" onclick={closeLogs}>
{t('common.close', 'Close')}
</button>
{/snippet}
+212
View File
@@ -0,0 +1,212 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { session, ui } = vi.hoisted(() => ({
session: { user: { id: '1', username: 'admin', role: 'admin' } },
ui: { notify: vi.fn() }
}));
vi.mock('$lib/stores/session.svelte', () => ({ session }));
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
vi.mock('$lib/api/endpoints/admin', () => ({
clearPluginLogs: vi.fn(),
createUser: vi.fn(),
deletePlugin: vi.fn(),
deleteUser: vi.fn(),
getDashboard: vi.fn(),
getMigration: vi.fn(),
getOidcSettings: vi.fn(),
getPluginLogs: vi.fn(),
getPluginRetention: vi.fn(),
getSmtpInfo: vi.fn(),
getStorageSettings: vi.fn(),
installPlugin: vi.fn(),
listPlugins: vi.fn(),
listUsers: vi.fn(),
migrationAction: vi.fn(),
resetUserPassword: vi.fn(),
saveOidc: vi.fn(),
savePluginRetention: vi.fn(),
saveStorage: vi.fn(),
sendSmtpTest: vi.fn(),
setPluginEnabled: vi.fn(),
setRegistrationEnabled: vi.fn(),
setUserActive: vi.fn(),
setUserQuota: vi.fn(),
setUserRole: vi.fn(),
testOidc: vi.fn(),
testStorage: vi.fn(),
verifyMigration: vi.fn()
}));
import * as admin from '$lib/api/endpoints/admin';
import AdminPage from './+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
const dashboard = {
total_users: 3,
active_users: 2,
admin_users: 1,
server_version: '1.0',
total_used_bytes: 100,
total_quota_bytes: 1000,
storage_usage_percent: 10,
auth_enabled: true,
oidc_configured: false,
quotas_enabled: true,
registration_enabled: true,
users_over_80_percent: 0,
users_over_quota: 0
};
const user = {
id: 'u1',
username: 'bob',
email: 'bob@x.test',
role: 'user',
active: true,
is_active: true,
storage_used_bytes: 10,
storage_quota_bytes: 100,
is_external: false
};
beforeEach(() => {
vi.clearAllMocks();
m(admin.getDashboard).mockResolvedValue(dashboard);
m(admin.listUsers).mockResolvedValue({ total: 1, users: [user] });
m(admin.listPlugins).mockResolvedValue({ available: true, enabled: true, plugins: [] });
m(admin.getOidcSettings).mockResolvedValue({
enabled: false,
issuer_url: '',
client_id: '',
scopes: null,
auto_provision: false,
admin_groups: null,
disable_password_login: false,
provider_name: null,
callback_url: 'http://localhost/callback',
client_secret_set: false,
env_overrides: []
});
m(admin.getStorageSettings).mockResolvedValue({ backend: 'filesystem', env_overrides: [] });
m(admin.getMigration).mockResolvedValue({
status: 'idle',
total_blobs: 0,
migrated_blobs: 0,
migrated_bytes: 0
});
m(admin.getSmtpInfo).mockResolvedValue({
enabled: false,
host: 'localhost',
port: 25,
tls: 'none',
from: 'a@x.test',
user_state: 'unset'
});
});
it('loads the dashboard on mount', async () => {
render(AdminPage);
await waitFor(() => expect(admin.getDashboard).toHaveBeenCalled());
});
it('toggles registration from the dashboard', async () => {
m(admin.setRegistrationEnabled).mockResolvedValue(undefined);
render(AdminPage);
const cb = await screen.findByTestId('admin-dashboard-registration-checkbox');
await fireEvent.click(cb);
await waitFor(() => expect(admin.setRegistrationEnabled).toHaveBeenCalled());
});
it('loads users when the users tab is opened and creates a user', async () => {
m(admin.createUser).mockResolvedValue(undefined);
render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-users-tab'));
await waitFor(() => expect(admin.listUsers).toHaveBeenCalled());
await fireEvent.click(await screen.findByTestId('admin-users-create-btn'));
await fireEvent.input(await screen.findByTestId('admin-create-user-username-input'), {
target: { value: 'newbie' }
});
await fireEvent.input(screen.getByTestId('admin-create-user-password-input'), {
target: { value: 'Password123!' }
});
await fireEvent.click(screen.getByTestId('admin-create-user-submit-btn'));
await waitFor(() => expect(admin.createUser).toHaveBeenCalled());
});
it('loads OIDC settings when the OIDC tab is opened', async () => {
render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-oidc-tab'));
await waitFor(() => expect(admin.getOidcSettings).toHaveBeenCalled());
});
it('loads storage + migration when the storage tab is opened', async () => {
render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-storage-tab'));
await waitFor(() => expect(admin.getStorageSettings).toHaveBeenCalled());
await waitFor(() => expect(admin.getMigration).toHaveBeenCalled());
});
it('loads SMTP info when the SMTP tab is opened', async () => {
render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-smtp-tab'));
await waitFor(() => expect(admin.getSmtpInfo).toHaveBeenCalled());
});
it('loads plugins when the plugins tab is opened', async () => {
render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-plugins-tab'));
await waitFor(() => expect(admin.listPlugins).toHaveBeenCalled());
});
it("toggles a user's role through the confirm modal", async () => {
m(admin.setUserRole).mockResolvedValue(undefined);
render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-users-tab'));
await fireEvent.click(await screen.findByTestId('admin-user-toggle-role-u1'));
await fireEvent.click(await screen.findByTestId('admin-confirm-ok-btn'));
await waitFor(() => expect(admin.setUserRole).toHaveBeenCalledWith('u1', 'admin'));
});
it('deactivates a user through the confirm modal', async () => {
m(admin.setUserActive).mockResolvedValue(undefined);
render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-users-tab'));
await fireEvent.click(await screen.findByTestId('admin-user-toggle-active-u1'));
await fireEvent.click(await screen.findByTestId('admin-confirm-ok-btn'));
await waitFor(() => expect(admin.setUserActive).toHaveBeenCalledWith('u1', false));
});
it('saves OIDC settings from the OIDC form', async () => {
m(admin.saveOidc).mockResolvedValue(undefined);
render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-oidc-tab'));
await fireEvent.input(await screen.findByTestId('admin-oidc-issuer-input'), {
target: { value: 'https://idp.test' }
});
await fireEvent.submit(await screen.findByTestId('admin-oidc-form'));
await waitFor(() => expect(admin.saveOidc).toHaveBeenCalled());
});
it('sends an SMTP test email', async () => {
m(admin.sendSmtpTest).mockResolvedValue({ ok: true } as never);
render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-smtp-tab'));
await fireEvent.input(await screen.findByTestId('admin-smtp-to-input'), {
target: { value: 'to@x.test' }
});
await fireEvent.click(await screen.findByTestId('admin-smtp-send-btn'));
await waitFor(() => expect(admin.sendSmtpTest).toHaveBeenCalledWith('to@x.test'));
});
it('saves storage settings and starts a migration', async () => {
m(admin.saveStorage).mockResolvedValue(undefined);
m(admin.migrationAction).mockResolvedValue(undefined);
render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-storage-tab'));
await fireEvent.submit(await screen.findByTestId('admin-storage-form'));
await waitFor(() => expect(admin.saveStorage).toHaveBeenCalled());
await fireEvent.click(await screen.findByTestId('admin-migration-start-btn'));
await waitFor(() => expect(admin.migrationAction).toHaveBeenCalledWith('start'));
});
+20 -5
View File
@@ -123,11 +123,12 @@
<h1>{t('device.title', 'Device verification')}</h1>
{#if step === 'code'}
<form onsubmit={lookup}>
<form data-testid="device-code-form" onsubmit={lookup}>
<label class="device__field">
<span>{t('device.enter_code', 'Enter the code shown on your device')}</span>
<input
bind:this={codeInput}
data-testid="device-code-input"
value={code}
oninput={onCodeInput}
autocomplete="off"
@@ -137,7 +138,9 @@
maxlength={FULL_CODE_LENGTH}
/>
</label>
<button type="submit" disabled={!code}>{t('device.continue', 'Continue')}</button>
<button type="submit" data-testid="device-continue-btn" disabled={!code}
>{t('device.continue', 'Continue')}</button
>
</form>
{:else if step === 'loading'}
<p>{t('common.loading', 'Loading…')}</p>
@@ -149,10 +152,20 @@
<dd>{info?.scopes || 'all'}</dd>
</dl>
<div class="device__actions">
<button class="device__deny" disabled={busy} onclick={() => decide('deny')}>
<button
class="device__deny"
data-testid="device-deny-btn"
disabled={busy}
onclick={() => decide('deny')}
>
{t('device.deny', 'Deny')}
</button>
<button class="device__approve" disabled={busy} onclick={() => decide('approve')}>
<button
class="device__approve"
data-testid="device-approve-btn"
disabled={busy}
onclick={() => decide('approve')}
>
{t('device.approve', 'Approve')}
</button>
</div>
@@ -164,7 +177,9 @@
<p>{t('device.denied', 'Device access denied.')}</p>
{:else if step === 'error'}
<p class="device__error" role="alert">{errorText}</p>
<button onclick={backToCode}>{t('common.retry', 'Try again')}</button>
<button data-testid="device-retry-btn" onclick={backToCode}
>{t('common.retry', 'Try again')}</button
>
{/if}
</div>
</main>
+60
View File
@@ -0,0 +1,60 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { pageState } = vi.hoisted(() => ({
pageState: { url: new URL('http://localhost/device') }
}));
vi.mock('$app/state', () => ({ page: pageState }));
vi.mock('$lib/api/endpoints/device', () => {
class DeviceLookupFailure extends Error {}
return {
DeviceLookupFailure,
decideDevice: vi.fn(),
lookupDeviceCode: vi.fn()
};
});
import { decideDevice, lookupDeviceCode } from '$lib/api/endpoints/device';
import DevicePage from './+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
const info = { user_code: 'WXYZ-1234', client_name: 'CLI', scopes: ['files'] };
beforeEach(() => {
vi.clearAllMocks();
pageState.url = new URL('http://localhost/device');
});
it('looks up a typed code and shows the review step', async () => {
m(lookupDeviceCode).mockResolvedValue(info);
render(DevicePage);
await screen.findByTestId('device-code-form');
await fireEvent.input(screen.getByTestId('device-code-input'), {
target: { value: 'WXYZ-1234' }
});
await fireEvent.click(screen.getByTestId('device-continue-btn'));
await waitFor(() => expect(lookupDeviceCode).toHaveBeenCalledWith('WXYZ-1234'));
await screen.findByTestId('device-approve-btn');
});
it('approves a device after lookup', async () => {
m(lookupDeviceCode).mockResolvedValue(info);
m(decideDevice).mockResolvedValue(undefined);
render(DevicePage);
await fireEvent.input(await screen.findByTestId('device-code-input'), {
target: { value: 'WXYZ-1234' }
});
await fireEvent.click(screen.getByTestId('device-continue-btn'));
await fireEvent.click(await screen.findByTestId('device-approve-btn'));
await waitFor(() => expect(decideDevice).toHaveBeenCalled());
});
it('shows an error when lookup fails', async () => {
m(lookupDeviceCode).mockRejectedValue(new Error('nope'));
render(DevicePage);
await fireEvent.input(await screen.findByTestId('device-code-input'), {
target: { value: 'BAD' }
});
await fireEvent.click(screen.getByTestId('device-continue-btn'));
await screen.findByTestId('device-retry-btn');
});
+9 -3
View File
@@ -300,17 +300,23 @@
onselectionchange={(ids) => (selectedIds = ids)}
>
{#snippet batchToolbar()}
<Button icon="download" onclick={batchDownload}>{t('common.download', 'Download')}</Button>
<Button icon="download" data-testid="favorites-batch-download-btn" onclick={batchDownload}
>{t('common.download', 'Download')}</Button
>
<Button
icon="arrows-alt"
data-testid="favorites-batch-move-btn"
onclick={() => {
moveTarget = null;
moveItems = batchTargets();
moveOpen = true;
}}>{t('files.move', 'Move')}</Button
>
<Button variant="danger" icon="trash" onclick={batchDelete}
>{t('common.delete', 'Delete')}</Button
<Button
variant="danger"
icon="trash"
data-testid="favorites-batch-delete-btn"
onclick={batchDelete}>{t('common.delete', 'Delete')}</Button
>
{/snippet}
</ResourceList>
@@ -0,0 +1,92 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { confirmDialog, promptDialog } = vi.hoisted(() => ({
confirmDialog: vi.fn(),
promptDialog: vi.fn()
}));
vi.mock('$lib/api/endpoints/favorites', () => ({
fetchFavoritesPage: vi.fn(),
removeFavorite: vi.fn(),
resolveOwnerName: vi.fn(async () => 'me'),
sizeBucket: () => 'Small',
typeLabel: () => 'File'
}));
vi.mock('$lib/api/endpoints/files', () => ({
fileDownloadUrl: () => '/dl',
renameFile: vi.fn(),
deleteFile: vi.fn()
}));
vi.mock('$lib/api/endpoints/folders', () => ({ renameFolder: vi.fn(), deleteFolder: vi.fn() }));
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog, promptDialog }));
import { fetchFavoritesPage, removeFavorite } from '$lib/api/endpoints/favorites';
import { deleteFile } from '$lib/api/endpoints/files';
import FavoritesPage from './+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
function withOneFile() {
m(fetchFavoritesPage).mockResolvedValue({
items: [
{
resource_type: 'file',
favorited_at: '2024-01-01T00:00:00Z',
resource: {
category: 'Image',
created_at: 0,
icon_class: 'fa-file',
icon_special_class: '',
id: 'f1',
mime_type: 'image/png',
modified_at: 0,
name: 'photo.png',
owner_id: 'me',
folder_id: 'root',
path: '/photo.png',
size: 10,
size_formatted: '10 B',
sort_date: 0,
etag: 'e',
content_hash: 'h'
}
}
],
next_cursor: null
});
}
beforeEach(() => vi.clearAllMocks());
it('renders favorites returned by the API', async () => {
withOneFile();
render(FavoritesPage);
await waitFor(() => expect(fetchFavoritesPage).toHaveBeenCalled());
await waitFor(() => expect(screen.getByText('photo.png')).toBeTruthy());
});
it('renders an empty state when there are no favorites', async () => {
m(fetchFavoritesPage).mockResolvedValue({ items: [], next_cursor: null });
render(FavoritesPage);
await waitFor(() => expect(fetchFavoritesPage).toHaveBeenCalled());
});
it('unfavorites a row via the star button', async () => {
withOneFile();
m(removeFavorite).mockResolvedValue(undefined);
render(FavoritesPage);
await screen.findByText('photo.png');
await fireEvent.click(screen.getByTestId('resource-list-favorite-f1-btn'));
await waitFor(() => expect(removeFavorite).toHaveBeenCalledWith('file', 'f1'));
});
it('batch-deletes selected favorites after confirmation', async () => {
withOneFile();
confirmDialog.mockResolvedValue(true);
m(deleteFile).mockResolvedValue(undefined);
render(FavoritesPage);
await screen.findByText('photo.png');
await fireEvent.click(screen.getByTestId('resource-list-select-f1-checkbox'));
await fireEvent.click(await screen.findByTestId('favorites-batch-delete-btn'));
await waitFor(() => expect(deleteFile).toHaveBeenCalledWith('f1'));
});
@@ -1455,13 +1455,21 @@
>
<div class="page-sticky-header">
<!-- Hidden upload inputs stay mounted even while the batch bar is shown. -->
<input bind:this={fileInput} type="file" multiple hidden onchange={onUpload} />
<input
bind:this={fileInput}
type="file"
multiple
hidden
data-testid="files-upload-file-input"
onchange={onUpload}
/>
<input
bind:this={folderInput}
type="file"
multiple
hidden
webkitdirectory
data-testid="files-upload-folder-input"
onchange={onUploadFolder}
/>
@@ -1474,12 +1482,13 @@
>
{#snippet start()}
{#if selectedCount > 0}
<div class="action-buttons batch-selection-bar">
<div class="action-buttons batch-selection-bar" data-testid="files-batch-bar">
<div class="list-header-checkbox">
<button
class="batch-bar-close"
title={t('files.cancel_selection', 'Cancel selection')}
aria-label={t('files.cancel_selection', 'Cancel selection')}
data-testid="files-batch-cancel-btn"
onclick={clearSelection}
>
<Icon name="times" />
@@ -1493,22 +1502,34 @@
<button
class="batch-btn"
title={t('files.add_favorites', 'Add to favorites')}
data-testid="files-batch-favorite-btn"
onclick={() => void batchFavorites()}
>
<Icon name="star" />
<span>{t('files.add_favorites', 'Add to favorites')}</span>
</button>
<button class="batch-btn" title={t('files.move', 'Move')} onclick={batchMove}>
<button
class="batch-btn"
title={t('files.move', 'Move')}
data-testid="files-batch-move-btn"
onclick={batchMove}
>
<Icon name="arrows-alt" />
<span>{t('files.move', 'Move')}</span>
</button>
<button class="batch-btn" title={t('files.copy', 'Copy')} onclick={batchCopy}>
<button
class="batch-btn"
title={t('files.copy', 'Copy')}
data-testid="files-batch-copy-btn"
onclick={batchCopy}
>
<Icon name="copy" />
<span>{t('files.copy', 'Copy')}</span>
</button>
<button
class="batch-btn"
title={t('common.download', 'Download')}
data-testid="files-batch-download-btn"
onclick={() => void batchDownload()}
>
<Icon name="download" />
@@ -1517,6 +1538,7 @@
<button
class="batch-btn batch-btn-danger"
title={t('common.delete', 'Delete')}
data-testid="files-batch-delete-btn"
onclick={batchDelete}
>
<Icon name="trash" />
@@ -1527,9 +1549,10 @@
</div>
{:else}
<div class="action-buttons">
<div class="upload-dropdown">
<div class="upload-dropdown" data-testid="files-upload-dropdown">
<button
class="btn btn-primary"
data-testid="files-upload-btn"
onclick={() => (uploadMenuOpen = !uploadMenuOpen)}
disabled={uploading}
aria-haspopup="true"
@@ -1544,9 +1567,10 @@
<Icon name="caret-down" class="upload-caret" />
</button>
{#if uploadMenuOpen}
<div class="upload-dropdown-menu">
<div class="upload-dropdown-menu" data-testid="files-upload-menu">
<button
class="upload-dropdown-item"
data-testid="files-upload-files-item"
onclick={() => {
uploadMenuOpen = false;
fileInput?.click();
@@ -1557,6 +1581,7 @@
</button>
<button
class="upload-dropdown-item"
data-testid="files-upload-folder-item"
onclick={() => {
uploadMenuOpen = false;
folderInput?.click();
@@ -1568,7 +1593,11 @@
</div>
{/if}
</div>
<button class="btn btn-secondary" onclick={onNewFolder}>
<button
class="btn btn-secondary"
data-testid="files-new-folder-btn"
onclick={onNewFolder}
>
<Icon name="folder-plus" class="icon-mr" />
<span>{t('actions.new_folder', 'New folder')}</span>
</button>
@@ -1593,6 +1622,7 @@
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}`}
ondragover={(e) => e.dataTransfer?.types.includes(DRAG_TYPE) && e.preventDefault()}
ondrop={(e) => onCrumbDrop(e, c.id)}
>
@@ -1655,6 +1685,7 @@
<input
type="checkbox"
aria-label={t('files.select_all', 'Select all')}
data-testid="files-select-all-checkbox"
checked={selectedCount > 0 && selectedCount === totalCount}
indeterminate={selectedCount > 0 && selectedCount < totalCount}
onchange={toggleSelectAll}
@@ -1668,6 +1699,7 @@
class="list-header-sort"
class:is-active={sortField === col.f}
data-sort-field={col.f}
data-testid={`files-sort-${col.f}-btn`}
onclick={() => toggleSort(col.f as SortField)}
>
{col.l}
@@ -1700,6 +1732,8 @@
role="button"
tabindex="0"
draggable="true"
aria-label={folder.name}
data-testid={folder.name}
ondragstart={(e) => onItemDragStart(e, 'folder', folder.id, folder.name)}
ondragover={(e) => {
if (e.dataTransfer?.types.includes(DRAG_TYPE)) {
@@ -1723,6 +1757,7 @@
type="checkbox"
checked={selected.has(folder.id)}
aria-label={folder.name}
data-testid={`files-folder-checkbox-${folder.id}`}
onclick={(e) => {
e.stopPropagation();
toggleSelected(folder.id);
@@ -1760,6 +1795,7 @@
? t('files.unfavorite', 'Remove favorite')
: t('files.favorite', 'Add favorite')}
aria-pressed={favoriteIds.has(folder.id)}
data-testid={`files-folder-favorite-${folder.id}`}
onclick={(e) => {
e.stopPropagation();
void toggleFavorite('folder', folder.id);
@@ -1768,6 +1804,7 @@
<button
class="btn-action"
title={t('files.share', 'Share')}
data-testid={`files-folder-share-${folder.id}`}
onclick={(e) => {
e.stopPropagation();
openShare('folder', folder.id, folder.name);
@@ -1776,6 +1813,7 @@
<button
class="btn-action"
title={t('files.move', 'Move')}
data-testid={`files-folder-move-${folder.id}`}
onclick={(e) => {
e.stopPropagation();
openMove('folder', folder.id, folder.name);
@@ -1784,6 +1822,7 @@
<button
class="btn-action"
title={t('common.rename', 'Rename')}
data-testid={`files-folder-rename-${folder.id}`}
onclick={(e) => {
e.stopPropagation();
renameItem('folder', folder.id, folder.name);
@@ -1792,6 +1831,7 @@
<button
class="btn-action btn-action--delete"
title={t('common.delete', 'Delete')}
data-testid={`files-folder-delete-${folder.id}`}
onclick={(e) => {
e.stopPropagation();
deleteItem('folder', folder.id, folder.name);
@@ -1802,6 +1842,7 @@
title={t('files.more_actions', 'More actions')}
aria-label={t('files.more_actions', 'More actions')}
aria-haspopup="menu"
data-testid={`files-folder-more-${folder.id}`}
onclick={(e) => openContext(e, 'folder', folder.id, folder.name)}
><Icon name="ellipsis-v" /></button
>
@@ -1817,6 +1858,8 @@
role="button"
tabindex="0"
draggable="true"
aria-label={file.name}
data-testid={file.name}
ondragstart={(e) => onItemDragStart(e, 'file', file.id, file.name)}
ondblclick={() => openFile(file)}
onclick={(e) => {
@@ -1830,6 +1873,7 @@
type="checkbox"
checked={selected.has(file.id)}
aria-label={file.name}
data-testid={`files-file-checkbox-${file.id}`}
onclick={(e) => {
e.stopPropagation();
toggleSelected(file.id);
@@ -1881,6 +1925,7 @@
? t('files.unfavorite', 'Remove favorite')
: t('files.favorite', 'Add favorite')}
aria-pressed={favoriteIds.has(file.id)}
data-testid={`files-file-favorite-${file.id}`}
onclick={(e) => {
e.stopPropagation();
void toggleFavorite('file', file.id);
@@ -1889,6 +1934,7 @@
<button
class="btn-action"
title={t('files.share', 'Share')}
data-testid={`files-file-share-${file.id}`}
onclick={(e) => {
e.stopPropagation();
openShare('file', file.id, file.name);
@@ -1897,6 +1943,7 @@
<button
class="btn-action"
title={t('files.move', 'Move')}
data-testid={`files-file-move-${file.id}`}
onclick={(e) => {
e.stopPropagation();
openMove('file', file.id, file.name);
@@ -1908,11 +1955,13 @@
rel="external"
download
title={t('common.download', 'Download')}
data-testid={`files-file-download-${file.id}`}
onclick={(e) => e.stopPropagation()}><Icon name="download" /></a
>
<button
class="btn-action"
title={t('common.rename', 'Rename')}
data-testid={`files-file-rename-${file.id}`}
onclick={(e) => {
e.stopPropagation();
renameItem('file', file.id, file.name);
@@ -1921,6 +1970,7 @@
<button
class="btn-action btn-action--delete"
title={t('common.delete', 'Delete')}
data-testid={`files-file-delete-${file.id}`}
onclick={(e) => {
e.stopPropagation();
deleteItem('file', file.id, file.name);
@@ -1931,6 +1981,7 @@
title={t('files.more_actions', 'More actions')}
aria-label={t('files.more_actions', 'More actions')}
aria-haspopup="menu"
data-testid={`files-file-more-${file.id}`}
onclick={(e) => openContext(e, 'file', file.id, file.name)}
><Icon name="ellipsis-v" /></button
>
@@ -1977,14 +2028,22 @@
<div
class="ctx-scrim"
role="presentation"
data-testid="files-context-menu-scrim"
onclick={closeContext}
oncontextmenu={(e) => e.preventDefault()}
></div>
<div class="ctx-menu" style:left="{ctxX}px" style:top="{ctxY}px" role="menu">
<div
class="ctx-menu"
style:left="{ctxX}px"
style:top="{ctxY}px"
role="menu"
data-testid="files-context-menu"
>
{#if ctxTarget.kind === 'folder'}
<button
class="ctx-item"
role="menuitem"
data-testid="files-ctx-folder-open-item"
onclick={() => {
const id = ctxTarget!.id;
closeContext();
@@ -1994,6 +2053,7 @@
<button
class="ctx-item"
role="menuitem"
data-testid="files-ctx-download-zip-item"
onclick={() => {
const tg = ctxTarget!;
closeContext();
@@ -2004,6 +2064,7 @@
<button
class="ctx-item"
role="menuitem"
data-testid="files-ctx-file-open-item"
onclick={() => {
const f = listing.files.find((x) => x.id === ctxTarget!.id);
closeContext();
@@ -2014,6 +2075,7 @@
<button
class="ctx-item"
role="menuitem"
data-testid="files-ctx-edit-item"
onclick={() => {
const tg = ctxTarget!;
closeContext();
@@ -2023,6 +2085,7 @@
<button
class="ctx-item"
role="menuitem"
data-testid="files-ctx-edit-new-tab-item"
onclick={() => {
const tg = ctxTarget!;
closeContext();
@@ -2036,11 +2099,13 @@
href={fileDownloadUrl(ctxTarget.id)}
rel="external"
download
data-testid="files-ctx-download-item"
onclick={closeContext}><Icon name="download" /> {t('common.download', 'Download')}</a
>
<button
class="ctx-item"
role="menuitem"
data-testid="files-ctx-open-parent-item"
onclick={() => {
const f = listing.files.find((x) => x.id === ctxTarget!.id);
closeContext();
@@ -2051,6 +2116,7 @@
<button
class="ctx-item"
role="menuitem"
data-testid="files-ctx-add-playlist-item"
onclick={() => {
const f = listing.files.find((x) => x.id === ctxTarget!.id);
closeContext();
@@ -2062,6 +2128,7 @@
<button
class="ctx-item"
role="menuitem"
data-testid="files-ctx-share-item"
onclick={() => {
const tg = ctxTarget!;
closeContext();
@@ -2071,6 +2138,7 @@
<button
class="ctx-item"
role="menuitem"
data-testid="files-ctx-move-item"
onclick={() => {
const tg = ctxTarget!;
closeContext();
@@ -2080,6 +2148,7 @@
<button
class="ctx-item"
role="menuitem"
data-testid="files-ctx-copy-item"
onclick={() => {
const tg = ctxTarget!;
closeContext();
@@ -2089,6 +2158,7 @@
<button
class="ctx-item"
role="menuitem"
data-testid="files-ctx-favorite-item"
onclick={() => {
const tg = ctxTarget!;
closeContext();
@@ -2103,6 +2173,7 @@
<button
class="ctx-item"
role="menuitem"
data-testid="files-ctx-rename-item"
onclick={() => {
const tg = ctxTarget!;
closeContext();
@@ -2112,6 +2183,7 @@
<button
class="ctx-item ctx-item--danger"
role="menuitem"
data-testid="files-ctx-delete-item"
onclick={() => {
const tg = ctxTarget!;
closeContext();
+195
View File
@@ -0,0 +1,195 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { goto, pageState, session, ui, confirmDialog, promptDialog } = vi.hoisted(() => ({
goto: vi.fn(),
pageState: { params: { path: '' }, url: new URL('http://localhost/files') } as {
params: { path: string };
url: URL;
},
session: {
user: { id: 'me', username: 'admin', is_external: false },
isExternalUser: false,
loadHomeFolder: vi.fn(async () => 'home'),
refresh: vi.fn(async () => {})
},
ui: { notify: vi.fn() },
confirmDialog: vi.fn(),
promptDialog: vi.fn()
}));
vi.mock('$app/navigation', () => ({ goto }));
vi.mock('$app/state', () => ({ page: pageState }));
vi.mock('$lib/stores/session.svelte', () => ({ session }));
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog, promptDialog }));
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
vi.mock('$lib/api/endpoints/deltaUpload', () => ({ tryDeltaUpload: vi.fn() }));
vi.mock('$lib/api/endpoints/favorites', () => ({ addFavorite: vi.fn(), removeFavorite: vi.fn() }));
vi.mock('$lib/api/endpoints/wopi', () => ({
canEditWithWopi: () => false,
getEditorUrlWithFallback: vi.fn()
}));
vi.mock('$lib/api/endpoints/music', () => ({
addTracks: vi.fn(),
createPlaylist: vi.fn(),
listPlaylists: vi.fn(async () => [])
}));
vi.mock('$lib/api/endpoints/files', () => ({
deleteFile: vi.fn(),
fileDownloadUrl: () => '/dl',
fileThumbnailUrl: () => '/thumb',
moveFile: vi.fn(),
renameFile: vi.fn(),
uploadFile: vi.fn(),
uploadFileWithProgress: vi.fn()
}));
vi.mock('$lib/api/endpoints/folders', () => ({
cacheFolder: vi.fn(),
createFolder: vi.fn(),
deleteFolder: vi.fn(),
fetchFolderListing: vi.fn(),
folderZipUrl: () => '/zip',
getCachedFolder: () => undefined,
getFolder: vi.fn(async (id: string) => ({ id, name: id })),
getFolderName: () => undefined,
invalidateFolderCache: vi.fn(),
moveFolder: vi.fn(),
rememberFolderName: vi.fn(),
renameFolder: vi.fn()
}));
import { fetchFolderListing, createFolder, deleteFolder } from '$lib/api/endpoints/folders';
import { deleteFile } from '$lib/api/endpoints/files';
import { apiFetch } from '$lib/api/client';
import { files as filesStore } from '$lib/stores/files.svelte';
import FilesPage from './[...path]/+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
function withListing() {
m(fetchFolderListing).mockResolvedValue({
status: 200,
etag: 'v1',
listing: {
folders: [folderItem('sub1', 'Sub')],
files: [fileItem('f1', 'hello.txt')],
favoriteIds: [],
sharedIds: []
}
});
}
function fileItem(id: string, name: string) {
return {
category: 'Document',
created_at: 0,
icon_class: 'fa-file',
icon_special_class: '',
id,
mime_type: 'text/plain',
modified_at: 0,
name,
owner_id: 'me',
folder_id: 'home',
path: '/' + name,
size: 4,
size_formatted: '4 B',
sort_date: 0,
etag: 'e',
content_hash: 'h'
};
}
function folderItem(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();
// A concrete folder in the path: bare `/files` now canonicalizes to
// `/files/<drive-root>` via goto (see the external-user test), so the
// listing-oriented tests target a folder directly.
pageState.params.path = 'home';
// List view renders the select-all header + per-row checkboxes; grid hides them.
filesStore.viewMode = 'list';
});
it('loads the home folder listing on mount and renders its contents', async () => {
m(fetchFolderListing).mockResolvedValue({
status: 200,
etag: 'v1',
listing: {
folders: [folderItem('sub1', 'Sub')],
files: [fileItem('f1', 'hello.txt')],
favoriteIds: [],
sharedIds: []
}
});
render(FilesPage);
await waitFor(() => expect(fetchFolderListing).toHaveBeenCalledWith('home', expect.anything()));
// VirtualList windows rows by viewport height (0 in jsdom), so assert the
// surrounding chrome rendered rather than the windowed rows themselves.
await screen.findByTestId('files-new-folder-btn');
});
it('shows an error when the listing fails with no cache', async () => {
m(fetchFolderListing).mockRejectedValue(Object.assign(new Error('nope'), { status: 500 }));
render(FilesPage);
await waitFor(() => expect(fetchFolderListing).toHaveBeenCalled());
});
it('redirects external users away from the home folder', async () => {
session.isExternalUser = true;
pageState.params.path = '';
render(FilesPage);
await waitFor(() => expect(goto).toHaveBeenCalledWith('/shared-with-me', { replaceState: true }));
session.isExternalUser = false;
});
it('creates a new folder in the current directory', async () => {
withListing();
promptDialog.mockResolvedValue('Reports');
m(createFolder).mockResolvedValue({ id: 'new', name: 'Reports' });
render(FilesPage);
await fireEvent.click(await screen.findByTestId('files-new-folder-btn'));
await waitFor(() => expect(createFolder).toHaveBeenCalledWith('Reports', 'home'));
});
it('batch-deletes the whole selection after confirmation', async () => {
withListing();
confirmDialog.mockResolvedValue(true);
m(deleteFolder).mockResolvedValue(undefined);
m(deleteFile).mockResolvedValue(undefined);
render(FilesPage);
await fireEvent.click(await screen.findByTestId('files-select-all-checkbox'));
await fireEvent.click(await screen.findByTestId('files-batch-delete-btn'));
await waitFor(() => expect(deleteFolder).toHaveBeenCalledWith('sub1'));
await waitFor(() => expect(deleteFile).toHaveBeenCalledWith('f1'));
});
it('batch-favorites the selection via the favorites batch endpoint', async () => {
withListing();
m(apiFetch).mockResolvedValue({ ok: true });
render(FilesPage);
await fireEvent.click(await screen.findByTestId('files-select-all-checkbox'));
await fireEvent.click(await screen.findByTestId('files-batch-favorite-btn'));
await waitFor(() =>
expect(apiFetch).toHaveBeenCalledWith(
'/api/favorites/batch',
expect.objectContaining({ method: 'POST' })
)
);
});
+31 -9
View File
@@ -231,7 +231,8 @@
<main class="groups">
<header class="groups__head">
<h1>{t('nav.groups', 'Groups')}</h1>
<button class="btn btn--primary" onclick={onCreate}>{t('groups.create', 'Create group')}</button
<button class="btn btn--primary" data-testid="groups-create-btn" onclick={onCreate}
>{t('groups.create', 'Create group')}</button
>
</header>
@@ -247,7 +248,11 @@
{@const description = groupDescription(g)}
<li class="group">
<div class="group__row">
<button class="group__name" onclick={() => expand(g)}>
<button
class="group__name"
data-testid={`groups-expand-${g.id}`}
onclick={() => expand(g)}
>
<span class="avatar"><Icon name={groupIconName(g)} /></span>
<span class="group__text">
<span class="group__title">
@@ -264,10 +269,16 @@
</button>
{#if g.can_manage !== false && !g.is_virtual}
<div class="group__actions">
<button class="link-btn" onclick={() => onRename(g)}
>{t('common.rename', 'Rename')}</button
<button
class="link-btn"
data-testid={`groups-rename-${g.id}`}
onclick={() => onRename(g)}>{t('common.rename', 'Rename')}</button
>
<button
class="link-btn link-btn--danger"
data-testid={`groups-delete-${g.id}`}
onclick={() => onDelete(g)}
>
<button class="link-btn link-btn--danger" onclick={() => onDelete(g)}>
{t('common.delete', 'Delete')}
</button>
</div>
@@ -275,7 +286,7 @@
</div>
{#if expandedId === g.id}
<div class="members">
<div class="members" data-testid={`groups-members-panel-${g.id}`}>
<div class="members__head">
<h2>{t('groups.members', 'Members')}</h2>
</div>
@@ -284,6 +295,7 @@
<div class="add-member">
<input
class="add-member__input"
data-testid="groups-member-add-input"
placeholder={t('groups.add_member_search', 'Search users or groups to add…')}
bind:value={addQuery}
oninput={onAddQuery}
@@ -291,10 +303,14 @@
{#if addBusy}
<p class="muted">{t('common.loading', 'Loading…')}</p>
{:else if addResults.length > 0}
<ul class="add-member__results">
<ul class="add-member__results" data-testid="groups-member-add-results">
{#each addResults as r (r.type + r.id)}
<li>
<button class="add-member__opt" onclick={() => pickMember(g, r)}>
<button
class="add-member__opt"
data-testid={`groups-member-add-opt-${r.type}-${r.id}`}
onclick={() => pickMember(g, r)}
>
<span class="avatar avatar--sm">
<Icon name={r.type === 'group' ? 'user-group' : 'user'} />
</span>
@@ -338,6 +354,7 @@
{#if g.can_manage !== false && !g.is_virtual}
<button
class="link-btn link-btn--danger"
data-testid={`groups-member-remove-${m.kind}-${m.id}`}
onclick={() => onRemoveMember(g.id, m)}
>
{t('common.remove', 'Remove')}
@@ -354,7 +371,12 @@
</ul>
{#if hasMore}
<button class="btn load-more" disabled={loadingMore} onclick={loadMore}>
<button
class="btn load-more"
data-testid="groups-load-more-btn"
disabled={loadingMore}
onclick={loadMore}
>
{loadingMore ? t('common.loading', 'Loading…') : t('groups.load_more', 'Load more')}
</button>
{/if}
+56
View File
@@ -0,0 +1,56 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { ui, promptDialog } = vi.hoisted(() => ({ ui: { notify: vi.fn() }, promptDialog: vi.fn() }));
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
vi.mock('$lib/stores/dialogs.svelte', () => ({ promptDialog }));
vi.mock('$lib/api/endpoints/groups', () => ({
INTERNAL_GROUP_ID: '00000000-0000-0000-0000-000000000001',
createGroup: vi.fn(),
deleteGroup: vi.fn(),
addGroupMember: vi.fn(),
addUserMember: vi.fn(),
groupDescription: (g: { description?: string | null }) => g.description ?? null,
groupDisplayName: (g: { name: string }) => g.name,
groupIconName: () => 'fa-users',
listGroupsPage: vi.fn(),
listMembers: vi.fn(),
removeGroupMember: vi.fn(),
removeUserMember: vi.fn(),
renameGroup: vi.fn()
}));
vi.mock('$lib/api/endpoints/recipients', () => ({
ensureResolvers: vi.fn(),
resolveRecipient: (_t: string, id: string) => ({ id, label: id }),
searchRecipients: vi.fn(async () => [])
}));
import { listGroupsPage, createGroup, listMembers } from '$lib/api/endpoints/groups';
import GroupsPage from './+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
m(listMembers).mockResolvedValue([]);
});
it('renders groups returned by the API', async () => {
m(listGroupsPage).mockResolvedValue({
items: [{ id: 'g1', name: 'Engineers', member_count: 2 }],
total: 1
});
render(GroupsPage);
await waitFor(() => expect(listGroupsPage).toHaveBeenCalled());
await waitFor(() => expect(screen.getByText('Engineers')).toBeTruthy());
});
it('creates a new group', async () => {
m(listGroupsPage).mockResolvedValue({ items: [], total: 0 });
m(createGroup).mockResolvedValue(undefined);
promptDialog.mockResolvedValue('New Team');
render(GroupsPage);
await waitFor(() => expect(listGroupsPage).toHaveBeenCalled());
await fireEvent.click(screen.getByTestId('groups-create-btn'));
await waitFor(() => expect(createGroup).toHaveBeenCalledWith('New Team'));
});
+86 -15
View File
@@ -271,7 +271,7 @@
{#if mode === 'login'}
{#if passwordLoginEnabled}
{#if error}<div class="auth-error" style="display: block" role="alert">{error}</div>{/if}
<form class="auth-form" onsubmit={onLogin} novalidate>
<form class="auth-form" data-testid="login-form" onsubmit={onLogin} novalidate>
<div class="auth-input-group">
<label class="auth-label" for="login-username">
{t('auth.username', 'Username or email')}
@@ -280,6 +280,7 @@
<input
id="login-username"
class="auth-input"
data-testid="login-username-input"
type="text"
bind:value={username}
autocomplete="username"
@@ -296,6 +297,7 @@
<input
id="login-password"
class="auth-input"
data-testid="login-password-input"
type={showPassword ? 'text' : 'password'}
bind:value={password}
onkeydown={onPwKey}
@@ -308,6 +310,7 @@
type="button"
class="auth-pw-toggle"
aria-pressed={showPassword}
data-testid="login-password-toggle-btn"
aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (showPassword = !showPassword)}
></button>
@@ -317,12 +320,22 @@
{/if}
</div>
<button class="auth-button" type="submit" disabled={busy} aria-busy={busy}>
<button
class="auth-button"
type="submit"
data-testid="login-submit-btn"
disabled={busy}
aria-busy={busy}
>
{busy ? t('auth.signing_in', 'Signing in…') : t('auth.sign_in', 'Sign in')}
</button>
</form>
<button class="auth-magic-toggle" onclick={() => (magicOpen = !magicOpen)}>
<button
class="auth-magic-toggle"
data-testid="login-magic-toggle-btn"
onclick={() => (magicOpen = !magicOpen)}
>
{t('auth.magic_prompt', 'No password? Sign in with an email link')}
</button>
{#if magicOpen}
@@ -333,7 +346,7 @@
"No password? Enter your email and we'll send you a one-time sign-in link."
)}
</p>
<form class="auth-form" onsubmit={onMagicLink}>
<form class="auth-form" data-testid="login-magic-form" onsubmit={onMagicLink}>
<div class="auth-input-group">
<label class="auth-label" for="magic-email">
{t('auth.magic_email_label', 'Email address')}
@@ -342,6 +355,7 @@
<input
id="magic-email"
class="auth-input"
data-testid="login-magic-email-input"
type="email"
bind:value={magicEmail}
autocomplete="email"
@@ -349,7 +363,12 @@
/>
</div>
</div>
<button class="auth-button auth-button-secondary" type="submit" disabled={busy}>
<button
class="auth-button auth-button-secondary"
type="submit"
data-testid="login-magic-send-btn"
disabled={busy}
>
{t('auth.magic_send', 'Send link')}
</button>
</form>
@@ -371,7 +390,12 @@
<div class="auth-divider"><span>{t('auth.or', 'or')}</span></div>
{/if}
<!-- Backend OIDC authorize endpoint (not a SvelteKit route). -->
<a class="auth-button auth-button-oidc" href={oidc.authorize_endpoint} rel="external">
<a
class="auth-button auth-button-oidc"
data-testid="login-oidc-btn"
href={oidc.authorize_endpoint}
rel="external"
>
{t(
'auth.sso_login_provider',
{ provider: oidc.provider_name ?? 'SSO' },
@@ -383,7 +407,11 @@
{#if passwordLoginEnabled}
<div class="auth-toggle">
{t('auth.no_account', 'No account?')}
<button class="auth-toggle-link" onclick={() => (mode = 'register')}>
<button
class="auth-toggle-link"
data-testid="login-to-register-btn"
onclick={() => (mode = 'register')}
>
{t('auth.register', 'Create one')}
</button>
</div>
@@ -392,7 +420,11 @@
{#if setupAvailable}
<div class="auth-toggle">
{t('auth.admin_setup', 'First time?')}
<button class="auth-toggle-link" onclick={() => (mode = 'setup')}>
<button
class="auth-toggle-link"
data-testid="login-to-setup-btn"
onclick={() => (mode = 'setup')}
>
{t('auth.setup', 'Set up administrator')}
</button>
</div>
@@ -402,12 +434,13 @@
{regError}
</div>{/if}
{#if regSuccess}<div class="auth-success" style="display: block">{regSuccess}</div>{/if}
<form class="auth-form" onsubmit={onRegister} novalidate>
<form class="auth-form" data-testid="login-register-form" onsubmit={onRegister} novalidate>
<div class="auth-input-group">
<label class="auth-label" for="reg-username">{t('auth.username', 'Username')}</label>
<input
id="reg-username"
class="auth-input"
data-testid="login-register-username-input"
bind:value={regUsername}
required
disabled={busy}
@@ -418,6 +451,7 @@
<input
id="reg-email"
class="auth-input"
data-testid="login-register-email-input"
type="email"
bind:value={regEmail}
required
@@ -430,6 +464,7 @@
<input
id="reg-password"
class="auth-input"
data-testid="login-register-password-input"
type={regShowPassword ? 'text' : 'password'}
bind:value={regPassword}
onkeydown={onRegPwKey}
@@ -442,6 +477,7 @@
type="button"
class="auth-pw-toggle"
aria-pressed={regShowPassword}
data-testid="login-register-password-toggle-btn"
aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (regShowPassword = !regShowPassword)}
></button>
@@ -458,6 +494,7 @@
<input
id="reg-confirm"
class="auth-input"
data-testid="login-register-confirm-input"
type={regShowConfirm ? 'text' : 'password'}
bind:value={regConfirm}
onkeydown={onRegPwKey}
@@ -470,6 +507,7 @@
type="button"
class="auth-pw-toggle"
aria-pressed={regShowConfirm}
data-testid="login-register-confirm-toggle-btn"
aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (regShowConfirm = !regShowConfirm)}
></button>
@@ -484,13 +522,23 @@
</div>
{/if}
</div>
<button class="auth-button" type="submit" disabled={busy} aria-busy={busy}>
<button
class="auth-button"
type="submit"
data-testid="login-register-submit-btn"
disabled={busy}
aria-busy={busy}
>
{t('auth.register', 'Create account')}
</button>
</form>
<div class="auth-toggle">
{t('auth.have_account', 'Already have an account?')}
<button class="auth-toggle-link" onclick={() => (mode = 'login')}>
<button
class="auth-toggle-link"
data-testid="login-register-to-login-btn"
onclick={() => (mode = 'login')}
>
{t('auth.sign_in', 'Sign in')}
</button>
</div>
@@ -515,13 +563,20 @@
</div>{/if}
{#if setupSuccess}<div class="auth-success" style="display: block">{setupSuccess}</div>{/if}
<form class="auth-form" onsubmit={onSetup} novalidate>
<form class="auth-form" data-testid="login-setup-form" onsubmit={onSetup} novalidate>
<div class="auth-input-group">
<label class="auth-label" for="setup-username">
{t('auth.admin_username', 'Administrator username')}
</label>
<div class="auth-input-wrap auth-input-wrap--user">
<input id="setup-username" class="auth-input" type="text" value="admin" readonly />
<input
id="setup-username"
class="auth-input"
data-testid="login-setup-username-input"
type="text"
value="admin"
readonly
/>
</div>
</div>
@@ -533,6 +588,7 @@
<input
id="setup-email"
class="auth-input"
data-testid="login-setup-email-input"
type="email"
bind:value={setupEmail}
autocomplete="email"
@@ -550,6 +606,7 @@
<input
id="setup-password"
class="auth-input"
data-testid="login-setup-password-input"
type={setupShowPassword ? 'text' : 'password'}
bind:value={setupPassword}
onkeydown={onSetupPwKey}
@@ -563,6 +620,7 @@
type="button"
class="auth-pw-toggle"
aria-pressed={setupShowPassword}
data-testid="login-setup-password-toggle-btn"
aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (setupShowPassword = !setupShowPassword)}
></button>
@@ -580,6 +638,7 @@
<input
id="setup-confirm"
class="auth-input"
data-testid="login-setup-confirm-input"
type={setupShowConfirm ? 'text' : 'password'}
bind:value={setupConfirm}
onkeydown={onSetupPwKey}
@@ -592,6 +651,7 @@
type="button"
class="auth-pw-toggle"
aria-pressed={setupShowConfirm}
data-testid="login-setup-confirm-toggle-btn"
aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (setupShowConfirm = !setupShowConfirm)}
></button>
@@ -609,14 +669,24 @@
{/if}
</div>
<button class="auth-button" type="submit" disabled={busy} aria-busy={busy}>
<button
class="auth-button"
type="submit"
data-testid="login-setup-submit-btn"
disabled={busy}
aria-busy={busy}
>
{t('auth.create_admin', 'Create administrator')}
</button>
</form>
<div class="auth-toggle">
{t('auth.back_to_login', 'Already configured?')}
<button class="auth-toggle-link" onclick={() => (mode = 'login')}>
<button
class="auth-toggle-link"
data-testid="login-setup-to-login-btn"
onclick={() => (mode = 'login')}
>
{t('auth.sign_in', 'Sign in')}
</button>
</div>
@@ -626,6 +696,7 @@
<div class="auth-lang">
<select
aria-label={t('settings.language', 'Language')}
data-testid="login-language-select"
value={i18n.locale}
onchange={(e) => setLocale(e.currentTarget.value as Locale)}
>
+157
View File
@@ -0,0 +1,157 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { goto, pageState, session } = vi.hoisted(() => ({
goto: vi.fn(),
pageState: { url: new URL('http://localhost/login') } as { url: URL },
session: { user: null } as { user: unknown }
}));
vi.mock('$app/navigation', () => ({ goto }));
vi.mock('$app/state', () => ({ page: pageState }));
vi.mock('$lib/stores/session.svelte', () => ({ session }));
vi.mock('$lib/api/endpoints/auth', () => ({
exchangeOidcCode: vi.fn(),
fetchMe: vi.fn(),
getOidcProviders: vi.fn(),
getAuthStatus: vi.fn(),
login: vi.fn(),
register: vi.fn(),
sendMagicLink: vi.fn(),
setupAdmin: vi.fn()
}));
import * as auth from '$lib/api/endpoints/auth';
import LoginPage from './+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
pageState.url = new URL('http://localhost/login');
session.user = null;
m(auth.fetchMe).mockResolvedValue(null);
m(auth.getOidcProviders).mockResolvedValue({ providers: [] });
m(auth.getAuthStatus).mockResolvedValue({ initialized: true });
});
it('logs in and redirects', async () => {
m(auth.login).mockResolvedValue({ user: { id: '1' } });
render(LoginPage);
await screen.findByTestId('login-form');
await fireEvent.input(screen.getByTestId('login-username-input'), { target: { value: 'admin' } });
await fireEvent.input(screen.getByTestId('login-password-input'), { target: { value: 'pw' } });
await fireEvent.click(screen.getByTestId('login-submit-btn'));
await waitFor(() => expect(auth.login).toHaveBeenCalled());
});
it('exchanges an oidc code on mount and redirects', async () => {
pageState.url = new URL('http://localhost/login?oidc_code=abc');
m(auth.exchangeOidcCode).mockResolvedValue({ id: '1' });
render(LoginPage);
await waitFor(() => expect(auth.exchangeOidcCode).toHaveBeenCalledWith('abc'));
await waitFor(() => expect(goto).toHaveBeenCalled());
});
it('skips the form when already authenticated', async () => {
m(auth.fetchMe).mockResolvedValue({ id: '1' });
render(LoginPage);
await waitFor(() => expect(goto).toHaveBeenCalled());
});
it('enters setup mode on a fresh install', async () => {
m(auth.getAuthStatus).mockResolvedValue({ initialized: false });
render(LoginPage);
await screen.findByTestId('login-setup-form');
});
it('sends a magic link', async () => {
m(auth.sendMagicLink).mockResolvedValue('sent');
render(LoginPage);
await screen.findByTestId('login-form');
await fireEvent.click(screen.getByTestId('login-magic-toggle-btn'));
await fireEvent.input(screen.getByTestId('login-magic-email-input'), {
target: { value: 'a@b.test' }
});
await fireEvent.click(screen.getByTestId('login-magic-send-btn'));
await waitFor(() => expect(auth.sendMagicLink).toHaveBeenCalledWith('a@b.test'));
});
it('registers a new account', async () => {
m(auth.register).mockResolvedValue(undefined);
render(LoginPage);
await screen.findByTestId('login-form');
await fireEvent.click(screen.getByTestId('login-to-register-btn'));
await fireEvent.input(screen.getByTestId('login-register-username-input'), {
target: { value: 'u' }
});
await fireEvent.input(screen.getByTestId('login-register-email-input'), {
target: { value: 'u@b.test' }
});
await fireEvent.input(screen.getByTestId('login-register-password-input'), {
target: { value: 'TestPassword1!' }
});
await fireEvent.input(screen.getByTestId('login-register-confirm-input'), {
target: { value: 'TestPassword1!' }
});
await fireEvent.click(screen.getByTestId('login-register-submit-btn'));
await waitFor(() => expect(auth.register).toHaveBeenCalled());
});
it('shows an error message when login fails', async () => {
m(auth.login).mockRejectedValue(new Error('bad credentials'));
render(LoginPage);
await screen.findByTestId('login-form');
await fireEvent.input(screen.getByTestId('login-username-input'), { target: { value: 'admin' } });
await fireEvent.input(screen.getByTestId('login-password-input'), { target: { value: 'wrong' } });
await fireEvent.click(screen.getByTestId('login-submit-btn'));
await waitFor(() => expect(screen.getByText('bad credentials')).toBeTruthy());
});
it('rejects a registration with mismatched passwords without calling the API', async () => {
render(LoginPage);
await screen.findByTestId('login-form');
await fireEvent.click(screen.getByTestId('login-to-register-btn'));
await fireEvent.input(screen.getByTestId('login-register-username-input'), {
target: { value: 'u' }
});
await fireEvent.input(screen.getByTestId('login-register-password-input'), {
target: { value: 'TestPassword1!' }
});
await fireEvent.input(screen.getByTestId('login-register-confirm-input'), {
target: { value: 'Different1!' }
});
await fireEvent.click(screen.getByTestId('login-register-submit-btn'));
expect(auth.register).not.toHaveBeenCalled();
});
it('creates the first administrator in setup mode', async () => {
m(auth.getAuthStatus).mockResolvedValue({ initialized: false });
m(auth.setupAdmin).mockResolvedValue(undefined);
render(LoginPage);
await screen.findByTestId('login-setup-form');
await fireEvent.input(screen.getByTestId('login-setup-email-input'), {
target: { value: 'admin@x.test' }
});
await fireEvent.input(screen.getByTestId('login-setup-password-input'), {
target: { value: 'TestPassword1!' }
});
await fireEvent.input(screen.getByTestId('login-setup-confirm-input'), {
target: { value: 'TestPassword1!' }
});
await fireEvent.click(screen.getByTestId('login-setup-submit-btn'));
await waitFor(() =>
expect(auth.setupAdmin).toHaveBeenCalledWith('admin@x.test', 'TestPassword1!')
);
});
it('renders an SSO sign-in link when an OIDC provider is configured', async () => {
m(auth.getOidcProviders).mockResolvedValue({
enabled: true,
authorize_endpoint: 'https://idp.test/auth',
provider_name: 'Acme SSO',
password_login_enabled: true
});
render(LoginPage);
const sso = await screen.findByTestId('login-oidc-btn');
expect(sso.getAttribute('href')).toBe('https://idp.test/auth');
});
+72 -8
View File
@@ -512,7 +512,11 @@
<p class="music-empty-state-desc">
{t('music.empty_hint', 'Create a playlist to start collecting your tracks.')}
</p>
<button class="btn btn-primary" onclick={onCreate}>
<button
class="btn btn-primary"
data-testid="music-create-playlist-empty-btn"
onclick={onCreate}
>
<Icon name="plus" />
<span>{t('music.create_playlist', 'Create playlist')}</span>
</button>
@@ -526,6 +530,7 @@
class="music-sidebar-add-btn"
title={t('music.create_playlist', 'Create playlist')}
aria-label={t('music.create_playlist', 'Create playlist')}
data-testid="music-create-playlist-btn"
onclick={onCreate}
>
<Icon name="plus" />
@@ -538,6 +543,7 @@
<div
class="music-playlist-item"
class:active={current?.id === p.id}
data-testid={p.name}
onclick={() => select(p)}
>
<div class="music-playlist-icon"><Icon name="music" /></div>
@@ -561,6 +567,7 @@
class="music-playlist-cover"
title={t('music.set_cover', 'Set cover')}
aria-label={t('music.set_cover', 'Set cover')}
data-testid="music-set-cover-btn"
onclick={pickCover}
>
{#if coverUrl(current)}
@@ -585,12 +592,18 @@
</div>
<div class="music-playlist-actions">
<button class="btn btn-secondary" onclick={playAll} disabled={tracks.length === 0}>
<button
class="btn btn-secondary"
data-testid="music-play-all-btn"
onclick={playAll}
disabled={tracks.length === 0}
>
<Icon name="play" />
<span>{t('music.play_all', 'Play all')}</span>
</button>
<button
class="btn btn-secondary"
data-testid="music-shuffle-play-btn"
onclick={shufflePlay}
disabled={tracks.length === 0}
title={t('music.shuffle', 'Shuffle')}
@@ -598,12 +611,17 @@
>
<Icon name="shuffle" />
</button>
<button class="btn btn-secondary" onclick={openAdd}>
<button
class="btn btn-secondary"
data-testid="music-add-tracks-btn"
onclick={openAdd}
>
<Icon name="plus" />
<span>{t('music.add_tracks', 'Add tracks')}</span>
</button>
<button
class="btn btn-secondary"
data-testid="music-rename-playlist-btn"
onclick={onRenamePlaylist}
title={t('common.rename', 'Rename')}
aria-label={t('common.rename', 'Rename')}
@@ -612,6 +630,7 @@
</button>
<button
class="btn btn-secondary"
data-testid="music-edit-description-btn"
onclick={onEditDescription}
title={t('music.edit_description', 'Edit description')}
aria-label={t('music.edit_description', 'Edit description')}
@@ -620,6 +639,7 @@
</button>
<button
class="btn btn-secondary"
data-testid="music-manage-shares-btn"
onclick={openShares}
title={t('music.manage_shares', 'Manage shares')}
aria-label={t('music.manage_shares', 'Manage shares')}
@@ -629,6 +649,7 @@
<button
class="btn btn-secondary"
class:active={current.is_public}
data-testid="music-toggle-public-btn"
onclick={onTogglePublic}
title={current.is_public
? t('music.make_private', 'Make private')
@@ -641,6 +662,7 @@
</button>
<button
class="btn btn-secondary"
data-testid="music-delete-playlist-btn"
onclick={() => onDelete(current!)}
title={t('common.delete', 'Delete')}
aria-label={t('common.delete', 'Delete')}
@@ -673,6 +695,7 @@
<div
class="music-track"
class:playing={currentTrack?.id === track.id && playing}
data-testid={trackLabel(track)}
draggable="true"
ondblclick={() => playFromTracks(i)}
ondragstart={() => onDragStart(i)}
@@ -687,6 +710,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
data-testid={`music-track-play-${track.id}`}
onclick={(e) => {
e.stopPropagation();
if (currentTrack?.id === track.id) togglePlay();
@@ -714,6 +738,7 @@
class="music-track-remove-btn"
title={t('common.remove', 'Remove')}
aria-label={t('common.remove', 'Remove')}
data-testid={`music-track-remove-${track.id}`}
onclick={(e) => {
e.stopPropagation();
onRemoveTrack(track);
@@ -763,6 +788,7 @@
class:active={shuffle}
title={t('music.shuffle', 'Shuffle')}
aria-label={t('music.shuffle', 'Shuffle')}
data-testid="music-player-shuffle-btn"
onclick={() => (shuffle = !shuffle)}
>
<Icon name="shuffle" />
@@ -771,6 +797,7 @@
class="player-btn"
title={t('music.prev', 'Previous')}
aria-label={t('music.prev', 'Previous')}
data-testid="music-player-prev-btn"
onclick={prev}
>
<Icon name="backward" />
@@ -779,6 +806,7 @@
class="player-btn player-btn-main"
title={t('music.play', 'Play')}
aria-label={t('music.play', 'Play')}
data-testid="music-player-play-btn"
onclick={togglePlay}
>
<Icon name={playing ? 'pause' : 'play'} />
@@ -787,6 +815,7 @@
class="player-btn"
title={t('music.next', 'Next')}
aria-label={t('music.next', 'Next')}
data-testid="music-player-next-btn"
onclick={next}
>
<Icon name="forward" />
@@ -797,6 +826,7 @@
class:repeat-one={repeat === 'one'}
title={t('music.repeat', 'Repeat')}
aria-label={t('music.repeat', 'Repeat')}
data-testid="music-player-repeat-btn"
onclick={cycleRepeat}
>
<Icon name="repeat" />
@@ -813,6 +843,7 @@
value={currentTime}
oninput={seek}
aria-label={t('music.seek', 'Seek')}
data-testid="music-player-seek-input"
/>
<span class="player-time player-time-total">{fmtTime(duration)}</span>
</div>
@@ -824,6 +855,7 @@
class:active={queueOpen}
title={t('music.queue', 'Queue')}
aria-label={t('music.queue', 'Queue')}
data-testid="music-player-queue-toggle-btn"
onclick={() => (queueOpen = !queueOpen)}
>
<Icon name="list" />
@@ -832,6 +864,7 @@
class="player-btn player-btn-small"
title={t('music.mute', 'Mute')}
aria-label={t('music.mute', 'Mute')}
data-testid="music-player-mute-btn"
onclick={toggleMute}
>
<Icon name={volumeIcon} />
@@ -846,17 +879,19 @@
value={muted ? 0 : volume}
oninput={setVolume}
aria-label={t('music.volume', 'Volume')}
data-testid="music-player-volume-input"
/>
</div>
</div>
</div>
{#if queueOpen}
<div class="player-queue">
<div class="player-queue" data-testid="music-queue-panel">
<div class="player-queue-header">
<h3>{t('music.queue', 'Queue')}</h3>
<button
class="player-btn player-btn-small"
data-testid="music-queue-close-btn"
onclick={() => (queueOpen = false)}
aria-label={t('common.close', 'Close')}
>
@@ -876,6 +911,7 @@
<div
class="player-queue-item"
class:active={i === currentIndex}
data-testid={trackLabel(qt)}
onclick={() => jumpQueue(i)}
>
<span class="queue-item-num">{i + 1}</span>
@@ -887,6 +923,7 @@
<button
class="queue-item-remove"
aria-label={t('common.remove', 'Remove')}
data-testid={`music-queue-remove-${qt.id}`}
onclick={(e) => {
e.stopPropagation();
removeFromQueue(i);
@@ -917,6 +954,7 @@
type="file"
accept="image/*"
class="hidden-input"
data-testid="music-cover-input"
onchange={onCoverChosen}
/>
@@ -925,6 +963,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="music-picker-overlay active"
data-testid="music-add-tracks-dialog"
onclick={(e) => {
if (e.target === e.currentTarget) addOpen = false;
}}
@@ -935,6 +974,7 @@
<button
class="music-picker-close"
aria-label={t('common.close', 'Close')}
data-testid="music-add-tracks-close-btn"
onclick={() => (addOpen = false)}>&times;</button
>
</div>
@@ -948,6 +988,7 @@
oninput={onAddQueryInput}
autocomplete="off"
autofocus
data-testid="music-add-tracks-search-input"
/>
</div>
<div class="music-picker-list">
@@ -968,6 +1009,7 @@
type="checkbox"
checked={addSelected.has(f.id)}
onchange={() => addSelected.toggle(f.id)}
data-testid={f.name}
/>
<Icon name="file-audio" />
<span class="music-picker-name" title={f.name}>{f.name}</span>
@@ -980,10 +1022,19 @@
{t('music.selected_count', { n: addSelected.size }, '{{n}} selected')}
</span>
<div class="music-picker-actions">
<button class="btn btn-secondary" onclick={() => (addOpen = false)}>
<button
class="btn btn-secondary"
data-testid="music-add-tracks-cancel-btn"
onclick={() => (addOpen = false)}
>
{t('common.cancel', 'Cancel')}
</button>
<button class="btn btn-primary" disabled={addSelected.size === 0} onclick={confirmAdd}>
<button
class="btn btn-primary"
disabled={addSelected.size === 0}
data-testid="music-add-tracks-confirm-btn"
onclick={confirmAdd}
>
<Icon name="plus" />
{t('music.add_selected', 'Add selected')}
</button>
@@ -998,6 +1049,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="music-shares-overlay"
data-testid="music-shares-dialog"
onclick={(e) => {
if (e.target === e.currentTarget) sharesOpen = false;
}}
@@ -1008,6 +1060,7 @@
<button
class="music-shares-close-btn"
aria-label={t('common.close', 'Close')}
data-testid="music-shares-close-btn"
onclick={() => (sharesOpen = false)}
>
<Icon name="times" />
@@ -1029,6 +1082,7 @@
class="music-share-remove-btn"
title={t('music.remove_share', 'Remove')}
aria-label={t('music.remove_share', 'Remove')}
data-testid={`music-share-remove-${s.user_id}`}
onclick={() => onRemoveShare(s.user_id)}
>
<Icon name="times" />
@@ -1044,12 +1098,22 @@
placeholder={t('music.share_with_user', 'User ID or email')}
bind:value={shareUser}
autocomplete="off"
data-testid="music-share-user-input"
/>
<label class="music-shares-write-label">
<input type="checkbox" bind:checked={shareCanWrite} />
<input
type="checkbox"
bind:checked={shareCanWrite}
data-testid="music-share-can-write-checkbox"
/>
{t('music.can_write', 'Can edit')}
</label>
<button class="btn btn-primary btn-sm" disabled={!shareUser.trim()} onclick={onAddShare}>
<button
class="btn btn-primary btn-sm"
disabled={!shareUser.trim()}
data-testid="music-share-add-btn"
onclick={onAddShare}
>
<Icon name="plus" />
{t('music.share', 'Share')}
</button>
+119
View File
@@ -0,0 +1,119 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { ui, promptDialog, confirmDialog } = vi.hoisted(() => ({
ui: { notify: vi.fn() },
promptDialog: vi.fn(),
confirmDialog: vi.fn()
}));
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
vi.mock('$lib/stores/dialogs.svelte', () => ({ promptDialog, confirmDialog }));
vi.mock('$lib/api/endpoints/files', () => ({ fileInlineUrl: () => '/in' }));
vi.mock('$lib/api/endpoints/search', () => ({ searchFiles: vi.fn(async () => ({ items: [] })) }));
vi.mock('$lib/api/endpoints/music', () => ({
addTracks: vi.fn(),
createPlaylist: vi.fn(),
deletePlaylist: vi.fn(),
listPlaylists: vi.fn(),
listShares: vi.fn(async () => []),
listTracks: vi.fn(async () => []),
removeShare: vi.fn(),
removeTrack: vi.fn(),
renamePlaylist: vi.fn(),
reorderTracks: vi.fn(),
sharePlaylist: vi.fn(),
updatePlaylist: vi.fn(),
uploadCoverImage: vi.fn()
}));
import {
listPlaylists,
listTracks,
createPlaylist,
renamePlaylist,
deletePlaylist
} from '$lib/api/endpoints/music';
import MusicPage from './+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
const playlist = {
id: 'p1',
name: 'Roadtrip',
description: 'songs',
owner_id: 'me',
is_public: false,
cover_file_id: null,
track_count: 1,
total_duration_secs: 180,
created_at: 0,
updated_at: 0
};
const track = {
id: 't1',
playlist_id: 'p1',
file_id: 'f1',
position: 0,
added_at: 0,
file_name: 'song.mp3',
file_size: 100,
mime_type: 'audio/mpeg',
title: 'Song',
artist: 'Artist',
album: 'Album',
duration_secs: 180
};
beforeEach(() => {
vi.clearAllMocks();
m(listTracks).mockResolvedValue([track]);
});
it('renders an empty state when there are no playlists', async () => {
m(listPlaylists).mockResolvedValue([]);
render(MusicPage);
await waitFor(() => expect(listPlaylists).toHaveBeenCalled());
await screen.findByTestId('music-create-playlist-empty-btn');
});
it('selects the first playlist and loads its tracks', async () => {
m(listPlaylists).mockResolvedValue([playlist]);
render(MusicPage);
await waitFor(() => expect(listTracks).toHaveBeenCalledWith('p1'));
await waitFor(() => expect(screen.getAllByText('Roadtrip').length).toBeGreaterThan(0));
});
it('creates a playlist from the header button', async () => {
m(listPlaylists).mockResolvedValue([playlist]);
promptDialog.mockResolvedValue('Chill');
m(createPlaylist).mockResolvedValue({ ...playlist, id: 'p2', name: 'Chill' });
render(MusicPage);
await screen.findByTestId('music-create-playlist-btn');
await fireEvent.click(screen.getByTestId('music-create-playlist-btn'));
await waitFor(() => expect(createPlaylist).toHaveBeenCalledWith('Chill'));
});
it('surfaces a load error', async () => {
m(listPlaylists).mockRejectedValue(new Error('boom'));
render(MusicPage);
await waitFor(() => expect(listPlaylists).toHaveBeenCalled());
await waitFor(() => expect(screen.getByText('boom')).toBeTruthy());
});
it('renames the current playlist', async () => {
m(listPlaylists).mockResolvedValue([playlist]);
promptDialog.mockResolvedValue('Renamed');
m(renamePlaylist).mockResolvedValue(undefined);
render(MusicPage);
await fireEvent.click(await screen.findByTestId('music-rename-playlist-btn'));
await waitFor(() => expect(renamePlaylist).toHaveBeenCalledWith('p1', 'Renamed'));
});
it('deletes the current playlist after confirmation', async () => {
m(listPlaylists).mockResolvedValue([playlist]);
confirmDialog.mockResolvedValue(true);
m(deletePlaylist).mockResolvedValue(undefined);
render(MusicPage);
await fireEvent.click(await screen.findByTestId('music-delete-playlist-btn'));
await waitFor(() => expect(deletePlaylist).toHaveBeenCalledWith('p1'));
});
@@ -70,7 +70,12 @@
<Icon name="ban" class="nc-status__icon nc-status__icon--err" />
<h1>{view.title}</h1>
<p>{view.message}</p>
<button type="button" class="nc-status__action" onclick={onAction}>{view.actionLabel}</button>
<button
type="button"
class="nc-status__action"
data-testid="nextcloud-error-action-btn"
onclick={onAction}>{view.actionLabel}</button
>
</main>
<style>
@@ -52,7 +52,12 @@
</div>
{:else}
{#if passwordLoginEnabled}
<form class="auth-form" method="post" action={formAction}>
<form
class="auth-form"
data-testid="nextcloud-login-form"
method="post"
action={formAction}
>
<div class="auth-input-group">
<label class="auth-label" for="nc-user">{t('auth.username', 'Username or email')}</label
>
@@ -60,6 +65,7 @@
<input
id="nc-user"
class="auth-input"
data-testid="nextcloud-login-user-input"
name="user"
type="text"
autocomplete="username"
@@ -73,6 +79,7 @@
<input
id="nc-password"
class="auth-input"
data-testid="nextcloud-login-password-input"
name="password"
type="password"
autocomplete="current-password"
@@ -80,7 +87,9 @@
/>
</div>
</div>
<button class="auth-button" type="submit">{t('nextcloud.grant', 'Grant access')}</button>
<button class="auth-button" data-testid="nextcloud-login-grant-btn" type="submit"
>{t('nextcloud.grant', 'Grant access')}</button
>
</form>
{/if}
@@ -89,7 +98,12 @@
<div class="auth-divider"><span>{t('auth.or', 'or')}</span></div>
{/if}
<!-- Backend Nextcloud Login Flow v2 OIDC handshake (not a SvelteKit route). -->
<a class="auth-button auth-button-sso" href={`/login/v2/flow/${token}/oidc`} rel="external">
<a
class="auth-button auth-button-sso"
data-testid="nextcloud-login-sso-link"
href={`/login/v2/flow/${token}/oidc`}
rel="external"
>
{t('nextcloud.sign_in_with', { provider: oidcProvider }, 'Sign in with {{provider}}')}
</a>
{/if}
@@ -22,7 +22,12 @@
<Icon name="check" class="nc-status__icon nc-status__icon--ok" />
<h1>{t('nextcloud.success_title', 'Access granted')}</h1>
<p>{t('nextcloud.success_body', 'You can now return to your application — it is connected.')}</p>
<button type="button" class="nc-status__action" onclick={closeWindow}>
<button
type="button"
class="nc-status__action"
data-testid="nextcloud-success-close-btn"
onclick={closeWindow}
>
{t('nextcloud.close_window', 'Close Window')}
</button>
</main>
+21 -5
View File
@@ -328,6 +328,7 @@
class:active={tab === 'moments'}
role="tab"
aria-selected={tab === 'moments'}
data-testid="photos-tab-moments"
onclick={() => (tab = 'moments')}
>
{t('photos.tab_moments', 'Moments')}
@@ -337,6 +338,7 @@
class:active={tab === 'places'}
role="tab"
aria-selected={tab === 'places'}
data-testid="photos-tab-places"
onclick={() => (tab = 'places')}
>
{t('photos.tab_places', 'Places')}
@@ -347,6 +349,7 @@
class:active={tab === 'people'}
role="tab"
aria-selected={tab === 'people'}
data-testid="photos-tab-people"
onclick={() => (tab = 'people')}
>
{t('photos.tab_people', 'People')}
@@ -370,6 +373,7 @@
class:active={layoutMode === 'square'}
title={t('photos.layout_square', 'Grid')}
aria-label={t('photos.layout_square', 'Grid')}
data-testid="photos-layout-square-btn"
onclick={() => setLayoutMode('square')}><Icon name="th" /></button
>
<button
@@ -377,18 +381,25 @@
class:active={layoutMode === 'justified'}
title={t('photos.layout_justified', 'Justified')}
aria-label={t('photos.layout_justified', 'Justified')}
data-testid="photos-layout-justified-btn"
onclick={() => setLayoutMode('justified')}><Icon name="layer-group" /></button
>
</div>
</div>
{#if selected.size > 0}
<div class="batch-bar">
<div class="batch-bar" data-testid="photos-batch-bar">
<span>{t('files.selected_count', { count: selected.size }, '{{count}} selected')}</span>
<div class="batch-bar__actions">
<Button onclick={downloadSelected}>{t('common.download', 'Download')}</Button>
<Button onclick={() => selected.clear()}>{t('common.clear', 'Clear')}</Button>
<Button variant="danger" onclick={trashSelected}>{t('common.delete', 'Delete')}</Button>
<Button data-testid="photos-batch-download-btn" onclick={downloadSelected}
>{t('common.download', 'Download')}</Button
>
<Button data-testid="photos-batch-clear-btn" onclick={() => selected.clear()}
>{t('common.clear', 'Clear')}</Button
>
<Button data-testid="photos-batch-delete-btn" variant="danger" onclick={trashSelected}
>{t('common.delete', 'Delete')}</Button
>
</div>
</div>
{/if}
@@ -449,7 +460,11 @@
{#snippet tile(photo: PhotoItem, sizeStyle?: string)}
<div class="photo-tile" class:selected={selected.has(photo.id)} style={sizeStyle}>
<button class="photo-tile__open" onclick={() => onTileClick(photo)}>
<button
class="photo-tile__open"
data-testid={`photo-tile-${photo.id}`}
onclick={() => onTileClick(photo)}
>
<!-- Always-present placeholder: the thumbnail <img> overlays it and, when
it can't load (no server thumbnail, e.g. SVG), hides itself to reveal
this default rather than the browser's broken-image glyph. -->
@@ -471,6 +486,7 @@
class="photo-tile__check"
class:on={selected.has(photo.id)}
aria-label={t('common.select', 'Select')}
data-testid={`photo-tile-check-${photo.id}`}
onclick={() => selected.toggle(photo.id)}
>
<Icon name="check" />
+88
View File
@@ -0,0 +1,88 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/svelte';
const { ui, confirmDialog } = vi.hoisted(() => ({
ui: { notify: vi.fn() },
confirmDialog: vi.fn()
}));
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog }));
vi.mock('$lib/api/endpoints/photos', () => ({
batchTrash: vi.fn(),
fetchPhotos: vi.fn(),
uploadThumbnail: vi.fn()
}));
vi.mock('$lib/api/endpoints/people', () => ({ peopleEnabled: vi.fn() }));
vi.mock('$lib/api/endpoints/files', () => ({
fileDownloadUrl: () => '/dl',
fileThumbnailUrl: () => '/thumb'
}));
import { fetchPhotos } from '$lib/api/endpoints/photos';
import { peopleEnabled } from '$lib/api/endpoints/people';
import PhotosPage from './+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
function photo(id: string) {
return {
category: 'Image',
created_at: 0,
icon_class: 'fa-image',
icon_special_class: '',
id,
mime_type: 'image/jpeg',
modified_at: 0,
name: id + '.jpg',
owner_id: 'me',
folder_id: 'home',
path: '/' + id + '.jpg',
size: 100,
size_formatted: '100 B',
sort_date: 1_700_000_000,
etag: 'e',
content_hash: 'h',
width: 100,
height: 100
};
}
beforeEach(() => {
vi.clearAllMocks();
m(peopleEnabled).mockResolvedValue(false);
});
it('loads the first page of photos on mount', async () => {
m(fetchPhotos).mockResolvedValue({ items: [photo('a')], nextCursor: null });
render(PhotosPage);
await waitFor(() => expect(fetchPhotos).toHaveBeenCalled());
expect(m(fetchPhotos).mock.calls[0][0]).toBe(60);
});
it('shows an empty state when there are no photos', async () => {
m(fetchPhotos).mockResolvedValue({ items: [], nextCursor: null });
render(PhotosPage);
await waitFor(() => expect(fetchPhotos).toHaveBeenCalled());
await waitFor(() => expect(screen.getByTestId('photos-tab-moments')).toBeTruthy());
});
it('surfaces a load error', async () => {
m(fetchPhotos).mockRejectedValue(new Error('photos boom'));
render(PhotosPage);
await waitFor(() => expect(screen.getByText('photos boom')).toBeTruthy());
});
it('hides the People tab when face detection is disabled', async () => {
m(fetchPhotos).mockResolvedValue({ items: [], nextCursor: null });
m(peopleEnabled).mockResolvedValue(false);
render(PhotosPage);
await waitFor(() => expect(peopleEnabled).toHaveBeenCalled());
expect(screen.queryByTestId('photos-tab-people')).toBeNull();
});
it('shows the People tab when face detection is enabled', async () => {
m(fetchPhotos).mockResolvedValue({ items: [], nextCursor: null });
m(peopleEnabled).mockResolvedValue(true);
render(PhotosPage);
await waitFor(() => expect(screen.getByTestId('photos-tab-people')).toBeTruthy());
});
+86 -19
View File
@@ -318,6 +318,7 @@
{#if canEditImage}
<button
class="btn btn-secondary avatar-edit-btn"
data-testid="profile-avatar-edit-btn"
title={t('profile.edit_photo', 'Edit photo')}
onclick={openAvatarEdit}
>
@@ -327,10 +328,11 @@
</div>
{#if canEditImage && avatarEditOpen}
<div class="avatar-edit">
<div class="avatar-edit" data-testid="profile-avatar-edit-panel">
<div class="avatar-tabs">
<button
class="avatar-tab"
data-testid="profile-avatar-url-tab"
class:avatar-tab--active={avatarTab === 'url'}
onclick={() => (avatarTab = 'url')}
>
@@ -338,6 +340,7 @@
</button>
<button
class="avatar-tab"
data-testid="profile-avatar-upload-tab"
class:avatar-tab--active={avatarTab === 'upload'}
onclick={() => (avatarTab = 'upload')}
>
@@ -346,7 +349,12 @@
</div>
{#if avatarTab === 'url'}
<input type="url" bind:value={avatarUrl} placeholder="https://example.com/photo.jpg" />
<input
type="url"
data-testid="profile-avatar-url-input"
bind:value={avatarUrl}
placeholder="https://example.com/photo.jpg"
/>
<small class="muted">
{t('profile.photo_url_hint', 'https://, http://, or data:image/…;base64,… accepted')}
</small>
@@ -356,6 +364,7 @@
<span>{t('profile.photo_choose_file', 'Choose a photo (PNG, JPEG, WebP)')}</span>
<input
type="file"
data-testid="profile-avatar-file-input"
accept="image/png,image/jpeg,image/webp"
hidden
onchange={onAvatarFile}
@@ -373,19 +382,30 @@
{/if}
<div class="avatar-edit-actions">
<button class="btn btn-primary" disabled={avatarBusy} onclick={saveAvatar}>
<button
class="btn btn-primary"
data-testid="profile-avatar-save-btn"
disabled={avatarBusy}
onclick={saveAvatar}
>
{t('profile.photo_save', 'Save')}
</button>
{#if session.user.image}
<button
class="btn link-btn link-btn--danger"
data-testid="profile-avatar-remove-btn"
disabled={avatarBusy}
onclick={() => commitAvatar(null)}
>
{t('profile.photo_remove', 'Remove photo')}
</button>
{/if}
<button class="btn btn-secondary" disabled={avatarBusy} onclick={closeAvatarEdit}>
<button
class="btn btn-secondary"
data-testid="profile-avatar-cancel-btn"
disabled={avatarBusy}
onclick={closeAvatarEdit}
>
{t('common.cancel', 'Cancel')}
</button>
</div>
@@ -463,10 +483,11 @@
</span>
</div>
{:else}
<form onsubmit={saveProfile}>
<form data-testid="profile-edit-form" onsubmit={saveProfile}>
<label>
<span>{t('profile.username', 'Username')}</span>
<input
data-testid="profile-username-input"
bind:value={username}
maxlength="64"
autocomplete="username"
@@ -483,26 +504,42 @@
</label>
<label>
<span>{t('profile.given_name', 'First name')}</span>
<input bind:value={givenName} maxlength="128" autocomplete="given-name" />
<input
data-testid="profile-given-name-input"
bind:value={givenName}
maxlength="128"
autocomplete="given-name"
/>
</label>
<label>
<span>{t('profile.family_name', 'Last name')}</span>
<input bind:value={familyName} maxlength="128" autocomplete="family-name" />
<input
data-testid="profile-family-name-input"
bind:value={familyName}
maxlength="128"
autocomplete="family-name"
/>
</label>
<label>
<span>{t('profile.language', 'Language')}</span>
<select bind:value={preferredLocale}>
<option value="">{t('profile.language_auto', 'Automatic')}</option>
<select data-testid="profile-language-select" bind:value={preferredLocale}>
<option value="" data-testid="profile-language-auto-option"
>{t('profile.language_auto', 'Automatic')}</option
>
{#each SUPPORTED_LOCALES as loc (loc)}
<option value={loc}>{loc}</option>
<option value={loc} data-testid={`profile-language-option-${loc}`}>{loc}</option>
{/each}
</select>
</label>
<label class="checkbox">
<input type="checkbox" bind:checked={notifyOnShare} />
<input
type="checkbox"
data-testid="profile-notify-on-share-checkbox"
bind:checked={notifyOnShare}
/>
<span>{t('profile.notify_on_share', 'Email me when someone shares with me')}</span>
</label>
<button type="submit" disabled={savingProfile}
<button type="submit" data-testid="profile-save-btn" disabled={savingProfile}
>{t('profile.save_profile', 'Save changes')}</button
>
</form>
@@ -522,11 +559,17 @@
<div class="app-pw-create">
<input
data-testid="profile-app-pw-label-input"
bind:value={newLabel}
maxlength="128"
placeholder={t('profile.app_pw_label_placeholder', 'Label (e.g. Thunderbird, macOS)')}
/>
<button class="btn btn-primary" disabled={creatingPw} onclick={createPw}>
<button
class="btn btn-primary"
data-testid="profile-app-pw-generate-btn"
disabled={creatingPw}
onclick={createPw}
>
<Icon name="user-plus" />
{t('profile.generate', 'Generate')}
</button>
@@ -542,6 +585,7 @@
<code>{generated.password}</code>
<button
class="btn-action"
data-testid="profile-app-pw-copy-btn"
title={t('profile.copy_to_clipboard', 'Copy to clipboard')}
onclick={copyGenerated}
>
@@ -587,6 +631,7 @@
{#if p.active !== false}
<button
class="btn-action btn-action--danger"
data-testid={`profile-app-pw-revoke-${p.id}`}
title={t('profile.revoke_title', 'Revoke')}
onclick={() => revokePw(p)}
>
@@ -602,7 +647,11 @@
{#if autoPasswords.length > 0}
<div class="app-pw-auto">
<button class="app-pw-auto__toggle" onclick={() => (autoExpanded = !autoExpanded)}>
<button
class="app-pw-auto__toggle"
data-testid="profile-app-pw-auto-toggle-btn"
onclick={() => (autoExpanded = !autoExpanded)}
>
<Icon name={autoExpanded ? 'chevron-down' : 'chevron-right'} />
<span>{t('profile.client_sessions', 'Client sessions')}</span>
<span class="badge badge--count">{autoPasswords.length}</span>
@@ -637,6 +686,7 @@
{#if p.active !== false}
<button
class="btn-action btn-action--danger"
data-testid={`profile-app-pw-auto-revoke-${p.id}`}
title={t('profile.revoke_title', 'Revoke')}
onclick={() => revokePw(p)}
>
@@ -656,22 +706,39 @@
<!-- Change password -->
{#if showPasswordCard}
<form class="card" onsubmit={savePassword}>
<form class="card" data-testid="profile-password-form" onsubmit={savePassword}>
<h2><Icon name="key" /> {t('profile.change_password', 'Change Password')}</h2>
<label>
<span>{t('profile.current_password', 'Current Password')}</span>
<input type="password" bind:value={currentPw} autocomplete="current-password" />
<input
type="password"
data-testid="profile-current-password-input"
bind:value={currentPw}
autocomplete="current-password"
/>
</label>
<label>
<span>{t('profile.new_password', 'New Password')}</span>
<input type="password" bind:value={newPw} minlength="8" autocomplete="new-password" />
<input
type="password"
data-testid="profile-new-password-input"
bind:value={newPw}
minlength="8"
autocomplete="new-password"
/>
<small class="muted">{t('profile.min_8_chars', 'At least 8 characters')}</small>
</label>
<label>
<span>{t('profile.confirm_password', 'Confirm New Password')}</span>
<input type="password" bind:value={confirmPw} minlength="8" autocomplete="new-password" />
<input
type="password"
data-testid="profile-confirm-password-input"
bind:value={confirmPw}
minlength="8"
autocomplete="new-password"
/>
</label>
<button type="submit" disabled={savingPassword}>
<button type="submit" data-testid="profile-update-password-btn" disabled={savingPassword}>
{t('profile.update_password', 'Update Password')}
</button>
</form>
+128
View File
@@ -0,0 +1,128 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { session, ui } = vi.hoisted(() => ({
session: {
loaded: true,
load: vi.fn(),
user: {
id: '1',
username: 'admin',
email: 'a@x.test',
given_name: 'A',
family_name: 'B',
role: 'admin',
storage_used_bytes: 100,
storage_quota_bytes: 1000,
is_external: false
}
},
ui: { notify: vi.fn() }
}));
vi.mock('$lib/stores/session.svelte', () => ({ session }));
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog: vi.fn() }));
vi.mock('$lib/utils/errors', () => ({ errorToast: vi.fn() }));
vi.mock('$lib/api/endpoints/auth', () => ({ getOidcProviders: vi.fn() }));
vi.mock('$lib/api/endpoints/profile', () => ({
changePassword: vi.fn(),
createAppPassword: vi.fn(),
isAutoAppPassword: () => false,
listAppPasswords: vi.fn(),
revokeAppPassword: vi.fn(),
updateAvatar: vi.fn(),
updateProfile: vi.fn()
}));
import * as profile from '$lib/api/endpoints/profile';
import { getOidcProviders } from '$lib/api/endpoints/auth';
import { confirmDialog } from '$lib/stores/dialogs.svelte';
import ProfilePage from './+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
// Reset the shared session each test (handlers may mutate session.user).
session.loaded = true;
session.user = {
id: '1',
username: 'admin',
email: 'a@x.test',
given_name: 'A',
family_name: 'B',
role: 'admin',
storage_used_bytes: 100,
storage_quota_bytes: 1000,
is_external: false
};
m(profile.listAppPasswords).mockResolvedValue([]);
m(profile.updateProfile).mockResolvedValue(undefined);
m(getOidcProviders).mockResolvedValue({ password_login_enabled: true });
});
it('renders and saves the profile form', async () => {
m(profile.updateProfile).mockResolvedValue(undefined);
render(ProfilePage);
await screen.findByTestId('profile-edit-form');
await fireEvent.input(screen.getByTestId('profile-given-name-input'), {
target: { value: 'New' }
});
await fireEvent.click(screen.getByTestId('profile-save-btn'));
await waitFor(() => expect(profile.updateProfile).toHaveBeenCalled());
});
it('generates an app password', async () => {
m(profile.createAppPassword).mockResolvedValue({ id: 'ap1', secret: 'xyz', label: 'tok' });
render(ProfilePage);
await screen.findByTestId('profile-app-pw-label-input');
await fireEvent.input(screen.getByTestId('profile-app-pw-label-input'), {
target: { value: 'tok' }
});
await fireEvent.click(screen.getByTestId('profile-app-pw-generate-btn'));
await waitFor(() => expect(profile.createAppPassword).toHaveBeenCalledWith('tok'));
});
it('rejects a mismatched password change without calling the API', async () => {
render(ProfilePage);
await screen.findByTestId('profile-password-form');
await fireEvent.input(screen.getByTestId('profile-current-password-input'), {
target: { value: 'old' }
});
await fireEvent.input(screen.getByTestId('profile-new-password-input'), {
target: { value: 'new1' }
});
await fireEvent.input(screen.getByTestId('profile-confirm-password-input'), {
target: { value: 'new2' }
});
await fireEvent.click(screen.getByTestId('profile-update-password-btn'));
expect(profile.changePassword).not.toHaveBeenCalled();
});
it('changes the password when the confirmation matches', async () => {
m(profile.changePassword).mockResolvedValue(undefined);
render(ProfilePage);
await screen.findByTestId('profile-password-form');
await fireEvent.input(screen.getByTestId('profile-current-password-input'), {
target: { value: 'OldPassword1!' }
});
await fireEvent.input(screen.getByTestId('profile-new-password-input'), {
target: { value: 'NewPassword1!' }
});
await fireEvent.input(screen.getByTestId('profile-confirm-password-input'), {
target: { value: 'NewPassword1!' }
});
await fireEvent.click(screen.getByTestId('profile-update-password-btn'));
await waitFor(() => expect(profile.changePassword).toHaveBeenCalled());
});
it('revokes an existing app password after confirmation', async () => {
m(profile.listAppPasswords).mockResolvedValue([
{ id: 'ap1', label: 'CLI token', created_at: '2024-01-01T00:00:00Z' }
]);
m(confirmDialog).mockResolvedValue(true);
m(profile.revokeAppPassword).mockResolvedValue(undefined);
render(ProfilePage);
await fireEvent.click(await screen.findByTestId('profile-app-pw-revoke-ap1'));
await waitFor(() => expect(profile.revokeAppPassword).toHaveBeenCalledWith('ap1'));
});
+12 -4
View File
@@ -341,21 +341,29 @@
>
{#snippet toolbar()}
{#if entries.length > 0}
<Button icon="broom" onclick={clearAll}>{t('recent.clear', 'Clear recent')}</Button>
<Button icon="broom" data-testid="recent-clear-btn" onclick={clearAll}
>{t('recent.clear', 'Clear recent')}</Button
>
{/if}
{/snippet}
{#snippet batchToolbar()}
<Button icon="download" onclick={batchDownload}>{t('common.download', 'Download')}</Button>
<Button icon="download" data-testid="recent-batch-download-btn" onclick={batchDownload}
>{t('common.download', 'Download')}</Button
>
<Button
icon="arrows-alt"
data-testid="recent-batch-move-btn"
onclick={() => {
moveTarget = null;
moveItems = batchTargets();
moveOpen = true;
}}>{t('files.move', 'Move')}</Button
>
<Button variant="danger" icon="trash" onclick={batchDelete}
>{t('common.delete', 'Delete')}</Button
<Button
variant="danger"
icon="trash"
data-testid="recent-batch-delete-btn"
onclick={batchDelete}>{t('common.delete', 'Delete')}</Button
>
{/snippet}
</ResourceList>
+105
View File
@@ -0,0 +1,105 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { confirmDialog, promptDialog } = vi.hoisted(() => ({
confirmDialog: vi.fn(),
promptDialog: vi.fn()
}));
vi.mock('$lib/api/endpoints/recent', () => ({ clearRecent: vi.fn(), fetchRecentPage: vi.fn() }));
vi.mock('$lib/api/endpoints/favorites', () => ({
addFavorite: vi.fn(),
dateBucket: () => 'Today',
fetchFavoritesPage: vi.fn(async () => ({ items: [], next_cursor: null })),
removeFavorite: vi.fn(),
resolveOwnerName: vi.fn(async () => 'me'),
sizeBucket: () => 'Small',
typeLabel: () => 'File'
}));
vi.mock('$lib/api/endpoints/files', () => ({
fileDownloadUrl: () => '/dl',
renameFile: vi.fn(),
deleteFile: vi.fn()
}));
vi.mock('$lib/api/endpoints/folders', () => ({ renameFolder: vi.fn(), deleteFolder: vi.fn() }));
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog, promptDialog }));
import { fetchRecentPage, clearRecent } from '$lib/api/endpoints/recent';
import { addFavorite } from '$lib/api/endpoints/favorites';
import { deleteFile } from '$lib/api/endpoints/files';
import RecentPage from './+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
function withOneFile() {
m(fetchRecentPage).mockResolvedValue({
items: [
{
resource_type: 'file',
accessed_at: '2024-01-01T00:00:00Z',
resource: {
category: 'Document',
created_at: 0,
icon_class: 'fa-file',
icon_special_class: '',
id: 'r1',
mime_type: 'text/plain',
modified_at: 0,
name: 'notes.txt',
owner_id: 'me',
folder_id: 'root',
path: '/notes.txt',
size: 4,
size_formatted: '4 B',
sort_date: 0,
etag: 'e',
content_hash: 'h'
}
}
],
next_cursor: null
});
}
beforeEach(() => vi.clearAllMocks());
it('renders recent items returned by the API', async () => {
withOneFile();
render(RecentPage);
await waitFor(() => expect(fetchRecentPage).toHaveBeenCalled());
await waitFor(() => expect(screen.getByText('notes.txt')).toBeTruthy());
});
it('clears recent activity after confirmation', async () => {
withOneFile();
confirmDialog.mockResolvedValue(true);
m(clearRecent).mockResolvedValue(undefined);
render(RecentPage);
await fireEvent.click(await screen.findByTestId('recent-clear-btn'));
await waitFor(() => expect(clearRecent).toHaveBeenCalled());
});
it('favorites a recent row via the star button', async () => {
withOneFile();
m(addFavorite).mockResolvedValue(undefined);
render(RecentPage);
await screen.findByText('notes.txt');
await fireEvent.click(screen.getByTestId('resource-list-favorite-r1-btn'));
await waitFor(() => expect(addFavorite).toHaveBeenCalledWith('file', 'r1'));
});
it('batch-deletes selected recent items after confirmation', async () => {
withOneFile();
confirmDialog.mockResolvedValue(true);
m(deleteFile).mockResolvedValue(undefined);
render(RecentPage);
await screen.findByText('notes.txt');
await fireEvent.click(screen.getByTestId('resource-list-select-r1-checkbox'));
await fireEvent.click(await screen.findByTestId('recent-batch-delete-btn'));
await waitFor(() => expect(deleteFile).toHaveBeenCalledWith('r1'));
});
it('renders an empty state when there is no recent activity', async () => {
m(fetchRecentPage).mockResolvedValue({ items: [], next_cursor: null });
render(RecentPage);
await waitFor(() => expect(fetchRecentPage).toHaveBeenCalled());
});
+37 -6
View File
@@ -258,23 +258,32 @@
<p>{t('share.expired', 'This share link is no longer available.')}</p>
</div>
{:else if view === 'password'}
<form class="share__pw" onsubmit={submitPassword}>
<form class="share__pw" data-testid="public-share-password-form" onsubmit={submitPassword}>
<h1>{t('share.password_title', 'Password required')}</h1>
<input
type="password"
data-testid="public-share-password-input"
bind:value={pwInput}
placeholder={t('share.password', 'Password')}
disabled={busy}
autocomplete="off"
/>
{#if pwError}<p class="share__error" role="alert">{pwError}</p>{/if}
<button type="submit" disabled={busy}>{t('share.unlock', 'Unlock')}</button>
<button type="submit" data-testid="public-share-unlock-btn" disabled={busy}
>{t('share.unlock', 'Unlock')}</button
>
</form>
{:else if view === 'file'}
<div class="share__center">
<Icon name="file" class="share__big-icon" />
<h1>{meta?.item_name}</h1>
<a class="share__btn" href={shareDownloadUrl(token)} download rel="external">
<a
class="share__btn"
data-testid="public-share-download-btn"
href={shareDownloadUrl(token)}
download
rel="external"
>
{t('share.download', 'Download')}
</a>
</div>
@@ -286,7 +295,11 @@
{#if i === crumbs.length - 1}
<span class="breadcrumb__current">{c.name}</span>
{:else}
<button class="breadcrumb__link" onclick={() => gotoCrumb(i)}>{c.name}</button>
<button
class="breadcrumb__link"
data-testid={`public-share-breadcrumb-${i}`}
onclick={() => gotoCrumb(i)}>{c.name}</button
>
{/if}
{/each}
</nav>
@@ -296,6 +309,7 @@
type="button"
aria-pressed={viewMode === 'grid'}
class:active={viewMode === 'grid'}
data-testid="public-share-view-grid-btn"
title={t('files.grid', 'Grid')}
onclick={() => setViewMode('grid')}><Icon name="th" /></button
>
@@ -303,11 +317,18 @@
type="button"
aria-pressed={viewMode === 'list'}
class:active={viewMode === 'list'}
data-testid="public-share-view-list-btn"
title={t('files.list', 'List')}
onclick={() => setViewMode('list')}><Icon name="bars" /></button
>
</div>
<a class="share__btn" href={shareZipUrl(token, folderId)} download rel="external">
<a
class="share__btn"
data-testid="public-share-download-zip-btn"
href={shareZipUrl(token, folderId)}
download
rel="external"
>
<Icon name="file-archive" />
{t('share.download_zip', 'Download ZIP')}
</a>
@@ -323,7 +344,11 @@
<ul class="share__grid" class:share__grid--list={viewMode === 'list'}>
{#each listing.folders as f (f.id)}
<li>
<button class="card" onclick={() => openFolder(f.id, { id: f.id, name: f.name }, true)}>
<button
class="card"
data-testid={f.name}
onclick={() => openFolder(f.id, { id: f.id, name: f.name }, true)}
>
<span class="card__thumb"><Icon name="folder" class="card__icon" /></span>
<span class="card__name">{f.name}</span>
</button>
@@ -341,6 +366,7 @@
<li>
<button
class="card"
data-testid={f.name}
onclick={() => (lightbox = mediaFiles.findIndex((m) => m.id === f.id))}
>
<span class="card__thumb">
@@ -369,6 +395,7 @@
<li>
<a
class="card"
data-testid={f.name}
href={shareFileUrl(token, f.id)}
target="_blank"
rel="external noreferrer"
@@ -390,6 +417,7 @@
<div
class="lb"
role="dialog"
data-testid="public-share-lightbox-dialog"
aria-modal="true"
aria-label={m.name}
tabindex="-1"
@@ -397,11 +425,13 @@
>
<button
class="lb__close"
data-testid="public-share-lightbox-close-btn"
aria-label={t('common.close', 'Close')}
onclick={() => (lightbox = -1)}>×</button
>
<button
class="lb__nav lb__nav--prev"
data-testid="public-share-lightbox-prev-btn"
aria-label={t('common.previous', 'Previous')}
disabled={lightbox === 0}
onclick={(e) => {
@@ -417,6 +447,7 @@
{/if}
<button
class="lb__nav lb__nav--next"
data-testid="public-share-lightbox-next-btn"
aria-label={t('common.next', 'Next')}
disabled={lightbox === mediaFiles.length - 1}
onclick={(e) => {
+29 -7
View File
@@ -201,12 +201,18 @@
<div class="search-controls">
{#if filesStore.currentFolder}
<div class="seg" role="group" aria-label={t('search.scope', 'Scope')}>
<button class="seg__btn" class:active={scope === 'all'} onclick={() => (scope = 'all')}>
<button
class="seg__btn"
class:active={scope === 'all'}
data-testid="search-scope-all-btn"
onclick={() => (scope = 'all')}
>
{t('search.everywhere', 'Everywhere')}
</button>
<button
class="seg__btn"
class:active={scope === 'folder'}
data-testid="search-scope-folder-btn"
onclick={() => (scope = 'folder')}
>
{t('search.this_folder', 'This folder')}
@@ -217,28 +223,40 @@
class="sort-select"
bind:value={typeFilter}
aria-label={t('search.type_label', 'Type')}
data-testid="search-type-filter-select"
>
{#each TYPES as o (o.v)}<option value={o.v}>{o.l}</option>{/each}
{#each TYPES as o (o.v)}<option value={o.v} data-testid={`search-type-${o.v}`}>{o.l}</option
>{/each}
</select>
<select
class="sort-select"
bind:value={sizeFilter}
aria-label={t('search.size_label', 'Size')}
data-testid="search-size-filter-select"
>
{#each SIZES as o (o.v)}<option value={o.v}>{o.l}</option>{/each}
{#each SIZES as o (o.v)}<option value={o.v} data-testid={`search-size-${o.v}`}>{o.l}</option
>{/each}
</select>
<select
class="sort-select"
bind:value={dateFilter}
aria-label={t('search.date_label', 'Date')}
data-testid="search-date-filter-select"
>
{#each DATES as o (o.v)}<option value={o.v}>{o.l}</option>{/each}
{#each DATES as o (o.v)}<option value={o.v} data-testid={`search-date-${o.v}`}>{o.l}</option
>{/each}
</select>
<select class="sort-select" bind:value={sortBy} aria-label={t('search.sort_by', 'Sort by')}>
{#each SORTS as s (s.v)}<option value={s.v}>{s.l}</option>{/each}
<select
class="sort-select"
bind:value={sortBy}
aria-label={t('search.sort_by', 'Sort by')}
data-testid="search-sort-select"
>
{#each SORTS as s (s.v)}<option value={s.v} data-testid={`search-sort-${s.v}`}>{s.l}</option
>{/each}
</select>
{#if hasFilters}
<button class="clear-filters" onclick={clearFilters}>
<button class="clear-filters" data-testid="search-clear-filters-btn" onclick={clearFilters}>
<Icon name="times" />
{t('search.clear_filters', 'Clear filters')}
</button>
@@ -281,6 +299,8 @@
class="file-item"
role="button"
tabindex="0"
aria-label={e.folder.name}
data-testid={e.folder.name}
onclick={() => openFolder(e.folder)}
onkeydown={(ev) => ev.key === 'Enter' && openFolder(e.folder)}
>
@@ -297,6 +317,8 @@
class="file-item"
role="button"
tabindex="0"
aria-label={e.file.name}
data-testid={e.file.name}
onclick={() => openFile(e.file)}
onkeydown={(ev) => ev.key === 'Enter' && openFile(e.file)}
>
+43
View File
@@ -0,0 +1,43 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/svelte';
const { goto, pageState } = vi.hoisted(() => ({
goto: vi.fn(),
pageState: { url: new URL('http://localhost/search?q=report') }
}));
vi.mock('$app/navigation', () => ({ goto }));
vi.mock('$app/state', () => ({ page: pageState }));
vi.mock('$lib/api/endpoints/search', () => ({ searchFiles: vi.fn() }));
vi.mock('$lib/api/endpoints/files', () => ({ fileInlineUrl: () => '/in' }));
import { searchFiles } from '$lib/api/endpoints/search';
import SearchPage from './+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
pageState.url = new URL('http://localhost/search?q=report');
m(searchFiles).mockResolvedValue({ files: [], folders: [], total: 0 });
});
it('runs a search from the q query parameter on mount', async () => {
render(SearchPage);
await waitFor(() => expect(searchFiles).toHaveBeenCalled());
expect(m(searchFiles).mock.calls[0][0]).toBe('report');
});
it('does not search when there is no query', async () => {
pageState.url = new URL('http://localhost/search');
render(SearchPage);
// Give the reactive effect a tick to (not) fire.
await Promise.resolve();
expect(searchFiles).not.toHaveBeenCalled();
});
it('surfaces a search error', async () => {
m(searchFiles).mockRejectedValue(new Error('search boom'));
render(SearchPage);
await waitFor(() => expect(searchFiles).toHaveBeenCalled());
await waitFor(() => expect(screen.getByText('search boom')).toBeTruthy());
});
+47 -7
View File
@@ -391,11 +391,19 @@
<header class="ms-lane__header">
{#if lane.header.kind === 'resource'}
{@const laneItem = lane.header.item}
<button class="ms-lane__resource" onclick={() => openResource(laneItem)}>
<button
class="ms-lane__resource"
data-testid={`shared-lane-open-${laneItem.resource.id}`}
onclick={() => openResource(laneItem)}
>
<Icon name={resourceIcon(laneItem)} />
<span class="ms-lane__name">{laneItem.resource.name}</span>
</button>
<button class="btn btn-secondary ms-lane__edit" onclick={() => editSharing(laneItem)}>
<button
class="btn btn-secondary ms-lane__edit"
data-testid={`shared-edit-sharing-${laneItem.resource.id}`}
onclick={() => editSharing(laneItem)}
>
<Icon name="pencil-alt" />
{t('myshares.editSharing', 'Edit sharing')}
</button>
@@ -427,7 +435,11 @@
<!-- Identity -->
<span class="ms-row__identity">
{#if (grant.subject_type === 'user' || grant.subject_type === 'group') && groupBy === 'sharedWith'}
<button class="ms-link-btn" onclick={() => openResource(item)}>
<button
class="ms-link-btn"
data-testid={`shared-row-open-${grant.grant_id}`}
onclick={() => openResource(item)}
>
<Icon name={resourceIcon(item)} />
<span class="ms-row__name">{item.resource.name}</span>
</button>
@@ -443,6 +455,7 @@
<button
class="ms-chip ms-chip--link"
class:ms-chip--locked={grant.has_password}
data-testid={`shared-copy-link-${grant.grant_id}`}
onclick={() => copyLink(grant)}
title={t('share.copyLink', 'Copy link')}
>
@@ -451,7 +464,11 @@
</button>
{#if groupBy === 'sharedWith'}
<span class="ms-arrow">→</span>
<button class="ms-link-btn" onclick={() => openResource(item)}>
<button
class="ms-link-btn"
data-testid={`shared-link-open-${grant.grant_id}`}
onclick={() => openResource(item)}
>
<Icon name={resourceIcon(item)} />
<span>{item.resource.name}</span>
</button>
@@ -480,6 +497,7 @@
aria-label={t('myshares.manageAccess', 'Manage access')}
aria-haspopup="menu"
aria-expanded={menuFor === grant.grant_id}
data-testid={`shared-kebab-${grant.grant_id}`}
onclick={(e) => {
e.stopPropagation();
toggleMenu(grant.grant_id);
@@ -490,11 +508,17 @@
class="ms-menu"
role="menu"
tabindex="-1"
data-testid={`shared-menu-${grant.grant_id}`}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.key === 'Escape' && closeMenu()}
>
{#if grant.subject_type === 'user' || grant.subject_type === 'group'}
<button class="ms-menu__item" role="menuitem" onclick={() => notify(grant)}>
<button
class="ms-menu__item"
role="menuitem"
data-testid={`shared-notify-${grant.grant_id}`}
onclick={() => notify(grant)}
>
<Icon name="paper-plane" />
{grant.subject_type === 'group'
? t('myshares.notifyGroupMembers', 'Notify group members')
@@ -508,6 +532,7 @@
class="ms-menu__item"
class:ms-menu__item--current={grant.role === r.v}
role="menuitem"
data-testid={`shared-role-${r.v}-${grant.grant_id}`}
onclick={() => changeRole(grant, item, r.v)}
>
<Icon name={grant.role === r.v ? 'check' : r.icon} />
@@ -520,6 +545,7 @@
<input
type="date"
class="ms-menu__date"
data-testid={`shared-expiry-${grant.grant_id}-input`}
value={isoToDate(grant.expires_at)}
onchange={(e) =>
changeExpiry(grant, item, (e.currentTarget as HTMLInputElement).value)}
@@ -529,13 +555,19 @@
<button
class="ms-menu__item ms-menu__item--danger"
role="menuitem"
data-testid={`shared-remove-access-${grant.grant_id}`}
onclick={() => removeAccess(grant)}
>
<Icon name="user-xmark" />
{t('myshares.removeAccess', 'Remove access')}
</button>
{:else}
<button class="ms-menu__item" role="menuitem" onclick={() => copyLink(grant)}>
<button
class="ms-menu__item"
role="menuitem"
data-testid={`shared-menu-copy-link-${grant.grant_id}`}
onclick={() => copyLink(grant)}
>
<Icon name="copy" />
{t('myshares.copyLink', 'Copy link')}
</button>
@@ -545,6 +577,7 @@
<input
type="date"
class="ms-menu__date"
data-testid={`shared-link-expiry-${grant.grant_id}-input`}
value={isoToDate(grant.expires_at)}
onchange={(e) =>
changeLinkExpiry(grant, (e.currentTarget as HTMLInputElement).value)}
@@ -553,6 +586,7 @@
<button
class="ms-menu__item"
role="menuitem"
data-testid={`shared-edit-password-${grant.grant_id}`}
onclick={() => editLinkPassword(grant)}
>
<Icon name={grant.has_password ? 'lock' : 'lock-open'} />
@@ -564,6 +598,7 @@
<button
class="ms-menu__item ms-menu__item--danger"
role="menuitem"
data-testid={`shared-delete-link-${grant.grant_id}`}
onclick={() => deleteLink(grant)}
>
<Icon name="trash" />
@@ -580,7 +615,12 @@
{/each}
{#if cursor}
<button class="btn btn-secondary ms-more" onclick={() => load(false)} disabled={loading}>
<button
class="btn btn-secondary ms-more"
data-testid="shared-load-more-btn"
onclick={() => load(false)}
disabled={loading}
>
{loading ? t('common.loading', 'Loading…') : t('common.load_more', 'Load more')}
</button>
{/if}
+112
View File
@@ -0,0 +1,112 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { ui, goto } = vi.hoisted(() => ({ ui: { notify: vi.fn() }, goto: vi.fn() }));
vi.mock('$app/navigation', () => ({ goto }));
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
vi.mock('$lib/utils/errors', () => ({
errorMessage: (e: Error) => e.message,
errorToast: vi.fn()
}));
vi.mock('$lib/api/endpoints/grants', () => ({
displayRole: (r: string) => r,
expiryToIso: (v: string | null) => v,
fetchMyShares: vi.fn(),
notifyGrantRecipient: vi.fn(async () => ({ outcomes: [] })),
revokeGrant: vi.fn(),
updateGrantRole: vi.fn()
}));
vi.mock('$lib/api/endpoints/recipients', () => ({
ensureResolvers: vi.fn(),
resolveLabel: (_t: string, id: string) => id
}));
vi.mock('$lib/api/endpoints/shares', () => ({
copyShareLink: vi.fn(),
deleteShare: vi.fn(),
getShareById: vi.fn(),
updateShare: vi.fn()
}));
vi.mock('$lib/api/endpoints/files', () => ({ fileInlineUrl: () => '/in' }));
import { fetchMyShares, updateGrantRole, revokeGrant } from '$lib/api/endpoints/grants';
import { ensureResolvers } from '$lib/api/endpoints/recipients';
import SharedPage from './+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
function grantItem() {
return {
resource_type: 'folder' as const,
resource: {
category: 'Folder',
created_at: 0,
icon_class: 'fa-folder',
icon_special_class: '',
id: 'r1',
is_root: false,
modified_at: 0,
name: 'Docs',
owner_id: 'me',
parent_id: null,
path: '/Docs',
etag: 'e'
},
first_shared_at: '2024-01-01T00:00:00Z',
grants: [
{
grant_id: 'g1',
subject_type: 'user' as const,
subject_id: 'u1',
subject_display: 'Bob',
role: 'viewer' as const,
granted_at: '2024-01-01T00:00:00Z',
expires_at: null,
has_password: false,
is_external: false
}
]
};
}
beforeEach(() => {
vi.clearAllMocks();
m(ensureResolvers).mockResolvedValue(undefined);
});
it('renders a swimlane for each shared resource', async () => {
m(fetchMyShares).mockResolvedValue({ items: [grantItem()], next_cursor: null });
render(SharedPage);
await waitFor(() => expect(fetchMyShares).toHaveBeenCalled());
await screen.findByTestId('shared-kebab-g1');
});
it('changes a grant role from the kebab menu', async () => {
m(fetchMyShares).mockResolvedValue({ items: [grantItem()], next_cursor: null });
m(updateGrantRole).mockResolvedValue(undefined);
render(SharedPage);
await fireEvent.click(await screen.findByTestId('shared-kebab-g1'));
await fireEvent.click(await screen.findByTestId('shared-role-editor-g1'));
await waitFor(() =>
expect(updateGrantRole).toHaveBeenCalledWith(
{ type: 'user', id: 'u1' },
{ type: 'folder', id: 'r1' },
'editor',
null
)
);
});
it('revokes access from the kebab menu', async () => {
m(fetchMyShares).mockResolvedValue({ items: [grantItem()], next_cursor: null });
m(revokeGrant).mockResolvedValue(undefined);
render(SharedPage);
await fireEvent.click(await screen.findByTestId('shared-kebab-g1'));
await fireEvent.click(await screen.findByTestId('shared-remove-access-g1'));
await waitFor(() => expect(revokeGrant).toHaveBeenCalledWith('g1'));
});
it('surfaces a load error without crashing', async () => {
m(fetchMyShares).mockRejectedValue(new Error('boom'));
render(SharedPage);
await waitFor(() => expect(fetchMyShares).toHaveBeenCalled());
});
+8 -2
View File
@@ -174,7 +174,7 @@
>
{#snippet toolbar()}
{#if entries.length > 0}
<button class="btn btn-danger" onclick={purgeAll}>
<button class="btn btn-danger" data-testid="trash-empty-btn" onclick={purgeAll}>
<Icon name="trash" />
{t('trash.empty_action', 'Empty trash')}
</button>
@@ -188,11 +188,17 @@
</span>
{/snippet}
{#snippet actions(entry)}
<button class="btn-action" title={t('trash.restore', 'Restore')} onclick={() => restore(entry)}>
<button
class="btn-action"
data-testid={`trash-restore-btn-${entry.id}`}
title={t('trash.restore', 'Restore')}
onclick={() => restore(entry)}
>
<Icon name="undo" />
</button>
<button
class="btn-action btn-action--delete"
data-testid={`trash-delete-btn-${entry.id}`}
title={t('trash.delete', 'Delete permanently')}
onclick={() => purge(entry)}
>
+119
View File
@@ -0,0 +1,119 @@
/**
* Svelte markup preprocessor that strips test-only `data-testid` attributes at
* compile time for production builds.
*
* The attribute name, value, and any `{expression}` are removed from the
* template *before* Svelte compiles it, so production output carries no trace —
* neither in the rendered DOM nor in the JS bundle (no `'data-testid'` string
* and no attribute-setting call survive). A runtime guard could only blank the
* value; this removes the attribute outright.
*
* The attribute is kept intact when:
* - `VITE_E2E=1` is set (the e2e image / `just fe-build-e2e` build), or
* - the build is not a production build (the `vite dev` server),
* so Playwright and `playwright codegen` can rely on `getByTestId`.
*
* Only the markup *between* `<script>`/`<style>` blocks is scanned, so the token
* `data-testid` appearing inside component logic or styles is never touched.
*/
const ATTR = 'data-testid';
/** Strip only for production builds that didn't opt into test ids. */
function shouldStrip() {
if (process.env.VITE_E2E === '1') return false;
return process.env.NODE_ENV === 'production';
}
/** @returns {import('svelte/compiler').PreprocessorGroup} */
export function stripTestId() {
return {
name: 'strip-testid',
markup({ content }) {
if (!shouldStrip() || !content.includes(ATTR)) return;
return { code: stripOutsideBlocks(content) };
}
};
}
/** Run the attribute removal on markup only, leaving `<script>`/`<style>` verbatim. */
function stripOutsideBlocks(source) {
const blockRe = /<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi;
let result = '';
let last = 0;
let m;
while ((m = blockRe.exec(source)) !== null) {
result += removeAttr(source.slice(last, m.index));
result += m[0];
last = m.index + m[0].length;
}
result += removeAttr(source.slice(last));
return result;
}
/**
* Remove every `data-testid` attribute occurrence from a markup fragment,
* handling `="literal"`, `='literal'`, `={balanced expression}` (respecting
* nested braces and string literals), unquoted values, and the bare boolean
* form. One preceding space is consumed so no double-space is left behind.
*/
function removeAttr(markup) {
let out = '';
let i = 0;
while (i < markup.length) {
const idx = markup.indexOf(ATTR, i);
if (idx === -1) {
out += markup.slice(i);
break;
}
const prev = markup[idx - 1];
const after = markup[idx + ATTR.length];
const boundaryBefore = prev === undefined || /\s/.test(prev);
const boundaryAfter = after === undefined || after === '=' || /[\s/>]/.test(after);
if (!boundaryBefore || !boundaryAfter) {
out += markup.slice(i, idx + ATTR.length);
i = idx + ATTR.length;
continue;
}
// Emit up to the attribute, dropping one leading whitespace char if present.
const cut = prev !== undefined && /\s/.test(prev) ? idx - 1 : idx;
out += markup.slice(i, cut);
// Advance past the attribute and its value (if any).
let j = idx + ATTR.length;
if (markup[j] === '=') {
j++;
const q = markup[j];
if (q === '"' || q === "'") {
j++;
while (j < markup.length && markup[j] !== q) j++;
j++; // consume the closing quote
} else if (q === '{') {
let depth = 0;
while (j < markup.length) {
const c = markup[j];
if (c === '"' || c === "'" || c === '`') {
const sq = c;
j++;
while (j < markup.length && markup[j] !== sq) {
if (markup[j] === '\\') j++;
j++;
}
} else if (c === '{') {
depth++;
} else if (c === '}') {
depth--;
if (depth === 0) {
j++;
break;
}
}
j++;
}
} else {
while (j < markup.length && !/[\s/>]/.test(markup[j])) j++;
}
}
i = j;
}
return out;
}
+73 -1
View File
@@ -1,5 +1,41 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import { stripTestId } from './strip-testid.js';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { parse } from 'parse5';
// SvelteKit's `kit.csp` hash mode only hashes the inline scripts SvelteKit
// itself generates (its bootstrap) — NOT inline scripts authored in app.html.
// So we compute the SHA-256 of our anti-FOUC theme-init <script> here, at
// config load, and feed it into `script-src`. This keeps the script INLINE
// (zero extra request) while auto-managing its hash: edit the script and the
// hash regenerates on the next build — the CSP can't drift out of step.
//
// We parse app.html with parse5 (WHATWG-compliant) and target the <script> by
// id, so the lookup is exact. parse5 returns a raw-text element's content
// byte-for-byte, and SvelteKit emits app.html verbatim (only %sveltekit.*%
// substitution, no minification of the shell), so the bytes we hash equal what
// the browser parses. Throws if the id is missing — failing the build loudly
// rather than shipping a CSP that silently blocks the script.
function inlineScriptHash(htmlPath, id) {
const stack = [parse(readFileSync(htmlPath, 'utf-8'))];
while (stack.length) {
const node = stack.pop();
for (const child of node.childNodes ?? []) stack.push(child);
if (node.tagName === 'script' && node.attrs?.some((a) => a.name === 'id' && a.value === id)) {
const body = (node.childNodes ?? []).map((c) => c.value ?? '').join('');
return `sha256-${createHash('sha256').update(body, 'utf8').digest('base64')}`;
}
}
throw new Error(`svelte.config.js: no inline <script id="${id}"> found in ${htmlPath}`);
}
const themeInitHash = inlineScriptHash(
fileURLToPath(new URL('./src/app.html', import.meta.url)),
'theme-init'
);
/**
* SvelteKit config — pure SPA via adapter-static.
@@ -15,7 +51,9 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
* @type {import('@sveltejs/kit').Config}
*/
const config = {
preprocess: vitePreprocess(),
// `stripTestId` removes `data-testid` attributes from production builds; it
// runs after vitePreprocess and only scans markup outside <script>/<style>.
preprocess: [vitePreprocess(), stripTestId()],
kit: {
adapter: adapter({
// Cutover: emit the SPA into the repo-root `static-dist/` that the Rust
@@ -36,6 +74,40 @@ const config = {
// code after a rebuild (the classic "my fix isn't applied" trap).
version: {
pollInterval: 60000
},
// Content-Security-Policy for the SPA document.
//
// The Rust server deliberately does NOT send a CSP *header* on text/html
// responses (see `content_security_policy` in src/main.rs); this <meta>
// policy is the sole, strict authority for the app shell. `mode: 'hash'`
// auto-emits the SHA-256 of SvelteKit's inline bootstrap; `themeInitHash`
// (computed above from app.html) covers the inline theme-init script. Net
// result: strict script-src with no `'unsafe-inline'`. Directives mirror
// the server's header policy for every other response. (The shell has no
// inline <style>, so `style-src 'unsafe-inline'` stays effective for
// runtime element.style.)
csp: {
mode: 'hash',
directives: {
'default-src': ['self'],
'script-src': ['self', themeInitHash],
'worker-src': ['self'],
'style-src': ['self', 'unsafe-inline'],
'img-src': ['self', 'data:', 'blob:', 'https:'],
'media-src': ['self', 'blob:'],
'connect-src': ['self'],
'font-src': ['self', 'data:'],
'frame-src': ['*', 'blob:'],
'frame-ancestors': ['none'],
'base-uri': ['self'],
// 'https:' (beyond 'self') so the in-app WOPI office editor works: the
// modal POSTs a hidden token form to the editor's action URL, which is
// a cross-origin, admin-configured Collabora/OnlyOffice host (the same
// host 'frame-src *' already lets us iframe). Without this the browser
// refuses the submit and the editor never loads. Mirrors the server
// header in src/main.rs.
'form-action': ['self', 'https:']
}
}
}
};
+27 -2
View File
@@ -1,10 +1,17 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';
import istanbul from 'vite-plugin-istanbul';
import { svelteTesting } from '@testing-library/svelte/vite';
// Backend dev server (cargo run) — the Vite dev server proxies API/protocol
// traffic here so cookies, CSRF, and the auth-refresh flow are same-origin.
const BACKEND = process.env.OXICLOUD_BACKEND ?? 'http://localhost:8086';
// When COVERAGE=1, instrument the app source with Istanbul so Playwright e2e
// runs can read `window.__coverage__` and report SvelteKit code coverage. Off
// by default so normal dev/release builds carry no instrumentation overhead.
const COVERAGE = process.env.COVERAGE === '1';
const proxy = {
'/api': { target: BACKEND, changeOrigin: true },
'/locales': { target: BACKEND, changeOrigin: true },
@@ -20,14 +27,32 @@ const proxy = {
};
export default defineConfig({
plugins: [sveltekit()],
plugins: [
sveltekit(),
// Compiles Svelte components in client mode for Vitest component tests
// (so onMount etc. run); a no-op outside the test runner.
svelteTesting(),
...(COVERAGE
? [
istanbul({
include: 'src/**/*.{ts,svelte}',
exclude: ['node_modules', 'src/**/*.{test,spec}.{js,ts}'],
extension: ['.ts', '.svelte'],
requireEnv: false,
forceBuildInstrument: true
})
]
: [])
],
server: {
port: 5173,
proxy
},
test: {
environment: 'jsdom',
setupFiles: ['./vitest-setup.ts'],
// vitest-coverage.ts is a no-op unless COVERAGE=1; it collects Istanbul
// coverage into tests/e2e/.nyc_output_unit for the combined report.
setupFiles: ['./vitest-setup.ts', './vitest-coverage.ts'],
include: ['src/**/*.{test,spec}.{js,ts}'],
globals: true
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Combined-coverage collector for the unit tests.
*
* When `COVERAGE=1`, `vite-plugin-istanbul` (wired in vite.config.ts) instruments
* `src/` during Vitest's transform — the SAME instrumenter the Playwright e2e
* build uses — so the two coverage sets are mergeable. After every test file we
* dump the accumulated `globalThis.__coverage__` into `tests/e2e/.nyc_output_unit`
* (separate from the e2e `.nyc_output`). `tests/e2e/coverage-report.cjs` can then
* report unit-only, e2e-only, or the merge of both.
*
* Off unless `COVERAGE=1`, so the normal `npm run test:unit` is unaffected.
*/
import { afterAll } from 'vitest';
afterAll(async () => {
if (process.env.COVERAGE !== '1') return;
const cov = (globalThis as Record<string, unknown>).__coverage__;
if (!cov) return;
const fs = await import('node:fs');
const path = await import('node:path');
const dir = path.resolve(process.cwd(), '../tests/e2e/.nyc_output_unit');
fs.mkdirSync(dir, { recursive: true });
const id = `${process.pid}-${Math.random().toString(36).slice(2)}`;
fs.writeFileSync(path.join(dir, `unit-${id}.json`), JSON.stringify(cov));
});
+55
View File
@@ -1 +1,56 @@
import '@testing-library/jest-dom/vitest';
// jsdom lacks ResizeObserver / IntersectionObserver, which several list and
// virtualization components (ResourceList, VirtualList, photos grid) construct
// on mount. Provide inert stubs so component tests can render them.
class StubObserver {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
takeRecords(): [] {
return [];
}
}
const g = globalThis as Record<string, unknown>;
if (!g.ResizeObserver) g.ResizeObserver = StubObserver;
if (!g.IntersectionObserver) g.IntersectionObserver = StubObserver;
if (!g.scrollTo) g.scrollTo = () => {};
// Node 24+ ships a native global `localStorage`/`sessionStorage` (Web Storage
// API) that is unusable without a backing file and shadows jsdom's storage in
// bare-global access — so `localStorage` reads as undefined in some test files
// on newer Node. Install a deterministic in-memory implementation so storage
// behaves identically across Node versions and is fresh for every test file.
class MemoryStorage {
private store = new Map<string, string>();
get length(): number {
return this.store.size;
}
clear(): void {
this.store.clear();
}
getItem(key: string): string | null {
return this.store.has(key) ? (this.store.get(key) as string) : null;
}
key(index: number): string | null {
return [...this.store.keys()][index] ?? null;
}
removeItem(key: string): void {
this.store.delete(key);
}
setItem(key: string, value: string): void {
this.store.set(key, String(value));
}
}
for (const name of ['localStorage', 'sessionStorage']) {
try {
Object.defineProperty(globalThis, name, {
configurable: true,
writable: true,
value: new MemoryStorage() as unknown as Storage
});
} catch {
g[name] = new MemoryStorage() as unknown as Storage;
}
}

Some files were not shown because too many files have changed in this diff Show More