From 2d0a561fa3d415f24f7deea7a5a2412681ac7748 Mon Sep 17 00:00:00 2001 From: Oksamies Date: Fri, 19 Dec 2025 13:54:01 +0200 Subject: [PATCH] Enhance testing setup with Docker integration and Vitest configuration - Added testing instructions to README.md for Docker usage. - Updated package.json to include new test and coverage scripts for containerized testing. - Modified test files to utilize environment variables for API host configuration. - Introduced vite-env.d.ts to define environment variables for TypeScript. - Created run_test_container.mjs to manage test execution in Docker. - Updated Dockerfile.test and docker-compose.yml for test service configuration. - Added entrypoint script for test container setup. - Refactored CI scripts to use Docker for backend services. --- README.md | 19 ++ package.json | 4 +- .../dapper-ts/src/__tests__/index.test.ts | 6 +- packages/dapper-ts/src/vite-env.d.ts | 6 + packages/dapper-ts/tsconfig.json | 2 +- packages/thunderstore-api/package.json | 1 + .../src/__tests__/defaultConfig.ts | 4 +- packages/thunderstore-api/src/vite-env.d.ts | 6 + packages/thunderstore-api/tsconfig.json | 2 +- tools/scripts/run_test_container.mjs | 183 ++++++++++++++++++ tools/test-ci/run_ci_script.py | 20 +- .../thunderstore-test-backend/Dockerfile.test | 29 +++ .../docker-compose.yml | 39 +++- .../entrypoint.test.sh | 91 +++++++++ tools/visual-diff-ci/run_ci_script.py | 20 +- 15 files changed, 419 insertions(+), 13 deletions(-) create mode 100644 packages/dapper-ts/src/vite-env.d.ts create mode 100644 packages/thunderstore-api/src/vite-env.d.ts create mode 100644 tools/scripts/run_test_container.mjs create mode 100644 tools/thunderstore-test-backend/Dockerfile.test create mode 100644 tools/thunderstore-test-backend/entrypoint.test.sh diff --git a/README.md b/README.md index b7dfb5f8e..ae22b3f66 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,25 @@ running: docker compose -f docker-compose.build.yml build ``` +## Testing (Docker) + +Frontend tests run in Vitest browser mode (Playwright). To keep the environment consistent, use the dedicated test runner compose file instead of the dev container. + +Prereqs: +- Ensure `./build-secrets/.npmrc` exists (same requirement as Docker builds). + +Run tests: + +```bash +yarn test:container +``` + +Run coverage: + +```bash +yarn coverage:container +``` + ## pre-commit [Pre-commit](https://pre-commit.com/) enforces code style practices in this diff --git a/package.json b/package.json index 2c0ddcce4..879fbb388 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "plop": "plop", "test": "vitest run", "test:watch": "vitest watch", - "coverage": "vitest run --coverage" + "coverage": "vitest run --coverage", + "test:container": "node tools/scripts/run_test_container.mjs test", + "coverage:container": "node tools/scripts/run_test_container.mjs coverage" }, "engines": { "node": ">=20.17.0" diff --git a/packages/dapper-ts/src/__tests__/index.test.ts b/packages/dapper-ts/src/__tests__/index.test.ts index 4378d3ad5..ef2c111de 100644 --- a/packages/dapper-ts/src/__tests__/index.test.ts +++ b/packages/dapper-ts/src/__tests__/index.test.ts @@ -9,7 +9,11 @@ let dapper: DapperTs; beforeAll(() => { dapper = new DapperTs(() => { - return { apiHost: "http://127.0.0.1:8000" }; + return { + apiHost: + import.meta.env.VITE_THUNDERSTORE_TEST_API_HOST ?? + "http://127.0.0.1:8000", + }; }); }); diff --git a/packages/dapper-ts/src/vite-env.d.ts b/packages/dapper-ts/src/vite-env.d.ts new file mode 100644 index 000000000..cd7b9928c --- /dev/null +++ b/packages/dapper-ts/src/vite-env.d.ts @@ -0,0 +1,6 @@ +interface ImportMeta { + readonly env: { + readonly VITE_THUNDERSTORE_TEST_API_HOST?: string; + readonly [key: string]: string | undefined; + }; +} diff --git a/packages/dapper-ts/tsconfig.json b/packages/dapper-ts/tsconfig.json index 509a9bfcb..32a8f4044 100644 --- a/packages/dapper-ts/tsconfig.json +++ b/packages/dapper-ts/tsconfig.json @@ -23,7 +23,7 @@ "outDir": "./dist", "rootDir": "./src", "jsx": "react-jsx", - "types": ["@vitest/browser/providers/playwright"] + "types": ["vite/client", "@vitest/browser/providers/playwright"] }, "include": ["./src/**/*.tsx", "./src/**/*.ts"], "exclude": ["node_modules"] diff --git a/packages/thunderstore-api/package.json b/packages/thunderstore-api/package.json index 9b9a5d595..23d7b8d98 100644 --- a/packages/thunderstore-api/package.json +++ b/packages/thunderstore-api/package.json @@ -28,6 +28,7 @@ "@vitest/browser": "3.2.4", "playwright": "1.55.1", "typescript": "^5.6.2", + "vite": "7.1.7", "vitest": "3.2.4", "zod": "^3.23.8" }, diff --git a/packages/thunderstore-api/src/__tests__/defaultConfig.ts b/packages/thunderstore-api/src/__tests__/defaultConfig.ts index 9f665916c..17400fffd 100644 --- a/packages/thunderstore-api/src/__tests__/defaultConfig.ts +++ b/packages/thunderstore-api/src/__tests__/defaultConfig.ts @@ -1,6 +1,8 @@ export const config = () => { return { - apiHost: "http://127.0.0.1:8000", + apiHost: + import.meta.env.VITE_THUNDERSTORE_TEST_API_HOST ?? + "http://127.0.0.1:8000", }; }; diff --git a/packages/thunderstore-api/src/vite-env.d.ts b/packages/thunderstore-api/src/vite-env.d.ts new file mode 100644 index 000000000..cd7b9928c --- /dev/null +++ b/packages/thunderstore-api/src/vite-env.d.ts @@ -0,0 +1,6 @@ +interface ImportMeta { + readonly env: { + readonly VITE_THUNDERSTORE_TEST_API_HOST?: string; + readonly [key: string]: string | undefined; + }; +} diff --git a/packages/thunderstore-api/tsconfig.json b/packages/thunderstore-api/tsconfig.json index 509a9bfcb..32a8f4044 100644 --- a/packages/thunderstore-api/tsconfig.json +++ b/packages/thunderstore-api/tsconfig.json @@ -23,7 +23,7 @@ "outDir": "./dist", "rootDir": "./src", "jsx": "react-jsx", - "types": ["@vitest/browser/providers/playwright"] + "types": ["vite/client", "@vitest/browser/providers/playwright"] }, "include": ["./src/**/*.tsx", "./src/**/*.ts"], "exclude": ["node_modules"] diff --git a/tools/scripts/run_test_container.mjs b/tools/scripts/run_test_container.mjs new file mode 100644 index 000000000..4ad8257a9 --- /dev/null +++ b/tools/scripts/run_test_container.mjs @@ -0,0 +1,183 @@ +import { spawn } from "node:child_process"; +import process from "node:process"; + +function usage(exitCode = 1) { + // Keep this short: it shows up in CI logs. + console.error( + "Usage: node tools/scripts/run_test_container.mjs [-- ]" + ); + process.exit(exitCode); +} + +function spawnLogged(command, args, options = {}) { + const printable = [command, ...args].join(" "); + console.log(printable); + + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: "inherit", + shell: false, + ...options, + }); + + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) { + resolve(); + } else { + const err = new Error(`${printable} failed with exit code ${code}`); + err.exitCode = code; + reject(err); + } + }); + }); +} + +async function waitForDjangoInContainer( + composeFile, + { timeoutMs = 120_000, pollMs = 1_000 } = {} +) { + const deadline = Date.now() + timeoutMs; + + const pythonCheck = + "import http.client, sys; " + + "c=http.client.HTTPConnection('127.0.0.1',8000,timeout=2); " + + "c.request('GET','/'); " + + "r=c.getresponse(); " + + "sys.exit(0 if 200 <= r.status < 400 else 1)"; + + while (Date.now() < deadline) { + try { + await spawnLogged( + "docker", + [ + "compose", + "-f", + composeFile, + "exec", + "-T", + "django", + "python", + "-c", + pythonCheck, + ], + { + env: process.env, + stdio: "ignore", + } + ); + return; + } catch { + // ignore until timeout + } + + const remainingSeconds = Math.max( + 0, + Math.round((deadline - Date.now()) / 1000) + ); + console.log( + `Waiting for django to be ready inside container (${remainingSeconds}s remaining)` + ); + await new Promise((r) => setTimeout(r, pollMs)); + } + + throw new Error("Timed out waiting for django to be ready inside container"); +} + +function parseArgs(argv) { + const [subcommand, ...rest] = argv; + if (!subcommand || subcommand === "-h" || subcommand === "--help") { + usage(0); + } + + if (subcommand !== "test" && subcommand !== "coverage") { + console.error(`Unknown subcommand: ${subcommand}`); + usage(1); + } + + const dashDashIndex = rest.indexOf("--"); + // Yarn v1 forwards args without requiring `--`. Accept both forms: + // yarn test:container path/to/test + // yarn test:container -- path/to/test + const vitestArgs = + dashDashIndex === -1 ? rest : rest.slice(dashDashIndex + 1); + + return { subcommand, vitestArgs }; +} + +async function main() { + const { subcommand, vitestArgs } = parseArgs(process.argv.slice(2)); + + const backendComposeFile = + "tools/thunderstore-test-backend/docker-compose.yml"; + + // The test runner executes *inside* Docker. That container can resolve + // service names like `django`, but it can't resolve the host-published + // address `127.0.0.1:8000`. Override the backend's canonical host so + // redirects stay usable from within the container network. + const backendComposeEnv = { + ...process.env, + PRIMARY_HOST: process.env.PRIMARY_HOST ?? "django:8000", + }; + + try { + // Start backend in the background. + await spawnLogged( + "docker", + [ + "compose", + "-f", + backendComposeFile, + "up", + "-d", + "--remove-orphans", + "--build", + // Only start backend dependencies; the same compose file also contains + // the test runner services. + "db", + "redis", + "rabbitmq", + "minio", + "django", + ], + { env: backendComposeEnv } + ); + + // Wait until it's actually serving traffic (cold starts can take a while). + // We probe from inside the container network so we don't depend on any + // published host port and we implicitly wait for the backend setup + // commands inside the containers to finish. + await waitForDjangoInContainer(backendComposeFile, { timeoutMs: 300_000 }); + + const command = subcommand === "test" ? "test" : "coverage"; + + const args = [ + "compose", + "-f", + backendComposeFile, + "run", + "--rm", + "--build", + "cyberstorm-tests", + "yarn", + command, + ...vitestArgs, + ]; + + await spawnLogged("docker", args, { env: backendComposeEnv }); + } finally { + // Always tear down the test backend so it doesn't conflict with dev. + await spawnLogged( + "docker", + ["compose", "-f", backendComposeFile, "down", "--remove-orphans"], + { env: backendComposeEnv } + ).catch(() => { + // Best-effort cleanup. + }); + } +} + +main().catch((err) => { + console.error(err?.stack || String(err)); + process.exit(typeof err?.exitCode === "number" ? err.exitCode : 1); +}); diff --git a/tools/test-ci/run_ci_script.py b/tools/test-ci/run_ci_script.py index 2f2909feb..042b44b2d 100644 --- a/tools/test-ci/run_ci_script.py +++ b/tools/test-ci/run_ci_script.py @@ -97,7 +97,7 @@ def poll_backend() -> bool: except Exception: return False - timeout_threshold = time.time() + 60 + timeout_threshold = time.time() + 90 while (result := poll_backend()) is False and time.time() < timeout_threshold: print( "Polling failed, " @@ -112,12 +112,26 @@ def poll_backend() -> bool: def start_backend() -> bool: - run_command("docker compose up -d", cwd=BACKEND_DIR) + run_command( + [ + "docker", + "compose", + "up", + "-d", + "--remove-orphans", + "db", + "redis", + "rabbitmq", + "minio", + "django", + ], + cwd=BACKEND_DIR, + ) return wait_for_url("http://127.0.0.1:8000/") def stop_backend(): - run_command("docker compose down", cwd=BACKEND_DIR) + run_command(["docker", "compose", "down", "--remove-orphans"], cwd=BACKEND_DIR) def run_tests(): diff --git a/tools/thunderstore-test-backend/Dockerfile.test b/tools/thunderstore-test-backend/Dockerfile.test new file mode 100644 index 000000000..5c2541ace --- /dev/null +++ b/tools/thunderstore-test-backend/Dockerfile.test @@ -0,0 +1,29 @@ +FROM node:24-bookworm + +WORKDIR /workspace + +ARG PLAYWRIGHT_VERSION=1.55.1 + +ENV NODE_ENV=test + +RUN apt-get update \ + && apt-get install -y --no-install-recommends rsync \ + && rm -rf /var/lib/apt/lists/* + +# Install Playwright + its OS deps and browsers at image build time so +# containers don't redo this on every start. +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright +RUN npm -g i "playwright@${PLAYWRIGHT_VERSION}" \ + && npx playwright install-deps chromium \ + && npx playwright install chromium \ + && mkdir -p "$PLAYWRIGHT_BROWSERS_PATH" \ + && chown -R node:node "$PLAYWRIGHT_BROWSERS_PATH" + +# Copy entrypoint script +COPY entrypoint.test.sh /usr/local/bin/entrypoint.test.sh +RUN chmod +x /usr/local/bin/entrypoint.test.sh + +ENTRYPOINT ["/usr/local/bin/entrypoint.test.sh"] + +# Default: run the repo test script (vitest) +CMD ["yarn", "test"] diff --git a/tools/thunderstore-test-backend/docker-compose.yml b/tools/thunderstore-test-backend/docker-compose.yml index 8c2429645..8c18c55ab 100644 --- a/tools/thunderstore-test-backend/docker-compose.yml +++ b/tools/thunderstore-test-backend/docker-compose.yml @@ -1,9 +1,10 @@ version: "3.8" x-django-service: &django-service - image: ${DJANGO_IMAGE:-thunderstore/thunderstore:release-0.145.1} + image: ${DJANGO_IMAGE:-thunderstore/thunderstore:release-0.151.0} environment: CORS_ALLOWED_ORIGINS: "*" + CORS_ALLOW_ALL_ORIGINS: "True" CELERY_BROKER_URL: "pyamqp://django:django@rabbitmq/django" DATABASE_URL: "psql://django:django@db/django" REDIS_URL: "redis://redis:6379/0" @@ -37,7 +38,7 @@ x-django-service: &django-service USE_ASYNC_PACKAGE_SUBMISSION_FLOW: "True" USE_TIME_SERIES_PACKAGE_DOWNLOAD_METRICS: "True" ALLOWED_HOSTS: "*" - PRIMARY_HOST: "127.0.0.1:8000" + PRIMARY_HOST: "${PRIMARY_HOST:-127.0.0.1:8000}" depends_on: - db - redis @@ -80,3 +81,37 @@ services: - ./fix_migration.py:/app/fix_migration.py ports: - "127.0.0.1:8000:8000" + + cyberstorm-tests: + user: "0:0" + build: + context: . + dockerfile: Dockerfile.test + working_dir: /workspace + volumes: + - ../../:/src:ro + - workspace_test_src:/workspace + - thunderstore_ui_test_node_modules:/workspace/node_modules + - nimbus_test_node_modules:/workspace/apps/cyberstorm-remix/node_modules + - yarn_cache:/usr/local/share/.cache/yarn + environment: + NODE_ENV: test + NPM_CONFIG_USERCONFIG: /run/secrets/npmrc + PLAYWRIGHT_BROWSERS_PATH: /ms-playwright + VITE_THUNDERSTORE_TEST_API_HOST: "http://django:8000" + RSYNC_ARGS: "-a --delete --info=progress2 --exclude=.git --exclude=build-secrets --exclude=.npmrc --exclude=node_modules --exclude=.turbo --exclude=.cache --exclude=apps/cyberstorm-remix/build --exclude=apps/cyberstorm-remix/.react-router" + secrets: + - npmrc + extra_hosts: + - "host.docker.internal:host-gateway" + + +volumes: + workspace_test_src: + thunderstore_ui_test_node_modules: + nimbus_test_node_modules: + yarn_cache: + +secrets: + npmrc: + file: "../../build-secrets/.npmrc" diff --git a/tools/thunderstore-test-backend/entrypoint.test.sh b/tools/thunderstore-test-backend/entrypoint.test.sh new file mode 100644 index 000000000..4a276ab64 --- /dev/null +++ b/tools/thunderstore-test-backend/entrypoint.test.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -euo pipefail + +HOME_DIR="${HOME:-/root}" + +# Setup npmrc (auth) if provided +if [ -f /run/secrets/npmrc ]; then + # Keep tight perms; contains auth tokens. + mkdir -p "$HOME_DIR" + install -m 0600 /run/secrets/npmrc "$HOME_DIR/.npmrc" +fi + +export PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-/ms-playwright}" + +did_sync="false" + +# Sync the repo into the persistent /workspace volume (if /src is mounted). +if [ -d /src ]; then + echo "Syncing source into /workspace..." + if ! command -v rsync >/dev/null 2>&1; then + echo "ERROR: rsync is required but was not found in PATH" >&2 + exit 127 + fi + + rsync_args_default=( + -a + --delete + --exclude=.git + --exclude=build-secrets + --exclude=.npmrc + --exclude=node_modules + --exclude=.turbo + --exclude=.cache + --exclude=apps/cyberstorm-remix/build + --exclude=apps/cyberstorm-remix/.react-router + ) + + # If you need args that contain spaces, provide one arg per line via RSYNC_ARGS_NL. + # Otherwise RSYNC_ARGS is supported for backwards compatibility (whitespace-splitting). + if [ -n "${RSYNC_ARGS_NL:-}" ]; then + rsync_args=() + while IFS= read -r line; do + [ -z "$line" ] && continue + rsync_args+=("$line") + done <<< "$RSYNC_ARGS_NL" + elif [ -n "${RSYNC_ARGS:-}" ]; then + echo "WARNING: RSYNC_ARGS is split on whitespace; use RSYNC_ARGS_NL for args containing spaces" >&2 + set -f + IFS=$' \t\n' read -r -a rsync_args <<< "$RSYNC_ARGS" + set +f + else + rsync_args=("${rsync_args_default[@]}") + fi + rsync "${rsync_args[@]}" /src/ /workspace/ + + did_sync="true" +fi + +mkdir -p \ + /workspace/node_modules \ + /workspace/apps/cyberstorm-remix/node_modules \ + /workspace/apps/cyberstorm-remix/.react-router \ + /usr/local/share/.cache/yarn + +# Install JS deps if missing +if [ -z "$(ls -A /workspace/node_modules 2>/dev/null || true)" ] || [ ! -x /workspace/node_modules/.bin/vitest ]; then + echo "Installing dependencies..." + if ! command -v yarn >/dev/null 2>&1; then + echo "ERROR: yarn is required but was not found in PATH" >&2 + exit 127 + fi + yarn install --frozen-lockfile --production=false +fi + +# `preconstruct dev` generates workspace-local link files that rsync will delete on the next sync. +# When node_modules is cached, we still need to recreate those links so Vite can resolve package +# entrypoints (e.g. @thunderstore/dapper-ts, @thunderstore/thunderstore-api). +if [ "$did_sync" = "true" ]; then + if ! command -v yarn >/dev/null 2>&1; then + echo "ERROR: yarn is required but was not found in PATH" >&2 + exit 127 + fi + echo "Refreshing workspace links (postinstall)..." + yarn run -s postinstall +fi + +if [ "$#" -eq 0 ]; then + set -- yarn test +fi + +exec "$@" diff --git a/tools/visual-diff-ci/run_ci_script.py b/tools/visual-diff-ci/run_ci_script.py index d2ea6e70c..d8b5a8f73 100644 --- a/tools/visual-diff-ci/run_ci_script.py +++ b/tools/visual-diff-ci/run_ci_script.py @@ -158,7 +158,7 @@ def poll_backend() -> bool: except Exception: return False - timeout_threshold = time.time() + 60 + timeout_threshold = time.time() + 90 while (result := poll_backend()) is False and time.time() < timeout_threshold: print( "Polling failed, " @@ -173,12 +173,26 @@ def poll_backend() -> bool: def start_backend() -> bool: - run_command("docker compose up -d", cwd=BACKEND_DIR) + run_command( + [ + "docker", + "compose", + "up", + "-d", + "--remove-orphans", + "db", + "redis", + "rabbitmq", + "minio", + "django", + ], + cwd=BACKEND_DIR, + ) return wait_for_url("http://127.0.0.1:8000/") def stop_backend(): - run_command("docker compose down", cwd=BACKEND_DIR) + run_command(["docker", "compose", "down", "--remove-orphans"], cwd=BACKEND_DIR) PLAYWRIGHT_DIR = (REPO_ROOT / "tools" / "cyberstorm-playwright").resolve()