mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-01 02:01:24 +00:00
* feat(core): add cache-first npm update-check service Shared fail-open checker: validated 24h cache under GITNEXUS_HOME, acquireFileLock-guarded refresh, monotonic publication, hardened registry fetch (no credentials, private-address redirects refused, body-capped), strict x.y.z comparator, install-eligibility classification, and an unref'd refresh scheduler for long-lived processes. Extracts getGlobalDir into storage/global-dir.ts with a repo-manager re-export (no caller changes). Co-authored-by: Cursor <cursoragent@cursor.com> * feat(cli): notify on available updates via stderr and doctor One i18n'd stderr line on interactive invocations when the validated cache holds a newer version (TTY-gated, CI/opt-out/eligibility-gated, hook and help/version command identities excluded). Stale cache spawns a detached hidden __update-check refresh child so command exit latency is unchanged. doctor prints the cached latest version when known. Dockerfile.cli sets GITNEXUS_NO_UPDATE_NOTIFIER=1. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(mcp): emit one stderr update notice per process per version Process-scoped adapter in mcpCommand (stdio and --http), dynamically imported after the stdout sentinel, started only after connect, fully catch-isolated. Arms the shared refresh scheduler with cleanup on process exit. Protocol payloads stay free of update state (R15). Co-authored-by: Cursor <cursoragent@cursor.com> * feat(serve): expose update state on /api/info Serve-scoped controller owns an in-memory update snapshot: one staleness evaluation after listen, then the shared unref'd scheduler, stopped on close/shutdown. /api/info reads only the snapshot and gains optional latestVersion/updateAvailable fields for eligible installs; the existing three fields are byte-compatible. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web): dismissible update-available banner from /api/info Fetches server info after backend connect and on reconnect, renders a fixed banner in the exploring view only when updateAvailable is true and the version is undismissed, hides while the reconnect banner is active, and fails open on fetch errors. role=status + aria-live with a keyboard-focusable dismiss; dismissal persists per version in localStorage. Copy in en/zh-CN common.json with version interpolation. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(cli): document update notifications and opt-outs Co-authored-by: Cursor <cursoragent@cursor.com> * fix(review): apply review findings and simplify pass Review: gate the detached refresh spawn on a live lock-owner probe so parallel CLI invocations coalesce to one refresh child (validated P2, three-reviewer agreement); poll /api/info on a slow cadence while exploring so post-load server-side discoveries surface (validated P1); add a monotonic sequence guard so overlapping server-info fetches commit in order. Simplify (behavior-preserving): shared truthy-env/opt-out/freshness helpers in update-cache.ts, shared cachedUpdateNoticeLine for CLI and doctor, extracted install-eligibility core with per-process memo, memoized registry parsing, single evaluation per scheduler cycle, cache-only startup evaluate in serve, flattened MCP exit handler, shared bottom-banner shell, storage keys in ui-constants. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(update-notifier): address residual review tickets on this PR Stop the lock-busy 1ms scheduler spin, replace clock-skewed cache entries, move the outbound URL guard into core, and extract the serve update controller. Pin the startup/guard/single-flight/MCP/CLI contracts those tickets called out. Fixes #3167 #3168 #3169 #3170 #3171 #3172 #3173 #3174 Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3175) Fetch the npm /latest document instead of the full packument so the 64KiB cap can succeed, and treat reused lock PIDs as stale so refresh is not suppressed. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3175) Register the CLI spawn suite on the OS matrix, pin MCP opt-out env, and compare versions without IEEE-754 rounding. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(cli): add gitnexus update install and versioned command banners Give an explicit Claude/Codex-style upgrade (`npm i -g gitnexus@version`) and print `GitNexus <Name> (version)` on every command so the running build is obvious without silent self-update. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3175) - Document the pinned install as npm i -g gitnexus@<x.y.z>, not a copyable @version tag - Wait for wall-clock-future cache repair to publish before asserting - Restore the stdout spy if the TTY notice assertions fail Co-authored-by: Cursor <cursoragent@cursor.com> * fix(update-notifier): keep last known latestVersion on a failed refresh A later offline check was wiping the pin and hiding a known update for 24h. gitnexus update still treats a failed live fetch as checkFailed. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3175) - Word update.current so a newer-than-latest install is not called the latest stable version. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ci): hide the detached update-check spawn on Windows The refresh child was spawned without windowsHide, so Windows CI could stall before writing the cache and then fail cleanup with EBUSY. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
539 lines
19 KiB
TypeScript
539 lines
19 KiB
TypeScript
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { updateEligibleInstall } from '../../src/core/install-context.js';
|
|
import { isNewerVersion } from '../../src/core/update-cache.js';
|
|
import { armUpdateRefreshScheduler, evaluate, refresh } from '../../src/core/update-check.js';
|
|
import { acquireFileLock } from '../../src/storage/file-lock.js';
|
|
|
|
const DAY_MS = 24 * 60 * 60 * 1_000;
|
|
const NOW = Date.parse('2026-09-04T05:00:00.000Z');
|
|
const REGISTRY = 'https://registry.npmjs.org';
|
|
const tempDirs: string[] = [];
|
|
|
|
async function tempHome(): Promise<string> {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-update-check-'));
|
|
tempDirs.push(dir);
|
|
vi.stubEnv('GITNEXUS_HOME', dir);
|
|
return dir;
|
|
}
|
|
|
|
function cachePath(home: string): string {
|
|
return path.join(home, 'update-check.json');
|
|
}
|
|
|
|
async function writeCache(
|
|
home: string,
|
|
body: { lastCheckAt: string; registry: string; latestVersion?: string },
|
|
): Promise<void> {
|
|
await fs.mkdir(home, { recursive: true });
|
|
await fs.writeFile(cachePath(home), JSON.stringify(body));
|
|
}
|
|
|
|
async function readCache(home: string): Promise<Record<string, unknown>> {
|
|
return JSON.parse(await fs.readFile(cachePath(home), 'utf8')) as Record<string, unknown>;
|
|
}
|
|
|
|
function registryResponse(version = '1.7.0'): Response {
|
|
return new Response(JSON.stringify({ version }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.stubEnv('npm_config_registry', REGISTRY);
|
|
// GitHub Actions sets CI=true; the checker treats that as a hard opt-out.
|
|
vi.stubEnv('CI', '');
|
|
vi.stubEnv('GITNEXUS_NO_UPDATE_NOTIFIER', '');
|
|
vi.stubEnv('NO_UPDATE_NOTIFIER', '');
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.useRealTimers();
|
|
vi.unstubAllEnvs();
|
|
vi.unstubAllGlobals();
|
|
vi.restoreAllMocks();
|
|
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
|
});
|
|
|
|
describe('update check cache and versions', () => {
|
|
it('returns a newer version from a fresh cache', async () => {
|
|
const home = await tempHome();
|
|
await writeCache(home, {
|
|
lastCheckAt: new Date(NOW).toISOString(),
|
|
registry: REGISTRY,
|
|
latestVersion: '1.7.0',
|
|
});
|
|
|
|
await expect(
|
|
evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW }),
|
|
).resolves.toEqual({ updateAvailable: true, latestVersion: '1.7.0' });
|
|
});
|
|
|
|
it('returns stale valid state and starts one stale-while-revalidate refresh', async () => {
|
|
const home = await tempHome();
|
|
await writeCache(home, {
|
|
lastCheckAt: new Date(NOW - DAY_MS - 1).toISOString(),
|
|
registry: REGISTRY,
|
|
latestVersion: '1.7.0',
|
|
});
|
|
const fetchMock = vi.fn().mockResolvedValue(registryResponse('1.8.0'));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await expect(
|
|
evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW }),
|
|
).resolves.toEqual({ updateAvailable: true, latestVersion: '1.7.0' });
|
|
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
|
await vi.waitFor(async () => expect((await readCache(home)).latestVersion).toBe('1.8.0'));
|
|
});
|
|
|
|
it.each(['1.6.10', '1.6.9'])('is silent for fresh equal/lower latest %s', async (latest) => {
|
|
const home = await tempHome();
|
|
await writeCache(home, {
|
|
lastCheckAt: new Date(NOW).toISOString(),
|
|
registry: REGISTRY,
|
|
latestVersion: latest,
|
|
});
|
|
|
|
await expect(
|
|
evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW }),
|
|
).resolves.toEqual({ updateAvailable: false, latestVersion: latest });
|
|
});
|
|
|
|
it('treats corrupt JSON as a miss and refresh overwrites it', async () => {
|
|
const home = await tempHome();
|
|
await fs.writeFile(cachePath(home), '{broken');
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(registryResponse()));
|
|
|
|
await expect(
|
|
evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW }),
|
|
).resolves.toBeNull();
|
|
await vi.waitFor(async () => expect((await readCache(home)).latestVersion).toBe('1.7.0'));
|
|
});
|
|
|
|
it('never propagates a latestVersion with a non-strict format', async () => {
|
|
const home = await tempHome();
|
|
await writeCache(home, {
|
|
lastCheckAt: new Date(NOW).toISOString(),
|
|
registry: REGISTRY,
|
|
latestVersion: 'v1.7.0',
|
|
});
|
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
|
|
|
|
await expect(
|
|
evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW }),
|
|
).resolves.toBeNull();
|
|
await vi.waitFor(async () => expect((await readCache(home)).latestVersion).toBeUndefined());
|
|
});
|
|
|
|
it('treats a future timestamp as stale', async () => {
|
|
const home = await tempHome();
|
|
// Wall-clock-future lastCheckAt is monotonic poison; NOW+1ms is still in
|
|
// the past on a later wall clock and would not be overwritten.
|
|
await writeCache(home, {
|
|
lastCheckAt: new Date(Date.now() + 60_000).toISOString(),
|
|
registry: REGISTRY,
|
|
latestVersion: '1.6.0',
|
|
});
|
|
const fetchMock = vi.fn().mockResolvedValue(registryResponse());
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW });
|
|
await vi.waitFor(async () =>
|
|
expect(await readCache(home)).toEqual({
|
|
lastCheckAt: new Date(NOW).toISOString(),
|
|
registry: REGISTRY,
|
|
latestVersion: '1.7.0',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('writes a negative entry after failure and suppresses retries inside the TTL', async () => {
|
|
const home = await tempHome();
|
|
const fetchMock = vi.fn().mockRejectedValue(new Error('offline'));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await expect(refresh({ eligible: true, now: NOW })).resolves.toBeNull();
|
|
expect(await readCache(home)).toEqual({
|
|
lastCheckAt: new Date(NOW).toISOString(),
|
|
registry: REGISTRY,
|
|
});
|
|
await evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW + 1 });
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('preserves a known latestVersion when a later refresh fails', async () => {
|
|
const home = await tempHome();
|
|
await writeCache(home, {
|
|
lastCheckAt: new Date(NOW - DAY_MS - 1).toISOString(),
|
|
registry: REGISTRY,
|
|
latestVersion: '1.7.0',
|
|
});
|
|
const fetchMock = vi.fn().mockRejectedValue(new Error('offline'));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await expect(
|
|
refresh({ eligible: true, installedVersion: '1.6.10', now: NOW }),
|
|
).resolves.toBeNull();
|
|
expect(await readCache(home)).toEqual({
|
|
lastCheckAt: new Date(NOW).toISOString(),
|
|
registry: REGISTRY,
|
|
latestVersion: '1.7.0',
|
|
});
|
|
await expect(
|
|
evaluate({
|
|
eligible: true,
|
|
installedVersion: '1.6.10',
|
|
now: NOW + 1,
|
|
refreshIfStale: false,
|
|
}),
|
|
).resolves.toEqual({ updateAvailable: true, latestVersion: '1.7.0' });
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('skips fetch when another process holds the refresh lock', async () => {
|
|
const home = await tempHome();
|
|
const release = await acquireFileLock(path.join(home, 'update-check.lock'));
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
try {
|
|
await refresh({ eligible: true, now: NOW });
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
} finally {
|
|
await release();
|
|
}
|
|
});
|
|
|
|
it('returns stale cache state without fetching when refreshIfStale is false', async () => {
|
|
const home = await tempHome();
|
|
await writeCache(home, {
|
|
lastCheckAt: new Date(NOW - DAY_MS - 1).toISOString(),
|
|
registry: REGISTRY,
|
|
latestVersion: '1.7.0',
|
|
});
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await expect(
|
|
evaluate({
|
|
eligible: true,
|
|
installedVersion: '1.6.10',
|
|
now: NOW,
|
|
refreshIfStale: false,
|
|
}),
|
|
).resolves.toEqual({ updateAvailable: true, latestVersion: '1.7.0' });
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('replaces a future-dated cache timestamp instead of preserving it', async () => {
|
|
const home = await tempHome();
|
|
await writeCache(home, {
|
|
lastCheckAt: '2099-01-01T00:00:00.000Z',
|
|
registry: REGISTRY,
|
|
latestVersion: '9.9.9',
|
|
});
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(registryResponse('1.8.0')));
|
|
|
|
await refresh({ eligible: true, installedVersion: '1.6.10', now: NOW });
|
|
|
|
expect(await readCache(home)).toEqual({
|
|
lastCheckAt: new Date(NOW).toISOString(),
|
|
registry: REGISTRY,
|
|
latestVersion: '1.8.0',
|
|
});
|
|
});
|
|
|
|
it('dedupes concurrent refresh() callers onto one fetch', async () => {
|
|
await tempHome();
|
|
let resolveFetch!: (response: Response) => void;
|
|
const fetchMock = vi.fn(
|
|
() =>
|
|
new Promise<Response>((resolve) => {
|
|
resolveFetch = resolve;
|
|
}),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const first = refresh({ eligible: true, installedVersion: '1.6.10', now: NOW });
|
|
const second = refresh({ eligible: true, installedVersion: '1.6.10', now: NOW });
|
|
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
|
resolveFetch(registryResponse('1.8.0'));
|
|
|
|
await expect(Promise.all([first, second])).resolves.toEqual([
|
|
{ updateAvailable: true, latestVersion: '1.8.0' },
|
|
{ updateAvailable: true, latestVersion: '1.8.0' },
|
|
]);
|
|
expect(first).toBe(second);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not let a late failed attempt overwrite a newer success', async () => {
|
|
const home = await tempHome();
|
|
let rejectFetch!: (error: Error) => void;
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(
|
|
() =>
|
|
new Promise<Response>((_resolve, reject) => {
|
|
rejectFetch = reject;
|
|
}),
|
|
),
|
|
);
|
|
|
|
// Relative to the real clock: publishMonotonically only preserves a newer
|
|
// on-disk timestamp when it is not in the future (`currentAt <= Date.now()`).
|
|
const wall = Date.now();
|
|
const olderAttempt = wall - 10_000;
|
|
const newerSuccess = wall - 1;
|
|
const pending = refresh({ eligible: true, now: olderAttempt });
|
|
await vi.waitFor(() => expect(rejectFetch).toBeTypeOf('function'));
|
|
await writeCache(home, {
|
|
lastCheckAt: new Date(newerSuccess).toISOString(),
|
|
registry: REGISTRY,
|
|
latestVersion: '1.8.0',
|
|
});
|
|
rejectFetch(new Error('late failure'));
|
|
await pending;
|
|
|
|
expect(await readCache(home)).toEqual({
|
|
lastCheckAt: new Date(newerSuccess).toISOString(),
|
|
registry: REGISTRY,
|
|
latestVersion: '1.8.0',
|
|
});
|
|
});
|
|
|
|
it('treats a cache from another registry as a miss', async () => {
|
|
const home = await tempHome();
|
|
await writeCache(home, {
|
|
lastCheckAt: new Date(NOW).toISOString(),
|
|
registry: 'https://registry.example.test/custom',
|
|
latestVersion: '9.0.0',
|
|
});
|
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
|
|
|
|
await expect(
|
|
evaluate({
|
|
eligible: true,
|
|
installedVersion: '1.6.10',
|
|
now: NOW,
|
|
refreshIfStale: false,
|
|
}),
|
|
).resolves.toBeNull();
|
|
});
|
|
|
|
it('handles prerelease and lower versions with the strict comparator', () => {
|
|
expect(isNewerVersion('1.7.0-rc.1', '1.6.10')).toBe(false);
|
|
expect(isNewerVersion('1.7.0', '1.6.10')).toBe(false);
|
|
expect(isNewerVersion('1.6.10', '1.6.9')).toBe(false);
|
|
expect(isNewerVersion('1.6.10', '1.7.0')).toBe(true);
|
|
expect(isNewerVersion('1.0.9007199254740992', '1.0.9007199254740993')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('hardened registry request', () => {
|
|
it('strips registry userinfo and sends no credentials', async () => {
|
|
await tempHome();
|
|
vi.stubEnv('npm_config_registry', 'https://user:secret@registry.example.test/custom/');
|
|
const fetchMock = vi.fn().mockResolvedValue(registryResponse());
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await refresh({ eligible: true, now: NOW });
|
|
|
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(url).toBe('https://registry.example.test/custom/gitnexus/latest');
|
|
expect(url).not.toContain('user');
|
|
expect(new Headers(init.headers).has('authorization')).toBe(false);
|
|
});
|
|
|
|
it('refuses a redirect to loopback/private addresses', async () => {
|
|
const home = await tempHome();
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(
|
|
new Response(null, { status: 302, headers: { location: 'http://127.0.0.1/latest' } }),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await refresh({ eligible: true, now: NOW });
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(await readCache(home)).not.toHaveProperty('latestVersion');
|
|
});
|
|
|
|
it('caps an oversized response body', async () => {
|
|
const home = await tempHome();
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue(new Response('x'.repeat(70_000), { status: 200 })),
|
|
);
|
|
|
|
await refresh({ eligible: true, now: NOW });
|
|
|
|
expect(await readCache(home)).not.toHaveProperty('latestVersion');
|
|
});
|
|
});
|
|
|
|
describe('guards and scheduler', () => {
|
|
it.each(['GITNEXUS_NO_UPDATE_NOTIFIER', 'NO_UPDATE_NOTIFIER', 'CI'])(
|
|
'%s skips cache refresh and network with truthy-env semantics',
|
|
async (name) => {
|
|
await tempHome();
|
|
vi.stubEnv(name, 'yes');
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await expect(evaluate({ eligible: true, now: NOW })).resolves.toBeNull();
|
|
await refresh({ eligible: true, now: NOW });
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('ignoreOptOut still fetches when CI/opt-out env is set', async () => {
|
|
await tempHome();
|
|
vi.stubEnv('CI', 'true');
|
|
const fetchMock = vi.fn().mockResolvedValue(registryResponse());
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await expect(
|
|
refresh({
|
|
eligible: true,
|
|
ignoreOptOut: true,
|
|
installedVersion: '1.6.10',
|
|
now: NOW,
|
|
}),
|
|
).resolves.toEqual({ updateAvailable: true, latestVersion: '1.7.0' });
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('an ineligible install skips network', async () => {
|
|
await tempHome();
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await expect(evaluate({ eligible: false, now: NOW })).resolves.toBeNull();
|
|
await refresh({ eligible: false, now: NOW });
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('an unwritable cache path fails open', async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-update-unwritable-'));
|
|
tempDirs.push(root);
|
|
const blocker = path.join(root, 'not-a-directory');
|
|
await fs.writeFile(blocker, 'x');
|
|
vi.stubEnv('GITNEXUS_HOME', path.join(blocker, 'child'));
|
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
|
|
|
|
await expect(evaluate({ eligible: true, now: NOW })).resolves.toBeNull();
|
|
await expect(refresh({ eligible: true, now: NOW })).resolves.toBeNull();
|
|
});
|
|
|
|
it('arms an unrefd, clearable, single-flight scheduler', async () => {
|
|
await tempHome();
|
|
let resolveFetch!: (response: Response) => void;
|
|
const fetchMock = vi.fn(
|
|
() =>
|
|
new Promise<Response>((resolve) => {
|
|
resolveFetch = resolve;
|
|
}),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const onState = vi.fn();
|
|
const timeoutSpy = vi.spyOn(globalThis, 'setTimeout');
|
|
|
|
const clearA = armUpdateRefreshScheduler(onState, { eligible: true, now: () => NOW });
|
|
const clearB = armUpdateRefreshScheduler(onState, { eligible: true, now: () => NOW });
|
|
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
|
const immediateHandles = timeoutSpy.mock.results
|
|
.map((result) => result.value as NodeJS.Timeout)
|
|
.filter((handle) => typeof handle?.hasRef === 'function');
|
|
expect(immediateHandles.some((handle) => !handle.hasRef())).toBe(true);
|
|
resolveFetch(registryResponse());
|
|
await vi.waitFor(() => expect(onState).toHaveBeenCalled());
|
|
clearA();
|
|
clearB();
|
|
});
|
|
|
|
it('backs off when refresh is lock-busy instead of spinning at 1ms', async () => {
|
|
const home = await tempHome();
|
|
await writeCache(home, {
|
|
lastCheckAt: new Date(NOW - DAY_MS - 1).toISOString(),
|
|
registry: REGISTRY,
|
|
latestVersion: '1.7.0',
|
|
});
|
|
const release = await acquireFileLock(path.join(home, 'update-check.lock'));
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const timeoutSpy = vi.spyOn(globalThis, 'setTimeout');
|
|
const onState = vi.fn();
|
|
try {
|
|
const clear = armUpdateRefreshScheduler(onState, { eligible: true, now: () => NOW });
|
|
await vi.waitFor(() => expect(onState).toHaveBeenCalled());
|
|
const retryDelay = timeoutSpy.mock.calls
|
|
.map(([, delay]) => delay)
|
|
.find(
|
|
(delay): delay is number =>
|
|
typeof delay === 'number' && delay >= 30_000 && delay <= 60_000,
|
|
);
|
|
expect(retryDelay).toBeDefined();
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
clear();
|
|
} finally {
|
|
await release();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('updateEligibleInstall', () => {
|
|
async function entry(relative: string): Promise<{ root: string; entry: string }> {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-install-context-'));
|
|
tempDirs.push(root);
|
|
const entryPath = path.join(root, relative);
|
|
await fs.mkdir(path.dirname(entryPath), { recursive: true });
|
|
await fs.writeFile(entryPath, '');
|
|
return { root, entry: entryPath };
|
|
}
|
|
|
|
it('classifies global, local, ephemeral, dev, and Docker layouts', async () => {
|
|
const global = await entry('prefix/lib/node_modules/gitnexus/dist/cli/index.js');
|
|
expect(
|
|
await updateEligibleInstall(global.entry, {
|
|
npm_config_prefix: path.join(global.root, 'prefix'),
|
|
}),
|
|
).toBe(true);
|
|
|
|
const local = await entry('project/node_modules/gitnexus/dist/cli/index.js');
|
|
expect(await updateEligibleInstall(local.entry, {})).toBe(true);
|
|
|
|
const namedDlx = await entry('dlx/project/node_modules/gitnexus/dist/cli/index.js');
|
|
expect(await updateEligibleInstall(namedDlx.entry, {})).toBe(true);
|
|
|
|
for (const relative of [
|
|
'cache/_npx/123/node_modules/gitnexus/dist/cli/index.js',
|
|
'cache/_cacache/tmp/node_modules/gitnexus/dist/cli/index.js',
|
|
'pnpm/dlx/123/node_modules/gitnexus/dist/cli/index.js',
|
|
'.bun/install/cache/gitnexus@1.0.0/node_modules/gitnexus/dist/cli/index.js',
|
|
]) {
|
|
const ephemeral = await entry(relative);
|
|
expect(
|
|
await updateEligibleInstall(ephemeral.entry, {
|
|
npm_config_cache: path.join(ephemeral.root, 'cache'),
|
|
npm_execpath: path.join(ephemeral.root, relative),
|
|
}),
|
|
).toBe(false);
|
|
}
|
|
|
|
const dev = await entry('checkout/gitnexus/src/cli/index.ts');
|
|
await fs.mkdir(path.join(dev.root, 'checkout', '.git'));
|
|
expect(await updateEligibleInstall(dev.entry, {})).toBe(false);
|
|
|
|
const docker = await entry('usr/local/lib/node_modules/gitnexus/dist/cli/index.js');
|
|
expect(
|
|
await updateEligibleInstall(docker.entry, {
|
|
npm_config_prefix: path.join(docker.root, 'usr/local'),
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
});
|