ci: E2E workflow, web typecheck job, pre-commit hook, test suite (#486)

This commit is contained in:
John R. Eakin 2026-03-24 01:01:08 -05:00 committed by GitHub
parent fec47cbc32
commit f0540b33fb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 3413 additions and 1143 deletions

91
.github/workflows/ci-e2e.yml vendored Normal file
View 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

View file

@ -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

View file

@ -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} &ensp; \`${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 }}

View file

@ -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:

View file

@ -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
View file

@ -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/

View 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 });
});

View 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();
});

View 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 });
});
});

File diff suppressed because it is too large Load diff

View file

@ -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"
}
}

View 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',
});

View file

@ -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>

View file

@ -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

View file

@ -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
View 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: '',
};
}

View file

@ -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)
});

View 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);
}
});
});

View 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);
});
});

View 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');
});
});

View 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();
});
});

View 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({});
});
});

View 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([]);
});
});

View 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');
});
});

View file

@ -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,
},
},
},
});

View file

@ -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 . .

View file

@ -103,6 +103,6 @@
"tree-sitter": "0.22.4"
},
"engines": {
"node": ">=18.0.0"
"node": ">=20.0.0"
}
}

View file

@ -32,7 +32,6 @@ export default defineConfig({
branches: 23,
functions: 28,
lines: 27,
autoUpdate: true,
},
},
@ -92,3 +91,4 @@ export default defineConfig({
],
},
});