test(e2e): Playwright + Vitest coverage harness and test instrumentation

Add an end-to-end and unit test suite for the SvelteKit frontend:

- Playwright e2e specs (tests/e2e/spa) with a throwaway container stack,
  codegen scenarios, and an Istanbul-based coverage report pipeline.
- Vitest unit tests across API endpoints, components, stores and composables.
- `data-testid` hooks on interactive elements (AppShell, FileViewer,
  ShareDialog, search, photos, files breadcrumbs, login/Nextcloud flows,
  public share pages) so the e2e suite can target them deterministically.
- Serve the SPA app-shell CSP from a <meta> policy (svelte.config.js) plus a
  middleware that skips the CSP header on HTML; move the Nextcloud Login Flow
  v2 grant page to the SvelteKit /nextcloud/login route.
- `just front-codegen` recipe and start-server-spa.sh harness.

Make the test environment robust and consistent:
- Install a deterministic in-memory localStorage/sessionStorage in the Vitest
  setup so storage behaves identically across Node versions (Node 26 ships a
  native Web Storage global that otherwise shadows jsdom's).
- Pin devenv to Node 26 + PostgreSQL 18 and pin every CI job to Node 26.3.0
  so the dev shell and CI run the same toolchain versions.

Repair the API/WebDAV (hurl) suite, which had drifted from the backend:
- Migrate the removed `/api/folders/{id}/listing` endpoint to `/resources`
  (cursor-paginated `{items:[{resource_type,resource}]}` shape) across the
  batch-copy, grants, nested-group, and WebDAV NC tests + the dav_helpers
  wipe routine.
- Stop photos_etag from uploading the dedup-tracked fixture so the dedup
  blob-lifecycle test can own its content-addressed blob exclusively.
- dedup_create now asserts the idempotent same-content re-upload (201 +
  existing file id) instead of the stale 409 expectation.

Generated coverage reports, nyc output and the e2e server runtime data dir
are gitignored rather than committed.
This commit is contained in:
Bradley Nelson
2026-06-21 20:03:32 -06:00
parent 0c40c69f9b
commit e3823ce470
162 changed files with 13213 additions and 554 deletions
+11 -5
View File
@@ -104,15 +104,21 @@ jsonpath "$.successful[0].files_copied" == 1
# ─────────────────────────────────────────────────────────────
# Step 6 – Verify the copied folder is inside the target folder
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/folders/{{target_folder_id}}/listing
GET {{base_url}}/api/folders/{{target_folder_id}}/resources?resource_types=folder
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$.folders" count == 1
jsonpath "$.folders[0].id" == "{{new_root_folder_id}}"
jsonpath "$.folders[0].name" == "hurl-copy-source"
jsonpath "$.files" count == 0
jsonpath "$.items" count == 1
jsonpath "$.items[0].resource.id" == "{{new_root_folder_id}}"
jsonpath "$.items[0].resource.name" == "hurl-copy-source"
GET {{base_url}}/api/folders/{{target_folder_id}}/resources?resource_types=file
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$.items" count == 0
# ─────────────────────────────────────────────────────────────
+13 -6
View File
@@ -78,11 +78,15 @@ jsonpath "$.content_hash" == "b2208c5dc33ff951227bd0c139f5eccb04105d6da6a7519ee2
# ─────────────────────────────────────────────────────────────
# Step 4 — Duplicate-name probe (incidental but useful): try
# uploading the same fixture again, same name, same
# folder → 409 Conflict. This catches the (folder_id,
# name, user_id) WHERE NOT is_trashed unique index in
# `storage.files`.
# Step 4 — Idempotent re-upload: re-POSTing the same fixture (same
# folder, same name, IDENTICAL content/BLAKE3) is a no-op
# that returns the EXISTING file row, not a 409. See the
# idempotency branch in save_file_with_blob
# (file_blob_write_repository.rs): a (folder_id, name)
# unique clash whose content hash matches resolves to the
# existing file so re-uploading a partially-transferred
# folder is clean; only a same-name upload with DIFFERENT
# content still returns 409.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/files/upload
Authorization: Bearer {{token}}
@@ -90,7 +94,10 @@ Authorization: Bearer {{token}}
folder_id: {{home_folder_id}}
file: file,fixtures/hello-copy.txt; text/plain
HTTP 409
HTTP 201
[Asserts]
jsonpath "$.id" == "{{seed_file_id}}"
jsonpath "$.content_hash" == "{{seed_content_hash}}"
# ─────────────────────────────────────────────────────────────
+2 -2
View File
@@ -424,7 +424,7 @@ Authorization: Bearer {{adam_token}}
HTTP 404
GET {{base_url}}/api/folders/{{perm_folder_id}}/listing
GET {{base_url}}/api/folders/{{perm_folder_id}}/resources
Authorization: Bearer {{adam_token}}
HTTP 404
@@ -547,7 +547,7 @@ Authorization: Bearer {{adam_token}}
HTTP 200
GET {{base_url}}/api/folders/{{perm_folder_id}}/listing
GET {{base_url}}/api/folders/{{perm_folder_id}}/resources
Authorization: Bearer {{adam_token}}
HTTP 200
+2 -2
View File
@@ -164,7 +164,7 @@ Authorization: Bearer {{henry_token}}
HTTP 404
GET {{base_url}}/api/folders/{{perm_folder_id}}/listing
GET {{base_url}}/api/folders/{{perm_folder_id}}/resources
Authorization: Bearer {{henry_token}}
HTTP 404
@@ -303,7 +303,7 @@ Authorization: Bearer {{henry_token}}
HTTP 200
GET {{base_url}}/api/folders/{{perm_folder_id}}/listing
GET {{base_url}}/api/folders/{{perm_folder_id}}/resources
Authorization: Bearer {{henry_token}}
HTTP 200
+6 -2
View File
@@ -37,13 +37,17 @@ home_folder_id: jsonpath "$[0].id"
# ─────────────────────────────────────────────────────────────
# Step 3 – Upload an image so the photos timeline is non-empty
# Step 3 – Upload an image so the photos timeline is non-empty.
# Uses oxicloud-logo.jpg (not dedup-test.jpg): the dedup
# blob-lifecycle test asserts an exact ref_count on the
# dedup-test.jpg blob and must own it exclusively, and
# blobs are content-addressed (shared across the suite).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/files/upload
Authorization: Bearer {{token}}
[MultipartFormData]
folder_id: {{home_folder_id}}
file: file,fixtures/dedup-test.jpg; image/jpeg
file: file,fixtures/oxicloud-logo.jpg; image/jpeg
HTTP 201
+15
View File
@@ -0,0 +1,15 @@
# Generated by the e2e/coverage runs — never commit these.
.nyc_output/
.nyc_output_unit/
coverage-report/
coverage-report-unit/
coverage-report-combined/
playwright-report/
test-results/
# Server runtime data dir created by start-server-spa.sh (blob store + search index).
storage-spa/
storage/
# webServer startup log tee'd by start-server.sh (diagnostics).
server-startup.log
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env node
/**
* Coverage reporter for the SPA suites.
*
* node coverage-report.cjs # e2e only (.nyc_output)
* node coverage-report.cjs unit # unit only (.nyc_output_unit)
* node coverage-report.cjs combined # e2e + unit, merged BY SOURCE LINE
*
* Each mode writes html + json-summary + text-summary under its own report dir
* and prints a per-file table + totals. Paths in the coverage data are absolute
* (.../frontend/src/...); we report them relative to the repo root.
*
* ── Why combined isn't a plain istanbul merge ────────────────────────────
* The e2e build (vite dev server) and the unit build (vitest + the
* @testing-library/svelte client compile) both instrument with
* vite-plugin-istanbul, but they compile each `.svelte` file differently, so
* the resulting statement/fn/branch MAPS have different shapes and ids for the
* same source. istanbul's `CoverageMap.merge` keys by statement INDEX, so
* merging two mismatched maps UNIONS them — inflating the denominator and
* dragging the percentage toward a blend of the two passes instead of the true
* union of covered code (e.g. admin: e2e 2498 + unit 2717 lines → 4042 merged).
*
* Source LINE NUMBERS, however, are identical across the two instrumenters
* (same source file). So for `combined` we merge by line: we take the e2e
* FileCoverage as the canonical structure (it instruments the whole app, the
* widest denominator) and fold the unit pass in by marking any e2e
* statement/fn/branch whose source line the unit pass covered. Files only the
* unit pass loaded pass through unchanged. The denominator stays stable; the
* covered set becomes the genuine e2e ∪ unit union.
*/
const fs = require('fs');
const path = require('path');
const libCoverage = require('istanbul-lib-coverage');
const libReport = require('istanbul-lib-report');
const reports = require('istanbul-reports');
const REPO_ROOT = path.join(__dirname, '..', '..');
const E2E_DIR = path.join(__dirname, '.nyc_output');
const UNIT_DIR = path.join(__dirname, '.nyc_output_unit');
const MODES = {
e2e: { dirs: [E2E_DIR], report: path.join(__dirname, 'coverage-report'), label: 'E2E' },
unit: { dirs: [UNIT_DIR], report: path.join(__dirname, 'coverage-report-unit'), label: 'UNIT' },
combined: {
report: path.join(__dirname, 'coverage-report-combined'),
label: 'COMBINED (e2e ∪ unit, by source line)',
},
};
const mode = process.argv[2] || 'e2e';
const cfg = MODES[mode];
if (!cfg) {
console.error(`Unknown mode "${mode}". Use: e2e | unit | combined`);
process.exit(1);
}
/** Merge every coverage-*.json under `dirs` into one istanbul CoverageMap. */
function loadMap(dirs) {
const m = libCoverage.createCoverageMap({});
let n = 0;
for (const dir of dirs) {
if (!fs.existsSync(dir)) continue;
for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.json'))) {
m.merge(JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')));
n++;
}
}
return { map: m, count: n };
}
/** `{ sourceLine: maxHits }` for every function, keyed by its declaration line. */
function fnHitsByLine(fc) {
const out = {};
const d = fc.data;
for (const id of Object.keys(d.fnMap)) {
const loc = d.fnMap[id].decl || d.fnMap[id].loc;
const line = loc && loc.start ? loc.start.line : undefined;
if (line != null) out[line] = Math.max(out[line] || 0, d.f[id] || 0);
}
return out;
}
/** `{ sourceLine: maxArmHits }` across all branch arms, keyed by arm line. */
function branchHitsByLine(fc) {
const out = {};
const d = fc.data;
for (const id of Object.keys(d.branchMap)) {
const arms = d.b[id] || [];
const locs = d.branchMap[id].locations || [];
for (let k = 0; k < arms.length; k++) {
const loc = locs[k] || d.branchMap[id].loc;
const line = loc && loc.start ? loc.start.line : undefined;
if (line != null) out[line] = Math.max(out[line] || 0, arms[k] || 0);
}
}
return out;
}
/**
* Build the combined map: e2e structure + unit coverage folded in by line.
* Anything the unit pass covered on a given source line marks the
* corresponding (uncovered) e2e statement/fn/branch arm on that line.
*/
function buildLineUnion(mapE2E, mapUnit) {
const out = libCoverage.createCoverageMap({});
const e2eFiles = new Set(mapE2E.files());
const unitFiles = new Set(mapUnit.files());
const all = new Set([...e2eFiles, ...unitFiles]);
for (const file of all) {
if (!e2eFiles.has(file)) {
out.addFileCoverage(mapUnit.fileCoverageFor(file).data);
continue;
}
if (!unitFiles.has(file)) {
out.addFileCoverage(mapE2E.fileCoverageFor(file).data);
continue;
}
// Present in both → base on e2e, overlay unit hits by source line.
const raw = JSON.parse(JSON.stringify(mapE2E.fileCoverageFor(file).data));
const unitFc = mapUnit.fileCoverageFor(file);
const unitLines = unitFc.getLineCoverage(); // { line: hits }
const unitFnLine = fnHitsByLine(unitFc);
const unitBranchLine = branchHitsByLine(unitFc);
for (const id of Object.keys(raw.statementMap)) {
if ((raw.s[id] || 0) === 0) {
const line = raw.statementMap[id].start.line;
if (unitLines[line] > 0) raw.s[id] = unitLines[line];
}
}
for (const id of Object.keys(raw.fnMap)) {
if ((raw.f[id] || 0) === 0) {
const loc = raw.fnMap[id].decl || raw.fnMap[id].loc;
const line = loc && loc.start ? loc.start.line : undefined;
if (line != null && unitFnLine[line] > 0) raw.f[id] = unitFnLine[line];
}
}
for (const id of Object.keys(raw.branchMap)) {
const arms = raw.b[id] || [];
const locs = raw.branchMap[id].locations || [];
for (let k = 0; k < arms.length; k++) {
if ((arms[k] || 0) === 0) {
const loc = locs[k] || raw.branchMap[id].loc;
const line = loc && loc.start ? loc.start.line : undefined;
if (line != null && unitBranchLine[line] > 0) raw.b[id][k] = unitBranchLine[line];
}
}
}
out.addFileCoverage(raw);
}
return out;
}
let map;
let merged;
if (mode === 'combined') {
const e2e = loadMap([E2E_DIR]);
const unit = loadMap([UNIT_DIR]);
merged = e2e.count + unit.count;
if (merged === 0) {
console.error(`No coverage files found for combined in: ${E2E_DIR}, ${UNIT_DIR}`);
process.exit(1);
}
map = buildLineUnion(e2e.map, unit.map);
} else {
const loaded = loadMap(cfg.dirs);
map = loaded.map;
merged = loaded.count;
if (merged === 0) {
console.error(`No coverage files found for mode "${mode}" in: ${cfg.dirs.join(', ')}`);
process.exit(1);
}
}
const context = libReport.createContext({ dir: cfg.report, coverageMap: map });
reports.create('html').execute(context);
reports.create('json-summary').execute(context);
reports.create('text-summary').execute(context);
const rows = map.files().map((file) => {
const s = map.fileCoverageFor(file).toSummary();
return {
file: path.relative(REPO_ROOT, file),
pct: s.lines.pct,
covered: s.lines.covered,
total: s.lines.total,
};
});
rows.sort((a, b) => a.pct - b.pct);
console.log(`\n[${cfg.label}] merged ${merged} coverage file(s); ${rows.length} source files.\n`);
console.log('Least-covered files (line %):');
for (const r of rows.slice(0, 30)) {
console.log(` ${String(r.pct).padStart(6)}% ${r.covered}/${r.total} ${r.file}`);
}
const total = map.getCoverageSummary();
console.log(`\n=== ${cfg.label} TOTAL ===`);
console.log(`Statements : ${total.statements.pct}% (${total.statements.covered}/${total.statements.total})`);
console.log(`Branches : ${total.branches.pct}% (${total.branches.covered}/${total.branches.total})`);
console.log(`Functions : ${total.functions.pct}% (${total.functions.covered}/${total.functions.total})`);
console.log(`Lines : ${total.lines.pct}% (${total.lines.covered}/${total.lines.total})`);
console.log(`HTML report: ${path.relative(process.cwd(), cfg.report)}/index.html`);
+102
View File
@@ -0,0 +1,102 @@
import * as path from 'path';
import { loadEnv } from '../load-env';
/**
* A running, isolated OxiCloud stack: one Postgres container + one OxiCloud
* app container (Svelte SPA baked in, served by the Rust binary) wired
* together on a private Docker network. Both publish random host ports, so
* any number of stacks can run in parallel without collisions.
*/
export interface Stack {
/** Host-mapped base URL of the app container, e.g. `http://localhost:49231`. */
baseURL: string;
/** Tear down the app, DB, and network. Safe to call once per stack. */
stop(): Promise<void>;
}
const REPO_ROOT = path.join(__dirname, '../../..');
const SERVER_ENV_FILE = path.join(__dirname, '../../common/server.env');
// Match the DB image already pinned in tests/common/docker-compose.test.yml.
const POSTGRES_IMAGE = 'postgres:18.2-alpine3.23';
const APP_PORT = 8086;
/**
* Start a fully isolated OxiCloud stack via Testcontainers.
*
* The app image is, in order of preference:
* 1. `$OXICLOUD_IMAGE` — a prebuilt tag (recommended in CI: build once,
* reuse across workers).
* 2. built on demand from the repo-root `Dockerfile` (cached by Docker
* layers; first run is slow, subsequent runs are fast).
*
* `testcontainers` is imported dynamically so the legacy webServer flow
* (which never calls this function) doesn't pay its load cost.
*/
export async function startStack(): Promise<Stack> {
const { GenericContainer, Network, Wait } = await import('testcontainers');
const { PostgreSqlContainer } = await import('@testcontainers/postgresql');
const serverEnv = loadEnv(SERVER_ENV_FILE);
const network = await new Network().start();
const postgres = await new PostgreSqlContainer(POSTGRES_IMAGE)
.withDatabase('oxicloud_test')
.withUsername('oxicloud_test')
.withPassword('oxicloud_test')
.withNetwork(network)
.withNetworkAliases('db')
.start();
// Container-to-container DSN: the app reaches Postgres by its network alias,
// not via the random host port. The stock postgres:18-alpine image already
// ships pg_trgm, ltree, and citext, which the migrations require.
const dbUri = 'postgres://oxicloud_test:oxicloud_test@db:5432/oxicloud_test';
// VITE_E2E=1 keeps the `data-testid` tile hooks (stripped from release builds);
// a prebuilt $OXICLOUD_IMAGE must have been built with the same arg.
const baseImage = process.env.OXICLOUD_IMAGE
? new GenericContainer(process.env.OXICLOUD_IMAGE)
: await GenericContainer.fromDockerfile(REPO_ROOT)
// BuildKit (dockerode build version "2") is required for the Dockerfile's
// `RUN --mount=type=cache` cargo caching; testcontainers defaults to the
// classic builder, which would ignore the mounts. BUILDER=builder-cache
// routes the runtime stage to that cache-mount builder so repeat builds
// recompile only changed crates. (CI uses the default `builder` stage.)
.withBuildkit()
.withBuildArgs({ VITE_E2E: '1', BUILDER: 'builder-cache', BIN_DIR: '/app/bin' })
.build('oxicloud-e2e:latest', {
deleteOnExit: false,
});
const app = await baseImage
.withNetwork(network)
.withExposedPorts(APP_PORT)
.withEnvironment({
...serverEnv,
// Override the localhost:5433 DSN from server.env with the in-network one.
OXICLOUD_DB_CONNECTION_STRING: dbUri,
DATABASE_URL: dbUri,
OXICLOUD_SERVER_PORT: String(APP_PORT),
// The app defaults to binding 127.0.0.1, which a published container port
// can't reach. Bind all interfaces so Testcontainers' mapped port works.
OXICLOUD_SERVER_HOST: '0.0.0.0',
})
// /health is a fast liveness probe (no DB hit); 200 means the HTTP server
// is up and sqlx migrations have completed.
.withWaitStrategy(Wait.forHttp('/health', APP_PORT).forStatusCode(200))
.withStartupTimeout(180_000)
.start();
const baseURL = `http://${app.getHost()}:${app.getMappedPort(APP_PORT)}`;
return {
baseURL,
stop: async () => {
await app.stop().catch(() => {});
await postgres.stop().catch(() => {});
await network.stop().catch(() => {});
},
};
}
Binary file not shown.
Binary file not shown.
+9 -15
View File
@@ -1,18 +1,12 @@
import { TEST_ADMIN } from './scenarios/helpers';
import { seedAdmin } from './scenarios/helpers';
/**
* Legacy webServer flow only: seed the admin against the single server
* Playwright started at :8087. The container flow seeds per-worker inside
* the `stack` fixture instead, and does not use this global setup.
*/
export default async function globalSetup() {
const res = await fetch('http://localhost:8087/api/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: TEST_ADMIN.username,
email: TEST_ADMIN.email,
password: TEST_ADMIN.password,
}),
});
// 409 = admin already exists (idempotent across retries)
if (!res.ok && res.status !== 409) {
throw new Error(`Admin setup failed: ${res.status} ${await res.text()}`);
}
// 127.0.0.1, not `localhost`: matches the server's IPv4 bind; on CI runners
// `localhost` resolves to ::1 (IPv6) first and the seed request is refused.
await seedAdmin('http://127.0.0.1:8087');
}
+32
View File
@@ -0,0 +1,32 @@
import * as fs from 'fs';
/**
* Parse a `KEY=VALUE` env file, skipping blank lines and comments.
*
* Shared by `playwright.config.ts` (legacy webServer flow) and
* `fixtures/oxicloud-stack.ts` (Testcontainers flow) so the test server
* env is defined in exactly one place (`tests/common/server.env`).
*/
export function loadEnv(filePath: string): Record<string, string> {
const env: Record<string, string> = {};
for (const line of fs.readFileSync(filePath, 'utf-8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const idx = trimmed.indexOf('=');
if (idx === -1) continue;
const key = trimmed.slice(0, idx);
let value = trimmed.slice(idx + 1);
// Strip matching surrounding quotes, as `source`-ing the file in a shell
// would. Without this, values like RUST_LOG="warn,audit=info" keep their
// literal quotes and break the consumer (e.g. tracing's filter parser).
if (
value.length >= 2 &&
((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'")))
) {
value = value.slice(1, -1);
}
env[key] = value;
}
return env;
}
+3850 -1
View File
File diff suppressed because it is too large Load Diff
+11 -2
View File
@@ -6,7 +6,13 @@
"scripts": {
"pretest": "bash ../common/spawn-db.sh",
"test": "FORCE_COLOR=true npx playwright test scenarios/01-home-and-login.spec.ts scenarios/02-folder-management.spec.ts",
"posttest": "bash ../common/stop-db.sh"
"posttest": "bash ../common/stop-db.sh",
"test:containers": "OXICLOUD_E2E_CONTAINERS=1 FORCE_COLOR=true npx playwright test -c playwright.containers.config.ts",
"build:spa": "cd ../../frontend && COVERAGE=1 VITE_E2E=1 npm run build",
"test:coverage": "FORCE_COLOR=true npx playwright test -c playwright.coverage.config.ts",
"coverage:report": "node coverage-report.cjs e2e",
"coverage:report:unit": "node coverage-report.cjs unit",
"coverage:report:combined": "node coverage-report.cjs combined"
},
"keywords": [],
"author": "",
@@ -14,6 +20,9 @@
"type": "commonjs",
"devDependencies": {
"@playwright/test": "^1.60.0",
"@types/node": "^25.6.0"
"@testcontainers/postgresql": "^11.0.0",
"@types/node": "^25.6.0",
"nyc": "^18.0.0",
"testcontainers": "^11.0.0"
}
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from '@playwright/test';
import containers from './playwright.containers.config';
/**
* Codegen recorder config — reuses the isolated-stack setup from the container
* e2e config (worker-scoped DB + app container, Nix-chromium executablePath via
* PW_CHROMIUM_PATH, etc.), but points at the per-template recorder files in
* scenarios/codegen/ instead of the e2e suite.
*
* Driven by `just front-codegen`, which sets OXICLOUD_E2E_CONTAINERS=1 (so the
* stack boots) and runs one chosen scenarios/codegen/<name>.spec.ts headed.
*/
export default defineConfig({
...containers,
testDir: './scenarios/codegen',
testIgnore: [],
});
+15 -16
View File
@@ -1,22 +1,9 @@
import { defineConfig, devices } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import { loadEnv } from './load-env';
const startScript = path.join(__dirname, 'start-server.sh');
/** Parse a KEY=VALUE env file, skipping blank lines and comments. */
function loadEnv(filePath: string): Record<string, string> {
const env: Record<string, string> = {};
for (const line of fs.readFileSync(filePath, 'utf-8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const idx = trimmed.indexOf('=');
if (idx === -1) continue;
env[trimmed.slice(0, idx)] = trimmed.slice(idx + 1);
}
return env;
}
const commonEnv = loadEnv(path.join(__dirname, '../common/server.env'));
console.log(`starting playwright with env BUILD_TARGET=${process.env.BUILD_TARGET ?? "debug"}`);
@@ -35,11 +22,17 @@ export default defineConfig({
globalTeardown: require.resolve('./global-teardown'),
use: {
baseURL: 'http://localhost:8087',
// 127.0.0.1, not `localhost`: the server binds IPv4 (127.0.0.1:8087) and
// on CI runners `localhost` resolves to ::1 (IPv6) first, so requests get
// ECONNREFUSED and the webServer readiness check below times out.
baseURL: 'http://127.0.0.1:8087',
trace: 'on-first-retry',
headless: true,
// take a screenshot on failure
screenshot: 'only-on-failure',
// Tile/file rows expose a `data-testid` of the item name (kept only in e2e
// builds via VITE_E2E); makes getByTestId + codegen prefer stable selectors.
testIdAttribute: 'data-testid',
},
expect: {
@@ -57,7 +50,10 @@ export default defineConfig({
command: process.env.BUILD_TARGET
? `bash "${startScript}" "${workspace}/target/${process.env.BUILD_TARGET}/oxicloud"`
: `bash "${startScript}" cargo run`,
url: 'http://localhost:8087',
// Poll the dedicated readiness probe, not `/`: `/ready` returns 200 as soon
// as the DB pool is live, whereas `/` depends on the SPA build being present
// and can 404 (Playwright only treats 2xx/3xx/400-403 as "ready").
url: 'http://127.0.0.1:8087/ready',
timeout: 600_000,
reuseExistingServer: false,
cwd: '../..',
@@ -67,6 +63,9 @@ export default defineConfig({
...commonEnv,
OXICLOUD_SERVER_PORT: '8087',
OXICLOUD_STORAGE_PATH: './tests/e2e/storage',
// Verbose startup so a CI webServer-readiness timeout shows where the
// server stalls (DB connect, migrations, bind) instead of nothing.
RUST_LOG: 'info,oxicloud=debug,sqlx=warn,tower_http=info',
},
},
});
+58
View File
@@ -0,0 +1,58 @@
import { defineConfig, devices } from '@playwright/test';
import * as os from 'os';
/**
* Testcontainers e2e config (Option D): every worker boots its own isolated
* DB + OxiCloud app container (Svelte SPA baked in) via the worker-scoped
* `stack` fixture in `scenarios/helpers.ts`.
*
* Differences from `playwright.config.ts` (legacy single-server flow):
* - No `webServer` — the app is a container started per worker.
* - No `globalSetup` — each worker seeds its own admin in the fixture.
* - No `use.baseURL` — the fixture supplies a per-worker random port.
* - `fullyParallel` + N workers — stacks are fully isolated, so parallel.
*
* Run with: OXICLOUD_E2E_CONTAINERS=1 playwright test -c playwright.containers.config.ts
* Set $OXICLOUD_IMAGE to a prebuilt tag to skip the per-run Dockerfile build.
*/
export default defineConfig({
testDir: './scenarios',
// The recorder harnesses in scenarios/codegen/ call page.pause() and are run
// only via `just front-codegen` (playwright.codegen.config.ts) — never as
// part of the e2e suite.
testIgnore: ['**/codegen/**'],
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: 0,
// One DB + one app container per worker — cap so we don't exhaust Docker.
workers: process.env.CI ? 2 : Math.max(1, Math.floor(os.cpus().length / 4)),
// Real container app is slower than a local cargo process; give actions room.
timeout: 60_000,
reporter: process.env.CI ? [['line'], ['github'], ['html']] : [['list'], ['html']],
use: {
trace: 'on-first-retry',
headless: true,
screenshot: 'only-on-failure',
// Tile/file rows expose a `data-testid` of the item name (kept only in e2e
// builds via VITE_E2E); makes getByTestId + codegen prefer stable selectors.
testIdAttribute: 'data-testid',
// On NixOS (and other distros where Playwright's downloaded chromium can't
// run due to the generic dynamic linker), point at a system/Nix chromium
// via PW_CHROMIUM_PATH. Unset → use Playwright's bundled browser (CI).
launchOptions: process.env.PW_CHROMIUM_PATH
? { executablePath: process.env.PW_CHROMIUM_PATH }
: {},
},
expect: {
toHaveScreenshot: { maxDiffPixelRatio: 0.01 },
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
+70
View File
@@ -0,0 +1,70 @@
import { defineConfig, devices } from '@playwright/test';
import * as path from 'path';
import { loadEnv } from './load-env';
/**
* Coverage e2e suite — drives the **SvelteKit** SPA (built to `static-dist/`
* with Istanbul instrumentation via `COVERAGE=1`) and collects per-test
* `window.__coverage__` into `.nyc_output/` for an nyc report.
*
* This is separate from `playwright.config.ts` (which exercises the legacy
* `./static` vanilla frontend). The server here is a debug `cargo run` with
* `OXICLOUD_STATIC_PATH=./static-dist`, so the SPA — not the legacy app — is
* served on :8088.
*
* Build the instrumented SPA first:
* (cd frontend && COVERAGE=1 VITE_E2E=1 npm run build)
*/
const startScript = path.join(__dirname, 'start-server-spa.sh');
const commonEnv = loadEnv(path.join(__dirname, '../common/server.env'));
const workspace = process.env.GITHUB_WORKSPACE ?? path.join(__dirname, '../..');
export default defineConfig({
testDir: './spa',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: 0,
workers: 1,
reporter: process.env.CI ? [['line'], ['github'], ['html']] : [['list'], ['html']],
globalSetup: require.resolve('./spa/global-setup'),
globalTeardown: require.resolve('./global-teardown'),
use: {
baseURL: 'http://localhost:8088',
trace: 'on-first-retry',
headless: true,
screenshot: 'only-on-failure',
testIdAttribute: 'data-testid',
// NixOS (and distros where Playwright's bundled chromium can't run) need a
// system chromium via PW_CHROMIUM_PATH. Unset → Playwright's bundled
// browser (CI). Mirrors playwright.containers.config.ts.
launchOptions: process.env.PW_CHROMIUM_PATH
? { executablePath: process.env.PW_CHROMIUM_PATH }
: {},
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
command: process.env.BUILD_TARGET
? `bash "${startScript}" "${workspace}/target/${process.env.BUILD_TARGET}/oxicloud"`
: `bash "${startScript}" cargo run --features plugins`,
url: 'http://localhost:8088',
timeout: 600_000,
reuseExistingServer: false,
cwd: '../..',
stdout: 'inherit',
stderr: 'inherit',
env: {
...commonEnv,
OXICLOUD_SERVER_PORT: '8088',
OXICLOUD_STORAGE_PATH: './tests/e2e/storage-spa',
// Serve the instrumented SvelteKit build, not the legacy ./static app.
OXICLOUD_STATIC_PATH: './static-dist',
// Enable the WASM plugin runtime so the admin Plugins tab is exercisable
// (the suite installs the example hello plugin fixture).
OXICLOUD_ENABLE_PLUGINS: 'true',
},
},
});
@@ -0,0 +1,17 @@
import { test, apiLogin, seedFilesAndFolders } from './helpers';
import { expect } from '@playwright/test';
test('TextFilesShowContents', async ({ page }) => {
await apiLogin(page);
await seedFilesAndFolders(page);
await page.goto('/');
// recorded steps
await page.getByTestId('Documents').click();
await page.getByTestId('notes.txt').click();
await expect(page.locator('pre')).toContainText('Hello from the codegen seed. Line two.');
await page.getByTestId('file-viewer-close-btn').click();
await page.getByTestId('README.md').click();
await expect(page.locator('pre')).toContainText('# Seeded A **markdown** file for the file browser.');
await page.getByTestId('file-viewer-close-btn').click();
});
+70
View File
@@ -0,0 +1,70 @@
# Codegen recorder templates
Each file here is a **starting point** for `just front-codegen`. It boots an
isolated container stack, runs your setup, then opens the Playwright Inspector
so you record from a known state. The file name is what shows up in the menu —
drop a new `*.spec.ts` here and it appears automatically.
## Anatomy
```ts
import { test, apiLogin } from '../helpers';
test('codegen: <name>', async ({ page }) => {
test.setTimeout(0); // no timeout — recorder stays open until you close it
await apiLogin(page); // setup: get to the state you want to record from
await page.goto('/files');
await page.pause(); // MUST be last — opens the Inspector
});
```
Rules:
- Import `test` (and any helpers) from `../helpers` — **not** `@playwright/test`.
That wires in the container-stack fixture and the JS-error guard.
- Set `test.setTimeout(0)` so the recorder doesn't time out while you work.
- End with `await page.pause()`. Everything **before** it runs first, so put as
much setup as you like there (log in, open a folder, start an upload, …) and
you'll record the continuation from that state.
- Use the helpers for setup: `apiLogin(page)` signs in via the API (no UI
clicks, selector-independent). Skip it for an anonymous/login flow.
## What carries into the saved spec
When you save a recording, the setup lines (everything before `page.pause()`,
minus `test.setTimeout`) are copied into the generated `scenarios/<name>.spec.ts`,
then your recorded steps are spliced in. So a recorder's setup == the saved
test's setup — keep it to the state you want every recording from this template
to start in.
## Seeding content before you record
To record flows that need existing files/folders (move, copy, delete,
multi-select, previews, sorting by type), seed them via the API in the setup —
no UI clicks, just like `apiLogin`. The `authed-files` template does this:
```ts
import { test, apiLogin, seedFilesAndFolders } from '../helpers';
test('codegen: authed-files', async ({ page }) => {
test.setTimeout(0);
await apiLogin(page);
await seedFilesAndFolders(page); // Documents/, Documents/Reports/, Images/ + files of each type
await page.goto('/');
await page.pause();
});
```
`helpers.ts` exposes the building blocks: `apiCreateFolder`, `apiUploadFile`,
`SAMPLE_FILES` (text / markdown / JSON / CSV / PNG / PDF), and the
`seedFilesAndFolders` convenience that lays down a small mixed-type tree.
**Remember:** the saved spec inherits this setup, so the recorded selectors only
have content to act on if `seedFilesAndFolders(page)` runs there too — it does,
because the template's setup is copied into the saved test.
## Add one
```sh
cp authed.spec.ts my-start.spec.ts # edit the setup, keep page.pause() last
just front-codegen # "my-start" is now in the menu
```
+15
View File
@@ -0,0 +1,15 @@
import { test } from '../helpers';
/**
* Codegen recorder — START: anonymous at the app root.
*
* Run via `just front-codegen` → pick "anon". No login; navigates to `/` and
* lets the app route an unauthenticated visitor (currently it redirects to
* /login?redirect=/). Use this to record the logged-out redirect behaviour or
* a future public landing page. Click the Record ⏺ button to start generating.
*/
test('codegen: anon', async ({ page }) => {
test.setTimeout(0); // recorder stays open until you close the Inspector
await page.goto('/');
await page.pause();
});
@@ -0,0 +1,30 @@
import { test, apiLogin, seedFilesAndFolders } from '../helpers';
/**
* Codegen recorder — START: signed in, on a file browser already populated
* with folders and files of different types.
*
* Builds on the "authed" template: same `apiLogin`, but also seeds a small
* tree via the API before pausing, so you record flows that need existing
* content (move/copy/delete, drag-drop, previews, multi-select, sorting by
* type, …) without first creating it by hand.
*
* Seeded in the home folder (see `seedFilesAndFolders`):
* config.json, pixel.png (root)
* Documents/ → README.md, notes.txt
* Documents/Reports/ → data.csv, sample.pdf
* Images/ → pixel.png
*
* Run via `just front-codegen` → pick "authed-files". Click the Record ⏺
* button in the Inspector to start generating; copy the code into a real
* *.spec.ts (see scenarios/example.template.ts). Note the saved test must run
* `seedFilesAndFolders(page)` in its setup too, or the recorded selectors
* won't have anything to act on.
*/
test('codegen: authed-files', async ({ page }) => {
test.setTimeout(0); // recorder stays open until you close the Inspector
await apiLogin(page);
await seedFilesAndFolders(page);
await page.goto('/');
await page.pause();
});
@@ -0,0 +1,16 @@
import { test, apiLogin } from '../helpers';
/**
* Codegen recorder — START: signed in, on the trash view.
*
* Run via `just front-codegen` → pick "authed-trash". An example of a
* deep-linked start point — copy this file (apiLogin + goto a route) to add
* your own recorders; `just front-codegen` discovers every *.spec.ts here
* automatically. Click the Record ⏺ button in the Inspector to start generating.
*/
test('codegen: authed-trash', async ({ page }) => {
test.setTimeout(0); // recorder stays open until you close the Inspector
await apiLogin(page);
await page.goto('/trash');
await page.pause();
});
@@ -0,0 +1,20 @@
import { test, apiLogin } from '../helpers';
/**
* Codegen recorder — START: signed in, on the file browser.
*
* Run via `just front-codegen` → pick "authed". Boots an isolated container
* stack, runs the setup below, then page.pause() opens the Inspector. Click
* the Record ⏺ button to start generating; copy the code into a real
* *.spec.ts (see scenarios/example.template.ts).
*
* Extend the setup to record from a deeper state (e.g. open a folder, start an
* upload) — everything before page.pause() runs first, so you record the
* continuation.
*/
test('codegen: authed', async ({ page }) => {
test.setTimeout(0); // recorder stays open until you close the Inspector
await apiLogin(page);
await page.goto('/');
await page.pause();
});
+13
View File
@@ -0,0 +1,13 @@
import { test } from '../helpers';
/**
* Codegen recorder — START: the SPA sign-in screen (not authenticated).
*
* Run via `just front-codegen` → pick "login". Use this to record the login
* flow itself. Click the Record ⏺ button in the Inspector to start generating.
*/
test('codegen: login', async ({ page }) => {
test.setTimeout(0); // recorder stays open until you close the Inspector
await page.goto('/login');
await page.pause();
});
+29
View File
@@ -0,0 +1,29 @@
/**
* Scaffold for a recorded spec — NOT collected by the runner (no `.spec`/`.test`
* in the name). Turn a codegen recording into a real test:
*
* 1. cp scenarios/example.template.ts scenarios/<name>.spec.ts
* 2. Match the setup to the codegen TEMPLATE you recorded from (below).
* 3. Paste the recorded steps from the Playwright Inspector where marked.
* 4. Add assertions.
*
* Run it with: npm run test:containers
*/
import { test, apiLogin } from './helpers';
// import { expect } from '@playwright/test'; // ← uncomment when you add assertions
test('describe the behaviour under test', async ({ page }) => {
// ── Setup: mirror your codegen TEMPLATE ──────────────────────────────────
// template "authed" / "authed-*":
await apiLogin(page);
await page.goto('/');
// template "login": (remove the two lines above) await page.goto('/login');
// template "anon": (remove the two lines above) await page.goto('/');
// ── ▼ Paste recorded steps from the Inspector below ▼ ────────────────────
// ── ▲ End recorded steps ▲ ───────────────────────────────────────────────
// Assertions, e.g.:
// await expect(page.getByRole('heading', { name: 'Files' })).toBeVisible();
});
+319 -4
View File
@@ -1,13 +1,60 @@
import { test as base, Page, expect } from '@playwright/test';
import { startStack, Stack } from '../fixtures/oxicloud-stack';
/**
* Extended `test` fixture that fails on any unhandled browser-side JavaScript
* error (SyntaxError, ReferenceError, uncaught promise rejections, etc.).
* When `OXICLOUD_E2E_CONTAINERS=1`, each Playwright worker boots its own
* isolated DB + app stack via Testcontainers (see `playwright.containers.
* config.ts`). Otherwise the legacy single-server webServer flow is used
* and this fixture is an inert pass-through.
*/
const USE_CONTAINERS = process.env.OXICLOUD_E2E_CONTAINERS === '1';
type WorkerFixtures = {
/** The per-worker isolated stack, or `null` in the legacy webServer flow. */
stack: Stack | null;
};
/**
* Extended `test` fixture with two responsibilities:
*
* 1. `stack` (worker-scoped) — in container mode, starts a dedicated
* DB + app stack per worker, seeds its admin, and tears it down at
* worker exit. The app instance is reused across every test the worker
* runs, so container startup is paid once per worker, not per test.
* 2. `page` — fails any test that produces an unhandled browser-side JS
* error (SyntaxError, ReferenceError, uncaught rejection, etc.).
*
* Import `test` from this module instead of `@playwright/test` so every spec
* gets the listener automatically without per-file boilerplate.
* gets both behaviours automatically without per-file boilerplate.
*/
export const test = base.extend<object>({
export const test = base.extend<object, WorkerFixtures>({
stack: [
async ({}, use) => {
if (!USE_CONTAINERS) {
await use(null);
return;
}
const stack = await startStack();
try {
await seedAdmin(stack.baseURL);
await use(stack);
} finally {
await stack.stop();
}
},
// Worker-scoped: booting Postgres + the app container (image build on
// first run, migrations on boot) can take well over the default 30s
// fixture timeout. Match the 180s container startup budget in startStack.
{ scope: 'worker', timeout: 200_000 },
],
// Point relative `page.goto('/')` at the per-worker stack when present;
// otherwise fall back to the baseURL configured in the project (the
// legacy webServer at :8087).
baseURL: async ({ stack }, use, testInfo) => {
await use(stack ? stack.baseURL : testInfo.project.use.baseURL);
},
page: async ({ page }, use) => {
const jsErrors: Error[] = [];
page.on('pageerror', (err) => jsErrors.push(err));
@@ -27,6 +74,274 @@ export const TEST_ADMIN = {
password: 'TestPassword1!',
};
/**
* Create the first-admin account via the public `POST /api/setup` route.
* Idempotent: a 409 (admin already exists) is treated as success so the
* call is safe to retry and to run once per worker.
*
* Shared by `global-setup.ts` (legacy flow, single server) and the
* worker-scoped `stack` fixture (container flow, one server per worker).
*/
export async function seedAdmin(baseURL: string, admin = TEST_ADMIN): Promise<void> {
const res = await fetch(`${baseURL}/api/setup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: admin.username,
email: admin.email,
password: admin.password,
}),
});
if (!res.ok && res.status !== 409) {
throw new Error(`Admin setup failed: ${res.status} ${await res.text()}`);
}
}
/**
* Authenticate the page's browser context via the API, so a subsequent
* `page.goto()` loads already signed in — no UI clicks. Selector-independent,
* which keeps it robust while the SPA login markup is still in flux.
*
* `POST /api/auth/login` is CSRF-exempt and sets the auth cookies on the
* context's (shared) cookie jar, so `page.request` here authenticates the
* page too. Use this at the top of any spec that needs an authenticated app
* (and in the codegen recorder, so you record post-login flows).
*/
export async function apiLogin(page: Page, admin = TEST_ADMIN): Promise<void> {
const res = await page.request.post('/api/auth/login', {
data: { username: admin.username, password: admin.password },
});
if (!res.ok()) {
throw new Error(`apiLogin failed: ${res.status()} ${await res.text()}`);
}
}
/**
* Build the `x-csrf-token` header for a cookie-authenticated, state-changing
* request. The server uses a double-submit cookie: login sets a non-HttpOnly
* `oxicloud_csrf` cookie, and POST/PUT/DELETE must echo its value in the
* header (see `csrf_middleware`). Returns `{}` if no cookie is present (e.g.
* not logged in), letting the caller's request fail with the real 4xx.
*/
async function csrfHeaders(page: Page): Promise<Record<string, string>> {
const cookies = await page.context().cookies();
const token = cookies.find((c) => c.name === 'oxicloud_csrf')?.value;
return token ? { 'x-csrf-token': token } : {};
}
/** A folder as returned by the API (subset we use). */
export type ApiFolder = { id: string; parent_id: string | null };
/**
* Create a folder via the API and return it. `parentId` omitted ⇒ the folder
* is created in the caller's home (root) folder, and the returned `parent_id`
* is that home folder's id (handy as the target for "root" file uploads, which
* require an explicit folder id). Requires the page to already be
* authenticated (see `apiLogin`).
*/
export async function apiCreateFolder(
page: Page,
name: string,
parentId?: string,
): Promise<ApiFolder> {
const res = await page.request.post('/api/folders', {
headers: await csrfHeaders(page),
data: parentId ? { name, parent_id: parentId } : { name },
});
if (!res.ok()) {
throw new Error(`apiCreateFolder(${name}) failed: ${res.status()} ${await res.text()}`);
}
return (await res.json()) as ApiFolder;
}
/**
* Create a regular user via the admin API. Requires the page to be authenticated
* as an admin (see `apiLogin`). Returns the created username. Handy for tests
* that need a second account (sharing, group membership, recipient search).
*/
export async function apiAdminCreateUser(page: Page, username: string): Promise<string> {
const res = await page.request.post('/api/admin/users', {
headers: await csrfHeaders(page),
data: {
username,
password: 'TestPassword1!',
email: `${username}@example.test`,
role: 'user',
quota_bytes: 1073741824,
},
});
if (!res.ok()) {
throw new Error(`apiAdminCreateUser(${username}) failed: ${res.status()} ${await res.text()}`);
}
return username;
}
/**
* Create a group via the API (requires an authenticated admin/manager). Returns
* the group name. Useful for sharing-with-group and group-membership tests.
*/
export async function apiCreateGroup(page: Page, name: string): Promise<string> {
const res = await page.request.post('/api/groups', {
headers: await csrfHeaders(page),
data: { name, description: null },
});
if (!res.ok()) {
throw new Error(`apiCreateGroup(${name}) failed: ${res.status()} ${await res.text()}`);
}
return name;
}
/** Move a folder to trash via the API (DELETE /api/folders/{id}). */
export async function apiTrashFolder(page: Page, folderId: string): Promise<void> {
const res = await page.request.delete(`/api/folders/${folderId}`, {
headers: await csrfHeaders(page),
});
if (!res.ok()) {
throw new Error(`apiTrashFolder(${folderId}) failed: ${res.status()} ${await res.text()}`);
}
}
/**
* Record an access in the user's "recent" list (POST /api/recent/{type}/{id})
* so the /recent route has deterministic content. Best-effort: a non-2xx is
* tolerated so callers don't fail on a recents quirk.
*/
export async function apiRecordRecent(
page: Page,
itemType: 'file' | 'folder',
id: string,
): Promise<void> {
await page.request
.post(`/api/recent/${itemType}/${id}`, { headers: await csrfHeaders(page) })
.catch(() => {});
}
/** Empty the trash via the API (DELETE /api/trash/empty) for a clean slate. */
export async function apiEmptyTrash(page: Page): Promise<void> {
const res = await page.request.delete('/api/trash/empty', { headers: await csrfHeaders(page) });
if (!res.ok()) {
throw new Error(`apiEmptyTrash failed: ${res.status()} ${await res.text()}`);
}
}
/** A file to seed: its name, MIME type, and raw bytes. */
export type SeedFile = { name: string; mimeType: string; body: Buffer };
/**
* Upload one file via the API into `folderId`. The target folder is
* **required**: the server resolves the file's owner from its parent folder
* and rejects an upload with no `folder_id` ("folder_id is required to
* determine file owner"). For a "root" file, pass the home folder's id — the
* `parent_id` returned by `apiCreateFolder(name)`.
*
* The `folder_id` field is sent before `file` because the upload handler
* parses the multipart stream in order and permission-checks the target folder
* before spooling the body.
*/
export async function apiUploadFile(
page: Page,
file: SeedFile,
folderId: string,
): Promise<void> {
const filePart = { name: file.name, mimeType: file.mimeType, buffer: file.body };
const res = await page.request.post('/api/files/upload', {
headers: await csrfHeaders(page),
multipart: { folder_id: folderId, file: filePart },
});
if (!res.ok()) {
throw new Error(`apiUploadFile(${file.name}) failed: ${res.status()} ${await res.text()}`);
}
}
/**
* A small library of files spanning common types (text, markdown, JSON, CSV,
* PNG image, PDF), so a recording starts from a browser that exercises the
* different icons / previews / row renderers. Bytes are tiny but valid.
*/
export const SAMPLE_FILES = {
text: (): SeedFile => ({
name: 'notes.txt',
mimeType: 'text/plain',
body: Buffer.from('Hello from the codegen seed.\nLine two.\n'),
}),
markdown: (): SeedFile => ({
name: 'README.md',
mimeType: 'text/markdown',
body: Buffer.from('# Seeded\n\nA **markdown** file for the file browser.\n'),
}),
json: (): SeedFile => ({
name: 'config.json',
mimeType: 'application/json',
body: Buffer.from(JSON.stringify({ seeded: true, items: [1, 2, 3] }, null, 2)),
}),
csv: (): SeedFile => ({
name: 'data.csv',
mimeType: 'text/csv',
body: Buffer.from('id,name,size\n1,alpha,10\n2,beta,20\n'),
}),
png: (): SeedFile => ({
name: 'pixel.png',
mimeType: 'image/png',
// 1×1 transparent PNG.
body: Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64',
),
}),
pdf: (): SeedFile => ({
name: 'sample.pdf',
mimeType: 'application/pdf',
body: Buffer.from(
'%PDF-1.1\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n' +
'2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n' +
'3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]>>endobj\n' +
'trailer<</Root 1 0 R>>\n%%EOF\n',
),
}),
};
/**
* Seed a representative tree of folders and files of different types for the
* authenticated user, so a codegen recording (or a test) starts from a
* populated file browser. Idempotent enough for one run per worker; re-running
* creates duplicate names (the backend allows them). Requires `apiLogin` first.
*
* Layout created in the user's home folder:
*
* config.json (home/root)
* pixel.png (home/root)
* Documents/ README.md, notes.txt
* Documents/Reports/ data.csv, sample.pdf
* Images/ pixel.png
*
* Returns the created folder ids (plus the resolved `home` id) so callers can
* deep-link or assert.
*/
export async function seedFilesAndFolders(
page: Page,
): Promise<{ home: string; documents: string; reports: string; images: string }> {
const documents = await apiCreateFolder(page, 'Documents');
const reports = await apiCreateFolder(page, 'Reports', documents.id);
const images = await apiCreateFolder(page, 'Images');
// Created-at-root folders carry the home folder id as their parent — use it
// as the target for the "root" files (uploads require an explicit folder).
const home = documents.parent_id;
if (!home) {
throw new Error('seedFilesAndFolders: could not resolve home folder id from a root folder');
}
await apiUploadFile(page, SAMPLE_FILES.json(), home);
await apiUploadFile(page, SAMPLE_FILES.png(), home);
await apiUploadFile(page, SAMPLE_FILES.markdown(), documents.id);
await apiUploadFile(page, SAMPLE_FILES.text(), documents.id);
await apiUploadFile(page, SAMPLE_FILES.csv(), reports.id);
await apiUploadFile(page, SAMPLE_FILES.pdf(), reports.id);
await apiUploadFile(page, SAMPLE_FILES.png(), images.id);
return { home, documents: documents.id, reports: reports.id, images: images.id };
}
/**
* Log in as the test admin and wait until the main app is fully initialized.
*
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# Interactive Playwright codegen driver (invoked by `just front-codegen`).
#
# Pick a starting-point recorder from scenarios/codegen/, record against a
# throwaway container stack, then turn the recording into a real spec:
# record → name (re-prompts until free) → $EDITOR paste → assemble → run →
# reopen in --debug on failure.
set -euo pipefail
# Run from tests/e2e regardless of how we were invoked.
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# ── Pick a recorder (menu of scenarios/codegen/*.spec.ts) ─────────────────────
shopt -s nullglob
files=(scenarios/codegen/*.spec.ts)
[[ ${#files[@]} -gt 0 ]] || { echo "no recorder files in scenarios/codegen/" >&2; exit 1; }
names=()
for f in "${files[@]}"; do names+=("$(basename "$f" .spec.ts)"); done
echo "Pick a codegen starting point:"
PS3="› "
select n in "${names[@]}"; do [[ -n "${n:-}" ]] && break; echo " invalid choice — enter a number"; done
echo "→ recorder: $n"
# ── Environment ───────────────────────────────────────────────────────────────
# NixOS (and distros where Playwright's bundled chromium can't run) need a
# system chromium. Respect an explicit PW_CHROMIUM_PATH, else auto-detect.
chromium="${PW_CHROMIUM_PATH:-$(command -v chromium 2>/dev/null || true)}"
if [[ -n "$chromium" ]]; then export PW_CHROMIUM_PATH="$chromium"; echo "→ chromium: $chromium"; fi
# Build the app image from the CURRENT source for codegen, so recordings reflect
# the latest frontend (and its data-testid hooks) instead of a stale image.
#
# We build it HERE with the buildx CLI rather than letting the Testcontainers
# fixture build it. The fixture's JS-driven BuildKit build does NOT reuse the
# local cargo cache mounts, so it cold-compiles the whole binary every run
# (>5 min) and blows past the 200 s stack-setup timeout. The CLI build uses the
# `builder-cache` stage and the shared cache mounts, so it recompiles only what
# changed (seconds on a warm cache); we then hand the tag to the fixture via
# $OXICLOUD_IMAGE, which makes it skip its own build entirely.
#
# Escape hatch: export OXICLOUD_IMAGE yourself to force-reuse a prebuilt image
# (it must be built with --build-arg VITE_E2E=1, and may be stale vs local edits).
if [[ -n "${OXICLOUD_IMAGE:-}" ]]; then
echo "→ OXICLOUD_IMAGE=$OXICLOUD_IMAGE (env override) — reusing it; needs VITE_E2E=1 and may be stale."
else
REPO_ROOT="$(cd ../.. && pwd)"
OXICLOUD_IMAGE="oxicloud-e2e:latest"
echo "→ building $OXICLOUD_IMAGE from current source (buildx, incremental — only changed crates/assets recompile)…"
DOCKER_BUILDKIT=1 docker build \
--build-arg BUILDER=builder-cache \
--build-arg BIN_DIR=/app/bin \
--build-arg VITE_E2E=1 \
--tag "$OXICLOUD_IMAGE" \
"$REPO_ROOT"
export OXICLOUD_IMAGE
echo "→ OXICLOUD_IMAGE=$OXICLOUD_IMAGE (prebuilt; fixture will reuse it)"
fi
export OXICLOUD_E2E_CONTAINERS=1
# ── Record ────────────────────────────────────────────────────────────────────
npx playwright test -c playwright.codegen.config.ts "scenarios/codegen/$n.spec.ts" --headed --workers=1
# ── Save the recording as a real spec ─────────────────────────────────────────
# Keeps the template's setup (apiLogin/goto), drops page.pause(), and splices in
# the steps you paste (handles a full codegen file or bare action lines).
echo
# Prompt for a spec name until it's valid and not already taken (blank = skip).
spec=""
while :; do
read -rp "Save recording to a spec? Enter a name (blank to skip): " out || true
[[ -z "${out:-}" ]] && break
spec="$(node scripts/finish-codegen.mjs --resolve "$out")" && break || true
done
[[ -n "$spec" ]] || exit 0
# Collect the recording in $VISUAL/$EDITOR (paste, then save & close). For GUI
# editors set a blocking flag, e.g. EDITOR="code --wait". Falls back to nano/vi,
# then to a Ctrl-D paste if no editor is available.
editor="${VISUAL:-${EDITOR:-}}"
[[ -z "$editor" ]] && editor="$(command -v nano 2>/dev/null || command -v vi 2>/dev/null || true)"
tmp="$(mktemp --suffix=.recording.ts)"
trap 'rm -f "$tmp"' EXIT
if [[ -n "$editor" ]]; then
echo "Opening $editor — paste the recorded output, then save & close."
$editor "$tmp"
recording="$(cat "$tmp")"
else
echo "No \$VISUAL/\$EDITOR set. Paste the recorded output, then press Ctrl-D:"
recording="$(cat)"
fi
if [[ -z "${recording//[[:space:]]/}" ]]; then
echo "(empty — nothing saved)"
exit 0
fi
printf '%s' "$recording" | node scripts/finish-codegen.mjs "scenarios/codegen/$n.spec.ts" "$out" >/dev/null
# ── Run the new spec; reopen in --debug on failure ────────────────────────────
echo "→ running $spec"
if npx playwright test -c playwright.containers.config.ts "$spec" --workers=1; then
echo "✓ $spec passed"
else
echo "✗ $spec failed — reopening in debug mode…"
npx playwright test -c playwright.containers.config.ts "$spec" --workers=1 --debug
fi
+120
View File
@@ -0,0 +1,120 @@
// Assemble a real spec from a codegen recording.
//
// Usage: node scripts/finish-codegen.mjs <templatePath> <outName> (recording on stdin)
//
// Takes the recorder template the user started from (scenarios/codegen/<x>.spec.ts),
// keeps its SETUP (everything before page.pause(), minus test.setTimeout), and
// splices in the recorded steps read from stdin — writing scenarios/<outName>.spec.ts.
//
// The pasted recording may be either bare action lines, e.g.
// await page.getByText('…').click();
// or a whole codegen file, e.g.
// import { test, expect } from '@playwright/test';
// test('test', async ({ page }) => { await page.…; });
// In the latter case the import + test(...) wrapper are stripped, leaving the body.
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
const argv = process.argv.slice(2);
/** Sanitize a name to scenarios/<base>.spec.ts; exit non-zero if invalid/taken. */
function resolveOutPath(rawName) {
const base = (rawName ?? '')
.trim()
.replace(/\.spec\.ts$/, '')
.replace(/[^a-zA-Z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '');
if (!base) {
console.error('finish-codegen: empty/invalid output name');
process.exit(1);
}
const outPath = `scenarios/${base}.spec.ts`;
if (existsSync(outPath)) {
console.error(`finish-codegen: ${outPath} already exists — pick another name`);
process.exit(1);
}
return { base, outPath };
}
// `--resolve <name>`: validate a name + print its spec path (no stdin, no write).
// The just recipe loops this to re-prompt until a free, valid name is given.
if (argv[0] === '--resolve') {
console.log(resolveOutPath(argv[1]).outPath);
process.exit(0);
}
const [templatePath, rawOutName] = argv;
if (!templatePath || !rawOutName) {
console.error('usage: finish-codegen.mjs <templatePath> <outName> (recording on stdin)');
console.error(' or: finish-codegen.mjs --resolve <outName>');
process.exit(1);
}
const recording = readFileSync(0, 'utf-8'); // stdin
const template = readFileSync(templatePath, 'utf-8');
const { base, outPath } = resolveOutPath(rawOutName);
/** Body between the first `=> {` and the final `});` of a test file/snippet. */
function callbackBody(src) {
const m = src.match(/=>\s*\{([\s\S]*)\}\s*\)\s*;?\s*$/);
return m ? m[1] : null;
}
/** Re-indent non-empty lines to a common 2-space base. */
function reindent(lines, spaces = 2) {
const body = lines.filter((l) => l.trim());
const min = body.length ? Math.min(...body.map((l) => l.match(/^\s*/)[0].length)) : 0;
const pad = ' '.repeat(spaces);
return lines.map((l) => (l.trim() ? pad + l.slice(min) : ''));
}
// ── 1. helpers import from the template (fix ../helpers → ./helpers) ──────────
const helperImport =
(template.match(/^import .*from ['"]\.\.\/helpers['"];?$/m) || [])[0]?.replace(
/\.\.\/helpers/,
'./helpers',
) ?? "import { test } from './helpers';";
// ── 2. setup lines = template body before page.pause(), minus setTimeout ──────
const tBody = callbackBody(template);
const setupLines = reindent(
(tBody ?? '')
.split('\n')
.filter((l) => l.trim() && !/page\.pause\(/.test(l) && !/test\.setTimeout\(/.test(l)),
);
// ── 3. recorded steps from stdin (strip wrapper if a whole file was pasted) ───
let recBody = recording;
if (/^\s*(import\b|test\s*\()/.test(recording)) {
recBody = callbackBody(recording) ?? recording;
}
let actionLines = recBody.split('\n').filter((l) => !/^\s*import\s/.test(l));
while (actionLines.length && !actionLines[0].trim()) actionLines.shift();
while (actionLines.length && !actionLines[actionLines.length - 1].trim()) actionLines.pop();
actionLines = reindent(actionLines);
if (!actionLines.length) {
console.error('finish-codegen: no recorded steps found in the pasted input');
process.exit(1);
}
// ── 4. compose ───────────────────────────────────────────────────────────────
const imports = [helperImport];
if (/\bexpect\(/.test(actionLines.join('\n'))) {
imports.push("import { expect } from '@playwright/test';");
}
const out = [
...imports,
'',
`test('${base.replace(/'/g, "\\'")}', async ({ page }) => {`,
...setupLines,
'',
' // recorded steps',
...actionLines,
'});',
'',
].join('\n');
writeFileSync(outPath, out);
console.error(`✓ wrote ${outPath}`);
console.log(outPath); // stdout = machine-readable path; the recipe runs it
+307
View File
@@ -0,0 +1,307 @@
import * as path from 'path';
import { test, expect } from './coverage-helpers';
import { apiLogin, apiAdminCreateUser } from '../scenarios/helpers';
const PLUGIN_ZIP = path.join(__dirname, '..', 'fixtures', 'plugin-hello.zip');
/**
* Admin panel — walk every tab and open a couple of forms. The admin route is
* one of the largest source files; exercising each tab pulls in a lot of it
* plus the admin endpoint module.
*/
test.beforeEach(async ({ page }) => {
await apiLogin(page);
});
test('walk every admin tab', async ({ page }) => {
await page.goto('/admin');
await expect(page.getByTestId('admin-dashboard-tab')).toBeVisible({ timeout: 15_000 });
// Dashboard is the default tab.
await expect(page.getByTestId('admin-dashboard-registration-checkbox')).toBeVisible();
await page.getByTestId('admin-users-tab').click();
await expect(page.getByTestId('admin-users-create-btn')).toBeVisible();
await page.getByTestId('admin-oidc-tab').click();
await expect(page.getByTestId('admin-oidc-form')).toBeVisible();
await page.getByTestId('admin-storage-tab').click();
await expect(page.getByTestId('admin-storage-form')).toBeVisible();
await page.getByTestId('admin-smtp-tab').click();
await expect(page.getByTestId('admin-smtp-send-btn')).toBeVisible();
await page.getByTestId('admin-plugins-tab').click();
// Plugins panel content is conditional; assert the tab became active.
await expect(page.getByTestId('admin-plugins-tab')).toHaveAttribute('aria-selected', 'true');
});
test('open the create-user form', async ({ page }) => {
await page.goto('/admin');
await page.getByTestId('admin-users-tab').click();
await page.getByTestId('admin-users-create-btn').click();
await expect(page.getByTestId('admin-create-user-form')).toBeVisible({ timeout: 15_000 });
});
test('storage tab: change backend select', async ({ page }) => {
await page.goto('/admin');
await page.getByTestId('admin-storage-tab').click();
await expect(page.getByTestId('admin-storage-form')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('admin-storage-backend-select').selectOption({ index: 1 }).catch(() => {});
});
test('storage tab: save the local backend settings', async ({ page }) => {
await page.goto('/admin');
await page.getByTestId('admin-storage-tab').click();
await expect(page.getByTestId('admin-storage-form')).toBeVisible({ timeout: 15_000 });
// Keep the (safe) local backend and save — exercises the save handler without
// reconfiguring storage to a remote backend.
await page.getByTestId('admin-storage-backend-select').selectOption('local').catch(() => {});
await page.getByTestId('admin-storage-save-btn').click().catch(() => {});
await expect(page.getByTestId('admin-storage-form')).toBeVisible();
});
test('oidc tab: toggle enabled and fill issuer', async ({ page }) => {
await page.goto('/admin');
await page.getByTestId('admin-oidc-tab').click();
await expect(page.getByTestId('admin-oidc-form')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('admin-oidc-enabled-checkbox').check().catch(() => {});
await page
.getByTestId('admin-oidc-issuer-input')
.fill('https://example.test/issuer', { timeout: 2_000 })
.catch(() => {});
});
function uniqUser(): string {
return `u${Date.now()}${Math.floor(Math.random() * 1e6)}`;
}
/**
* Create a user via the admin form, dismiss the (modal-backdrop) create dialog,
* and return a locator for that user's table row.
*/
async function createUserRow(
page: import('@playwright/test').Page,
uname: string,
): Promise<ReturnType<import('@playwright/test').Page['locator']>> {
await page.goto('/admin');
await page.getByTestId('admin-users-tab').click();
await page.getByTestId('admin-users-create-btn').click();
await expect(page.getByTestId('admin-create-user-form')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('admin-create-user-username-input').fill(uname);
await page.getByTestId('admin-create-user-email-input').fill(`${uname}@example.test`);
await page.getByTestId('admin-create-user-password-input').fill('TestPassword1!');
await page.getByTestId('admin-create-user-submit-btn').click();
const row = page.locator('tr').filter({ hasText: uname });
await expect(row).toBeVisible({ timeout: 15_000 });
// The create flow can leave a modal open whose backdrop blocks row actions.
await page.keyboard.press('Escape');
const cancel = page.getByTestId('admin-create-user-cancel-btn');
if (await cancel.isVisible().catch(() => false)) await cancel.click();
await expect(page.locator('.modal__backdrop')).toHaveCount(0, { timeout: 10_000 });
return row;
}
test('create and delete a user', async ({ page }) => {
const uname = uniqUser();
const row = await createUserRow(page, uname);
// Delete — admin uses its own confirm modal (admin-confirm-ok-btn).
await row.locator('[data-testid^="admin-user-delete-"]').first().click();
await page.getByTestId('admin-confirm-ok-btn').click();
await expect(page.locator('tr').filter({ hasText: uname })).toHaveCount(0, { timeout: 15_000 });
});
test('toggle a user role and active state', async ({ page }) => {
const uname = uniqUser();
const row = await createUserRow(page, uname);
// Each of these confirms through the admin confirm modal.
await row.locator('[data-testid^="admin-user-toggle-role-"]').first().click();
await page.getByTestId('admin-confirm-ok-btn').click();
await row.locator('[data-testid^="admin-user-toggle-active-"]').first().click(); // deactivate
await page.getByTestId('admin-confirm-ok-btn').click();
await row.locator('[data-testid^="admin-user-toggle-active-"]').first().click(); // reactivate
await page.getByTestId('admin-confirm-ok-btn').click();
await expect(row).toBeVisible();
});
test('reset a user password', async ({ page }) => {
const uname = uniqUser();
const row = await createUserRow(page, uname);
await row.locator('[data-testid^="admin-user-reset-password-"]').first().click();
await expect(page.getByTestId('admin-reset-password-form')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('admin-reset-password-input').fill('NewPassword1!');
await page.getByTestId('admin-reset-password-submit-btn').click();
await expect(page.getByTestId('admin-reset-password-form')).toHaveCount(0, { timeout: 15_000 });
});
test('save a user quota and deactivate the user', async ({ page }) => {
const uname = uniqUser();
const row = await createUserRow(page, uname);
await row.locator('[data-testid^="admin-user-quota-"]').first().click();
await expect(page.getByTestId('admin-quota-form')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('admin-quota-save-btn').click();
await expect(page.getByTestId('admin-quota-form')).toHaveCount(0, { timeout: 15_000 });
// Deactivate (admin's own confirm modal).
await row.locator('[data-testid^="admin-user-toggle-active-"]').first().click();
await page.getByTestId('admin-confirm-ok-btn').click();
});
test('save oidc settings', async ({ page }) => {
await page.goto('/admin');
await page.getByTestId('admin-oidc-tab').click();
await expect(page.getByTestId('admin-oidc-form')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('admin-oidc-issuer-input').fill('https://example.test/issuer').catch(() => {});
await page.getByTestId('admin-oidc-client-id-input').fill('client-123').catch(() => {});
await page.getByTestId('admin-oidc-save-btn').click().catch(() => {});
await expect(page.getByTestId('admin-oidc-form')).toBeVisible();
});
test('install, toggle, and delete a plugin', async ({ page }) => {
await page.goto('/admin');
await page.getByTestId('admin-plugins-tab').click();
await expect(page.getByTestId('admin-plugins-tab')).toHaveAttribute('aria-selected', 'true');
// Install the example hello plugin (plugins are enabled in the coverage env).
await page.getByTestId('admin-plugins-install-input').setInputFiles(PLUGIN_ZIP);
// The installed plugin shows up with toggle/delete controls.
const toggle = page.locator('[data-testid^="admin-plugin-toggle-"]').first();
await expect(toggle).toBeVisible({ timeout: 20_000 });
await toggle.click(); // disable
await page.waitForTimeout(400);
await toggle.click(); // re-enable
await page.waitForTimeout(400);
// Delete it (admin confirm modal).
await page.locator('[data-testid^="admin-plugin-delete-"]').first().click();
const ok = page.getByTestId('admin-confirm-ok-btn');
if (await ok.isVisible().catch(() => false)) await ok.click();
await expect(page.locator('[data-testid^="admin-plugin-toggle-"]')).toHaveCount(0, {
timeout: 15_000,
});
});
test('view plugin logs and details', async ({ page }) => {
await page.goto('/admin');
await page.getByTestId('admin-plugins-tab').click();
await page.getByTestId('admin-plugins-install-input').setInputFiles(PLUGIN_ZIP);
await expect(page.locator('[data-testid^="admin-plugin-details-"]').first()).toBeVisible({
timeout: 20_000,
});
// Open the logs/details view and exercise its controls, then close.
await page.locator('[data-testid^="admin-plugin-details-"]').first().click();
await page.getByTestId('admin-plugin-logs-level-select').selectOption({ index: 1 }).catch(() => {});
await page.getByTestId('admin-plugin-logs-search-input').fill('hello').catch(() => {});
await page.getByTestId('admin-plugin-logs-search-btn').click({ timeout: 2_000 }).catch(() => {});
// Note: the live-tail checkbox opens an SSE stream that prevents the page from
// settling, so it's left untested here.
await page.getByTestId('admin-plugin-logs-close-btn').click({ timeout: 3_000 }).catch(() => {});
// Clean up: delete the plugin.
await page.locator('[data-testid^="admin-plugin-delete-"]').first().click();
const ok = page.getByTestId('admin-confirm-ok-btn');
if (await ok.isVisible().catch(() => false)) await ok.click();
});
test('plugins tab: save retention settings', async ({ page }) => {
await page.goto('/admin');
await page.getByTestId('admin-plugins-tab').click();
await expect(page.getByTestId('admin-plugins-tab')).toHaveAttribute('aria-selected', 'true');
// The retention form is conditional; fill + save it when present.
const retention = page.getByTestId('admin-plugin-retention-form');
if (await retention.isVisible().catch(() => false)) {
await page.getByTestId('admin-plugin-retention-days-input').fill('30').catch(() => {});
await page.getByTestId('admin-plugin-retention-save-btn').click().catch(() => {});
}
});
test('toggle the dashboard registration setting', async ({ page }) => {
await page.goto('/admin');
await expect(page.getByTestId('admin-dashboard-registration-checkbox')).toBeVisible({
timeout: 15_000,
});
await page.getByTestId('admin-dashboard-registration-checkbox').click();
});
test('send a test email from the smtp tab', async ({ page }) => {
await page.goto('/admin');
await page.getByTestId('admin-smtp-tab').click();
await expect(page.getByTestId('admin-smtp-to-input')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('admin-smtp-to-input').fill('test@example.test');
await page.getByTestId('admin-smtp-send-btn').click();
await expect(page.getByTestId('admin-smtp-send-btn')).toBeVisible();
});
test('storage tab: fill the S3 backend fields', async ({ page }) => {
await page.goto('/admin');
await page.getByTestId('admin-storage-tab').click();
await expect(page.getByTestId('admin-storage-form')).toBeVisible({ timeout: 15_000 });
// Switch to S3 to reveal + fill the conditional fields (no save — that would
// reconfigure storage to an unreachable backend).
await page.getByTestId('admin-storage-backend-select').selectOption('s3').catch(() => {});
await page.getByTestId('admin-storage-endpoint-input').fill('https://s3.example.test', { timeout: 2_000 }).catch(() => {});
await page.getByTestId('admin-storage-bucket-input').fill('e2e-bucket', { timeout: 2_000 }).catch(() => {});
await page.getByTestId('admin-storage-region-input').fill('us-east-1', { timeout: 2_000 }).catch(() => {});
await page.getByTestId('admin-storage-access-key-input').fill('AKIA', { timeout: 2_000 }).catch(() => {});
await page.getByTestId('admin-storage-secret-key-input').fill('secret', { timeout: 2_000 }).catch(() => {});
await page.getByTestId('admin-storage-path-style-checkbox').click({ timeout: 2_000 }).catch(() => {});
// Switch back to the safe local backend.
await page.getByTestId('admin-storage-backend-select').selectOption('local').catch(() => {});
});
test('oidc tab: run discovery against a bogus issuer', async ({ page }) => {
await page.goto('/admin');
await page.getByTestId('admin-oidc-tab').click();
await expect(page.getByTestId('admin-oidc-form')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('admin-oidc-issuer-input').fill('https://idp.example.test').catch(() => {});
// Discovery fails (no real IdP) — exercises the discover + error path.
await page.getByTestId('admin-oidc-discover-btn').click().catch(() => {});
await page.waitForTimeout(800);
await expect(page.getByTestId('admin-oidc-form')).toBeVisible();
});
test('users tab: paginate the user list', async ({ page }) => {
// The list paginates at PAGE_SIZE (25); create enough to get a second page.
await apiLogin(page);
for (let i = 0; i < 26; i++) {
await apiAdminCreateUser(page, `pageu${Date.now()}${i}`);
}
await page.goto('/admin');
await page.getByTestId('admin-users-tab').click();
await expect(page.getByTestId('admin-users-pager-next-btn')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('admin-users-pager-next-btn').click();
await page.waitForTimeout(400);
await page.getByTestId('admin-users-pager-prev-btn').click().catch(() => {});
});
test('plugins tab: install then save retention settings', async ({ page }) => {
await page.goto('/admin');
await page.getByTestId('admin-plugins-tab').click();
await page.getByTestId('admin-plugins-install-input').setInputFiles(PLUGIN_ZIP);
await expect(page.locator('[data-testid^="admin-plugin-delete-"]').first()).toBeVisible({
timeout: 20_000,
});
// Retention form is shown once a plugin exists.
const retention = page.getByTestId('admin-plugin-retention-form');
if (await retention.isVisible().catch(() => false)) {
await page.getByTestId('admin-plugin-retention-days-input').fill('30').catch(() => {});
await page.getByTestId('admin-plugin-retention-max-input').fill('100').catch(() => {});
await page.getByTestId('admin-plugin-retention-save-btn').click().catch(() => {});
}
// Clean up.
await page.locator('[data-testid^="admin-plugin-delete-"]').first().click();
const ok = page.getByTestId('admin-confirm-ok-btn');
if (await ok.isVisible().catch(() => false)) await ok.click();
});
+40
View File
@@ -0,0 +1,40 @@
import { test, expect } from './coverage-helpers';
import { apiLogin, seedFilesAndFolders } from '../scenarios/helpers';
/**
* AppShell chrome — global search box (suggestions + submit), notification
* bell, language menu, and user menu. Exercises AppShell.svelte.
*/
let seeded = false;
test.beforeEach(async ({ page }) => {
await apiLogin(page);
if (!seeded) {
await seedFilesAndFolders(page).catch(() => {});
seeded = true;
}
});
test('global search box shows suggestions and submits', async ({ page }) => {
await page.goto('/files');
await expect(page.getByTestId('appshell-logo-link')).toBeVisible({ timeout: 15_000 });
// The collapsed search toggle only appears on narrow layouts; ignore if absent.
await page.getByTestId('appshell-search-toggle-btn').click({ timeout: 2_000 }).catch(() => {});
await page.getByTestId('appshell-search-input').fill('README', { timeout: 5_000 }).catch(() => {});
await page.waitForTimeout(600); // debounced suggestion fetch
await page.getByTestId('appshell-search-clear-btn').click({ timeout: 2_000 }).catch(() => {});
await page.getByTestId('appshell-search-input').fill('Documents', { timeout: 5_000 }).catch(() => {});
await page.getByTestId('appshell-search-submit-btn').click({ timeout: 2_000 }).catch(() => {});
});
test('notification, user, and language menus open', async ({ page }) => {
await page.goto('/files');
await expect(page.getByTestId('appshell-user-menu-btn')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('appshell-notif-bell-btn').click({ timeout: 2_000 }).catch(() => {});
await page.getByTestId('appshell-user-menu-btn').click();
await expect(page.getByTestId('appshell-user-menu-profile-item')).toBeVisible();
await page.getByTestId('appshell-lang-toggle-btn').click({ timeout: 2_000 }).catch(() => {});
});
+53
View File
@@ -0,0 +1,53 @@
import { test, expect, uiLogin, TEST_ADMIN } from './coverage-helpers';
test.describe('SPA · authentication', () => {
test('login page renders the sign-in form', async ({ page }) => {
await page.goto('/login');
await expect(page.getByTestId('login-form')).toBeVisible();
await expect(page.getByTestId('login-username-input')).toBeVisible();
await expect(page.getByTestId('login-password-input')).toBeVisible();
await expect(page.getByTestId('login-submit-btn')).toBeVisible();
await expect(page).toHaveTitle(/OxiCloud/i);
});
test('wrong password is rejected with an error', async ({ page }) => {
await page.goto('/login');
await page.getByTestId('login-username-input').fill(TEST_ADMIN.username);
await page.getByTestId('login-password-input').fill('definitely-wrong-password');
await page.getByTestId('login-submit-btn').click();
await expect(page.locator('.auth-error[role="alert"]')).toBeVisible();
// Still on the login page — no redirect into the app.
await expect(page.getByTestId('login-form')).toBeVisible();
});
test('register and setup panels are reachable from login', async ({ page }) => {
await page.goto('/login');
await page.getByTestId('login-to-register-btn').click();
await expect(page.getByTestId('login-register-form')).toBeVisible();
await page.getByTestId('login-register-to-login-btn').click();
await expect(page.getByTestId('login-form')).toBeVisible();
});
test('magic-link panel toggles open', async ({ page }) => {
await page.goto('/login');
await page.getByTestId('login-magic-toggle-btn').click();
await expect(page.getByTestId('login-magic-form')).toBeVisible();
await expect(page.getByTestId('login-magic-email-input')).toBeVisible();
});
test('successful login reaches the files app shell', async ({ page }) => {
await uiLogin(page);
await expect(page).toHaveURL(/\/files/);
await expect(page.getByTestId('appshell-logo-link')).toBeVisible();
await expect(page.getByTestId('appshell-user-menu-btn')).toBeVisible();
});
test('logout returns to the login page', async ({ page }) => {
await uiLogin(page);
await page.getByTestId('appshell-user-menu-btn').click();
await page.getByTestId('appshell-user-menu-logout-btn').click();
await page.waitForURL('**/login**', { timeout: 15_000 });
await expect(page.getByTestId('login-form')).toBeVisible();
});
});
+69
View File
@@ -0,0 +1,69 @@
import { test, expect } from './coverage-helpers';
import { apiLogin, apiCreateFolder } from '../scenarios/helpers';
/**
* Batch selection bar — select-all then batch favorite / batch delete, each in
* its own dedicated parent folder so it can't disturb other tests and starts
* from a stable (un-re-rendered) state.
*/
test.beforeEach(async ({ page }) => {
await apiLogin(page);
});
function uniq(p: string): string {
return `${p}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
}
/** Create a parent folder with two children and open it in list view. */
async function openFolderWithChildren(
page: import('@playwright/test').Page,
): Promise<{ c1: string; c2: string }> {
const parent = await apiCreateFolder(page, uniq('Batch'));
const c1 = uniq('c1');
const c2 = uniq('c2');
await apiCreateFolder(page, c1, parent.id);
await apiCreateFolder(page, c2, parent.id);
await page.goto(`/files/${parent.id}`);
await expect(page.getByTestId(c1)).toBeVisible({ timeout: 15_000 });
await page.getByTestId('list-toolbar-view-list-btn').click();
return { c1, c2 };
}
test('select-all then batch favorite', async ({ page }) => {
const { c1 } = await openFolderWithChildren(page);
await page.getByTestId('files-select-all-checkbox').check();
await expect(page.getByTestId('files-batch-bar')).toBeVisible();
await page.getByTestId('files-batch-favorite-btn').click();
// Items remain in the folder after favoriting.
await expect(page.getByTestId(c1)).toBeVisible({ timeout: 15_000 });
});
test('select-all then batch copy and download', async ({ page }) => {
const { c1 } = await openFolderWithChildren(page);
await page.getByTestId('files-select-all-checkbox').check();
await expect(page.getByTestId('files-batch-bar')).toBeVisible();
// Copy → the move dialog (copy mode); cancel.
await page.getByTestId('files-batch-copy-btn').click();
await expect(page.getByTestId('move-dialog')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('move-dialog-cancel-btn').click();
// Re-select and batch-download (a zip).
await page.getByTestId('files-select-all-checkbox').check();
await expect(page.getByTestId('files-batch-bar')).toBeVisible();
const dl = page.waitForEvent('download', { timeout: 10_000 }).catch(() => null);
await page.getByTestId('files-batch-download-btn').click();
await dl;
await expect(page.getByTestId(c1)).toBeVisible();
});
test('select-all then batch delete', async ({ page }) => {
const { c1, c2 } = await openFolderWithChildren(page);
await page.getByTestId('files-select-all-checkbox').check();
await expect(page.getByTestId('files-batch-bar')).toBeVisible();
await page.getByTestId('files-batch-delete-btn').click();
await page.getByTestId('dialog-host-confirm-btn').click();
await expect(page.getByTestId(c1)).toHaveCount(0, { timeout: 15_000 });
await expect(page.getByTestId(c2)).toHaveCount(0);
});
+37
View File
@@ -0,0 +1,37 @@
import { test, expect } from './coverage-helpers';
import { apiLogin } from '../scenarios/helpers';
/**
* Command palette — opens on Ctrl/Cmd+K, filters on typed input, and closes on
* Escape. Covers CommandPalette.svelte.
*/
test.beforeEach(async ({ page }) => {
await apiLogin(page);
});
test('command palette opens, filters, and closes', async ({ page }) => {
await page.goto('/files');
await expect(page.getByTestId('appshell-logo-link')).toBeVisible({ timeout: 15_000 });
await page.keyboard.press('Control+k');
await expect(page.getByTestId('command-palette-panel')).toBeVisible({ timeout: 5_000 });
await page.getByTestId('command-palette-input').fill('trash');
// At least one command item should match.
await expect(page.locator('[data-testid^="command-palette-"][data-testid$="-item"]').first()).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByTestId('command-palette-panel')).toHaveCount(0);
});
test('command palette navigates via a command', async ({ page }) => {
await page.goto('/files');
await expect(page.getByTestId('appshell-logo-link')).toBeVisible({ timeout: 15_000 });
await page.keyboard.press('Control+k');
await expect(page.getByTestId('command-palette-panel')).toBeVisible({ timeout: 5_000 });
// Pick the first matching command via keyboard (covers the run path).
await page.getByTestId('command-palette-input').fill('trash');
await page.keyboard.press('Enter');
await expect(page.getByTestId('command-palette-panel')).toHaveCount(0);
});
+51
View File
@@ -0,0 +1,51 @@
import * as fs from 'fs';
import * as path from 'path';
import { Page, expect } from '@playwright/test';
import { test as base, TEST_ADMIN } from '../scenarios/helpers';
/**
* `test` for the SvelteKit SPA coverage suite. Extends the shared `test`
* (JS-error guard + container/stack support) with an auto fixture that, after
* every test, reads the Istanbul `window.__coverage__` accumulated by the
* instrumented build and appends it to `.nyc_output/` for an nyc report.
*
* Import `test`/`expect` from here in every `spa/*.spec.ts`.
*/
const NYC_DIR = path.join(__dirname, '..', '.nyc_output');
export const test = base.extend<{ collectCoverage: void }>({
collectCoverage: [
async ({ page }, use, testInfo) => {
await use();
// The instrumented bundle accumulates into window.__coverage__ across
// SPA navigations; read it once at test end. May be undefined if the
// page never loaded the app (pure-API test) — skip those.
const coverage = await page
.evaluate(() => (window as unknown as { __coverage__?: unknown }).__coverage__)
.catch(() => undefined);
if (coverage) {
const file = path.join(
NYC_DIR,
`coverage-${testInfo.workerIndex}-${testInfo.testId}.json`,
);
fs.writeFileSync(file, JSON.stringify(coverage));
}
},
{ auto: true },
],
});
export { expect, TEST_ADMIN };
/**
* Sign in through the SvelteKit login UI and wait until the app shell is
* mounted. Uses stable `data-testid` selectors (kept in VITE_E2E builds).
*/
export async function uiLogin(page: Page, admin = TEST_ADMIN): Promise<void> {
await page.goto('/login');
await page.getByTestId('login-username-input').fill(admin.username);
await page.getByTestId('login-password-input').fill(admin.password);
await page.getByTestId('login-submit-btn').click();
await page.waitForURL('**/files**', { timeout: 15_000 });
await expect(page.getByTestId('appshell-logo-link')).toBeVisible({ timeout: 15_000 });
}
+30
View File
@@ -0,0 +1,30 @@
import { test, expect } from './coverage-helpers';
import { apiLogin } from '../scenarios/helpers';
/**
* Device-pairing route — submit a (bogus) device code to exercise the lookup
* path and its error/retry handling. Standalone page (no app shell).
*/
test.beforeEach(async ({ page }) => {
await apiLogin(page);
});
test('submitting an unknown device code shows the retry path', async ({ page }) => {
await page.goto('/device');
await expect(page.getByTestId('device-code-form')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('device-code-input').fill('ZZZZ-ZZZZ');
await page.getByTestId('device-continue-btn').click();
// An unknown code returns to a retry/error state (or stays on the form).
await expect(
page.getByTestId('device-retry-btn').or(page.getByTestId('device-code-form')),
).toBeVisible({ timeout: 15_000 });
// If a retry button appeared, use it to return to the code form.
const retry = page.getByTestId('device-retry-btn');
if (await retry.isVisible().catch(() => false)) {
await retry.click();
await expect(page.getByTestId('device-code-form')).toBeVisible({ timeout: 15_000 });
}
});
+49
View File
@@ -0,0 +1,49 @@
import { test, expect } from './coverage-helpers';
import { apiLogin, apiCreateFolder } from '../scenarios/helpers';
/**
* Error-path coverage — drive the catch/error branches that the happy-path
* specs don't reach: a duplicate-folder 409 and an over-cap (413) upload.
*/
test.beforeEach(async ({ page }) => {
await apiLogin(page);
});
function uniq(p: string): string {
return `${p}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
}
test('creating a duplicate folder surfaces an error toast', async ({ page }) => {
const parent = await apiCreateFolder(page, uniq('DupHost'));
const name = uniq('Dup');
await apiCreateFolder(page, name, parent.id); // pre-create the clashing name
await page.goto(`/files/${parent.id}`);
await expect(page.getByTestId(name)).toBeVisible({ timeout: 15_000 });
await page.getByTestId('files-new-folder-btn').click();
await page.getByTestId('dialog-host-prompt-input').fill(name);
await page.getByTestId('dialog-host-submit-btn').click();
// The 409 is caught and raised as a toast.
await expect(page.locator('[data-testid^="toaster-toast-"]').first()).toBeVisible({
timeout: 15_000,
});
});
test('an over-cap upload is handled without crashing', async ({ page }) => {
const folder = await apiCreateFolder(page, uniq('BigUp'));
await page.goto(`/files/${folder.id}`);
await expect(page.getByTestId('files-upload-file-input')).toBeAttached({ timeout: 15_000 });
// 5 MiB exceeds the 4 MiB test cap → the upload fails (413) and is caught.
const big = Buffer.alloc(5 * 1024 * 1024, 65);
await page.getByTestId('files-upload-file-input').setInputFiles({
name: 'big.bin',
mimeType: 'application/octet-stream',
buffer: big,
});
await page.waitForTimeout(2_000);
// The page stays usable (the error path ran).
await expect(page.getByTestId('files-upload-btn')).toBeVisible();
});
+62
View File
@@ -0,0 +1,62 @@
import { test, expect } from './coverage-helpers';
import { apiLogin, apiCreateFolder } from '../scenarios/helpers';
/**
* Favorites route — favorite a folder from the files context menu, see it in
* /favorites, then unfavorite it via the row star. Covers the favorites page +
* favorites endpoint.
*/
test.beforeEach(async ({ page }) => {
await apiLogin(page);
});
function uniq(p: string): string {
return `${p}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
}
test('favorite, view, and unfavorite a folder', async ({ page }) => {
const name = uniq('Fav');
await apiCreateFolder(page, name);
await page.goto('/files');
await expect(page.getByTestId(name)).toBeVisible({ timeout: 15_000 });
await page.getByTestId(name).click({ button: 'right' });
await page.getByTestId('files-ctx-favorite-item').click();
await expect(page.getByTestId('files-context-menu')).toHaveCount(0);
await page.goto('/favorites');
const row = page.getByTestId(name);
await expect(row).toBeVisible({ timeout: 15_000 });
// The favorite star is hover-revealed; dispatch the click to toggle it off.
await row.locator('[data-testid^="resource-list-favorite-"]').dispatchEvent('click');
await expect(page.getByTestId(name)).toHaveCount(0, { timeout: 15_000 });
});
test('favorites batch select-all then move dialog', async ({ page }) => {
const f1 = uniq('FavB1');
const f2 = uniq('FavB2');
await apiCreateFolder(page, f1);
await apiCreateFolder(page, f2);
// Favorite both via the files context menu.
await page.goto('/files');
for (const n of [f1, f2]) {
await expect(page.getByTestId(n)).toBeVisible({ timeout: 15_000 });
await page.getByTestId(n).click({ button: 'right' });
await page.getByTestId('files-ctx-favorite-item').click();
await expect(page.getByTestId('files-context-menu')).toHaveCount(0);
}
await page.goto('/favorites');
await expect(page.getByTestId(f1)).toBeVisible({ timeout: 15_000 });
// The select-all checkbox lives in the list-view header.
await page.getByTestId('list-toolbar-view-list-btn').click();
await page.getByTestId('resource-list-select-all-checkbox').check();
await expect(page.getByTestId('resource-list-batch-toolbar')).toBeVisible();
// Batch-move opens the move dialog; cancel it.
await page.getByTestId('favorites-batch-move-btn').click();
await expect(page.getByTestId('move-dialog')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('move-dialog-cancel-btn').click();
});
+250
View File
@@ -0,0 +1,250 @@
import { test, expect } from './coverage-helpers';
import { apiLogin, apiCreateFolder, apiUploadFile, SAMPLE_FILES } from '../scenarios/helpers';
/**
* Deeper files-page coverage (the largest source file): list/grid view toggle,
* column sorting, group-by lanes, breadcrumb navigation, and copy-via-dialog.
*/
test.beforeEach(async ({ page }) => {
await apiLogin(page);
});
function uniq(p: string): string {
return `${p}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
}
test('sort columns and toggle list/grid views', async ({ page }) => {
const folder = await apiCreateFolder(page, uniq('SortHost'));
await apiUploadFile(page, SAMPLE_FILES.text(), folder.id);
await apiUploadFile(page, SAMPLE_FILES.json(), folder.id);
await page.goto(`/files/${folder.id}`);
await expect(page.getByTestId(SAMPLE_FILES.text().name)).toBeVisible({ timeout: 15_000 });
await page.getByTestId('list-toolbar-view-list-btn').click();
// Column sort buttons live in the list-view header.
await page.getByTestId('files-sort-name-btn').click({ timeout: 5_000 }).catch(() => {});
await page.getByTestId('files-sort-size-btn').click({ timeout: 5_000 }).catch(() => {});
await page.getByTestId('files-sort-modified_at-btn').click({ timeout: 5_000 }).catch(() => {});
await page.getByTestId('list-toolbar-view-grid-btn').click();
});
test('sort by every column and group by every dimension', async ({ page }) => {
const folder = await apiCreateFolder(page, uniq('Dims'));
await apiUploadFile(page, SAMPLE_FILES.text(), folder.id);
await apiUploadFile(page, SAMPLE_FILES.png(), folder.id);
await apiUploadFile(page, SAMPLE_FILES.pdf(), folder.id);
await page.goto(`/files/${folder.id}`);
await expect(page.getByTestId(SAMPLE_FILES.text().name)).toBeVisible({ timeout: 15_000 });
// List view exposes the column-sort buttons.
await page.getByTestId('list-toolbar-view-list-btn').click();
for (const col of ['name', 'owner', 'type', 'size', 'modified_at']) {
await page.getByTestId(`files-sort-${col}-btn`).click({ timeout: 3_000 }).catch(() => {});
}
// Flip the sort direction.
await page.getByTestId('list-toolbar-sort-direction-btn').click({ timeout: 3_000 }).catch(() => {});
// Cycle through every group-by dimension.
for (const g of ['type', 'size', 'modifiedAt', 'createdAt']) {
await page.getByTestId('list-toolbar-groupby-btn').click({ timeout: 3_000 }).catch(() => {});
await page.getByTestId(`list-toolbar-groupby-${g}-item`).click({ timeout: 3_000 }).catch(() => {});
}
});
test('group files by type', async ({ page }) => {
const folder = await apiCreateFolder(page, uniq('GroupHost'));
await apiUploadFile(page, SAMPLE_FILES.text(), folder.id);
await apiUploadFile(page, SAMPLE_FILES.png(), folder.id);
await page.goto(`/files/${folder.id}`);
await expect(page.getByTestId(SAMPLE_FILES.text().name)).toBeVisible({ timeout: 15_000 });
await page.getByTestId('list-toolbar-groupby-btn').click();
await page.getByTestId('list-toolbar-groupby-type-item').click();
// The grouped (swimlane) view now renders; items remain visible.
await expect(page.getByTestId(SAMPLE_FILES.text().name)).toBeVisible();
});
test('file context menu: favorite, copy, open-parent', async ({ page }) => {
const folder = await apiCreateFolder(page, uniq('Ctx'));
await apiCreateFolder(page, uniq('CtxDest'));
const f = SAMPLE_FILES.text();
await apiUploadFile(page, f, folder.id);
await page.goto(`/files/${folder.id}`);
await expect(page.getByTestId(f.name)).toBeVisible({ timeout: 15_000 });
// Favorite the file from its context menu.
await page.getByTestId(f.name).click({ button: 'right' });
await page.getByTestId('files-ctx-favorite-item').click();
await expect(page.getByTestId('files-context-menu')).toHaveCount(0);
// Copy → opens the move dialog (copy mode); cancel.
await page.getByTestId(f.name).click({ button: 'right' });
await page.getByTestId('files-ctx-copy-item').click();
await expect(page.getByTestId('move-dialog')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('move-dialog-cancel-btn').click();
// Open-parent navigates to the file's parent folder.
await page.getByTestId(f.name).click({ button: 'right' });
await page.getByTestId('files-ctx-open-parent-item').click();
await expect(page).toHaveURL(/\/files\//, { timeout: 15_000 });
});
test('download a folder as a zip via the context menu', async ({ page }) => {
const folderName = uniq('Zip');
const folder = await apiCreateFolder(page, folderName);
await apiUploadFile(page, SAMPLE_FILES.text(), folder.id);
await page.goto('/files');
await expect(page.getByTestId(folderName)).toBeVisible({ timeout: 15_000 });
await page.getByTestId(folderName).click({ button: 'right' });
const downloadPromise = page.waitForEvent('download', { timeout: 10_000 }).catch(() => null);
await page.getByTestId('files-ctx-download-zip-item').click();
await downloadPromise;
});
test('deep-link ?file= opens the viewer', async ({ page }) => {
const folder = await apiCreateFolder(page, uniq('DeepLink'));
const f = SAMPLE_FILES.markdown();
await apiUploadFile(page, f, folder.id);
await page.goto(`/files/${folder.id}`);
await expect(page.getByTestId(f.name)).toBeVisible({ timeout: 15_000 });
// Extract the file id from a row action button, then deep-link to it.
const tid = await page
.locator('[data-testid^="files-file-share-"]')
.first()
.getAttribute('data-testid');
const fileId = (tid ?? '').replace('files-file-share-', '');
await page.goto(`/files/${folder.id}?file=${fileId}`);
await expect(page.getByTestId('file-viewer-dialog')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('file-viewer-close-btn').click();
});
test('download a file via the context menu', async ({ page }) => {
const folder = await apiCreateFolder(page, uniq('Dl'));
const f = SAMPLE_FILES.text();
await apiUploadFile(page, f, folder.id);
await page.goto(`/files/${folder.id}`);
await expect(page.getByTestId(f.name)).toBeVisible({ timeout: 15_000 });
await page.getByTestId(f.name).click({ button: 'right' });
const downloadPromise = page.waitForEvent('download', { timeout: 10_000 }).catch(() => null);
await page.getByTestId('files-ctx-download-item').click();
const download = await downloadPromise;
if (download) expect(download.suggestedFilename().length).toBeGreaterThan(0);
});
test('upload a folder via the hidden folder input', async ({ page }) => {
const folder = await apiCreateFolder(page, uniq('FolderUp'));
await page.goto(`/files/${folder.id}`);
await expect(page.getByTestId('files-upload-folder-input')).toBeAttached({ timeout: 15_000 });
// webkitdirectory input — set a couple of nested files to exercise the
// folder-upload handler.
await page
.getByTestId('files-upload-folder-input')
.setInputFiles([
{ name: 'up/a.txt', mimeType: 'text/plain', buffer: Buffer.from('a') },
{ name: 'up/b.txt', mimeType: 'text/plain', buffer: Buffer.from('b') },
])
.catch(() => {});
await page.waitForTimeout(1500);
// The page stays functional whether or not the upload fully completes.
await expect(page.getByTestId('files-upload-btn')).toBeVisible();
});
test('drag a file onto a subfolder to move it', async ({ page }) => {
const parent = await apiCreateFolder(page, uniq('DragHost'));
const destName = uniq('DragDest');
await apiCreateFolder(page, destName, parent.id);
const f = SAMPLE_FILES.text();
await apiUploadFile(page, f, parent.id);
await page.goto(`/files/${parent.id}`);
await expect(page.getByTestId(f.name)).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId(destName)).toBeVisible();
// HTML5 drag-and-drop: the row is draggable and the folder is a drop target.
await page.getByTestId(f.name).dragTo(page.getByTestId(destName));
// The file moved into the destination folder.
await expect(page.getByTestId(f.name)).toHaveCount(0, { timeout: 15_000 });
});
test('open a folder with the keyboard (focus + Enter)', async ({ page }) => {
const parent = await apiCreateFolder(page, uniq('KeyNav'));
const childName = uniq('KeyChild');
const child = await apiCreateFolder(page, childName, parent.id);
await page.goto(`/files/${parent.id}`);
await expect(page.getByTestId(childName)).toBeVisible({ timeout: 15_000 });
// Focus the folder row and press Enter to open it (covers the keydown path).
await page.getByTestId(childName).focus();
await page.keyboard.press('Enter');
// The URL ends with the child id (nested under the parent path).
await expect(page).toHaveURL(new RegExp(`${child.id}$`), { timeout: 15_000 });
});
test('keyboard select-all and escape in the files list', async ({ page }) => {
const folder = await apiCreateFolder(page, uniq('Keys'));
await apiCreateFolder(page, uniq('k1'), folder.id);
await apiCreateFolder(page, uniq('k2'), folder.id);
await page.goto(`/files/${folder.id}`);
await expect(page.locator('.files-page')).toBeVisible({ timeout: 15_000 });
await page.locator('.files-page').click({ position: { x: 5, y: 5 } });
await page.keyboard.press('Control+a');
await expect(page.getByTestId('files-batch-bar')).toBeVisible({ timeout: 5_000 }).catch(() => {});
await page.keyboard.press('Escape');
});
test('breadcrumb navigates back to home', async ({ page }) => {
const folderName = uniq('Crumb');
const folder = await apiCreateFolder(page, folderName);
await page.goto(`/files/${folder.id}`);
// Breadcrumb home link returns to the root listing.
await page.getByTestId('files-breadcrumb-home-link').click();
await expect(page).toHaveURL(/\/files\/?$/);
});
test('open an image in the viewer and use the zoom controls', async ({ page }) => {
const folder = await apiCreateFolder(page, uniq('ImgView'));
const img = SAMPLE_FILES.png();
await apiUploadFile(page, img, folder.id);
await page.goto(`/files/${folder.id}`);
await expect(page.getByTestId(img.name)).toBeVisible({ timeout: 15_000 });
await page.getByTestId(img.name).click({ button: 'right' });
await page.getByTestId('files-ctx-file-open-item').click();
await expect(page.getByTestId('file-viewer-dialog')).toBeVisible({ timeout: 15_000 });
// Zoom controls render only for images.
await page.getByTestId('file-viewer-zoom-in-btn').click({ timeout: 3_000 }).catch(() => {});
await page.getByTestId('file-viewer-zoom-out-btn').click({ timeout: 3_000 }).catch(() => {});
await page.getByTestId('file-viewer-zoom-reset-btn').click({ timeout: 3_000 }).catch(() => {});
// The download + open-in-new-tab links are present in the viewer toolbar.
await expect(page.getByTestId('file-viewer-download-link')).toBeVisible();
await expect(page.getByTestId('file-viewer-open-new-tab-link')).toBeVisible();
await page.getByTestId('file-viewer-close-btn').click();
await expect(page.getByTestId('file-viewer-dialog')).toHaveCount(0);
});
test('copy a folder into another (copy mode keeps the source)', async ({ page }) => {
const srcName = uniq('CopySrc');
await apiCreateFolder(page, srcName);
const dest = await apiCreateFolder(page, uniq('CopyDest'));
await page.goto('/files');
await expect(page.getByTestId(srcName)).toBeVisible({ timeout: 15_000 });
await page.getByTestId(srcName).click({ button: 'right' });
await page.getByTestId('files-ctx-copy-item').click();
await expect(page.getByTestId('move-dialog')).toBeVisible();
await page.getByTestId(`move-dialog-folder-${dest.id}`).click();
await page.getByTestId('move-dialog-confirm-btn').click();
// Copy leaves the source in place.
await expect(page.getByTestId(srcName)).toBeVisible({ timeout: 15_000 });
// And a copy now lives in the destination.
await page.goto(`/files/${dest.id}`);
await expect(page.getByTestId(srcName)).toBeVisible({ timeout: 15_000 });
});
+104
View File
@@ -0,0 +1,104 @@
import { test, expect } from './coverage-helpers';
import { apiLogin, apiCreateFolder, apiUploadFile, SAMPLE_FILES } from '../scenarios/helpers';
/**
* File-browser CRUD against the SvelteKit `/files` route — the single largest
* source file. Drives the toolbar, the right-click context menu, and the
* prompt/confirm dialogs, which also exercises the files/folders/favorites
* endpoint modules.
*/
test.beforeEach(async ({ page }) => {
await apiLogin(page);
});
function uniq(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
}
test('create a folder via the toolbar', async ({ page }) => {
const name = uniq('Created');
await page.goto('/files');
await page.getByTestId('files-new-folder-btn').click();
await page.getByTestId('dialog-host-prompt-input').fill(name);
await page.getByTestId('dialog-host-submit-btn').click();
await expect(page.getByTestId(name)).toBeVisible({ timeout: 15_000 });
});
test('rename a folder via the context menu', async ({ page }) => {
const before = uniq('Rename');
const after = uniq('Renamed');
await apiCreateFolder(page, before);
await page.goto('/files');
await expect(page.getByTestId(before)).toBeVisible({ timeout: 15_000 });
await page.getByTestId(before).click({ button: 'right' });
await page.getByTestId('files-ctx-rename-item').click();
const input = page.getByTestId('dialog-host-prompt-input');
await input.fill(after);
await page.getByTestId('dialog-host-submit-btn').click();
await expect(page.getByTestId(after)).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId(before)).toHaveCount(0);
});
test('favorite a folder via the context menu', async ({ page }) => {
const name = uniq('Fav');
await apiCreateFolder(page, name);
await page.goto('/files');
await expect(page.getByTestId(name)).toBeVisible({ timeout: 15_000 });
await page.getByTestId(name).click({ button: 'right' });
await page.getByTestId('files-ctx-favorite-item').click();
// Action completed — the row is still present and the menu has closed.
await expect(page.getByTestId('files-context-menu')).toHaveCount(0);
await expect(page.getByTestId(name)).toBeVisible();
});
test('delete a folder via the context menu', async ({ page }) => {
const name = uniq('Delete');
await apiCreateFolder(page, name);
await page.goto('/files');
await expect(page.getByTestId(name)).toBeVisible({ timeout: 15_000 });
await page.getByTestId(name).click({ button: 'right' });
await page.getByTestId('files-ctx-delete-item').click();
await page.getByTestId('dialog-host-confirm-btn').click();
await expect(page.getByTestId(name)).toHaveCount(0, { timeout: 15_000 });
});
test('upload a file via the hidden file input', async ({ page }) => {
const folder = uniq('Uploads');
const created = await apiCreateFolder(page, folder);
// Navigate straight into the (empty) folder by ID (the route keys on folder
// id, not name) — avoids the crowded root listing and click ambiguity.
await page.goto(`/files/${created.id}`);
await expect(page.getByTestId('files-upload-file-input')).toBeAttached({ timeout: 15_000 });
// Now inside the folder; upload a text file by setting the hidden input.
const f = SAMPLE_FILES.text();
await page.getByTestId('files-upload-file-input').setInputFiles({
name: f.name,
mimeType: f.mimeType,
buffer: f.body,
});
await expect(page.getByTestId(f.name)).toBeVisible({ timeout: 15_000 });
});
test('open a text file in the viewer', async ({ page }) => {
const folderName = uniq('Viewer');
const folder = await apiCreateFolder(page, folderName);
const f = SAMPLE_FILES.markdown();
await apiUploadFile(page, f, folder.id);
await page.goto('/files');
await page.getByTestId(folderName).click();
await expect(page.getByTestId(f.name)).toBeVisible({ timeout: 15_000 });
// Open via the context menu, then assert the inline viewer dialog appears.
await page.getByTestId(f.name).click({ button: 'right' });
await page.getByTestId('files-ctx-file-open-item').click();
await expect(page.getByTestId('file-viewer-dialog')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('file-viewer-close-btn').click();
await expect(page.getByTestId('file-viewer-dialog')).toHaveCount(0);
});
+16
View File
@@ -0,0 +1,16 @@
import * as fs from 'fs';
import * as path from 'path';
import { seedAdmin } from '../scenarios/helpers';
/**
* Global setup for the SPA coverage suite:
* 1. Wipe `.nyc_output/` so each run starts from a clean coverage slate.
* 2. Seed the admin account against the SPA server at :8088.
*/
export default async function globalSetup() {
const nycDir = path.join(__dirname, '..', '.nyc_output');
fs.rmSync(nycDir, { recursive: true, force: true });
fs.mkdirSync(nycDir, { recursive: true });
await seedAdmin('http://localhost:8088');
}
+76
View File
@@ -0,0 +1,76 @@
import { test, expect } from './coverage-helpers';
import { apiLogin, apiAdminCreateUser } from '../scenarios/helpers';
/**
* Groups route — create a group, expand it, search members, rename, and delete
* (delete is a type-the-name confirm). Group rows are id-keyed, so we scope to
* our own `li.group` by its unique name and prefix-match the buttons.
*/
test.beforeEach(async ({ page }) => {
await apiLogin(page);
});
function uniq(p: string): string {
return `${p}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
}
test('group lifecycle: create, expand, rename, delete', async ({ page }) => {
const name = uniq('Grp');
const renamed = uniq('Grpr');
await page.goto('/groups');
await expect(page.getByTestId('groups-create-btn')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('groups-create-btn').click();
await page.getByTestId('dialog-host-prompt-input').fill(name);
await page.getByTestId('dialog-host-submit-btn').click();
const row = page.locator('li.group').filter({ hasText: name });
await expect(row).toBeVisible({ timeout: 15_000 });
// Expand → members panel, then run a member search (covers recipients).
await row.locator('[data-testid^="groups-expand-"]').click();
await expect(row.locator('[data-testid^="groups-members-panel-"]')).toBeVisible();
await row.getByTestId('groups-member-add-input').fill('admin').catch(() => {});
await page.waitForTimeout(600);
// Rename.
await row.locator('[data-testid^="groups-rename-"]').click();
await page.getByTestId('dialog-host-prompt-input').fill(renamed);
await page.getByTestId('dialog-host-submit-btn').click();
const renamedRow = page.locator('li.group').filter({ hasText: renamed });
await expect(renamedRow).toBeVisible({ timeout: 15_000 });
// Delete — the confirm prompt requires typing the group name.
await renamedRow.locator('[data-testid^="groups-delete-"]').click();
await page.getByTestId('dialog-host-prompt-input').fill(renamed);
await page.getByTestId('dialog-host-submit-btn').click();
await expect(page.locator('li.group').filter({ hasText: renamed })).toHaveCount(0, {
timeout: 15_000,
});
});
test('add and remove a group member', async ({ page }) => {
const username = await apiAdminCreateUser(page, uniq('grpmem'));
const groupName = uniq('GrpM');
await page.goto('/groups');
await page.getByTestId('groups-create-btn').click();
await page.getByTestId('dialog-host-prompt-input').fill(groupName);
await page.getByTestId('dialog-host-submit-btn').click();
const row = page.locator('li.group').filter({ hasText: groupName });
await expect(row).toBeVisible({ timeout: 15_000 });
await row.locator('[data-testid^="groups-expand-"]').click();
await expect(row.locator('[data-testid^="groups-members-panel-"]')).toBeVisible();
// Search the new user and add them.
await row.getByTestId('groups-member-add-input').fill(username);
const opt = row.locator('[data-testid^="groups-member-add-opt-user-"]').first();
await expect(opt).toBeVisible({ timeout: 15_000 });
await opt.click();
// Remove the member we just added.
const remove = row.locator('[data-testid^="groups-member-remove-"]').first();
await expect(remove).toBeVisible({ timeout: 15_000 });
await remove.click();
});
+70
View File
@@ -0,0 +1,70 @@
import { test, expect } from './coverage-helpers';
/**
* Login route — register and magic-link flows (logged out). Exercises the
* register/magic submit handlers in the login page + auth endpoints.
*/
function uniq(p: string): string {
return `${p}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
}
test('register a new account from the login page', async ({ page }) => {
const u = uniq('reg');
await page.goto('/login');
await page.getByTestId('login-to-register-btn').click();
await expect(page.getByTestId('login-register-form')).toBeVisible();
await page.getByTestId('login-register-username-input').fill(u);
await page.getByTestId('login-register-email-input').fill(`${u}@example.test`);
await page.getByTestId('login-register-password-input').fill('TestPassword1!');
await page.getByTestId('login-register-confirm-input').fill('TestPassword1!');
await page.getByTestId('login-register-submit-btn').click();
// Either we land in the app or a notice/error appears — both run onRegister.
await expect(
page
.getByTestId('appshell-logo-link')
.or(page.locator('.auth-error'))
.or(page.getByTestId('login-register-form'))
.first(),
).toBeVisible({ timeout: 15_000 });
});
// Note: the first-run setup panel is unreachable here — `login-to-setup-btn`
// only renders when no admin exists, but the test env always seeds one.
test('register with mismatched passwords shows a validation error', async ({ page }) => {
await page.goto('/login');
await page.getByTestId('login-to-register-btn').click();
await expect(page.getByTestId('login-register-form')).toBeVisible();
await page.getByTestId('login-register-username-input').fill(uniq('mm'));
await page.getByTestId('login-register-email-input').fill('mm@example.test');
await page.getByTestId('login-register-password-input').fill('TestPassword1!');
await page.getByTestId('login-register-confirm-input').fill('Different1!');
await page.getByTestId('login-register-submit-btn').click();
// Client-side validation rejects the mismatch before any request.
await expect(page.locator('.auth-error[role="alert"]')).toBeVisible({ timeout: 5_000 });
});
test('an oidc callback code is exchanged on load', async ({ page }) => {
// Landing with ?oidc_code triggers the SPA's OIDC code-exchange path; a bogus
// code fails and falls back to the login form (exercises the handler).
await page.goto('/login?oidc_code=fake-code-123');
await expect(
page.getByTestId('login-form').or(page.locator('.auth-error')).first(),
).toBeVisible({ timeout: 15_000 });
});
test('request a magic link from the login page', async ({ page }) => {
await page.goto('/login');
await page.getByTestId('login-magic-toggle-btn').click();
await expect(page.getByTestId('login-magic-form')).toBeVisible();
await page.getByTestId('login-magic-email-input').fill('someone@example.test');
await page.getByTestId('login-magic-send-btn').click();
// A status message resolves (success or error); give the request time to run.
await page.waitForTimeout(1_000);
await expect(page.getByTestId('login-magic-form').or(page.getByTestId('login-form')).first()).toBeVisible();
});
+65
View File
@@ -0,0 +1,65 @@
import { test, expect } from './coverage-helpers';
import { apiLogin, apiCreateFolder } from '../scenarios/helpers';
/**
* MoveDialog coverage — move a folder into another via the context menu.
* Exercises the move dialog's tree navigation and the folders move endpoint.
*/
test.beforeEach(async ({ page }) => {
await apiLogin(page);
});
function uniq(p: string): string {
return `${p}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
}
test('move a folder into another via the move dialog', async ({ page }) => {
const srcName = uniq('MoveSrc');
const destName = uniq('MoveDest');
await apiCreateFolder(page, srcName);
const dest = await apiCreateFolder(page, destName);
await page.goto('/files');
await expect(page.getByTestId(srcName)).toBeVisible({ timeout: 15_000 });
await page.getByTestId(srcName).click({ button: 'right' });
await page.getByTestId('files-ctx-move-item').click();
await expect(page.getByTestId('move-dialog')).toBeVisible();
// Exercise the dialog tree navigation (into a folder, up to parent, home),
// then settle into the destination and confirm.
await page.getByTestId(`move-dialog-folder-${dest.id}`).click();
await page.getByTestId('move-dialog-parent-btn').click({ timeout: 3_000 }).catch(() => {});
await page.getByTestId('move-dialog-home-btn').click({ timeout: 3_000 }).catch(() => {});
await page.getByTestId(`move-dialog-folder-${dest.id}`).click();
await page.getByTestId('move-dialog-confirm-btn').click();
// Source left the root listing.
await expect(page.getByTestId(srcName)).toHaveCount(0, { timeout: 15_000 });
// And now lives inside the destination (route keys on folder id).
await page.goto(`/files/${dest.id}`);
await expect(page.getByTestId(srcName)).toBeVisible({ timeout: 15_000 });
});
test('navigate a nested tree in the move dialog', async ({ page }) => {
const srcName = uniq('NavSrc');
await apiCreateFolder(page, srcName);
const dest = await apiCreateFolder(page, uniq('NavDest'));
const sub = await apiCreateFolder(page, uniq('NavSub'), dest.id);
await page.goto('/files');
await expect(page.getByTestId(srcName)).toBeVisible({ timeout: 15_000 });
await page.getByTestId(srcName).click({ button: 'right' });
await page.getByTestId('files-ctx-move-item').click();
await expect(page.getByTestId('move-dialog')).toBeVisible();
// Descend dest → sub, climb back via parent, jump home, re-enter dest, move.
await page.getByTestId(`move-dialog-folder-${dest.id}`).click();
await page.getByTestId(`move-dialog-folder-${sub.id}`).click();
await page.getByTestId('move-dialog-parent-btn').click();
await page.getByTestId('move-dialog-home-btn').click();
await page.getByTestId(`move-dialog-folder-${dest.id}`).click();
await page.getByTestId('move-dialog-confirm-btn').click();
await expect(page.getByTestId(srcName)).toHaveCount(0, { timeout: 15_000 });
});
+157
View File
@@ -0,0 +1,157 @@
import * as fs from 'fs';
import * as path from 'path';
import { test, expect } from './coverage-helpers';
import { apiLogin, apiCreateFolder, apiUploadFile } from '../scenarios/helpers';
/**
* Music route — playlist lifecycle, the add-tracks dialog, and the audio player
* (driven with a real WAV fixture so the track shows up in the picker). The
* music page is one of the largest source files.
*/
const TONE_WAV = fs.readFileSync(path.join(__dirname, '..', 'fixtures', 'tone.wav'));
test.beforeEach(async ({ page }) => {
await apiLogin(page);
});
function uniq(p: string): string {
return `${p}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
}
/** Create a playlist and return its name; works whether or not others exist. */
async function createPlaylist(page: import('@playwright/test').Page, name: string): Promise<void> {
await page.goto('/music');
const createBtn = page.getByTestId('music-create-playlist-btn');
const emptyBtn = page.getByTestId('music-create-playlist-empty-btn');
await expect(createBtn.or(emptyBtn)).toBeVisible({ timeout: 15_000 });
if (await createBtn.isVisible().catch(() => false)) await createBtn.click();
else await emptyBtn.click();
await page.getByTestId('dialog-host-prompt-input').fill(name);
await page.getByTestId('dialog-host-submit-btn').click();
await expect(page.getByTestId(name)).toBeVisible({ timeout: 15_000 });
}
test('playlist lifecycle: create, toggle public, rename, delete', async ({ page }) => {
const name = uniq('PL');
const renamed = uniq('PLr');
await createPlaylist(page, name);
// Select the playlist → detail panel with its controls.
await page.getByTestId(name).click();
await expect(page.getByTestId('music-rename-playlist-btn')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('music-toggle-public-btn').click().catch(() => {});
await page.getByTestId('music-rename-playlist-btn').click();
await page.getByTestId('dialog-host-prompt-input').fill(renamed);
await page.getByTestId('dialog-host-submit-btn').click();
await expect(page.getByTestId(renamed)).toBeVisible({ timeout: 15_000 });
await page.getByTestId('music-delete-playlist-btn').click();
await page.getByTestId('dialog-host-confirm-btn').click();
await expect(page.getByTestId(renamed)).toHaveCount(0, { timeout: 15_000 });
});
test('reorder playlist tracks by dragging', async ({ page }) => {
const folder = await apiCreateFolder(page, uniq('Audio2'));
const token = `multi${Date.now()}`;
await apiUploadFile(page, { name: `${token}-1.wav`, mimeType: 'audio/wav', body: TONE_WAV }, folder.id);
await apiUploadFile(page, { name: `${token}-2.wav`, mimeType: 'audio/wav', body: TONE_WAV }, folder.id);
const name = uniq('PLr2');
await createPlaylist(page, name);
await page.getByTestId(name).click();
await page.getByTestId('music-add-tracks-btn').click();
await expect(page.getByTestId('music-add-tracks-dialog')).toBeVisible();
await page.getByTestId('music-add-tracks-search-input').fill(token);
await page.getByTestId(`${token}-1.wav`).check({ timeout: 15_000 });
await page.getByTestId(`${token}-2.wav`).check({ timeout: 5_000 });
await page.getByTestId('music-add-tracks-confirm-btn').click();
await expect(page.getByTestId('music-add-tracks-dialog')).toHaveCount(0);
// Drag the first track onto the second to reorder.
const tracks = page.locator('.music-track');
await expect(tracks).toHaveCount(2, { timeout: 15_000 });
await tracks.nth(0).dragTo(tracks.nth(1));
});
test('add-tracks dialog opens, searches, and closes', async ({ page }) => {
const name = uniq('PLt');
await createPlaylist(page, name);
await page.getByTestId(name).click();
await expect(page.getByTestId('music-add-tracks-btn')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('music-add-tracks-btn').click();
await expect(page.getByTestId('music-add-tracks-dialog')).toBeVisible();
await page.getByTestId('music-add-tracks-search-input').fill('song');
await page.getByTestId('music-add-tracks-close-btn').click();
await expect(page.getByTestId('music-add-tracks-dialog')).toHaveCount(0);
});
test('add an audio track and drive the player', async ({ page }) => {
// A real WAV so the backend recognises it as audio and lists it in the picker.
const folder = await apiCreateFolder(page, uniq('Audio'));
const songName = `${uniq('song')}.wav`;
await apiUploadFile(page, { name: songName, mimeType: 'audio/wav', body: TONE_WAV }, folder.id);
const name = uniq('PLp');
await createPlaylist(page, name);
await page.getByTestId(name).click();
await expect(page.getByTestId('music-add-tracks-btn')).toBeVisible({ timeout: 15_000 });
// Open the picker and search by name (an empty query may match nothing).
await page.getByTestId('music-add-tracks-btn').click();
await expect(page.getByTestId('music-add-tracks-dialog')).toBeVisible();
await page.getByTestId('music-add-tracks-search-input').fill(songName.replace('.wav', ''));
const songCheckbox = page.getByTestId(songName);
await expect(songCheckbox).toBeVisible({ timeout: 15_000 });
await songCheckbox.check();
await page.getByTestId('music-add-tracks-confirm-btn').click();
await expect(page.getByTestId('music-add-tracks-dialog')).toHaveCount(0);
// Playlist-level actions on the detail panel.
await page.getByTestId('music-play-all-btn').click({ timeout: 3_000 }).catch(() => {});
await page.getByTestId('music-shuffle-play-btn').click({ timeout: 3_000 }).catch(() => {});
// Play the track and exercise the transport controls.
await page.locator('[data-testid^="music-track-play-"]').first().click({ timeout: 5_000 });
await page.getByTestId('music-player-play-btn').click({ timeout: 3_000 }).catch(() => {});
await page.getByTestId('music-player-shuffle-btn').click({ timeout: 3_000 }).catch(() => {});
await page.getByTestId('music-player-repeat-btn').click({ timeout: 3_000 }).catch(() => {});
await page.getByTestId('music-player-next-btn').click({ timeout: 3_000 }).catch(() => {});
await page.getByTestId('music-player-mute-btn').click({ timeout: 3_000 }).catch(() => {});
// Queue panel — open, remove a queued track, then close.
await page.getByTestId('music-player-queue-toggle-btn').click({ timeout: 3_000 }).catch(() => {});
await page
.locator('[data-testid^="music-queue-remove-"]')
.first()
.click({ timeout: 2_000 })
.catch(() => {});
await page.getByTestId('music-queue-close-btn').click({ timeout: 3_000 }).catch(() => {});
// Edit the playlist description.
await page.getByTestId('music-edit-description-btn').click({ timeout: 3_000 }).catch(() => {});
const descInput = page.getByTestId('dialog-host-prompt-input');
if (await descInput.isVisible().catch(() => false)) {
await descInput.fill('e2e description');
await page.getByTestId('dialog-host-submit-btn').click().catch(() => {});
}
// Manage-shares dialog.
await page.getByTestId('music-manage-shares-btn').click({ timeout: 3_000 }).catch(() => {});
await page.getByTestId('music-shares-close-btn').click({ timeout: 3_000 }).catch(() => {});
// Set a cover image (exercises the cover upload path).
await page.getByTestId('music-set-cover-btn').click({ timeout: 3_000 }).catch(() => {});
await page
.getByTestId('music-cover-input')
.setInputFiles({
name: 'cover.png',
mimeType: 'image/png',
buffer: Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64',
),
})
.catch(() => {});
});
+54
View File
@@ -0,0 +1,54 @@
import { test, expect } from './coverage-helpers';
import { apiLogin, seedFilesAndFolders } from '../scenarios/helpers';
/**
* Loads every primary route while authenticated. Each route is its own test
* (own page/document) so its `window.__coverage__` is captured before the next
* navigation resets it — a `goto` loop in one test would only keep the last
* route's data. Visiting a route mounts its `+page.svelte` and the API
* endpoint modules it imports, which is the bulk of first-load coverage.
*/
let seeded = false;
test.beforeEach(async ({ page }) => {
await apiLogin(page);
if (!seeded) {
// Seed once per worker. Tolerant of a re-seed (e.g. after a worker reset)
// hitting "already exists" so it never fails an unrelated route test.
await seedFilesAndFolders(page).catch(() => {});
seeded = true;
}
});
// Routes that render inside the standard app shell (assert the shell logo).
const SHELL_ROUTES: { path: string; name: string }[] = [
{ path: '/files', name: 'files' },
{ path: '/shared', name: 'shared' },
{ path: '/shared-with-me', name: 'shared-with-me' },
{ path: '/recent', name: 'recent' },
{ path: '/favorites', name: 'favorites' },
{ path: '/photos', name: 'photos' },
{ path: '/music', name: 'music' },
{ path: '/trash', name: 'trash' },
{ path: '/search?q=README', name: 'search' },
{ path: '/profile', name: 'profile' },
{ path: '/groups', name: 'groups' },
{ path: '/admin', name: 'admin' },
];
for (const route of SHELL_ROUTES) {
test(`route ${route.name} mounts inside the app shell`, async ({ page }) => {
await page.goto(route.path);
await expect(page.getByTestId('appshell-logo-link')).toBeVisible({ timeout: 15_000 });
// Let lazy chunks + onMount data fetches settle so their code is exercised.
await page.waitForLoadState('networkidle').catch(() => {});
});
}
// The device-pairing route is a standalone page (OAuth device-code flow); it
// does NOT use the app shell, so assert its own form instead.
test('route device renders the pairing form', async ({ page }) => {
await page.goto('/device');
await expect(page.getByTestId('device-code-form')).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId('device-code-input')).toBeVisible();
});
+80
View File
@@ -0,0 +1,80 @@
import { test, expect } from './coverage-helpers';
import { apiLogin, apiCreateFolder, apiUploadFile, SAMPLE_FILES } from '../scenarios/helpers';
/**
* Photos route — populate the grid with uploaded images, open the lightbox and
* navigate it, and switch the moments/places/people subnav. Covers the photos
* page + PhotoLightbox (and mounts PlacesMap / PeopleView).
*/
test.beforeEach(async ({ page }) => {
await apiLogin(page);
});
function uniq(p: string): string {
return `${p}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
}
async function seedPhotos(page: import('@playwright/test').Page, n: number): Promise<void> {
const folder = await apiCreateFolder(page, uniq('Photos'));
for (let i = 0; i < n; i++) {
await apiUploadFile(
page,
{ name: `${uniq('pic')}.png`, mimeType: 'image/png', body: SAMPLE_FILES.png().body },
folder.id,
);
}
}
test('photo grid opens the lightbox and navigates', async ({ page }) => {
await seedPhotos(page, 3);
await page.goto('/photos');
await expect(page.getByTestId('appshell-logo-link')).toBeVisible({ timeout: 15_000 });
const tile = page.locator('[data-testid^="photo-tile-"]').first();
await expect(tile).toBeVisible({ timeout: 15_000 });
await tile.click();
await expect(page.getByTestId('photo-lightbox')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('photo-lightbox-next-btn').click({ timeout: 3_000 }).catch(() => {});
await page.getByTestId('photo-lightbox-prev-btn').click({ timeout: 3_000 }).catch(() => {});
await page.getByTestId('photo-lightbox-close-btn').click();
await expect(page.getByTestId('photo-lightbox')).toHaveCount(0);
});
test('select photos, toggle layout, and batch-delete', async ({ page }) => {
await seedPhotos(page, 3);
await page.goto('/photos');
// The tile check button is hover-revealed; dispatch the click to select.
const tileCheck = page.locator('[data-testid^="photo-tile-check-"]').first();
await expect(tileCheck).toBeAttached({ timeout: 15_000 });
await tileCheck.dispatchEvent('click');
await expect(page.getByTestId('photos-batch-bar')).toBeVisible({ timeout: 5_000 });
// Layout toggles.
await page.getByTestId('photos-layout-justified-btn').click();
await page.getByTestId('photos-layout-square-btn').click();
// Batch-delete the selection (confirm if prompted).
await page.getByTestId('photos-batch-delete-btn').click();
const confirm = page.getByTestId('dialog-host-confirm-btn');
if (await confirm.isVisible().catch(() => false)) await confirm.click();
});
test('photos subnav switches to places and people', async ({ page }) => {
await seedPhotos(page, 2);
await page.goto('/photos');
await expect(page.getByTestId('photos-tab-places')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('photos-tab-places').click();
await page.waitForTimeout(800);
const peopleTab = page.getByTestId('photos-tab-people');
if (await peopleTab.isVisible().catch(() => false)) {
await peopleTab.click();
await page.waitForTimeout(800);
}
await page.getByTestId('photos-tab-moments').click();
await expect(page.getByTestId('photos-tab-moments')).toHaveAttribute('aria-selected', 'true');
});
+85
View File
@@ -0,0 +1,85 @@
import { test, expect } from './coverage-helpers';
import { apiLogin } from '../scenarios/helpers';
/**
* Profile route — edit the profile, open the avatar panel, and generate an app
* password. Covers the profile page + the auth/admin profile endpoints it uses.
*/
test.beforeEach(async ({ page }) => {
await apiLogin(page);
});
test('edit the profile form', async ({ page }) => {
await page.goto('/profile');
await expect(page.getByTestId('profile-edit-form')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('profile-given-name-input').fill('E2E');
await page.getByTestId('profile-family-name-input').fill('Tester');
await page.getByTestId('profile-language-select').selectOption({ index: 1 }).catch(() => {});
await page.getByTestId('profile-notify-on-share-checkbox').click({ timeout: 2_000 }).catch(() => {});
await page.getByTestId('profile-save-btn').click();
// The save button is the same control; just ensure the form stays mounted.
await expect(page.getByTestId('profile-edit-form')).toBeVisible();
});
test('open the avatar edit panel and upload an image', async ({ page }) => {
await page.goto('/profile');
await expect(page.getByTestId('profile-avatar-edit-btn')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('profile-avatar-edit-btn').click();
await expect(page.getByTestId('profile-avatar-edit-panel')).toBeVisible();
await page.getByTestId('profile-avatar-url-tab').click();
await expect(page.getByTestId('profile-avatar-url-input')).toBeVisible();
// Upload tab → set an image (exercises client-side image resizing).
await page.getByTestId('profile-avatar-upload-tab').click();
await page
.getByTestId('profile-avatar-file-input')
.setInputFiles({
name: 'avatar.png',
mimeType: 'image/png',
buffer: Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64',
),
})
.catch(() => {});
await page.waitForTimeout(500);
await page.getByTestId('profile-avatar-save-btn').click({ timeout: 3_000 }).catch(() => {});
// Remove the avatar we just set (the remove button shows once one exists).
await page.getByTestId('profile-avatar-edit-btn').click({ timeout: 3_000 }).catch(() => {});
await page.getByTestId('profile-avatar-remove-btn').click({ timeout: 3_000 }).catch(() => {});
await page.getByTestId('profile-avatar-cancel-btn').click({ timeout: 3_000 }).catch(() => {});
});
test('password change rejects a wrong current password', async ({ page }) => {
await page.goto('/profile');
await expect(page.getByTestId('profile-password-form')).toBeVisible({ timeout: 15_000 });
// Wrong current password → rejected, so the admin login stays valid for
// other tests while still exercising the change-password handler.
await page.getByTestId('profile-current-password-input').fill('definitely-wrong-current');
await page.getByTestId('profile-new-password-input').fill('NewPassword1!');
await page.getByTestId('profile-confirm-password-input').fill('NewPassword1!');
await page.getByTestId('profile-update-password-btn').click();
await expect(page.getByTestId('profile-password-form')).toBeVisible();
});
test('generate and revoke an app password', async ({ page }) => {
await page.goto('/profile');
await expect(page.getByTestId('profile-app-pw-label-input')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('profile-app-pw-label-input').fill('e2e-token');
await page.getByTestId('profile-app-pw-generate-btn').click();
// The generated secret + copy button appear once created.
await expect(page.getByTestId('profile-app-pw-copy-btn')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('profile-app-pw-copy-btn').click().catch(() => {});
// Revoke the app password we just created.
const revoke = page.locator('[data-testid^="profile-app-pw-revoke-"]').first();
if (await revoke.isVisible().catch(() => false)) {
await revoke.click();
const confirm = page.getByTestId('dialog-host-confirm-btn');
if (await confirm.isVisible().catch(() => false)) await confirm.click();
}
await expect(page.getByTestId('profile-app-pw-generate-btn')).toBeVisible();
});
+60
View File
@@ -0,0 +1,60 @@
import { test, expect } from './coverage-helpers';
import { apiLogin, apiCreateFolder, apiRecordRecent } from '../scenarios/helpers';
/**
* Recent route — populate it via the recents API (the SPA doesn't auto-record
* through this endpoint), then exercise the list, batch selection, and clear.
*/
test.beforeEach(async ({ page }) => {
await apiLogin(page);
});
function uniq(p: string): string {
return `${p}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
}
test('recent shows accessed items, batch selection, and clear', async ({ page }) => {
const a = await apiCreateFolder(page, uniq('RecA'));
const b = await apiCreateFolder(page, uniq('RecB'));
await apiRecordRecent(page, 'folder', a.id);
await apiRecordRecent(page, 'folder', b.id);
await page.goto('/recent');
await expect(page.getByTestId('appshell-logo-link')).toBeVisible({ timeout: 15_000 });
// Switch to list view (reveals the select-all header) and batch-select.
await page.getByTestId('list-toolbar-view-list-btn').click({ timeout: 3_000 }).catch(() => {});
const selectAll = page.getByTestId('resource-list-select-all-checkbox');
if (await selectAll.isVisible().catch(() => false)) {
await selectAll.check();
await page.getByTestId('recent-batch-move-btn').click({ timeout: 3_000 }).catch(() => {});
await page.getByTestId('move-dialog-cancel-btn').click({ timeout: 3_000 }).catch(() => {});
}
// Clear the history if the control is present.
const clearBtn = page.getByTestId('recent-clear-btn');
if (await clearBtn.isVisible().catch(() => false)) {
await clearBtn.click();
const confirm = page.getByTestId('dialog-host-confirm-btn');
if (await confirm.isVisible().catch(() => false)) await confirm.click();
}
});
test('recent grouping and sort cycle (ResourceList toolbar)', async ({ page }) => {
const a = await apiCreateFolder(page, uniq('RecG'));
await apiRecordRecent(page, 'folder', a.id);
await page.goto('/recent');
await expect(page.getByTestId('appshell-logo-link')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('list-toolbar-view-list-btn').click({ timeout: 3_000 }).catch(() => {});
// Cycle every group-by dimension exposed by the shared ResourceList toolbar.
for (let i = 0; i < 5; i++) {
await page.getByTestId('list-toolbar-groupby-btn').click({ timeout: 2_000 }).catch(() => {});
await page
.locator('[data-testid^="list-toolbar-groupby-"][data-testid$="-item"]')
.nth(i)
.click({ timeout: 2_000 })
.catch(() => {});
}
await page.getByTestId('list-toolbar-sort-direction-btn').click({ timeout: 2_000 }).catch(() => {});
});
+34
View File
@@ -0,0 +1,34 @@
import { test, expect } from './coverage-helpers';
import { apiLogin, apiCreateFolder, apiUploadFile } from '../scenarios/helpers';
/**
* Search route — seed a uniquely-named file, query for it, then exercise the
* type/size/date/sort filters and the clear-filters control. Covers the search
* route + search endpoint module.
*/
test.beforeEach(async ({ page }) => {
await apiLogin(page);
});
test('search finds a seeded file and applies filters', async ({ page }) => {
const token = `srch${Date.now()}${Math.floor(Math.random() * 1000)}`;
const folder = await apiCreateFolder(page, `SearchHost-${token}`);
await apiUploadFile(
page,
{ name: `${token}.txt`, mimeType: 'text/plain', body: Buffer.from('searchable content') },
folder.id,
);
await page.goto(`/search?q=${token}`);
await expect(page.getByTestId(`${token}.txt`)).toBeVisible({ timeout: 15_000 });
// Exercise the filter + sort controls (onchange handlers). Done after the
// result assertion; selecting by index keeps this resilient to option sets.
// Short timeouts so optional controls that aren't rendered fail fast instead
// of waiting the full test timeout (the scope/clear buttons are conditional).
const opt = { timeout: 2_000 };
await page.getByTestId('search-sort-select').selectOption({ index: 1 }, opt).catch(() => {});
await page.getByTestId('search-size-filter-select').selectOption({ index: 1 }, opt).catch(() => {});
await page.getByTestId('search-date-filter-select').selectOption({ index: 1 }, opt).catch(() => {});
await page.getByTestId('search-clear-filters-btn').click(opt).catch(() => {});
});
+103
View File
@@ -0,0 +1,103 @@
import { test, expect } from './coverage-helpers';
import { apiLogin, apiCreateFolder, apiAdminCreateUser, apiCreateGroup } from '../scenarios/helpers';
/**
* User-to-user sharing — create a second user, add them as a member through the
* ShareDialog people tab, change role / toggle notify, then manage the grant
* from /shared. Covers the ShareDialog member paths + shared user-grant rows.
*/
test.beforeEach(async ({ page }) => {
await apiLogin(page);
});
function uniq(p: string): string {
return `${p}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
}
test('share a folder with another user and manage the grant', async ({ page }) => {
const username = await apiAdminCreateUser(page, uniq('shareu'));
const folderName = uniq('UserShare');
// Isolate in a fresh parent (the /files root accumulates 100s of folders
// across the suite and won't render an arbitrary one).
const parent = await apiCreateFolder(page, uniq('UserShareHost'));
await apiCreateFolder(page, folderName, parent.id);
// Open the share dialog → people tab → search for the user and add them.
await page.goto(`/files/${parent.id}`);
await expect(page.getByTestId(folderName)).toBeVisible({ timeout: 15_000 });
await page.getByTestId(folderName).click({ button: 'right' });
await page.getByTestId('files-ctx-share-item').click();
await expect(page.getByTestId('share-dialog')).toBeVisible();
await page.getByTestId('share-dialog-people-tab').click();
await page.getByTestId('share-dialog-search-input').fill(username);
const result = page.locator('[data-testid^="share-dialog-result-user-"]').first();
await expect(result).toBeVisible({ timeout: 15_000 });
await result.click();
// The member row appears with role/notify/remove controls.
const memberRole = page.locator('[data-testid^="share-dialog-member-role-"]').first();
await expect(memberRole).toBeVisible({ timeout: 15_000 });
await memberRole.selectOption({ index: 1 }).catch(() => {});
await page
.locator('[data-testid^="share-dialog-member-notify-"]')
.first()
.click({ timeout: 3_000 })
.catch(() => {});
await page.getByTestId('share-dialog-close-btn').click();
await expect(page.getByTestId('share-dialog')).toHaveCount(0);
// Manage the grant from /shared. Scope to our folder's lane when present so
// we drive the *user* grant menu (role / notify / expiry / remove); the menu
// closes after each action, so reopen the kebab between steps.
await page.goto('/shared');
const lane = page.locator('section.ms-lane').filter({ hasText: folderName });
const scope = (await lane.count()) > 0 ? lane.first() : page.locator('body');
const kebab = scope.locator('[data-testid^="shared-kebab-"]').first();
await expect(kebab).toBeVisible({ timeout: 15_000 });
await kebab.click();
await scope.locator('[data-testid^="shared-notify-"]').first().click({ timeout: 2_000 }).catch(() => {});
await kebab.click().catch(() => {});
await scope.locator('[data-testid^="shared-role-"]').first().click({ timeout: 2_000 }).catch(() => {});
await kebab.click().catch(() => {});
await scope
.locator('[data-testid^="shared-expiry-"][data-testid$="-input"]')
.first()
.fill('2031-06-01', { timeout: 2_000 })
.catch(() => {});
await kebab.click().catch(() => {});
await scope
.locator('[data-testid^="shared-remove-access-"]')
.first()
.click({ timeout: 2_000 })
.catch(() => {});
const confirm = page.getByTestId('dialog-host-confirm-btn');
if (await confirm.isVisible().catch(() => false)) await confirm.click();
});
test('share a folder with a group', async ({ page }) => {
const groupName = await apiCreateGroup(page, uniq('shareg'));
const folderName = uniq('GroupShare');
const parent = await apiCreateFolder(page, uniq('GroupShareHost'));
await apiCreateFolder(page, folderName, parent.id);
await page.goto(`/files/${parent.id}`);
await expect(page.getByTestId(folderName)).toBeVisible({ timeout: 15_000 });
await page.getByTestId(folderName).click({ button: 'right' });
await page.getByTestId('files-ctx-share-item').click();
await expect(page.getByTestId('share-dialog')).toBeVisible();
await page.getByTestId('share-dialog-people-tab').click();
// Search the group and add it as a member.
await page.getByTestId('share-dialog-search-input').fill(groupName);
const groupResult = page.locator('[data-testid^="share-dialog-result-group-"]').first();
await expect(groupResult).toBeVisible({ timeout: 15_000 });
await groupResult.click();
await expect(page.locator('[data-testid^="share-dialog-member-"]').first()).toBeVisible({
timeout: 15_000,
});
await page.getByTestId('share-dialog-close-btn').click();
await expect(page.getByTestId('share-dialog')).toHaveCount(0);
});
+65
View File
@@ -0,0 +1,65 @@
import { test, expect } from './coverage-helpers';
import { apiLogin, apiCreateFolder } from '../scenarios/helpers';
/**
* Shared route — create a public link via the share dialog, then view it in
* /shared and open a grant's kebab menu. Exercises the shared page (lanes,
* rows, menu) plus the grants/shares endpoints.
*/
test.beforeEach(async ({ page }) => {
await apiLogin(page);
});
function uniq(p: string): string {
return `${p}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
}
test('a created link appears in /shared with an openable menu', async ({ page }) => {
const name = uniq('Shared');
// Isolate in a fresh parent (the /files root accumulates 100s of folders
// across the suite and its virtualised list won't render an arbitrary one).
const parent = await apiCreateFolder(page, uniq('SharedHost'));
await apiCreateFolder(page, name, parent.id);
// Create a public link through the share dialog.
await page.goto(`/files/${parent.id}`);
await expect(page.getByTestId(name)).toBeVisible({ timeout: 15_000 });
await page.getByTestId(name).click({ button: 'right' });
await page.getByTestId('files-ctx-share-item').click();
await expect(page.getByTestId('share-dialog')).toBeVisible();
await page.getByTestId('share-dialog-link-tab').click();
await page.getByTestId('share-dialog-create-btn').click();
await expect(page.locator('[data-testid^="share-dialog-link-delete-btn-"]').first()).toBeVisible({
timeout: 15_000,
});
await page.getByTestId('share-dialog-close-btn').click();
// It now shows up under My shares; open the first grant's kebab menu.
await page.goto('/shared');
// Load more grants if the pager is shown (covers cursor pagination).
await page.getByTestId('shared-load-more-btn').click({ timeout: 2_000 }).catch(() => {});
const kebab = page.locator('[data-testid^="shared-kebab-"]').first();
await expect(kebab).toBeVisible({ timeout: 15_000 });
await kebab.click();
await expect(page.locator('[data-testid^="shared-menu-"]').first()).toBeVisible();
// Exercise the link-grant menu actions, then delete the link.
await page
.locator('[data-testid^="shared-link-expiry-"]')
.first()
.fill('2031-01-01', { timeout: 3_000 })
.catch(() => {});
await page
.locator('[data-testid^="shared-menu-copy-link-"]')
.first()
.click({ timeout: 3_000 })
.catch(() => {});
await page
.locator('[data-testid^="shared-delete-link-"]')
.first()
.click({ timeout: 3_000 })
.catch(() => {});
const confirm = page.getByTestId('dialog-host-confirm-btn');
if (await confirm.isVisible().catch(() => false)) await confirm.click();
});
+89
View File
@@ -0,0 +1,89 @@
import { test, expect } from './coverage-helpers';
import { apiLogin, apiCreateFolder } from '../scenarios/helpers';
/**
* ShareDialog coverage — opened from the files context menu. Exercises the
* link tab (create a public link) and the people tab search, which pulls in
* the share / shares / grants / recipients endpoint modules.
*/
test.beforeEach(async ({ page }) => {
await apiLogin(page);
});
function uniq(p: string): string {
return `${p}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
}
/**
* Create the named folder inside a *fresh* parent and open it, so the folder is
* the only row (the shared `/files` root accumulates hundreds of folders across
* the suite and its virtualised list won't render an arbitrary one), then open
* its share dialog from the context menu.
*/
async function openShareDialog(page: import('@playwright/test').Page, name: string): Promise<void> {
const parent = await apiCreateFolder(page, uniq('ShareHost'));
await apiCreateFolder(page, name, parent.id);
await page.goto(`/files/${parent.id}`);
await expect(page.getByTestId(name)).toBeVisible({ timeout: 15_000 });
await page.getByTestId(name).click({ button: 'right' });
await page.getByTestId('files-ctx-share-item').click();
await expect(page.getByTestId('share-dialog')).toBeVisible();
}
test('create a public link via the share dialog', async ({ page }) => {
const name = uniq('Share');
await openShareDialog(page, name);
// Link tab → fill name/password/expiry, then create a link.
await page.getByTestId('share-dialog-link-tab').click();
await page.getByTestId('share-dialog-link-name-input').fill('e2e link');
await page.getByTestId('share-dialog-link-password-input').fill('secret123', { timeout: 2_000 }).catch(() => {});
await page.getByTestId('share-dialog-link-expires-input').fill('2031-01-01', { timeout: 2_000 }).catch(() => {});
await page.getByTestId('share-dialog-create-btn').click();
// A created link exposes copy/delete buttons keyed by its id.
const del = page.locator('[data-testid^="share-dialog-link-delete-btn-"]').first();
await expect(del).toBeVisible({ timeout: 15_000 });
await page.locator('[data-testid^="share-dialog-link-copy-btn-"]').first().click({ timeout: 2_000 }).catch(() => {});
// Delete the link we just made.
await del.click();
const confirm = page.getByTestId('dialog-host-confirm-btn');
if (await confirm.isVisible().catch(() => false)) await confirm.click();
await page.getByTestId('share-dialog-close-btn').click();
await expect(page.getByTestId('share-dialog')).toHaveCount(0);
});
test('people tab search runs in the share dialog', async ({ page }) => {
const name = uniq('ShareP');
await openShareDialog(page, name);
await page.getByTestId('share-dialog-people-tab').click();
await page.getByTestId('share-dialog-search-input').fill('admin');
// Let the debounced search fire (covers the recipients endpoint).
await page.waitForTimeout(800);
await page.getByTestId('share-dialog-close-btn').click();
await expect(page.getByTestId('share-dialog')).toHaveCount(0);
});
test('invite an external user by email from the share dialog', async ({ page }) => {
const name = uniq('ShareE');
await openShareDialog(page, name);
await page.getByTestId('share-dialog-people-tab').click();
// Typing an email surfaces a synthetic "invite by email" result.
const email = `invitee-${Date.now()}@example.test`;
await page.getByTestId('share-dialog-search-input').fill(email);
const inviteRow = page.locator('[data-testid^="share-dialog-result-email-"]').first();
await expect(inviteRow).toBeVisible({ timeout: 15_000 });
await inviteRow.click();
// The invitee becomes a pending member.
await expect(page.locator('[data-testid^="share-dialog-member-"]').first()).toBeVisible({
timeout: 15_000,
});
await page.getByTestId('share-dialog-close-btn').click();
await expect(page.getByTestId('share-dialog')).toHaveCount(0);
});
+61
View File
@@ -0,0 +1,61 @@
import { test, expect } from './coverage-helpers';
import { apiLogin, apiCreateFolder, apiTrashFolder } from '../scenarios/helpers';
/**
* Trash flow — restore and permanently delete items from /trash. The folder is
* trashed via the API (the /files root accumulates hundreds of folders across
* the suite and its virtualised list won't render a specific one), and the
* trash is emptied first so our item is the only — and thus visible — row.
*/
test.beforeEach(async ({ page }) => {
await apiLogin(page);
});
function uniq(p: string): string {
return `${p}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
}
test('restore an item from trash', async ({ page }) => {
// Ensure the trash is non-empty, then restore the first item (the trash list
// accumulates across the suite, so we exercise the endpoint on whatever is
// first rather than hunting a specific virtualised row).
const folder = await apiCreateFolder(page, uniq('Trash'));
await apiTrashFolder(page, folder.id);
await page.goto('/trash');
// Row action buttons are CSS hover-revealed (display:none until hover), so
// assert they're attached and dispatch the click directly.
const restore = page.locator('[data-testid^="trash-restore-btn-"]').first();
await expect(restore).toBeAttached({ timeout: 15_000 });
await restore.dispatchEvent('click');
// The restore ran without error and the app is still mounted.
await expect(page.getByTestId('appshell-logo-link')).toBeVisible({ timeout: 15_000 });
});
test('permanently delete an item from trash', async ({ page }) => {
const folder = await apiCreateFolder(page, uniq('Purge'));
await apiTrashFolder(page, folder.id);
await page.goto('/trash');
const del = page.locator('[data-testid^="trash-delete-btn-"]').first();
await expect(del).toBeAttached({ timeout: 15_000 });
await del.dispatchEvent('click');
const confirm = page.getByTestId('dialog-host-confirm-btn');
if (await confirm.isVisible().catch(() => false)) await confirm.click();
await expect(page.getByTestId('appshell-logo-link')).toBeVisible({ timeout: 15_000 });
});
test('empty the trash from the toolbar', async ({ page }) => {
// Seed a trashed item so the empty-trash button is shown, then empty via UI.
const folder = await apiCreateFolder(page, uniq('EmptyMe'));
await apiTrashFolder(page, folder.id);
await page.goto('/trash');
await expect(page.getByTestId('trash-empty-btn')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('trash-empty-btn').click();
const confirm = page.getByTestId('dialog-host-confirm-btn');
if (await confirm.isVisible().catch(() => false)) await confirm.click();
await expect(page.locator('[data-testid^="trash-restore-btn-"]')).toHaveCount(0, {
timeout: 15_000,
});
});
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Boot a clean test DB then start OxiCloud serving the *SvelteKit* SPA
# (static-dist) for the coverage e2e suite. Mirrors start-server.sh but uses a
# separate storage dir so it can coexist with the legacy suite's state.
#
# The caller (playwright.coverage.config.ts) sets OXICLOUD_STATIC_PATH to
# ./static-dist so the debug `cargo run` build serves the instrumented Vite
# output instead of the legacy ./static vanilla frontend.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
SPA_STORAGE_PATH="$REPO_ROOT/tests/e2e/storage-spa"
# ensure storage is empty before starting
echo "Wipe $SPA_STORAGE_PATH to ensure clean startup"
rm -rf "$SPA_STORAGE_PATH"
mkdir -p "$SPA_STORAGE_PATH"
# Spawn database (idempotent — reuses the running test postgres if present).
bash "$REPO_ROOT/tests/common/spawn-db.sh"
# Replace the shell with the server process so Playwright's PID tracking works.
exec "$@"
+22 -1
View File
@@ -7,13 +7,34 @@ set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
OXICLOUD_STORAGE_PATH="$REPO_ROOT/tests/e2e/storage"
# Mirror everything (these markers + the server's own stdout/stderr) to a log
# file as well as the console. Playwright captures the webServer's stdout, but
# in CI that capture isn't always surfaced in the job log — the file is, via an
# `if: always()` "print server startup log" step in ci.yml. The final
# `exec "$@"` below inherits these redirected fds, so the server's output is
# tee'd too, while the process still replaces this shell (Playwright tracks the
# PID for teardown).
SERVER_LOG="$REPO_ROOT/tests/e2e/server-startup.log"
exec > >(tee "$SERVER_LOG") 2>&1
mark() { echo "[start-server $(date -u +%H:%M:%S)] $*"; }
mark "repo_root=$REPO_ROOT"
mark "server binary args: $*"
if [[ -n "${1:-}" && "$1" != "cargo" ]]; then
ls -la "$1" 2>&1 || mark "WARNING: server binary '$1' not found"
fi
mark "DATABASE_URL=${DATABASE_URL:-<unset>} OXICLOUD_SERVER_PORT=${OXICLOUD_SERVER_PORT:-<unset>}"
# ensure storage is empty before starting
echo "Wipe $OXICLOUD_STORAGE_PATH to ensure clean startup"
mark "wiping $OXICLOUD_STORAGE_PATH to ensure clean startup"
rm -rf "$OXICLOUD_STORAGE_PATH"
mkdir -p "$OXICLOUD_STORAGE_PATH"
# Spawn database
mark "spawning test database…"
bash "$REPO_ROOT/tests/common/spawn-db.sh"
mark "database ready; starting server…"
# Replace the shell with the server process so Playwright's PID tracking works.
exec "$@"
+7 -7
View File
@@ -284,12 +284,12 @@ api_empty_trash() {
wipe_home_folder() {
[[ -n "${HOME_FOLDER_ID:-}" ]] \
|| fail "wipe_home_folder: HOME_FOLDER_ID is unset — call resolve_home_folder_id first"
# `/listing` (NOT `/contents`) is the endpoint that returns the
# `.files[]` / `.folders[]` arrays we iterate here — same one
# `tests/api/storage_cleanup_check.sh` uses for the equivalent
# full-tree wipe before its disk-audit step.
# `/resources` (the `/listing` and `/contents` endpoints were removed)
# returns `{ items: [{ resource_type, resource }] }`; split the items by
# `resource_type` to iterate child files and folders. `limit=200` covers
# any test home folder in one page.
local listing
listing=$(api_curl "$base_url/api/folders/$HOME_FOLDER_ID/listing")
listing=$(api_curl "$base_url/api/folders/$HOME_FOLDER_ID/resources?limit=200")
# Delete every direct child file (recursive contents go with the
# file's row). Errors are swallowed because the test that called
# us doesn't care WHY a leftover was unreachable — it just wants
@@ -297,12 +297,12 @@ wipe_home_folder() {
while IFS= read -r fid; do
[[ -z "$fid" || "$fid" == "null" ]] && continue
api_curl -X DELETE "$base_url/api/files/$fid" > /dev/null 2>&1 || true
done < <(jq -r '.files[]?.id // empty' <<< "$listing")
done < <(jq -r '.items[]? | select(.resource_type == "file") | .resource.id // empty' <<< "$listing")
# Then every direct child folder (recursive subtree goes with).
while IFS= read -r fid; do
[[ -z "$fid" || "$fid" == "null" ]] && continue
api_curl -X DELETE "$base_url/api/folders/$fid" > /dev/null 2>&1 || true
done < <(jq -r '.folders[]?.id // empty' <<< "$listing")
done < <(jq -r '.items[]? | select(.resource_type == "folder") | .resource.id // empty' <<< "$listing")
# Finally permanently delete everything in trash so the row-level
# `is_trashed` orphans the upstream tests left behind don't make
# *us* leak chunks/blobs into storage_cleanup_check's audit.
@@ -183,10 +183,11 @@ pass "J6: assembled file is byte-identical to concat(chunk1, chunk2)"
# dedup / lifecycle hooks downstream key on.
# ─────────────────────────────────────────────────────────────
echo " J7: BLAKE3 round-trip on assembled file (HEADLINE)"
# Find the assembled file's id via the REST listing.
listing=$(api_curl "$base_url/api/folders/$HOME_FOLDER_ID/listing")
ASSEMBLED_ID=$(jq -r '.files[]? | select(.name == "j-assembled.bin") | .id' <<< "$listing")
SERVER_HASH=$(jq -r '.files[]? | select(.name == "j-assembled.bin") | .content_hash' <<< "$listing")
# Find the assembled file's id via the REST `/resources` listing
# ({ items: [{ resource_type, resource }] }; `/listing` was removed).
listing=$(api_curl "$base_url/api/folders/$HOME_FOLDER_ID/resources?resource_types=file&limit=200")
ASSEMBLED_ID=$(jq -r '.items[]?.resource | select(.name == "j-assembled.bin") | .id' <<< "$listing")
SERVER_HASH=$(jq -r '.items[]?.resource | select(.name == "j-assembled.bin") | .content_hash' <<< "$listing")
[[ -n "$ASSEMBLED_ID" && "$ASSEMBLED_ID" != "null" ]] \
|| fail "J7: assembled file not visible via REST listing"
[[ -n "$SERVER_HASH" && "$SERVER_HASH" != "null" ]] \
+7 -10
View File
@@ -60,19 +60,16 @@ header_value() {
# ── Helper: list home folder, find file by name, capture id + content_hash ───
#
# Uses `/listing` (NOT `/contents`): `/contents` is deprecated AND
# its response shape was changed from `{files, folders}` to a flat
# array, so callers that try `.files[]` fail with "Cannot index
# array with string 'files'". The non-deprecated `/listing`
# endpoint still returns the `.files[] / .folders[]` shape we
# need here. Same endpoint `wipe_home_folder` + the API cleanup
# audit (`tests/api/storage_cleanup_check.sh`) use.
# Uses the cursor-paginated `/resources` listing (the `/listing` and
# `/contents` endpoints were removed). `?resource_types=file` returns only
# file rows as `{ items: [{ resource_type, resource }], next_cursor }`, so
# the file DTO (id + content_hash) is at `.items[].resource`.
nc_lookup_via_rest() {
local name="$1"
local response
response=$(api_curl "$base_url/api/folders/$HOME_FOLDER_ID/listing")
LAST_FILE_ID=$(jq -r --arg n "$name" '.files[]? | select(.name == $n) | .id' <<< "$response")
LAST_FILE_CONTENT_HASH=$(jq -r --arg n "$name" '.files[]? | select(.name == $n) | .content_hash' <<< "$response")
response=$(api_curl "$base_url/api/folders/$HOME_FOLDER_ID/resources?resource_types=file&limit=200")
LAST_FILE_ID=$(jq -r --arg n "$name" '.items[]?.resource | select(.name == $n) | .id' <<< "$response")
LAST_FILE_CONTENT_HASH=$(jq -r --arg n "$name" '.items[]?.resource | select(.name == $n) | .content_hash' <<< "$response")
[[ -n "$LAST_FILE_ID" && "$LAST_FILE_ID" != "null" ]] \
|| fail "REST lookup for '$name' in home folder returned no id (response: $response)"
}