From 6298aea64a67de1d5162f97cedaa59bd27e3fa27 Mon Sep 17 00:00:00 2001 From: Adam Lamers Date: Wed, 29 Apr 2026 01:26:48 -0400 Subject: [PATCH] e2e test setup --- .github/workflows/ci.yml | 52 ++++++++++++++ .gitignore | 20 ++++++ backend/app/api/inventory.py | 6 ++ backend/app/providers/mock.py | 113 +++++++++++++++++++++++++++++++ backend/app/services/archiver.py | 6 ++ frontend/package-lock.json | 64 +++++++++++++++++ frontend/package.json | 1 + frontend/playwright.config.ts | 45 ++++++++++++ frontend/tests/e2e.test.ts | 32 +++++++++ justfile | 13 ++++ 10 files changed, 352 insertions(+) create mode 100644 backend/app/providers/mock.py create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/tests/e2e.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c72954..ebcb4f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,3 +55,55 @@ jobs: cd frontend npx svelte-kit sync npm run check + + e2e-tests: + runs-on: ubuntu-latest + needs: [backend-tests, frontend-check] + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + version: "latest" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install Frontend Dependencies & Playwright + run: | + cd frontend + npm ci + npx playwright install --with-deps chromium + + - name: Start Backend Test Server + run: | + cd backend + uv sync --dev + DATABASE_URL="sqlite:///test.db" TAPEHOARD_TEST_MODE="true" uv run alembic upgrade head + DATABASE_URL="sqlite:///test.db" TAPEHOARD_TEST_MODE="true" uv run uvicorn app.main:app --host 0.0.0.0 --port 8001 & + sleep 5 # Wait for server to start + + - name: Run Playwright Tests + run: | + cd frontend + npx playwright test + + - name: Upload Playwright Report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: frontend/playwright-report/ + retention-days: 14 diff --git a/.gitignore b/.gitignore index ecbf916..820fa11 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,23 @@ yarn-error.log* /staging/ /source_data/ /config/ + +# Testing & Coverage +.coverage +coverage.xml + +# OpenAPI client errors +openapi-ts-error-*.log + +# Playwright +test-results/ +playwright-report/ +playwright/.cache/ + +# SQLite WAL and SHM +*-shm +*-wal +file:memdb* + +# Docker DB +docker/db/ diff --git a/backend/app/api/inventory.py b/backend/app/api/inventory.py index f833d83..b29717d 100644 --- a/backend/app/api/inventory.py +++ b/backend/app/api/inventory.py @@ -61,9 +61,15 @@ def list_storage_providers(): from app.providers.cloud import CloudStorageProvider from app.providers.hdd import OfflineHDDProvider from app.providers.tape import LTOProvider + import os providers = [LTOProvider, OfflineHDDProvider, CloudStorageProvider] + if os.environ.get("TAPEHOARD_TEST_MODE") == "true": + from app.providers.mock import MockLTOProvider + + providers.append(MockLTOProvider) + return [ StorageProviderSchema( provider_id=p.provider_id, diff --git a/backend/app/providers/mock.py b/backend/app/providers/mock.py new file mode 100644 index 0000000..b7bbac2 --- /dev/null +++ b/backend/app/providers/mock.py @@ -0,0 +1,113 @@ +import os +import shutil +from typing import Any, BinaryIO, Dict, Optional +from loguru import logger + +from .base import AbstractStorageProvider + + +class MockLTOProvider(AbstractStorageProvider): + provider_id = "mock_lto" + name = "Mock LTO Tape (Test)" + description = "A simulated tape drive for end-to-end testing." + capabilities = { + "supports_random_access": False, + "is_offline_capable": True, + "supports_hardware_encryption": False, + } + config_schema = { + "device_path": { + "type": "string", + "title": "Mock Directory Path", + "description": "Path to a directory representing the tape drive (e.g., /tmp/mock_lto)", + "required": True, + "default": "/tmp/mock_lto", + } + } + + def __init__(self, config: Dict[str, Any]): + self.device_path = config.get("device_path", "/tmp/mock_lto") + os.makedirs(self.device_path, exist_ok=True) + # We store metadata in a .mam file inside the mock directory + self.mam_path = os.path.join(self.device_path, ".mam") + + def get_name(self) -> str: + return "Mock LTO Drive" + + def check_online(self, force: bool = False) -> bool: + # For testing, we consider it online if the directory exists + return os.path.exists(self.device_path) + + def check_existing_data(self) -> bool: + # Check if there are archive files in the directory + if not self.check_online(): + return False + for f in os.listdir(self.device_path): + if f.endswith(".tar"): + return True + return False + + def identify_media(self) -> Optional[str]: + if not os.path.exists(self.mam_path): + return None + try: + with open(self.mam_path, "r") as f: + return f.read().strip() + except Exception: + return None + + def initialize_media(self, media_id: str) -> bool: + # Clear out the directory + for item in os.listdir(self.device_path): + item_path = os.path.join(self.device_path, item) + if os.path.isfile(item_path): + os.remove(item_path) + elif os.path.isdir(item_path): + shutil.rmtree(item_path) + + # Write the new media_id + with open(self.mam_path, "w") as f: + f.write(media_id) + return True + + def prepare_for_write(self, media_id: str) -> bool: + return self.identify_media() == media_id + + def write_archive(self, media_id: str, stream: BinaryIO) -> str: + if not self.prepare_for_write(media_id): + raise Exception("Media mismatch") + + # Determine the next file number + file_num = 0 + while os.path.exists(os.path.join(self.device_path, f"archive_{file_num}.tar")): + file_num += 1 + + file_path = os.path.join(self.device_path, f"archive_{file_num}.tar") + + with open(file_path, "wb") as f: + shutil.copyfileobj(stream, f) + + return str(file_num) + + def get_utilization(self) -> Optional[float]: + return 0.1 # Mock utilization + + def finalize_media(self, media_id: str): + logger.info(f"Mock media {media_id} finalized") + + def read_archive(self, media_id: str, location_id: str) -> BinaryIO: + if self.identify_media() != media_id: + raise Exception("Media mismatch") + + file_path = os.path.join(self.device_path, f"archive_{location_id}.tar") + if not os.path.exists(file_path): + raise Exception(f"Archive {location_id} not found on tape") + + return open(file_path, "rb") + + def get_live_info(self, force: bool = False) -> Dict[str, Any]: + return { + "online": self.check_online(force), + "mam": {"barcode": self.identify_media()}, + "drive": {"vendor": "MOCK", "product": "VIRTUAL LTO"}, + } diff --git a/backend/app/services/archiver.py b/backend/app/services/archiver.py index ce8f633..81f74e8 100644 --- a/backend/app/services/archiver.py +++ b/backend/app/services/archiver.py @@ -88,6 +88,7 @@ class ArchiverService: def _get_storage_provider(self, media_record: models.StorageMedia): """Initializes the appropriate hardware provider based on media type.""" + import os provider_map = { LTOProvider.provider_id: LTOProvider, @@ -100,6 +101,11 @@ class ArchiverService: "s3": CloudStorageProvider, } + if os.environ.get("TAPEHOARD_TEST_MODE") == "true": + from app.providers.mock import MockLTOProvider + + provider_map[MockLTOProvider.provider_id] = MockLTOProvider + provider_cls = provider_map.get(media_record.media_type) if not provider_cls: return None diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dee1be2..730c640 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ }, "devDependencies": { "@hey-api/openapi-ts": "^0.96.1", + "@playwright/test": "^1.59.1", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.57.1", "@sveltejs/vite-plugin-svelte": "^7.0.0", @@ -309,6 +310,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -2150,6 +2167,53 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", diff --git a/frontend/package.json b/frontend/package.json index b59ea41..b66120f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ }, "devDependencies": { "@hey-api/openapi-ts": "^0.96.1", + "@playwright/test": "^1.59.1", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.57.1", "@sveltejs/vite-plugin-svelte": "^7.0.0", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..6d513c8 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,45 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: false, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: [['html', { open: 'never' }]], + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'http://localhost:5173', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + video: 'retain-on-failure', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: [ + { + command: 'VITE_API_URL=http://localhost:8001 npm run dev', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, + ], +}); diff --git a/frontend/tests/e2e.test.ts b/frontend/tests/e2e.test.ts new file mode 100644 index 0000000..581df61 --- /dev/null +++ b/frontend/tests/e2e.test.ts @@ -0,0 +1,32 @@ +import { test, expect } from '@playwright/test'; + +test.describe('TapeHoard Golden Path', () => { + test('homepage loads and shows basic navigation', async ({ page }) => { + await page.goto('/'); + + // Validate the page title or basic UI elements exist + // This assumes there's some header or title indicating TapeHoard + await expect(page).toHaveTitle(/TapeHoard|Svelte/i); + + // Check if navigation links are visible + // We expect links to Backup Manager, Media Inventory, Archive Index etc based on E2E.md + const nav = page.locator('nav'); + if (await nav.count() > 0) { + await expect(page.locator('text=Inventory').first()).toBeVisible(); + await expect(page.locator('text=Archive').first()).toBeVisible(); + } + }); + + test('media inventory shows mock provider when in test mode', async ({ page }) => { + await page.goto('/inventory'); + + // Wait for the page to be fully loaded and hydrated + await page.waitForLoadState('networkidle'); + + // Click the Register media button to open the dialog + await page.getByRole('button', { name: /Register media/i }).click(); + + // Check for the Mock provider text inside the dialog + await expect(page.getByText('Mock LTO Tape (Test)')).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/justfile b/justfile index 7fbcc15..44c60a5 100644 --- a/justfile +++ b/justfile @@ -89,3 +89,16 @@ docker-up: docker-down: @echo "Stopping TapeHoard stack..." cd docker && docker-compose down + +# --- End-to-End Testing --- + +# Run the backend in test mode +e2e-server: + @echo "Starting Backend in Test Mode..." + cd backend && DATABASE_URL="sqlite:///test.db" TAPEHOARD_TEST_MODE="true" uv run alembic upgrade head + cd backend && DATABASE_URL="sqlite:///test.db" TAPEHOARD_TEST_MODE="true" uv run uvicorn app.main:app --host 0.0.0.0 --port 8001 + +# Run playwright tests +e2e: + @echo "Running Playwright E2E Tests..." + cd frontend && npx playwright test