Frontend Error
-A runtime error occurred while rendering this page.
-++ )} >Frontend Error
++ A runtime error occurred while rendering this page. +
+{String((err as any)?.message || err)}{props.children} +
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..bae7cec
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,28 @@
+node_modules
+.git
+.gitignore
+.env
+.env.local
+.env.*.local
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.vscode
+.idea
+*.log
+.DS_Store
+.output
+.vinxi
+dist
+*.tsbuildinfo
+coverage
+.nyc_output
+.cache
+.next
+.vercel
+netlify
+*.test.ts
+*.spec.ts
+**/__tests__
+**/test
+**/tests
\ No newline at end of file
diff --git a/.gitea/scripts/registry_prune.py b/.gitea/scripts/registry_prune.py
new file mode 100644
index 0000000..12c40ec
--- /dev/null
+++ b/.gitea/scripts/registry_prune.py
@@ -0,0 +1,277 @@
+#!/usr/bin/env python3
+"""
+Registry Image Tag Pruner - Keeps only the latest 1 SHA-tag per repository.
+
+Usage:
+ python3 registry_prune.py \
+ --registry registry.nxtgauge.com \
+ --repo nxtgauge-rust-gateway \
+ --username "$REGISTRY_USERNAME" \
+ --password "$REGISTRY_PASSWORD"
+
+Environment variables can also be used:
+ REGISTRY_HOST, REGISTRY_REPO, REGISTRY_USERNAME, REGISTRY_PASSWORD
+
+SHA-like tags are identified by pattern: ^[a-f0-9]{40}$
+Non-SHA tags (e.g., high-performance-latest, main-latest, latest) are NEVER deleted.
+
+Exit code: 0 on success (or if prune fails gracefully), non-zero only on critical error.
+"""
+
+import argparse
+import base64
+import json
+import os
+import sys
+import time
+from urllib.request import Request, urlopen
+from urllib.error import URLError, HTTPError
+
+
+def parse_args():
+ parser = argparse.ArgumentParser(
+ description="Prune Docker registry tags, keeping only the latest SHA tag."
+ )
+ parser.add_argument("--registry", default=os.environ.get("REGISTRY_HOST"))
+ parser.add_argument("--repo", default=os.environ.get("REGISTRY_REPO"))
+ parser.add_argument("--username", default=os.environ.get("REGISTRY_USERNAME"))
+ parser.add_argument("--password", default=os.environ.get("REGISTRY_PASSWORD"))
+ parser.add_argument("--keep", type=int, default=1, help="Number of SHA tags to keep (default: 1)")
+ return parser.parse_args()
+
+
+def api_request(url: str, method: str, username: str, password: str, data=None, retries: int = 3) -> dict | None:
+ """Make an authenticated API request with retry logic."""
+ auth = base64.b64encode(f"{username}:{password}".encode()).decode()
+ headers = {
+ "Authorization": f"Basic {auth}",
+ "Content-Type": "application/json",
+ }
+
+ for attempt in range(1, retries + 1):
+ try:
+ req = Request(url, method=method, headers=headers, data=data)
+ with urlopen(req, timeout=30) as response:
+ content = response.read()
+ if content:
+ return json.loads(content)
+ return {}
+ except HTTPError as e:
+ if e.code == 401:
+ print(f" [ERROR] Authentication failed (401)")
+ return None
+ if e.code == 404:
+ print(f" [WARN] Resource not found: {url}")
+ return None
+ print(f" [RETRY {attempt}/{retries}] HTTP {e.code} for {url}")
+ except URLError as e:
+ print(f" [RETRY {attempt}/{retries}] URL error: {e.reason}")
+ except Exception as e:
+ print(f" [RETRY {attempt}/{retries}] Error: {e}")
+
+ if attempt < retries:
+ time.sleep(attempt * 2)
+
+ print(f" [ERROR] Failed after {retries} attempts for {url}")
+ return None
+
+
+def get_tag_digest(registry: str, repo: str, tag: str, username: str, password: str) -> tuple[str, str] | None:
+ """Get the digest (sha256:...) and created time for a tag."""
+ url = f"https://{registry}/v2/{repo}/manifests/{tag}"
+ auth = base64.b64encode(f"{username}:{password}".encode()).decode()
+
+ for attempt in range(1, 4):
+ try:
+ req = Request(url, method="GET", headers={
+ "Authorization": f"Basic {auth}",
+ "Accept": "application/vnd.docker.distribution.manifest.v2+json",
+ })
+ with urlopen(req, timeout=30) as response:
+ digest = response.headers.get("Docker-Content-Digest", "")
+ created = response.headers.get("Date", "")
+ return digest, created
+ except Exception as e:
+ print(f" [RETRY {attempt}/3] Getting digest for {tag}: {e}")
+ time.sleep(attempt)
+
+ return None
+
+
+def delete_tag(registry: str, repo: str, digest: str, username: str, password: str) -> bool:
+ """Delete a tag by its digest."""
+ url = f"https://{registry}/v2/{repo}/manifests/{digest}"
+ auth = base64.b64encode(f"{username}:{password}".encode()).decode()
+
+ for attempt in range(1, 4):
+ try:
+ req = Request(url, method="DELETE", headers={
+ "Authorization": f"Basic {auth}",
+ })
+ with urlopen(req, timeout=30) as response:
+ if response.status in (200, 202, 404):
+ return True
+ except HTTPError as e:
+ if e.code == 404:
+ return True # Already deleted
+ print(f" [RETRY {attempt}/3] Deleting {digest[:20]}...: {e}")
+ except Exception as e:
+ print(f" [RETRY {attempt}/3] Deleting {digest[:20]}...: {e}")
+
+ time.sleep(attempt)
+
+ return False
+
+
+def is_sha_tag(tag: str) -> bool:
+ """Check if tag looks like a SHA (40 hex chars)."""
+ import re
+ return bool(re.match(r"^[a-f0-9]{40}$", tag))
+
+
+def prune_tags(registry: str, repo: str, username: str, password: str, keep: int = 1) -> bool:
+ """
+ Main prune logic:
+ - List all tags for the repo
+ - Filter SHA-like tags
+ - Sort by created date (newest first)
+ - Keep newest `keep` tags
+ - Delete older SHA tags by digest
+ - Never delete non-SHA tags
+ """
+ print(f"\n=== Pruning {registry}/{repo} ===")
+ print(f"Strategy: Keep {keep} newest SHA tag(s), delete older SHA tags")
+ print(f"Non-SHA tags (e.g., high-performance-latest, main-latest, latest) are preserved\n")
+
+ # Get catalog (list of repos)
+ catalog_url = f"https://{registry}/v2/_catalog"
+ catalog = api_request(catalog_url, "GET", username, password)
+ if catalog is None:
+ print("[ERROR] Failed to get repository catalog")
+ return False
+
+ if repo not in catalog.get("repositories", []):
+ print(f"[INFO] Repository {repo} not found in catalog")
+ return True
+
+ # Get tags for repo
+ tags_url = f"https://{registry}/v2/{repo}/tags/list"
+ tags_data = api_request(tags_url, "GET", username, password)
+ if tags_data is None:
+ print(f"[ERROR] Failed to get tags for {repo}")
+ return False
+
+ all_tags = tags_data.get("tags", [])
+ if not all_tags:
+ print("[INFO] No tags found")
+ return True
+
+ # Separate SHA tags from non-SHA tags
+ sha_tags = [t for t in all_tags if is_sha_tag(t)]
+ non_sha_tags = [t for t in all_tags if not is_sha_tag(t)]
+
+ print(f"Total tags: {len(all_tags)}")
+ print(f" SHA tags (candidates for pruning): {len(sha_tags)}")
+ print(f" Non-SHA tags (protected): {len(non_sha_tags)}")
+ if non_sha_tags:
+ print(f" Protected tags: {', '.join(sorted(non_sha_tags))}")
+
+ if not sha_tags:
+ print("\n[INFO] No SHA tags to prune")
+ return True
+
+ # Get digest and created time for each SHA tag
+ tag_info = []
+ for tag in sha_tags:
+ result = get_tag_digest(registry, repo, tag, username, password)
+ if result:
+ digest, created = result
+ tag_info.append({
+ "tag": tag,
+ "digest": digest,
+ "created": created,
+ "timestamp": parse_http_date(created) if created else 0,
+ })
+ time.sleep(0.1) # Be nice to the registry
+
+ if not tag_info:
+ print("\n[ERROR] Could not get info for any SHA tags")
+ return False
+
+ # Sort by timestamp (newest first)
+ tag_info.sort(key=lambda x: x["timestamp"], reverse=True)
+
+ print(f"\nSHA tags sorted by age (newest first):")
+ for i, info in enumerate(tag_info):
+ marker = " [KEEP]" if i < keep else " [DELETE]"
+ print(f" {i+1}. {info['tag']} ({info['created'] or 'unknown date'}){marker}")
+
+ # Delete older SHA tags
+ deleted_count = 0
+ kept_count = 0
+
+ for i, info in enumerate(tag_info):
+ if i < keep:
+ print(f"\n[KEEP] {info['tag']}")
+ kept_count += 1
+ continue
+
+ print(f"\n[DELETE] {info['tag']} (digest: {info['digest'][:20]}...)")
+ if delete_tag(registry, repo, info["digest"], username, password):
+ print(f" [OK] Deleted {info['tag']}")
+ deleted_count += 1
+ else:
+ print(f" [WARN] Failed to delete {info['tag']} (will retry next run)")
+
+ time.sleep(0.2) # Be nice to the registry
+
+ print(f"\n=== Prune Summary ===")
+ print(f"Tags kept: {kept_count}")
+ print(f"Tags deleted: {deleted_count}")
+ print(f"Tags protected (non-SHA): {len(non_sha_tags)}")
+
+ return True
+
+
+def parse_http_date(date_str: str) -> float:
+ """Parse HTTP Date header to timestamp."""
+ from email.utils import parsedate_to_datetime
+ try:
+ return parsedate_to_datetime(date_str).timestamp()
+ except Exception:
+ return 0
+
+
+def main():
+ args = parse_args()
+
+ # Validate required args
+ registry = args.registry or os.environ.get("REGISTRY_HOST")
+ repo = args.repo or os.environ.get("REGISTRY_REPO")
+ username = args.username or os.environ.get("REGISTRY_USERNAME")
+ password = args.password or os.environ.get("REGISTRY_PASSWORD")
+
+ if not all([registry, repo, username, password]):
+ print("[ERROR] Missing required arguments. Need: --registry, --repo, --username, --password")
+ print("Or set environment variables: REGISTRY_HOST, REGISTRY_REPO, REGISTRY_USERNAME, REGISTRY_PASSWORD")
+ sys.exit(1)
+
+ print(f"Registry: {registry}")
+ print(f"Repository: {repo}")
+ print(f"Username: {username}")
+
+ try:
+ success = prune_tags(registry, repo, username, password, args.keep)
+ if success:
+ print("\n[OK] Prune completed successfully")
+ sys.exit(0)
+ else:
+ print("\n[WARN] Prune completed with some errors")
+ sys.exit(0) # Exit 0 per requirement - never fail workflow
+ except Exception as e:
+ print(f"\n[ERROR] Prune failed with exception: {e}")
+ sys.exit(0) # Exit 0 per requirement - never fail workflow
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.gitea/scripts/update-gitops.py b/.gitea/scripts/update-gitops.py
new file mode 100644
index 0000000..03805a0
--- /dev/null
+++ b/.gitea/scripts/update-gitops.py
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+"""
+Update GitOps kustomization.yaml with new image SHA tags.
+
+Usage:
+ python3 update-gitops.py \
+ --repo /path/to/nxtgauge-gitops \
+ --service gateway \
+ --sha abc123def456...
+
+This script:
+1. Updates the newTag for the specified service to the SHA
+2. Commits and pushes to the gitops repo
+3. ArgoCD detects the change and deploys
+"""
+
+import argparse
+import os
+import re
+import subprocess
+import sys
+
+
+def run(cmd: list[str], cwd: str = None) -> tuple[int, str, str]:
+ """Run a command and return (returncode, stdout, stderr)."""
+ result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
+ return result.returncode, result.stdout, result.stderr
+
+
+def update_kustomization(kustomization_path: str, service: str, sha: str) -> bool:
+ """Update the newTag for a service in kustomization.yaml."""
+ with open(kustomization_path, "r") as f:
+ content = f.read()
+
+ # Pattern to find image entry for the service
+ # Matches: - name: registry.nxtgauge.com/nxtgauge-rust-{service}
+ # newTag: something
+ pattern = rf'(\s+-\s+name:\s+registry\.nxtgauge\.com/nxtgauge-rust-{re.escape(service)}\n\s+newTag:\s+)[^\n]+'
+
+ replacement = rf'\g<1>{sha}'
+
+ new_content, count = re.subn(pattern, replacement, content)
+
+ if count == 0:
+ # Try without the nxtgauge-rust- prefix (for frontend, admin, etc)
+ pattern = rf'(\s+-\s+name:\s+registry\.nxtgauge\.com/nxtgauge-{re.escape(service)}\n\s+newTag:\s+)[^\n]+'
+ new_content, count = re.subn(pattern, replacement, content)
+
+ if count == 0:
+ print(f"[ERROR] Could not find image entry for service: {service}")
+ return False
+
+ with open(kustomization_path, "w") as f:
+ f.write(new_content)
+
+ print(f"[OK] Updated {service} to SHA {sha}")
+ return True
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Update GitOps with new image SHA")
+ parser.add_argument("--repo", required=True, help="Path to gitops repo")
+ parser.add_argument("--service", required=True, help="Service name (e.g., gateway, users, frontend-solid)")
+ parser.add_argument("--sha", required=True, help="Git SHA to deploy")
+ parser.add_argument("--message", default=None, help="Commit message")
+ args = parser.parse_args()
+
+ service_image_map = {
+ "gateway": "nxtgauge-rust-gateway",
+ "users": "nxtgauge-rust-users",
+ "companies": "nxtgauge-rust-companies",
+ "jobs": "nxtgauge-rust-jobs",
+ "leads": "nxtgauge-rust-leads",
+ "job-seekers": "nxtgauge-rust-job-seekers",
+ "customers": "nxtgauge-rust-customers",
+ "payments": "nxtgauge-rust-payments",
+ "employees": "nxtgauge-rust-employees",
+ "photographers": "nxtgauge-rust-photographers",
+ "makeup-artists": "nxtgauge-rust-makeup-artists",
+ "tutors": "nxtgauge-rust-tutors",
+ "developers": "nxtgauge-rust-developers",
+ "video-editors": "nxtgauge-rust-video-editors",
+ "graphic-designers": "nxtgauge-rust-graphic-designers",
+ "social-media-managers": "nxtgauge-rust-social-media-managers",
+ "fitness-trainers": "nxtgauge-rust-fitness-trainers",
+ "catering-services": "nxtgauge-rust-catering-services",
+ "ugc-content-creators": "nxtgauge-rust-ugc-content-creators",
+ "cron": "nxtgauge-rust-cron",
+ "frontend-solid": "nxtgauge-frontend-solid",
+ "admin-solid": "nxtgauge-admin-solid",
+ "ai-assistant": "nxtgauge-ai-assistant",
+ }
+
+ # Determine which kustomization file to update
+ if service_image_map.get(args.service):
+ image_name = service_image_map[args.service]
+ else:
+ image_name = f"nxtgauge-{args.service}"
+
+ # Find the right kustomization file based on service
+ if "frontend" in args.service or "admin" in args.service:
+ kustomization_path = os.path.join(args.repo, "apps/nxtgauge-frontend-solid/overlays/prod/kustomization.yaml")
+ if not os.path.exists(kustomization_path):
+ kustomization_path = os.path.join(args.repo, "apps/nxtgauge-frontend-solid/base/kustomization.yaml")
+ elif "ai-assistant" in args.service:
+ kustomization_path = os.path.join(args.repo, "apps/nxtgauge-ai-assistant/overlays/prod/kustomization.yaml")
+ if not os.path.exists(kustomization_path):
+ kustomization_path = os.path.join(args.repo, "apps/nxtgauge-ai-assistant/base/kustomization.yaml")
+ else:
+ kustomization_path = os.path.join(args.repo, "apps/nxtgauge-backend-rust/overlays/prod/kustomization.yaml")
+
+ if not os.path.exists(kustomization_path):
+ print(f"[ERROR] Kustomization file not found: {kustomization_path}")
+ sys.exit(0) # Exit 0 per workflow requirement
+
+ print(f"Updating {kustomization_path} for service {args.service}")
+
+ if not update_kustomization(kustomization_path, args.service, args.sha):
+ sys.exit(0) # Exit 0 per workflow requirement
+
+ # Git add, commit, push
+ commit_msg = args.message or f"chore: deploy {args.service}@{args.sha}"
+
+ run(["git", "add", "-A"], cwd=args.repo)
+ code, stdout, stderr = run(["git", "diff", "--cached", "--stat"], cwd=args.repo)
+
+ if not stdout.strip():
+ print("[INFO] No changes to commit")
+ sys.exit(0)
+
+ print(f"Changes to commit:\n{stdout}")
+
+ run(["git", "commit", "-m", commit_msg], cwd=args.repo)
+ code, stdout, stderr = run(["git", "push"], cwd=args.repo)
+
+ if code != 0:
+ print(f"[ERROR] Push failed: {stderr}")
+ else:
+ print(f"[OK] Pushed update to gitops repo")
+
+ sys.exit(0) # Always exit 0 per workflow requirement
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml
new file mode 100644
index 0000000..4b9478d
--- /dev/null
+++ b/.gitea/workflows/build.yaml
@@ -0,0 +1,111 @@
+name: build-and-push
+
+on:
+ push:
+ branches:
+ - main
+ - high-performance
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ env:
+ DOCKER_HOST: unix:///var/run/docker.sock
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ run: |
+ export DOCKER_HOST=unix:///var/run/docker.sock
+ docker version
+ docker buildx create --use || true
+ docker buildx inspect --bootstrap
+
+ - name: Login to Registry
+ env:
+ REGISTRY_HOSTPORT: ${{ secrets.REGISTRY_HOSTPORT }}
+ REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
+ REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
+ run: |
+ set -euo pipefail
+ export DOCKER_HOST=unix:///var/run/docker.sock
+ test -n "$REGISTRY_HOSTPORT"
+ echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOSTPORT" -u "$REGISTRY_USERNAME" --password-stdin
+
+ - name: Build and push
+ env:
+ REGISTRY_HOSTPORT: ${{ secrets.REGISTRY_HOSTPORT }}
+ run: |
+ set -euo pipefail
+ export DOCKER_HOST=unix:///var/run/docker.sock
+
+ build_and_push() {
+ docker buildx build --push \
+ -f Dockerfile \
+ -t "$REGISTRY_HOSTPORT/nxtgauge-frontend-solid:${{ gitea.sha }}" \
+ -t "$REGISTRY_HOSTPORT/nxtgauge-frontend-solid:high-performance-latest" \
+ .
+ }
+
+ for attempt in 1 2 3; do
+ echo "Build attempt $attempt"
+ if build_and_push; then
+ exit 0
+ fi
+ echo "Build attempt $attempt failed; recreating builder and retrying"
+ docker buildx rm --all-inactive --force || true
+ docker buildx create --use || true
+ docker buildx inspect --bootstrap
+ sleep $((attempt * 10))
+ done
+
+ echo "Build failed after retries"
+ exit 1
+
+ - name: Prune old image tags (keep latest 1 SHA)
+ if: success()
+ continue-on-error: true
+ env:
+ REGISTRY_HOST: ${{ secrets.REGISTRY_HOSTPORT }}
+ REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
+ REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
+ run: |
+ set -euo pipefail
+ python3 .gitea/scripts/registry_prune.py \
+ --registry "$REGISTRY_HOST" \
+ --repo "nxtgauge-frontend-solid" \
+ --username "$REGISTRY_USERNAME" \
+ --password "$REGISTRY_PASSWORD" \
+ --keep 1
+
+ - name: Update GitOps and trigger deployment
+ if: success()
+ continue-on-error: true
+ env:
+ GITEOPS_REPO: ${{ secrets.GITEOPS_REPO }}
+ GITEOPS_SSH_KEY: ${{ secrets.GITEOPS_SSH_KEY }}
+ run: |
+ set -euo pipefail
+
+ if [ -z "$GITEOPS_REPO" ]; then
+ echo "GITEOPS_REPO secret not set, skipping GitOps update"
+ exit 0
+ fi
+
+ GITEOPS_DIR=$(mktemp -d)
+ git clone "$GITEOPS_REPO" "$GITEOPS_DIR"
+ cd "$GITEOPS_DIR"
+
+ mkdir -p ~/.ssh
+ echo "$GITEOPS_SSH_KEY" > ~/.ssh/id_ed25519
+ chmod 600 ~/.ssh/id_ed25519
+ ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
+
+ python3 .gitea/scripts/update-gitops.py \
+ --repo "$GITEOPS_DIR" \
+ --service "frontend-solid" \
+ --sha "${{ gitea.sha }}" \
+ --message "chore: deploy frontend-solid@${{ gitea.sha }}"
+
+ rm -rf "$GITEOPS_DIR"
diff --git a/.gitea/workflows/test.yaml b/.gitea/workflows/test.yaml
new file mode 100644
index 0000000..e6f0576
--- /dev/null
+++ b/.gitea/workflows/test.yaml
@@ -0,0 +1,191 @@
+name: nightly-tests
+
+on:
+ schedule:
+ - cron: "30 2 * * *" # 2:30 AM daily
+ workflow_dispatch: # Manual trigger
+
+env:
+ DOCKER_HOST: unix:///var/run/docker.sock
+
+jobs:
+ # ── Unit Tests ────────────────────────────────────────────────────────────────
+ unit-tests:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: "npm"
+ cache-dependency-path: package-lock.json
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run unit tests
+ run: npm run test
+ env:
+ CI: "true"
+
+ - name: Upload Vitest coverage
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: vitest-coverage
+ path: test-results/vitest-coverage/
+
+ # ── E2E Tests ─────────────────────────────────────────────────────────────────
+ e2e-tests:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: "npm"
+ cache-dependency-path: package-lock.json
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Install Playwright browsers
+ run: npx playwright install chromium --with-deps
+
+ - name: Start dev server
+ run: npm run dev &
+ env:
+ PORT: 3000
+ shell: bash
+
+ - name: Wait for server
+ run: npx wait-on http://localhost:3000 --timeout 60000
+
+ - name: Run E2E tests
+ run: npx playwright test --config=playwright.config.ts
+ env:
+ CI: "true"
+
+ - name: Upload Playwright report
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: playwright-report
+ path: test-results/
+
+ - name: Upload test videos
+ uses: actions/upload-artifact@v4
+ if: failure()
+ with:
+ name: playwright-videos
+ path: test-videos/
+
+ # ── Accessibility Tests ────────────────────────────────────────────────────────
+ a11y-tests:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: "npm"
+ cache-dependency-path: package-lock.json
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Install Playwright browsers
+ run: npx playwright install chromium --with-deps
+
+ - name: Start dev server
+ run: npm run dev &
+ env:
+ PORT: 3000
+ shell: bash
+
+ - name: Wait for server
+ run: npx wait-on http://localhost:3000 --timeout 60000
+
+ - name: Run accessibility tests
+ run: npx playwright test --config=playwright.a11y.config.ts
+ env:
+ CI: "true"
+
+ - name: Upload a11y report
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: playwright-a11y-report
+ path: test-results/
+
+ # ── Visual Tests ──────────────────────────────────────────────────────────────
+ visual-tests:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: "npm"
+ cache-dependency-path: package-lock.json
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Install Playwright browsers
+ run: npx playwright install chromium --with-deps
+
+ - name: Start dev server
+ run: npm run dev &
+ env:
+ PORT: 3000
+ shell: bash
+
+ - name: Wait for server
+ run: npx wait-on http://localhost:3000 --timeout 60000
+
+ - name: Run visual tests
+ run: npx playwright test --config=playwright.visual.config.ts
+ env:
+ CI: "true"
+
+ - name: Upload visual diffs
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: playwright-visual-diffs
+ path: test-results/visual/
+
+ # ── Test Summary ──────────────────────────────────────────────────────────────
+ test-summary:
+ runs-on: ubuntu-latest
+ needs: [unit-tests, e2e-tests, a11y-tests]
+ if: always()
+ steps:
+ - name: Test results summary
+ run: |
+ echo "## Nightly Test Results" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY
+ echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
+ echo "| Unit Tests | ${{ needs.unit-tests.result }} |" >> $GITHUB_STEP_SUMMARY
+ echo "| E2E Tests | ${{ needs.e2e-tests.result }} |" >> $GITHUB_STEP_SUMMARY
+ echo "| Accessibility Tests | ${{ needs.a11y-tests.result }} |" >> $GITHUB_STEP_SUMMARY
+ echo "| Visual Tests | ${{ needs.visual-tests.result }} |" >> $GITHUB_STEP_SUMMARY
+
+ - name: Notify on failure
+ if: needs.unit-tests.result == 'failure' || needs.e2e-tests.result == 'failure' || needs.a11y-tests.result == 'failure'
+ run: |
+ echo "⚠️ Some tests failed. Check the artifacts for details."
\ No newline at end of file
diff --git a/.github/workflows/sync-to-gitea.yml b/.github/workflows/sync-to-gitea.yml
new file mode 100644
index 0000000..a9b0694
--- /dev/null
+++ b/.github/workflows/sync-to-gitea.yml
@@ -0,0 +1,46 @@
+name: sync-to-gitea
+
+on:
+ push:
+ branches:
+ - high-performance
+
+jobs:
+ sync:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Sync to Gitea
+ env:
+ GITEA_TOKEN: ${{ secrets.GITEA_SECRET }}
+ REPO: ${{ github.event.repository.name }}
+ BRANCH: ${{ github.ref_name }}
+ run: |
+ set -euxo pipefail
+ export GIT_TERMINAL_PROMPT=0
+ export GIT_TRACE=1
+ export GIT_CURL_VERBOSE=1
+
+ USER="Admin"
+ TARGET="https://ci.nxtgauge.com/Admin/${REPO}.git"
+ AUTH="$(printf '%s' "${USER}:${GITEA_TOKEN}" | base64 -w0)"
+
+ test -n "${GITEA_TOKEN:-}" || (echo "GITEA_TOKEN empty" && exit 1)
+ curl -fsS -H "Authorization: token ${GITEA_TOKEN}" https://ci.nxtgauge.com/api/v1/user >/dev/null
+ curl -fsS -H "Authorization: Basic ${AUTH}" "${TARGET}/info/refs?service=git-receive-pack" >/dev/null
+
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git config --global http.version HTTP/1.1
+ git config --global http.postBuffer 524288000
+ git remote remove gitea 2>/dev/null || true
+ git remote add gitea "${TARGET}"
+
+ git -c http.extraheader="Authorization: Basic ${AUTH}" push gitea "HEAD:${BRANCH}" --force
+ git -c http.extraheader="Authorization: Basic ${AUTH}" push gitea --tags --force
diff --git a/.woodpecker.yml b/.woodpecker.yml
deleted file mode 100644
index 45dbb75..0000000
--- a/.woodpecker.yml
+++ /dev/null
@@ -1,25 +0,0 @@
-when:
- branch: [main, high-performance]
- event: push
-
-steps:
- - name: build-and-push
- image: woodpeckerci/plugin-kaniko:2.1.1
- settings:
- registry:
- from_secret: REGISTRY_HOSTPORT
- repo: nxtgauge-frontend-solid
- dockerfile: Dockerfile.simple
- tags:
- - ${CI_COMMIT_SHA}
- - latest
- - high-performance-latest
- username:
- from_secret: REGISTRY_USERNAME
- password:
- from_secret: REGISTRY_PASSWORD
- insecure: true
- insecure_pull: true
- skip_tls_verify: true
- platforms: linux/amd64
- cache: false
diff --git a/Dockerfile b/Dockerfile
index c874eaa..a912af5 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
# Multi-stage build with memory optimization
-FROM node:20-slim AS builder
+FROM registry.nxtgauge.com/node:20-alpine AS builder
WORKDIR /app
# Skip browser downloads
@@ -13,13 +13,13 @@ RUN echo "VITE_API_URL=http://localhost:9100" > .env && \
echo "VITE_RUST_API_URL=http://localhost:9100/api" >> .env
# Install build dependencies
-RUN apt-get update && apt-get install -y python3 make g++ git && rm -rf /var/lib/apt/lists/*
+RUN apk add --no-cache python3 make g++ git
# Copy package files
COPY package*.json ./
-# Install dependencies
-RUN npm ci --legacy-peer-deps --prefer-offline --no-audit
+# Install dependencies including devDependencies (needed for Tailwind/Vite)
+RUN npm ci --legacy-peer-deps --prefer-offline --no-audit --include=dev
# Copy source
COPY . .
@@ -29,7 +29,7 @@ ENV NODE_OPTIONS="--max-old-space-size=4096"
RUN npm run build
# Runtime stage
-FROM node:20-alpine
+FROM registry.nxtgauge.com/node:20-alpine
WORKDIR /app
# Copy built output
diff --git a/Dockerfile.simple b/Dockerfile.simple
index 378ea45..86e3f7c 100644
--- a/Dockerfile.simple
+++ b/Dockerfile.simple
@@ -1,4 +1,4 @@
-FROM node:20-alpine
+FROM registry.nxtgauge.com/node:20-alpine
WORKDIR /app
diff --git a/README.md b/README.md
index d694ec1..37b05b9 100644
--- a/README.md
+++ b/README.md
@@ -6,3 +6,9 @@ SolidStart migration target for `nxtgauge-frontendwebsite`.
Reproduce the same user-facing behavior and runtime-config driven flows without changing product logic.
See `docs/MIGRATION_MASTER_PLAN.md` for the staged plan.
+
+## CI (Woodpecker)
+
+Required secrets:
+- `REGISTRY_USERNAME`
+- `REGISTRY_PASSWORD`
diff --git a/TESTING.md b/TESTING.md
new file mode 100644
index 0000000..654f054
--- /dev/null
+++ b/TESTING.md
@@ -0,0 +1,134 @@
+# Testing Guide — Nxtgauge Frontend
+
+## Test Stack
+
+| Type | Tool | Config |
+|------|------|--------|
+| Unit | Vitest | `vitest.config.ts` |
+| E2E | Playwright | `playwright.config.ts` |
+| Accessibility | Playwright + axe-core | `playwright.a11y.config.ts` |
+| Visual | Playwright screenshot diff | `playwright.visual.config.ts` |
+
+## Running Tests
+
+### Local (before push)
+
+```bash
+# Unit tests
+npm run test
+
+# Unit tests (watch mode)
+npm run test:watch
+
+# Unit tests with coverage
+npm run test:coverage
+
+# All E2E tests
+npm run test:e2e
+
+# Accessibility tests only
+npm run test:accessibility
+
+# Visual tests only
+npm run test:visual
+```
+
+### Dev server must be running for E2E/a11y/visual tests
+
+```bash
+npm run dev &
+npx wait-on http://localhost:3000
+# Then run tests in another terminal
+```
+
+## Test Directories
+
+```
+tests/
+├── e2e/
+│ ├── ai-chat-widget.spec.ts # AI Chat Widget E2E
+│ ├── company-jobs.spec.ts # Company Jobs Page E2E
+│ ├── company-verification-flow.spec.ts
+│ ├── signup-verification-submission.spec.ts
+│ ├── accessibility.spec.ts
+│ └── visual/
+│ └── *.png # Visual baseline screenshots
+├── vitest/
+│ └── components/
+│ ├── AiChatWidget.test.tsx
+│ └── PublicFooter.test.tsx
+```
+
+## Writing E2E Tests
+
+```typescript
+import { test, expect } from "@playwright/test";
+
+test("description of test", async ({ page }) => {
+ await page.goto("/route");
+ await page.waitForLoadState("networkidle");
+
+ // Assertions
+ await expect(page.locator("text=Expected")).toBeVisible();
+
+ // Interactions
+ await page.click("button[type='submit']");
+ await page.fill("input[name='email']", "test@example.com");
+});
+```
+
+## Writing Vitest Unit Tests
+
+```typescript
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@solidjs/testing-library";
+import { MyComponent } from "../src/components/MyComponent";
+
+global.fetch = vi.fn();
+
+describe("MyComponent", () => {
+ it("renders correctly", () => {
+ render(() =>
A runtime error occurred while rendering this page.
-++ )} >Frontend Error
++ A runtime error occurred while rendering this page. +
+{String((err as any)?.message || err)}{props.children} +
+ + AI Assistant +
+{msg.content}
++ Intent: {msg.intent} +
+