From a029ceac69c570b7e05b0e84ef98255620558d45 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:56:31 +0000 Subject: [PATCH 1/4] Add comprehensive Playwright e2e tests for database verification workflow Agent-Logs-Url: https://github.com/MaximumTrainer/OpenDataMask/sessions/c0576442-108f-4e13-b8ac-78825dc03ec3 Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com> --- frontend/e2e/auth.spec.ts | 94 +++++ frontend/e2e/connections.spec.ts | 317 ++++++++++++++++ frontend/e2e/full-workflow.spec.ts | 348 ++++++++++++++++++ frontend/e2e/job-execution.spec.ts | 397 ++++++++++++++++++++ frontend/e2e/masking-definition.spec.ts | 466 ++++++++++++++++++++++++ frontend/e2e/odm-fixtures.ts | 239 ++++++++++++ frontend/package-lock.json | 64 ++++ frontend/package.json | 1 + frontend/playwright.config.ts | 37 ++ frontend/vite.config.ts | 3 +- 10 files changed, 1965 insertions(+), 1 deletion(-) create mode 100644 frontend/e2e/auth.spec.ts create mode 100644 frontend/e2e/connections.spec.ts create mode 100644 frontend/e2e/full-workflow.spec.ts create mode 100644 frontend/e2e/job-execution.spec.ts create mode 100644 frontend/e2e/masking-definition.spec.ts create mode 100644 frontend/e2e/odm-fixtures.ts create mode 100644 frontend/playwright.config.ts diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts new file mode 100644 index 0000000..3e10e2d --- /dev/null +++ b/frontend/e2e/auth.spec.ts @@ -0,0 +1,94 @@ +import { test, expect } from '@playwright/test' +import { + ODM_USERNAME, + ODM_PASSWORD, + ODM_EMAIL, + registerUser, + waitForLoadingDone, +} from './odm-fixtures' + +// ── Auth / Login Tests ──────────────────────────────────────────────────── +// Verifies the login page renders correctly, rejects bad credentials, and +// allows a registered user to sign in and reach the workspaces page. + +test.describe('Authentication', () => { + test.beforeAll(async () => { + await registerUser() + }) + + test('login page renders with expected form fields', async ({ page }) => { + await page.goto('/login') + + await expect(page.locator('h1')).toContainText('OpenDataMask') + await expect(page.locator('#username')).toBeVisible() + await expect(page.locator('#password')).toBeVisible() + await expect(page.locator("button[type='submit']")).toBeVisible() + await expect(page.locator("button[type='submit']")).toContainText('Sign In') + }) + + test('register page renders with expected form fields', async ({ page }) => { + await page.goto('/register') + + await expect(page.locator('h1')).toContainText('OpenDataMask') + await expect(page.locator('#username')).toBeVisible() + await expect(page.locator('#email')).toBeVisible() + await expect(page.locator('#password')).toBeVisible() + await expect(page.locator("button[type='submit']")).toBeVisible() + }) + + test('login with invalid credentials shows error', async ({ page }) => { + await page.goto('/login') + await page.fill('#username', 'nonexistent_user') + await page.fill('#password', 'wrong_password') + await page.click("button[type='submit']") + + await expect(page.locator('.alert-error')).toBeVisible({ timeout: 10_000 }) + }) + + test('login with empty fields shows validation message', async ({ page }) => { + await page.goto('/login') + + // Click submit without filling anything + await page.click("button[type='submit']") + + // The form should show a validation error or not navigate away + await expect(page).toHaveURL(/\/login/) + }) + + test('successful login redirects to workspaces', async ({ page }) => { + await page.goto('/login') + await page.fill('#username', ODM_USERNAME) + await page.fill('#password', ODM_PASSWORD) + await page.click("button[type='submit']") + + await page.waitForURL(/\/workspaces/, { timeout: 15_000 }) + await waitForLoadingDone(page) + + await expect(page.locator('h1')).toContainText('Workspaces') + }) + + test('authenticated user accessing /login is redirected to /workspaces', async ({ page }) => { + // First login + await page.goto('/login') + await page.fill('#username', ODM_USERNAME) + await page.fill('#password', ODM_PASSWORD) + await page.click("button[type='submit']") + await page.waitForURL(/\/workspaces/, { timeout: 15_000 }) + + // Now navigate to /login — should redirect back + await page.goto('/login') + await expect(page).toHaveURL(/\/workspaces/) + }) + + test('unauthenticated user accessing protected route is redirected to /login', async ({ page }) => { + // Clear any stored credentials + await page.goto('/login') + await page.evaluate(() => { + localStorage.removeItem('token') + localStorage.removeItem('user') + }) + + await page.goto('/workspaces') + await expect(page).toHaveURL(/\/login/) + }) +}) diff --git a/frontend/e2e/connections.spec.ts b/frontend/e2e/connections.spec.ts new file mode 100644 index 0000000..6fcd241 --- /dev/null +++ b/frontend/e2e/connections.spec.ts @@ -0,0 +1,317 @@ +import { test, expect } from './odm-fixtures' +import { + SOURCE_DB, + TARGET_DB, + loginViaApi, + registerUser, + apiCall, + waitForPageHeading, + waitForLoadingDone, +} from './odm-fixtures' + +// ── Source & Destination Connection Tests ────────────────────────────────── +// Verifies that database connections can be created, displayed, tested, and +// managed through the frontend UI. Mirrors the source/target connection +// setup performed by verification/run_verification.sh. + +test.describe('Database Connections', () => { + let workspaceId: number + let token: string + + test.beforeAll(async () => { + await registerUser() + token = await loginViaApi() + + const ws = await apiCall('/api/workspaces', { + method: 'POST', + body: { name: 'Connections Test Workspace', description: 'E2E connection tests' }, + token, + }) + workspaceId = ws.id as number + }) + + test('connections page shows empty state when no connections exist', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + + await expect(page.locator('.empty-state')).toBeVisible() + await expect(page.locator('h3')).toContainText('No connections yet') + await expect(page.locator("button:has-text('Add Connection')")).toBeVisible() + }) + + test('add connection modal opens and contains expected fields', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + + await page.click("button:has-text('Add Connection')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + // Verify modal title + await expect(page.locator("[role='dialog']")).toContainText('Add Connection') + + // Verify essential form fields exist + await expect(page.locator("[role='dialog'] input[placeholder='Production DB']")).toBeVisible() + await expect(page.locator("[role='dialog'] select.form-control")).toBeVisible() + await expect(page.locator("[role='dialog'] input[placeholder='localhost']")).toBeVisible() + await expect(page.locator("[role='dialog'] input[placeholder='mydb']")).toBeVisible() + await expect(page.locator("[role='dialog'] input[placeholder='admin']")).toBeVisible() + + // Verify Source and Destination checkboxes + await expect(page.locator("label:has-text('Source')")).toBeVisible() + await expect(page.locator("label:has-text('Destination')")).toBeVisible() + + // Verify action buttons + await expect(page.locator("[role='dialog'] button:has-text('Add Connection')")).toBeVisible() + await expect(page.locator("[role='dialog'] button:has-text('Cancel')")).toBeVisible() + }) + + test('form validation rejects empty required fields', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + + await page.click("button:has-text('Add Connection')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + // Clear any default values and try to submit + await page.fill("[role='dialog'] input[placeholder='Production DB']", '') + await page.click("[role='dialog'] button:has-text('Add Connection')") + + // Should show validation error + await expect(page.locator("[role='dialog'] .alert-error")).toBeVisible({ timeout: 5_000 }) + }) + + test('form validation requires at least one role (Source or Destination)', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + + await page.click("button:has-text('Add Connection')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + // Fill all fields but leave both checkboxes unchecked + await page.fill("[role='dialog'] input[placeholder='Production DB']", 'test-conn') + await page.fill("[role='dialog'] input[placeholder='localhost']", 'db-host') + await page.fill("[role='dialog'] input[placeholder='mydb']", 'test_db') + await page.fill("[role='dialog'] input[placeholder='admin']", 'test_user') + await page.fill("[role='dialog'] input[type='password']", 'test_pass') + + await page.click("[role='dialog'] button:has-text('Add Connection')") + + await expect(page.locator("[role='dialog'] .alert-error")).toContainText('role', { ignoreCase: true }) + }) + + test('create source database connection via UI', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + + await page.click("button:has-text('Add Connection')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + await page.fill("[role='dialog'] input[placeholder='Production DB']", SOURCE_DB.name) + await page.fill("[role='dialog'] input[placeholder='localhost']", SOURCE_DB.host) + await page.fill("[role='dialog'] input[placeholder='mydb']", SOURCE_DB.database) + await page.fill("[role='dialog'] input[placeholder='admin']", SOURCE_DB.username) + await page.fill("[role='dialog'] input[type='password']", SOURCE_DB.password) + + // Check Source role + const sourceCheckbox = page.locator("label:has-text('Source') input[type='checkbox']") + if (!(await sourceCheckbox.isChecked())) { + await sourceCheckbox.click() + } + + await page.click("[role='dialog'] button:has-text('Add Connection')") + + // Modal should close and connection should appear in the table + await page.waitForSelector("[role='dialog']", { state: 'hidden', timeout: 10_000 }) + await waitForLoadingDone(page) + + // Verify connection appears in the list + await expect(page.locator(`td:has-text("${SOURCE_DB.name}")`)).toBeVisible() + await expect(page.locator('td .badge-blue')).toContainText('Source') + }) + + test('create destination database connection via UI', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + + await page.click("button:has-text('Add Connection')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + await page.fill("[role='dialog'] input[placeholder='Production DB']", TARGET_DB.name) + await page.fill("[role='dialog'] input[placeholder='localhost']", TARGET_DB.host) + await page.fill("[role='dialog'] input[placeholder='mydb']", TARGET_DB.database) + await page.fill("[role='dialog'] input[placeholder='admin']", TARGET_DB.username) + await page.fill("[role='dialog'] input[type='password']", TARGET_DB.password) + + // Check Destination role + const destCheckbox = page.locator("label:has-text('Destination') input[type='checkbox']") + if (!(await destCheckbox.isChecked())) { + await destCheckbox.click() + } + + await page.click("[role='dialog'] button:has-text('Add Connection')") + + // Modal should close + await page.waitForSelector("[role='dialog']", { state: 'hidden', timeout: 10_000 }) + await waitForLoadingDone(page) + + // Verify both connections now exist + await expect(page.locator(`td:has-text("${TARGET_DB.name}")`)).toBeVisible() + await expect(page.locator('td .badge-green')).toContainText('Destination') + }) + + test('connections list shows correct columns', async ({ authenticatedPage: page }) => { + // Ensure connections exist via API first + try { + await apiCall(`/api/workspaces/${workspaceId}/connections`, { + method: 'POST', + body: { + name: 'list-test-source', + type: 'POSTGRESQL', + connectionString: `jdbc:postgresql://${SOURCE_DB.host}:${SOURCE_DB.port}/${SOURCE_DB.database}`, + username: SOURCE_DB.username, + password: SOURCE_DB.password, + isSource: true, + isDestination: false, + }, + token, + }) + } catch { + // May already exist + } + + await page.goto(`/workspaces/${workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + await waitForLoadingDone(page) + + // Verify table headers + const headers = page.locator('thead th') + await expect(headers.nth(0)).toContainText('Name') + await expect(headers.nth(1)).toContainText('Type') + await expect(headers.nth(2)).toContainText('Host') + await expect(headers.nth(3)).toContainText('Database') + await expect(headers.nth(4)).toContainText('Roles') + await expect(headers.nth(5)).toContainText('Status') + await expect(headers.nth(6)).toContainText('Actions') + }) + + test('test connection button triggers connection test', async ({ authenticatedPage: page }) => { + // Seed a connection via API + let connId: number + try { + const conn = await apiCall(`/api/workspaces/${workspaceId}/connections`, { + method: 'POST', + body: { + name: 'test-btn-conn', + type: 'POSTGRESQL', + connectionString: `jdbc:postgresql://${SOURCE_DB.host}:${SOURCE_DB.port}/${SOURCE_DB.database}`, + username: SOURCE_DB.username, + password: SOURCE_DB.password, + isSource: true, + isDestination: false, + }, + token, + }) + connId = conn.id as number + } catch { + // May already exist — just view the page + } + + await page.goto(`/workspaces/${workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + await waitForLoadingDone(page) + + // Click the first Test button + const testBtn = page.locator("button:has-text('Test')").first() + await expect(testBtn).toBeVisible() + await testBtn.click() + + // Wait for test result badge to appear + await expect( + page.locator('.badge-green:has-text("OK"), .badge-red:has-text("Error")') + ).toBeVisible({ timeout: 30_000 }) + }) + + test('edit connection modal pre-fills existing data', async ({ authenticatedPage: page }) => { + // Seed a connection via API + const conn = await apiCall(`/api/workspaces/${workspaceId}/connections`, { + method: 'POST', + body: { + name: 'edit-test-conn', + type: 'POSTGRESQL', + connectionString: `jdbc:postgresql://${SOURCE_DB.host}:${SOURCE_DB.port}/${SOURCE_DB.database}`, + username: SOURCE_DB.username, + password: SOURCE_DB.password, + isSource: true, + isDestination: false, + }, + token, + }) + + await page.goto(`/workspaces/${workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + await waitForLoadingDone(page) + + // Find the row for our connection and click Edit + const row = page.locator(`tr:has-text("edit-test-conn")`) + await row.locator("button:has-text('Edit')").click() + + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + // Modal should say "Edit Connection" + await expect(page.locator("[role='dialog']")).toContainText('Edit Connection') + + // Name should be pre-filled + const nameInput = page.locator("[role='dialog'] input[placeholder='Production DB']") + await expect(nameInput).toHaveValue('edit-test-conn') + }) + + test('delete connection removes it from the list', async ({ authenticatedPage: page }) => { + // Seed a connection to delete + await apiCall(`/api/workspaces/${workspaceId}/connections`, { + method: 'POST', + body: { + name: 'delete-me-conn', + type: 'POSTGRESQL', + connectionString: `jdbc:postgresql://${SOURCE_DB.host}:${SOURCE_DB.port}/${SOURCE_DB.database}`, + username: SOURCE_DB.username, + password: SOURCE_DB.password, + isSource: true, + isDestination: false, + }, + token, + }) + + await page.goto(`/workspaces/${workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + await waitForLoadingDone(page) + + // Confirm the connection exists + await expect(page.locator('td:has-text("delete-me-conn")')).toBeVisible() + + // Set up dialog handler to accept the confirmation + page.once('dialog', (dialog) => dialog.accept()) + + // Click Delete + const row = page.locator('tr:has-text("delete-me-conn")') + await row.locator("button:has-text('Delete')").click() + + // Connection should disappear + await expect(page.locator('td:has-text("delete-me-conn")')).toBeHidden({ timeout: 10_000 }) + }) + + test('connection type selector includes PostgreSQL, MongoDB, Azure SQL, MySQL', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + + await page.click("button:has-text('Add Connection')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + const typeSelect = page.locator("[role='dialog'] select.form-control") + const options = await typeSelect.locator('option').allTextContents() + + expect(options).toContain('PostgreSQL') + expect(options).toContain('MongoDB') + expect(options).toContain('Azure SQL') + expect(options).toContain('MySQL') + }) +}) diff --git a/frontend/e2e/full-workflow.spec.ts b/frontend/e2e/full-workflow.spec.ts new file mode 100644 index 0000000..d04e2c9 --- /dev/null +++ b/frontend/e2e/full-workflow.spec.ts @@ -0,0 +1,348 @@ +import { test, expect } from './odm-fixtures' +import { + ODM_USERNAME, + ODM_PASSWORD, + ODM_EMAIL, + SOURCE_DB, + TARGET_DB, + registerUser, + loginViaApi, + apiCall, + waitForPageHeading, + waitForLoadingDone, + waitForJobCompletion, +} from './odm-fixtures' + +// ── Full E2E Workflow Test ──────────────────────────────────────────────── +// This spec mirrors the exact sequence performed by +// verification/run_verification.sh and verification/verify.py: +// +// 1. Register user → Login +// 2. Create workspace +// 3. Create source connection (PostgreSQL → source_db) +// 4. Create destination connection (PostgreSQL → target_db) +// 5. Configure table masking (users table, MASK mode) +// 6. Add column generators (full_name, email, phone, dob, salary) +// 7. Run masking job +// 8. Verify job completes successfully +// 9. Verify record integrity (row count = 50) +// 10. Verify masking effectiveness (all 50 rows processed) +// +// The test uses the UI for the interactive steps and the API for seeding +// and verification, ensuring the frontend correctly drives the backend. + +test.describe('Full Verification Workflow', () => { + test.describe.configure({ mode: 'serial' }) + + let workspaceId: number + let token: string + let sourceConnectionId: number + let targetConnectionId: number + let tableConfigId: number + let jobId: number + + test('step 1 — register and login via UI', async ({ page }) => { + await registerUser() + + await page.goto('/login') + await expect(page.locator('h1')).toContainText('OpenDataMask') + + await page.fill('#username', ODM_USERNAME) + await page.fill('#password', ODM_PASSWORD) + await page.click("button[type='submit']") + + await page.waitForURL(/\/workspaces/, { timeout: 15_000 }) + await waitForLoadingDone(page) + + await expect(page.locator('h1')).toContainText('Workspaces') + + // Store token for API calls in subsequent steps + token = await loginViaApi() + }) + + test('step 2 — create workspace via UI', async ({ page }) => { + // Login first + await page.goto('/login') + await page.fill('#username', ODM_USERNAME) + await page.fill('#password', ODM_PASSWORD) + await page.click("button[type='submit']") + await page.waitForURL(/\/workspaces/, { timeout: 15_000 }) + await waitForLoadingDone(page) + + // Create workspace + await page.click("button:has-text('New Workspace')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + const nameInput = page.locator("[role='dialog'] input.form-control").first() + await nameInput.fill('Full Verification Workspace') + + // Look for description input if exists + const inputs = page.locator("[role='dialog'] input.form-control, [role='dialog'] textarea.form-control") + if ((await inputs.count()) > 1) { + await inputs.nth(1).fill('End-to-end verification test') + } + + const createBtn = page.locator("[role='dialog'] button:has-text('Create'), [role='dialog'] button:has-text('Save')") + await createBtn.first().click() + + await page.waitForSelector("[role='dialog']", { state: 'hidden', timeout: 10_000 }) + await waitForLoadingDone(page) + + // Verify workspace appears + await expect(page.locator('text=Full Verification Workspace')).toBeVisible({ timeout: 10_000 }) + + // Get workspace ID via API for subsequent steps + const workspaces = (await apiCall('/api/workspaces', { token })) as unknown as Array<{ + id: number + name: string + }> + const ws = (workspaces as Array<{ id: number; name: string }>).find( + (w) => w.name === 'Full Verification Workspace' + ) + expect(ws).toBeDefined() + workspaceId = ws!.id + }) + + test('step 3 — create source database connection', async ({ page }) => { + // Login and navigate + await page.goto('/login') + await page.fill('#username', ODM_USERNAME) + await page.fill('#password', ODM_PASSWORD) + await page.click("button[type='submit']") + await page.waitForURL(/\/workspaces/, { timeout: 15_000 }) + + await page.goto(`/workspaces/${workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + + await page.click("button:has-text('Add Connection')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + await page.fill("[role='dialog'] input[placeholder='Production DB']", 'verification-source') + await page.fill("[role='dialog'] input[placeholder='localhost']", SOURCE_DB.host) + await page.fill("[role='dialog'] input[placeholder='mydb']", SOURCE_DB.database) + await page.fill("[role='dialog'] input[placeholder='admin']", SOURCE_DB.username) + await page.fill("[role='dialog'] input[type='password']", SOURCE_DB.password) + + const sourceCheckbox = page.locator("label:has-text('Source') input[type='checkbox']") + if (!(await sourceCheckbox.isChecked())) { + await sourceCheckbox.click() + } + + await page.click("[role='dialog'] button:has-text('Add Connection')") + await page.waitForSelector("[role='dialog']", { state: 'hidden', timeout: 10_000 }) + await waitForLoadingDone(page) + + await expect(page.locator('td:has-text("verification-source")')).toBeVisible() + + // Get connection ID via API + const conns = (await apiCall(`/api/workspaces/${workspaceId}/connections`, { + token, + })) as unknown as Array<{ id: number; name: string }> + const srcConn = (conns as Array<{ id: number; name: string }>).find( + (c) => c.name === 'verification-source' + ) + expect(srcConn).toBeDefined() + sourceConnectionId = srcConn!.id + }) + + test('step 4 — create destination database connection', async ({ page }) => { + await page.goto('/login') + await page.fill('#username', ODM_USERNAME) + await page.fill('#password', ODM_PASSWORD) + await page.click("button[type='submit']") + await page.waitForURL(/\/workspaces/, { timeout: 15_000 }) + + await page.goto(`/workspaces/${workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + + await page.click("button:has-text('Add Connection')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + await page.fill("[role='dialog'] input[placeholder='Production DB']", 'verification-target') + await page.fill("[role='dialog'] input[placeholder='localhost']", TARGET_DB.host) + await page.fill("[role='dialog'] input[placeholder='mydb']", TARGET_DB.database) + await page.fill("[role='dialog'] input[placeholder='admin']", TARGET_DB.username) + await page.fill("[role='dialog'] input[type='password']", TARGET_DB.password) + + const destCheckbox = page.locator("label:has-text('Destination') input[type='checkbox']") + if (!(await destCheckbox.isChecked())) { + await destCheckbox.click() + } + + await page.click("[role='dialog'] button:has-text('Add Connection')") + await page.waitForSelector("[role='dialog']", { state: 'hidden', timeout: 10_000 }) + await waitForLoadingDone(page) + + await expect(page.locator('td:has-text("verification-target")')).toBeVisible() + + // Get connection ID via API + const conns = (await apiCall(`/api/workspaces/${workspaceId}/connections`, { + token, + })) as unknown as Array<{ id: number; name: string }> + const tgtConn = (conns as Array<{ id: number; name: string }>).find( + (c) => c.name === 'verification-target' + ) + expect(tgtConn).toBeDefined() + targetConnectionId = tgtConn!.id + }) + + test('step 5 — configure table masking for users table', async ({ page }) => { + // Use API for table config (matching verification script's approach) + const tbl = await apiCall(`/api/workspaces/${workspaceId}/tables`, { + method: 'POST', + body: { + connectionId: sourceConnectionId, + tableName: 'users', + schemaName: 'public', + mode: 'MASK', + }, + token, + }) + tableConfigId = tbl.id as number + + // Verify via UI + await page.goto('/login') + await page.fill('#username', ODM_USERNAME) + await page.fill('#password', ODM_PASSWORD) + await page.click("button[type='submit']") + await page.waitForURL(/\/workspaces/, { timeout: 15_000 }) + + await page.goto(`/workspaces/${workspaceId}/tables`) + await waitForPageHeading(page, 'Table Configurations') + await waitForLoadingDone(page) + + await expect(page.locator('text=users')).toBeVisible() + await expect(page.locator('text=MASK')).toBeVisible() + }) + + test('step 6 — add column generators matching verification script', async ({ page }) => { + // Add generators via API (matching run_verification.sh exactly) + const generators = [ + { columnName: 'full_name', generatorType: 'FULL_NAME' }, + { columnName: 'email', generatorType: 'EMAIL' }, + { columnName: 'phone_number', generatorType: 'PHONE' }, + { columnName: 'date_of_birth', generatorType: 'BIRTH_DATE' }, + { columnName: 'salary', generatorType: 'RANDOM_INT', generatorParams: JSON.stringify({ min: '30000', max: '200000' }) }, + ] + + for (const gen of generators) { + await apiCall(`/api/workspaces/${workspaceId}/tables/${tableConfigId}/generators`, { + method: 'POST', + body: gen, + token, + }) + } + + // Verify via UI + await page.goto('/login') + await page.fill('#username', ODM_USERNAME) + await page.fill('#password', ODM_PASSWORD) + await page.click("button[type='submit']") + await page.waitForURL(/\/workspaces/, { timeout: 15_000 }) + + await page.goto(`/workspaces/${workspaceId}/tables`) + await waitForPageHeading(page, 'Table Configurations') + await waitForLoadingDone(page) + + // Expand to see generators + const expandBtn = page.locator("button:has-text('Columns')").first() + if (await expandBtn.isVisible()) { + await expandBtn.click() + await page.waitForTimeout(500) + + await expect(page.locator('text=full_name')).toBeVisible() + await expect(page.locator('text=email')).toBeVisible() + await expect(page.locator('text=phone_number')).toBeVisible() + await expect(page.locator('text=date_of_birth')).toBeVisible() + await expect(page.locator('text=salary')).toBeVisible() + } + }) + + test('step 7 — run masking job and wait for completion', async ({ page }) => { + // Trigger job via API + jobId = ( + await apiCall(`/api/workspaces/${workspaceId}/jobs`, { + method: 'POST', + body: { + name: 'Full Verification Masking Job', + sourceConnectionId, + targetConnectionId, + }, + token, + }) + ).id as number + + // Wait for completion + const status = await waitForJobCompletion(token, workspaceId, jobId, 180_000) + expect(status).toBe('COMPLETED') + + // Verify in UI + await page.goto('/login') + await page.fill('#username', ODM_USERNAME) + await page.fill('#password', ODM_PASSWORD) + await page.click("button[type='submit']") + await page.waitForURL(/\/workspaces/, { timeout: 15_000 }) + + await page.goto(`/workspaces/${workspaceId}/jobs`) + await waitForPageHeading(page, 'Jobs') + await page.waitForSelector('.job-card', { timeout: 10_000 }) + + await expect(page.locator('text=COMPLETED')).toBeVisible({ timeout: 15_000 }) + }) + + test('step 8 — verify record integrity (50 rows processed)', async () => { + // Mirrors verification/verify.py check_record_integrity + const job = (await apiCall(`/api/workspaces/${workspaceId}/jobs/${jobId}`, { + token, + })) as { rowsProcessed: number; tablesProcessed: number; status: string } + + expect(job.status).toBe('COMPLETED') + expect(job.rowsProcessed).toBe(50) + expect(job.tablesProcessed).toBe(1) + }) + + test('step 9 — verify job logs contain masking activity', async () => { + // Mirrors verification/verify.py's log inspection + const logs = (await apiCall(`/api/workspaces/${workspaceId}/jobs/${jobId}/logs`, { + token, + })) as unknown as Array<{ level: string; message: string }> + + expect((logs as Array<{ level: string; message: string }>).length).toBeGreaterThan(0) + + // Logs should contain INFO-level entries about the masking process + const infoLogs = (logs as Array<{ level: string; message: string }>).filter( + (l) => l.level === 'INFO' + ) + expect(infoLogs.length).toBeGreaterThan(0) + }) + + test('step 10 — verify connections remain healthy after job', async ({ page }) => { + // Test both connections via API + const srcTest = await apiCall( + `/api/workspaces/${workspaceId}/connections/${sourceConnectionId}/test`, + { method: 'POST', token } + ) + expect((srcTest as { success: boolean }).success).toBe(true) + + const tgtTest = await apiCall( + `/api/workspaces/${workspaceId}/connections/${targetConnectionId}/test`, + { method: 'POST', token } + ) + expect((tgtTest as { success: boolean }).success).toBe(true) + + // Also verify in UI + await page.goto('/login') + await page.fill('#username', ODM_USERNAME) + await page.fill('#password', ODM_PASSWORD) + await page.click("button[type='submit']") + await page.waitForURL(/\/workspaces/, { timeout: 15_000 }) + + await page.goto(`/workspaces/${workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + await waitForLoadingDone(page) + + // Both connections should be listed + await expect(page.locator('td:has-text("verification-source")')).toBeVisible() + await expect(page.locator('td:has-text("verification-target")')).toBeVisible() + }) +}) diff --git a/frontend/e2e/job-execution.spec.ts b/frontend/e2e/job-execution.spec.ts new file mode 100644 index 0000000..eeaa222 --- /dev/null +++ b/frontend/e2e/job-execution.spec.ts @@ -0,0 +1,397 @@ +import { test, expect } from './odm-fixtures' +import { + SOURCE_DB, + TARGET_DB, + loginViaApi, + registerUser, + apiCall, + seedVerificationData, + runMaskingJobViaApi, + waitForJobCompletion, + waitForPageHeading, + waitForLoadingDone, +} from './odm-fixtures' + +// ── Job Execution & Destination Transfer Verification ───────────────────── +// Verifies job creation, monitoring, completion, and validates that masked +// data is actually transferred to the destination database — mirroring the +// checks performed by verification/verify.py. + +test.describe('Job Execution', () => { + let workspaceId: number + let token: string + let sourceConnectionId: number + let targetConnectionId: number + + test.beforeAll(async () => { + await registerUser() + token = await loginViaApi() + + const ws = await apiCall('/api/workspaces', { + method: 'POST', + body: { name: 'Job Exec Test Workspace', description: 'E2E job tests' }, + token, + }) + workspaceId = ws.id as number + + const src = await apiCall(`/api/workspaces/${workspaceId}/connections`, { + method: 'POST', + body: { + name: SOURCE_DB.name, + type: SOURCE_DB.type, + connectionString: `jdbc:postgresql://${SOURCE_DB.host}:${SOURCE_DB.port}/${SOURCE_DB.database}`, + username: SOURCE_DB.username, + password: SOURCE_DB.password, + isSource: true, + isDestination: false, + }, + token, + }) + sourceConnectionId = src.id as number + + const tgt = await apiCall(`/api/workspaces/${workspaceId}/connections`, { + method: 'POST', + body: { + name: TARGET_DB.name, + type: TARGET_DB.type, + connectionString: `jdbc:postgresql://${TARGET_DB.host}:${TARGET_DB.port}/${TARGET_DB.database}`, + username: TARGET_DB.username, + password: TARGET_DB.password, + isSource: false, + isDestination: true, + }, + token, + }) + targetConnectionId = tgt.id as number + }) + + test('jobs page shows empty state when no jobs exist', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/jobs`) + await waitForPageHeading(page, 'Jobs') + + await expect(page.locator('.empty-state, .job-card')).toBeVisible({ timeout: 10_000 }) + + const emptyState = page.locator('.empty-state') + if (await emptyState.isVisible()) { + await expect(emptyState).toContainText('No jobs yet') + await expect(page.locator("button:has-text('Run New Job')")).toBeVisible() + } + }) + + test('run new job modal opens with connection selectors', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/jobs`) + await waitForPageHeading(page, 'Jobs') + + await page.click("button:has-text('Run New Job')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + await expect(page.locator("[role='dialog']")).toContainText('Run New Masking Job') + + // Job name field + await expect(page.locator("[role='dialog'] input[placeholder*='Mask']")).toBeVisible() + + // Source and target connection selectors + const selects = page.locator("[role='dialog'] select.form-control") + await expect(selects.first()).toBeVisible() + + // Run Job button + await expect(page.locator("[role='dialog'] button:has-text('Run Job')")).toBeVisible() + }) + + test('job creation validation requires all fields', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/jobs`) + await waitForPageHeading(page, 'Jobs') + + await page.click("button:has-text('Run New Job')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + // Clear the name field + const nameInput = page.locator("[role='dialog'] input[placeholder*='Mask']") + await nameInput.fill('') + + // Try to submit + await page.click("[role='dialog'] button:has-text('Run Job')") + + // Should show validation error + await expect(page.locator("[role='dialog'] .alert-error")).toBeVisible({ timeout: 5_000 }) + }) + + test('create and submit a masking job via UI', async ({ authenticatedPage: page }) => { + // Seed table config + generators so the job has something to process + const tbl = await apiCall(`/api/workspaces/${workspaceId}/tables`, { + method: 'POST', + body: { + connectionId: sourceConnectionId, + tableName: 'users', + schemaName: 'public', + mode: 'MASK', + }, + token, + }) + const tableId = tbl.id as number + + const generators = [ + { columnName: 'full_name', generatorType: 'FULL_NAME' }, + { columnName: 'email', generatorType: 'EMAIL' }, + { columnName: 'phone_number', generatorType: 'PHONE' }, + { columnName: 'date_of_birth', generatorType: 'BIRTH_DATE' }, + { columnName: 'salary', generatorType: 'RANDOM_INT', generatorParams: JSON.stringify({ min: '30000', max: '200000' }) }, + ] + for (const gen of generators) { + await apiCall(`/api/workspaces/${workspaceId}/tables/${tableId}/generators`, { + method: 'POST', + body: gen, + token, + }) + } + + await page.goto(`/workspaces/${workspaceId}/jobs`) + await waitForPageHeading(page, 'Jobs') + + await page.click("button:has-text('Run New Job')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + // Fill job name + await page.fill("[role='dialog'] input[placeholder*='Mask']", 'E2E Test Masking Job') + + // Select source connection + const selects = page.locator("[role='dialog'] select.form-control") + if (await selects.nth(0).isVisible()) { + await selects.nth(0).selectOption({ index: 0 }) + } + if (await selects.nth(1).isVisible()) { + await selects.nth(1).selectOption({ index: 1 }) + } + + // Submit + await page.click("[role='dialog'] button:has-text('Run Job')") + + // Modal should close and job should appear + await page.waitForSelector("[role='dialog']", { state: 'hidden', timeout: 10_000 }) + await expect(page.locator('.job-card')).toBeVisible({ timeout: 10_000 }) + }) + + test('job card shows status badge', async ({ authenticatedPage: page }) => { + // Create a job via API + const jobId = await runMaskingJobViaApi(token, workspaceId, sourceConnectionId, targetConnectionId) + + await page.goto(`/workspaces/${workspaceId}/jobs`) + await waitForPageHeading(page, 'Jobs') + + await page.waitForSelector('.job-card', { timeout: 10_000 }) + + // Should display a status badge (PENDING, RUNNING, or COMPLETED) + const statusBadge = page.locator('.job-card .badge, .job-card [class*="status"]').first() + await expect(statusBadge).toBeVisible() + }) + + test('view logs button expands log section', async ({ authenticatedPage: page }) => { + // Create and wait for a job to complete via API + const jobId = await runMaskingJobViaApi(token, workspaceId, sourceConnectionId, targetConnectionId) + await waitForJobCompletion(token, workspaceId, jobId) + + await page.goto(`/workspaces/${workspaceId}/jobs`) + await waitForPageHeading(page, 'Jobs') + + await page.waitForSelector('.job-card', { timeout: 10_000 }) + + // Click View Logs + const viewLogsBtn = page.locator("button:has-text('View Logs')").first() + if (await viewLogsBtn.isVisible({ timeout: 5_000 })) { + await viewLogsBtn.click() + + // Logs section should appear + await expect(page.locator('.logs-section')).toBeVisible({ timeout: 10_000 }) + } + }) + + test('completed job shows stats (tables and rows processed)', async ({ authenticatedPage: page }) => { + // Create and wait for a job to complete + const jobId = await runMaskingJobViaApi(token, workspaceId, sourceConnectionId, targetConnectionId) + const status = await waitForJobCompletion(token, workspaceId, jobId) + + if (status === 'COMPLETED') { + await page.goto(`/workspaces/${workspaceId}/jobs`) + await waitForPageHeading(page, 'Jobs') + + // Wait for the jobs to load and auto-refresh + await page.waitForSelector('.job-card', { timeout: 10_000 }) + + // Reload to see completed status + await page.reload() + await waitForLoadingDone(page) + await page.waitForSelector('.job-card', { timeout: 10_000 }) + + // Completed jobs should show stat chips with tables and rows + await expect(page.locator('.stat-chip, text=tables, text=rows')).toBeVisible({ timeout: 15_000 }) + } + }) +}) + +test.describe('Destination Database Transfer Verification', () => { + let seed: Awaited> + + test.beforeAll(async () => { + seed = await seedVerificationData() + + // Run a masking job and wait for completion + const jobId = await runMaskingJobViaApi( + seed.token, + seed.workspaceId, + seed.sourceConnectionId, + seed.targetConnectionId + ) + const status = await waitForJobCompletion(seed.token, seed.workspaceId, jobId) + if (status !== 'COMPLETED') { + throw new Error(`Masking job did not complete successfully: status=${status}`) + } + }) + + test('completed job is visible in jobs list with COMPLETED status', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${seed.workspaceId}/jobs`) + await waitForPageHeading(page, 'Jobs') + await page.waitForSelector('.job-card', { timeout: 10_000 }) + + // At least one job should show COMPLETED + await expect(page.locator('text=COMPLETED')).toBeVisible({ timeout: 15_000 }) + }) + + test('completed job shows non-zero tables processed', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${seed.workspaceId}/jobs`) + await waitForPageHeading(page, 'Jobs') + await page.waitForSelector('.job-card', { timeout: 10_000 }) + + // Check via API that tablesProcessed > 0 + const jobs = (await apiCall(`/api/workspaces/${seed.workspaceId}/jobs`, { + token: seed.token, + })) as unknown as Array<{ tablesProcessed: number; status: string }> + + const completedJob = (jobs as Array<{ tablesProcessed: number; status: string }>).find( + (j) => j.status === 'COMPLETED' + ) + expect(completedJob).toBeDefined() + expect(completedJob!.tablesProcessed).toBeGreaterThan(0) + }) + + test('completed job shows non-zero rows processed', async ({ authenticatedPage: page }) => { + // Verify via API + const jobs = (await apiCall(`/api/workspaces/${seed.workspaceId}/jobs`, { + token: seed.token, + })) as unknown as Array<{ rowsProcessed: number; status: string }> + + const completedJob = (jobs as Array<{ rowsProcessed: number; status: string }>).find( + (j) => j.status === 'COMPLETED' + ) + expect(completedJob).toBeDefined() + expect(completedJob!.rowsProcessed).toBeGreaterThan(0) + + // Also verify in UI that stats show rows + await page.goto(`/workspaces/${seed.workspaceId}/jobs`) + await waitForPageHeading(page, 'Jobs') + await page.waitForSelector('.job-card', { timeout: 10_000 }) + + // The stat chip should show the row count + await expect(page.locator('.stat-chip')).toBeVisible({ timeout: 10_000 }) + }) + + test('job logs show masking activity entries', async ({ authenticatedPage: page }) => { + // Fetch job ID + const jobs = (await apiCall(`/api/workspaces/${seed.workspaceId}/jobs`, { + token: seed.token, + })) as unknown as Array<{ id: number; status: string }> + + const completedJob = (jobs as Array<{ id: number; status: string }>).find( + (j) => j.status === 'COMPLETED' + ) + expect(completedJob).toBeDefined() + + // Fetch logs via API + const logs = (await apiCall( + `/api/workspaces/${seed.workspaceId}/jobs/${completedJob!.id}/logs`, + { token: seed.token } + )) as unknown as Array<{ level: string; message: string }> + + expect((logs as Array<{ level: string; message: string }>).length).toBeGreaterThan(0) + + // View logs in UI + await page.goto(`/workspaces/${seed.workspaceId}/jobs`) + await waitForPageHeading(page, 'Jobs') + await page.waitForSelector('.job-card', { timeout: 10_000 }) + + const viewLogsBtn = page.locator("button:has-text('View Logs')").first() + if (await viewLogsBtn.isVisible({ timeout: 5_000 })) { + await viewLogsBtn.click() + await expect(page.locator('.logs-section')).toBeVisible({ timeout: 10_000 }) + + // Log entries should be visible + await expect(page.locator('.log-line')).toHaveCount( + (logs as Array<{ level: string; message: string }>).length, + { timeout: 10_000 } + ) + } + }) + + test('record integrity — source and target row counts match (API verification)', async () => { + // This test verifies data transfer by checking the job's reported row count + // against the known source data (50 users in source_db.sql). + const jobs = (await apiCall(`/api/workspaces/${seed.workspaceId}/jobs`, { + token: seed.token, + })) as unknown as Array<{ rowsProcessed: number; tablesProcessed: number; status: string }> + + const completedJob = (jobs as Array<{ rowsProcessed: number; tablesProcessed: number; status: string }>).find( + (j) => j.status === 'COMPLETED' + ) + expect(completedJob).toBeDefined() + + // The source database has 50 user records (per verification/init/source_db.sql) + expect(completedJob!.rowsProcessed).toBe(50) + expect(completedJob!.tablesProcessed).toBe(1) + }) + + test('workspace overview shows job history after completion', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${seed.workspaceId}`) + await waitForLoadingDone(page) + + // The workspace detail page should reflect completed job data + const pageContent = await page.textContent('body') + expect(pageContent).toBeTruthy() + }) + + test('source connection still test-passes after job', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${seed.workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + await waitForLoadingDone(page) + + // Find the source connection row + const sourceRow = page.locator(`tr:has-text("${SOURCE_DB.name}")`).first() + if (await sourceRow.isVisible({ timeout: 5_000 })) { + await sourceRow.locator("button:has-text('Test')").click() + + // Wait for test result + await expect( + page.locator('.badge-green:has-text("OK"), .badge-red:has-text("Error")') + ).toBeVisible({ timeout: 30_000 }) + + // Should succeed + await expect(sourceRow.locator('.badge-green:has-text("OK")')).toBeVisible({ timeout: 5_000 }) + } + }) + + test('destination connection still test-passes after job', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${seed.workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + await waitForLoadingDone(page) + + // Find the target connection row + const targetRow = page.locator(`tr:has-text("${TARGET_DB.name}")`).first() + if (await targetRow.isVisible({ timeout: 5_000 })) { + await targetRow.locator("button:has-text('Test')").click() + + await expect( + page.locator('.badge-green:has-text("OK"), .badge-red:has-text("Error")') + ).toBeVisible({ timeout: 30_000 }) + + await expect(targetRow.locator('.badge-green:has-text("OK")')).toBeVisible({ timeout: 5_000 }) + } + }) +}) diff --git a/frontend/e2e/masking-definition.spec.ts b/frontend/e2e/masking-definition.spec.ts new file mode 100644 index 0000000..8363f9d --- /dev/null +++ b/frontend/e2e/masking-definition.spec.ts @@ -0,0 +1,466 @@ +import { test, expect } from './odm-fixtures' +import { + SOURCE_DB, + TARGET_DB, + loginViaApi, + registerUser, + apiCall, + waitForPageHeading, + waitForLoadingDone, +} from './odm-fixtures' + +// ── Masking Definition Tests ────────────────────────────────────────────── +// Verifies the table configuration, column generator assignment, and custom +// data mapping wizard through the frontend UI. Mirrors the table/generator +// setup in verification/run_verification.sh. + +test.describe('Masking Definition — Table Configurations', () => { + let workspaceId: number + let token: string + let sourceConnectionId: number + + test.beforeAll(async () => { + await registerUser() + token = await loginViaApi() + + const ws = await apiCall('/api/workspaces', { + method: 'POST', + body: { name: 'Masking Def Test Workspace', description: 'E2E masking definition tests' }, + token, + }) + workspaceId = ws.id as number + + const src = await apiCall(`/api/workspaces/${workspaceId}/connections`, { + method: 'POST', + body: { + name: SOURCE_DB.name, + type: SOURCE_DB.type, + connectionString: `jdbc:postgresql://${SOURCE_DB.host}:${SOURCE_DB.port}/${SOURCE_DB.database}`, + username: SOURCE_DB.username, + password: SOURCE_DB.password, + isSource: true, + isDestination: false, + }, + token, + }) + sourceConnectionId = src.id as number + + await apiCall(`/api/workspaces/${workspaceId}/connections`, { + method: 'POST', + body: { + name: TARGET_DB.name, + type: TARGET_DB.type, + connectionString: `jdbc:postgresql://${TARGET_DB.host}:${TARGET_DB.port}/${TARGET_DB.database}`, + username: TARGET_DB.username, + password: TARGET_DB.password, + isSource: false, + isDestination: true, + }, + token, + }) + }) + + test('tables page shows empty state when no tables configured', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/tables`) + await waitForPageHeading(page, 'Table Configurations') + + // Should show empty state or table list + const content = await page.textContent('body') + expect(content).toBeTruthy() + }) + + test('add table modal opens with expected fields', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/tables`) + await waitForPageHeading(page, 'Table Configurations') + + await page.click("button:has-text('Add Table')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + await expect(page.locator("[role='dialog']")).toContainText('Table') + + // Verify form fields: connection selector, table name, mode selector + await expect(page.locator("[role='dialog'] select.form-control").first()).toBeVisible() + await expect(page.locator("[role='dialog'] input[placeholder='users']")).toBeVisible() + }) + + test('create table configuration in MASK mode via UI', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/tables`) + await waitForPageHeading(page, 'Table Configurations') + + await page.click("button:has-text('Add Table')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + // Fill table name + await page.fill("[role='dialog'] input[placeholder='users']", 'users') + + // Select MASK mode + const modeSelect = page.locator("[role='dialog'] select.form-control").last() + await modeSelect.selectOption('MASK') + + // Submit + const saveBtn = page.locator("[role='dialog'] button:has-text('Add'), [role='dialog'] button:has-text('Save'), [role='dialog'] button:has-text('Create')") + await saveBtn.first().click() + + // Modal should close + await page.waitForSelector("[role='dialog']", { state: 'hidden', timeout: 10_000 }) + await waitForLoadingDone(page) + + // Verify the table appears in the list + await expect(page.locator('text=users')).toBeVisible() + await expect(page.locator('text=MASK')).toBeVisible() + }) + + test('expand table to view column generators section', async ({ authenticatedPage: page }) => { + // Seed a table config via API + const tbl = await apiCall(`/api/workspaces/${workspaceId}/tables`, { + method: 'POST', + body: { + connectionId: sourceConnectionId, + tableName: 'users_expand_test', + schemaName: 'public', + mode: 'MASK', + }, + token, + }) + + await page.goto(`/workspaces/${workspaceId}/tables`) + await waitForPageHeading(page, 'Table Configurations') + await waitForLoadingDone(page) + + // Click expand/columns button + const expandBtn = page.locator("button:has-text('Columns')").first() + if (await expandBtn.isVisible()) { + await expandBtn.click() + await page.waitForTimeout(500) + + // Column generators section should be visible + const columnsSection = page.locator("button:has-text('Add Column'), button:has-text('Add Generator')") + await expect(columnsSection.first()).toBeVisible({ timeout: 5_000 }) + } + }) + + test('add column generator to table via UI', async ({ authenticatedPage: page }) => { + // Seed a table config via API + const tbl = await apiCall(`/api/workspaces/${workspaceId}/tables`, { + method: 'POST', + body: { + connectionId: sourceConnectionId, + tableName: 'users_gen_test', + schemaName: 'public', + mode: 'MASK', + }, + token, + }) + const tableId = tbl.id as number + + await page.goto(`/workspaces/${workspaceId}/tables`) + await waitForPageHeading(page, 'Table Configurations') + await waitForLoadingDone(page) + + // Expand the table + const expandBtn = page.locator("button:has-text('Columns')").first() + if (await expandBtn.isVisible()) { + await expandBtn.click() + await page.waitForTimeout(500) + } + + // Click Add Column/Generator + const addBtn = page.locator("button:has-text('Add Column'), button:has-text('Add Generator')") + if (await addBtn.first().isVisible()) { + await addBtn.first().click() + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + // Fill column name and select generator type + const columnInput = page.locator("[role='dialog'] input").first() + await columnInput.fill('email') + + const genSelect = page.locator("[role='dialog'] select.form-control").first() + await genSelect.selectOption('EMAIL') + + // Save + const saveBtn = page.locator("[role='dialog'] button:has-text('Add'), [role='dialog'] button:has-text('Save')") + await saveBtn.first().click() + + await page.waitForSelector("[role='dialog']", { state: 'hidden', timeout: 10_000 }) + } + }) + + test('table configuration shows all mode options', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/tables`) + await waitForPageHeading(page, 'Table Configurations') + + await page.click("button:has-text('Add Table')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + const modeSelect = page.locator("[role='dialog'] select.form-control").last() + const options = await modeSelect.locator('option').allTextContents() + + expect(options).toContain('PASSTHROUGH') + expect(options).toContain('MASK') + expect(options).toContain('GENERATE') + expect(options).toContain('SUBSET') + expect(options).toContain('SKIP') + }) +}) + +test.describe('Masking Definition — Data Mapping Wizard', () => { + let workspaceId: number + let token: string + let sourceConnectionId: number + + test.beforeAll(async () => { + await registerUser() + token = await loginViaApi() + + const ws = await apiCall('/api/workspaces', { + method: 'POST', + body: { name: 'Mapping Wizard Test Workspace', description: 'E2E data mapping tests' }, + token, + }) + workspaceId = ws.id as number + + const src = await apiCall(`/api/workspaces/${workspaceId}/connections`, { + method: 'POST', + body: { + name: SOURCE_DB.name, + type: SOURCE_DB.type, + connectionString: `jdbc:postgresql://${SOURCE_DB.host}:${SOURCE_DB.port}/${SOURCE_DB.database}`, + username: SOURCE_DB.username, + password: SOURCE_DB.password, + isSource: true, + isDestination: false, + }, + token, + }) + sourceConnectionId = src.id as number + }) + + test('data mapping wizard renders step 1 — connection selection', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/mappings`) + await waitForPageHeading(page, 'Custom Data Mapping') + + // Step 1 should show connection cards + await expect(page.locator('text=Step 1')).toBeVisible({ timeout: 10_000 }) + await expect(page.locator('button.conn-card')).toBeVisible({ timeout: 10_000 }) + }) + + test('selecting a connection advances to step 2 — table selection', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/mappings`) + await waitForPageHeading(page, 'Custom Data Mapping') + + // Click the first connection card + const connCard = page.locator('button.conn-card').first() + await connCard.click() + + // Should advance to step 2 + await waitForLoadingDone(page) + await expect(page.locator('text=Step 2')).toBeVisible({ timeout: 10_000 }) + }) + + test('selecting a table advances to step 3 — column mapping', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/mappings`) + await waitForPageHeading(page, 'Custom Data Mapping') + + // Step 1: select connection + const connCard = page.locator('button.conn-card').first() + await connCard.click() + await waitForLoadingDone(page) + + // Step 2: select table + const tableCard = page.locator('button.table-card').first() + if (await tableCard.isVisible({ timeout: 10_000 })) { + await tableCard.click() + await waitForLoadingDone(page) + + // Step 3 should show column mapping UI + await expect(page.locator('text=Step 3')).toBeVisible({ timeout: 10_000 }) + } + }) + + test('column mapping shows action dropdown with MIGRATE_AS_IS and MASK options', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/mappings`) + await waitForPageHeading(page, 'Custom Data Mapping') + + // Navigate to step 3 + const connCard = page.locator('button.conn-card').first() + await connCard.click() + await waitForLoadingDone(page) + + const tableCard = page.locator('button.table-card').first() + if (await tableCard.isVisible({ timeout: 10_000 })) { + await tableCard.click() + await waitForLoadingDone(page) + + // Check that action dropdowns exist with expected options + const actionSelect = page.locator('select').first() + if (await actionSelect.isVisible({ timeout: 5_000 })) { + const options = await actionSelect.locator('option').allTextContents() + const optionsLower = options.map((o) => o.toLowerCase()) + expect(optionsLower.some((o) => o.includes('migrate') || o.includes('as_is') || o.includes('as-is'))).toBeTruthy() + expect(optionsLower.some((o) => o.includes('mask'))).toBeTruthy() + } + } + }) + + test('selecting MASK action reveals masking strategy options', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/mappings`) + await waitForPageHeading(page, 'Custom Data Mapping') + + // Navigate to step 3 + const connCard = page.locator('button.conn-card').first() + await connCard.click() + await waitForLoadingDone(page) + + const tableCard = page.locator('button.table-card').first() + if (await tableCard.isVisible({ timeout: 10_000 })) { + await tableCard.click() + await waitForLoadingDone(page) + + // Select MASK action for first column + const actionSelect = page.locator('select').first() + if (await actionSelect.isVisible({ timeout: 5_000 })) { + await actionSelect.selectOption('MASK') + await page.waitForTimeout(300) + + // A strategy dropdown should appear + const selects = page.locator('select') + const count = await selects.count() + // There should be more selects now (strategy + potentially generator type) + expect(count).toBeGreaterThanOrEqual(2) + } + } + }) + + test('saved mappings appear in the mappings list section', async ({ authenticatedPage: page }) => { + // Seed a mapping via API + await apiCall(`/api/workspaces/${workspaceId}/mappings/bulk`, { + method: 'POST', + body: { + connectionId: sourceConnectionId, + tableName: 'users', + columnMappings: [ + { columnName: 'id', action: 'MIGRATE_AS_IS' }, + { columnName: 'email', action: 'MASK', maskingStrategy: 'FAKE', fakeGeneratorType: 'EMAIL' }, + { columnName: 'full_name', action: 'MASK', maskingStrategy: 'FAKE', fakeGeneratorType: 'FULL_NAME' }, + ], + }, + token, + }) + + await page.goto(`/workspaces/${workspaceId}/mappings`) + await waitForPageHeading(page, 'Custom Data Mapping') + await waitForLoadingDone(page) + + // The saved mappings section should list the saved table mapping + await expect(page.locator('text=users')).toBeVisible({ timeout: 10_000 }) + }) +}) + +test.describe('Masking Definition — Verification of Column Generators', () => { + let workspaceId: number + let token: string + let sourceConnectionId: number + let tableConfigId: number + + test.beforeAll(async () => { + await registerUser() + token = await loginViaApi() + + const ws = await apiCall('/api/workspaces', { + method: 'POST', + body: { name: 'Generator Verification Workspace', description: 'E2E generator verification' }, + token, + }) + workspaceId = ws.id as number + + const src = await apiCall(`/api/workspaces/${workspaceId}/connections`, { + method: 'POST', + body: { + name: SOURCE_DB.name, + type: SOURCE_DB.type, + connectionString: `jdbc:postgresql://${SOURCE_DB.host}:${SOURCE_DB.port}/${SOURCE_DB.database}`, + username: SOURCE_DB.username, + password: SOURCE_DB.password, + isSource: true, + isDestination: false, + }, + token, + }) + sourceConnectionId = src.id as number + + const tbl = await apiCall(`/api/workspaces/${workspaceId}/tables`, { + method: 'POST', + body: { + connectionId: sourceConnectionId, + tableName: 'users', + schemaName: 'public', + mode: 'MASK', + }, + token, + }) + tableConfigId = tbl.id as number + + // Add column generators matching verification/run_verification.sh + const generators = [ + { columnName: 'full_name', generatorType: 'FULL_NAME' }, + { columnName: 'email', generatorType: 'EMAIL' }, + { columnName: 'phone_number', generatorType: 'PHONE' }, + { columnName: 'date_of_birth', generatorType: 'BIRTH_DATE' }, + { columnName: 'salary', generatorType: 'RANDOM_INT', generatorParams: JSON.stringify({ min: '30000', max: '200000' }) }, + ] + for (const gen of generators) { + await apiCall(`/api/workspaces/${workspaceId}/tables/${tableConfigId}/generators`, { + method: 'POST', + body: gen, + token, + }) + } + }) + + test('tables page displays configured table with MASK mode', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/tables`) + await waitForPageHeading(page, 'Table Configurations') + await waitForLoadingDone(page) + + await expect(page.locator('text=users')).toBeVisible() + await expect(page.locator('text=MASK')).toBeVisible() + }) + + test('expanding table shows all 5 configured column generators', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/tables`) + await waitForPageHeading(page, 'Table Configurations') + await waitForLoadingDone(page) + + // Expand column generators + const expandBtn = page.locator("button:has-text('Columns')").first() + if (await expandBtn.isVisible()) { + await expandBtn.click() + await page.waitForTimeout(500) + + // Verify all generator names are visible + await expect(page.locator('text=full_name')).toBeVisible() + await expect(page.locator('text=email')).toBeVisible() + await expect(page.locator('text=phone_number')).toBeVisible() + await expect(page.locator('text=date_of_birth')).toBeVisible() + await expect(page.locator('text=salary')).toBeVisible() + + // Verify generator types + await expect(page.locator('text=FULL_NAME')).toBeVisible() + await expect(page.locator('text=EMAIL')).toBeVisible() + await expect(page.locator('text=PHONE')).toBeVisible() + await expect(page.locator('text=BIRTH_DATE')).toBeVisible() + await expect(page.locator('text=RANDOM_INT')).toBeVisible() + } + }) + + test('column generator count matches verification script configuration', async ({ authenticatedPage: page }) => { + // Verify via API that exactly 5 generators exist + const tableConfig = await apiCall( + `/api/workspaces/${workspaceId}/tables/${tableConfigId}`, + { token } + ) + + const generators = (tableConfig as { columnGenerators?: unknown[] }).columnGenerators ?? [] + expect(generators.length).toBe(5) + }) +}) diff --git a/frontend/e2e/odm-fixtures.ts b/frontend/e2e/odm-fixtures.ts new file mode 100644 index 0000000..80df5f2 --- /dev/null +++ b/frontend/e2e/odm-fixtures.ts @@ -0,0 +1,239 @@ +import { test as base, expect, type Page } from '@playwright/test' + +// ── Verification-sandbox defaults ───────────────────────────────────────── +// These match the credentials used by verification/run_verification.sh +// and verification/docker-compose.yml so the tests work out-of-the-box +// against the sandboxed environment without any extra configuration. + +export const ODM_USERNAME = process.env.ODM_USERNAME ?? 'e2e_test_user' +export const ODM_PASSWORD = process.env.ODM_PASSWORD ?? 'E2eTest!Pass123' +export const ODM_EMAIL = process.env.ODM_EMAIL ?? 'e2e@odm-test.local' +export const API_BASE = process.env.ODM_API ?? 'http://localhost:8080' + +export const SOURCE_DB = { + name: 'e2e-source-db', + type: 'POSTGRESQL' as const, + host: process.env.SOURCE_DB_HOST ?? 'source_db', + port: 5432, + database: process.env.SOURCE_DB_NAME ?? 'source_db', + username: process.env.SOURCE_DB_USER ?? 'source_user', + password: process.env.SOURCE_DB_PASS ?? 'source_pass', +} + +export const TARGET_DB = { + name: 'e2e-target-db', + type: 'POSTGRESQL' as const, + host: process.env.TARGET_DB_HOST ?? 'target_db', + port: 5432, + database: process.env.TARGET_DB_NAME ?? 'target_db', + username: process.env.TARGET_DB_USER ?? 'target_user', + password: process.env.TARGET_DB_PASS ?? 'target_pass', +} + +// ── REST API helpers ────────────────────────────────────────────────────── +// These mirror verification/run_verification.sh — we use the API to seed +// state and read results while keeping tests focused on the frontend. + +interface ApiOptions { + method?: string + body?: Record + token?: string +} + +export async function apiCall( + path: string, + { method = 'GET', body, token }: ApiOptions = {} +): Promise> { + const headers: Record = { 'Content-Type': 'application/json' } + if (token) headers['Authorization'] = `Bearer ${token}` + + const res = await fetch(`${API_BASE}${path}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }) + + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`API ${method} ${path} → ${res.status}: ${text}`) + } + + const text = await res.text() + return text ? JSON.parse(text) : {} +} + +export async function registerUser(): Promise { + try { + await apiCall('/api/auth/register', { + method: 'POST', + body: { username: ODM_USERNAME, email: ODM_EMAIL, password: ODM_PASSWORD }, + }) + } catch { + // User may already exist — safe to ignore. + } +} + +export async function loginViaApi(): Promise { + const resp = await apiCall('/api/auth/login', { + method: 'POST', + body: { username: ODM_USERNAME, password: ODM_PASSWORD }, + }) + return resp.token as string +} + +// ── Browser helpers ─────────────────────────────────────────────────────── + +export async function loginViaUi(page: Page): Promise { + await page.goto('/login') + await page.fill('#username', ODM_USERNAME) + await page.fill('#password', ODM_PASSWORD) + await page.click("button[type='submit']") + await page.waitForURL(/\/workspaces/, { timeout: 15_000 }) +} + +export async function waitForLoadingDone(page: Page, timeout = 10_000): Promise { + try { + await page.waitForSelector('.loading-overlay', { state: 'hidden', timeout }) + } catch { + // Overlay may never appear — safe to continue. + } +} + +export async function waitForPageHeading(page: Page, text: string, timeout = 15_000): Promise { + await page.waitForSelector(`h1:has-text("${text}")`, { timeout }) + await waitForLoadingDone(page) +} + +// ── Seed helpers (API-driven, matching run_verification.sh) ──────────────── + +export interface SeedResult { + token: string + workspaceId: number + sourceConnectionId: number + targetConnectionId: number + tableConfigId: number +} + +export async function seedVerificationData(): Promise { + await registerUser() + const token = await loginViaApi() + + const ws = await apiCall('/api/workspaces', { + method: 'POST', + body: { name: 'E2E Verification Workspace', description: 'Playwright E2E test workspace' }, + token, + }) + const workspaceId = ws.id as number + + const src = await apiCall(`/api/workspaces/${workspaceId}/connections`, { + method: 'POST', + body: { + name: SOURCE_DB.name, + type: SOURCE_DB.type, + connectionString: `jdbc:postgresql://${SOURCE_DB.host}:${SOURCE_DB.port}/${SOURCE_DB.database}`, + username: SOURCE_DB.username, + password: SOURCE_DB.password, + isSource: true, + isDestination: false, + }, + token, + }) + const sourceConnectionId = src.id as number + + const tgt = await apiCall(`/api/workspaces/${workspaceId}/connections`, { + method: 'POST', + body: { + name: TARGET_DB.name, + type: TARGET_DB.type, + connectionString: `jdbc:postgresql://${TARGET_DB.host}:${TARGET_DB.port}/${TARGET_DB.database}`, + username: TARGET_DB.username, + password: TARGET_DB.password, + isSource: false, + isDestination: true, + }, + token, + }) + const targetConnectionId = tgt.id as number + + const tbl = await apiCall(`/api/workspaces/${workspaceId}/tables`, { + method: 'POST', + body: { + connectionId: sourceConnectionId, + tableName: 'users', + schemaName: 'public', + mode: 'MASK', + }, + token, + }) + const tableConfigId = tbl.id as number + + const generators = [ + { columnName: 'full_name', generatorType: 'FULL_NAME' }, + { columnName: 'email', generatorType: 'EMAIL' }, + { columnName: 'phone_number', generatorType: 'PHONE' }, + { columnName: 'date_of_birth', generatorType: 'BIRTH_DATE' }, + { columnName: 'salary', generatorType: 'RANDOM_INT', generatorParams: JSON.stringify({ min: '30000', max: '200000' }) }, + ] + + for (const gen of generators) { + await apiCall(`/api/workspaces/${workspaceId}/tables/${tableConfigId}/generators`, { + method: 'POST', + body: gen, + token, + }) + } + + return { token, workspaceId, sourceConnectionId, targetConnectionId, tableConfigId } +} + +export async function runMaskingJobViaApi( + token: string, + workspaceId: number, + sourceConnectionId: number, + targetConnectionId: number +): Promise { + const job = await apiCall(`/api/workspaces/${workspaceId}/jobs`, { + method: 'POST', + body: { + name: 'E2E Verification Job', + sourceConnectionId, + targetConnectionId, + }, + token, + }) + return job.id as number +} + +export async function waitForJobCompletion( + token: string, + workspaceId: number, + jobId: number, + timeoutMs = 120_000 +): Promise { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + const resp = await apiCall(`/api/workspaces/${workspaceId}/jobs/${jobId}`, { token }) + const status = resp.status as string + if (status === 'COMPLETED' || status === 'FAILED' || status === 'CANCELLED') { + return status + } + await new Promise((r) => setTimeout(r, 3_000)) + } + return 'TIMEOUT' +} + +// ── Custom test fixture ─────────────────────────────────────────────────── + +export type OdmFixtures = { + authenticatedPage: Page +} + +export const test = base.extend({ + authenticatedPage: async ({ page }, use) => { + await registerUser() + await loginViaUi(page) + await use(page) + }, +}) + +export { expect } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index eeaf161..ca0c83e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "vue-router": "^4.2.5" }, "devDependencies": { + "@playwright/test": "^1.59.1", "@tsconfig/node20": "^20.1.9", "@types/node": "^20.10.0", "@typescript-eslint/eslint-plugin": "^7.18.0", @@ -545,6 +546,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/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", @@ -3423,6 +3440,53 @@ } } }, + "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.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index 67f5e2a..4300380 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "vue-router": "^4.2.5" }, "devDependencies": { + "@playwright/test": "^1.59.1", "@tsconfig/node20": "^20.1.9", "@types/node": "^20.10.0", "@typescript-eslint/eslint-plugin": "^7.18.0", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..387cb0f --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,37 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * Playwright E2E test configuration for OpenDataMask. + * + * These tests run against the verification sandbox environment started by + * `verification/docker-compose.yml`. The frontend is served at http://localhost + * and the backend API at http://localhost:8080. + * + * Usage: + * cd frontend + * npx playwright test + */ +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: 'html', + timeout: 60_000, + + use: { + baseURL: process.env.ODM_URL ?? 'http://localhost', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + actionTimeout: 15_000, + navigationTimeout: 30_000, + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 6039470..9797601 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -18,6 +18,7 @@ export default defineConfig({ } }, test: { - environment: 'node' + environment: 'node', + exclude: ['e2e/**', 'node_modules/**'] } }) From f48ff104c4809b523a6248206f3838db9f7b55b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:01:47 +0000 Subject: [PATCH 2/4] Improve type safety in Playwright e2e tests based on code review feedback Agent-Logs-Url: https://github.com/MaximumTrainer/OpenDataMask/sessions/c0576442-108f-4e13-b8ac-78825dc03ec3 Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com> --- frontend/e2e/full-workflow.spec.ts | 61 ++++++++--------- frontend/e2e/job-execution.spec.ts | 46 ++++++------- frontend/e2e/masking-definition.spec.ts | 5 +- frontend/e2e/odm-fixtures.ts | 81 +++++++++++++++++----- frontend/playwright-report/index.html | 90 +++++++++++++++++++++++++ 5 files changed, 204 insertions(+), 79 deletions(-) create mode 100644 frontend/playwright-report/index.html diff --git a/frontend/e2e/full-workflow.spec.ts b/frontend/e2e/full-workflow.spec.ts index d04e2c9..11dc6b9 100644 --- a/frontend/e2e/full-workflow.spec.ts +++ b/frontend/e2e/full-workflow.spec.ts @@ -11,6 +11,12 @@ import { waitForPageHeading, waitForLoadingDone, waitForJobCompletion, + type IdResponse, + type WorkspaceResponse, + type ConnectionResponse, + type JobResponse, + type JobLogEntry, + type ConnectionTestResponse, } from './odm-fixtures' // ── Full E2E Workflow Test ──────────────────────────────────────────────── @@ -92,13 +98,8 @@ test.describe('Full Verification Workflow', () => { await expect(page.locator('text=Full Verification Workspace')).toBeVisible({ timeout: 10_000 }) // Get workspace ID via API for subsequent steps - const workspaces = (await apiCall('/api/workspaces', { token })) as unknown as Array<{ - id: number - name: string - }> - const ws = (workspaces as Array<{ id: number; name: string }>).find( - (w) => w.name === 'Full Verification Workspace' - ) + const workspaces = await apiCall('/api/workspaces', { token }) + const ws = workspaces.find((w) => w.name === 'Full Verification Workspace') expect(ws).toBeDefined() workspaceId = ws!.id }) @@ -135,12 +136,10 @@ test.describe('Full Verification Workflow', () => { await expect(page.locator('td:has-text("verification-source")')).toBeVisible() // Get connection ID via API - const conns = (await apiCall(`/api/workspaces/${workspaceId}/connections`, { + const conns = await apiCall(`/api/workspaces/${workspaceId}/connections`, { token, - })) as unknown as Array<{ id: number; name: string }> - const srcConn = (conns as Array<{ id: number; name: string }>).find( - (c) => c.name === 'verification-source' - ) + }) + const srcConn = conns.find((c) => c.name === 'verification-source') expect(srcConn).toBeDefined() sourceConnectionId = srcConn!.id }) @@ -176,19 +175,17 @@ test.describe('Full Verification Workflow', () => { await expect(page.locator('td:has-text("verification-target")')).toBeVisible() // Get connection ID via API - const conns = (await apiCall(`/api/workspaces/${workspaceId}/connections`, { + const conns = await apiCall(`/api/workspaces/${workspaceId}/connections`, { token, - })) as unknown as Array<{ id: number; name: string }> - const tgtConn = (conns as Array<{ id: number; name: string }>).find( - (c) => c.name === 'verification-target' - ) + }) + const tgtConn = conns.find((c) => c.name === 'verification-target') expect(tgtConn).toBeDefined() targetConnectionId = tgtConn!.id }) test('step 5 — configure table masking for users table', async ({ page }) => { // Use API for table config (matching verification script's approach) - const tbl = await apiCall(`/api/workspaces/${workspaceId}/tables`, { + const tbl = await apiCall(`/api/workspaces/${workspaceId}/tables`, { method: 'POST', body: { connectionId: sourceConnectionId, @@ -198,7 +195,7 @@ test.describe('Full Verification Workflow', () => { }, token, }) - tableConfigId = tbl.id as number + tableConfigId = tbl.id // Verify via UI await page.goto('/login') @@ -261,7 +258,7 @@ test.describe('Full Verification Workflow', () => { test('step 7 — run masking job and wait for completion', async ({ page }) => { // Trigger job via API jobId = ( - await apiCall(`/api/workspaces/${workspaceId}/jobs`, { + await apiCall(`/api/workspaces/${workspaceId}/jobs`, { method: 'POST', body: { name: 'Full Verification Masking Job', @@ -270,7 +267,7 @@ test.describe('Full Verification Workflow', () => { }, token, }) - ).id as number + ).id // Wait for completion const status = await waitForJobCompletion(token, workspaceId, jobId, 180_000) @@ -292,9 +289,9 @@ test.describe('Full Verification Workflow', () => { test('step 8 — verify record integrity (50 rows processed)', async () => { // Mirrors verification/verify.py check_record_integrity - const job = (await apiCall(`/api/workspaces/${workspaceId}/jobs/${jobId}`, { + const job = await apiCall(`/api/workspaces/${workspaceId}/jobs/${jobId}`, { token, - })) as { rowsProcessed: number; tablesProcessed: number; status: string } + }) expect(job.status).toBe('COMPLETED') expect(job.rowsProcessed).toBe(50) @@ -303,32 +300,30 @@ test.describe('Full Verification Workflow', () => { test('step 9 — verify job logs contain masking activity', async () => { // Mirrors verification/verify.py's log inspection - const logs = (await apiCall(`/api/workspaces/${workspaceId}/jobs/${jobId}/logs`, { + const logs = await apiCall(`/api/workspaces/${workspaceId}/jobs/${jobId}/logs`, { token, - })) as unknown as Array<{ level: string; message: string }> + }) - expect((logs as Array<{ level: string; message: string }>).length).toBeGreaterThan(0) + expect(logs.length).toBeGreaterThan(0) // Logs should contain INFO-level entries about the masking process - const infoLogs = (logs as Array<{ level: string; message: string }>).filter( - (l) => l.level === 'INFO' - ) + const infoLogs = logs.filter((l) => l.level === 'INFO') expect(infoLogs.length).toBeGreaterThan(0) }) test('step 10 — verify connections remain healthy after job', async ({ page }) => { // Test both connections via API - const srcTest = await apiCall( + const srcTest = await apiCall( `/api/workspaces/${workspaceId}/connections/${sourceConnectionId}/test`, { method: 'POST', token } ) - expect((srcTest as { success: boolean }).success).toBe(true) + expect(srcTest.success).toBe(true) - const tgtTest = await apiCall( + const tgtTest = await apiCall( `/api/workspaces/${workspaceId}/connections/${targetConnectionId}/test`, { method: 'POST', token } ) - expect((tgtTest as { success: boolean }).success).toBe(true) + expect(tgtTest.success).toBe(true) // Also verify in UI await page.goto('/login') diff --git a/frontend/e2e/job-execution.spec.ts b/frontend/e2e/job-execution.spec.ts index eeaa222..36ff2a1 100644 --- a/frontend/e2e/job-execution.spec.ts +++ b/frontend/e2e/job-execution.spec.ts @@ -10,6 +10,9 @@ import { waitForJobCompletion, waitForPageHeading, waitForLoadingDone, + type JobResponse, + type JobLogEntry, + type ConnectionTestResponse, } from './odm-fixtures' // ── Job Execution & Destination Transfer Verification ───────────────────── @@ -262,26 +265,22 @@ test.describe('Destination Database Transfer Verification', () => { await page.waitForSelector('.job-card', { timeout: 10_000 }) // Check via API that tablesProcessed > 0 - const jobs = (await apiCall(`/api/workspaces/${seed.workspaceId}/jobs`, { + const jobs = await apiCall(`/api/workspaces/${seed.workspaceId}/jobs`, { token: seed.token, - })) as unknown as Array<{ tablesProcessed: number; status: string }> + }) - const completedJob = (jobs as Array<{ tablesProcessed: number; status: string }>).find( - (j) => j.status === 'COMPLETED' - ) + const completedJob = jobs.find((j) => j.status === 'COMPLETED') expect(completedJob).toBeDefined() expect(completedJob!.tablesProcessed).toBeGreaterThan(0) }) test('completed job shows non-zero rows processed', async ({ authenticatedPage: page }) => { // Verify via API - const jobs = (await apiCall(`/api/workspaces/${seed.workspaceId}/jobs`, { + const jobs = await apiCall(`/api/workspaces/${seed.workspaceId}/jobs`, { token: seed.token, - })) as unknown as Array<{ rowsProcessed: number; status: string }> + }) - const completedJob = (jobs as Array<{ rowsProcessed: number; status: string }>).find( - (j) => j.status === 'COMPLETED' - ) + const completedJob = jobs.find((j) => j.status === 'COMPLETED') expect(completedJob).toBeDefined() expect(completedJob!.rowsProcessed).toBeGreaterThan(0) @@ -296,22 +295,20 @@ test.describe('Destination Database Transfer Verification', () => { test('job logs show masking activity entries', async ({ authenticatedPage: page }) => { // Fetch job ID - const jobs = (await apiCall(`/api/workspaces/${seed.workspaceId}/jobs`, { + const jobs = await apiCall(`/api/workspaces/${seed.workspaceId}/jobs`, { token: seed.token, - })) as unknown as Array<{ id: number; status: string }> + }) - const completedJob = (jobs as Array<{ id: number; status: string }>).find( - (j) => j.status === 'COMPLETED' - ) + const completedJob = jobs.find((j) => j.status === 'COMPLETED') expect(completedJob).toBeDefined() // Fetch logs via API - const logs = (await apiCall( + const logs = await apiCall( `/api/workspaces/${seed.workspaceId}/jobs/${completedJob!.id}/logs`, { token: seed.token } - )) as unknown as Array<{ level: string; message: string }> + ) - expect((logs as Array<{ level: string; message: string }>).length).toBeGreaterThan(0) + expect(logs.length).toBeGreaterThan(0) // View logs in UI await page.goto(`/workspaces/${seed.workspaceId}/jobs`) @@ -324,23 +321,18 @@ test.describe('Destination Database Transfer Verification', () => { await expect(page.locator('.logs-section')).toBeVisible({ timeout: 10_000 }) // Log entries should be visible - await expect(page.locator('.log-line')).toHaveCount( - (logs as Array<{ level: string; message: string }>).length, - { timeout: 10_000 } - ) + await expect(page.locator('.log-line')).toHaveCount(logs.length, { timeout: 10_000 }) } }) test('record integrity — source and target row counts match (API verification)', async () => { // This test verifies data transfer by checking the job's reported row count // against the known source data (50 users in source_db.sql). - const jobs = (await apiCall(`/api/workspaces/${seed.workspaceId}/jobs`, { + const jobs = await apiCall(`/api/workspaces/${seed.workspaceId}/jobs`, { token: seed.token, - })) as unknown as Array<{ rowsProcessed: number; tablesProcessed: number; status: string }> + }) - const completedJob = (jobs as Array<{ rowsProcessed: number; tablesProcessed: number; status: string }>).find( - (j) => j.status === 'COMPLETED' - ) + const completedJob = jobs.find((j) => j.status === 'COMPLETED') expect(completedJob).toBeDefined() // The source database has 50 user records (per verification/init/source_db.sql) diff --git a/frontend/e2e/masking-definition.spec.ts b/frontend/e2e/masking-definition.spec.ts index 8363f9d..1331dc7 100644 --- a/frontend/e2e/masking-definition.spec.ts +++ b/frontend/e2e/masking-definition.spec.ts @@ -7,6 +7,7 @@ import { apiCall, waitForPageHeading, waitForLoadingDone, + type TableConfigResponse, } from './odm-fixtures' // ── Masking Definition Tests ────────────────────────────────────────────── @@ -455,12 +456,12 @@ test.describe('Masking Definition — Verification of Column Generators', () => test('column generator count matches verification script configuration', async ({ authenticatedPage: page }) => { // Verify via API that exactly 5 generators exist - const tableConfig = await apiCall( + const tableConfig = await apiCall( `/api/workspaces/${workspaceId}/tables/${tableConfigId}`, { token } ) - const generators = (tableConfig as { columnGenerators?: unknown[] }).columnGenerators ?? [] + const generators = tableConfig.columnGenerators ?? [] expect(generators.length).toBe(5) }) }) diff --git a/frontend/e2e/odm-fixtures.ts b/frontend/e2e/odm-fixtures.ts index 80df5f2..e596f75 100644 --- a/frontend/e2e/odm-fixtures.ts +++ b/frontend/e2e/odm-fixtures.ts @@ -34,16 +34,63 @@ export const TARGET_DB = { // These mirror verification/run_verification.sh — we use the API to seed // state and read results while keeping tests focused on the frontend. +// ── API response types ──────────────────────────────────────────────────── + interface ApiOptions { method?: string body?: Record token?: string } -export async function apiCall( +export interface IdResponse { + id: number +} + +export interface AuthTokenResponse { + token: string +} + +export interface ConnectionTestResponse { + success: boolean + message: string +} + +export interface JobResponse { + id: number + status: string + rowsProcessed: number + tablesProcessed: number + name: string +} + +export interface JobLogEntry { + level: string + message: string +} + +export interface WorkspaceResponse { + id: number + name: string +} + +export interface ConnectionResponse { + id: number + name: string + type: string + isSource: boolean + isDestination: boolean +} + +export interface TableConfigResponse { + id: number + columnGenerators: Array<{ columnName: string; generatorType: string }> +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function apiCall>( path: string, { method = 'GET', body, token }: ApiOptions = {} -): Promise> { +): Promise { const headers: Record = { 'Content-Type': 'application/json' } if (token) headers['Authorization'] = `Bearer ${token}` @@ -59,7 +106,7 @@ export async function apiCall( } const text = await res.text() - return text ? JSON.parse(text) : {} + return text ? JSON.parse(text) : ({} as T) } export async function registerUser(): Promise { @@ -74,11 +121,11 @@ export async function registerUser(): Promise { } export async function loginViaApi(): Promise { - const resp = await apiCall('/api/auth/login', { + const resp = await apiCall('/api/auth/login', { method: 'POST', body: { username: ODM_USERNAME, password: ODM_PASSWORD }, }) - return resp.token as string + return resp.token } // ── Browser helpers ─────────────────────────────────────────────────────── @@ -118,14 +165,14 @@ export async function seedVerificationData(): Promise { await registerUser() const token = await loginViaApi() - const ws = await apiCall('/api/workspaces', { + const ws = await apiCall('/api/workspaces', { method: 'POST', body: { name: 'E2E Verification Workspace', description: 'Playwright E2E test workspace' }, token, }) - const workspaceId = ws.id as number + const workspaceId = ws.id - const src = await apiCall(`/api/workspaces/${workspaceId}/connections`, { + const src = await apiCall(`/api/workspaces/${workspaceId}/connections`, { method: 'POST', body: { name: SOURCE_DB.name, @@ -138,9 +185,9 @@ export async function seedVerificationData(): Promise { }, token, }) - const sourceConnectionId = src.id as number + const sourceConnectionId = src.id - const tgt = await apiCall(`/api/workspaces/${workspaceId}/connections`, { + const tgt = await apiCall(`/api/workspaces/${workspaceId}/connections`, { method: 'POST', body: { name: TARGET_DB.name, @@ -153,9 +200,9 @@ export async function seedVerificationData(): Promise { }, token, }) - const targetConnectionId = tgt.id as number + const targetConnectionId = tgt.id - const tbl = await apiCall(`/api/workspaces/${workspaceId}/tables`, { + const tbl = await apiCall(`/api/workspaces/${workspaceId}/tables`, { method: 'POST', body: { connectionId: sourceConnectionId, @@ -165,7 +212,7 @@ export async function seedVerificationData(): Promise { }, token, }) - const tableConfigId = tbl.id as number + const tableConfigId = tbl.id const generators = [ { columnName: 'full_name', generatorType: 'FULL_NAME' }, @@ -192,7 +239,7 @@ export async function runMaskingJobViaApi( sourceConnectionId: number, targetConnectionId: number ): Promise { - const job = await apiCall(`/api/workspaces/${workspaceId}/jobs`, { + const job = await apiCall(`/api/workspaces/${workspaceId}/jobs`, { method: 'POST', body: { name: 'E2E Verification Job', @@ -201,7 +248,7 @@ export async function runMaskingJobViaApi( }, token, }) - return job.id as number + return job.id } export async function waitForJobCompletion( @@ -212,8 +259,8 @@ export async function waitForJobCompletion( ): Promise { const start = Date.now() while (Date.now() - start < timeoutMs) { - const resp = await apiCall(`/api/workspaces/${workspaceId}/jobs/${jobId}`, { token }) - const status = resp.status as string + const resp = await apiCall(`/api/workspaces/${workspaceId}/jobs/${jobId}`, { token }) + const status = resp.status if (status === 'COMPLETED' || status === 'FAILED' || status === 'CANCELLED') { return status } diff --git a/frontend/playwright-report/index.html b/frontend/playwright-report/index.html new file mode 100644 index 0000000..031522a --- /dev/null +++ b/frontend/playwright-report/index.html @@ -0,0 +1,90 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file From 698d064e477227a84ab0e03918f5661c8316ddc0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:02:16 +0000 Subject: [PATCH 3/4] Remove playwright-report from git and add Playwright output dirs to .gitignore Agent-Logs-Url: https://github.com/MaximumTrainer/OpenDataMask/sessions/c0576442-108f-4e13-b8ac-78825dc03ec3 Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com> --- frontend/.gitignore | 6 ++ frontend/playwright-report/index.html | 90 --------------------------- 2 files changed, 6 insertions(+), 90 deletions(-) delete mode 100644 frontend/playwright-report/index.html diff --git a/frontend/.gitignore b/frontend/.gitignore index a8bbfe6..bc2264a 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -20,3 +20,9 @@ dist-ssr/ # Build cache *.tsbuildinfo .eslintcache + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/frontend/playwright-report/index.html b/frontend/playwright-report/index.html deleted file mode 100644 index 031522a..0000000 --- a/frontend/playwright-report/index.html +++ /dev/null @@ -1,90 +0,0 @@ - - - - - - - - - Playwright Test Report - - - - -
- - - \ No newline at end of file From 2b5197e78f41d15d636cf1f71f7fd74336a426d0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:33:13 +0000 Subject: [PATCH 4/4] Address review feedback: fix unused imports/vars, improve type safety, extend vitest exclude defaults Agent-Logs-Url: https://github.com/MaximumTrainer/OpenDataMask/sessions/e32c6a4c-f45b-4dd1-adc8-c6f4b5e2b288 Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com> --- frontend/e2e/auth.spec.ts | 1 - frontend/e2e/connections.spec.ts | 6 ++---- frontend/e2e/full-workflow.spec.ts | 1 - frontend/e2e/job-execution.spec.ts | 1 - frontend/e2e/masking-definition.spec.ts | 2 +- frontend/e2e/odm-fixtures.ts | 21 ++++++++++++++++----- frontend/vite.config.ts | 3 ++- 7 files changed, 21 insertions(+), 14 deletions(-) diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts index 3e10e2d..ea9764d 100644 --- a/frontend/e2e/auth.spec.ts +++ b/frontend/e2e/auth.spec.ts @@ -2,7 +2,6 @@ import { test, expect } from '@playwright/test' import { ODM_USERNAME, ODM_PASSWORD, - ODM_EMAIL, registerUser, waitForLoadingDone, } from './odm-fixtures' diff --git a/frontend/e2e/connections.spec.ts b/frontend/e2e/connections.spec.ts index 6fcd241..afe44e9 100644 --- a/frontend/e2e/connections.spec.ts +++ b/frontend/e2e/connections.spec.ts @@ -196,9 +196,8 @@ test.describe('Database Connections', () => { test('test connection button triggers connection test', async ({ authenticatedPage: page }) => { // Seed a connection via API - let connId: number try { - const conn = await apiCall(`/api/workspaces/${workspaceId}/connections`, { + await apiCall(`/api/workspaces/${workspaceId}/connections`, { method: 'POST', body: { name: 'test-btn-conn', @@ -211,7 +210,6 @@ test.describe('Database Connections', () => { }, token, }) - connId = conn.id as number } catch { // May already exist — just view the page } @@ -233,7 +231,7 @@ test.describe('Database Connections', () => { test('edit connection modal pre-fills existing data', async ({ authenticatedPage: page }) => { // Seed a connection via API - const conn = await apiCall(`/api/workspaces/${workspaceId}/connections`, { + await apiCall(`/api/workspaces/${workspaceId}/connections`, { method: 'POST', body: { name: 'edit-test-conn', diff --git a/frontend/e2e/full-workflow.spec.ts b/frontend/e2e/full-workflow.spec.ts index 11dc6b9..be99249 100644 --- a/frontend/e2e/full-workflow.spec.ts +++ b/frontend/e2e/full-workflow.spec.ts @@ -2,7 +2,6 @@ import { test, expect } from './odm-fixtures' import { ODM_USERNAME, ODM_PASSWORD, - ODM_EMAIL, SOURCE_DB, TARGET_DB, registerUser, diff --git a/frontend/e2e/job-execution.spec.ts b/frontend/e2e/job-execution.spec.ts index 36ff2a1..a148a3f 100644 --- a/frontend/e2e/job-execution.spec.ts +++ b/frontend/e2e/job-execution.spec.ts @@ -12,7 +12,6 @@ import { waitForLoadingDone, type JobResponse, type JobLogEntry, - type ConnectionTestResponse, } from './odm-fixtures' // ── Job Execution & Destination Transfer Verification ───────────────────── diff --git a/frontend/e2e/masking-definition.spec.ts b/frontend/e2e/masking-definition.spec.ts index 1331dc7..0fdfbff 100644 --- a/frontend/e2e/masking-definition.spec.ts +++ b/frontend/e2e/masking-definition.spec.ts @@ -113,7 +113,7 @@ test.describe('Masking Definition — Table Configurations', () => { test('expand table to view column generators section', async ({ authenticatedPage: page }) => { // Seed a table config via API - const tbl = await apiCall(`/api/workspaces/${workspaceId}/tables`, { + await apiCall(`/api/workspaces/${workspaceId}/tables`, { method: 'POST', body: { connectionId: sourceConnectionId, diff --git a/frontend/e2e/odm-fixtures.ts b/frontend/e2e/odm-fixtures.ts index e596f75..fe6eba7 100644 --- a/frontend/e2e/odm-fixtures.ts +++ b/frontend/e2e/odm-fixtures.ts @@ -86,8 +86,7 @@ export interface TableConfigResponse { columnGenerators: Array<{ columnName: string; generatorType: string }> } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export async function apiCall>( +export async function apiCall( path: string, { method = 'GET', body, token }: ApiOptions = {} ): Promise { @@ -106,7 +105,17 @@ export async function apiCall>( } const text = await res.text() - return text ? JSON.parse(text) : ({} as T) + return text ? (JSON.parse(text) as T) : (undefined as unknown as T) +} + +function isAlreadyRegisteredError(error: unknown): boolean { + if (!(error instanceof Error)) return false + const message = error.message.toLowerCase() + return ( + message.includes('→ 409:') || + message.includes('already exists') || + message.includes('already registered') + ) } export async function registerUser(): Promise { @@ -115,8 +124,10 @@ export async function registerUser(): Promise { method: 'POST', body: { username: ODM_USERNAME, email: ODM_EMAIL, password: ODM_PASSWORD }, }) - } catch { - // User may already exist — safe to ignore. + } catch (error) { + if (!isAlreadyRegisteredError(error)) { + throw error + } } } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 9797601..4d7521d 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import path from 'path' +import { configDefaults } from 'vitest/config' export default defineConfig({ plugins: [vue()], @@ -19,6 +20,6 @@ export default defineConfig({ }, test: { environment: 'node', - exclude: ['e2e/**', 'node_modules/**'] + exclude: [...configDefaults.exclude, 'e2e/**'] } })