diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d21489db3d..df99b771ae 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. @@ -751,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 @@ -1045,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/.github/workflows/quality.yml b/.github/workflows/quality.yml index 2c5640ca6b..e54c4ac4c2 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 @@ -226,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/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/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/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) 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 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/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/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/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" + ); +}); 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"] +}