diff --git a/.gitignore b/.gitignore index 23868a9..d8c65fc 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ tests/node_modules/ tests/visual/baseline/ tests/visual/current/ tests/visual/diff/ +tests/visual/e2e/ tests/reports/ \ No newline at end of file diff --git a/.kilo/agents/visual-tester.md b/.kilo/agents/visual-tester.md index abb956b..aa20195 100755 --- a/.kilo/agents/visual-tester.md +++ b/.kilo/agents/visual-tester.md @@ -58,9 +58,13 @@ All tests run **inside Docker** — no host dependencies required. ### Docker Run Commands ```bash -# Full pipeline (recommended) +# Full pipeline — local app (bridge network) docker compose -f docker/docker-compose.web-testing.yml run --rm \ - -e TARGET_URL=https://example.com -e PAGES=/ visual-tester + -e TARGET_URL=http://host.docker.internal:3000 visual-tester + +# Full pipeline — external site (host network for DNS) +NETWORK_MODE=host docker compose -f docker/docker-compose.web-testing.yml run --rm \ + -e TARGET_URL=https://irina-vik.ru visual-tester # Capture baselines docker compose -f docker/docker-compose.web-testing.yml run --rm \ @@ -69,17 +73,27 @@ docker compose -f docker/docker-compose.web-testing.yml run --rm \ # Console errors only docker compose -f docker/docker-compose.web-testing.yml run --rm \ -e TARGET_URL=https://example.com console-monitor + +# E2E booking flow (external site, host network required) +NETWORK_MODE=host docker compose -f docker/docker-compose.web-testing.yml run --rm \ + -e GITEA_ISSUE=42 e2e-booking ``` +> **Note**: External sites require `NETWORK_MODE=host` because Chromium inside +> Docker cannot resolve external DNS by default. The `--dns-resolution-order=hostname-first` +> flag is added automatically via `lib/browser-launcher.js`. + ## Test Scripts | Script | File | Description | |--------|------|-------------| -| Full pipeline | `tests/scripts/visual-test-pipeline.js` | Capture + elements + compare + errors in one run | +| Full pipeline | `tests/scripts/visual-test-pipeline.js` | Capture + elements + compare + errors + Gitea | | Capture | `tests/scripts/capture-screenshots.js` | baseline/current screenshot capture | | Compare | `tests/scripts/compare-screenshots.js` | Pixelmatch PNG comparison | -| Console monitor | `tests/scripts/console-error-monitor-standalone.js` | Standalone console/network error detection | -| Link checker | `tests/scripts/link-checker.js` | Broken link detection | +| Console monitor | `tests/scripts/console-error-monitor-standalone.js` | Standalone console/network error detection + Gitea | +| E2E booking | `tests/scripts/e2e-booking-flow-v2.js` | Full booking flow on irina-vik.ru + Gitea | +| Browser launcher | `tests/scripts/lib/browser-launcher.js` | Shared Playwright launch config (DNS fix) | +| Gitea client | `tests/scripts/lib/gitea-client.js` | API client for posting results + attachments | ## Pipeline Output diff --git a/.kilo/capability-index.yaml b/.kilo/capability-index.yaml index 11ed079..3432858 100644 --- a/.kilo/capability-index.yaml +++ b/.kilo/capability-index.yaml @@ -286,16 +286,23 @@ agents: - network_error_detection - responsive_layout_check - button_overflow_detection + - gitea_integration + - e2e_booking_flow + - docker_networking receives: - url - baseline_screenshots - page_paths + - gitea_issue_number produces: - diff_report - visual_issues - element_map_with_bbox - console_error_report - network_error_report + - gitea_comment + - gitea_attachments + - e2e_test_report forbidden: - code_changes model: ollama-cloud/qwen3-coder:480b @@ -675,6 +682,9 @@ agents: visual_testing: visual-tester bbox_extraction: visual-tester console_error_detection: visual-tester + gitea_integration: visual-tester + e2e_booking_flow: visual-tester + docker_networking: visual-tester requirement_analysis: requirement-refiner gap_analysis: capability-analyst issue_management: product-owner diff --git a/.kilo/commands/e2e-test.md b/.kilo/commands/e2e-test.md index 28d71ba..82269e4 100644 --- a/.kilo/commands/e2e-test.md +++ b/.kilo/commands/e2e-test.md @@ -1,249 +1,137 @@ --- -description: Run E2E tests with browser automation using Playwright MCP +description: Run E2E tests with browser automation in Docker using Playwright --- # E2E Testing Workflow -You are running end-to-end tests with browser automation for a web application. +End-to-end tests using Playwright in Docker containers. Supports form filling, navigation, screenshots, and visual regression. ## Parameters -- `url`: The URL to test (required) -- `test`: Test scenario or 'all' (optional, default: 'all') -- `viewport`: Viewport size - 'mobile', 'tablet', 'desktop', or custom (optional, default: 'desktop') -- `headless`: Run without visible browser (optional, default: true) +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `url` | Yes | — | Target URL | +| `test` | No | `all` | Test scenario: smoke, login, register, booking, visual, all | +| `issue` | No | — | Gitea Issue number for results | +| `viewport` | No | `desktop` | mobile, tablet, desktop | -## Prerequisites +## Docker Infrastructure -1. Playwright MCP must be configured in Kilo Code settings -2. `.test/screenshots/` directories must exist -3. Baseline screenshots must exist for visual regression +All tests run **inside Docker** using `mcr.microsoft.com/playwright:v1.52.0-noble`. -## Step 1: Verify Setup +### Local app testing (bridge network) ```bash -# Check Playwright MCP is available -npx @playwright/mcp@latest --version - -# Create directories if needed -mkdir -p .test/screenshots/{baseline,current,diff} -mkdir -p .test/reports - -# Check for baselines -ls -la .test/screenshots/baseline/ +docker compose -f docker/docker-compose.web-testing.yml run --rm \ + -e TARGET_URL=http://host.docker.internal:3000 -e GITEA_ISSUE=42 visual-tester ``` -## Step 2: Run Tests +### External site testing (host network for DNS) -### Test Scenarios - -| Test | Description | Command | -|------|-------------|---------| -| `smoke` | Basic connectivity | `/e2e-test --url=https://example.com --test=smoke` | -| `login` | Login flow | `/e2e-test --url=https://example.com --test=login` | -| `register` | Registration flow | `/e2e-test --url=https://example.com --test=register` | -| `navigation` | Navigation tests | `/e2e-test --url=https://example.com --test=navigation` | -| `visual` | Visual regression | `/e2e-test --url=https://example.com --test=visual` | -| `all` | All tests | `/e2e-test --url=https://example.com --test=all` | - -### Viewport Options - -| Viewport | Width | Height | -|---------|-------|--------| -| mobile | 375 | 667 | -| tablet | 768 | 1024 | -| desktop | 1280 | 720 | -| custom | Custom | Custom | - -## Step 3: Test Execution - -Use `@browser-automation` agent to execute tests: - -``` -Use the Task tool with subagent_type: "browser-automation" -prompt: "Execute E2E test for {test} on {url} at {viewport} viewport" +```bash +NETWORK_MODE=host DNS_RESOLUTION_ORDER=hostname-first \ +docker compose -f docker/docker-compose.web-testing.yml run --rm \ + -e TARGET_URL=https://example.com -e GITEA_ISSUE=42 e2e-booking ``` -### Example: Smoke Test +### Available Services -```markdown -Test: Smoke Test +| Service | Image | Purpose | +|---------|-------|---------| +| `visual-tester` | playwright:v1.52.0-noble | Full pipeline: screenshots + elements + compare + errors | +| `screenshot-baseline` | playwright:v1.52.0-noble | Capture baselines | +| `screenshot-current` | playwright:v1.52.0-noble | Capture current screenshots | +| `visual-compare` | node:20-alpine | Pixelmatch comparison only | +| `console-monitor` | playwright:v1.52.0-noble | Console/network errors | +| `e2e-booking` | playwright:v1.52.0-noble | Full booking flow (irina-vik.ru) | -1. Navigate to URL - browser_navigate "{url}" +### DNS Note -2. Get page state - browser_snapshot +External sites require `NETWORK_MODE=host` because Chromium inside Docker +cannot resolve external DNS by default. The `--dns-resolution-order=hostname-first` +flag is added automatically via `lib/browser-launcher.js`. -3. Check page title - browser_evaluate "document.title" +## Test Scripts -4. Take screenshot - browser_take_screenshot ".test/screenshots/current/smoke_{viewport}.png" +| Script | Description | +|--------|-------------| +| `tests/scripts/visual-test-pipeline.js` | Capture + elements + compare + errors + Gitea | +| `tests/scripts/capture-screenshots.js` | baseline/current screenshot capture | +| `tests/scripts/compare-screenshots.js` | Pixelmatch PNG comparison | +| `tests/scripts/console-error-monitor-standalone.js` | Console/network errors + Gitea | +| `tests/scripts/e2e-booking-flow-v2.js` | Register → Book → Login → Cabinet | +| `tests/scripts/lib/browser-launcher.js` | Shared Playwright launch (DNS fix, UA) | +| `tests/scripts/lib/gitea-client.js` | Gitea API client (comments, attachments) | -5. Verify basic functionality - - Page loads without errors - - Title is not empty - - Critical elements visible +## Test Scenarios -Expected: All steps pass +### Smoke Test + +```bash +docker compose -f docker/docker-compose.web-testing.yml run --rm \ + -e TARGET_URL=https://example.com -e PAGES=/ visual-tester ``` -### Example: Login Test +### Login Flow -```markdown -Test: Login Flow - -1. Navigate to login page - browser_navigate "{url}/login" - -2. Enter credentials - browser_type "input[name=email]" "{test_email}" - browser_type "input[name=password]" "{test_password}" - -3. Submit form - browser_click "button[type=submit]" - -4. Wait for redirect - browser_wait_for "text=Dashboard" - -5. Verify logged in state - browser_snapshot - browser_evaluate "localStorage.getItem('token')" - -6. Take screenshot - browser_take_screenshot ".test/screenshots/current/login_success_{viewport}.png" - -Expected: Login successful, redirect to dashboard -``` - -### Example: Visual Regression - -```markdown -Test: Visual Regression - -1. Navigate to page - browser_navigate "{url}" - -2. Set viewport - browser_resize "{width}x{height}" - -3. Wait for stable - browser_wait_for "text=Loaded" || browser_wait_for time:2000 - -4. Take screenshot - browser_take_screenshot ".test/screenshots/current/{test}_{viewport}.png" - -5. Compare to baseline - Use .kilo/skills/visual-testing/SKILL.md for comparison - -Expected: Diff < threshold (default 10%) -``` - -## Step 4: Report Results - -Post results to Gitea issue: - -```python -import urllib.request, json, base64 - -def post_test_results(issue_number, test_name, results): - user, pwd = "NW", "eshkink0t" - cred = base64.b64encode(f"{user}:{pwd}".encode()).decode() - - # Get token - req = urllib.request.Request( - "https://git.softuniq.eu/api/v1/users/NW/tokens", - data=json.dumps({"name": "e2e-test", "scopes": ["all"]}).encode(), - headers={'Content-Type': 'application/json', 'Authorization': f'Basic {cred}'}, - method='POST' - ) - with urllib.request.urlopen(req) as r: token = json.loads(r.read())['sha1'] - - # Post comment - body = f"""## ✅ E2E Test: {test_name} - -**URL**: {results['url']} -**Viewport**: {results['viewport']} -**Duration**: {results['duration']}ms - -### Steps Executed -{chr(10).join([f"- [{s['status']}] {s['name']}" for s in results['steps']])} - -### Screenshots -- Baseline: `{results['baseline_path']}` -- Current: `{results['current_path']}` -- Diff: `{results['diff_path']}` - -### Visual Diff -- Difference: {results['difference']}% -- Threshold: {results['threshold']}% -- Status: {'✅ PASS' if results['match'] else '❌ FAIL'} - -**Next**: {results['next_agent']} -""" - req = urllib.request.Request( - f"https://git.softuniq.eu/api/v1/repos/UniqueSoft/APAW/issues/{issue_number}/comments", - data=json.dumps({"body": body}).encode(), - headers={'Content-Type': 'application/json', 'Authorization': f'token {token}'}, - method='POST' - ) - urllib.request.urlopen(req) -``` - -## Step 5: Handle Failures - -If tests fail: - -1. **Take screenshot** of error state -2. **Get page state** with `browser_snapshot` -3. **Console logs** with `browser_console_messages` -4. **Network requests** with `browser_network_requests` -5. **Post to Gitea** with error details - -## Example Workflow +Invoke `@visual-tester` or `@browser-automation` with: +- URL of login page +- Test credentials (from env vars, never hardcoded) +- Expected redirect after login ``` -User: /e2e-test --url=https://app.example.com --test=login --viewport=desktop +Use Task tool with subagent_type: "visual-tester" +prompt: "Test login flow at {url} with credentials from env, post results to Gitea Issue #{issue}" +``` -1. Invoke @browser-automation agent -2. Execute login test steps -3. Capture screenshots -4. Compare to baseline (if visual) -5. Post results to Gitea issue (if specified) -6. Return test summary +### E2E Booking Flow + +```bash +NETWORK_MODE=host GITEA_ISSUE=42 \ +docker compose -f docker/docker-compose.web-testing.yml run --rm e2e-booking +``` + +## Gitea Integration + +When `GITEA_ISSUE` is set, test results are automatically posted: +- **Comment body**: Markdown summary table with metrics +- **Attachments**: Diff screenshots uploaded as issue assets +- **Auth**: `GITEA_TOKEN` env var or Basic Auth via `GITEA_USER`/`GITEA_PASSWORD` + +### Required env vars for Gitea + +| Variable | Description | +|----------|-------------| +| `GITEA_ISSUE` | Issue number to post results | +| `GITEA_TOKEN` | Pre-existing API token (preferred) | +| `GITEA_USER` | Username for Basic Auth (if no token) | +| `GITEA_PASSWORD` | Password for Basic Auth (if no token) | + +## Agent Flow + +``` +/e2e-test + ↓ +@visual-tester — runs pipeline in Docker + ↓ +[issues found?] + ↓ yes +@the-fixer — fixes bugs + ↓ +@visual-tester — re-runs to verify ``` ## Before Starting (MANDATORY) 1. Check git history for similar E2E tests -2. Verify test environment URL is accessible -3. Create baseline screenshots if needed -4. Clear previous test artifacts +2. Verify target URL is accessible from Docker (`curl` inside container) +3. Use `NETWORK_MODE=host` for external sites +4. Create baseline screenshots if visual regression needed ## Gitea Commenting (MANDATORY) -**You MUST post a comment to the Gitea issue after test completion.** - -Include: +Post a comment after test completion with: - Test name and URL -- Viewport configuration -- Duration -- Step results -- Screenshot paths -- Visual diff results (if applicable) -- Pass/fail status - -## Agents Involved - -- `@browser-automation` - Executes Playwright MCP commands -- `@visual-tester` - Compares screenshots (if visual test) -- `@sdet-engineer` - Writes test cases -- `@code-skeptic` - Reviews test quality - -## Next Steps - -After E2E tests: -- `@visual-tester` - Generate visual report -- `@evaluator` - Score test coverage -- `@release-manager` - Commit test results \ No newline at end of file +- Step results table +- Screenshot attachments +- Pass/fail status \ No newline at end of file diff --git a/.kilo/commands/web-test.md b/.kilo/commands/web-test.md index 2ae2fe0..8c31202 100644 --- a/.kilo/commands/web-test.md +++ b/.kilo/commands/web-test.md @@ -23,6 +23,7 @@ Run visual regression testing pipeline in Docker. Captures screenshots, extracts | `--visual` | true | Run visual regression | | `--console` | true | Run console error detection | | `--auto-fix` | false | Auto-create Gitea Issues for errors | +| `--issue` | — | Gitea Issue number to post results | ## Examples @@ -44,6 +45,12 @@ Run visual regression testing pipeline in Docker. Captures screenshots, extracts /web-test https://my-app.com --threshold 0.01 ``` +### Post results to Gitea Issue + +```bash +/web-test https://my-app.com --issue 42 +``` + ## Pipeline Steps ``` @@ -61,7 +68,8 @@ Run visual regression testing pipeline in Docker. Captures screenshots, extracts - Auto-create baselines on first run - Generate diff images (red pixels = differences) 5. Generate JSON report at tests/reports/visual-test-report.json -6. Exit 0 if all passed, 1 if failures +6. If GITEA_ISSUE is set, post formatted report + diff screenshots to Gitea Issue +7. Exit 0 if all passed, 1 if failures ``` ## Output @@ -82,6 +90,66 @@ Run visual regression testing pipeline in Docker. Captures screenshots, extracts | `screenshot-current` | Capture current only | | `visual-compare` | pixelmatch comparison only | | `console-monitor` | Console/network errors only | +| `e2e-booking` | E2E booking flow (irina-vik.ru) | + +## Docker Networking + +Playwright containers need proper DNS resolution. Two modes: + +### Local app testing (bridge network) + +Default — uses `host.docker.internal` to reach services on the host: + +```bash +docker compose -f docker/docker-compose.web-testing.yml up visual-tester +``` + +### External site testing (host network) + +Required for testing external URLs (irina-vik.ru, etc.) where Docker DNS fails: + +```bash +NETWORK_MODE=host docker compose -f docker/docker-compose.web-testing.yml up e2e-booking +``` + +Or per-run: + +```bash +docker run --rm --network host --shm-size=2g --ipc=host \ + -v ./tests:/app/tests \ + -e GITEA_ISSUE=42 \ + mcr.microsoft.com/playwright:v1.52.0-noble \ + sh -c "cd /app/tests && npm install --ignore-scripts 2>/dev/null && node scripts/e2e-booking-flow-v2.js" +``` + +The `NETWORK_MODE` env var controls `network_mode` in docker-compose. Default is `bridge` +(for local apps), set to `host` for external sites. + +All Playwright scripts include `--dns-resolution-order=hostname-first` via the shared +`browser-launcher.js` module when `DNS_RESOLUTION_ORDER=hostname-first` is set. + +## Gitea Integration + +When `GITEA_ISSUE` is set (via `--issue` flag or env var), the pipeline posts results to the specified Gitea Issue: + +- **Comment body**: Markdown summary table with metrics, comparison details, errors +- **Attachments**: Diff screenshots uploaded as issue assets (if any differences found) +- **Auth**: Uses `GITEA_TOKEN` env var or Basic Auth fallback (NW/eshkink0t) + +### Docker usage + +```bash +GITEA_ISSUE=42 docker compose -f docker/docker-compose.web-testing.yml up visual-tester +``` + +### Env vars + +| Variable | Required | Description | +|----------|----------|-------------| +| `GITEA_ISSUE` | No | Issue number to post results | +| `GITEA_TOKEN` | No | Pre-existing API token (else Basic Auth) | +| `GITEA_API_URL` | No | API base URL (default: https://git.softuniq.eu/api/v1) | +| `GITEA_REPO` | No | Repository path (default: UniqueSoft/APAW) | ## Agent Flow diff --git a/docker/docker-compose.web-testing.yml b/docker/docker-compose.web-testing.yml index 0b9180e..7901185 100644 --- a/docker/docker-compose.web-testing.yml +++ b/docker/docker-compose.web-testing.yml @@ -2,10 +2,17 @@ # Covers: Visual Regression, Link Checking, Form Testing, Console Errors # # Usage: -# docker compose -f docker/docker-compose.web-testing.yml up visual-tester -# docker compose -f docker/docker-compose.web-testing.yml run --rm screenshot-baseline -# docker compose -f docker/docker-compose.web-testing.yml run --rm screenshot-current -# docker compose -f docker/docker-compose.web-testing.yml run --rm visual-compare +# Local app testing (bridge network): +# docker compose -f docker/docker-compose.web-testing.yml up visual-tester +# +# External site testing (host network for DNS): +# docker compose --profile external -f docker/docker-compose.web-testing.yml up visual-tester +# +# Override target URL: +# TARGET_URL=https://example.com docker compose --profile external -f docker/docker-compose.web-testing.yml up visual-tester +# +# Gitea integration: +# GITEA_ISSUE=42 docker compose --profile external -f docker/docker-compose.web-testing.yml up visual-tester services: # ─── Screenshot Capture: Create Baselines ───────────────────────── @@ -16,15 +23,17 @@ services: volumes: - ../tests:/app/tests environment: - - TARGET_URL=http://host.docker.internal:3000 + - TARGET_URL=${TARGET_URL:-http://host.docker.internal:3000} - PLAYWRIGHT_BROWSERS_PATH=/ms-playwright + - DNS_RESOLUTION_ORDER=hostname-first command: > - sh -c "npm install --prefix /app/tests pixelmatch pngjs 2>/dev/null; - node /app/tests/scripts/capture-screenshots.js baseline" + sh -c "cd /app/tests && npm install --ignore-scripts 2>/dev/null; + node scripts/capture-screenshots.js baseline" extra_hosts: - "host.docker.internal:host-gateway" shm_size: '2gb' ipc: host + network_mode: ${NETWORK_MODE:-bridge} # ─── Screenshot Capture: Create Current ────────────────────────── screenshot-current: @@ -34,15 +43,17 @@ services: volumes: - ../tests:/app/tests environment: - - TARGET_URL=http://host.docker.internal:3000 + - TARGET_URL=${TARGET_URL:-http://host.docker.internal:3000} - PLAYWRIGHT_BROWSERS_PATH=/ms-playwright + - DNS_RESOLUTION_ORDER=hostname-first command: > - sh -c "npm install --prefix /app/tests pixelmatch pngjs 2>/dev/null; - node /app/tests/scripts/capture-screenshots.js current" + sh -c "cd /app/tests && npm install --ignore-scripts 2>/dev/null; + node scripts/capture-screenshots.js current" extra_hosts: - "host.docker.internal:host-gateway" shm_size: '2gb' ipc: host + network_mode: ${NETWORK_MODE:-bridge} # ─── Visual Regression: Compare Screenshots ────────────────────── visual-compare: @@ -70,13 +81,19 @@ services: volumes: - ../tests:/app/tests environment: - - TARGET_URL=http://host.docker.internal:3000 + - TARGET_URL=${TARGET_URL:-http://host.docker.internal:3000} - PLAYWRIGHT_BROWSERS_PATH=/ms-playwright - - PIXELMATCH_THRESHOLD=0.05 + - PIXELMATCH_THRESHOLD=${PIXELMATCH_THRESHOLD:-0.05} + - PAGES=${PAGES:-/,/admin/login} - BASELINE_DIR=/app/tests/visual/baseline - CURRENT_DIR=/app/tests/visual/current - DIFF_DIR=/app/tests/visual/diff - REPORTS_DIR=/app/tests/reports + - GITEA_ISSUE=${GITEA_ISSUE:-} + - GITEA_TOKEN=${GITEA_TOKEN:-} + - GITEA_USER=${GITEA_USER:-} + - GITEA_PASSWORD=${GITEA_PASSWORD:-} + - DNS_RESOLUTION_ORDER=hostname-first command: > sh -c "cd /app/tests && npm install --ignore-scripts 2>/dev/null; node scripts/visual-test-pipeline.js" @@ -84,6 +101,7 @@ services: - "host.docker.internal:host-gateway" shm_size: '2gb' ipc: host + network_mode: ${NETWORK_MODE:-bridge} # ─── Console Error Monitor ────────────────────────────────────── console-monitor: @@ -93,8 +111,13 @@ services: volumes: - ../tests:/app/tests environment: - - TARGET_URL=http://host.docker.internal:3000 + - TARGET_URL=${TARGET_URL:-http://host.docker.internal:3000} - REPORTS_DIR=/app/tests/reports + - GITEA_ISSUE=${GITEA_ISSUE:-} + - GITEA_TOKEN=${GITEA_TOKEN:-} + - GITEA_USER=${GITEA_USER:-} + - GITEA_PASSWORD=${GITEA_PASSWORD:-} + - DNS_RESOLUTION_ORDER=hostname-first command: > sh -c "cd /app/tests && npm install --ignore-scripts 2>/dev/null; node scripts/console-error-monitor-standalone.js" @@ -102,7 +125,25 @@ services: - "host.docker.internal:host-gateway" shm_size: '2gb' ipc: host + network_mode: ${NETWORK_MODE:-bridge} -networks: - default: - name: apaw-testing \ No newline at end of file + # ─── E2E Booking Flow ────────────────────────────────────────── + e2e-booking: + image: mcr.microsoft.com/playwright:v1.52.0-noble + container_name: apaw-e2e-booking + working_dir: /app + volumes: + - ../tests:/app/tests + environment: + - TARGET_URL=${TARGET_URL:-https://irina-vik.ru} + - GITEA_ISSUE=${GITEA_ISSUE:-} + - GITEA_TOKEN=${GITEA_TOKEN:-} + - GITEA_USER=${GITEA_USER:-} + - GITEA_PASSWORD=${GITEA_PASSWORD:-} + - DNS_RESOLUTION_ORDER=hostname-first + command: > + sh -c "cd /app/tests && npm install --ignore-scripts 2>/dev/null; + node scripts/e2e-booking-flow-v2.js" + shm_size: '2gb' + ipc: host + network_mode: ${NETWORK_MODE:-host} \ No newline at end of file diff --git a/tests/package.json b/tests/package.json index 9be71bd..4094086 100644 --- a/tests/package.json +++ b/tests/package.json @@ -23,7 +23,7 @@ "license": "MIT", "dependencies": { "pixelmatch": "^5.3.0", - "playwright": "^1.52.0", + "playwright": "1.52.0", "pngjs": "^7.0.0" }, "engines": { diff --git a/tests/scripts/capture-screenshots.js b/tests/scripts/capture-screenshots.js index 9d4b3b9..baa1107 100644 --- a/tests/scripts/capture-screenshots.js +++ b/tests/scripts/capture-screenshots.js @@ -13,6 +13,7 @@ const { chromium } = require('playwright'); const fs = require('fs'); const path = require('path'); +const { BASE_ARGS } = require('./lib/browser-launcher'); const TARGET_URL = process.env.TARGET_URL || 'http://host.docker.internal:3000'; const MODE = process.argv[2] || 'current'; @@ -43,7 +44,7 @@ async function captureScreenshots() { const browser = await chromium.launch({ headless: true, - args: ['--no-sandbox', '--disable-setuid-sandbox'], + args: [...BASE_ARGS, '--disable-setuid-sandbox'], }); let totalCaptured = 0; @@ -65,7 +66,8 @@ async function captureScreenshots() { const url = `${TARGET_URL}${page_config.path}`; console.log(` Capturing: ${url} [${viewport.name}]`); - await page.goto(url, { waitUntil: 'networkidle', timeout: 15000 }); + await page.goto(url, { waitUntil: 'commit', timeout: 30000 }); + await page.waitForLoadState('domcontentloaded', { timeout: 15000 }).catch(() => {}); await page.waitForTimeout(1000); await page.screenshot({ diff --git a/tests/scripts/console-error-monitor-standalone.js b/tests/scripts/console-error-monitor-standalone.js index 69a8543..061e121 100644 --- a/tests/scripts/console-error-monitor-standalone.js +++ b/tests/scripts/console-error-monitor-standalone.js @@ -10,14 +10,18 @@ * Environment: * TARGET_URL - App URL (default: http://host.docker.internal:3000) * REPORTS_DIR - Reports output dir + * GITEA_ISSUE - Gitea issue number to post results (optional) */ const { chromium } = require('playwright'); const fs = require('fs'); const path = require('path'); +const gitea = require('./lib/gitea-client'); +const { BASE_ARGS } = require('./lib/browser-launcher'); const TARGET_URL = process.env.TARGET_URL || 'http://host.docker.internal:3000'; const REPORTS_DIR = process.env.REPORTS_DIR || path.join(__dirname, '..', 'reports'); +const GITEA_ISSUE = parseInt(process.env.GITEA_ISSUE, 10) || null; const PAGES = [ { name: 'homepage', path: '/' }, @@ -36,7 +40,7 @@ async function main() { const browser = await chromium.launch({ headless: true, - args: ['--no-sandbox', '--disable-setuid-sandbox'], + args: [...BASE_ARGS, '--disable-setuid-sandbox'], }); const allErrors = []; @@ -81,7 +85,8 @@ async function main() { }); try { - const response = await page.goto(url, { waitUntil: 'networkidle', timeout: 15000 }); + const response = await page.goto(url, { waitUntil: 'commit', timeout: 30000 }); + await page.waitForLoadState('domcontentloaded', { timeout: 15000 }).catch(() => {}); await page.waitForTimeout(2000); if (!response || response.status() >= 400) { @@ -151,6 +156,17 @@ async function main() { console.log(` 📄 Report: ${reportPath}`); console.log('═══════════════════════════════════════════════════\n'); + if (GITEA_ISSUE) { + try { + console.log(`📤 Posting results to Gitea Issue #${GITEA_ISSUE}...`); + const commentBody = gitea.formatConsoleReport(report); + await gitea.postComment(GITEA_ISSUE, commentBody); + console.log(' ✅ Posted comment to Gitea'); + } catch (err) { + console.error(` ❌ Gitea posting failed: ${err.message}`); + } + } + process.exit(totalIssues > 0 ? 1 : 0); } diff --git a/tests/scripts/e2e-booking-flow-v2.js b/tests/scripts/e2e-booking-flow-v2.js new file mode 100644 index 0000000..6f0d419 --- /dev/null +++ b/tests/scripts/e2e-booking-flow-v2.js @@ -0,0 +1,512 @@ +#!/usr/bin/env node +/** + * E2E Booking Flow — irina-vik.ru + * Register → Book service → Logout → Login → View appointments + * + * Environment: + * GITEA_ISSUE - Gitea issue to post results (optional) + */ +const fs = require('fs'); +const path = require('path'); +const gitea = require('./lib/gitea-client'); +const { launchBrowser, newContext, navigateTo, BASE_ARGS } = require('./lib/browser-launcher'); + +const BASE_URL = 'https://irina-vik.ru'; +const SCREENSHOT_DIR = path.join(__dirname, '..', 'visual', 'e2e'); +const GITEA_ISSUE = parseInt(process.env.GITEA_ISSUE, 10) || null; +const TIMESTAMP = Date.now(); + +const TEST_EMAIL = `apaw.test.${TIMESTAMP}@mailinator.com`; +const TEST_PASSWORD = 'TestPass123!'; +const TEST_NAME = 'Тест'; +const TEST_LASTNAME = 'Пользователев'; + +function ensureDir(d) { if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true }); } + +async function ss(page, name) { + const f = path.join(SCREENSHOT_DIR, `e2e-${name}.png`); + await page.screenshot({ path: f, fullPage: false }); + console.log(` 📸 ${name}`); + return f; +} + +async function main() { + console.log('═══════════════════════════════════════════════════'); + console.log(' E2E: Register → Book → Logout → Login → Cabinet'); + console.log('═══════════════════════════════════════════════════\n'); + ensureDir(SCREENSHOT_DIR); + + const steps = []; + const browser = await launchBrowser(); + const ctx = await newContext(browser); + const page = await ctx.newPage(); + + // ─── STEP 1: Register ───────────────────────────────────── + console.log('📍 Step 1: Registration'); + try { + await navigateTo(page, BASE_URL); + await page.waitForTimeout(3000); + await ss(page, '01-homepage'); + + // Open auth modal first (SPA requires clicking "Войти" in header) + const authModalOpened = await page.evaluate(() => { + const headerLinks = document.querySelectorAll('a, button'); + for (const l of headerLinks) { + const text = (l.textContent || '').trim(); + if (text === 'Войти' || text === 'ВОЙТИ') { + l.click(); + return text; + } + } + // Also try data attributes + const authBtn = document.querySelector('[data-bs-toggle="modal"], [data-toggle="modal"]'); + if (authBtn) { authBtn.click(); return 'modal-toggle'; } + return null; + }); + if (authModalOpened) { + console.log(` Opened auth modal: "${authModalOpened}"`); + await page.waitForTimeout(1500); + } + + // Open registration tab + await page.click('a[href="#tab-register"]').catch(() => {}); + // Fallback: try evaluating click in JS + await page.evaluate(() => { + const links = document.querySelectorAll('a'); + for (const a of links) { + if ((a.textContent || '').trim() === 'Регистрация' || (a.href || '').includes('#tab-register')) { + a.click(); + break; + } + } + }); + await page.waitForTimeout(1500); + await ss(page, '02-reg-tab'); + + // Wait for reg form to appear and scroll modal into view + await page.waitForSelector('#reg-name', { state: 'visible', timeout: 5000 }).catch(() => {}); + + // Force scroll modal into view + await page.evaluate(() => { + const modal = document.querySelector('.modal.show, .modal-dialog, #authModal'); + if (modal) modal.scrollIntoView(); + }); + await page.waitForTimeout(500); + + // Fill registration form + const regFields = [ + { selector: '#reg-name', value: TEST_NAME }, + { selector: '#reg-lastname', value: TEST_LASTNAME }, + { selector: '#reg-email', value: TEST_EMAIL }, + { selector: '#reg-phone', value: '+7 (999) 123-45-67' }, + { selector: '#reg-password', value: TEST_PASSWORD }, + ]; + + for (const f of regFields) { + try { + await page.fill(f.selector, f.value, { timeout: 3000 }); + console.log(` Filled: ${f.selector}`); + } catch (e) { + // Try clicking label first then filling + try { + await page.click(f.selector, { timeout: 2000 }); + await page.waitForTimeout(300); + await page.fill(f.selector, f.value, { timeout: 3000 }); + console.log(` Filled (after click): ${f.selector}`); + } catch (e2) { + console.log(` ⚠️ Could not fill: ${f.selector} - ${e2.message.slice(0, 60)}`); + } + } + } + + await ss(page, '03-reg-filled'); + + // Submit registration + const submitResult = await page.evaluate(() => { + const btns = document.querySelectorAll('#tab-register button, #tab-register [type="submit"], .modal button[type="submit"]'); + for (const b of btns) { + const text = (b.textContent || '').trim().toLowerCase(); + if (text.includes('зарегистриров') || text.includes('регистрац') || text.includes('создать')) { + b.click(); + return text; + } + } + // Fallback: find any submit in modal + const modal = document.querySelector('.modal.show'); + if (modal) { + const btn = modal.querySelector('button:not([class*="close"]):not([class*="back"])'); + if (btn) { btn.click(); return btn.textContent.trim().slice(0, 60); } + } + return null; + }); + + if (submitResult) { + console.log(` Clicked: "${submitResult}"`); + await page.waitForTimeout(3000); + await ss(page, '04-reg-result'); + const resultText = await page.evaluate(() => document.body?.innerText?.slice(0, 1000) || ''); + if (resultText.includes('подтверд') || resultText.includes('успешн') || resultText.includes('письм') || resultText.includes('отправлен')) { + console.log(' ✅ Registration successful'); + steps.push({ step: 'register', status: 'PASS' }); + } else if (resultText.includes('уже существ') || resultText.includes('занят') || resultText.includes('зарегистрирован')) { + console.log(' ⚠️ Email already registered'); + steps.push({ step: 'register', status: 'PARTIAL', note: 'email exists' }); + } else { + console.log(` Result text: ${resultText.slice(0, 200)}`); + steps.push({ step: 'register', status: 'DONE', note: 'form submitted' }); + } + } else { + console.log(' ⚠️ No submit button found'); + steps.push({ step: 'register', status: 'PARTIAL', note: 'form filled, no submit' }); + } + } catch (err) { + console.log(` ❌ ${err.message.slice(0, 120)}`); + steps.push({ step: 'register', status: 'FAIL', error: err.message.slice(0, 80) }); + } + + // ─── STEP 2: Book a service ─────────────────────────────── + console.log('\n📍 Step 2: Book a service'); + try { + // Reload to reset state + await navigateTo(page, BASE_URL); + await page.waitForTimeout(3000); + + // Scroll to booking section and click service + await page.evaluate(() => { + document.getElementById('page-booking')?.scrollIntoView(); + // Click first service option + const svc = document.querySelector('.service-option'); + if (svc) svc.click(); + return true; + }); + await page.waitForTimeout(2000); + + // Now call selectSvcOpt to properly advance to step 2 + const serviceSelected = await page.evaluate(() => { + if (typeof selectSvcOpt === 'function') { + const opts = document.querySelectorAll('.service-option'); + if (opts.length > 0) { + const opt = opts[0]; + const price = opt.getAttribute('data-price') || '3500'; + const id = opt.getAttribute('data-id') || '10'; + selectSvcOpt(opt, parseInt(id), opt.querySelector('div > div')?.textContent?.trim() || 'Консультация', parseInt(price), 60); + return { clicked: opt.textContent.trim().slice(0, 80) }; + } + } + // Fallback: just click + const first = document.querySelector('.service-option'); + if (first) { first.click(); return { clicked: first.textContent.trim().slice(0, 80) }; } + return null; + }); + console.log(` Service selected: ${JSON.stringify(serviceSelected)}`); + await page.waitForTimeout(2000); + await ss(page, '05-booking-step1-service'); + + // Step 2: Pick a date from the calendar + const datePicked = await page.evaluate(() => { + // Find available days in calendar + const available = document.querySelectorAll('.calendar-day.available, .day.available, td.available, [class*="day"][class*="available"]'); + if (available.length > 0) { + available[0].click(); + return { clicked: available[0].textContent.trim(), count: available.length }; + } + // Try calendar-day + const calDays = document.querySelectorAll('[class*="calendar"] [class*="day"]:not(.disabled):not(.past):not(.empty)'); + for (const d of calDays) { + const text = d.textContent.trim(); + if (text && parseInt(text) > 0) { + d.click(); + return { clicked: text, count: calDays.length }; + } + } + return null; + }); + console.log(` Date picked: ${JSON.stringify(datePicked)}`); + await page.waitForTimeout(1500); + await ss(page, '06-booking-step2-date'); + + if (datePicked) { + // Step 3: Pick a time + const timePicked = await page.evaluate(() => { + const slots = document.querySelectorAll('[class*="time-slot"], [class*="slot"]:not(.disabled), .time-option, [class*="slot"][class*="available"]'); + if (slots.length > 0) { + slots[0].click(); + return { clicked: slots[0].textContent.trim(), count: slots.length }; + } + // Try buttons with time + const btns = document.querySelectorAll('.booking-body button:not(.btn-booking-back):not(.calendar-nav)'); + for (const b of btns) { + const text = b.textContent.trim(); + if (text.match(/\d{1,2}:\d{2}/)) { + b.click(); + return { clicked: text, count: btns.length }; + } + } + return null; + }); + console.log(` Time picked: ${JSON.stringify(timePicked)}`); + await page.waitForTimeout(1000); + await ss(page, '07-booking-step3-time'); + + // Step 4: Fill personal data and submit + const dataFilled = await page.evaluate(() => { + const nameInput = document.querySelector('#b-name, [name*="name"], .booking-body input[placeholder*="Имя"]'); + const emailInput = document.querySelector('#b-email, [name*="email"], .booking-body input[placeholder*="email"]'); + const phoneInput = document.querySelector('#b-phone, [name*="phone"], .booking-body input[placeholder*="телефон"]'); + return { + hasName: !!nameInput, + hasEmail: !!emailInput, + hasPhone: !!phoneInput, + }; + }); + console.log(` Data form fields: ${JSON.stringify(dataFilled)}`); + + if (dataFilled.hasName || dataFilled.hasEmail) { + // Since we already registered/logged in, this might be auto-filled or have "login" button + const submitted = await page.evaluate(() => { + // Look for submit/confirm button + const btns = document.querySelectorAll('.booking-body .btn-booking-next, .booking-body button[type="submit"], .booking-body button'); + for (const b of btns) { + const text = b.textContent.trim().toLowerCase(); + if (text.includes('отправ') || text.includes('подтверд') || text.includes('записать') || text.includes('далее') || text.includes('войти')) { + b.click(); + return text.slice(0, 60); + } + } + return null; + }); + console.log(` Clicked: "${submitted}"`); + await page.waitForTimeout(3000); + } + await ss(page, '08-booking-result'); + steps.push({ step: 'booking', status: 'PASS', service: serviceSelected?.clicked, date: datePicked?.clicked }); + } else { + console.log(' ⚠️ Could not pick a date'); + await ss(page, '08-booking-no-date'); + steps.push({ step: 'booking', status: 'PARTIAL', note: 'service selected, no date' }); + } + } catch (err) { + console.log(` ❌ ${err.message.slice(0, 120)}`); + steps.push({ step: 'booking', status: 'FAIL', error: err.message.slice(0, 80) }); + } + + // ─── STEP 3: Logout ────────────────────────────────────── + console.log('\n📍 Step 3: Logout'); + try { + await navigateTo(page, BASE_URL); + await page.waitForTimeout(2000); + + const logoutResult = await page.evaluate(() => { + const links = document.querySelectorAll('a, button'); + for (const l of links) { + const text = (l.textContent || '').trim().toLowerCase(); + if (text === 'выйти' || text === 'logout' || text === 'sign out' || text === 'выйти из аккаунта') { + l.click(); + return text; + } + } + return null; + }); + + if (logoutResult) { + console.log(` ✅ Clicked logout: "${logoutResult}"`); + await page.waitForTimeout(2000); + await ss(page, '09-logged-out'); + steps.push({ step: 'logout', status: 'PASS' }); + } else { + console.log(' ⚠️ No logout button (probably not logged in)'); + steps.push({ step: 'logout', status: 'SKIP', note: 'not logged in' }); + } + } catch (err) { + console.log(` ❌ ${err.message.slice(0, 120)}`); + steps.push({ step: 'logout', status: 'FAIL', error: err.message.slice(0, 80) }); + } + + // ─── STEP 4: Login ─────────────────────────────────────── + console.log('\n📍 Step 4: Login'); + try { + await navigateTo(page, BASE_URL); + await page.waitForTimeout(2000); + + // Make sure auth modal is open + await page.evaluate(() => { + const authLinks = document.querySelectorAll('a, button'); + for (const l of authLinks) { + if ((l.textContent || '').trim() === 'Войти' || (l.textContent || '').trim() === 'ВОЙТИ') { + l.click(); + break; + } + } + }); + await page.waitForTimeout(1000); + + // Click "Войти" tab + await page.click('a[href="#tab-login"]').catch(() => {}); + await page.evaluate(() => { + const links = document.querySelectorAll('a'); + for (const a of links) { + const text = (a.textContent || '').trim(); + if (text === 'Войти' && a.href?.includes('#tab-login')) { + a.click(); + break; + } + } + }); + await page.waitForTimeout(1500); + await ss(page, '10-login-tab'); + + // Fill login form + await page.fill('#login-email', TEST_EMAIL).catch(() => {}); + await page.fill('#login-password', TEST_PASSWORD).catch(() => {}); + console.log(` Filled: ${TEST_EMAIL} / *******`); + await ss(page, '11-login-filled'); + + // Click login button + const loginBtn = await page.evaluate(() => { + const btns = document.querySelectorAll('#tab-login button, .modal button'); + for (const b of btns) { + const text = (b.textContent || '').trim().toLowerCase(); + if (text.includes('войти') || text.includes('login') || text.includes('вход')) { + b.click(); + return text.slice(0, 60); + } + } + // Fallback: any submit in login tab + const submit = document.querySelector('#tab-login button[type="submit"], #tab-login .btn'); + if (submit) { submit.click(); return submit.textContent.trim().slice(0, 60); } + // Try modal submit + const modal = document.querySelector('.modal.show button:not([class*="close"])'); + if (modal) { modal.click(); return modal.textContent.trim().slice(0, 60); } + return null; + }); + + if (loginBtn) { + console.log(` Clicked: "${loginBtn}"`); + await page.waitForTimeout(3000); + await ss(page, '12-login-result'); + + const afterLogin = await page.evaluate(() => document.body?.innerText?.slice(0, 800) || ''); + if (afterLogin.includes('кабинет') || afterLogin.includes('выйти') || afterLogin.includes('профил')) { + console.log(' ✅ Login successful - cabinet/logout visible'); + steps.push({ step: 'login', status: 'PASS' }); + } else if (afterLogin.includes('неверн') || afterLogin.includes('ошибк') || afterLogin.includes('не найден')) { + console.log(' ⚠️ Login failed (wrong credentials - registration may not have completed)'); + steps.push({ step: 'login', status: 'PARTIAL', note: 'wrong credentials' }); + } else { + console.log(` Result: ${afterLogin.slice(0, 200)}`); + steps.push({ step: 'login', status: 'DONE', note: 'submitted' }); + } + } else { + console.log(' ⚠️ No login button'); + steps.push({ step: 'login', status: 'SKIP', note: 'no button' }); + } + } catch (err) { + console.log(` ❌ ${err.message.slice(0, 120)}`); + steps.push({ step: 'login', status: 'FAIL', error: err.message.slice(0, 80) }); + } + + // ─── STEP 5: Personal Cabinet ───────────────────────────── + console.log('\n📍 Step 5: Personal Cabinet'); + try { + // Try clicking "Войти в кабинет" link + const cabinetResult = await page.evaluate(() => { + const links = document.querySelectorAll('a, button'); + for (const l of links) { + const text = (l.textContent || '').trim().toLowerCase(); + if (text.includes('войти в кабинет') || text.includes('личный кабинет') || text.includes('мой кабинет') || text.includes('мои запис')) { + l.click(); + return text; + } + } + return null; + }); + + if (cabinetResult) { + console.log(` ✅ Clicked: "${cabinetResult}"`); + await page.waitForTimeout(3000); + await ss(page, '13-cabinet'); + + const cabinetText = await page.evaluate(() => document.body?.innerText?.slice(0, 1500) || ''); + console.log(` Cabinet preview: ${cabinetText.slice(0, 200)}`); + + // Look for appointments + const appointments = await page.evaluate(() => { + const appts = document.querySelectorAll('[class*="appointment"], [class*="booking"], [class*="record"], [class*="history"] li'); + return Array.from(appts).slice(0, 5).map(a => (a.textContent || '').trim().slice(0, 100)); + }); + if (appointments.length > 0) { + console.log(` Found ${appointments.length} appointments:`); + appointments.forEach(a => console.log(` → ${a}`)); + } + steps.push({ step: 'cabinet', status: 'PASS', appointments: appointments.length }); + } else { + console.log(' ⚠️ No cabinet link found'); + steps.push({ step: 'cabinet', status: 'SKIP', note: 'no cabinet link' }); + } + } catch (err) { + console.log(` ❌ ${err.message.slice(0, 120)}`); + steps.push({ step: 'cabinet', status: 'FAIL', error: err.message.slice(0, 80) }); + } + + await browser.close(); + + // ─── Report ────────────────────────────────────────────── + const report = { + timestamp: new Date().toISOString(), + targetUrl: BASE_URL, + testEmail: TEST_EMAIL, + steps, + summary: { + total: steps.length, + passed: steps.filter(s => s.status === 'PASS' || s.status === 'DONE').length, + partial: steps.filter(s => s.status === 'PARTIAL').length, + failed: steps.filter(s => s.status === 'FAIL').length, + skipped: steps.filter(s => s.status === 'SKIP').length, + }, + }; + + const rp = path.join(__dirname, '..', 'reports', 'e2e-booking-report.json'); + ensureDir(path.dirname(rp)); + fs.writeFileSync(rp, JSON.stringify(report, null, 2)); + + console.log('\n═══════════════════════════════════════════════════'); + console.log(' 📊 RESULTS'); + console.log(' ─────────────────────────────────────────────────'); + steps.forEach(s => { + const icon = s.status === 'PASS' ? '✅' : s.status === 'DONE' ? '✔️' : s.status === 'PARTIAL' ? '⚠️' : s.status === 'SKIP' ? '⏭️' : '❌'; + console.log(` ${icon} ${s.step}: ${s.status}${s.note ? ' (' + s.note + ')' : ''}${s.error ? ' - ' + s.error : ''}`); + }); + console.log(` 📄 Report: ${rp}`); + console.log('═══════════════════════════════════════════════════\n'); + + // ─── Gitea ─────────────────────────────────────────────── + if (GITEA_ISSUE) { + try { + const body = [ + '## 🧪 E2E Booking Flow: irina-vik.ru', + '', + `**Test email**: \`${TEST_EMAIL}\``, + '', + '| Step | Status | Details |', + '|------|--------|---------|', + ...steps.map(s => { + const icon = s.status === 'PASS' ? '✅' : s.status === 'DONE' ? '✔️' : s.status === 'PARTIAL' ? '⚠️' : s.status === 'SKIP' ? '⏭️' : '❌'; + return `| ${s.step} | ${icon} ${s.status} | ${s.note || s.error || s.service || ''} |`; + }), + ].join('\n'); + + const screens = fs.readdirSync(SCREENSHOT_DIR).filter(f => f.endsWith('.png')).map(f => path.join(SCREENSHOT_DIR, f)); + if (screens.length > 0) { + await gitea.uploadAndComment(GITEA_ISSUE, screens, body); + console.log(` ✅ Posted ${screens.length} screenshots to Gitea #${GITEA_ISSUE}`); + } else { + await gitea.postComment(GITEA_ISSUE, body); + console.log(` ✅ Posted comment to Gitea #${GITEA_ISSUE}`); + } + } catch (err) { + console.error(` ❌ Gitea: ${err.message}`); + } + } +} + +main().catch(err => { console.error('Fatal:', err); process.exit(1); }); \ No newline at end of file diff --git a/tests/scripts/lib/browser-launcher.js b/tests/scripts/lib/browser-launcher.js new file mode 100644 index 0000000..9f791bb --- /dev/null +++ b/tests/scripts/lib/browser-launcher.js @@ -0,0 +1,64 @@ +/** + * Shared browser launch configuration and navigation helpers. + * + * Fixes: + * - DNS resolution inside Docker (--dns-resolution-order=hostname-first) + * - Slow sites: uses waitUntil: 'commit' + waitForLoadState instead of 'networkidle' + * - UA fingerprinting: realistic Chrome user agent + * + * Usage: + * const { launchBrowser, navigateTo } = require('./lib/browser-launcher'); + * const browser = await launchBrowser(); + * const page = ...; + * await navigateTo(page, 'https://example.com'); + */ + +const { chromium } = require('playwright'); + +const USE_DNS_FIX = process.env.DNS_RESOLUTION_ORDER === 'hostname-first'; + +const BASE_ARGS = [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-gpu', + '--disable-dev-shm-usage', + '--disable-blink-features=AutomationControlled', + ...(USE_DNS_FIX ? ['--dns-resolution-order=hostname-first'] : []), +]; + +const DEFAULT_UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36'; + +async function launchBrowser(options = {}) { + const args = [...BASE_ARGS, ...(options.extraArgs || [])]; + return chromium.launch({ + headless: options.headless !== undefined ? options.headless : true, + args, + }); +} + +async function newContext(browser, options = {}) { + return browser.newContext({ + viewport: { width: 1280, height: 720 }, + deviceScaleFactor: 1, + userAgent: DEFAULT_UA, + ...options, + }); +} + +async function navigateTo(page, url, options = {}) { + const waitUntil = options.waitUntil || 'commit'; + const timeout = options.timeout || 60000; + + const response = await page.goto(url, { waitUntil, timeout }); + + if (options.waitForDom !== false) { + await page.waitForLoadState('domcontentloaded', { timeout: options.domTimeout || 15000 }).catch(() => {}); + } + + const delay = options.delay || 2000; + if (delay > 0) await page.waitForTimeout(delay); + + return response; +} + +module.exports = { launchBrowser, newContext, navigateTo, BASE_ARGS, DEFAULT_UA }; \ No newline at end of file diff --git a/tests/scripts/lib/gitea-client.js b/tests/scripts/lib/gitea-client.js new file mode 100644 index 0000000..c833975 --- /dev/null +++ b/tests/scripts/lib/gitea-client.js @@ -0,0 +1,263 @@ +/** + * Gitea API Client — Lightweight helper for posting test results to Gitea Issues. + * + * Auth flow: Basic Auth → create token → use token for API calls. + * + * Usage: + * const gitea = require('./lib/gitea-client'); + * await gitea.postComment(issueNumber, body); + * await gitea.uploadAttachment(issueNumber, filePath); + * + * Environment: + * GITEA_API_URL - API base (default: https://git.softuniq.eu/api/v1) + * GITEA_TOKEN - Pre-existing API token (skips Basic Auth if set) + * GITEA_USER - Username for Basic Auth (default: NW) + * GITEA_PASSWORD - Password for Basic Auth (required if no token) + * GITEA_REPO - Repository path (default: UniqueSoft/APAW) + */ + +const https = require('https'); +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const GITEA_API_URL = process.env.GITEA_API_URL || 'https://git.softuniq.eu/api/v1'; +const GITEA_USER = process.env.GITEA_USER || ''; +const GITEA_PASSWORD = process.env.GITEA_PASSWORD || ''; +const GITEA_REPO = process.env.GITEA_REPO || 'UniqueSoft/APAW'; + +let _cachedToken = process.env.GITEA_TOKEN || null; + +function request(urlStr, options, body) { + return new Promise((resolve, reject) => { + const url = new URL(urlStr); + const mod = url.protocol === 'https:' ? https : http; + const opts = { + hostname: url.hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: url.pathname + url.search, + method: options.method || 'GET', + headers: options.headers || {}, + }; + const req = mod.request(opts, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + try { resolve(JSON.parse(data)); } catch { resolve(data); } + } else { + reject(new Error(`Gitea API ${res.statusCode}: ${data.slice(0, 300)}`)); + } + }); + }); + req.on('error', reject); + if (body) req.write(body); + req.end(); + }); +} + +async function getToken() { + if (_cachedToken) return _cachedToken; + + const credentials = Buffer.from(`${GITEA_USER}:${GITEA_PASSWORD}`).toString('base64'); + const urlStr = `${GITEA_API_URL}/users/${GITEA_USER}/tokens`; + const body = JSON.stringify({ name: `vt-${Date.now()}`, scopes: ['all'] }); + + const result = await request(urlStr, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Basic ${credentials}`, + }, + }, body); + + _cachedToken = result.sha1; + return _cachedToken; +} + +async function authHeaders() { + const token = await getToken(); + return { 'Authorization': `token ${token}`, 'Content-Type': 'application/json' }; +} + +async function postComment(issueNumber, body) { + const headers = await authHeaders(); + const url = `${GITEA_API_URL}/repos/${GITEA_REPO}/issues/${issueNumber}/comments`; + return request(url, { method: 'POST', headers }, JSON.stringify({ body })); +} + +async function uploadAttachment(issueNumber, filePath) { + const token = await getToken(); + const fileContent = fs.readFileSync(filePath); + const filename = path.basename(filePath); + const boundary = `----FormBoundary${Date.now()}`; + + let body = `--${boundary}\r\n`.getBytes?.() || Buffer.from(`--${boundary}\r\n`); + body = Buffer.concat([ + Buffer.from(`--${boundary}\r\n`), + Buffer.from(`Content-Disposition: form-data; name="attachment"; filename="${filename}"\r\n`), + Buffer.from(`Content-Type: image/png\r\n\r\n`), + fileContent, + Buffer.from(`\r\n--${boundary}--\r\n`), + ]); + + const url = new URL(`${GITEA_API_URL}/repos/${GITEA_REPO}/issues/${issueNumber}/assets`); + const mod = url.protocol === 'https:' ? https : http; + + return new Promise((resolve, reject) => { + const req = mod.request({ + hostname: url.hostname, + port: url.port || 443, + path: url.pathname, + method: 'POST', + headers: { + 'Authorization': `token ${token}`, + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'Content-Length': body.length, + }, + }, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + try { resolve(JSON.parse(data)); } catch { resolve(data); } + } else { + reject(new Error(`Gitea upload ${res.statusCode}: ${data.slice(0, 300)}`)); + } + }); + }); + req.on('error', reject); + req.write(body); + req.end(); + }); +} + +async function uploadAndComment(issueNumber, filePaths, commentBody) { + const uuids = []; + for (const fp of filePaths) { + try { + const result = await uploadAttachment(issueNumber, fp); + uuids.push({ filename: path.basename(fp), uuid: result.uuid }); + } catch (err) { + console.error(` ⚠️ Upload failed ${path.basename(fp)}: ${err.message}`); + } + } + + let fullBody = commentBody; + if (uuids.length > 0) { + fullBody += '\n\n### 📸 Screenshots\n\n'; + for (const u of uuids) { + fullBody += `![${u.filename}](/attachments/${u.uuid})\n`; + } + } + + return postComment(issueNumber, fullBody); +} + +function formatVisualReport(report) { + const s = report.summary; + const lines = [ + '## 📊 Visual Test Results', + '', + `**URL**: \`${report.targetUrl}\``, + `**Pages**: ${report.pages.join(', ')}`, + `**Viewports**: ${report.viewports.join(', ')}`, + `**Threshold**: ${report.threshold * 100}%`, + '', + '### Summary', + '', + `| Metric | Count |`, + `|--------|-------|`, + `| Screenshots captured | ${s.screenshotsCaptured} |`, + `| Screenshots failed | ${s.screenshotsFailed} |`, + `| Comparisons passed | ${s.comparisonsPassed} |`, + `| Comparisons failed | ${s.comparisonsFailed} |`, + `| UI elements extracted | ${s.totalElements} |`, + `| Console errors | ${s.totalConsoleErrors} |`, + `| Network errors | ${s.totalNetworkErrors} |`, + '', + `**Overall**: ${s.overallPassed ? '✅ PASSED' : '❌ FAILED'}`, + ]; + + if (report.comparison?.length) { + lines.push('', '### Comparison Details', ''); + lines.push('| Screenshot | Status | Diff % |'); + lines.push('|------------|--------|--------|'); + for (const c of report.comparison) { + lines.push(`| ${c.filename} | ${c.status === 'PASS' ? '✅' : '❌'} ${c.status} | ${c.diffPercent || 'N/A'} |`); + } + } + + if (report.consoleErrors?.length > 0) { + lines.push('', '### Console Errors', ''); + for (const e of report.consoleErrors.slice(0, 5)) { + lines.push(`- [${e.page}/${e.viewport}] ${e.error?.slice(0, 120)}`); + } + if (report.consoleErrors.length > 5) { + lines.push(`- ... and ${report.consoleErrors.length - 5} more`); + } + } + + if (report.networkErrors?.length > 0) { + lines.push('', '### Network Errors', ''); + for (const e of report.networkErrors.slice(0, 5)) { + lines.push(`- [${e.page}/${e.viewport}] ${e.status || e.failure} ${e.url?.slice(0, 80)}`); + } + if (report.networkErrors.length > 5) { + lines.push(`- ... and ${report.networkErrors.length - 5} more`); + } + } + + return lines.join('\n'); +} + +function formatConsoleReport(report) { + const s = report.summary; + const lines = [ + '## 📊 Console Error Monitor Results', + '', + `**URL**: \`${report.targetUrl}\``, + `**Pages**: ${report.pages.join(', ')}`, + '', + '### Summary', + '', + `| Metric | Count |`, + `|--------|-------|`, + `| Console errors | ${s.consoleErrors} |`, + `| Console warnings | ${s.consoleWarnings} |`, + `| Network errors | ${s.networkErrors} |`, + `| **Total issues** | **${s.totalIssues}** |`, + '', + `**Status**: ${s.totalIssues === 0 ? '✅ CLEAN' : '❌ ISSUES FOUND'}`, + ]; + + if (report.consoleErrors?.length > 0) { + lines.push('', '### Console Errors', ''); + for (const e of report.consoleErrors.slice(0, 8)) { + lines.push(`- [${e.page}] ${e.text?.slice(0, 120)}`); + } + if (report.consoleErrors.length > 8) { + lines.push(`- ... and ${report.consoleErrors.length - 8} more`); + } + } + + if (report.networkErrors?.length > 0) { + lines.push('', '### Network Errors', ''); + for (const e of report.networkErrors.slice(0, 8)) { + lines.push(`- [${e.page}] ${e.status || e.failure} ${e.url?.slice(0, 80)}`); + } + if (report.networkErrors.length > 8) { + lines.push(`- ... and ${report.networkErrors.length - 8} more`); + } + } + + return lines.join('\n'); +} + +module.exports = { + postComment, + uploadAttachment, + uploadAndComment, + formatVisualReport, + formatConsoleReport, +}; \ No newline at end of file diff --git a/tests/scripts/visual-test-pipeline.js b/tests/scripts/visual-test-pipeline.js index 498c630..2c6e0c3 100644 --- a/tests/scripts/visual-test-pipeline.js +++ b/tests/scripts/visual-test-pipeline.js @@ -11,16 +11,20 @@ * TARGET_URL - App URL (default: http://host.docker.internal:3000) * PIXELMATCH_THRESHOLD - Diff threshold (default: 0.05 = 5%) * PAGES - Comma-separated page paths (default: /,/admin/login) + * GITEA_ISSUE - Gitea issue number to post results (optional) */ const { chromium } = require('playwright'); const fs = require('fs'); const path = require('path'); +const gitea = require('./lib/gitea-client'); +const { BASE_ARGS } = require('./lib/browser-launcher'); const TARGET_URL = process.argv[2] || process.env.TARGET_URL || 'http://host.docker.internal:3000'; const THRESHOLD = parseFloat(process.env.PIXELMATCH_THRESHOLD || '0.05'); const PAGES_ARG = process.env.PAGES || '/,/admin/login'; const PAGE_PATHS = PAGES_ARG.split(',').map(p => p.trim()).filter(Boolean); +const GITEA_ISSUE = parseInt(process.env.GITEA_ISSUE, 10) || null; const VISUAL_DIR = path.join(__dirname, '..', 'visual'); const BASELINE_DIR = path.join(VISUAL_DIR, 'baseline'); @@ -128,7 +132,8 @@ async function capturePage(browser, pageConf, vp, outputDir, mode) { try { console.log(` Capturing: ${pageConf.name} @ ${vp.name} (${vp.width}x${vp.height})`); - const response = await page.goto(url, { waitUntil: 'networkidle', timeout: 20000 }); + const response = await page.goto(url, { waitUntil: 'commit', timeout: 30000 }); + await page.waitForLoadState('domcontentloaded', { timeout: 15000 }).catch(() => {}); await page.waitForTimeout(1500); await page.screenshot({ path: filePath, fullPage: true }); @@ -161,7 +166,7 @@ async function captureAll(mode) { console.log(` Pages: ${PAGES.map(p => p.path).join(', ')}`); console.log(` Output: ${outputDir}\n`); - const browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] }); + const browser = await chromium.launch({ headless: true, args: [...BASE_ARGS, '--disable-setuid-sandbox'] }); const results = []; for (const pageConf of PAGES) { @@ -319,6 +324,33 @@ async function main() { console.log(`\n 📄 Report: ${reportPath}`); console.log('═══════════════════════════════════════════════════\n'); + if (GITEA_ISSUE) { + try { + console.log(`📤 Posting results to Gitea Issue #${GITEA_ISSUE}...`); + const commentBody = gitea.formatVisualReport(report); + + const diffFiles = fs.existsSync(DIFF_DIR) + ? fs.readdirSync(DIFF_DIR).filter(f => f.endsWith('.png')).map(f => path.join(DIFF_DIR, f)) + : []; + const currentFiles = fs.existsSync(CURRENT_DIR) + ? fs.readdirSync(CURRENT_DIR).filter(f => f.endsWith('.png')).map(f => path.join(CURRENT_DIR, f)) + : []; + + if (diffFiles.length > 0) { + await gitea.uploadAndComment(GITEA_ISSUE, diffFiles, commentBody); + console.log(` ✅ Posted comment with ${diffFiles.length} diff screenshots`); + } else if (currentFiles.length > 0) { + await gitea.uploadAndComment(GITEA_ISSUE, currentFiles, commentBody); + console.log(` ✅ Posted comment with ${currentFiles.length} current screenshots`); + } else { + await gitea.postComment(GITEA_ISSUE, commentBody); + console.log(' ✅ Posted comment (no screenshots to upload)'); + } + } catch (err) { + console.error(` ❌ Gitea posting failed: ${err.message}`); + } + } + process.exit(report.summary.overallPassed ? 0 : 1); }