mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
ci: E2E workflow, web typecheck job, pre-commit hook, test suite (#486)
This commit is contained in:
parent
fec47cbc32
commit
f0540b33fb
28 changed files with 3413 additions and 1143 deletions
91
.github/workflows/ci-e2e.yml
vendored
Normal file
91
.github/workflows/ci-e2e.yml
vendored
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
name: E2E Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
check-changes:
|
||||
name: Check web module changes
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
web_changed: ${{ steps.filter.outputs.web }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
web:
|
||||
- 'gitnexus-web/**'
|
||||
|
||||
e2e:
|
||||
name: e2e (chromium)
|
||||
needs: check-changes
|
||||
if: needs.check-changes.result == 'success' && needs.check-changes.outputs.web_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: gitnexus-web/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci
|
||||
working-directory: gitnexus-web
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
working-directory: gitnexus-web
|
||||
|
||||
- name: Install backend dependencies
|
||||
run: npm ci
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Build backend
|
||||
run: npm run build
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Analyze repository (index for backend)
|
||||
run: |
|
||||
node gitnexus/dist/cli/index.js analyze || true
|
||||
if [ ! -d ".gitnexus" ]; then
|
||||
echo "::error::No .gitnexus index created"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Start backend server
|
||||
run: node dist/cli/index.js serve &
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Wait for backend readiness
|
||||
run: npx wait-on http://localhost:4747/api/repos --timeout 30000
|
||||
working-directory: gitnexus-web
|
||||
|
||||
- name: Start Vite dev server
|
||||
run: npm run dev &
|
||||
working-directory: gitnexus-web
|
||||
|
||||
- name: Wait for Vite dev server
|
||||
run: npx wait-on http://localhost:5173 --timeout 30000
|
||||
working-directory: gitnexus-web
|
||||
|
||||
- name: Run E2E tests
|
||||
run: npx playwright test
|
||||
working-directory: gitnexus-web
|
||||
env:
|
||||
E2E: '1'
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: e2e-results
|
||||
path: |
|
||||
gitnexus-web/test-results/
|
||||
gitnexus-web/playwright-report/
|
||||
retention-days: 5
|
||||
15
.github/workflows/ci-quality.yml
vendored
15
.github/workflows/ci-quality.yml
vendored
|
|
@ -12,3 +12,18 @@ jobs:
|
|||
- uses: ./.github/actions/setup-gitnexus
|
||||
- run: npx tsc --noEmit
|
||||
working-directory: gitnexus
|
||||
|
||||
typecheck-web:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: gitnexus-web/package-lock.json
|
||||
- run: npm ci
|
||||
working-directory: gitnexus-web
|
||||
- run: npx tsc -b --noEmit
|
||||
working-directory: gitnexus-web
|
||||
|
|
|
|||
563
.github/workflows/ci-report.yml
vendored
563
.github/workflows/ci-report.yml
vendored
|
|
@ -1,132 +1,126 @@
|
|||
name: CI Report
|
||||
|
||||
# Triggered after the CI workflow completes. Because workflow_run
|
||||
# always runs code from the *default branch*, it receives a read/write
|
||||
# GITHUB_TOKEN — even when the triggering PR comes from a fork.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI']
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: write
|
||||
actions: read # needed to list/download workflow run artifacts
|
||||
contents: read # needed for sparse checkout of vitest.config.ts
|
||||
pull-requests: write # needed to post sticky PR comment
|
||||
|
||||
jobs:
|
||||
pr-report:
|
||||
name: PR Report
|
||||
# Only run for pull-request CI runs
|
||||
if: >-
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion != 'cancelled'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Download PR metadata
|
||||
# ── Download artifacts from the CI run ────────────────────────
|
||||
- name: Download artifacts
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const runId = context.payload.workflow_run.id;
|
||||
|
||||
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: ${{ github.event.workflow_run.id }},
|
||||
run_id: runId,
|
||||
});
|
||||
|
||||
const meta = artifacts.data.artifacts.find(a => a.name === 'pr-meta');
|
||||
if (!meta) {
|
||||
core.setFailed('pr-meta artifact not found — skipping report');
|
||||
return;
|
||||
async function downloadArtifact(name, dest) {
|
||||
const match = allArtifacts.data.artifacts.find(a => a.name === name);
|
||||
if (!match) {
|
||||
core.warning(`Artifact "${name}" not found`);
|
||||
return false;
|
||||
}
|
||||
const zip = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: match.id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
fs.writeFileSync(path.join(dest, `${name}.zip`), Buffer.from(zip.data));
|
||||
return true;
|
||||
}
|
||||
|
||||
const zip = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: meta.id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
const temp = process.env.RUNNER_TEMP;
|
||||
await downloadArtifact('pr-meta', path.join(temp, 'dl'));
|
||||
await downloadArtifact('test-reports', path.join(temp, 'dl'));
|
||||
|
||||
const dest = path.join(process.env.RUNNER_TEMP, 'pr-meta');
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
fs.writeFileSync(path.join(dest, 'pr-meta.zip'), Buffer.from(zip.data));
|
||||
- name: Extract artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
cd "$RUNNER_TEMP/dl"
|
||||
# Extract each artifact into its own directory to avoid filename collisions
|
||||
for z in *.zip; do
|
||||
[ -f "$z" ] || continue
|
||||
name="${z%.zip}"
|
||||
mkdir -p "$RUNNER_TEMP/artifacts/$name"
|
||||
unzip -o "$z" -d "$RUNNER_TEMP/artifacts/$name"
|
||||
done
|
||||
|
||||
- name: Extract PR metadata
|
||||
- name: Read PR metadata
|
||||
id: meta
|
||||
shell: bash
|
||||
run: |
|
||||
cd "$RUNNER_TEMP/pr-meta"
|
||||
unzip -o pr-meta.zip
|
||||
|
||||
PR_NUMBER=$(cat pr-number | tr -d '[:space:]')
|
||||
if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then
|
||||
echo "::error::Invalid PR number: '$PR_NUMBER'"
|
||||
exit 1
|
||||
DIR="$RUNNER_TEMP/artifacts/pr-meta"
|
||||
if [ ! -f "$DIR/pr_number" ]; then
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::pr_number artifact missing — skipping report"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "pr-number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
|
||||
echo "quality=$(cat quality-result | tr -d '[:space:]')" >> "$GITHUB_OUTPUT"
|
||||
echo "tests=$(cat tests-result | tr -d '[:space:]')" >> "$GITHUB_OUTPUT"
|
||||
# Validate PR number is a positive integer (artifact comes from
|
||||
# untrusted fork code, so treat contents defensively).
|
||||
PR_NUM=$(cat "$DIR/pr_number" | tr -d '[:space:]')
|
||||
if ! [[ "$PR_NUM" =~ ^[0-9]+$ ]]; then
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::error::Invalid PR number in artifact: '$PR_NUM'"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
- name: Download test reports
|
||||
id: download-test-reports
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
echo "pr_number=$PR_NUM" >> "$GITHUB_OUTPUT"
|
||||
# Validate job-result strings against known GitHub Actions values.
|
||||
# Artifact contents come from the PR workflow (potentially untrusted
|
||||
# fork code), so we whitelist to prevent newline injection into
|
||||
# GITHUB_OUTPUT.
|
||||
validate_result() {
|
||||
local val
|
||||
val=$(cat "$1" | tr -d '[:space:]')
|
||||
case "$val" in
|
||||
success|failure|cancelled|skipped) echo "$val" ;;
|
||||
*) echo "unknown" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
echo "quality=$(validate_result "$DIR/quality_result")" >> "$GITHUB_OUTPUT"
|
||||
echo "tests=$(validate_result "$DIR/tests_result")" >> "$GITHUB_OUTPUT"
|
||||
echo "e2e=$(validate_result "$DIR/e2e_result")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout (for vitest config)
|
||||
if: steps.meta.outputs.skip != 'true'
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: ${{ github.event.workflow_run.id }},
|
||||
});
|
||||
|
||||
const reports = artifacts.data.artifacts.find(a => a.name === 'test-reports');
|
||||
if (!reports) {
|
||||
core.warning('test-reports artifact not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const zip = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: reports.id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
|
||||
const dest = path.join(process.env.RUNNER_TEMP, 'test-reports');
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
fs.writeFileSync(path.join(dest, 'test-reports.zip'), Buffer.from(zip.data));
|
||||
|
||||
- name: Extract test reports
|
||||
if: steps.download-test-reports.outcome == 'success'
|
||||
shell: bash
|
||||
run: |
|
||||
cd "$RUNNER_TEMP/test-reports"
|
||||
unzip -o test-reports.zip || true
|
||||
|
||||
- name: Fetch cross-platform job results
|
||||
id: jobs
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
with:
|
||||
script: |
|
||||
const jobs = await github.rest.actions.listJobsForWorkflowRun({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: ${{ github.event.workflow_run.id }},
|
||||
per_page: 50,
|
||||
});
|
||||
|
||||
const results = {};
|
||||
for (const job of jobs.data.jobs) {
|
||||
if (job.name.includes('ubuntu')) results.ubuntu = job.conclusion || 'pending';
|
||||
else if (job.name.includes('windows')) results.windows = job.conclusion || 'pending';
|
||||
else if (job.name.includes('macos')) results.macos = job.conclusion || 'pending';
|
||||
}
|
||||
core.setOutput('ubuntu', results.ubuntu || 'unknown');
|
||||
core.setOutput('windows', results.windows || 'unknown');
|
||||
core.setOutput('macos', results.macos || 'unknown');
|
||||
sparse-checkout: gitnexus/vitest.config.ts
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
# ── Fetch base branch coverage for delta reporting ───────────
|
||||
- name: Fetch base branch coverage
|
||||
if: steps.meta.outputs.skip != 'true'
|
||||
id: base-coverage
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
with:
|
||||
|
|
@ -134,6 +128,7 @@ jobs:
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Find the latest successful CI run on main
|
||||
const runs = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
|
|
@ -145,6 +140,7 @@ jobs:
|
|||
|
||||
if (runs.data.workflow_runs.length === 0) {
|
||||
core.setOutput('found', 'false');
|
||||
core.info('No successful main branch CI runs found');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -158,6 +154,7 @@ jobs:
|
|||
const testReports = artifacts.data.artifacts.find(a => a.name === 'test-reports');
|
||||
if (!testReports) {
|
||||
core.setOutput('found', 'false');
|
||||
core.info('No test-reports artifact on main branch');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -175,174 +172,242 @@ jobs:
|
|||
core.setOutput('dir', dest);
|
||||
|
||||
- name: Extract base coverage
|
||||
if: steps.base-coverage.outputs.found == 'true'
|
||||
if: steps.meta.outputs.skip != 'true' && steps.base-coverage.outputs.found == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
cd "${{ steps.base-coverage.outputs.dir }}"
|
||||
mkdir -p base
|
||||
unzip -o base.zip -d base
|
||||
|
||||
- name: Build and post report
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
- name: Build report
|
||||
if: steps.meta.outputs.skip != 'true'
|
||||
id: report
|
||||
shell: bash
|
||||
env:
|
||||
PR_NUMBER: ${{ steps.meta.outputs.pr-number }}
|
||||
QUALITY: ${{ steps.meta.outputs.quality }}
|
||||
TESTS: ${{ steps.meta.outputs.tests }}
|
||||
UBUNTU: ${{ steps.jobs.outputs.ubuntu }}
|
||||
WINDOWS: ${{ steps.jobs.outputs.windows }}
|
||||
MACOS: ${{ steps.jobs.outputs.macos }}
|
||||
E2E: ${{ steps.meta.outputs.e2e }}
|
||||
BASE_FOUND: ${{ steps.base-coverage.outputs.found }}
|
||||
BASE_DIR: ${{ steps.base-coverage.outputs.dir }}
|
||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
RUN_URL: ${{ github.event.workflow_run.html_url }}
|
||||
run: |
|
||||
DIR="$RUNNER_TEMP/artifacts"
|
||||
|
||||
# ── Helper: read coverage summary into prefixed vars ──
|
||||
read_cov() {
|
||||
local prefix=$1 file=$2
|
||||
if [ -n "$file" ] && [ -f "$file" ]; then
|
||||
local val
|
||||
val=$(jq -r '.total.statements.pct // "N/A"' "$file" 2>/dev/null) || val="N/A"
|
||||
printf -v "${prefix}_STMTS" '%s' "$val"
|
||||
val=$(jq -r '.total.branches.pct // "N/A"' "$file" 2>/dev/null) || val="N/A"
|
||||
printf -v "${prefix}_BRANCH" '%s' "$val"
|
||||
val=$(jq -r '.total.functions.pct // "N/A"' "$file" 2>/dev/null) || val="N/A"
|
||||
printf -v "${prefix}_FUNCS" '%s' "$val"
|
||||
val=$(jq -r '.total.lines.pct // "N/A"' "$file" 2>/dev/null) || val="N/A"
|
||||
printf -v "${prefix}_LINES" '%s' "$val"
|
||||
val=$(jq -r '"\(.total.statements.covered)/\(.total.statements.total)"' "$file" 2>/dev/null) || val=""
|
||||
printf -v "${prefix}_STMTS_COV" '%s' "$val"
|
||||
val=$(jq -r '"\(.total.branches.covered)/\(.total.branches.total)"' "$file" 2>/dev/null) || val=""
|
||||
printf -v "${prefix}_BRANCH_COV" '%s' "$val"
|
||||
val=$(jq -r '"\(.total.functions.covered)/\(.total.functions.total)"' "$file" 2>/dev/null) || val=""
|
||||
printf -v "${prefix}_FUNCS_COV" '%s' "$val"
|
||||
val=$(jq -r '"\(.total.lines.covered)/\(.total.lines.total)"' "$file" 2>/dev/null) || val=""
|
||||
printf -v "${prefix}_LINES_COV" '%s' "$val"
|
||||
return 0
|
||||
else
|
||||
printf -v "${prefix}_STMTS" '%s' "N/A"
|
||||
printf -v "${prefix}_BRANCH" '%s' "N/A"
|
||||
printf -v "${prefix}_FUNCS" '%s' "N/A"
|
||||
printf -v "${prefix}_LINES" '%s' "N/A"
|
||||
printf -v "${prefix}_STMTS_COV" '%s' ""
|
||||
printf -v "${prefix}_BRANCH_COV" '%s' ""
|
||||
printf -v "${prefix}_FUNCS_COV" '%s' ""
|
||||
printf -v "${prefix}_LINES_COV" '%s' ""
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Read coverage reports ──
|
||||
UNIT_SUMMARY=$(find "$DIR/test-reports" -name "coverage-summary.json" -type f 2>/dev/null | head -1)
|
||||
|
||||
read_cov "U" "$UNIT_SUMMARY"
|
||||
|
||||
# ── Read base branch coverage (main) ──
|
||||
BASE_SUMMARY=""
|
||||
if [ "$BASE_FOUND" = "true" ] && [ -n "$BASE_DIR" ]; then
|
||||
BASE_SUMMARY=$(find "$BASE_DIR/base" -name "coverage-summary.json" -type f 2>/dev/null | head -1)
|
||||
fi
|
||||
read_cov "B" "$BASE_SUMMARY"
|
||||
|
||||
# ── Locate test results ──
|
||||
RESULTS_FILE=$(find "$DIR/test-reports" -name "test-results.json" -type f 2>/dev/null | head -1)
|
||||
WEB_RESULTS_FILE=$(find "$DIR/test-reports" -name "web-test-results.json" -type f 2>/dev/null | head -1)
|
||||
|
||||
sum_results() {
|
||||
local file=$1
|
||||
if [ -n "$file" ] && [ -f "$file" ]; then
|
||||
jq -r '"\(.numTotalTests) \(.numPassedTests) \(.numFailedTests) \(.numPendingTests) \(.numTotalTestSuites) \(((.testResults | map(.endTime) | max) - (.startTime)) / 1000 | floor)"' "$file" 2>/dev/null || echo "0 0 0 0 0 0"
|
||||
else
|
||||
echo "0 0 0 0 0 0"
|
||||
fi
|
||||
}
|
||||
|
||||
read CLI_T CLI_P CLI_F CLI_S CLI_SU CLI_D <<< "$(sum_results "$RESULTS_FILE")"
|
||||
read WEB_T WEB_P WEB_F WEB_S WEB_SU WEB_D <<< "$(sum_results "$WEB_RESULTS_FILE")"
|
||||
|
||||
TOTAL=$((CLI_T + WEB_T))
|
||||
PASSED=$((CLI_P + WEB_P))
|
||||
FAILED=$((CLI_F + WEB_F))
|
||||
SKIPPED=$((CLI_S + WEB_S))
|
||||
SUITES=$((CLI_SU + WEB_SU))
|
||||
DURATION=$((CLI_D > WEB_D ? CLI_D : WEB_D))
|
||||
|
||||
# ── Status helpers ──
|
||||
status_icon() {
|
||||
case "$1" in
|
||||
success) echo "✅" ;;
|
||||
failure) echo "❌" ;;
|
||||
cancelled) echo "⏭️" ;;
|
||||
*) echo "❓" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Validate a value looks like a number (integer or decimal, optional
|
||||
# leading minus). Returns 1 for anything else — guards against awk
|
||||
# injection when artifact values come from untrusted fork code.
|
||||
is_numeric() { [[ "$1" =~ ^-?[0-9]+(\.[0-9]+)?$ ]]; }
|
||||
|
||||
cov_delta() {
|
||||
local pct=$1 base=$2
|
||||
if [ "$pct" = "N/A" ] || [ "$base" = "N/A" ]; then echo "—"; return; fi
|
||||
if ! is_numeric "$pct" || ! is_numeric "$base"; then echo "—"; return; fi
|
||||
local diff
|
||||
diff=$(awk -v p="$pct" -v b="$base" 'BEGIN { printf "%.1f", p - b }')
|
||||
if [ "$(awk -v p="$pct" -v b="$base" 'BEGIN { print (p > b) ? 1 : 0 }')" = "1" ]; then
|
||||
echo "📈 +${diff}"
|
||||
elif [ "$(awk -v p="$pct" -v b="$base" 'BEGIN { print (p < b) ? 1 : 0 }')" = "1" ]; then
|
||||
echo "📉 ${diff}"
|
||||
else
|
||||
echo "= ${diff}"
|
||||
fi
|
||||
}
|
||||
|
||||
cov_bar() {
|
||||
local pct=$1 base=$2
|
||||
if [ "$pct" = "N/A" ] || ! is_numeric "$pct"; then echo "—"; return; fi
|
||||
local filled
|
||||
filled=$(awk -v p="$pct" 'BEGIN { printf "%d", p / 5 }')
|
||||
(( filled < 0 )) && filled=0
|
||||
(( filled > 20 )) && filled=20
|
||||
local empty=$((20 - filled))
|
||||
local bar=""
|
||||
for ((i=0; i<filled; i++)); do bar+="█"; done
|
||||
for ((i=0; i<empty; i++)); do bar+="░"; done
|
||||
# Green if >= base (or base unavailable), red if dropped
|
||||
if [ "$base" = "N/A" ] || ! is_numeric "$base" || [ "$(awk -v p="$pct" -v b="$base" 'BEGIN { print (p >= b) ? 1 : 0 }')" = "1" ]; then
|
||||
echo "🟢 ${bar}"
|
||||
else
|
||||
echo "🔴 ${bar}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Overall status ──
|
||||
if [[ "$QUALITY" == "success" && "$TESTS" == "success" && ("$E2E" == "success" || "$E2E" == "skipped") ]]; then
|
||||
OVERALL="✅ **All checks passed**"
|
||||
else
|
||||
OVERALL="❌ **Some checks failed**"
|
||||
fi
|
||||
|
||||
# ── Build markdown ──
|
||||
{
|
||||
echo "body<<GITNEXUS_CI_REPORT_EOF_7f3a"
|
||||
echo "## CI Report"
|
||||
echo ""
|
||||
echo "${OVERALL}"
|
||||
echo ""
|
||||
echo "### Pipeline Status"
|
||||
echo ""
|
||||
echo "| Stage | Status | Details |"
|
||||
echo "|-------|--------|---------|"
|
||||
echo "| $(status_icon "$QUALITY") Typecheck | \`${QUALITY}\` | tsc --noEmit |"
|
||||
echo "| $(status_icon "$TESTS") Tests | \`${TESTS}\` | unit tests, 3 platforms |"
|
||||
echo "| $(status_icon "$E2E") E2E | \`${E2E}\` | gitnexus-web changes only |"
|
||||
echo ""
|
||||
|
||||
if [ "$TOTAL" -gt 0 ] 2>/dev/null; then
|
||||
echo "### Test Results"
|
||||
echo ""
|
||||
echo "| Tests | Passed | Failed | Skipped | Duration |"
|
||||
echo "|-------|--------|--------|---------|----------|"
|
||||
echo "| ${TOTAL} | ${PASSED} | ${FAILED} | ${SKIPPED} | ${DURATION}s |"
|
||||
echo ""
|
||||
|
||||
if [ "$FAILED" = "0" ]; then
|
||||
echo "✅ All **${PASSED}** tests passed"
|
||||
else
|
||||
echo "❌ **${FAILED}** failed / **${PASSED}** passed"
|
||||
fi
|
||||
if [ "$SKIPPED" != "0" ]; then
|
||||
echo ""
|
||||
echo "<details>"
|
||||
echo "<summary>${SKIPPED} test(s) skipped — expand for details</summary>"
|
||||
echo ""
|
||||
for rf in "$RESULTS_FILE" "$WEB_RESULTS_FILE"; do
|
||||
if [ -n "$rf" ] && [ -f "$rf" ]; then
|
||||
jq -r '
|
||||
.testResults[]
|
||||
| .assertionResults[]?
|
||||
| select(.status == "pending" or .status == "skipped")
|
||||
| "- \(.ancestorTitles | join(" > ")) > \(.title)"
|
||||
' "$rf" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
echo "</details>"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ── Coverage table helper ──
|
||||
cov_table() {
|
||||
local label=$1 s=$2 b=$3 f=$4 l=$5 sc=$6 bc=$7 fc=$8 lc=$9
|
||||
shift 9
|
||||
local bs=$1 bb=$2 bf=$3 bl=$4
|
||||
echo "#### ${label}"
|
||||
echo ""
|
||||
echo "| Metric | Coverage | Covered | Base | Delta | Status |"
|
||||
echo "|--------|----------|---------|------|-------|--------|"
|
||||
echo "| Statements | **${s}%** | ${sc} | ${bs}% | $(cov_delta "$s" "$bs") | $(cov_bar "$s" "$bs") |"
|
||||
echo "| Branches | **${b}%** | ${bc} | ${bb}% | $(cov_delta "$b" "$bb") | $(cov_bar "$b" "$bb") |"
|
||||
echo "| Functions | **${f}%** | ${fc} | ${bf}% | $(cov_delta "$f" "$bf") | $(cov_bar "$f" "$bf") |"
|
||||
echo "| Lines | **${l}%** | ${lc} | ${bl}% | $(cov_delta "$l" "$bl") | $(cov_bar "$l" "$bl") |"
|
||||
echo ""
|
||||
}
|
||||
|
||||
if [ "$U_STMTS" != "N/A" ]; then
|
||||
echo "### Code Coverage"
|
||||
echo ""
|
||||
cov_table "Tests" \
|
||||
"$U_STMTS" "$U_BRANCH" "$U_FUNCS" "$U_LINES" \
|
||||
"$U_STMTS_COV" "$U_BRANCH_COV" "$U_FUNCS_COV" "$U_LINES_COV" \
|
||||
"$B_STMTS" "$B_BRANCH" "$B_FUNCS" "$B_LINES"
|
||||
else
|
||||
echo "### Code Coverage"
|
||||
echo ""
|
||||
echo "⚠️ Coverage data unavailable - check the [unit test job](${RUN_URL}) for details."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "---"
|
||||
echo "<sub>📋 [View full run](${RUN_URL}) · Generated by CI</sub>"
|
||||
echo "GITNEXUS_CI_REPORT_EOF_7f3a"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Comment on PR
|
||||
if: steps.meta.outputs.skip != 'true'
|
||||
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const icon = (s) => ({ success: '✅', failure: '❌', cancelled: '⏭️' }[s] || '❓');
|
||||
const temp = process.env.RUNNER_TEMP;
|
||||
|
||||
// ── Read coverage ──
|
||||
function readCov(dir) {
|
||||
const out = { stmts: 'N/A', branch: 'N/A', funcs: 'N/A', lines: 'N/A',
|
||||
stmtsCov: '', branchCov: '', funcsCov: '', linesCov: '' };
|
||||
try {
|
||||
const files = require('child_process')
|
||||
.execSync(`find "${dir}" -name coverage-summary.json -type f`, { encoding: 'utf8' })
|
||||
.trim().split('\n').filter(Boolean);
|
||||
if (!files.length) return out;
|
||||
const d = JSON.parse(fs.readFileSync(files[0], 'utf8')).total;
|
||||
out.stmts = d.statements.pct; out.branch = d.branches.pct;
|
||||
out.funcs = d.functions.pct; out.lines = d.lines.pct;
|
||||
out.stmtsCov = `${d.statements.covered}/${d.statements.total}`;
|
||||
out.branchCov = `${d.branches.covered}/${d.branches.total}`;
|
||||
out.funcsCov = `${d.functions.covered}/${d.functions.total}`;
|
||||
out.linesCov = `${d.lines.covered}/${d.lines.total}`;
|
||||
} catch {}
|
||||
return out;
|
||||
}
|
||||
|
||||
const cov = readCov(path.join(temp, 'test-reports'));
|
||||
const base = process.env.BASE_FOUND === 'true'
|
||||
? readCov(path.join(process.env.BASE_DIR, 'base'))
|
||||
: { stmts: 'N/A', branch: 'N/A', funcs: 'N/A', lines: 'N/A' };
|
||||
|
||||
// ── Read test results ──
|
||||
let total = 0, passed = 0, failed = 0, skipped = 0, suites = 0, duration = '0s';
|
||||
let skippedTests = [];
|
||||
try {
|
||||
const files = require('child_process')
|
||||
.execSync(`find "${path.join(temp, 'test-reports')}" -name test-results.json -type f`, { encoding: 'utf8' })
|
||||
.trim().split('\n').filter(Boolean);
|
||||
if (files.length) {
|
||||
const r = JSON.parse(fs.readFileSync(files[0], 'utf8'));
|
||||
total = r.numTotalTests || 0;
|
||||
passed = r.numPassedTests || 0;
|
||||
failed = r.numFailedTests || 0;
|
||||
skipped = r.numPendingTests || 0;
|
||||
suites = r.numTotalTestSuites || 0;
|
||||
const durS = Math.floor((Math.max(...r.testResults.map(t => t.endTime)) - r.startTime) / 1000);
|
||||
duration = durS >= 60 ? `${Math.floor(durS / 60)}m ${durS % 60}s` : `${durS}s`;
|
||||
// Collect skipped test names
|
||||
for (const suite of r.testResults) {
|
||||
for (const t of (suite.assertionResults || [])) {
|
||||
if (t.status === 'pending' || t.status === 'skipped') {
|
||||
skippedTests.push(`- ${t.ancestorTitles.join(' > ')} > ${t.title}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// ── Coverage delta ──
|
||||
function delta(pct, basePct) {
|
||||
if (pct === 'N/A' || basePct === 'N/A') return '—';
|
||||
const d = (pct - basePct).toFixed(1);
|
||||
const dNum = parseFloat(d);
|
||||
if (dNum > 0) return `📈 +${d}%`;
|
||||
if (dNum < 0) return `📉 ${d}%`;
|
||||
return '=';
|
||||
}
|
||||
|
||||
// ── Build markdown ──
|
||||
const { PR_NUMBER, QUALITY, TESTS, UBUNTU, WINDOWS, MACOS, RUN_ID, HEAD_SHA } = process.env;
|
||||
const prNumber = parseInt(PR_NUMBER, 10);
|
||||
const overall = (QUALITY === 'success' && TESTS === 'success')
|
||||
? '✅ **All checks passed**' : '❌ **Some checks failed**';
|
||||
const sha = HEAD_SHA.slice(0, 7);
|
||||
|
||||
let body = `## CI Report\n\n${overall}   \`${sha}\`\n\n`;
|
||||
|
||||
body += `### Pipeline\n\n`;
|
||||
body += `| Stage | Status | Ubuntu | Windows | macOS |\n`;
|
||||
body += `|-------|--------|--------|---------|-------|\n`;
|
||||
body += `| Typecheck | ${icon(QUALITY)} \`${QUALITY}\` | — | — | — |\n`;
|
||||
body += `| Tests | ${icon(TESTS)} \`${TESTS}\` | ${icon(UBUNTU)} | ${icon(WINDOWS)} | ${icon(MACOS)} |\n\n`;
|
||||
|
||||
if (total > 0) {
|
||||
body += `### Tests\n\n`;
|
||||
body += `| Metric | Value |\n|--------|-------|\n`;
|
||||
body += `| Total | **${total}** |\n`;
|
||||
body += `| Passed | **${passed}** |\n`;
|
||||
if (failed > 0) body += `| Failed | **${failed}** |\n`;
|
||||
if (skipped > 0) body += `| Skipped | ${skipped} |\n`;
|
||||
body += `| Files | ${suites} |\n`;
|
||||
body += `| Duration | ${duration} |\n\n`;
|
||||
|
||||
if (failed === 0) {
|
||||
body += `✅ All **${passed}** tests passed across **${suites}** files\n`;
|
||||
} else {
|
||||
body += `❌ **${failed}** failed / **${passed}** passed\n`;
|
||||
}
|
||||
|
||||
if (skippedTests.length > 0) {
|
||||
body += `\n<details>\n<summary>${skipped} test(s) skipped</summary>\n\n`;
|
||||
body += skippedTests.join('\n') + '\n\n</details>\n';
|
||||
}
|
||||
body += '\n';
|
||||
}
|
||||
|
||||
if (cov.stmts !== 'N/A') {
|
||||
body += `### Coverage\n\n`;
|
||||
body += `| Metric | Coverage | Covered | Base (main) | Delta |\n`;
|
||||
body += `|--------|----------|---------|-------------|-------|\n`;
|
||||
body += `| Statements | **${cov.stmts}%** | ${cov.stmtsCov} | ${base.stmts}% | ${delta(cov.stmts, base.stmts)} |\n`;
|
||||
body += `| Branches | **${cov.branch}%** | ${cov.branchCov} | ${base.branch}% | ${delta(cov.branch, base.branch)} |\n`;
|
||||
body += `| Functions | **${cov.funcs}%** | ${cov.funcsCov} | ${base.funcs}% | ${delta(cov.funcs, base.funcs)} |\n`;
|
||||
body += `| Lines | **${cov.lines}%** | ${cov.linesCov} | ${base.lines}% | ${delta(cov.lines, base.lines)} |\n\n`;
|
||||
} else {
|
||||
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${RUN_ID}`;
|
||||
body += `### Coverage\n\n⚠️ Coverage data unavailable — check the [test job](${runUrl}) for details.\n\n`;
|
||||
}
|
||||
|
||||
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${RUN_ID}`;
|
||||
body += `---\n<sub>📋 [Full run](${runUrl}) · Coverage from Ubuntu · Generated by CI</sub>`;
|
||||
|
||||
// ── Post sticky comment ──
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100,
|
||||
direction: 'desc',
|
||||
});
|
||||
|
||||
const marker = '<!-- ci-report -->';
|
||||
const existing = comments.find(c => c.body?.includes(marker));
|
||||
const fullBody = marker + '\n' + body;
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body: fullBody,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body: fullBody,
|
||||
});
|
||||
}
|
||||
header: ci-report
|
||||
number: ${{ steps.meta.outputs.pr_number }}
|
||||
message: ${{ steps.report.outputs.body }}
|
||||
|
|
|
|||
13
.github/workflows/ci-tests.yml
vendored
13
.github/workflows/ci-tests.yml
vendored
|
|
@ -28,6 +28,18 @@ jobs:
|
|||
--coverage.reportOnFailure=true
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Install gitnexus-web dependencies
|
||||
run: npm ci
|
||||
working-directory: gitnexus-web
|
||||
|
||||
- name: Run gitnexus-web unit tests
|
||||
run: >-
|
||||
npx vitest run
|
||||
--reporter=default
|
||||
--reporter=json
|
||||
--outputFile=web-test-results.json
|
||||
working-directory: gitnexus-web
|
||||
|
||||
- name: Upload test reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
|
|
@ -37,6 +49,7 @@ jobs:
|
|||
gitnexus/coverage/coverage-summary.json
|
||||
gitnexus/coverage/coverage-final.json
|
||||
gitnexus/test-results.json
|
||||
gitnexus-web/web-test-results.json
|
||||
retention-days: 5
|
||||
|
||||
cross-platform:
|
||||
|
|
|
|||
97
.github/workflows/ci.yml
vendored
97
.github/workflows/ci.yml
vendored
|
|
@ -16,8 +16,10 @@ concurrency:
|
|||
# ── Reusable workflow orchestration ─────────────────────────────────
|
||||
# Each concern lives in its own workflow file for maintainability:
|
||||
# ci-quality.yml — typecheck (tsc --noEmit)
|
||||
# ci-tests.yml — all tests with coverage (ubuntu) + cross-platform
|
||||
# ci-report.yml — PR comment (workflow_run trigger for fork write access)
|
||||
# ci-tests.yml — unit + integration tests with coverage + cross-platform
|
||||
# ci-e2e.yml — E2E tests (only when gitnexus-web/ changes)
|
||||
#
|
||||
# Shared setup is DRY via .github/actions/setup-gitnexus composite action.
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
|
|
@ -30,11 +32,59 @@ jobs:
|
|||
permissions:
|
||||
contents: read
|
||||
|
||||
e2e:
|
||||
uses: ./.github/workflows/ci-e2e.yml
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# ── Save PR metadata for the reporting workflow ─────────────────
|
||||
# The ci-report.yml workflow (triggered by workflow_run) needs the
|
||||
# PR number and job results to post a comment. We save them as an
|
||||
# artifact because workflow_run context doesn't reliably carry PR
|
||||
# info for fork PRs.
|
||||
save-pr-meta:
|
||||
name: Save PR Metadata
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
needs: [quality, tests, e2e]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Write metadata
|
||||
shell: bash
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
QUALITY: ${{ needs.quality.result }}
|
||||
TESTS: ${{ needs.tests.result }}
|
||||
E2E: ${{ needs.e2e.result }}
|
||||
run: |
|
||||
mkdir -p pr-meta
|
||||
echo "$PR_NUMBER" > pr-meta/pr_number
|
||||
echo "$QUALITY" > pr-meta/quality_result
|
||||
echo "$TESTS" > pr-meta/tests_result
|
||||
echo "$E2E" > pr-meta/e2e_result
|
||||
# TODO(post-merge): remove backward-compat copies once ci-report.yml
|
||||
# on main reads underscore names.
|
||||
# Backward-compat: ci-report.yml on main still reads hyphenated
|
||||
# names. workflow_run always executes from the default branch, so
|
||||
# the main-branch reader won't find the underscore variants until
|
||||
# this PR is merged. Write both until then.
|
||||
cp pr-meta/pr_number pr-meta/pr-number
|
||||
cp pr-meta/quality_result pr-meta/quality-result
|
||||
cp pr-meta/tests_result pr-meta/tests-result
|
||||
cp pr-meta/e2e_result pr-meta/e2e-result
|
||||
|
||||
- name: Upload PR metadata
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: pr-meta
|
||||
path: pr-meta/
|
||||
retention-days: 1
|
||||
|
||||
# ── Unified CI gate ──────────────────────────────────────────────
|
||||
# Single required check for branch protection.
|
||||
ci-status:
|
||||
name: CI Gate
|
||||
needs: [quality, tests]
|
||||
needs: [quality, tests, e2e]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
|
@ -44,40 +94,17 @@ jobs:
|
|||
env:
|
||||
QUALITY: ${{ needs.quality.result }}
|
||||
TESTS: ${{ needs.tests.result }}
|
||||
E2E: ${{ needs.e2e.result }}
|
||||
run: |
|
||||
echo "Quality: $QUALITY"
|
||||
echo "Tests: $TESTS"
|
||||
echo "Quality: $QUALITY"
|
||||
echo "Tests: $TESTS"
|
||||
echo "E2E: $E2E"
|
||||
if [[ "$QUALITY" != "success" ]] ||
|
||||
[[ "$TESTS" != "success" ]]; then
|
||||
echo "::error::One or more CI jobs failed"
|
||||
echo "::error::Quality or test jobs failed"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$E2E" != "success" && "$E2E" != "skipped" ]]; then
|
||||
echo "::error::E2E job failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── PR metadata for ci-report.yml ────────────────────────────────
|
||||
# Saves PR number and job results so the workflow_run-triggered
|
||||
# report can post comments with a write token (works for forks).
|
||||
save-pr-meta:
|
||||
name: Save PR Metadata
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
needs: [quality, tests]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Write PR metadata
|
||||
shell: bash
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
QUALITY: ${{ needs.quality.result }}
|
||||
TESTS: ${{ needs.tests.result }}
|
||||
run: |
|
||||
mkdir -p pr-meta
|
||||
echo "$PR_NUMBER" > pr-meta/pr-number
|
||||
echo "$QUALITY" > pr-meta/quality-result
|
||||
echo "$TESTS" > pr-meta/tests-result
|
||||
|
||||
- name: Upload PR metadata
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: pr-meta
|
||||
path: pr-meta/
|
||||
retention-days: 1
|
||||
|
|
|
|||
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -33,6 +33,8 @@ coverage/
|
|||
|
||||
# Misc
|
||||
*.local
|
||||
HANDOFF.md
|
||||
HANDOFF*.md
|
||||
|
||||
.vercel
|
||||
|
||||
|
|
@ -57,6 +59,14 @@ assets/
|
|||
# Generated files (should not be indexed)
|
||||
repomix-output*
|
||||
|
||||
# Playwright artifacts
|
||||
gitnexus-web/playwright-report/
|
||||
gitnexus-web/test-results/
|
||||
|
||||
# Python test artifacts
|
||||
eval/.coverage
|
||||
eval/.hypothesis/
|
||||
|
||||
# Design docs (local only)
|
||||
docs/plans/
|
||||
|
||||
|
|
|
|||
127
gitnexus-web/e2e/debug-issues.spec.ts
Normal file
127
gitnexus-web/e2e/debug-issues.spec.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { test, expect, type TestInfo } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Debug harnesses for investigating specific UI issues.
|
||||
* Excluded from `npm run test:e2e` via testIgnore in playwright.config.ts.
|
||||
* Run directly: DEBUG_E2E=1 npx playwright test e2e/debug-issues.spec.ts
|
||||
*/
|
||||
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747';
|
||||
const debugTest = process.env.DEBUG_E2E ? test : test.skip;
|
||||
|
||||
async function connectToServer(page: import('@playwright/test').Page) {
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error') console.log(`[error] ${msg.text()}`);
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByText('Server').click();
|
||||
const serverInput = page.locator('input[name="server-url-input"]');
|
||||
await serverInput.fill(BACKEND_URL);
|
||||
await page.getByRole('button', { name: /Connect/ }).click();
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
// Wait for LadybugDB to finish loading — poll isDatabaseReady via process list visibility
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
debugTest('debug: process view Reset View button', async ({ page }, testInfo) => {
|
||||
await connectToServer(page);
|
||||
|
||||
// Open Processes tab
|
||||
await page.getByRole('button', { name: 'Nexus AI' }).click();
|
||||
await page.getByText('Processes').click();
|
||||
await expect(page.getByText(/\d+ processes detected/)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Click View on the first Cross-Community process
|
||||
// The View button has opacity-0 by default, use JS click to bypass
|
||||
const viewButtons = page.locator('button:has-text("View")');
|
||||
const count = await viewButtons.count();
|
||||
console.log(`Found ${count} View buttons`);
|
||||
|
||||
// Use evaluate to click the first one regardless of visibility
|
||||
await page.evaluate(() => {
|
||||
const btns = document.querySelectorAll('button');
|
||||
for (const btn of btns) {
|
||||
if (btn.textContent?.trim() === 'View') {
|
||||
btn.click();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for modal to appear
|
||||
const modal = page.locator('[data-testid="process-modal"]');
|
||||
await expect(modal).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Screenshot: modal should be open with flowchart
|
||||
await page.screenshot({ path: testInfo.outputPath('debug-modal-open.png'), fullPage: true });
|
||||
|
||||
// Get the diagram's current transform
|
||||
const diagramDiv = modal.locator('[style*="transform"]');
|
||||
const transformBefore = await diagramDiv.getAttribute('style');
|
||||
console.log('Transform BEFORE zoom:', transformBefore);
|
||||
|
||||
// Zoom in using the + button
|
||||
const zoomInBtn = modal.getByRole('button', { name: /Zoom in/ });
|
||||
await zoomInBtn.click();
|
||||
await zoomInBtn.click();
|
||||
await zoomInBtn.click();
|
||||
// Wait for zoom animation to settle
|
||||
await expect(async () => {
|
||||
const t = await diagramDiv.getAttribute('style');
|
||||
expect(t).not.toBe(transformBefore);
|
||||
}).toPass({ timeout: 2_000 });
|
||||
|
||||
const transformAfterZoom = await diagramDiv.getAttribute('style');
|
||||
console.log('Transform AFTER zoom:', transformAfterZoom);
|
||||
await page.screenshot({ path: testInfo.outputPath('debug-modal-zoomed.png'), fullPage: true });
|
||||
|
||||
// Click Reset View
|
||||
const resetBtn = modal.getByRole('button', { name: 'Reset View' });
|
||||
await resetBtn.click();
|
||||
// Wait for reset animation to settle
|
||||
await expect(async () => {
|
||||
const t = await diagramDiv.getAttribute('style');
|
||||
expect(t).toBe(transformBefore);
|
||||
}).toPass({ timeout: 2_000 });
|
||||
|
||||
const transformAfterReset = await diagramDiv.getAttribute('style');
|
||||
console.log('Transform AFTER reset:', transformAfterReset);
|
||||
await page.screenshot({ path: testInfo.outputPath('debug-modal-after-reset.png'), fullPage: true });
|
||||
|
||||
// Verify transform actually changed back
|
||||
expect(transformAfterZoom).not.toBe(transformBefore);
|
||||
expect(transformAfterReset).toBe(transformBefore);
|
||||
});
|
||||
|
||||
debugTest('debug: lightbulb clears node selection dimming', async ({ page }, testInfo) => {
|
||||
await connectToServer(page);
|
||||
|
||||
// Wait for graph canvas to render
|
||||
await expect(page.locator('canvas').first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath('debug-before-select.png'), fullPage: true });
|
||||
|
||||
// Click a file in the tree to select a node (causes dimming)
|
||||
const fileItem = page.getByText('start.sh');
|
||||
await fileItem.click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: testInfo.outputPath('debug-node-selected.png'), fullPage: true });
|
||||
|
||||
// Check the lightbulb button state
|
||||
const lightbulbBtn = page.locator('button[title*="Turn off"], button[title*="Turn on"]');
|
||||
const title = await lightbulbBtn.getAttribute('title');
|
||||
console.log('Lightbulb title before click:', title);
|
||||
|
||||
// Click the lightbulb
|
||||
await lightbulbBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const titleAfter = await lightbulbBtn.getAttribute('title');
|
||||
console.log('Lightbulb title after click:', titleAfter);
|
||||
await page.screenshot({ path: testInfo.outputPath('debug-after-lightbulb.png'), fullPage: true });
|
||||
|
||||
// Click it again to toggle back on
|
||||
await lightbulbBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: testInfo.outputPath('debug-after-lightbulb-toggle-back.png'), fullPage: true });
|
||||
});
|
||||
28
gitnexus-web/e2e/manual-record.spec.ts
Normal file
28
gitnexus-web/e2e/manual-record.spec.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { test } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Manual recording session for interactive debugging.
|
||||
* Opens the app and pauses so you can interact with the UI.
|
||||
* Trace, video, and screenshots are saved automatically on close.
|
||||
*
|
||||
* Run with: npx playwright test e2e/manual-record.spec.ts --headed --timeout=0
|
||||
*
|
||||
* Excluded from `npm run test:e2e` via testIgnore in playwright.config.ts.
|
||||
* Also skipped when PWDEBUG is not set or in CI, as a safety net.
|
||||
*/
|
||||
test.skip(
|
||||
!!process.env.CI || process.env.PWDEBUG !== '1',
|
||||
'Manual recording requires --headed and PWDEBUG=1. Run: PWDEBUG=1 npx playwright test e2e/manual-record.spec.ts --headed --timeout=0'
|
||||
);
|
||||
|
||||
test('manual recording session', async ({ page }) => {
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error' || msg.type() === 'warning') {
|
||||
console.log(`[${msg.type()}] ${msg.text()}`);
|
||||
}
|
||||
});
|
||||
page.on('pageerror', err => console.log(`[crash] ${err.message}`));
|
||||
|
||||
await page.goto('http://localhost:5173');
|
||||
await page.pause();
|
||||
});
|
||||
165
gitnexus-web/e2e/server-connect.spec.ts
Normal file
165
gitnexus-web/e2e/server-connect.spec.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { test, expect, type TestInfo } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E tests for the GitNexus web UI.
|
||||
* Requires:
|
||||
* - gitnexus serve running on localhost:4747
|
||||
* - gitnexus-web dev server running on localhost:5173
|
||||
*
|
||||
* Skipped when servers aren't available (CI without services, etc.).
|
||||
* Set E2E=1 to force-run even without the availability check.
|
||||
*/
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747';
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:5173';
|
||||
// Skip all tests if the gitnexus server or Vite dev server isn't reachable
|
||||
test.beforeAll(async () => {
|
||||
if (process.env.E2E) return; // force-run
|
||||
try {
|
||||
const [backendRes, frontendRes] = await Promise.allSettled([
|
||||
fetch(`${BACKEND_URL}/api/repos`),
|
||||
fetch(FRONTEND_URL),
|
||||
]);
|
||||
if (backendRes.status === 'rejected' || (backendRes.status === 'fulfilled' && !backendRes.value.ok)) {
|
||||
test.skip(true, 'gitnexus serve not available on :4747');
|
||||
return;
|
||||
}
|
||||
if (frontendRes.status === 'rejected' || (frontendRes.status === 'fulfilled' && !frontendRes.value.ok)) {
|
||||
test.skip(true, 'Vite dev server not available on :5173');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
test.skip(true, 'servers not available');
|
||||
}
|
||||
});
|
||||
|
||||
/** Shared helper: connect to the local server and wait for the graph to load */
|
||||
async function connectAndWaitForGraph(page: import('@playwright/test').Page, testInfo: TestInfo) {
|
||||
// Signal to the app that we are running under Playwright (used to skip heavy Ladybug loads).
|
||||
await page.addInitScript(() => {
|
||||
(window as unknown as { __PLAYWRIGHT_TEST__?: boolean }).__PLAYWRIGHT_TEST__ = true;
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
// Wait for the app to fully render before interacting
|
||||
const serverTab = page.getByRole('button', { name: 'Server' });
|
||||
await expect(serverTab).toBeVisible({ timeout: 15_000 });
|
||||
await page.screenshot({ path: testInfo.outputPath('step-1-landing.png') });
|
||||
|
||||
// Click "Server" tab and wait for the input to appear
|
||||
await serverTab.click();
|
||||
const serverInput = page.locator('input[name="server-url-input"]');
|
||||
await expect(serverInput).toBeVisible({ timeout: 15_000 });
|
||||
await page.screenshot({ path: testInfo.outputPath('step-2-server-tab.png') });
|
||||
await serverInput.fill(BACKEND_URL);
|
||||
await page.screenshot({ path: testInfo.outputPath('step-3-url-filled.png') });
|
||||
|
||||
await page.getByRole('button', { name: /Connect/ }).click();
|
||||
|
||||
// Wait for graph to load — status bar shows "Ready"
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText(/\d+ nodes/).first()).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath('step-4-graph-loaded.png') });
|
||||
}
|
||||
|
||||
test.describe('Server Connection & Graph Loading', () => {
|
||||
test('connects to server and loads graph', async ({ page }, testInfo) => {
|
||||
await connectAndWaitForGraph(page, testInfo);
|
||||
await page.screenshot({ path: testInfo.outputPath('graph-loaded.png'), fullPage: true });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Nexus AI', () => {
|
||||
test('panel opens and agent initializes without error', async ({ page }, testInfo) => {
|
||||
await connectAndWaitForGraph(page, testInfo);
|
||||
|
||||
// Click Nexus AI button to open the panel
|
||||
await page.getByRole('button', { name: 'Nexus AI' }).click();
|
||||
|
||||
// Should see the Nexus AI tab content
|
||||
await expect(page.getByText('Ask me anything')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath('nexus-ai-panel.png'), fullPage: true });
|
||||
|
||||
// "Database not ready" should NOT be visible
|
||||
const errorBanner = page.getByText('Database not ready');
|
||||
expect(await errorBanner.isVisible().catch(() => false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Processes Panel', () => {
|
||||
test('shows process list and View button works', async ({ page }, testInfo) => {
|
||||
await connectAndWaitForGraph(page, testInfo);
|
||||
|
||||
// Open Nexus AI panel, switch to Processes tab
|
||||
await page.getByRole('button', { name: 'Nexus AI' }).click();
|
||||
await page.getByText('Processes').click();
|
||||
|
||||
// Should show process count — wait for data-testid instead of fixed timeout
|
||||
await expect(page.locator('[data-testid="process-list-loaded"]')).toBeVisible({ timeout: 15_000 });
|
||||
await page.screenshot({ path: testInfo.outputPath('processes-panel.png'), fullPage: true });
|
||||
|
||||
// Hover first process item to reveal View button, then click it
|
||||
const processRow = page.locator('[data-testid="process-row"]').first();
|
||||
await expect(processRow).toBeVisible({ timeout: 10_000 });
|
||||
await processRow.hover();
|
||||
|
||||
const viewBtn = processRow.locator('[data-testid="process-view-button"]');
|
||||
await viewBtn.waitFor({ state: 'visible', timeout: 5_000 });
|
||||
await viewBtn.click();
|
||||
// Wait for modal to appear
|
||||
await expect(page.locator('[data-testid="process-modal"]')).toBeVisible({ timeout: 5_000 });
|
||||
await page.screenshot({ path: testInfo.outputPath('process-view-clicked.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('lightbulb highlights nodes in graph', async ({ page }, testInfo) => {
|
||||
await connectAndWaitForGraph(page, testInfo);
|
||||
|
||||
await page.getByRole('button', { name: 'Nexus AI' }).click();
|
||||
await page.getByText('Processes').click();
|
||||
await expect(page.locator('[data-testid="process-list-loaded"]')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath('before-highlight.png'), fullPage: true });
|
||||
|
||||
// Hover first process to reveal lightbulb
|
||||
const processRow = page.locator('[data-testid="process-row"]').first();
|
||||
await expect(processRow).toBeVisible({ timeout: 10_000 });
|
||||
await processRow.hover();
|
||||
|
||||
const lightbulb = processRow.locator('[data-testid="process-highlight-button"]');
|
||||
await lightbulb.waitFor({ state: 'visible', timeout: 5_000 });
|
||||
await lightbulb.click();
|
||||
// Wait for highlight to apply — the process row gets amber styling when focused
|
||||
await expect(processRow).toHaveClass(/bg-amber-950/, { timeout: 5_000 });
|
||||
await page.screenshot({ path: testInfo.outputPath('after-highlight.png'), fullPage: true });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Turn Off All Highlights', () => {
|
||||
test('selecting a node dims others, button clears it', async ({ page }, testInfo) => {
|
||||
await connectAndWaitForGraph(page, testInfo);
|
||||
|
||||
// Wait for graph to fully render by checking for canvas element
|
||||
await expect(page.locator('canvas').first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath('before-select.png'), fullPage: true });
|
||||
|
||||
// Click a file in the file tree to select a node
|
||||
const fileItem = page.getByText('package.json').first();
|
||||
await expect(fileItem).toBeVisible({ timeout: 10_000 });
|
||||
await fileItem.click();
|
||||
|
||||
// Wait for highlight toggle to show "Turn off" (indicates highlights are active)
|
||||
const highlightToggle = page.locator('[data-testid="ai-highlights-toggle"]');
|
||||
await expect(highlightToggle).toHaveAttribute('title', 'Turn off all highlights', { timeout: 5_000 });
|
||||
await page.screenshot({ path: testInfo.outputPath('node-selected.png'), fullPage: true });
|
||||
|
||||
// Click the toggle to clear all highlights
|
||||
await highlightToggle.click();
|
||||
|
||||
// Verify highlights are now off — button title changes to "Turn on"
|
||||
await expect(highlightToggle).toHaveAttribute('title', 'Turn on AI highlights', { timeout: 5_000 });
|
||||
await page.screenshot({ path: testInfo.outputPath('highlights-cleared.png'), fullPage: true });
|
||||
});
|
||||
});
|
||||
2722
gitnexus-web/package-lock.json
generated
2722
gitnexus-web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -2,16 +2,25 @@
|
|||
"name": "gitnexus",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:report": "playwright show-report"
|
||||
},
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^3.0.0",
|
||||
"@isomorphic-git/lightning-fs": "^4.6.2",
|
||||
"@ladybugdb/wasm-core": "^0.15.1",
|
||||
"@langchain/anthropic": "^1.3.10",
|
||||
"@langchain/core": "^1.1.15",
|
||||
"@langchain/google-genai": "^2.1.10",
|
||||
|
|
@ -24,22 +33,22 @@
|
|||
"buffer": "^6.0.3",
|
||||
"comlink": "^4.4.2",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-indices": "^0.17.0",
|
||||
"graphology-utils": "^2.3.0",
|
||||
"mnemonist": "^0.39.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"graphology-layout-force": "^0.2.4",
|
||||
"graphology-layout-forceatlas2": "^0.10.1",
|
||||
"graphology-layout-noverlap": "^0.4.2",
|
||||
"graphology-utils": "^2.3.0",
|
||||
"isomorphic-git": "^1.36.1",
|
||||
"jszip": "^3.10.1",
|
||||
"@ladybugdb/wasm-core": "^0.15.2",
|
||||
"langchain": "^1.2.10",
|
||||
"lru-cache": "^11.2.4",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mermaid": "^11.12.2",
|
||||
"minisearch": "^7.2.0",
|
||||
"mnemonist": "^0.39.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
|
@ -56,6 +65,11 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^7.28.5",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^18.3.5",
|
||||
|
|
@ -63,10 +77,13 @@
|
|||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vercel/node": "^5.5.16",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"jsdom": "^29.0.0",
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.0",
|
||||
"vite-plugin-static-copy": "^3.1.4",
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "^3.2.4",
|
||||
"wait-on": "^8.0.5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
48
gitnexus-web/playwright.config.ts
Normal file
48
gitnexus-web/playwright.config.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
// Enable insecure browser config (disabled security + CSP bypass) only when explicitly requested.
|
||||
// Example: PLAYWRIGHT_INSECURE=1 npx playwright test
|
||||
const insecureE2E = process.env.PLAYWRIGHT_INSECURE === '1';
|
||||
|
||||
// Base launch args: always enable software WebGL for sigma.js graph rendering in headless mode.
|
||||
const launchArgs = [
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--enable-webgl',
|
||||
'--enable-unsafe-swiftshader',
|
||||
];
|
||||
|
||||
if (insecureE2E) {
|
||||
// Allow cross-origin requests to gitnexus serve on a different port when explicitly enabled.
|
||||
launchArgs.unshift('--disable-web-security', '--disable-site-isolation-trials');
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
testIgnore: ['**/manual-record.spec.ts', '**/debug-issues.spec.ts'],
|
||||
timeout: 60_000,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'retain-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
launchOptions: {
|
||||
args: launchArgs,
|
||||
},
|
||||
// Vite dev server sets COEP require-corp for SharedArrayBuffer (LadybugDB WASM).
|
||||
// Only bypass CSP when explicitly running in insecure E2E mode.
|
||||
bypassCSP: insecureE2E,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { browserName: 'chromium' },
|
||||
},
|
||||
],
|
||||
reporter: [
|
||||
['list'],
|
||||
['html', { open: 'never', outputFolder: 'playwright-report' }],
|
||||
],
|
||||
outputDir: 'test-results',
|
||||
});
|
||||
|
|
@ -331,6 +331,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
|||
: 'w-10 h-10 flex items-center justify-center bg-elevated border border-border-subtle rounded-lg text-text-muted hover:bg-hover hover:text-text-primary transition-colors'
|
||||
}
|
||||
title={isAIHighlightsEnabled ? 'Turn off all highlights' : 'Turn on AI highlights'}
|
||||
data-testid="ai-highlights-toggle"
|
||||
>
|
||||
{isAIHighlightsEnabled ? <Lightbulb className="w-4 h-4" /> : <LightbulbOff className="w-4 h-4" />}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -211,6 +211,7 @@ export const ProcessFlowModal = ({ process, onClose, onFocusInGraph, isFullScree
|
|||
ref={containerRef}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/20 animate-fade-in"
|
||||
onClick={handleBackdropClick}
|
||||
data-testid="process-modal"
|
||||
>
|
||||
{/* Glassmorphism Modal */}
|
||||
<div className={`bg-slate-900/60 backdrop-blur-2xl border border-white/10 rounded-3xl shadow-2xl shadow-cyan-500/10 flex flex-col animate-scale-in overflow-hidden relative ${isFullScreen
|
||||
|
|
|
|||
|
|
@ -326,7 +326,7 @@ export const ProcessesPanel = () => {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-text-muted">
|
||||
<div className="flex items-center gap-2 text-xs text-text-muted" data-testid="process-list-loaded">
|
||||
<span>{totalCount} processes detected</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -462,7 +462,7 @@ const ProcessItem = ({ process, isLoading, isSelected, isFocused, onView, onTogg
|
|||
: '';
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 px-4 py-2 mx-2 rounded-lg hover:bg-hover group transition-all ${rowClass}`}>
|
||||
<div data-testid="process-row" className={`flex items-center gap-2 px-4 py-2 mx-2 rounded-lg hover:bg-hover group transition-all ${rowClass}`}>
|
||||
<GitBranch className="w-4 h-4 text-text-muted flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-text-primary truncate">{process.label}</div>
|
||||
|
|
@ -484,12 +484,14 @@ const ProcessItem = ({ process, isLoading, isSelected, isFocused, onView, onTogg
|
|||
: 'text-text-muted hover:text-cyan-400 bg-white/5 hover:bg-cyan-500/20 border border-white/10 hover:border-cyan-400/40 opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
title={isFocused ? 'Click to remove highlight from graph' : 'Click to highlight in graph'}
|
||||
data-testid="process-highlight-button"
|
||||
>
|
||||
<Lightbulb className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onView}
|
||||
disabled={isLoading}
|
||||
data-testid="process-view-button"
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md transition-all disabled:opacity-50 shadow-sm ${isSelected
|
||||
? 'text-cyan-300 bg-cyan-900/60 border border-cyan-400/60 opacity-100'
|
||||
: 'text-cyan-400 hover:text-cyan-300 bg-cyan-950/30 hover:bg-cyan-900/50 border border-cyan-500/30 hover:border-cyan-400/50 opacity-0 group-hover:opacity-100 shadow-cyan-900/20'
|
||||
|
|
|
|||
66
gitnexus-web/test/fixtures/graph.ts
vendored
Normal file
66
gitnexus-web/test/fixtures/graph.ts
vendored
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* Shared test data factories for graph structures.
|
||||
* No test code — pure data exports.
|
||||
*/
|
||||
|
||||
import type { GraphNode, GraphRelationship } from '../../src/core/graph/types';
|
||||
|
||||
export function createFileNode(name: string, filePath?: string): GraphNode {
|
||||
return {
|
||||
id: `File:${filePath ?? name}`,
|
||||
label: 'File',
|
||||
properties: { name, filePath: filePath ?? name },
|
||||
};
|
||||
}
|
||||
|
||||
export function createFunctionNode(name: string, filePath: string, line = 1): GraphNode {
|
||||
return {
|
||||
id: `Function:${filePath}:${name}:${line}`,
|
||||
label: 'Function',
|
||||
properties: { name, filePath, startLine: line, endLine: line + 10 },
|
||||
};
|
||||
}
|
||||
|
||||
export function createClassNode(name: string, filePath: string): GraphNode {
|
||||
return {
|
||||
id: `Class:${filePath}:${name}`,
|
||||
label: 'Class',
|
||||
properties: { name, filePath },
|
||||
};
|
||||
}
|
||||
|
||||
export function createProcessNode(id: string, label: string, type: 'cross_community' | 'intra_community' = 'cross_community'): GraphNode {
|
||||
return {
|
||||
id,
|
||||
label: 'Process',
|
||||
properties: {
|
||||
name: label,
|
||||
heuristicLabel: label,
|
||||
processType: type,
|
||||
stepCount: 3,
|
||||
communities: ['cluster-a', 'cluster-b'],
|
||||
} as any,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCallsRelationship(sourceId: string, targetId: string): GraphRelationship {
|
||||
return {
|
||||
id: `${sourceId}_CALLS_${targetId}`,
|
||||
sourceId,
|
||||
targetId,
|
||||
type: 'CALLS',
|
||||
confidence: 0.9,
|
||||
reason: 'same-file',
|
||||
};
|
||||
}
|
||||
|
||||
export function createContainsRelationship(sourceId: string, targetId: string): GraphRelationship {
|
||||
return {
|
||||
id: `${sourceId}_CONTAINS_${targetId}`,
|
||||
sourceId,
|
||||
targetId,
|
||||
type: 'CONTAINS',
|
||||
confidence: 1.0,
|
||||
reason: '',
|
||||
};
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
// Reset storage between tests
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
sessionStorage.removeItem('gitnexus-llm-settings');
|
||||
localStorage.removeItem('gitnexus-llm-settings'); // legacy key (migration)
|
||||
});
|
||||
|
|
|
|||
81
gitnexus-web/test/unit/constants.test.ts
Normal file
81
gitnexus-web/test/unit/constants.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
NODE_COLORS,
|
||||
NODE_SIZES,
|
||||
COMMUNITY_COLORS,
|
||||
getCommunityColor,
|
||||
DEFAULT_VISIBLE_LABELS,
|
||||
FILTERABLE_LABELS,
|
||||
ALL_EDGE_TYPES,
|
||||
DEFAULT_VISIBLE_EDGES,
|
||||
EDGE_INFO,
|
||||
} from '../../src/lib/constants';
|
||||
|
||||
describe('NODE_COLORS', () => {
|
||||
it('has a color for every node label used in NODE_SIZES', () => {
|
||||
for (const label of Object.keys(NODE_SIZES)) {
|
||||
expect(NODE_COLORS).toHaveProperty(label);
|
||||
expect(NODE_COLORS[label as keyof typeof NODE_COLORS]).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('NODE_SIZES', () => {
|
||||
it('gives Project the largest size', () => {
|
||||
const maxLabel = Object.entries(NODE_SIZES).reduce((a, b) => a[1] > b[1] ? a : b);
|
||||
expect(maxLabel[0]).toBe('Project');
|
||||
});
|
||||
|
||||
it('gives structural nodes larger sizes than code nodes', () => {
|
||||
expect(NODE_SIZES.Folder).toBeGreaterThan(NODE_SIZES.Function);
|
||||
expect(NODE_SIZES.File).toBeGreaterThan(NODE_SIZES.Variable);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCommunityColor', () => {
|
||||
it('returns valid hex colors', () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
expect(getCommunityColor(i)).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
}
|
||||
});
|
||||
|
||||
it('wraps around the palette', () => {
|
||||
const paletteSize = COMMUNITY_COLORS.length;
|
||||
expect(getCommunityColor(0)).toBe(getCommunityColor(paletteSize));
|
||||
expect(getCommunityColor(1)).toBe(getCommunityColor(paletteSize + 1));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_VISIBLE_LABELS', () => {
|
||||
it('includes common structural and code labels', () => {
|
||||
expect(DEFAULT_VISIBLE_LABELS).toContain('File');
|
||||
expect(DEFAULT_VISIBLE_LABELS).toContain('Function');
|
||||
expect(DEFAULT_VISIBLE_LABELS).toContain('Class');
|
||||
});
|
||||
|
||||
it('excludes noisy labels by default', () => {
|
||||
expect(DEFAULT_VISIBLE_LABELS).not.toContain('Variable');
|
||||
expect(DEFAULT_VISIBLE_LABELS).not.toContain('Import');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge types', () => {
|
||||
it('ALL_EDGE_TYPES contains all EDGE_INFO keys', () => {
|
||||
const edgeInfoKeys = Object.keys(EDGE_INFO).sort();
|
||||
const allEdgeTypes = [...ALL_EDGE_TYPES].sort();
|
||||
expect(edgeInfoKeys).toEqual(allEdgeTypes);
|
||||
});
|
||||
|
||||
it('DEFAULT_VISIBLE_EDGES is a subset of ALL_EDGE_TYPES', () => {
|
||||
for (const type of DEFAULT_VISIBLE_EDGES) {
|
||||
expect(ALL_EDGE_TYPES).toContain(type);
|
||||
}
|
||||
});
|
||||
|
||||
it('EDGE_INFO entries have color and label', () => {
|
||||
for (const info of Object.values(EDGE_INFO)) {
|
||||
expect(info.color).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
expect(info.label.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
68
gitnexus-web/test/unit/graph.test.ts
Normal file
68
gitnexus-web/test/unit/graph.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { createKnowledgeGraph } from '../../src/core/graph/graph';
|
||||
import { createFileNode, createFunctionNode, createCallsRelationship, createContainsRelationship } from '../fixtures/graph';
|
||||
|
||||
describe('createKnowledgeGraph', () => {
|
||||
it('starts empty', () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
expect(graph.nodeCount).toBe(0);
|
||||
expect(graph.relationshipCount).toBe(0);
|
||||
expect(graph.nodes).toEqual([]);
|
||||
expect(graph.relationships).toEqual([]);
|
||||
});
|
||||
|
||||
it('adds nodes', () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const node = createFileNode('index.ts', 'src/index.ts');
|
||||
graph.addNode(node);
|
||||
|
||||
expect(graph.nodeCount).toBe(1);
|
||||
expect(graph.nodes[0].id).toBe('File:src/index.ts');
|
||||
});
|
||||
|
||||
it('deduplicates nodes by id', () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const node = createFileNode('index.ts', 'src/index.ts');
|
||||
const duplicateNode = createFileNode('index.ts', 'src/index.ts');
|
||||
graph.addNode(node);
|
||||
graph.addNode(duplicateNode);
|
||||
|
||||
expect(graph.nodeCount).toBe(1);
|
||||
});
|
||||
|
||||
it('adds relationships', () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const rel = createCallsRelationship('fn:a', 'fn:b');
|
||||
graph.addRelationship(rel);
|
||||
|
||||
expect(graph.relationshipCount).toBe(1);
|
||||
expect(graph.relationships[0].type).toBe('CALLS');
|
||||
});
|
||||
|
||||
it('deduplicates relationships by id', () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const rel = createCallsRelationship('fn:a', 'fn:b');
|
||||
const duplicateRel = createCallsRelationship('fn:a', 'fn:b');
|
||||
graph.addRelationship(rel);
|
||||
graph.addRelationship(duplicateRel);
|
||||
|
||||
expect(graph.relationshipCount).toBe(1);
|
||||
});
|
||||
|
||||
it('builds a multi-node graph', () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const file = createFileNode('app.ts', 'src/app.ts');
|
||||
const fn1 = createFunctionNode('main', 'src/app.ts', 1);
|
||||
const fn2 = createFunctionNode('helper', 'src/app.ts', 20);
|
||||
|
||||
graph.addNode(file);
|
||||
graph.addNode(fn1);
|
||||
graph.addNode(fn2);
|
||||
graph.addRelationship(createContainsRelationship(file.id, fn1.id));
|
||||
graph.addRelationship(createContainsRelationship(file.id, fn2.id));
|
||||
graph.addRelationship(createCallsRelationship(fn1.id, fn2.id));
|
||||
|
||||
expect(graph.nodeCount).toBe(3);
|
||||
expect(graph.relationshipCount).toBe(3);
|
||||
});
|
||||
});
|
||||
107
gitnexus-web/test/unit/mermaid-generator.test.ts
Normal file
107
gitnexus-web/test/unit/mermaid-generator.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { generateProcessMermaid, generateSimpleMermaid } from '../../src/lib/mermaid-generator';
|
||||
import type { ProcessData } from '../../src/lib/mermaid-generator';
|
||||
|
||||
describe('generateProcessMermaid', () => {
|
||||
it('returns placeholder for empty steps', () => {
|
||||
const process: ProcessData = {
|
||||
id: 'p1',
|
||||
label: 'Empty',
|
||||
processType: 'intra_community',
|
||||
steps: [],
|
||||
};
|
||||
expect(generateProcessMermaid(process)).toContain('No steps found');
|
||||
});
|
||||
|
||||
it('generates a linear chain without edges', () => {
|
||||
const process: ProcessData = {
|
||||
id: 'p1',
|
||||
label: 'GET -> Handler',
|
||||
processType: 'intra_community',
|
||||
steps: [
|
||||
{ id: 'fn:a', name: 'handleGet', filePath: 'src/routes.ts', stepNumber: 1 },
|
||||
{ id: 'fn:b', name: 'validate', filePath: 'src/validate.ts', stepNumber: 2 },
|
||||
{ id: 'fn:c', name: 'respond', filePath: 'src/respond.ts', stepNumber: 3 },
|
||||
],
|
||||
};
|
||||
|
||||
const result = generateProcessMermaid(process);
|
||||
expect(result).toContain('graph TD');
|
||||
expect(result).toContain('handleGet');
|
||||
expect(result).toContain('validate');
|
||||
expect(result).toContain('respond');
|
||||
// Linear chain: a -> b -> c
|
||||
expect(result).toContain('-->');
|
||||
});
|
||||
|
||||
it('uses CALLS edges when provided', () => {
|
||||
const process: ProcessData = {
|
||||
id: 'p1',
|
||||
label: 'Branching',
|
||||
processType: 'intra_community',
|
||||
steps: [
|
||||
{ id: 'fn:a', name: 'entry', filePath: 'src/a.ts', stepNumber: 1 },
|
||||
{ id: 'fn:b', name: 'branchA', filePath: 'src/b.ts', stepNumber: 2 },
|
||||
{ id: 'fn:c', name: 'branchB', filePath: 'src/c.ts', stepNumber: 3 },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'fn:a', to: 'fn:b', type: 'CALLS' },
|
||||
{ from: 'fn:a', to: 'fn:c', type: 'CALLS' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = generateProcessMermaid(process);
|
||||
// Both edges should appear
|
||||
expect(result).toContain('fn_a --> fn_b');
|
||||
expect(result).toContain('fn_a --> fn_c');
|
||||
});
|
||||
|
||||
it('applies entry and terminal classes', () => {
|
||||
const process: ProcessData = {
|
||||
id: 'p1',
|
||||
label: 'Flow',
|
||||
processType: 'intra_community',
|
||||
steps: [
|
||||
{ id: 'fn:start', name: 'start', filePath: 'src/a.ts', stepNumber: 1 },
|
||||
{ id: 'fn:end', name: 'end', filePath: 'src/b.ts', stepNumber: 2 },
|
||||
],
|
||||
};
|
||||
|
||||
const result = generateProcessMermaid(process);
|
||||
expect(result).toContain(':::entry');
|
||||
expect(result).toContain(':::terminal');
|
||||
});
|
||||
|
||||
it('uses subgraphs for cross-community processes with clusters', () => {
|
||||
const process: ProcessData = {
|
||||
id: 'p1',
|
||||
label: 'Cross',
|
||||
processType: 'cross_community',
|
||||
steps: [
|
||||
{ id: 'fn:a', name: 'a', filePath: 'src/a.ts', stepNumber: 1, cluster: 'Auth' },
|
||||
{ id: 'fn:b', name: 'b', filePath: 'src/b.ts', stepNumber: 2, cluster: 'DB' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = generateProcessMermaid(process);
|
||||
expect(result).toContain('subgraph');
|
||||
expect(result).toContain('Auth');
|
||||
expect(result).toContain('DB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateSimpleMermaid', () => {
|
||||
it('generates a preview with entry and terminal', () => {
|
||||
const result = generateSimpleMermaid('POST -> ShouldRedact', 5);
|
||||
expect(result).toContain('graph LR');
|
||||
expect(result).toContain('POST');
|
||||
expect(result).toContain('ShouldRedact');
|
||||
expect(result).toContain('3 steps');
|
||||
});
|
||||
|
||||
it('handles labels without arrow', () => {
|
||||
const result = generateSimpleMermaid('SingleNode', 2);
|
||||
expect(result).toContain('graph LR');
|
||||
expect(result).toContain('SingleNode');
|
||||
});
|
||||
});
|
||||
31
gitnexus-web/test/unit/path-resolution.test.ts
Normal file
31
gitnexus-web/test/unit/path-resolution.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizePath, resolveFilePath } from '../../src/lib/path-resolution';
|
||||
|
||||
describe('path-resolution utilities', () => {
|
||||
const contents = new Map<string, string>([
|
||||
['src/components/Header.tsx', ''],
|
||||
['src/core/utils/index.ts', ''],
|
||||
['README.md', ''],
|
||||
['src/lib/path-resolution.ts', ''],
|
||||
]);
|
||||
|
||||
it('normalizes leading ./ and backslashes', () => {
|
||||
expect(normalizePath('./src\\components\\Header.tsx')).toBe('src/components/Header.tsx');
|
||||
});
|
||||
|
||||
it('prefers exact matches', () => {
|
||||
expect(resolveFilePath(contents, 'src/components/Header.tsx')).toBe('src/components/Header.tsx');
|
||||
});
|
||||
|
||||
it('resolves ends-with partials', () => {
|
||||
expect(resolveFilePath(contents, 'core/utils/index.ts')).toBe('src/core/utils/index.ts');
|
||||
});
|
||||
|
||||
it('falls back to segment matching', () => {
|
||||
expect(resolveFilePath(contents, 'lib/path')).toBe('src/lib/path-resolution.ts');
|
||||
});
|
||||
|
||||
it('returns null for empty requests', () => {
|
||||
expect(resolveFilePath(contents, '')).toBeNull();
|
||||
});
|
||||
});
|
||||
76
gitnexus-web/test/unit/server-connection.test.ts
Normal file
76
gitnexus-web/test/unit/server-connection.test.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeServerUrl, extractFileContents } from '../../src/services/server-connection';
|
||||
import type { GraphNode } from '../../src/core/graph/types';
|
||||
|
||||
describe('normalizeServerUrl', () => {
|
||||
it('adds http:// to localhost', () => {
|
||||
expect(normalizeServerUrl('localhost:4747')).toBe('http://localhost:4747/api');
|
||||
});
|
||||
|
||||
it('adds http:// to 127.0.0.1', () => {
|
||||
expect(normalizeServerUrl('127.0.0.1:4747')).toBe('http://127.0.0.1:4747/api');
|
||||
});
|
||||
|
||||
it('adds https:// to non-local hosts', () => {
|
||||
expect(normalizeServerUrl('example.com')).toBe('https://example.com/api');
|
||||
});
|
||||
|
||||
it('strips trailing slashes', () => {
|
||||
expect(normalizeServerUrl('http://localhost:4747/')).toBe('http://localhost:4747/api');
|
||||
expect(normalizeServerUrl('http://localhost:4747///')).toBe('http://localhost:4747/api');
|
||||
});
|
||||
|
||||
it('does not double-append /api', () => {
|
||||
expect(normalizeServerUrl('http://localhost:4747/api')).toBe('http://localhost:4747/api');
|
||||
});
|
||||
|
||||
it('trims whitespace', () => {
|
||||
expect(normalizeServerUrl(' localhost:4747 ')).toBe('http://localhost:4747/api');
|
||||
});
|
||||
|
||||
it('preserves existing https://', () => {
|
||||
expect(normalizeServerUrl('https://gitnexus.example.com')).toBe('https://gitnexus.example.com/api');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractFileContents', () => {
|
||||
it('extracts content from File nodes', () => {
|
||||
const nodes: GraphNode[] = [
|
||||
{
|
||||
id: 'File:src/index.ts',
|
||||
label: 'File',
|
||||
properties: { name: 'index.ts', filePath: 'src/index.ts', content: 'console.log("hello")' } as any,
|
||||
},
|
||||
];
|
||||
const result = extractFileContents(nodes);
|
||||
expect(result['src/index.ts']).toBe('console.log("hello")');
|
||||
});
|
||||
|
||||
it('ignores non-File nodes', () => {
|
||||
const nodes: GraphNode[] = [
|
||||
{
|
||||
id: 'Function:main',
|
||||
label: 'Function',
|
||||
properties: { name: 'main', filePath: 'src/index.ts', content: 'fn body' } as any,
|
||||
},
|
||||
];
|
||||
const result = extractFileContents(nodes);
|
||||
expect(Object.keys(result)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignores File nodes without content', () => {
|
||||
const nodes: GraphNode[] = [
|
||||
{
|
||||
id: 'File:src/empty.ts',
|
||||
label: 'File',
|
||||
properties: { name: 'empty.ts', filePath: 'src/empty.ts' },
|
||||
},
|
||||
];
|
||||
const result = extractFileContents(nodes);
|
||||
expect(Object.keys(result)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns empty object for empty input', () => {
|
||||
expect(extractFileContents([])).toEqual({});
|
||||
});
|
||||
});
|
||||
145
gitnexus-web/test/unit/settings-service.test.ts
Normal file
145
gitnexus-web/test/unit/settings-service.test.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
setActiveProvider,
|
||||
getActiveProviderConfig,
|
||||
isProviderConfigured,
|
||||
clearSettings,
|
||||
getProviderDisplayName,
|
||||
getAvailableModels,
|
||||
} from '../../src/core/llm/settings-service';
|
||||
|
||||
describe('loadSettings', () => {
|
||||
it('returns defaults when nothing is stored', () => {
|
||||
const settings = loadSettings();
|
||||
expect(settings.activeProvider).toBeDefined();
|
||||
expect(settings.openai).toBeDefined();
|
||||
expect(settings.ollama).toBeDefined();
|
||||
});
|
||||
|
||||
it('merges stored values with defaults', () => {
|
||||
sessionStorage.setItem('gitnexus-llm-settings', JSON.stringify({
|
||||
activeProvider: 'ollama',
|
||||
ollama: { model: 'qwen3-coder:30b' },
|
||||
}));
|
||||
|
||||
const settings = loadSettings();
|
||||
expect(settings.activeProvider).toBe('ollama');
|
||||
expect(settings.ollama.model).toBe('qwen3-coder:30b');
|
||||
// Should still have other provider defaults
|
||||
expect(settings.openai).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns defaults on corrupted JSON', () => {
|
||||
sessionStorage.setItem('gitnexus-llm-settings', 'not-json{{{');
|
||||
const settings = loadSettings();
|
||||
expect(settings.activeProvider).toBeDefined();
|
||||
});
|
||||
|
||||
it('migrates legacy localStorage to sessionStorage', () => {
|
||||
localStorage.setItem('gitnexus-llm-settings', JSON.stringify({
|
||||
activeProvider: 'ollama',
|
||||
ollama: { model: 'migrated-model' },
|
||||
}));
|
||||
|
||||
const settings = loadSettings();
|
||||
expect(settings.ollama.model).toBe('migrated-model');
|
||||
expect(sessionStorage.getItem('gitnexus-llm-settings')).not.toBeNull();
|
||||
expect(localStorage.getItem('gitnexus-llm-settings')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveSettings / clearSettings', () => {
|
||||
it('persists settings to sessionStorage', () => {
|
||||
const settings = loadSettings();
|
||||
settings.activeProvider = 'anthropic';
|
||||
saveSettings(settings);
|
||||
expect(loadSettings().activeProvider).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('clearSettings removes settings from both storages', () => {
|
||||
saveSettings({ ...loadSettings(), activeProvider: 'anthropic' });
|
||||
expect(sessionStorage.getItem('gitnexus-llm-settings')).not.toBeNull();
|
||||
clearSettings();
|
||||
expect(sessionStorage.getItem('gitnexus-llm-settings')).toBeNull();
|
||||
expect(localStorage.getItem('gitnexus-llm-settings')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setActiveProvider', () => {
|
||||
it('changes the active provider and persists', () => {
|
||||
setActiveProvider('gemini');
|
||||
expect(loadSettings().activeProvider).toBe('gemini');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActiveProviderConfig', () => {
|
||||
it('returns null for unconfigured providers requiring API keys', () => {
|
||||
setActiveProvider('openai');
|
||||
expect(getActiveProviderConfig()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns config for ollama without API key', () => {
|
||||
setActiveProvider('ollama');
|
||||
const config = getActiveProviderConfig();
|
||||
expect(config).not.toBeNull();
|
||||
expect(config!.provider).toBe('ollama');
|
||||
});
|
||||
|
||||
it('returns config for openai when API key is set', () => {
|
||||
const settings = loadSettings();
|
||||
settings.activeProvider = 'openai';
|
||||
settings.openai = { ...settings.openai, apiKey: 'sk-test-123' };
|
||||
saveSettings(settings);
|
||||
|
||||
const config = getActiveProviderConfig();
|
||||
expect(config).not.toBeNull();
|
||||
expect(config!.provider).toBe('openai');
|
||||
});
|
||||
|
||||
it('returns null for openrouter with empty API key', () => {
|
||||
const settings = loadSettings();
|
||||
settings.activeProvider = 'openrouter';
|
||||
settings.openrouter = { ...settings.openrouter, apiKey: ' ' };
|
||||
saveSettings(settings);
|
||||
|
||||
expect(getActiveProviderConfig()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isProviderConfigured', () => {
|
||||
it('returns false when provider requires API key and none is set', () => {
|
||||
// Manually build a clean openai config with no API key
|
||||
saveSettings({ ...loadSettings(), activeProvider: 'openai', openai: { apiKey: '', model: 'gpt-4o', temperature: 0.1 } });
|
||||
expect(isProviderConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for ollama (no key required)', () => {
|
||||
setActiveProvider('ollama');
|
||||
expect(isProviderConfigured()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProviderDisplayName', () => {
|
||||
it('returns human-readable names', () => {
|
||||
expect(getProviderDisplayName('openai')).toBe('OpenAI');
|
||||
expect(getProviderDisplayName('azure-openai')).toBe('Azure OpenAI');
|
||||
expect(getProviderDisplayName('gemini')).toBe('Google Gemini');
|
||||
expect(getProviderDisplayName('anthropic')).toBe('Anthropic');
|
||||
expect(getProviderDisplayName('ollama')).toBe('Ollama (Local)');
|
||||
expect(getProviderDisplayName('openrouter')).toBe('OpenRouter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailableModels', () => {
|
||||
it('returns models for known providers', () => {
|
||||
expect(getAvailableModels('openai').length).toBeGreaterThan(0);
|
||||
expect(getAvailableModels('ollama').length).toBeGreaterThan(0);
|
||||
expect(getAvailableModels('anthropic')).toContain('claude-sonnet-4-20250514');
|
||||
});
|
||||
|
||||
it('returns empty array for unknown provider', () => {
|
||||
expect(getAvailableModels('unknown' as any)).toEqual([]);
|
||||
});
|
||||
});
|
||||
17
gitnexus-web/test/unit/utils.test.ts
Normal file
17
gitnexus-web/test/unit/utils.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { generateId } from '../../src/lib/utils';
|
||||
|
||||
describe('generateId', () => {
|
||||
it('creates label:name format', () => {
|
||||
expect(generateId('File', 'index.ts')).toBe('File:index.ts');
|
||||
expect(generateId('Function', 'main')).toBe('Function:main');
|
||||
});
|
||||
|
||||
it('handles empty strings', () => {
|
||||
expect(generateId('', '')).toBe(':');
|
||||
});
|
||||
|
||||
it('preserves special characters in name', () => {
|
||||
expect(generateId('File', 'src/components/App.tsx')).toBe('File:src/components/App.tsx');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,17 +1,40 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['test/setup.ts'],
|
||||
include: ['test/**/*.test.ts'],
|
||||
exclude: ['**/node_modules/**', '**/dist/**'],
|
||||
},
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@anthropic-ai/sdk/lib/transform-json-schema': path.resolve(__dirname, 'node_modules/@anthropic-ai/sdk/lib/transform-json-schema.mjs'),
|
||||
'mermaid': path.resolve(__dirname, 'node_modules/mermaid/dist/mermaid.esm.min.mjs'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./test/setup.ts'],
|
||||
include: ['test/**/*.test.{ts,tsx}'],
|
||||
testTimeout: 15000,
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/**/*.{ts,tsx}'],
|
||||
exclude: [
|
||||
'src/workers/**', // Web workers (require worker env)
|
||||
'src/core/lbug/**', // WASM (requires SharedArrayBuffer)
|
||||
'src/core/tree-sitter/**', // WASM (requires tree-sitter binaries)
|
||||
'src/core/embeddings/**', // WASM (requires ML model)
|
||||
'src/main.tsx', // Entry point
|
||||
'src/vite-env.d.ts', // Type declarations
|
||||
],
|
||||
thresholds: {
|
||||
statements: 10,
|
||||
branches: 10,
|
||||
functions: 10,
|
||||
lines: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
FROM node:22-bookworm
|
||||
FROM node:20-bookworm
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
COPY . .
|
||||
|
|
|
|||
|
|
@ -103,6 +103,6 @@
|
|||
"tree-sitter": "0.22.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ export default defineConfig({
|
|||
branches: 23,
|
||||
functions: 28,
|
||||
lines: 27,
|
||||
autoUpdate: true,
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -92,3 +91,4 @@ export default defineConfig({
|
|||
],
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue