e2e test setup
Continuous Integration / backend-tests (push) Successful in 9m53s
Continuous Integration / frontend-check (push) Successful in 9m36s
Continuous Integration / e2e-tests (push) Failing after 13m51s

This commit is contained in:
2026-04-29 01:26:48 -04:00
parent 01eabbc913
commit 6298aea64a
10 changed files with 352 additions and 0 deletions
+52
View File
@@ -55,3 +55,55 @@ jobs:
cd frontend cd frontend
npx svelte-kit sync npx svelte-kit sync
npm run check 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
+20
View File
@@ -61,3 +61,23 @@ yarn-error.log*
/staging/ /staging/
/source_data/ /source_data/
/config/ /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/
+6
View File
@@ -61,9 +61,15 @@ def list_storage_providers():
from app.providers.cloud import CloudStorageProvider from app.providers.cloud import CloudStorageProvider
from app.providers.hdd import OfflineHDDProvider from app.providers.hdd import OfflineHDDProvider
from app.providers.tape import LTOProvider from app.providers.tape import LTOProvider
import os
providers = [LTOProvider, OfflineHDDProvider, CloudStorageProvider] providers = [LTOProvider, OfflineHDDProvider, CloudStorageProvider]
if os.environ.get("TAPEHOARD_TEST_MODE") == "true":
from app.providers.mock import MockLTOProvider
providers.append(MockLTOProvider)
return [ return [
StorageProviderSchema( StorageProviderSchema(
provider_id=p.provider_id, provider_id=p.provider_id,
+113
View File
@@ -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"},
}
+6
View File
@@ -88,6 +88,7 @@ class ArchiverService:
def _get_storage_provider(self, media_record: models.StorageMedia): def _get_storage_provider(self, media_record: models.StorageMedia):
"""Initializes the appropriate hardware provider based on media type.""" """Initializes the appropriate hardware provider based on media type."""
import os
provider_map = { provider_map = {
LTOProvider.provider_id: LTOProvider, LTOProvider.provider_id: LTOProvider,
@@ -100,6 +101,11 @@ class ArchiverService:
"s3": CloudStorageProvider, "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) provider_cls = provider_map.get(media_record.media_type)
if not provider_cls: if not provider_cls:
return None return None
+64
View File
@@ -12,6 +12,7 @@
}, },
"devDependencies": { "devDependencies": {
"@hey-api/openapi-ts": "^0.96.1", "@hey-api/openapi-ts": "^0.96.1",
"@playwright/test": "^1.59.1",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.57.1", "@sveltejs/kit": "^2.57.1",
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0",
@@ -309,6 +310,22 @@
"url": "https://github.com/sponsors/Boshen" "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": { "node_modules/@polka/url": {
"version": "1.0.0-next.29", "version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -2150,6 +2167,53 @@
"pathe": "^2.0.3" "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": { "node_modules/postcss": {
"version": "8.5.10", "version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
+1
View File
@@ -12,6 +12,7 @@
}, },
"devDependencies": { "devDependencies": {
"@hey-api/openapi-ts": "^0.96.1", "@hey-api/openapi-ts": "^0.96.1",
"@playwright/test": "^1.59.1",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.57.1", "@sveltejs/kit": "^2.57.1",
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0",
+45
View File
@@ -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,
},
],
});
+32
View File
@@ -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 });
});
});
+13
View File
@@ -89,3 +89,16 @@ docker-up:
docker-down: docker-down:
@echo "Stopping TapeHoard stack..." @echo "Stopping TapeHoard stack..."
cd docker && docker-compose down 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