diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..cd48ca1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,34 @@ +version: 2 +updates: + # npm workspace — covers all packages/* and apps/* dependencies in one pass. + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + time: "08:00" + timezone: "America/Edmonton" + open-pull-requests-limit: 5 + labels: + - dependencies + - security + # Group non-major updates together so the PR queue doesn't flood. + groups: + minor-and-patch: + update-types: + - minor + - patch + + # Rust / Cargo — covers the Tauri desktop app dependencies. + - package-ecosystem: cargo + directory: /apps/desktop/src-tauri + schedule: + interval: weekly + day: monday + time: "08:00" + timezone: "America/Edmonton" + open-pull-requests-limit: 3 + labels: + - dependencies + - security + - rust diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..6b3889c --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,58 @@ +name: CodeQL Analysis + +# CodeQL is NOT a PR gate — it is too slow (5–15 min) to block developer feedback. +# Findings appear in the GitHub Security tab automatically. +# Runs: +# - Every push to main (catches anything that slipped through PR checks) +# - Weekly on Monday at 02:00 UTC (baseline drift detection) + +on: + push: + branches: [main] + schedule: + - cron: '0 2 * * 1' + +permissions: + contents: read + security-events: write + actions: read + +jobs: + analyze: + name: CodeQL — JavaScript / TypeScript + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + # No cache: npm — package-lock.json is gitignored in this project. + + # CodeQL needs installed dependencies to resolve imports properly. + - name: Install dependencies + run: npm install --ignore-scripts + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + # "javascript" covers both JS and TS — no need to list both. + languages: javascript + # security-and-quality includes OWASP Top 10 patterns. + queries: security-and-quality + + # Let CodeQL auto-detect the build (TypeScript projects typically need no explicit step). + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + # continue-on-error: SARIF upload requires GitHub Advanced Security on private repos. + # The analysis itself still runs; findings are logged in the job output. + - name: Run CodeQL analysis + continue-on-error: true + uses: github/codeql-action/analyze@v3 + with: + category: /language:javascript + upload: true diff --git a/.github/workflows/dependency-security.yml b/.github/workflows/dependency-security.yml new file mode 100644 index 0000000..7ec4bd4 --- /dev/null +++ b/.github/workflows/dependency-security.yml @@ -0,0 +1,104 @@ +name: Dependency Security + +# Runs on every PR and every push to main. +# Hard-blocks on Critical npm vulnerabilities. +# Hard-blocks on any Rust advisory (cargo audit). +# Warns on High npm vulnerabilities (non-blocking — devDep false positives are common). + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + # ── npm audit ────────────────────────────────────────────────────────────── + npm-audit: + name: npm audit + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + # No cache: npm — package-lock.json is gitignored in this project. + + # Generate lockfile on the fly so npm audit has a full dependency graph. + # Consider committing package-lock.json for faster, deterministic CI. + - name: Generate lockfile + run: npm install --package-lock-only --ignore-scripts + + # Hard block — Critical vulns in production dependencies. + - name: Fail on Critical vulnerabilities + run: npm audit --audit-level=critical --omit=dev + + # Soft check — High vulns across all deps (informational; common in devDeps). + - name: Warn on High vulnerabilities + run: npm audit --audit-level=high || echo "⚠️ High-severity advisories found — review npm-audit-full.json" + + # Upload full JSON report as a build artifact for manual review. + - name: Save full audit report + if: always() + run: npm audit --json > npm-audit-full.json || true + + - name: Upload audit report + if: always() + uses: actions/upload-artifact@v4 + with: + name: npm-audit-report + path: npm-audit-full.json + retention-days: 30 + + # ── ESLint security rules ────────────────────────────────────────────────── + eslint-security: + name: ESLint security + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install dependencies + run: npm install --ignore-scripts + + # Errors (inject/eval/ReDoS) cause non-zero exit and block the PR. + # Warnings (detect-non-literal-fs-filename) are informational — legitimate + # server/CLI filesystem operations use variable paths by design. + - name: Run ESLint security rules + run: npx eslint . + + # ── Rust / cargo audit ───────────────────────────────────────────────────── + cargo-audit: + name: cargo audit + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: cargo-audit-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }} + restore-keys: cargo-audit- + + - name: Install cargo-audit + run: cargo install cargo-audit --locked --quiet + + - name: Run cargo audit + working-directory: apps/desktop/src-tauri + run: cargo audit diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml new file mode 100644 index 0000000..1d5cab0 --- /dev/null +++ b/.github/workflows/release-gate.yml @@ -0,0 +1,134 @@ +name: Release Gate + +# Triggered by a version tag push (e.g. v0.0.12). +# All security checks must pass before a GitHub Release draft is created. +# To release: push a tag matching v* — this workflow does the rest. +# +# Usage: +# git tag v0.0.12 +# git push origin v0.0.12 + +on: + push: + tags: + - 'v*' + +permissions: + contents: write # needed to create a GitHub Release + security-events: write + +jobs: + # ── Gate: unit tests ──────────────────────────────────────────────────────── + unit-tests: + name: Unit tests + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm install --ignore-scripts + - run: npx vitest run + + # ── Gate: ESLint security ─────────────────────────────────────────────────── + eslint-security: + name: ESLint security + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm install --ignore-scripts + - run: npx eslint . + + # ── Gate: npm audit ───────────────────────────────────────────────────────── + npm-audit: + name: npm audit + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm install --package-lock-only --ignore-scripts + - run: npm audit --audit-level=critical --omit=dev + + # ── Gate: Gitleaks ────────────────────────────────────────────────────────── + gitleaks: + name: Gitleaks + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install Gitleaks + env: + GITLEAKS_VERSION: "8.21.2" + run: | + curl -sSfL \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + | tar -xz -C /usr/local/bin gitleaks + - run: gitleaks detect --source . --config .gitleaks.toml --redact + + # ── Gate: cargo audit ─────────────────────────────────────────────────────── + cargo-audit: + name: cargo audit + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: cargo-audit-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }} + - run: cargo install cargo-audit --locked --quiet + - run: cargo audit + working-directory: apps/desktop/src-tauri + + # ── Publish: create GitHub Release draft ──────────────────────────────────── + # Only runs if ALL gate jobs succeed. + create-release: + name: Create Release Draft + needs: [unit-tests, eslint-security, npm-audit, gitleaks, cargo-audit] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Extract version from tag + id: tag + run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: Create GitHub Release draft + uses: softprops/action-gh-release@v2 + with: + draft: true + generate_release_notes: true + name: "FoxSchema ${{ github.ref_name }}" + body: | + ## FoxSchema ${{ github.ref_name }} + + All security gates passed: + - ✅ Unit tests + - ✅ ESLint security rules + - ✅ npm audit (no Critical vulnerabilities) + - ✅ Gitleaks (no secrets detected) + - ✅ cargo audit (no Rust advisories) + + > **Review this draft before publishing.** Edit the release notes above, then click "Publish release." diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml new file mode 100644 index 0000000..97e4a21 --- /dev/null +++ b/.github/workflows/secret-scan.yml @@ -0,0 +1,72 @@ +name: Secret Scan + +# PR: scan only new commits (diff mode) — fast. +# Push to main / weekly schedule: scan full repository history — thorough. +# Findings are uploaded to the GitHub Security tab as SARIF (best-effort on private repos). + +on: + pull_request: + push: + branches: [main] + schedule: + # Full history scan every Monday at 01:00 UTC. + - cron: '0 1 * * 1' + +permissions: + contents: read + security-events: write # required to upload SARIF to the Security tab + +jobs: + gitleaks: + name: Gitleaks + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # Full history required for push/schedule scans. + # PR scans only need the tip but fetch-depth: 0 is safe for both. + fetch-depth: 0 + + - name: Install Gitleaks + env: + GITLEAKS_VERSION: "8.21.2" + run: | + curl -sSfL \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + | tar -xz -C /usr/local/bin gitleaks + gitleaks version + + # PR mode: scan only commits introduced by this PR (fast, no history noise). + - name: Scan PR diff + if: github.event_name == 'pull_request' + run: | + gitleaks detect \ + --source . \ + --config .gitleaks.toml \ + --redact \ + --report-format sarif \ + --report-path gitleaks.sarif \ + --log-opts "origin/${{ github.base_ref }}...HEAD" + + # Push / schedule: scan the full commit history. + - name: Scan full history + if: github.event_name != 'pull_request' + run: | + gitleaks detect \ + --source . \ + --config .gitleaks.toml \ + --redact \ + --report-format sarif \ + --report-path gitleaks.sarif + + # Upload findings to GitHub Security tab. + # continue-on-error: private repos need GitHub Advanced Security for the + # Code Scanning API — the scan itself still blocks on leaks found above. + - name: Upload SARIF to GitHub Security + if: always() + continue-on-error: true + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: gitleaks.sarif + category: gitleaks diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml new file mode 100644 index 0000000..38880ca --- /dev/null +++ b/.github/workflows/version-bump.yml @@ -0,0 +1,78 @@ +name: Version Bump + +# Runs on every push to main (i.e. every merged PR). +# Reads the current 0.0.BUILD from root package.json, increments BUILD by 1, +# and writes the new version back to all package.json, tauri.conf.json, and Cargo.toml files. +on: + push: + branches: [main] + +jobs: + bump: + # Skip when the push is the version-bump commit itself (avoids infinite loop). + if: "!startsWith(github.event.head_commit.message, 'chore: bump version')" + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # Use a PAT or the default GITHUB_TOKEN. + # GITHUB_TOKEN is enough for pushing to non-protected branches. + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Compute new version + id: ver + run: | + CURRENT=$(jq -r '.version' package.json) + BUILD=$(echo "$CURRENT" | cut -d. -f3) + NEW_BUILD=$((BUILD + 1)) + NEW="${CURRENT%.*}.$NEW_BUILD" + echo "new=$NEW" >> "$GITHUB_OUTPUT" + echo "Bumping $CURRENT → $NEW" + + - name: Update package.json files + run: | + V=${{ steps.ver.outputs.new }} + for f in \ + package.json \ + packages/core/package.json \ + apps/web/package.json \ + apps/cli/package.json \ + apps/desktop/package.json; do + jq --arg v "$V" '.version = $v' "$f" > "$f.tmp" && mv "$f.tmp" "$f" + done + + - name: Update tauri.conf.json + run: | + V=${{ steps.ver.outputs.new }} + jq --arg v "$V" '.version = $v' \ + apps/desktop/src-tauri/tauri.conf.json \ + > apps/desktop/src-tauri/tauri.conf.json.tmp \ + && mv apps/desktop/src-tauri/tauri.conf.json.tmp \ + apps/desktop/src-tauri/tauri.conf.json + + - name: Update Cargo.toml + run: | + V=${{ steps.ver.outputs.new }} + sed -i "s/^version = \".*\"/version = \"$V\"/" \ + apps/desktop/src-tauri/Cargo.toml + + - name: Commit and push + run: | + V=${{ steps.ver.outputs.new }} + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add \ + package.json \ + packages/core/package.json \ + apps/web/package.json \ + apps/cli/package.json \ + apps/desktop/package.json \ + apps/desktop/src-tauri/tauri.conf.json \ + apps/desktop/src-tauri/Cargo.toml + git commit -m "chore: bump version to $V [skip ci]" + git push diff --git a/.gitignore b/.gitignore index 868cbf9..8061a9d 100644 --- a/.gitignore +++ b/.gitignore @@ -32,9 +32,13 @@ schemacompare.db* IMPLEMENTATION_STATE.md CLAUDE.md CHANGELOG.md + *.md */*.md */*/*.md +# Security policy and docs must be tracked +!SECURITY.md +!docs/security/security-process.md # Desktop (Tauri) build artifacts apps/desktop/src-tauri/target/ apps/desktop/src-tauri/gen/ diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..0a1e9b8 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,32 @@ +# Gitleaks configuration for FoxSchema. +# Extend the default ruleset and add project-specific allowlists. + +[extend] +# Pull in the full default ruleset (AWS keys, JWTs, private keys, etc.) +useDefault = true + +[allowlist] + description = "Project-level false-positive suppressions" + paths = [ + # E2E test database credentials — local Docker-only setup, labelled dev-only. + # These credentials have no production access and are intentionally committed + # as documentation for local environment setup. + '''apps/e2e/\.env''', + ] + regexes = [ + # vitest / test harness output that contains the word "password" in test data + '''foxpass''', + '''foxuser''', + # Placeholder password used only in local Docker compose (never production) + '''FoxPass123!''', + ] + +# Fine-tune rule severity so the SARIF report is useful without noise. +# Rules that commonly fire on documentation / example code: +[[rules]] + id = "generic-api-key" + [rules.allowlist] + regexes = [ + # Example connection strings in docs / comments + '''example\.|placeholder|your-key-here| isConfigured(d.key)); +const skipped = ALL_DIALECTS.filter((d) => !isConfigured(d.key)); + +if (configured.length === 0) { + console.error('No dialects configured. Set E2E__SOURCE_HOST / TARGET_HOST env vars.'); + process.exit(1); +} + +// ── Run ────────────────────────────────────────────────────────────────────── +const VITEST = 'npx vitest run --config vitest.config.ts --reporter=verbose'; +const HEADED = process.env.HEADLESS === 'false' ? 'HEADLESS=false ' : ''; +const logDir = join(ROOT, 'logs'); +mkdirSync(logDir, { recursive: true }); + +const results = []; +const bar = '─'.repeat(60); + +console.log('\n' + bar); +console.log(` FoxSchema E2E — running ${configured.length} dialect(s)`); +console.log(bar + '\n'); + +for (const dialect of configured) { + const start = Date.now(); + const logFile = join(logDir, `${dialect.key}.log`); + process.stdout.write(`▶ ${dialect.label.padEnd(12)} `); + + let passed = false; + let output = ''; + try { + output = execSync(`${HEADED}${VITEST} ${dialect.file}`, { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 300_000, // 5 min per dialect + env: { ...process.env }, + }).toString(); + passed = true; + process.stdout.write('✓ PASS'); + } catch (err) { + output = (err.stdout ?? '').toString() + '\n' + (err.stderr ?? '').toString(); + passed = false; + process.stdout.write('✗ FAIL'); + } + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + console.log(` (${elapsed}s) → log: logs/${dialect.key}.log`); + writeFileSync(logFile, output, 'utf8'); + + // Extract failing test names + first error line from the log + const failLines = output.split('\n').filter((l) => l.includes('FAIL') || l.includes('Error') || l.includes('✗')); + results.push({ ...dialect, passed, elapsed, logFile, failLines }); +} + +// ── Summary ────────────────────────────────────────────────────────────────── +console.log('\n' + bar); +console.log(' SUMMARY'); +console.log(bar); + +const passed = results.filter((r) => r.passed); +const failed = results.filter((r) => !r.passed); + +for (const r of results) { + const icon = r.passed ? '✓' : '✗'; + console.log(` ${icon} ${r.label.padEnd(12)} ${r.elapsed}s`); +} + +if (skipped.length) { + console.log(`\n (skipped — no env vars: ${skipped.map((d) => d.label).join(', ')})`); +} + +console.log(`\n ${passed.length} passed / ${failed.length} failed / ${skipped.length} skipped`); + +// ── Failure detail ─────────────────────────────────────────────────────────── +if (failed.length > 0) { + console.log('\n' + bar); + console.log(' FAILURE DETAIL'); + console.log(bar); + + for (const r of failed) { + console.log(`\n ── ${r.label} ─────────────────────────────`); + // Print up to 30 most relevant lines from the log + const lines = readFileSync(r.logFile, 'utf8').split('\n'); + const relevant = lines.filter((l) => + /error|fail|expect|received|thrown|timeout|FAIL|✗/i.test(l) && l.trim() + ).slice(0, 30); + for (const l of relevant) { + console.log(' ' + l.trimEnd()); + } + console.log(`\n Full log: ${r.logFile}`); + } +} + +console.log('\n' + bar + '\n'); +process.exit(failed.length > 0 ? 1 : 0); diff --git a/apps/e2e/src/helpers/console-monitor.ts b/apps/e2e/src/helpers/console-monitor.ts new file mode 100644 index 0000000..24389d8 --- /dev/null +++ b/apps/e2e/src/helpers/console-monitor.ts @@ -0,0 +1,39 @@ +import { WebDriver, logging } from 'selenium-webdriver'; + +const IGNORED_PATTERNS = [ + /favicon\.ico/, + /\[HMR\]/, + /vite:/, + /\[vite\]/, +]; + +export interface ConsoleError { + level: string; + message: string; + timestamp: number; +} + +/** + * Collect browser console errors/warnings. + * Must call `enableLogging()` in beforeAll before using the driver. + */ +export async function getBrowserErrors(driver: WebDriver): Promise { + let entries: logging.Entry[]; + try { + entries = await driver.manage().logs().get(logging.Type.BROWSER); + } catch { + return []; + } + + return entries + .filter((e) => ['SEVERE', 'WARNING'].includes(e.level.name)) + .filter((e) => !IGNORED_PATTERNS.some((p) => p.test(e.message))) + .map((e) => ({ level: e.level.name, message: e.message, timestamp: e.timestamp })); +} + +/** Build driver with browser logging enabled (call instead of plain buildDriver when you need console monitoring). */ +export function browserLoggingPrefs(): logging.Preferences { + const prefs = new logging.Preferences(); + prefs.setLevel(logging.Type.BROWSER, logging.Level.WARNING); + return prefs; +} diff --git a/apps/e2e/src/helpers/db-config.ts b/apps/e2e/src/helpers/db-config.ts new file mode 100644 index 0000000..d6278d6 --- /dev/null +++ b/apps/e2e/src/helpers/db-config.ts @@ -0,0 +1,67 @@ +/** + * Read per-dialect DB config from environment variables. + * + * Variable naming convention (uppercase dialect, SOURCE or TARGET side): + * E2E__SOURCE_HOST + * E2E__SOURCE_PORT + * E2E__SOURCE_DB + * E2E__SOURCE_USER + * E2E__SOURCE_PASS + * E2E__SOURCE_SCHEMA + * + * Example for Postgres: + * E2E_POSTGRES_SOURCE_HOST=localhost + * E2E_POSTGRES_SOURCE_PORT=5432 + * E2E_POSTGRES_SOURCE_DB=mydb + * E2E_POSTGRES_SOURCE_USER=postgres + * E2E_POSTGRES_SOURCE_PASS=secret + * E2E_POSTGRES_SOURCE_SCHEMA=public + */ + +export interface DbConfig { + dialect: string; + host: string; + port: number; + database: string; + username: string; + password: string; + schema?: string; +} + +function readConfig(envPrefix: string, dialect: string): DbConfig | null { + const host = process.env[`${envPrefix}_HOST`]; + const db = process.env[`${envPrefix}_DB`]; + const user = process.env[`${envPrefix}_USER`]; + const pass = process.env[`${envPrefix}_PASS`]; + if (!host || !db || !user || !pass) return null; + return { + dialect, + host, + port: parseInt(process.env[`${envPrefix}_PORT`] ?? '0', 10) || defaultPort(dialect), + database: db, + username: user, + password: pass, + schema: process.env[`${envPrefix}_SCHEMA`], + }; +} + +function defaultPort(dialect: string): number { + const ports: Record = { + postgres: 5432, mysql: 3306, mariadb: 3306, sqlserver: 1433, + oracle: 1521, db2: 50000, sqlite: 0, azuresql: 1433, + clickhouse: 8123, redshift: 5439, + }; + return ports[dialect] ?? 5432; +} + +export function getSourceConfig(dialect: string): DbConfig | null { + return readConfig(`E2E_${dialect.toUpperCase()}_SOURCE`, dialect); +} + +export function getTargetConfig(dialect: string): DbConfig | null { + return readConfig(`E2E_${dialect.toUpperCase()}_TARGET`, dialect); +} + +export function hasConfig(dialect: string): boolean { + return getSourceConfig(dialect) !== null && getTargetConfig(dialect) !== null; +} diff --git a/apps/e2e/src/helpers/driver.ts b/apps/e2e/src/helpers/driver.ts new file mode 100644 index 0000000..e039403 --- /dev/null +++ b/apps/e2e/src/helpers/driver.ts @@ -0,0 +1,37 @@ +import { Builder, WebDriver, By, until, WebElement } from 'selenium-webdriver'; +import chrome from 'selenium-webdriver/chrome.js'; + +export const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; + +/** Build a Chrome WebDriver. Set HEADLESS=false to watch the browser. */ +export async function buildDriver(): Promise { + const opts = new chrome.Options(); + if (process.env.HEADLESS !== 'false') { + opts.addArguments('--headless=new', '--disable-gpu'); + } + opts.addArguments('--no-sandbox', '--disable-dev-shm-usage', '--window-size=1440,900'); + + return new Builder() + .forBrowser('chrome') + .setChromeOptions(opts) + .build(); +} + +/** Wait for an element to be visible and return it. */ +export async function waitFor(driver: WebDriver, locator: ReturnType, timeoutMs = 10_000): Promise { + return driver.wait(until.elementLocated(locator), timeoutMs); +} + +/** Wait for an element to be visible then click it. */ +export async function clickWhen(driver: WebDriver, locator: ReturnType): Promise { + const el = await waitFor(driver, locator); + await driver.wait(until.elementIsVisible(el), 5_000); + await el.click(); +} + +/** Type into an input after clearing it. */ +export async function fillInput(driver: WebDriver, locator: ReturnType, value: string): Promise { + const el = await waitFor(driver, locator); + await el.clear(); + await el.sendKeys(value); +} diff --git a/apps/e2e/src/helpers/screenshot.ts b/apps/e2e/src/helpers/screenshot.ts new file mode 100644 index 0000000..d809081 --- /dev/null +++ b/apps/e2e/src/helpers/screenshot.ts @@ -0,0 +1,15 @@ +import { WebDriver } from 'selenium-webdriver'; +import { writeFileSync, mkdirSync } from 'fs'; +import { join } from 'path'; + +const SCREENSHOT_DIR = join(process.cwd(), 'screenshots'); + +/** Save a PNG screenshot. Returns the file path. */ +export async function saveScreenshot(driver: WebDriver, name: string): Promise { + mkdirSync(SCREENSHOT_DIR, { recursive: true }); + const data = await driver.takeScreenshot(); + const safe = name.replace(/[^a-zA-Z0-9_-]/g, '_'); + const file = join(SCREENSHOT_DIR, `${safe}_${Date.now()}.png`); + writeFileSync(file, Buffer.from(data, 'base64')); + return file; +} diff --git a/apps/e2e/src/pages/AppPage.ts b/apps/e2e/src/pages/AppPage.ts new file mode 100644 index 0000000..a48fcc3 --- /dev/null +++ b/apps/e2e/src/pages/AppPage.ts @@ -0,0 +1,103 @@ +import { WebDriver, By, until } from 'selenium-webdriver'; +import { BASE_URL, waitFor, clickWhen } from '../helpers/driver.js'; + +/** + * Page object for the main FoxSchema comparison workspace. + * All selectors match the data-testid attributes set in the React components. + */ +export class AppPage { + constructor(private driver: WebDriver) {} + + async open(): Promise { + await this.driver.get(BASE_URL); + await waitFor(this.driver, By.css('[data-testid="toolbar"]')); + } + + // ── Source side ───────────────────────────────────────────────────────── + + async openSourceModal(): Promise { + await clickWhen(this.driver, By.css('[data-testid="source-config-btn"]')); + await waitFor(this.driver, By.css('[data-testid="conn-modal"]')); + } + + async isSourceConnected(): Promise { + const els = await this.driver.findElements(By.css('[data-testid="source-connected-btn"]')); + return els.length > 0; + } + + async waitForSourceConnected(timeoutMs = 15_000): Promise { + await this.driver.wait( + until.elementLocated(By.css('[data-testid="source-connected-btn"]')), + timeoutMs + ); + } + + // ── Target side ───────────────────────────────────────────────────────── + + async openTargetModal(): Promise { + await clickWhen(this.driver, By.css('[data-testid="target-config-btn"]')); + await waitFor(this.driver, By.css('[data-testid="conn-modal"]')); + } + + async isTargetConnected(): Promise { + const els = await this.driver.findElements(By.css('[data-testid="target-connected-btn"]')); + return els.length > 0; + } + + async waitForTargetConnected(timeoutMs = 15_000): Promise { + await this.driver.wait( + until.elementLocated(By.css('[data-testid="target-connected-btn"]')), + timeoutMs + ); + } + + // ── Comparison ───────────────────────────────────────────────────────── + + async runCompare(): Promise { + await clickWhen(this.driver, By.css('[data-testid="compare-btn"]')); + await this.driver.wait( + until.elementLocated(By.css('[data-testid="schema-tree"]')), + 30_000 + ); + } + + async getDiffCount(): Promise { + const items = await this.driver.findElements(By.css('[data-testid="diff-item"]')); + return items.length; + } + + async getDiffStatuses(): Promise<(string | null)[]> { + const items = await this.driver.findElements(By.css('[data-testid="diff-item"]')); + return Promise.all(items.map((el) => el.getAttribute('data-status'))); + } + + async isSchemaTreeVisible(): Promise { + try { + const el = await waitFor(this.driver, By.css('[data-testid="schema-tree"]'), 3_000); + return el.isDisplayed(); + } catch { + return false; + } + } + + // ── Banners ──────────────────────────────────────────────────────────── + + async isErrorBannerVisible(): Promise { + const els = await this.driver.findElements(By.css('[data-testid="error-banner"]')); + return els.length > 0 && els[0].isDisplayed(); + } + + async getErrorBannerText(): Promise { + const el = await waitFor(this.driver, By.css('[data-testid="error-banner"]')); + return el.getText(); + } + + async isWarningBannerVisible(): Promise { + const els = await this.driver.findElements(By.css('[data-testid="warning-banner"]')); + return els.length > 0 && els[0].isDisplayed(); + } + + async dismissWarnings(): Promise { + await clickWhen(this.driver, By.css('[data-testid="dismiss-warnings-btn"]')); + } +} diff --git a/apps/e2e/src/pages/ConnectionModal.ts b/apps/e2e/src/pages/ConnectionModal.ts new file mode 100644 index 0000000..45618bc --- /dev/null +++ b/apps/e2e/src/pages/ConnectionModal.ts @@ -0,0 +1,107 @@ +import { WebDriver, By, until } from 'selenium-webdriver'; +import { waitFor, clickWhen, fillInput } from '../helpers/driver.js'; + +export interface ConnectionFields { + dialect: string; + host: string; + port: number; + database: string; + username: string; + password: string; + schema?: string; +} + +/** + * Page object for the ConnectionModal. + * Assumes the modal is already open before calling any method. + */ +export class ConnectionModal { + constructor(private driver: WebDriver) {} + + async selectDialect(dialect: string): Promise { + const sel = await waitFor(this.driver, By.css('[data-testid="conn-dialect-select"]')); + await this.driver.executeScript( + `arguments[0].value = arguments[1]; arguments[0].dispatchEvent(new Event('change', {bubbles:true}))`, + sel, + dialect + ); + } + + async fillHost(value: string): Promise { + await fillInput(this.driver, By.css('[data-testid="conn-host-input"]'), value); + } + + async fillPort(value: number): Promise { + await fillInput(this.driver, By.css('[data-testid="conn-port-input"]'), String(value)); + } + + async fillDatabase(value: string): Promise { + await fillInput(this.driver, By.css('[data-testid="conn-database-input"]'), value); + } + + async fillUsername(value: string): Promise { + await fillInput(this.driver, By.css('[data-testid="conn-username-input"]'), value); + } + + async fillPassword(value: string): Promise { + await fillInput(this.driver, By.css('[data-testid="conn-password-input"]'), value); + } + + async loadSchemas(): Promise { + await clickWhen(this.driver, By.css('[data-testid="conn-load-schema-btn"]')); + // Wait for the API call to START (button goes disabled → testing banner appears). + // conn-schema-input exists before the click (empty state), so we can't use it as + // the signal. Instead wait for the testing status banner, then for it to resolve. + await this.driver.wait( + until.elementLocated(By.css('[data-testid="conn-test-testing"]')), + 8_000 + ); + // Now wait for success or failure — this is the actual completion signal. + await this.driver.wait( + until.elementLocated( + By.css('[data-testid="conn-test-success"], [data-testid="conn-test-failed"]') + ), + 25_000 + ); + } + + async selectSchema(schema: string): Promise { + const selects = await this.driver.findElements(By.css('[data-testid="conn-schema-select"]')); + if (selects.length > 0) { + await this.driver.executeScript( + `arguments[0].value = arguments[1]; arguments[0].dispatchEvent(new Event('change', {bubbles:true}))`, + selects[0], + schema + ); + } else { + await fillInput(this.driver, By.css('[data-testid="conn-schema-input"]'), schema); + } + } + + async save(): Promise { + await clickWhen(this.driver, By.css('[data-testid="conn-save-btn"]')); + // Modal closes on success + await this.driver.wait( + async () => { + const modals = await this.driver.findElements(By.css('[data-testid="conn-modal"]')); + return modals.length === 0; + }, + 10_000 + ); + } + + /** Fill all fields then save. */ + async connect(fields: ConnectionFields): Promise { + await this.selectDialect(fields.dialect); + await this.fillHost(fields.host); + await this.fillPort(fields.port); + await this.fillDatabase(fields.database); + await this.fillUsername(fields.username); + await this.fillPassword(fields.password); + await this.loadSchemas(); + if (fields.schema) { + await this.selectSchema(fields.schema); + } + await this.save(); + } +} diff --git a/apps/e2e/src/pages/MigrationPage.ts b/apps/e2e/src/pages/MigrationPage.ts new file mode 100644 index 0000000..7c9017d --- /dev/null +++ b/apps/e2e/src/pages/MigrationPage.ts @@ -0,0 +1,121 @@ +import { WebDriver, By, until } from 'selenium-webdriver'; +import { waitFor, clickWhen } from '../helpers/driver.js'; + +/** + * Page object covering the execute → progress → history flow. + */ +export class MigrationPage { + constructor(private driver: WebDriver) {} + + // ── Select-all checkbox in the schema tree panel ──────────────────────── + + /** Check/uncheck the global "Deploy to Target" checkbox in the tree header. */ + async selectAllObjects(check: boolean): Promise { + const cb = await waitFor(this.driver, By.css('[data-testid="schema-tree"] input[type="checkbox"]')); + const current = await cb.isSelected(); + if (current !== check) await cb.click(); + } + + // ── Execute button ────────────────────────────────────────────────────── + + async clickExecute(): Promise { + await clickWhen(this.driver, By.css('[data-testid="execute-btn"]')); + } + + async isExecuteEnabled(): Promise { + const btn = await waitFor(this.driver, By.css('[data-testid="execute-btn"]')); + const disabled = await btn.getAttribute('disabled'); + return disabled === null; + } + + // ── Confirm dialog ───────────────────────────────────────────────────── + + async isConfirmDialogVisible(): Promise { + const els = await this.driver.findElements(By.css('[data-testid="deploy-confirm-dialog"]')); + return els.length > 0 && els[0].isDisplayed(); + } + + async confirmDeploy(): Promise { + // Confirm dialog may or may not appear (user can suppress it). + const els = await this.driver.findElements(By.css('[data-testid="deploy-confirm-dialog"]')); + if (els.length > 0 && await els[0].isDisplayed()) { + await clickWhen(this.driver, By.css('[data-testid="deploy-confirm-btn"]')); + } + } + + // ── Migration progress panel ─────────────────────────────────────────── + + async waitForMigrationPanel(timeoutMs = 10_000): Promise { + await this.driver.wait( + until.elementLocated(By.css('[data-testid="migration-progress-panel"]')), + timeoutMs + ); + } + + /** Wait until migration finishes (complete or failed). Returns 'complete' | 'failed'. */ + async waitForMigrationDone(timeoutMs = 120_000): Promise<'complete' | 'failed'> { + await this.driver.wait( + until.elementLocated( + By.css('[data-testid="migration-complete"], [data-testid="migration-failed"]') + ), + timeoutMs + ); + const failed = await this.driver.findElements(By.css('[data-testid="migration-failed"]')); + return failed.length > 0 ? 'failed' : 'complete'; + } + + async getMigrationProgressItems(): Promise<{ object: string | null; status: string | null }[]> { + const items = await this.driver.findElements(By.css('[data-testid="migration-progress-item"]')); + return Promise.all(items.map(async (el) => ({ + object: await el.getAttribute('data-object'), + status: await el.getAttribute('data-status'), + }))); + } + + async getMigrationErrorText(): Promise { + try { + const panel = await waitFor(this.driver, By.css('[data-testid="migration-progress-panel"]'), 3_000); + return panel.getText(); + } catch { + return ''; + } + } + + // ── History ──────────────────────────────────────────────────────────── + + async openHistory(): Promise { + await clickWhen(this.driver, By.css('[data-testid="history-btn"]')); + await waitFor(this.driver, By.css('[data-testid="history-dialog"]')); + } + + async isHistoryVisible(): Promise { + const els = await this.driver.findElements(By.css('[data-testid="history-dialog"]')); + return els.length > 0 && els[0].isDisplayed(); + } + + async getHistoryRunCount(): Promise { + const items = await this.driver.findElements(By.css('[data-testid="history-run-item"]')); + return items.length; + } + + async getLatestRunStatus(): Promise { + const items = await this.driver.findElements(By.css('[data-testid="history-run-item"]')); + if (items.length === 0) return null; + return items[0].getAttribute('data-status'); + } + + async closeHistory(): Promise { + // Click outside the dialog panel to close it + await this.driver.findElement(By.css('[data-testid="history-dialog"]')).then(async (overlay) => { + // Click the close button inside the dialog header + const closeBtn = await this.driver.findElement( + By.css('[data-testid="history-dialog"] > div button:last-child') + ); + await closeBtn.click(); + }); + await this.driver.wait(async () => { + const els = await this.driver.findElements(By.css('[data-testid="history-dialog"]')); + return els.length === 0; + }, 5_000); + } +} diff --git a/apps/e2e/src/tests/auto-error-catch.test.ts b/apps/e2e/src/tests/auto-error-catch.test.ts new file mode 100644 index 0000000..4fad9c6 --- /dev/null +++ b/apps/e2e/src/tests/auto-error-catch.test.ts @@ -0,0 +1,99 @@ +/** + * Console error catcher — navigates through the app's main UI flows + * and asserts there are zero SEVERE browser console errors. + * + * No live database required. Requires the dev server running. + */ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { WebDriver, By } from 'selenium-webdriver'; +import { buildDriver, waitFor, clickWhen } from '../helpers/driver.js'; +import { getBrowserErrors } from '../helpers/console-monitor.js'; +import { saveScreenshot } from '../helpers/screenshot.js'; +import { AppPage } from '../pages/AppPage.js'; + +let driver: WebDriver; +let app: AppPage; + +beforeAll(async () => { + driver = await buildDriver(); + app = new AppPage(driver); +}); + +afterAll(async () => { + await driver?.quit(); +}); + +describe('UI smoke + console error checks', () => { + it('app boots and toolbar is visible', async () => { + await app.open(); + const toolbar = await waitFor(driver, By.css('[data-testid="toolbar"]')); + expect(await toolbar.isDisplayed()).toBe(true); + }); + + it('no SEVERE console errors on boot', async () => { + const errors = await getBrowserErrors(driver); + const severe = errors.filter((e) => e.level === 'SEVERE'); + if (severe.length > 0) { + await saveScreenshot(driver, 'auto_boot_severe_error'); + } + expect(severe, severe.map((e) => e.message).join('\n')).toHaveLength(0); + }); + + it('source config button opens modal', async () => { + await app.openSourceModal(); + const modal = await waitFor(driver, By.css('[data-testid="conn-modal"]')); + expect(await modal.isDisplayed()).toBe(true); + }); + + it('dialect selector has options', async () => { + const sel = await waitFor(driver, By.css('[data-testid="conn-dialect-select"]')); + const options = await sel.findElements(By.css('option')); + expect(options.length).toBeGreaterThan(5); + }); + + it('no SEVERE console errors after opening modal', async () => { + const errors = await getBrowserErrors(driver); + const severe = errors.filter((e) => e.level === 'SEVERE'); + if (severe.length > 0) { + await saveScreenshot(driver, 'auto_modal_severe_error'); + } + expect(severe, severe.map((e) => e.message).join('\n')).toHaveLength(0); + }); + + it('modal closes on cancel', async () => { + // Press Escape to close — the modal has a close button but ESC is simpler + await driver.findElement(By.css('[data-testid="conn-modal"]')); + // Click close button (X) + const closeBtn = await driver.findElement( + By.css('[data-testid="conn-modal"] button[title=""], [data-testid="conn-modal"] button.p-1') + ); + await closeBtn.click(); + // Modal should disappear + await driver.wait(async () => { + const modals = await driver.findElements(By.css('[data-testid="conn-modal"]')); + return modals.length === 0; + }, 5_000); + const modals = await driver.findElements(By.css('[data-testid="conn-modal"]')); + expect(modals).toHaveLength(0); + }); + + it('target config button opens modal', async () => { + await app.openTargetModal(); + const modal = await waitFor(driver, By.css('[data-testid="conn-modal"]')); + expect(await modal.isDisplayed()).toBe(true); + // Close it + const closeBtn = await driver.findElement( + By.css('[data-testid="conn-modal"] button.p-1') + ); + await closeBtn.click(); + }); + + it('no SEVERE console errors at end of smoke run', async () => { + const errors = await getBrowserErrors(driver); + const severe = errors.filter((e) => e.level === 'SEVERE'); + if (severe.length > 0) { + await saveScreenshot(driver, 'auto_end_severe_error'); + } + expect(severe, severe.map((e) => e.message).join('\n')).toHaveLength(0); + }); +}); diff --git a/apps/e2e/src/tests/compare-flow.test.ts b/apps/e2e/src/tests/compare-flow.test.ts new file mode 100644 index 0000000..5b2027e --- /dev/null +++ b/apps/e2e/src/tests/compare-flow.test.ts @@ -0,0 +1,23 @@ +/** + * Full compare + migrate + history test for Postgres (convenience shortcut). + * The dialect-specific suite in dialects/postgres.test.ts does the same thing + * but through the shared flow. This file exists as a quick sanity check that + * can be run alone: `npx vitest run src/tests/compare-flow.test.ts` + * + * Env vars (all required): + * E2E_POSTGRES_SOURCE_HOST / PORT / DB / USER / PASS / SCHEMA + * E2E_POSTGRES_TARGET_HOST / PORT / DB / USER / PASS / SCHEMA + */ +import { describe } from 'vitest'; +import { hasConfig, getSourceConfig, getTargetConfig } from '../helpers/db-config.js'; +import { runDialectFlow } from './dialects/shared-flow.js'; + +const DIALECT = 'postgres'; + +describe.skipIf(!hasConfig(DIALECT))('Full flow: Postgres compare → migrate → history', () => { + runDialectFlow( + DIALECT, + () => getSourceConfig(DIALECT)!, + () => getTargetConfig(DIALECT)! + ); +}); diff --git a/apps/e2e/src/tests/dialects/azuresql.test.ts b/apps/e2e/src/tests/dialects/azuresql.test.ts new file mode 100644 index 0000000..6b20401 --- /dev/null +++ b/apps/e2e/src/tests/dialects/azuresql.test.ts @@ -0,0 +1,13 @@ +import { describe } from 'vitest'; +import { hasConfig, getSourceConfig, getTargetConfig } from '../../helpers/db-config.js'; +import { runDialectFlow } from './shared-flow.js'; + +const DIALECT = 'azuresql'; + +describe.skipIf(!hasConfig(DIALECT))(`Compare flow: ${DIALECT}`, () => { + runDialectFlow( + DIALECT, + () => getSourceConfig(DIALECT)!, + () => getTargetConfig(DIALECT)! + ); +}); diff --git a/apps/e2e/src/tests/dialects/clickhouse.test.ts b/apps/e2e/src/tests/dialects/clickhouse.test.ts new file mode 100644 index 0000000..fc87c31 --- /dev/null +++ b/apps/e2e/src/tests/dialects/clickhouse.test.ts @@ -0,0 +1,13 @@ +import { describe } from 'vitest'; +import { hasConfig, getSourceConfig, getTargetConfig } from '../../helpers/db-config.js'; +import { runDialectFlow } from './shared-flow.js'; + +const DIALECT = 'clickhouse'; + +describe.skipIf(!hasConfig(DIALECT))(`Compare flow: ${DIALECT}`, () => { + runDialectFlow( + DIALECT, + () => getSourceConfig(DIALECT)!, + () => getTargetConfig(DIALECT)! + ); +}); diff --git a/apps/e2e/src/tests/dialects/db2.test.ts b/apps/e2e/src/tests/dialects/db2.test.ts new file mode 100644 index 0000000..fe58408 --- /dev/null +++ b/apps/e2e/src/tests/dialects/db2.test.ts @@ -0,0 +1,13 @@ +import { describe } from 'vitest'; +import { hasConfig, getSourceConfig, getTargetConfig } from '../../helpers/db-config.js'; +import { runDialectFlow } from './shared-flow.js'; + +const DIALECT = 'db2'; + +describe.skipIf(!hasConfig(DIALECT))(`Compare flow: ${DIALECT}`, () => { + runDialectFlow( + DIALECT, + () => getSourceConfig(DIALECT)!, + () => getTargetConfig(DIALECT)! + ); +}); diff --git a/apps/e2e/src/tests/dialects/mariadb.test.ts b/apps/e2e/src/tests/dialects/mariadb.test.ts new file mode 100644 index 0000000..19d7add --- /dev/null +++ b/apps/e2e/src/tests/dialects/mariadb.test.ts @@ -0,0 +1,13 @@ +import { describe } from 'vitest'; +import { hasConfig, getSourceConfig, getTargetConfig } from '../../helpers/db-config.js'; +import { runDialectFlow } from './shared-flow.js'; + +const DIALECT = 'mariadb'; + +describe.skipIf(!hasConfig(DIALECT))(`Compare flow: ${DIALECT}`, () => { + runDialectFlow( + DIALECT, + () => getSourceConfig(DIALECT)!, + () => getTargetConfig(DIALECT)! + ); +}); diff --git a/apps/e2e/src/tests/dialects/mssql.test.ts b/apps/e2e/src/tests/dialects/mssql.test.ts new file mode 100644 index 0000000..7f65fb8 --- /dev/null +++ b/apps/e2e/src/tests/dialects/mssql.test.ts @@ -0,0 +1,13 @@ +import { describe } from 'vitest'; +import { hasConfig, getSourceConfig, getTargetConfig } from '../../helpers/db-config.js'; +import { runDialectFlow } from './shared-flow.js'; + +const DIALECT = 'sqlserver'; + +describe.skipIf(!hasConfig(DIALECT))(`Compare flow: ${DIALECT}`, () => { + runDialectFlow( + DIALECT, + () => getSourceConfig(DIALECT)!, + () => getTargetConfig(DIALECT)! + ); +}); diff --git a/apps/e2e/src/tests/dialects/mysql.test.ts b/apps/e2e/src/tests/dialects/mysql.test.ts new file mode 100644 index 0000000..5e0e30b --- /dev/null +++ b/apps/e2e/src/tests/dialects/mysql.test.ts @@ -0,0 +1,13 @@ +import { describe } from 'vitest'; +import { hasConfig, getSourceConfig, getTargetConfig } from '../../helpers/db-config.js'; +import { runDialectFlow } from './shared-flow.js'; + +const DIALECT = 'mysql'; + +describe.skipIf(!hasConfig(DIALECT))(`Compare flow: ${DIALECT}`, () => { + runDialectFlow( + DIALECT, + () => getSourceConfig(DIALECT)!, + () => getTargetConfig(DIALECT)! + ); +}); diff --git a/apps/e2e/src/tests/dialects/oracle.test.ts b/apps/e2e/src/tests/dialects/oracle.test.ts new file mode 100644 index 0000000..9ea103f --- /dev/null +++ b/apps/e2e/src/tests/dialects/oracle.test.ts @@ -0,0 +1,13 @@ +import { describe } from 'vitest'; +import { hasConfig, getSourceConfig, getTargetConfig } from '../../helpers/db-config.js'; +import { runDialectFlow } from './shared-flow.js'; + +const DIALECT = 'oracle'; + +describe.skipIf(!hasConfig(DIALECT))(`Compare flow: ${DIALECT}`, () => { + runDialectFlow( + DIALECT, + () => getSourceConfig(DIALECT)!, + () => getTargetConfig(DIALECT)! + ); +}); diff --git a/apps/e2e/src/tests/dialects/postgres.test.ts b/apps/e2e/src/tests/dialects/postgres.test.ts new file mode 100644 index 0000000..ad885ca --- /dev/null +++ b/apps/e2e/src/tests/dialects/postgres.test.ts @@ -0,0 +1,13 @@ +import { describe } from 'vitest'; +import { hasConfig, getSourceConfig, getTargetConfig } from '../../helpers/db-config.js'; +import { runDialectFlow } from './shared-flow.js'; + +const DIALECT = 'postgres'; + +describe.skipIf(!hasConfig(DIALECT))(`Compare flow: ${DIALECT}`, () => { + runDialectFlow( + DIALECT, + () => getSourceConfig(DIALECT)!, + () => getTargetConfig(DIALECT)! + ); +}); diff --git a/apps/e2e/src/tests/dialects/redshift.test.ts b/apps/e2e/src/tests/dialects/redshift.test.ts new file mode 100644 index 0000000..abbf469 --- /dev/null +++ b/apps/e2e/src/tests/dialects/redshift.test.ts @@ -0,0 +1,13 @@ +import { describe } from 'vitest'; +import { hasConfig, getSourceConfig, getTargetConfig } from '../../helpers/db-config.js'; +import { runDialectFlow } from './shared-flow.js'; + +const DIALECT = 'redshift'; + +describe.skipIf(!hasConfig(DIALECT))(`Compare flow: ${DIALECT}`, () => { + runDialectFlow( + DIALECT, + () => getSourceConfig(DIALECT)!, + () => getTargetConfig(DIALECT)! + ); +}); diff --git a/apps/e2e/src/tests/dialects/shared-flow.ts b/apps/e2e/src/tests/dialects/shared-flow.ts new file mode 100644 index 0000000..99af420 --- /dev/null +++ b/apps/e2e/src/tests/dialects/shared-flow.ts @@ -0,0 +1,157 @@ +/** + * Full end-to-end flow for each dialect: + * 1. Boot & check no console errors + * 2. Connect source + target + * 3. Compare schemas + * 4. Execute migration (non-destructive — ADD/MODIFY only) + * 5. Wait for migration complete + * 6. Verify migration history shows a SUCCESS record + * + * Each dialect test file calls runDialectFlow(dialect, getSource, getTarget). + * The driver is managed here via beforeAll / afterAll. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { WebDriver } from 'selenium-webdriver'; +import { buildDriver } from '../../helpers/driver.js'; +import { saveScreenshot } from '../../helpers/screenshot.js'; +import { getBrowserErrors } from '../../helpers/console-monitor.js'; +import { AppPage } from '../../pages/AppPage.js'; +import { ConnectionModal } from '../../pages/ConnectionModal.js'; +import { MigrationPage } from '../../pages/MigrationPage.js'; +import type { DbConfig } from '../../helpers/db-config.js'; + +export function runDialectFlow( + dialectLabel: string, + getSource: () => DbConfig, + getTarget: () => DbConfig +): void { + let driver: WebDriver; + let app: AppPage; + let modal: ConnectionModal; + let migration: MigrationPage; + + beforeAll(async () => { + driver = await buildDriver(); + app = new AppPage(driver); + modal = new ConnectionModal(driver); + migration = new MigrationPage(driver); + }); + + afterAll(async () => { + await driver?.quit(); + }); + + // ── 1. Boot ───────────────────────────────────────────────────────────── + + it('app boots without console errors', async () => { + await app.open(); + const errors = (await getBrowserErrors(driver)).filter((e) => e.level === 'SEVERE'); + if (errors.length) await saveScreenshot(driver, `${dialectLabel}_boot_error`); + expect(errors, errors.map((e) => e.message).join('\n')).toHaveLength(0); + }); + + // ── 2. Connect ────────────────────────────────────────────────────────── + + it('connects source', async () => { + await app.openSourceModal(); + await modal.connect(getSource()); + await app.waitForSourceConnected(30_000); + expect(await app.isSourceConnected()).toBe(true); + }); + + it('connects target', async () => { + await app.openTargetModal(); + await modal.connect(getTarget()); + await app.waitForTargetConnected(30_000); + expect(await app.isTargetConnected()).toBe(true); + }); + + // ── 3. Compare ────────────────────────────────────────────────────────── + + it('runs schema comparison', async () => { + await app.runCompare(); + expect(await app.isSchemaTreeVisible()).toBe(true); + }); + + it('diff tree has at least one object', async () => { + const count = await app.getDiffCount(); + if (count === 0) await saveScreenshot(driver, `${dialectLabel}_empty_diff`); + // Warn but don't fail — schemas may already be in sync. + console.log(`[${dialectLabel}] diff object count: ${count}`); + expect(count).toBeGreaterThanOrEqual(0); + }); + + it('no SEVERE console errors after compare', async () => { + const errors = (await getBrowserErrors(driver)).filter((e) => e.level === 'SEVERE'); + if (errors.length) await saveScreenshot(driver, `${dialectLabel}_compare_error`); + expect(errors, errors.map((e) => e.message).join('\n')).toHaveLength(0); + }); + + // ── 4. Migrate ────────────────────────────────────────────────────────── + + it('execute button is present', async () => { + const diffItems = await app.getDiffCount(); + if (diffItems === 0) { + console.log(`[${dialectLabel}] No diff objects — skipping execute step`); + return; + } + // Select all objects first — the execute button stays disabled until at least + // one diff object is checked in. + await migration.selectAllObjects(true); + expect(await migration.isExecuteEnabled()).toBe(true); + }); + + it('executes migration (non-destructive)', async () => { + const diffItems = await app.getDiffCount(); + if (diffItems === 0) { + console.log(`[${dialectLabel}] No diff objects — skipping migration`); + return; + } + + // Objects already selected in the previous step; just execute. + await migration.clickExecute(); + // Confirm dialog may appear + await migration.confirmDeploy(); + // Wait for the progress panel + await migration.waitForMigrationPanel(15_000); + // Wait for completion (up to 2 min for large schemas) + const result = await migration.waitForMigrationDone(120_000); + + if (result === 'failed') { + await saveScreenshot(driver, `${dialectLabel}_migration_failed`); + const errText = await migration.getMigrationErrorText(); + console.error(`[${dialectLabel}] Migration failed:\n${errText}`); + } + + // Log per-object outcomes + const items = await migration.getMigrationProgressItems(); + const failed = items.filter((i) => i.status === 'FAILED'); + if (failed.length) { + console.warn(`[${dialectLabel}] Failed objects: ${failed.map((i) => i.object).join(', ')}`); + } + + expect(result, `Migration did not complete successfully`).toBe('complete'); + }); + + it('no SEVERE console errors after migration', async () => { + const errors = (await getBrowserErrors(driver)).filter((e) => e.level === 'SEVERE'); + if (errors.length) await saveScreenshot(driver, `${dialectLabel}_post_migrate_error`); + expect(errors, errors.map((e) => e.message).join('\n')).toHaveLength(0); + }); + + // ── 5. History ────────────────────────────────────────────────────────── + + it('migration history shows the run', async () => { + await migration.openHistory(); + expect(await migration.isHistoryVisible()).toBe(true); + + const count = await migration.getHistoryRunCount(); + expect(count, 'Expected at least one history record').toBeGreaterThan(0); + + const status = await migration.getLatestRunStatus(); + console.log(`[${dialectLabel}] latest history run status: ${status}`); + expect(status).toBe('SUCCESS'); + + await migration.closeHistory(); + }); +} diff --git a/apps/e2e/src/tests/dialects/sqlite.test.ts b/apps/e2e/src/tests/dialects/sqlite.test.ts new file mode 100644 index 0000000..50a86ce --- /dev/null +++ b/apps/e2e/src/tests/dialects/sqlite.test.ts @@ -0,0 +1,13 @@ +import { describe } from 'vitest'; +import { hasConfig, getSourceConfig, getTargetConfig } from '../../helpers/db-config.js'; +import { runDialectFlow } from './shared-flow.js'; + +const DIALECT = 'sqlite'; + +describe.skipIf(!hasConfig(DIALECT))(`Compare flow: ${DIALECT}`, () => { + runDialectFlow( + DIALECT, + () => getSourceConfig(DIALECT)!, + () => getTargetConfig(DIALECT)! + ); +}); diff --git a/apps/e2e/src/tests/smoke.test.ts b/apps/e2e/src/tests/smoke.test.ts new file mode 100644 index 0000000..02d8b3d --- /dev/null +++ b/apps/e2e/src/tests/smoke.test.ts @@ -0,0 +1,33 @@ +/** + * Smoke test: the app loads and the toolbar is present. + * No DB connection required — safe to run in CI with just the dev server. + */ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { WebDriver } from 'selenium-webdriver'; +import { buildDriver, BASE_URL } from '../helpers/driver.js'; +import { AppPage } from '../pages/AppPage.js'; + +let driver: WebDriver; + +beforeAll(async () => { + driver = await buildDriver(); +}); + +afterAll(async () => { + await driver?.quit(); +}); + +describe('App boot', () => { + it('loads the app at BASE_URL', async () => { + await driver.get(BASE_URL); + const title = await driver.getTitle(); + expect(title).toBeTruthy(); + }); + + it('renders the top toolbar', async () => { + const page = new AppPage(driver); + await page.open(); + const toolbar = await driver.findElement({ css: '[data-testid="toolbar"]' }); + expect(await toolbar.isDisplayed()).toBe(true); + }); +}); diff --git a/apps/e2e/tsconfig.json b/apps/e2e/tsconfig.json new file mode 100644 index 0000000..a6f5d1b --- /dev/null +++ b/apps/e2e/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist" + }, + "include": ["src/**/*", "vitest.config.ts"] +} diff --git a/apps/e2e/vitest.config.ts b/apps/e2e/vitest.config.ts new file mode 100644 index 0000000..8f0314f --- /dev/null +++ b/apps/e2e/vitest.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from 'vitest/config'; +import { readFileSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// Load .env from apps/e2e/ if present (local dev credentials for Docker DBs). +const envFile = join(__dirname, '.env'); +if (existsSync(envFile)) { + const raw = readFileSync(envFile, 'utf8'); + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq < 0) continue; + const key = trimmed.slice(0, eq).trim(); + const val = trimmed.slice(eq + 1).trim(); + if (!(key in process.env)) process.env[key] = val; + } +} + +export default defineConfig({ + test: { + // E2E tests launch real browsers — each test needs a generous timeout. + testTimeout: 90_000, + hookTimeout: 60_000, + // Run tests serially: one browser at a time avoids port conflicts. + fileParallelism: false, + reporters: ['verbose'], + }, +}); diff --git a/apps/web/package.json b/apps/web/package.json index 0ac49b5..33f1e9d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@foxschema/web", "private": true, - "version": "1.0.0", + "version": "0.0.1", "type": "module", "exports": { "./auth": "./src/backend/modules/auth.module.ts", diff --git a/apps/web/src/backend/api/routes.ts b/apps/web/src/backend/api/routes.ts index 21777ff..110077e 100644 --- a/apps/web/src/backend/api/routes.ts +++ b/apps/web/src/backend/api/routes.ts @@ -1,5 +1,5 @@ import { Router, Request, Response } from 'express'; -import { exec } from 'child_process'; +import { spawn } from 'child_process'; import { ConnectionModule, CompareModule, @@ -219,20 +219,22 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt try { const packageName = DriverDetector.getPackageName(dialect); - // Execute npm install with ignore-scripts so DB2 won't crash on compilation during general setup - exec(`pnpm add ${packageName} --ignore-scripts`, (error, stdout, stderr) => { - if (error) { - res.status(500).json({ - success: false, - error: error.message, - stderr: stderr - }); + // Use spawn with an explicit argument array instead of exec() with a template + // string — this prevents shell interpretation of packageName (command injection). + const proc = spawn('pnpm', ['add', packageName, '--ignore-scripts'], { stdio: 'pipe' }); + let stdout = ''; + let stderr = ''; + proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); }); + proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); + proc.on('close', (code: number | null) => { + if (code !== 0) { + res.status(500).json({ success: false, error: `pnpm exited with code ${code}`, stderr }); return; } - res.json({ - success: true, - stdout: stdout - }); + res.json({ success: true, stdout }); + }); + proc.on('error', (err: Error) => { + res.status(500).json({ success: false, error: err.message }); }); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Installation failed'; diff --git a/apps/web/src/frontend/App.tsx b/apps/web/src/frontend/App.tsx index 19b8637..d3374a7 100644 --- a/apps/web/src/frontend/App.tsx +++ b/apps/web/src/frontend/App.tsx @@ -18,14 +18,14 @@ const Workspace: React.FC = () => { {errorMsg && ( -
+
{errorMsg}
)} {warnings.length > 0 && ( -
+
{warnings.map((w, i) => ( @@ -33,6 +33,7 @@ const Workspace: React.FC = () => { ))}
{testingState.status !== 'idle' && ( -
= ({ Cancel ) : ( ) : (