mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-22 00:31:17 +00:00
Final step of the iterative vite 5 -> 8 migration. This is the
substantive hop: Rolldown replaces Rollup, Oxc replaces esbuild,
Lightning CSS replaces esbuild for CSS, and vitest jumps to v4 (vitest
3 only peers with vite ^5||^6||^7).
Dep changes (gitnexus-web/package.json):
- vite ^7.3.2 -> ^8.0.10
- vitest ^3.2.4 -> ^4.1.5
- @vitest/coverage-v8 ^3.2.4 -> ^4.1.5
- @tailwindcss/vite ^4.1.18 -> ^4.2.4 (vite ^8 peer support starts at 4.2.2)
- tailwindcss ^4.2.2 -> ^4.2.4 (match the vite plugin minor)
- @vitejs/plugin-react already at 5.2.0 from iter 2 (vite ^8 peer included)
Test fix (heartbeat.test.ts):
- vitest 4 enforces [[Construct]] on mock implementations used with `new`.
The arrow function passed to .mockImplementation() in the EventSource
stub is now rejected with "() => { ... } is not a constructor". Switched
to a regular function declaration, which restores constructor semantics
without changing test behaviour. All 7 heartbeat tests pass again.
Coverage threshold tune (vitest.config.ts):
- vitest 4 ships AST-aware coverage remapping by default, which measures
reachable code more accurately than the legacy istanbul-style mapping.
Same 220 tests now report 9.44%/4.47%/7.24%/9.58% instead of just over
10% on each axis. Lowered thresholds to 9/4/7/9 to keep them as soft
regression floors rather than coverage targets. No tests removed.
What we deliberately did NOT change:
- vite.config.ts: the five resolve.alias entries (mermaid, anthropic deep
import, gitnexus-shared, @, @shared) all keep working under Rolldown.
server.fs.allow: ['..'] is unchanged in v8. The mermaid alias is
arguably MORE important now because vite 8.0.10 explicitly removed
format-sniffing module resolution from the JS resolver.
- engines.node: vite 8 has the same Node floor as vite 7
(^20.19.0 || >=22.12.0), already set in iter 2.
- CI setup-node pin: already at 20.19.0 from iter 2.
Verified locally (Node v22.14.0):
- npm install: clean (+11 / -55 / 27 changed; size shrinks because vite 8
bundles deps internally), no ERESOLVE on @tailwindcss/vite
- npx tsc -b --noEmit: clean
- npm test: 220/220 pass, 1.80s (~21x faster than vite 7's 3.05s)
- npm run test:coverage: passes new thresholds
- npm run build: clean, **539ms** with Rolldown (vs 11.41s on vite 7,
~21x speedup), bundle ~1% smaller than vite 7
Closes the iterative vite 5 -> 8 series (#1061 vite 6, #1062 vite 7,
this PR vite 8). Supersedes Dependabot #1040.
Made-with: Cursor
150 lines
4.5 KiB
TypeScript
150 lines
4.5 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { connectHeartbeat } from '../../src/services/backend-client';
|
|
|
|
// Mock EventSource to simulate SSE behavior
|
|
class MockEventSource {
|
|
onopen: (() => void) | null = null;
|
|
onerror: (() => void) | null = null;
|
|
closed = false;
|
|
|
|
close() {
|
|
this.closed = true;
|
|
}
|
|
}
|
|
|
|
let lastEventSource: MockEventSource | null = null;
|
|
|
|
beforeEach(() => {
|
|
lastEventSource = null;
|
|
// vitest 4 enforces that mock implementations used with `new` must have a
|
|
// [[Construct]] slot. Arrow functions don't, so we use a regular function
|
|
// declaration here. The production code calls `new EventSource(...)`.
|
|
vi.stubGlobal(
|
|
'EventSource',
|
|
vi.fn().mockImplementation(function () {
|
|
lastEventSource = new MockEventSource();
|
|
return lastEventSource;
|
|
}),
|
|
);
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe('connectHeartbeat', () => {
|
|
it('calls onConnect when EventSource opens', () => {
|
|
const onConnect = vi.fn();
|
|
const onReconnecting = vi.fn();
|
|
connectHeartbeat(onConnect, onReconnecting);
|
|
|
|
lastEventSource!.onopen!();
|
|
expect(onConnect).toHaveBeenCalledOnce();
|
|
expect(onReconnecting).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('calls onReconnecting on first error, then retries', () => {
|
|
const onConnect = vi.fn();
|
|
const onReconnecting = vi.fn();
|
|
connectHeartbeat(onConnect, onReconnecting);
|
|
|
|
// Simulate connection drop
|
|
lastEventSource!.onerror!();
|
|
|
|
expect(onReconnecting).toHaveBeenCalledOnce();
|
|
expect(lastEventSource!.closed).toBe(true);
|
|
|
|
// Advance past first retry delay (1s)
|
|
vi.advanceTimersByTime(1_000);
|
|
|
|
// A new EventSource should have been created
|
|
expect(EventSource).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('fires onReconnecting only once per disconnect', () => {
|
|
const onConnect = vi.fn();
|
|
const onReconnecting = vi.fn();
|
|
connectHeartbeat(onConnect, onReconnecting);
|
|
|
|
// First error
|
|
lastEventSource!.onerror!();
|
|
expect(onReconnecting).toHaveBeenCalledOnce();
|
|
|
|
// Second retry fires error again
|
|
vi.advanceTimersByTime(1_000);
|
|
lastEventSource!.onerror!();
|
|
expect(onReconnecting).toHaveBeenCalledOnce(); // still 1
|
|
|
|
// Third retry fires error
|
|
vi.advanceTimersByTime(2_000);
|
|
lastEventSource!.onerror!();
|
|
expect(onReconnecting).toHaveBeenCalledOnce(); // still 1
|
|
});
|
|
|
|
it('retries indefinitely instead of giving up after 3 attempts', () => {
|
|
const onConnect = vi.fn();
|
|
const onReconnecting = vi.fn();
|
|
connectHeartbeat(onConnect, onReconnecting);
|
|
|
|
// Simulate 10 consecutive failures — should never stop retrying
|
|
for (let i = 0; i < 10; i++) {
|
|
lastEventSource!.onerror!();
|
|
// Advance past the max backoff (15s) to ensure the next retry fires
|
|
vi.advanceTimersByTime(16_000);
|
|
}
|
|
|
|
// Should have created 11 EventSources (1 initial + 10 retries)
|
|
expect(EventSource).toHaveBeenCalledTimes(11);
|
|
});
|
|
|
|
it('resets reconnecting state when connection recovers', () => {
|
|
const onConnect = vi.fn();
|
|
const onReconnecting = vi.fn();
|
|
connectHeartbeat(onConnect, onReconnecting);
|
|
|
|
// Drop
|
|
lastEventSource!.onerror!();
|
|
expect(onReconnecting).toHaveBeenCalledOnce();
|
|
|
|
// Retry succeeds
|
|
vi.advanceTimersByTime(1_000);
|
|
lastEventSource!.onopen!();
|
|
expect(onConnect).toHaveBeenCalledOnce();
|
|
|
|
// Drop again — should fire onReconnecting again (reset after recovery)
|
|
lastEventSource!.onerror!();
|
|
expect(onReconnecting).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('caps backoff at 15 seconds', () => {
|
|
const onConnect = vi.fn();
|
|
const onReconnecting = vi.fn();
|
|
connectHeartbeat(onConnect, onReconnecting);
|
|
|
|
// Fail many times to push backoff past the cap
|
|
for (let i = 0; i < 6; i++) {
|
|
lastEventSource!.onerror!();
|
|
// The delay for attempt i is min(1000 * 2^i, 15000)
|
|
// i=0: 1s, i=1: 2s, i=2: 4s, i=3: 8s, i=4: 15s (capped), i=5: 15s (capped)
|
|
vi.advanceTimersByTime(16_000);
|
|
}
|
|
|
|
// All retries should have fired — 7 EventSources total
|
|
expect(EventSource).toHaveBeenCalledTimes(7);
|
|
});
|
|
|
|
it('stops retrying when cleanup is called', () => {
|
|
const onConnect = vi.fn();
|
|
const onReconnecting = vi.fn();
|
|
const cleanup = connectHeartbeat(onConnect, onReconnecting);
|
|
|
|
lastEventSource!.onerror!();
|
|
cleanup();
|
|
|
|
// Advance time — no new EventSource should be created
|
|
vi.advanceTimersByTime(30_000);
|
|
expect(EventSource).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|