e3823ce470
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.
103 lines
4.1 KiB
TypeScript
103 lines
4.1 KiB
TypeScript
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(() => {});
|
|
},
|
|
};
|
|
}
|