GitNexus/gitnexus/test/unit/server-info.test.ts
Gergő Magyar c0c3fa18a9
chore: release v1.6.11 (#3177)
* chore: release v1.6.11

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(test): read /api/info version from package.json

server-info unit tests hardcoded 1.6.10 while buildServerInfo reads
package.json, so the 1.6.11 bump failed ubuntu coverage 3/3.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: read the published version from one helper

Release bumps kept breaking tests that each re-required package.json.
packageVersion() is now the single read for CLI, MCP, serve, and those tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 20:02:37 +01:00

212 lines
7.2 KiB
TypeScript

import { EventEmitter } from 'node:events';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
bindServeUpdateControllerLifecycle,
buildServerInfo,
createServeUpdateController,
} from '../../src/server/update-controller.js';
import { packageVersion } from '../../src/core/package-version.js';
import { evaluate } from '../../src/core/update-check.js';
import type { UpdateState } from '../../src/core/update-check.js';
const PKG_VERSION = packageVersion();
const baseKeys = ['version', 'launchContext', 'nodeVersion'];
const tempDirs: string[] = [];
afterEach(async () => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
vi.restoreAllMocks();
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
describe('GET /api/info update state', () => {
it('keeps the existing three fields unchanged when no update is available', () => {
const response = buildServerInfo(null);
expect(Object.keys(response)).toEqual(baseKeys);
expect(response).toEqual({
version: PKG_VERSION,
launchContext: expect.stringMatching(/^(npx|global|local)$/),
nodeVersion: process.version,
});
});
it('adds optional update fields only for an available version', () => {
expect(buildServerInfo({ updateAvailable: true, latestVersion: '9.8.7' })).toEqual({
version: PKG_VERSION,
launchContext: expect.stringMatching(/^(npx|global|local)$/),
nodeVersion: process.version,
latestVersion: '9.8.7',
updateAvailable: true,
});
expect(
Object.keys(buildServerInfo({ updateAvailable: false, latestVersion: PKG_VERSION })),
).toEqual(baseKeys);
});
it.each(['GITNEXUS_NO_UPDATE_NOTIFIER', 'NO_UPDATE_NOTIFIER', 'CI'])(
'omits update fields after start when %s is set',
async (name) => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-serve-update-'));
tempDirs.push(home);
vi.stubEnv('GITNEXUS_HOME', home);
vi.stubEnv('npm_config_registry', 'https://registry.npmjs.org');
vi.stubEnv(name, '1');
await fs.writeFile(
path.join(home, 'update-check.json'),
JSON.stringify({
lastCheckAt: new Date().toISOString(),
registry: 'https://registry.npmjs.org',
latestVersion: '9.9.9',
}),
);
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const evaluateSpy = vi.fn((options?: { refreshIfStale?: boolean }) =>
evaluate({ ...options, eligible: true }),
);
const controller = createServeUpdateController({
evaluate: evaluateSpy,
armScheduler: vi.fn(() => vi.fn()),
});
await controller.start();
expect(evaluateSpy).toHaveBeenCalledWith({ refreshIfStale: false });
expect(Object.keys(buildServerInfo(controller.snapshot()))).toEqual(baseKeys);
expect(fetchMock).not.toHaveBeenCalled();
},
);
it('omits update fields after start for an ineligible install', async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-serve-update-'));
tempDirs.push(home);
vi.stubEnv('GITNEXUS_HOME', home);
vi.stubEnv('npm_config_registry', 'https://registry.npmjs.org');
vi.stubEnv('CI', '');
await fs.writeFile(
path.join(home, 'update-check.json'),
JSON.stringify({
lastCheckAt: new Date().toISOString(),
registry: 'https://registry.npmjs.org',
latestVersion: '9.9.9',
}),
);
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const evaluateSpy = vi.fn((options?: { refreshIfStale?: boolean }) =>
evaluate({ ...options, eligible: false }),
);
const controller = createServeUpdateController({
evaluate: evaluateSpy,
armScheduler: vi.fn(() => vi.fn()),
});
await controller.start();
expect(evaluateSpy).toHaveBeenCalledWith({ refreshIfStale: false });
expect(Object.keys(buildServerInfo(controller.snapshot()))).toEqual(baseKeys);
expect(fetchMock).not.toHaveBeenCalled();
});
it('stays assignable to the web client ServerInfo contract', () => {
// Mirrors gitnexus-web/src/services/backend-client.ts without creating a
// cross-package import that would couple either package's build graph.
interface WebClientServerInfo {
version: string;
launchContext: 'npx' | 'global' | 'local';
nodeVersion: string;
latestVersion?: string;
updateAvailable?: boolean;
}
const response: WebClientServerInfo = buildServerInfo({
updateAvailable: true,
latestVersion: '9.8.7',
});
expect(response.updateAvailable).toBe(true);
});
});
describe('serve update controller lifecycle', () => {
it('starts only after successful listen and stops whenever the server closes', async () => {
const server = new EventEmitter();
const controller = {
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn(),
snapshot: vi.fn().mockReturnValue(null),
};
bindServeUpdateControllerLifecycle(server, controller);
expect(controller.start).not.toHaveBeenCalled();
server.emit('listening');
expect(controller.start).toHaveBeenCalledOnce();
server.emit('close');
expect(controller.stop).toHaveBeenCalledOnce();
});
it('evaluates once before arming the scheduler and serves memory-only snapshots', async () => {
const calls: string[] = [];
let publish: ((state: UpdateState | null) => void) | undefined;
const evaluate = vi.fn(async () => {
calls.push('evaluate');
return { updateAvailable: true, latestVersion: '2.0.0' };
});
const armScheduler = vi.fn((onState) => {
calls.push('arm');
publish = onState;
return vi.fn();
});
const controller = createServeUpdateController({ evaluate, armScheduler });
expect(controller.snapshot()).toBeNull();
await controller.start();
expect(calls).toEqual(['evaluate', 'arm']);
expect(controller.snapshot()).toEqual({
updateAvailable: true,
latestVersion: '2.0.0',
});
publish?.({ updateAvailable: true, latestVersion: '2.1.0' });
expect(controller.snapshot()).toEqual({
updateAvailable: true,
latestVersion: '2.1.0',
});
expect(evaluate).toHaveBeenCalledTimes(1);
expect(evaluate).toHaveBeenCalledWith({ refreshIfStale: false });
});
it('fails open and still arms the long-lived refresh scheduler', async () => {
const armScheduler = vi.fn(() => vi.fn());
const controller = createServeUpdateController({
evaluate: vi.fn().mockRejectedValue(new Error('checker failed')),
armScheduler,
});
await expect(controller.start()).resolves.toBeUndefined();
expect(controller.snapshot()).toBeNull();
expect(armScheduler).toHaveBeenCalledOnce();
expect(Object.keys(buildServerInfo(controller.snapshot()))).toEqual(baseKeys);
});
it('stops the scheduler on shutdown and is idempotent', async () => {
const stop = vi.fn();
const controller = createServeUpdateController({
evaluate: vi.fn().mockResolvedValue(null),
armScheduler: vi.fn(() => stop),
});
await controller.start();
controller.stop();
controller.stop();
expect(stop).toHaveBeenCalledOnce();
});
});