From c8599313146d0f891e3e7bd0990ad4e9a8f6ca46 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:24:14 -0300 Subject: [PATCH 1/4] fix: add dashboard-scoped typecheck gate covering src/app/(dashboard) TSX (#7033) (#7203) typecheck:core (the only blocking CI typecheck gate) runs against a curated 27-file allowlist that excludes all src/app/(dashboard) TSX, and next.config.mjs sets typescript.ignoreBuildErrors: true so next build never type-checks it either. Orphaned-identifier regressions there (the exact class fixed in #6625/#6909) were invisible to CI. Adds tsconfig.typecheck-dashboard.json (extends tsconfig.json, scoped to src/app/(dashboard)/**/*.ts(x)) plus check:dashboard-typecheck, a gate script that runs tsc against it and diffs per-file/per-TS-code error counts against a frozen baseline (config/quality/dashboard-typecheck-baseline.json, 262 pre-existing errors), following the same stale-enforcement allowlist pattern as check-known-symbols. Only NEW errors beyond the baselined count fail the gate; wired as a new blocking step in ci.yml (lint job) and quality.yml (fast-gates). Regression test (tests/unit/build/check-dashboard-typecheck.test.ts, 8 tests) reproduces the #6625/#6909 orphaned-identifier bug class against the pure parseTscOutput/diffAgainstBaseline helpers. --- .github/workflows/ci.yml | 7 + .github/workflows/quality.yml | 4 + .../fixes/7033-dashboard-typecheck-gate.md | 1 + .../quality/dashboard-typecheck-baseline.json | 259 ++++++++++++++++++ docs/architecture/QUALITY_GATES.md | 1 + package.json | 1 + scripts/check/check-dashboard-typecheck.mjs | 177 ++++++++++++ .../build/check-dashboard-typecheck.test.ts | 111 ++++++++ tsconfig.typecheck-dashboard.json | 8 + 9 files changed, 569 insertions(+) create mode 100644 changelog.d/fixes/7033-dashboard-typecheck-gate.md create mode 100644 config/quality/dashboard-typecheck-baseline.json create mode 100644 scripts/check/check-dashboard-typecheck.mjs create mode 100644 tests/unit/build/check-dashboard-typecheck.test.ts create mode 100644 tsconfig.typecheck-dashboard.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d21489db3d..b665c6735d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,6 +135,13 @@ jobs: # check:docs-sync is run by the docs-sync-strict job (via check:docs-all) and the # husky pre-commit hook; the standalone copy here was redundant (ROI dedup). - run: npm run typecheck:core + # #7033: typecheck:core's curated file allowlist does not cover + # src/app/(dashboard) TSX (and next.config.mjs sets ignoreBuildErrors: + # true, so `next build` never type-checks it either) — orphaned + # identifiers there (see #6625/#6909) were invisible to CI. This gate + # runs tsc scoped to the dashboard tree against a frozen baseline of + # pre-existing errors; only NEW errors fail it. + - run: npm run check:dashboard-typecheck # typecheck:noimplicit:core dropped from this job (2026-07 optimize): # it was advisory (continue-on-error) and largely subsumed by the blocking # check:type-coverage ratchet in quality-gate. Local: npm run typecheck:noimplicit:core. diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 2c5640ca6b..e16b4b1df6 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -143,6 +143,10 @@ jobs: - run: npm run check:complexity-ratchets - name: Typecheck (core) run: npm run typecheck:core + # #7033: dashboard-scoped typecheck gate — src/app/(dashboard) TSX is not + # covered by typecheck:core's curated allowlist. See check-dashboard-typecheck.mjs. + - name: Typecheck (dashboard) + run: npm run check:dashboard-typecheck # WS4.2 (v3.8.49 plan): TypeScript 7 native-compiler SHADOW — advisory only. # TS7 went GA 2026-07-08 with 8-12x type-check speedups; its Compiler API only # arrives in 7.1, so typescript-eslint / type-coverage / Stryker stay on 6.x diff --git a/changelog.d/fixes/7033-dashboard-typecheck-gate.md b/changelog.d/fixes/7033-dashboard-typecheck-gate.md new file mode 100644 index 0000000000..89a33f44d6 --- /dev/null +++ b/changelog.d/fixes/7033-dashboard-typecheck-gate.md @@ -0,0 +1 @@ +- fix(ci): add a dashboard-scoped typecheck gate covering `src/app/(dashboard)` TSX, previously invisible to `typecheck:core` and `next build` (#7033) diff --git a/config/quality/dashboard-typecheck-baseline.json b/config/quality/dashboard-typecheck-baseline.json new file mode 100644 index 0000000000..4f7857d6ce --- /dev/null +++ b/config/quality/dashboard-typecheck-baseline.json @@ -0,0 +1,259 @@ +{ + "open-sse/services/payloadRules.ts": { + "TS2677": 1 + }, + "src/app/(dashboard)/dashboard/HomePageClient.tsx": { + "TS2339": 16 + }, + "src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient.tsx": { + "TS2503": 3 + }, + "src/app/(dashboard)/dashboard/agent-skills/components/CoverageBar.tsx": { + "TS2503": 1 + }, + "src/app/(dashboard)/dashboard/agent-skills/components/McpA2aLinksBar.tsx": { + "TS2503": 2 + }, + "src/app/(dashboard)/dashboard/agent-skills/components/SkillCard.tsx": { + "TS2503": 1 + }, + "src/app/(dashboard)/dashboard/agent-skills/components/SkillPreviewPane.tsx": { + "TS2503": 2 + }, + "src/app/(dashboard)/dashboard/cache/__tests__/CachePage.test.tsx": { + "TS2305": 3, + "TS1117": 1 + }, + "src/app/(dashboard)/dashboard/cache/__tests__/CachePerformance.test.tsx": { + "TS2305": 1, + "TS2322": 2 + }, + "src/app/(dashboard)/dashboard/cache/__tests__/CacheTrends.test.tsx": { + "TS2305": 1, + "TS2322": 6 + }, + "src/app/(dashboard)/dashboard/cache/__tests__/IdempotencyLayer.test.tsx": { + "TS2305": 1 + }, + "src/app/(dashboard)/dashboard/cache/__tests__/MemoryCards.test.tsx": { + "TS2305": 1, + "TS2322": 1 + }, + "src/app/(dashboard)/dashboard/cache/components/CachePerformance.tsx": { + "TS2339": 1 + }, + "src/app/(dashboard)/dashboard/cli-code/components/ClaudeToolCard.tsx": { + "TS2339": 2 + }, + "src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx": { + "TS2345": 3 + }, + "src/app/(dashboard)/dashboard/cli-code/components/CustomCliCard.tsx": { + "TS2345": 1 + }, + "src/app/(dashboard)/dashboard/cli-code/components/DroidToolCard.tsx": { + "TS2554": 2 + }, + "src/app/(dashboard)/dashboard/combos/page.tsx": { + "TS2339": 4, + "TS2345": 5, + "TS2698": 1, + "TS2322": 13 + }, + "src/app/(dashboard)/dashboard/compression/studio/EncoderComparisonTable.tsx": { + "TS2322": 1 + }, + "src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": { + "TS2304": 1 + }, + "src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx": { + "TS2551": 7, + "TS2322": 2, + "TS2719": 2, + "TS2739": 1 + }, + "src/app/(dashboard)/dashboard/costs/quota-share/components/StackedAllocationBar.tsx": { + "TS2503": 1 + }, + "src/app/(dashboard)/dashboard/costs/quota-share/components/UsageLogCard.tsx": { + "TS2869": 2 + }, + "src/app/(dashboard)/dashboard/discovery/__tests__/DiscoveryPageClient.test.tsx": { + "TS2305": 2 + }, + "src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": { + "TS2322": 18 + }, + "src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab.tsx": { + "TS2322": 1 + }, + "src/app/(dashboard)/dashboard/omni-skills/OmniSkillsPageClient.tsx": { + "TS2503": 1 + }, + "src/app/(dashboard)/dashboard/omni-skills/components/OmniExecutionsTab.tsx": { + "TS2503": 1 + }, + "src/app/(dashboard)/dashboard/omni-skills/components/OmniMarketplaceTab.tsx": { + "TS2503": 1 + }, + "src/app/(dashboard)/dashboard/omni-skills/components/OmniSandboxTab.tsx": { + "TS2503": 1 + }, + "src/app/(dashboard)/dashboard/omni-skills/components/OmniSkillCard.tsx": { + "TS2503": 1 + }, + "src/app/(dashboard)/dashboard/omni-skills/components/OmniSkillsList.tsx": { + "TS2503": 1 + }, + "src/app/(dashboard)/dashboard/omni-skills/components/SkillInspectorPane.tsx": { + "TS2503": 1 + }, + "src/app/(dashboard)/dashboard/playground/components/PresetPicker.tsx": { + "TS2352": 1 + }, + "src/app/(dashboard)/dashboard/playground/components/ToolsBuilder.tsx": { + "TS2339": 1 + }, + "src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": { + "TS2322": 4 + }, + "src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1e.test.tsx": { + "TS2741": 2 + }, + "src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx": { + "TS2741": 1 + }, + "src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx": { + "TS2345": 3, + "TS2322": 1 + }, + "src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsListPanel.tsx": { + "TS2322": 2 + }, + "src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx": { + "TS2739": 1 + }, + "src/app/(dashboard)/dashboard/providers/[id]/components/ModelCompatPopover.tsx": { + "TS2304": 5 + }, + "src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx": { + "TS2322": 3, + "TS2739": 1, + "TS2345": 3 + }, + "src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModelsSection.tsx": { + "TS2322": 1 + }, + "src/app/(dashboard)/dashboard/providers/[id]/components/ProviderParamFilterSection.tsx": { + "TS2339": 6 + }, + "src/app/(dashboard)/dashboard/providers/[id]/components/ProviderPlaygroundPanel.tsx": { + "TS2503": 1 + }, + "src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/phase1d.test.tsx": { + "TS2739": 2 + }, + "src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": { + "TS2322": 1 + }, + "src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelImportHandlers.ts": { + "TS2339": 1 + }, + "src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts": { + "TS2339": 15 + }, + "src/app/(dashboard)/dashboard/providers/components/onboarding/providerOnboardingCatalog.ts": { + "TS2339": 4, + "TS2345": 2 + }, + "src/app/(dashboard)/dashboard/providers/providerPageUtils.ts": { + "TS2345": 1 + }, + "src/app/(dashboard)/dashboard/providers/services/components/CliproxyModelMappingEditor.tsx": { + "TS2339": 1 + }, + "src/app/(dashboard)/dashboard/quota/page.tsx": { + "TS2339": 4 + }, + "src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": { + "TS2304": 1 + }, + "src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx": { + "TS2339": 4 + }, + "src/app/(dashboard)/dashboard/settings/components/RedisLauncherPanel.tsx": { + "TS2345": 11 + }, + "src/app/(dashboard)/dashboard/settings/components/SidebarTab.tsx": { + "TS2322": 1 + }, + "src/app/(dashboard)/dashboard/settings/components/proxy/FreePoolTab.tsx": { + "TS2304": 1 + }, + "src/app/(dashboard)/dashboard/tools/traffic-inspector/components/CustomHostsManager.tsx": { + "TS2339": 1 + }, + "src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/ResponseBodyTab.tsx": { + "TS2339": 5 + }, + "src/app/(dashboard)/dashboard/translator/components/MonitorTab.tsx": { + "TS2345": 1 + }, + "src/app/(dashboard)/dashboard/translator/components/advanced/CompressionPreviewAccordion.tsx": { + "TS4104": 1 + }, + "src/app/(dashboard)/dashboard/translator/components/advanced/StreamTransformerAccordion.tsx": { + "TS2345": 1 + }, + "src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": { + "TS2339": 2 + }, + "src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaEnvGroup.tsx": { + "TS2739": 1 + }, + "src/lib/combos/builderDraft.ts": { + "TS2741": 1 + }, + "src/lib/providers/codexFastTier.ts": { + "TS2367": 1 + }, + "src/lib/services/htmlRewriter.ts": { + "TS2322": 2, + "TS2345": 2 + }, + "src/mitm/inspector/sseMerger.ts": { + "TS2352": 1 + }, + "src/shared/components/Header.tsx": { + "TS2353": 1 + }, + "src/shared/components/MonacoEditor.tsx": { + "TS2307": 1 + }, + "src/shared/components/OAuthModal.tsx": { + "TS2769": 4, + "TS2345": 4 + }, + "src/shared/components/SkillsConceptCard.tsx": { + "TS2503": 1 + }, + "src/shared/components/analytics/charts.tsx": { + "TS2345": 1 + }, + "src/shared/components/analytics/rechartsDonuts.tsx": { + "TS2739": 2 + }, + "src/shared/hooks/useElectron.ts": { + "TS2339": 19 + }, + "src/shared/providers/webSessionCredentials.ts": { + "TS2353": 1, + "TS2322": 1 + }, + "src/shared/schemas/cliCatalog.ts": { + "TS2554": 2 + }, + "src/shared/services/opencodeConfig.ts": { + "TS2345": 1 + } +} diff --git a/docs/architecture/QUALITY_GATES.md b/docs/architecture/QUALITY_GATES.md index c2970af8f9..5fa94abd27 100644 --- a/docs/architecture/QUALITY_GATES.md +++ b/docs/architecture/QUALITY_GATES.md @@ -46,6 +46,7 @@ Runs on every PR to `main`. Blocks merge on failure. | `check:docs-sync` | CHANGELOG version, OpenAPI version, and `llm.txt` are in sync | Yes | | `typecheck:core` | TypeScript compilation without errors (advisory warnings only) | Yes | | `typecheck:noimplicit:core` | Strict `noImplicitAny` — forward-looking; many pre-existing call sites still need annotations | **Advisory** (`continue-on-error: true`) | +| `check:dashboard-typecheck` | `tsc` scoped to `src/app/(dashboard)/**` (#7033) — `typecheck:core`'s curated 27-file allowlist does not include any dashboard TSX, and `next build` never type-checks it either (`next.config.mjs` sets `ignoreBuildErrors: true`), so orphaned-identifier regressions there (#6625/#6909) were invisible to CI. Diffs against a frozen per-file/per-TS-code count baseline (`config/quality/dashboard-typecheck-baseline.json`, same stale-enforcement pattern as `check:known-symbols`) — only NEW errors beyond the baselined count fail the gate; ratchet down with `--update` when a pre-existing error is fixed. | Yes | ### Job: `quality-gate` diff --git a/package.json b/package.json index 2e731cb195..937d70363d 100644 --- a/package.json +++ b/package.json @@ -185,6 +185,7 @@ "audit:electron": "npm --prefix electron audit --audit-level=critical && (npm --prefix electron audit --audit-level=high || echo '::warning::electron high-severity advisories present (non-blocking)')", "typecheck:core": "tsc --pretty false -p tsconfig.typecheck-core.json", "typecheck:noimplicit:core": "tsc --pretty false -p tsconfig.typecheck-noimplicit-core.json", + "check:dashboard-typecheck": "node scripts/check/check-dashboard-typecheck.mjs", "backfill-aggregation": "node --import tsx src/scripts/backfillAggregation.ts", "env:sync": "node scripts/dev/sync-env.mjs", "test:integration": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 tests/integration/*.test.ts \"tests/integration/combo-matrix/*.test.ts\"", diff --git a/scripts/check/check-dashboard-typecheck.mjs b/scripts/check/check-dashboard-typecheck.mjs new file mode 100644 index 0000000000..454bb308e9 --- /dev/null +++ b/scripts/check/check-dashboard-typecheck.mjs @@ -0,0 +1,177 @@ +#!/usr/bin/env node +// scripts/check/check-dashboard-typecheck.mjs +// Dashboard-scoped typecheck gate (#7033). +// +// `typecheck:core` (the only blocking CI typecheck gate) runs against a curated +// 27-file `"files"` allowlist in tsconfig.typecheck-core.json — none of it lives +// under `src/app/(dashboard)`, and `next.config.mjs` sets +// `typescript.ignoreBuildErrors: true`, so `next build` never type-checks either. +// Net effect: orphaned-identifier regressions in dashboard TSX (deleted `useState` +// decls with live usages left behind) are invisible to both CI type-check paths +// and only surface at runtime — exactly what happened in #6625/#6909. +// +// This gate runs `tsc` scoped to `src/app/(dashboard)/**/*.{ts,tsx}` via +// tsconfig.typecheck-dashboard.json and diffs the result against a frozen +// per-file/per-TS-code count baseline (config/quality/dashboard-typecheck-baseline.json), +// following this repo's stale-enforcement allowlist convention (see +// scripts/check/check-known-symbols.ts). A live count that EXCEEDS the baselined +// count for a given (file, TS code) pair is a regression and fails the gate; a +// live count that is lower is an improvement and does not fail (use --update to +// ratchet the baseline down). +// +// Run: +// node scripts/check/check-dashboard-typecheck.mjs +// node scripts/check/check-dashboard-typecheck.mjs --update # re-freeze baseline + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const ROOT = process.cwd(); +const TSCONFIG = path.join(ROOT, "tsconfig.typecheck-dashboard.json"); +const BASELINE_PATH = path.join(ROOT, "config/quality/dashboard-typecheck-baseline.json"); +const UPDATE = process.argv.includes("--update"); + +// Matches tsc --pretty false output lines, e.g.: +// src/app/(dashboard)/dashboard/foo.tsx(12,7): error TS2304: Cannot find name 'bar'. +const TSC_ERROR_LINE = /^(.+?)\((\d+),(\d+)\): error (TS\d+):/; + +/** + * Parses raw `tsc --pretty false` stdout into a nested count map: + * { "": { "": } } + * + * Pure/exported for unit testing against synthetic tsc output — no child + * process involved here. + */ +export function parseTscOutput(raw) { + const counts = {}; + const lines = String(raw).split("\n"); + for (const line of lines) { + const match = TSC_ERROR_LINE.exec(line); + if (!match) continue; + const [, file, , , code] = match; + if (!counts[file]) counts[file] = {}; + counts[file][code] = (counts[file][code] || 0) + 1; + } + return counts; +} + +/** + * Compares live (file, TS code) error counts against a frozen baseline. + * Returns `{ regressions, improvements }`: + * - regressions: entries where live count > baselined count (or the pair is + * entirely new/unbaselined) — these fail the gate. + * - improvements: entries where live count < baselined count — informational, + * do not fail (use --update to ratchet the baseline down). + * + * Exported for unit testing. + */ +export function diffAgainstBaseline(live, baseline) { + const regressions = []; + const improvements = []; + + for (const [file, codes] of Object.entries(live)) { + for (const [code, liveCount] of Object.entries(codes)) { + const baselineCount = (baseline[file] && baseline[file][code]) || 0; + if (liveCount > baselineCount) { + regressions.push({ file, code, liveCount, baselineCount }); + } else if (liveCount < baselineCount) { + improvements.push({ file, code, liveCount, baselineCount }); + } + } + } + + for (const [file, codes] of Object.entries(baseline)) { + for (const [code, baselineCount] of Object.entries(codes)) { + const liveCount = (live[file] && live[file][code]) || 0; + if (liveCount === 0 && baselineCount > 0) { + improvements.push({ file, code, liveCount: 0, baselineCount }); + } + } + } + + return { regressions, improvements }; +} + +function runTsc() { + try { + const stdout = execFileSync( + process.platform === "win32" ? "npx.cmd" : "npx", + ["tsc", "--pretty", "false", "--noEmit", "-p", TSCONFIG], + { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, cwd: ROOT } + ); + return stdout; + } catch (err) { + // tsc exits non-zero when there are type errors — stdout still has the report. + if (err.stdout) return String(err.stdout); + throw err; + } +} + +function loadBaseline() { + if (!fs.existsSync(BASELINE_PATH)) return {}; + return JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8")); +} + +function writeBaseline(counts) { + fs.writeFileSync(BASELINE_PATH, JSON.stringify(counts, null, 2) + "\n"); +} + +function main() { + if (!fs.existsSync(TSCONFIG)) { + process.stderr.write(`[dashboard-typecheck] FAIL — tsconfig not found at ${TSCONFIG}\n`); + process.exit(2); + } + + console.log("[dashboard-typecheck] Running tsc scoped to src/app/(dashboard)/**…"); + const stdout = runTsc(); + const live = parseTscOutput(stdout); + const baseline = loadBaseline(); + const { regressions, improvements } = diffAgainstBaseline(live, baseline); + + const liveErrorCount = Object.values(live).reduce( + (sum, codes) => sum + Object.values(codes).reduce((s, c) => s + c, 0), + 0 + ); + console.log(`dashboardTypecheckErrors=${liveErrorCount}`); + + if (UPDATE) { + writeBaseline(live); + console.log(`[dashboard-typecheck] baseline rewritten (${liveErrorCount} errors frozen).`); + process.exit(0); + } + + if (improvements.length > 0) { + console.log( + `[dashboard-typecheck] ${improvements.length} baselined error(s) no longer present ` + + `— run 'node scripts/check/check-dashboard-typecheck.mjs --update' to ratchet the baseline down:\n` + + improvements + .map((i) => ` - ${i.file} ${i.code} (baseline ${i.baselineCount} -> live ${i.liveCount})`) + .join("\n") + ); + } + + if (regressions.length > 0) { + process.stderr.write( + `[dashboard-typecheck] FAIL — ${regressions.length} new/regressed TypeScript error(s) ` + + `under src/app/(dashboard)/ not covered by the frozen baseline:\n` + + regressions + .map((r) => ` ✗ ${r.file} ${r.code} (baseline ${r.baselineCount}, live ${r.liveCount})`) + .join("\n") + + `\n\nIf this is a genuine new dashboard TSX bug (e.g. an orphaned identifier), fix it.\n` + + `If it's pre-existing type looseness you're intentionally not fixing in this PR,\n` + + `do NOT widen the baseline for new regressions — that defeats the gate.\n` + ); + process.exit(1); + } + + console.log( + `[dashboard-typecheck] OK — ${liveErrorCount} pre-existing error(s), all within frozen baseline.` + ); + process.exit(0); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) { + main(); +} diff --git a/tests/unit/build/check-dashboard-typecheck.test.ts b/tests/unit/build/check-dashboard-typecheck.test.ts new file mode 100644 index 0000000000..dec6d54fad --- /dev/null +++ b/tests/unit/build/check-dashboard-typecheck.test.ts @@ -0,0 +1,111 @@ +// tests/unit/build/check-dashboard-typecheck.test.ts +// Unit tests for the pure parsing/diff helpers in check-dashboard-typecheck.mjs. +// No child process is spawned — synthetic tsc-style output only, so the suite is +// fast and hermetic. Proves the gate actually DETECTS the #6625/#6909 bug class +// (an orphaned identifier — used but not declared — in a dashboard TSX file), +// not just that the script runs. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { + parseTscOutput, + diffAgainstBaseline, +} from "../../../scripts/check/check-dashboard-typecheck.mjs"; + +test("parseTscOutput: parses a TS2304 orphaned-identifier error (the #6625/#6909 bug class)", () => { + const raw = + `src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx(564,7): error TS2304: Cannot find name 'setPoolLoaded'.\n` + + `src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx(1204,12): error TS2304: Cannot find name 'poolLoaded'.\n`; + + const counts = parseTscOutput(raw); + + assert.deepEqual(counts, { + "src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": { + TS2304: 2, + }, + }); +}); + +test("parseTscOutput: ignores non-error lines (summary/info output)", () => { + const raw = + `Some info line that is not an error\n` + + `src/app/(dashboard)/dashboard/foo.tsx(1,1): error TS2339: Property 'bar' does not exist.\n` + + `Found 1 error in 1 file.\n`; + + const counts = parseTscOutput(raw); + + assert.deepEqual(counts, { + "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 1 }, + }); +}); + +test("parseTscOutput: returns empty map for clean output", () => { + assert.deepEqual(parseTscOutput(""), {}); + assert.deepEqual(parseTscOutput("Found 0 errors.\n"), {}); +}); + +test("diffAgainstBaseline: flags a brand-new orphaned-identifier error as a regression", () => { + const baseline = {}; + const live = { + "src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": { + TS2304: 5, + }, + }; + + const { regressions, improvements } = diffAgainstBaseline(live, baseline); + + assert.equal(regressions.length, 1); + assert.equal( + regressions[0].file, + "src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx" + ); + assert.equal(regressions[0].code, "TS2304"); + assert.equal(regressions[0].liveCount, 5); + assert.equal(regressions[0].baselineCount, 0); + assert.equal(improvements.length, 0); +}); + +test("diffAgainstBaseline: does NOT flag a frozen pre-existing error within its baselined count", () => { + const baseline = { "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 3 } }; + const live = { "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 3 } }; + + const { regressions, improvements } = diffAgainstBaseline(live, baseline); + + assert.equal(regressions.length, 0); + assert.equal(improvements.length, 0); +}); + +test("diffAgainstBaseline: flags a count INCREASE beyond the frozen baseline as a regression", () => { + const baseline = { "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 2 } }; + const live = { "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 3 } }; + + const { regressions } = diffAgainstBaseline(live, baseline); + + assert.equal(regressions.length, 1); + assert.equal(regressions[0].baselineCount, 2); + assert.equal(regressions[0].liveCount, 3); +}); + +test("diffAgainstBaseline: reports (does not fail on) a count DECREASE as an improvement", () => { + const baseline = { "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 3 } }; + const live = { "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 1 } }; + + const { regressions, improvements } = diffAgainstBaseline(live, baseline); + + assert.equal(regressions.length, 0); + assert.equal(improvements.length, 1); + assert.equal(improvements[0].baselineCount, 3); + assert.equal(improvements[0].liveCount, 1); +}); + +test("diffAgainstBaseline: a baselined error that fully disappears is reported as an improvement, not a failure", () => { + const baseline = { "src/app/(dashboard)/dashboard/foo.tsx": { TS2339: 2 } }; + const live = {}; + + const { regressions, improvements } = diffAgainstBaseline(live, baseline); + + assert.equal(regressions.length, 0); + assert.equal(improvements.length, 1); + assert.equal(improvements[0].liveCount, 0); + assert.equal(improvements[0].baselineCount, 2); +}); diff --git a/tsconfig.typecheck-dashboard.json b/tsconfig.typecheck-dashboard.json new file mode 100644 index 0000000000..a6cfaddf7c --- /dev/null +++ b/tsconfig.typecheck-dashboard.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "incremental": false + }, + "include": ["src/app/(dashboard)/**/*.ts", "src/app/(dashboard)/**/*.tsx"] +} From af0c72fba5503cb077c85e4bde4a9a81b3a5a44b Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:25:09 -0300 Subject: [PATCH 2/4] fix: raise main server keepAliveTimeout/headersTimeout above Node's 5s default (#7003) (#7191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: raise main server keepAliveTimeout/headersTimeout above Node's 5s default (#7003) JetBrains AI Assistant's pooled java.net.http.HttpClient reuses a keep-alive connection past Node's unconfigured 5_000ms keepAliveTimeout, hitting a socket the server already tore down and getting 0 response bytes back ("HTTP/1.1 header parser received no bytes"). Wire a new getMainServerTimeoutConfig() (mirroring apiBridgeServer's pattern) into run-next.mjs so the main dashboard/API server raises keepAliveTimeout to 65s and headersTimeout to 66s by default, both env-overridable. * fix: wire main-server keepAlive timeouts into standalone/production server path (#7003) getMainServerTimeoutConfig() was only wired into scripts/dev/run-next.mjs, the dev-only entry point for `npm run dev`/`npm start`. The server real end users run — `omniroute serve` (npm-installed CLI), Docker, and Electron — spawns the standalone Next build's server.js via run-standalone.mjs, which prefers server-ws.mjs (built verbatim from scripts/dev/standalone-server-ws.mjs by assembleStandalone.mjs) over the bare server.js precisely because it wraps http.createServer with production behavior the bare server lacks. That wrapper never configured keepAliveTimeout/headersTimeout, so the JetBrains AI Assistant reconnect bug this issue reports still hit the production entry point after the first pass of this fix. Wire the same helper into the wrapped server object there too. --- .../7003-jetbrains-ai-loopback-connect.md | 1 + scripts/dev/run-next.mjs | 10 + scripts/dev/standalone-server-ws.mjs | 14 ++ src/shared/utils/runtimeTimeouts.ts | 47 ++++ ...main-server-keepalive-timeout-7003.test.ts | 202 ++++++++++++++++++ ...e-server-ws-keepalive-timeout-7003.test.ts | 73 +++++++ 6 files changed, 347 insertions(+) create mode 100644 changelog.d/fixes/7003-jetbrains-ai-loopback-connect.md create mode 100644 tests/unit/main-server-keepalive-timeout-7003.test.ts create mode 100644 tests/unit/standalone-server-ws-keepalive-timeout-7003.test.ts diff --git a/changelog.d/fixes/7003-jetbrains-ai-loopback-connect.md b/changelog.d/fixes/7003-jetbrains-ai-loopback-connect.md new file mode 100644 index 0000000000..bc3b0a89c4 --- /dev/null +++ b/changelog.d/fixes/7003-jetbrains-ai-loopback-connect.md @@ -0,0 +1 @@ +- fix(api): raise the main server's `keepAliveTimeout`/`headersTimeout` well above Node's 5s default so pooled keep-alive clients (e.g. JetBrains AI Assistant's JVM `HttpClient`) stop getting 0 bytes back on a reused connection (#7003) diff --git a/scripts/dev/run-next.mjs b/scripts/dev/run-next.mjs index 68eb9d2198..fffdb06cfb 100644 --- a/scripts/dev/run-next.mjs +++ b/scripts/dev/run-next.mjs @@ -14,6 +14,7 @@ import headResponseGuard from "./head-response-guard.cjs"; import { ensureNativeSqlite } from "./ensure-native-sqlite.mjs"; import { isTurbopackCacheCorruption, purgeAllTurbopackCaches } from "./turbopackCacheHeal.mjs"; import { randomUUID } from "node:crypto"; +import { getMainServerTimeoutConfig } from "../../src/shared/utils/runtimeTimeouts.ts"; const { maybeHandleDisallowedMethod } = methodGuard; const { wrapRequestListenerWithHeadResponseGuard } = headResponseGuard; @@ -153,6 +154,15 @@ async function start() { return requestHandler(req, res); }) ); + // Node's http.Server default keepAliveTimeout (5_000ms) races pooled + // keep-alive HTTP clients that idle longer than that between requests (e.g. + // the JVM java.net.http.HttpClient used by JetBrains AI Assistant), which + // reuse a socket the server already tore down and get 0 response bytes back + // (#7003). Raise both timeouts well above any realistic client idle-pool + // window, mirroring src/lib/apiBridgeServer.ts's pattern. + const mainServerTimeouts = getMainServerTimeoutConfig(); + server.keepAliveTimeout = mainServerTimeouts.keepAliveTimeoutMs; + server.headersTimeout = mainServerTimeouts.headersTimeoutMs; server.on("upgrade", async (req, socket, head) => { try { const responsesWsHandled = await responsesWsProxy.handleUpgrade(req, socket, head); diff --git a/scripts/dev/standalone-server-ws.mjs b/scripts/dev/standalone-server-ws.mjs index 3d7bd1f0ca..ebbca99936 100644 --- a/scripts/dev/standalone-server-ws.mjs +++ b/scripts/dev/standalone-server-ws.mjs @@ -7,6 +7,7 @@ import { maybeHandleWebdav } from "./webdav-handler.mjs"; import methodGuard from "./http-method-guard.cjs"; import headResponseGuard from "./head-response-guard.cjs"; import { resolveTlsOptions, createServerListener } from "./tls-options.mjs"; +import { getMainServerTimeoutConfig } from "../../src/shared/utils/runtimeTimeouts.ts"; const originalCreateServer = http.createServer.bind(http); const proxiesByPort = new Map(); @@ -151,6 +152,19 @@ http.createServer = function createServerWithResponsesWs(...args) { // listener); otherwise the original http.Server. The downstream .on/.addListener // patches below apply identically to both (https.Server extends http.Server). const server = createServerListener(args, tlsOptions, { createHttp: originalCreateServer }); + // Node's http.Server default keepAliveTimeout (5_000ms) races pooled + // keep-alive HTTP clients that idle longer than that between requests (e.g. + // the JVM java.net.http.HttpClient used by JetBrains AI Assistant), which + // reuse a socket the server already tore down and get 0 response bytes back + // (#7003). This wrapper is what `omniroute serve` / Docker / Electron actually + // spawn in production (run-standalone.mjs prefers server-ws.mjs over the bare + // Next server.js), so it needs the same fix already wired into run-next.mjs + // (the dev-only entry point) — otherwise real installs never got it. Raise + // both timeouts well above any realistic client idle-pool window, mirroring + // src/lib/apiBridgeServer.ts's pattern. + const mainServerTimeouts = getMainServerTimeoutConfig(); + server.keepAliveTimeout = mainServerTimeouts.keepAliveTimeoutMs; + server.headersTimeout = mainServerTimeouts.headersTimeoutMs; const originalOn = server.on.bind(server); const originalAddListener = server.addListener.bind(server); diff --git a/src/shared/utils/runtimeTimeouts.ts b/src/shared/utils/runtimeTimeouts.ts index 294bb53b8a..cd148d9177 100644 --- a/src/shared/utils/runtimeTimeouts.ts +++ b/src/shared/utils/runtimeTimeouts.ts @@ -19,6 +19,14 @@ export const DEFAULT_API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS = 300_000; export const DEFAULT_API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS = 60_000; export const DEFAULT_API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS = 5_000; export const DEFAULT_API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS = 0; +// Node's http.Server default keepAliveTimeout is 5_000ms with no Keep-Alive +// response header hint. Pooled keep-alive clients that don't race that exact +// window (e.g. the JVM java.net.http.HttpClient used by JetBrains AI +// Assistant) can reuse a socket the server has already torn down, getting 0 +// response bytes back (#7003). Raise both well above any realistic client +// idle-pool window, mirroring the API bridge server's pattern. +export const DEFAULT_MAIN_SERVER_KEEPALIVE_TIMEOUT_MS = 65_000; +export const DEFAULT_MAIN_SERVER_HEADERS_TIMEOUT_MS = 66_000; function hasEnvValue(env: EnvSource, name: string): boolean { const raw = env[name]; @@ -49,6 +57,11 @@ export type ApiBridgeTimeoutConfig = { serverSocketTimeoutMs: number; }; +export type MainServerTimeoutConfig = { + keepAliveTimeoutMs: number; + headersTimeoutMs: number; +}; + function readTimeoutMs( env: EnvSource, name: string, @@ -255,3 +268,37 @@ export function getApiBridgeTimeoutConfig( ), }; } + +export function getMainServerTimeoutConfig( + env: EnvSource = process.env, + logger?: TimeoutLogger +): MainServerTimeoutConfig { + const keepAliveTimeoutMs = readTimeoutMs( + env, + "MAIN_SERVER_KEEPALIVE_TIMEOUT_MS", + DEFAULT_MAIN_SERVER_KEEPALIVE_TIMEOUT_MS, + { + allowZero: true, + logger, + } + ); + const headersTimeoutMs = readTimeoutMs( + env, + "MAIN_SERVER_HEADERS_TIMEOUT_MS", + DEFAULT_MAIN_SERVER_HEADERS_TIMEOUT_MS, + { + allowZero: true, + logger, + } + ); + + return { + keepAliveTimeoutMs, + // Node requires headersTimeout > keepAliveTimeout to avoid its internal + // race-condition warning; keep both configurable but always coherent. + headersTimeoutMs: + headersTimeoutMs > 0 && keepAliveTimeoutMs > 0 + ? Math.max(headersTimeoutMs, keepAliveTimeoutMs + 1_000) + : headersTimeoutMs, + }; +} diff --git a/tests/unit/main-server-keepalive-timeout-7003.test.ts b/tests/unit/main-server-keepalive-timeout-7003.test.ts new file mode 100644 index 0000000000..fd20f19774 --- /dev/null +++ b/tests/unit/main-server-keepalive-timeout-7003.test.ts @@ -0,0 +1,202 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import net from "node:net"; +import { getMainServerTimeoutConfig } from "../../src/shared/utils/runtimeTimeouts.ts"; + +// #7003 — JetBrains AI Assistant ("Test Connection" / completions) reported +// "HTTP/1.1 header parser received no bytes". The main OmniRoute server +// (scripts/dev/run-next.mjs) boots a bare `http.createServer(...)` and never +// configures `keepAliveTimeout`/`headersTimeout`, leaving Node's http.Server +// default of keepAliveTimeout=5_000ms with no `Keep-Alive: timeout=N` response +// hint. JetBrains AI Assistant's JVM `java.net.http.HttpClient` connection pool +// can reuse a socket idle for longer than that window; the server has already +// torn the socket down, so the client gets 0 response bytes back instead of a +// fresh HTTP response. +// +// This spec proves both halves: +// 1. `getMainServerTimeoutConfig()` raises the defaults well above Node's +// unconfigured 5_000ms window (the actual fix wired into run-next.mjs). +// 2. A bare http.Server left at Node's defaults drops a socket reused after +// an idle gap past 5s, while the same server configured via +// `getMainServerTimeoutConfig()` keeps serving the reused connection. + +describe("#7003 getMainServerTimeoutConfig", () => { + it("defaults keepAliveTimeout/headersTimeout well above Node's 5_000ms default", () => { + const config = getMainServerTimeoutConfig({}); + assert.equal(config.keepAliveTimeoutMs, 65_000); + assert.equal(config.headersTimeoutMs, 66_000); + assert.ok(config.keepAliveTimeoutMs > 5_000, "must exceed Node's unconfigured default"); + assert.ok( + config.headersTimeoutMs > config.keepAliveTimeoutMs, + "headersTimeout must stay above keepAliveTimeout per Node's own requirement" + ); + }); + + it("honors env overrides and keeps headersTimeout coherent with a raised keepAliveTimeout", () => { + const config = getMainServerTimeoutConfig({ + MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "120000", + MAIN_SERVER_HEADERS_TIMEOUT_MS: "121000", + }); + assert.equal(config.keepAliveTimeoutMs, 120_000); + assert.equal(config.headersTimeoutMs, 121_000); + }); + + it("bumps an inconsistent explicit headersTimeout override above keepAliveTimeout", () => { + const config = getMainServerTimeoutConfig({ + MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "120000", + MAIN_SERVER_HEADERS_TIMEOUT_MS: "1000", + }); + assert.equal(config.keepAliveTimeoutMs, 120_000); + assert.equal(config.headersTimeoutMs, 121_000); + }); + + it("falls back to defaults on invalid env values", () => { + const config = getMainServerTimeoutConfig({ + MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "not-a-number", + }); + assert.equal(config.keepAliveTimeoutMs, 65_000); + }); +}); + +/** + * Sends a raw HTTP/1.1 GET over an already-connected keep-alive socket and + * resolves with whatever bytes arrive within a short settle window (empty + * string if nothing comes back — the exact "0 bytes back" failure mode + * JetBrains AI Assistant surfaces as "header parser received no bytes"). + * + * The socket is opened with `allowHalfOpen: true` so it faithfully mimics a + * JVM/OkHttp-style client: Node's default `allowHalfOpen: false` proactively + * ends the writable side the instant it processes an incoming FIN, turning + * the reused write into a synchronous "socket has been ended" error instead + * of the real-world race — a write that is accepted locally (the server + * already destroyed the connection, so it never arrives) whose response + * settles as 0 bytes. + */ +function sendKeepAliveRequest(socket: net.Socket, port: number): Promise { + return new Promise((resolve) => { + let received = ""; + let settleTimer: NodeJS.Timeout; + const finish = () => { + socket.off("data", onData); + clearTimeout(settleTimer); + resolve(received); + }; + // A short settle window once the full chunked response has arrived (fast + // path); a generous cap in case nothing ever comes back — the torn-down + // connection case this test proves, and a safety margin against first-run + // JIT/module-load jitter under the test runner. + const onData = (chunk: Buffer) => { + received += chunk.toString("utf8"); + if (received.endsWith("0\r\n\r\n")) { + clearTimeout(settleTimer); + settleTimer = setTimeout(finish, 50); + } + }; + socket.on("data", onData); + socket.write(`GET / HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nConnection: keep-alive\r\n\r\n`); + settleTimer = setTimeout(finish, 3_000); + }); +} + +function startEchoServer(configure: (server: http.Server) => void): Promise { + return new Promise((resolve) => { + const server = http.createServer((_req, res) => { + res.writeHead(200, { "content-type": "text/plain" }); + res.end("ok"); + }); + configure(server); + server.listen(0, "127.0.0.1", () => resolve(server)); + }); +} + +async function withServer( + configure: (server: http.Server) => void, + run: (port: number) => Promise +): Promise { + const server = await startEchoServer(configure); + try { + const address = server.address(); + if (typeof address !== "object" || address === null) { + throw new Error("expected server to bind a TCP address"); + } + await run(address.port); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +} + +// Node's default keepAliveTimeout is 5_000ms, but the server only starts that +// timer once the response has fully flushed and there is a small amount of +// internal scheduling overhead before the socket is actually torn down — +// empirically ~5.8-6s end-to-end on loopback. 6.5s reliably clears that +// window without relying on a hair-trigger race. +const IDLE_GAP_MS = 6_500; + +describe("#7003 keep-alive socket reuse across an idle gap", () => { + it( + "current Node defaults (keepAliveTimeout=5000ms): a pooled socket reused after 6.5s idle gets 0 bytes back", + { timeout: 30_000 }, + async () => { + await withServer( + () => { + /* leave Node's http.Server defaults untouched (keepAliveTimeout=5000ms) */ + }, + async (port) => { + const socket = net.connect({ port, host: "127.0.0.1", allowHalfOpen: true }); + await new Promise((resolve, reject) => { + socket.once("connect", () => resolve()); + socket.once("error", reject); + }); + + const first = await sendKeepAliveRequest(socket, port); + assert.match(first, /200/, "first request on a fresh socket must succeed"); + + await new Promise((resolve) => setTimeout(resolve, IDLE_GAP_MS)); + + const second = await sendKeepAliveRequest(socket, port); + assert.equal( + second, + "", + "reusing the idle-torn-down socket must get exactly 0 bytes back (the reported bug)" + ); + socket.destroy(); + } + ); + } + ); + + it( + "fixed config (getMainServerTimeoutConfig): the same reused connection stays alive past 6.5s idle", + { timeout: 30_000 }, + async () => { + const fixedTimeouts = getMainServerTimeoutConfig({}); + await withServer( + (server) => { + server.keepAliveTimeout = fixedTimeouts.keepAliveTimeoutMs; + server.headersTimeout = fixedTimeouts.headersTimeoutMs; + }, + async (port) => { + const socket = net.connect({ port, host: "127.0.0.1", allowHalfOpen: true }); + await new Promise((resolve, reject) => { + socket.once("connect", () => resolve()); + socket.once("error", reject); + }); + + const first = await sendKeepAliveRequest(socket, port); + assert.match(first, /200/, "first request on a fresh socket must succeed"); + + await new Promise((resolve) => setTimeout(resolve, IDLE_GAP_MS)); + + const second = await sendKeepAliveRequest(socket, port); + assert.match( + second, + /200/, + "the reused connection must still get a valid response after the fix" + ); + socket.destroy(); + } + ); + } + ); +}); diff --git a/tests/unit/standalone-server-ws-keepalive-timeout-7003.test.ts b/tests/unit/standalone-server-ws-keepalive-timeout-7003.test.ts new file mode 100644 index 0000000000..076e84b1e4 --- /dev/null +++ b/tests/unit/standalone-server-ws-keepalive-timeout-7003.test.ts @@ -0,0 +1,73 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// #7003 — the RED/GREEN spec in main-server-keepalive-timeout-7003.test.ts proves +// getMainServerTimeoutConfig() raises keepAliveTimeout/headersTimeout above Node's +// unconfigured 5_000ms default, and that the original fix wired it into +// scripts/dev/run-next.mjs. But run-next.mjs only runs `npm run dev`/`npm start` +// from a source checkout. The server real end users run — `omniroute serve` +// (npm-installed CLI), Docker, and Electron — spawns the standalone Next build's +// server.js via scripts/dev/run-standalone.mjs, which prefers server-ws.mjs +// (built from scripts/dev/standalone-server-ws.mjs, copied byte-for-byte into +// dist/server-ws.mjs by scripts/build/assembleStandalone.mjs) over the bare +// server.js specifically because it wraps `http.createServer` with production +// behavior the bare server lacks (peer-IP stamping, method/HEAD guards, WS +// proxying, TLS). Before this fix, that wrapper left Node's http.Server +// keepAliveTimeout/headersTimeout at their unconfigured defaults, so the +// JetBrains AI Assistant reconnect bug reproduced by main-server-keepalive-timeout +// -7003.test.ts still hit the production entry point every real user runs. +// +// standalone-server-ws.mjs has top-level side effects (monkeypatches +// http.createServer, generates a random UUID, and unconditionally +// `await import("./server.js")` — a file that only exists in the assembled +// standalone output, not in the source tree) so it cannot be imported +// in-process. Guard the fix by inspecting the source, mirroring the pattern +// used for run-next.mjs in run-next-node-env.test.ts. +const here = path.dirname(fileURLToPath(import.meta.url)); +const source = fs.readFileSync( + path.resolve(here, "../../scripts/dev/standalone-server-ws.mjs"), + "utf8" +); + +test("standalone-server-ws.mjs imports getMainServerTimeoutConfig", () => { + assert.match( + source, + /import\s*\{\s*getMainServerTimeoutConfig\s*\}\s*from\s*["'][^"']*runtimeTimeouts(?:\.ts)?["']/, + "expected the production server wrapper to import getMainServerTimeoutConfig, " + + "the same helper run-next.mjs uses" + ); +}); + +test("standalone-server-ws.mjs applies keepAliveTimeout/headersTimeout to the wrapped server", () => { + assert.match( + source, + /server\.keepAliveTimeout\s*=\s*\w*[Tt]imeouts?\.keepAliveTimeoutMs/, + "expected the wrapped server object to have keepAliveTimeout set from getMainServerTimeoutConfig()" + ); + assert.match( + source, + /server\.headersTimeout\s*=\s*\w*[Tt]imeouts?\.headersTimeoutMs/, + "expected the wrapped server object to have headersTimeout set from getMainServerTimeoutConfig()" + ); +}); + +test("keepAliveTimeout/headersTimeout are applied inside createServerWithResponsesWs, before the server is returned", () => { + const factoryIdx = source.search(/function createServerWithResponsesWs/); + const keepAliveIdx = source.search(/server\.keepAliveTimeout\s*=/); + const returnIdx = source.search(/return server;/); + + assert.ok(factoryIdx !== -1, "expected createServerWithResponsesWs to exist"); + assert.ok(keepAliveIdx !== -1, "expected a server.keepAliveTimeout assignment to exist"); + assert.ok(returnIdx !== -1, "expected the wrapped server to be returned"); + assert.ok( + keepAliveIdx > factoryIdx, + "timeout wiring must happen inside createServerWithResponsesWs" + ); + assert.ok( + keepAliveIdx < returnIdx, + "timeout wiring must happen before the server object is returned to the caller" + ); +}); From abe686dab90df734e213022a46439d60004dc51d Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:11:56 -0300 Subject: [PATCH 3/4] feat(ci): Trunk Flaky Tests uploads for vitest + Playwright E2E (WS5.2/5.3) (#7175) --- .github/workflows/ci.yml | 30 ++++++++++++++++--- .../maintenance/trunk-flaky-uploads.md | 1 + 2 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 changelog.d/maintenance/trunk-flaky-uploads.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b665c6735d..df99b771ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -758,11 +758,23 @@ jobs: - uses: ./.github/actions/npm-ci-retry # The second test runner (CLAUDE.md: "Both test runners must pass") — was never # wired into CI until the 2026-06-09 quality audit (Fase 6A.2). - - run: npm run test:vitest + # WS5.2/5.3 (v3.8.49 plan): JUnit output feeds Trunk Flaky Tests (advisory upload + # below). node:test stays OUT of the first wave (fd1-sensitive reporter stream). + - run: npm run test:vitest -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-mcp.xml # vitest:ui went back to 870/870 green in the v3.8.49 quality plan (WS6.1, # PR #7127 — 69 fails triaged: matchMedia polyfill, node:test→vitest migration, # CompareTab D22 cap). Promoted to BLOCKING per the plan's post-merge step. - - run: npm run test:vitest:ui + - run: npm run test:vitest:ui -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-ui.xml + # Trunk Flaky Tests upload — advisory (never blocks), own-origin only (fork PRs + # have no TRUNK_TOKEN). Pinned by SHA (tag v2.1.2). + - name: Upload test results to Trunk (advisory) + if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} + continue-on-error: true + uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2 + with: + junit-paths: trunk-junit/**/*.xml + org-slug: omniroute + token: ${{ secrets.TRUNK_TOKEN }} # Node 24/26 compatibility matrices moved to .github/workflows/nightly-compat.yml # (plano mestre testes+CI, Eixo D2 — they cost ~28% of every heavy run to catch a @@ -1052,16 +1064,26 @@ jobs: - name: Run E2E tests (duration-balanced shard) env: SHARD: ${{ matrix.shard }} + PLAYWRIGHT_JUNIT_OUTPUT_NAME: junit-e2e-results.xml run: | if FILES=$(node scripts/quality/balance-e2e-shards.mjs "$SHARD" 9); then if [ -z "$FILES" ]; then echo "[e2e-balance] shard $SHARD has no files"; exit 0; fi echo "[e2e-balance] shard $SHARD runs:"; echo "$FILES" # shellcheck disable=SC2086 — FILES is our own newline-separated path list - npx playwright test $(echo "$FILES" | tr '\n' ' ') + npx playwright test $(echo "$FILES" | tr '\n' ' ') --reporter=line,junit else echo "[e2e-balance] balancer unavailable — plain --shard fallback" - npx playwright test tests/e2e/*.spec.ts --shard="$SHARD"/9 + npx playwright test tests/e2e/*.spec.ts --shard="$SHARD"/9 --reporter=line,junit fi + # WS5.2/5.3: Trunk Flaky Tests upload — advisory, own-origin only, SHA-pinned. + - name: Upload test results to Trunk (advisory) + if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} + continue-on-error: true + uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2 + with: + junit-paths: junit-e2e-results.xml + org-slug: omniroute + token: ${{ secrets.TRUNK_TOKEN }} test-integration: name: Integration Tests (${{ matrix.shard }}/2) diff --git a/changelog.d/maintenance/trunk-flaky-uploads.md b/changelog.d/maintenance/trunk-flaky-uploads.md new file mode 100644 index 0000000000..89c0e6b853 --- /dev/null +++ b/changelog.d/maintenance/trunk-flaky-uploads.md @@ -0,0 +1 @@ +- **CI**: Playwright E2E and both vitest suites now emit JUnit and upload to Trunk Flaky Tests (org `omniroute`) — advisory step, own-origin only, uploader action SHA-pinned (WS5.2/5.3 of the quality plan; node:test stays out of the first wave) From d3f88716bbf7f3cdc2eb5e4ee45698325818d3c5 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:54:34 -0300 Subject: [PATCH 4/4] feat(ci): Trunk Flaky Tests upload on the fast-path vitest job (per-PR volume) (#7205) --- .github/workflows/quality.yml | 13 ++++++++++++- changelog.d/maintenance/trunk-upload-fastpath.md | 1 + 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 changelog.d/maintenance/trunk-upload-fastpath.md diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index e16b4b1df6..e54c4ac4c2 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -230,7 +230,18 @@ jobs: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - run: npm ci - - run: npm run test:vitest + # WS5.2/5.3: JUnit feeds Trunk Flaky Tests — the fast-path runs on EVERY PR, + # which is where flaky-detection volume actually comes from (ci.yml's heavy + # jobs only run on the release PR). Advisory upload, own-origin only. + - run: npm run test:vitest -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-fastpath.xml + - name: Upload test results to Trunk (advisory) + if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} + continue-on-error: true + uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2 + with: + junit-paths: trunk-junit/**/*.xml + org-slug: omniroute + token: ${{ secrets.TRUNK_TOKEN }} fast-unit: name: Unit Tests fast-path (${{ matrix.shard }}/4) diff --git a/changelog.d/maintenance/trunk-upload-fastpath.md b/changelog.d/maintenance/trunk-upload-fastpath.md new file mode 100644 index 0000000000..441f1300fe --- /dev/null +++ b/changelog.d/maintenance/trunk-upload-fastpath.md @@ -0,0 +1 @@ +- **CI**: the fast-path Vitest job (every PR) now also emits JUnit and uploads to Trunk Flaky Tests — the heavy-gate uploads alone (release PR only) would never accumulate flaky-detection volume