e2e test setup
This commit is contained in:
@@ -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
|
||||
|
||||
+20
@@ -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/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Generated
+64
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user