Files
wehub-resource-sync 719032b19f
Update Schema / Update configuration json schema (push) Has been cancelled
Memory Benchmark / Memory Test (Full Analysis) (push) Has been cancelled
CI Quality / Lint GitHub Actions (actionlint) (push) Has been cancelled
CI Quality / Lint GitHub Actions (zizmor) (push) Has been cancelled
CI Quality / Check typos (push) Has been cancelled
CI / Test coverage (push) Has been cancelled
CI / Test (22.x, windows-latest) (push) Has been cancelled
CI / Test (24.x, macos-latest) (push) Has been cancelled
CI / Test (24.x, ubuntu-latest) (push) Has been cancelled
CI / Test (24.x, windows-latest) (push) Has been cancelled
CI / Test (26.x, macos-latest) (push) Has been cancelled
CI / Test (26.x, ubuntu-latest) (push) Has been cancelled
CI / Test (26.x, windows-latest) (push) Has been cancelled
CI / Test with Bun (latest, macos-latest) (push) Has been cancelled
CI / Test with Bun (latest, ubuntu-latest) (push) Has been cancelled
CI / Test with Bun (latest, windows-latest) (push) Has been cancelled
CI / Build and run (22.x, macos-latest) (push) Has been cancelled
CI / Build and run (22.x, ubuntu-latest) (push) Has been cancelled
CI / Build and run (22.x, windows-latest) (push) Has been cancelled
autofix.ci / autofix (push) Has been cancelled
CI Browser Extension / Lint Browser Extension (push) Has been cancelled
CI Browser Extension / Test Browser Extension (push) Has been cancelled
Memory Benchmark / Memory Test (push) Has been cancelled
CI Website / Lint Website Client (push) Has been cancelled
CI Website / Lint Website Server (push) Has been cancelled
CI Website / Test Website Server (push) Has been cancelled
CI Website / Bundle Website Server (push) Has been cancelled
CI / Lint Biome (push) Has been cancelled
CI / Lint oxlint (push) Has been cancelled
CI / Lint TypeScript (push) Has been cancelled
CI / Lint Secretlint (push) Has been cancelled
CI / Test (22.x, macos-latest) (push) Has been cancelled
CI / Test (22.x, ubuntu-latest) (push) Has been cancelled
CI / Build and run (24.x, macos-latest) (push) Has been cancelled
CI / Build and run (24.x, ubuntu-latest) (push) Has been cancelled
CI / Build and run (24.x, windows-latest) (push) Has been cancelled
CI / Build and run (26.x, macos-latest) (push) Has been cancelled
CI / Build and run (26.x, ubuntu-latest) (push) Has been cancelled
CI / Build and run (26.x, windows-latest) (push) Has been cancelled
CI / Build and run with Bun (latest, macos-latest) (push) Has been cancelled
CI / Build and run with Bun (latest, ubuntu-latest) (push) Has been cancelled
CI / Build and run with Bun (latest, windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Docker / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Docker / build (linux/arm/v7, ubuntu-24.04-arm) (push) Has been cancelled
Docker / build (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker / merge (push) Has been cancelled
Pack repository with Repomix / pack-repo (push) Has been cancelled
Performance Benchmark History / Benchmark (macos-latest) (push) Has been cancelled
Performance Benchmark History / Benchmark (ubuntu-latest) (push) Has been cancelled
Performance Benchmark History / Benchmark (windows-latest) (push) Has been cancelled
Performance Benchmark History / Store Results (push) Has been cancelled
Test Repomix Action / Test Node.js 22 (push) Has been cancelled
Test Repomix Action / Test Node.js 24 (push) Has been cancelled
Test Repomix Action / Test Node.js 26 (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:37 +08:00

313 lines
12 KiB
TypeScript

import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
__resetTokenCountCacheForTests,
contentCacheKey,
getCached,
getRepoSeenMarkerPath,
isCacheDisabled,
loadTokenCountCache,
MAX_CACHE_ENTRIES,
saveTokenCountCache,
setCached,
tokenCountCacheFileExistsSync,
tokenCountCacheSeenMarkerExistsSync,
} from '../../../src/core/metrics/tokenCountCache.js';
describe('tokenCountCache', () => {
let tmpDir: string;
let cacheFile: string;
const originalDisableEnv = process.env.REPOMIX_TOKEN_CACHE;
const originalPathEnv = process.env.REPOMIX_TOKEN_CACHE_PATH;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repomix-cache-test-'));
cacheFile = path.join(tmpDir, 'token-counts.json');
process.env.REPOMIX_TOKEN_CACHE_PATH = cacheFile;
delete process.env.REPOMIX_TOKEN_CACHE;
__resetTokenCountCacheForTests();
});
afterEach(async () => {
if (originalDisableEnv === undefined) {
delete process.env.REPOMIX_TOKEN_CACHE;
} else {
process.env.REPOMIX_TOKEN_CACHE = originalDisableEnv;
}
if (originalPathEnv === undefined) {
delete process.env.REPOMIX_TOKEN_CACHE_PATH;
} else {
process.env.REPOMIX_TOKEN_CACHE_PATH = originalPathEnv;
}
__resetTokenCountCacheForTests();
await fs.rm(tmpDir, { recursive: true, force: true });
});
describe('contentCacheKey', () => {
it('produces stable keys for identical content/encoding pairs', () => {
const a = contentCacheKey('o200k_base', 'hello world');
const b = contentCacheKey('o200k_base', 'hello world');
expect(a).toBe(b);
});
it('differs across encodings', () => {
const a = contentCacheKey('o200k_base', 'hello');
const b = contentCacheKey('cl100k_base', 'hello');
expect(a).not.toBe(b);
});
it('differs across content', () => {
const a = contentCacheKey('o200k_base', 'hello');
const b = contentCacheKey('o200k_base', 'world');
expect(a).not.toBe(b);
});
it('embeds the byte length so same-digest different-length inputs cannot collide', () => {
const key = contentCacheKey('o200k_base', 'abc');
const [encoding, byteLength] = key.split(':');
expect(encoding).toBe('o200k_base');
expect(byteLength).toBe('3');
});
});
describe('load + save round-trip', () => {
it('persists entries to disk and reloads them in a fresh process', async () => {
const key = contentCacheKey('o200k_base', 'hello');
setCached(key, 42);
await saveTokenCountCache();
__resetTokenCountCacheForTests();
await loadTokenCountCache();
expect(getCached(key)).toBe(42);
});
it('skips writing when nothing has been added since the last save', async () => {
// Nothing dirty → no file should exist
await saveTokenCountCache();
await expect(fs.stat(cacheFile)).rejects.toThrow();
});
it('starts fresh when the cache file is missing', async () => {
await loadTokenCountCache();
expect(getCached('whatever:0:0000000000000000')).toBeUndefined();
});
it('starts fresh when the cache file is corrupt JSON', async () => {
await fs.mkdir(path.dirname(cacheFile), { recursive: true });
await fs.writeFile(cacheFile, '{not valid json');
await loadTokenCountCache();
const key = contentCacheKey('o200k_base', 'x');
expect(getCached(key)).toBeUndefined();
// Subsequent writes should still succeed
setCached(key, 7);
await saveTokenCountCache();
__resetTokenCountCacheForTests();
await loadTokenCountCache();
expect(getCached(key)).toBe(7);
});
it('discards entries when the on-disk version does not match', async () => {
await fs.mkdir(path.dirname(cacheFile), { recursive: true });
await fs.writeFile(cacheFile, JSON.stringify({ version: 999, entries: { 'o200k_base:1:abc': 5 } }));
await loadTokenCountCache();
expect(getCached('o200k_base:1:abc')).toBeUndefined();
});
it('is atomic: the destination file is replaced via rename, not overwritten in place', async () => {
const key = contentCacheKey('o200k_base', 'first');
setCached(key, 1);
await saveTokenCountCache();
const firstStat = await fs.stat(cacheFile);
const key2 = contentCacheKey('o200k_base', 'second');
setCached(key2, 2);
await saveTokenCountCache();
const secondStat = await fs.stat(cacheFile);
// Atomic rename gives the file a new inode on most filesystems.
// The data, at minimum, must have been replaced cleanly.
const raw = await fs.readFile(cacheFile, 'utf8');
const data = JSON.parse(raw);
expect(data.entries[key]).toBe(1);
expect(data.entries[key2]).toBe(2);
expect(secondStat.size).toBeGreaterThanOrEqual(firstStat.size);
});
});
describe('FIFO eviction', () => {
it('drops the oldest entries once the cap is exceeded on save', async () => {
// Use a stub cap by directly inserting MAX + 5 entries
for (let i = 0; i < MAX_CACHE_ENTRIES + 5; i++) {
setCached(`o200k_base:1:${i.toString(16).padStart(16, '0')}`, i);
}
await saveTokenCountCache();
__resetTokenCountCacheForTests();
await loadTokenCountCache();
// Oldest 5 dropped; newest preserved
expect(getCached(`o200k_base:1:${(0).toString(16).padStart(16, '0')}`)).toBeUndefined();
expect(getCached(`o200k_base:1:${(4).toString(16).padStart(16, '0')}`)).toBeUndefined();
expect(getCached(`o200k_base:1:${(5).toString(16).padStart(16, '0')}`)).toBe(5);
expect(getCached(`o200k_base:1:${(MAX_CACHE_ENTRIES + 4).toString(16).padStart(16, '0')}`)).toBe(
MAX_CACHE_ENTRIES + 4,
);
});
});
describe('disable switch', () => {
it('reports disabled when REPOMIX_TOKEN_CACHE=0', () => {
process.env.REPOMIX_TOKEN_CACHE = '0';
expect(isCacheDisabled()).toBe(true);
});
it('treats unset and other values as enabled', () => {
delete process.env.REPOMIX_TOKEN_CACHE;
expect(isCacheDisabled()).toBe(false);
process.env.REPOMIX_TOKEN_CACHE = '1';
expect(isCacheDisabled()).toBe(false);
});
it('skips load and save when disabled', async () => {
process.env.REPOMIX_TOKEN_CACHE = '0';
const key = contentCacheKey('o200k_base', 'hello');
setCached(key, 99);
await saveTokenCountCache();
// No file should have been written
await expect(fs.stat(cacheFile)).rejects.toThrow();
__resetTokenCountCacheForTests();
await loadTokenCountCache();
expect(getCached(key)).toBeUndefined();
});
it('also disables the in-memory map so long-running processes do not grow unbounded', async () => {
process.env.REPOMIX_TOKEN_CACHE = '0';
await loadTokenCountCache();
const key = contentCacheKey('o200k_base', 'memory');
setCached(key, 11);
// getCached returns undefined even though setCached was called
expect(getCached(key)).toBeUndefined();
});
it('honors REPOMIX_TOKEN_CACHE=0 even when setCached/getCached run before load', () => {
// Order-independence: a caller that bypasses loadTokenCountCache (or
// calls setCached before load resolves) must still respect the env var.
process.env.REPOMIX_TOKEN_CACHE = '0';
const key = contentCacheKey('o200k_base', 'pre-load');
setCached(key, 42);
expect(getCached(key)).toBeUndefined();
});
});
describe('in-memory eviction', () => {
// Test against a smaller working set so the test stays fast — the eviction
// logic in setCached uses MAX_CACHE_ENTRIES as the threshold and the
// ordering invariant is independent of the absolute cap value. We assert
// by inserting one entry beyond the cap and confirming the oldest is gone.
it('evicts the oldest entry on insert when at the cap', async () => {
// Fill the in-memory map to exactly the cap, then push one more.
const firstKey = `o200k_base:1:${(0).toString(16).padStart(16, '0')}`;
setCached(firstKey, 0);
for (let i = 1; i < MAX_CACHE_ENTRIES; i++) {
setCached(`o200k_base:1:${i.toString(16).padStart(16, '0')}`, i);
}
// At the cap; one more insertion of a new key must evict firstKey.
const newKey = `o200k_base:1:${MAX_CACHE_ENTRIES.toString(16).padStart(16, '0')}`;
setCached(newKey, MAX_CACHE_ENTRIES);
expect(getCached(firstKey)).toBeUndefined();
expect(getCached(newKey)).toBe(MAX_CACHE_ENTRIES);
});
it('refreshes (does not evict) when overwriting an existing key at the cap', async () => {
const firstKey = `o200k_base:1:${(0).toString(16).padStart(16, '0')}`;
setCached(firstKey, 0);
for (let i = 1; i < MAX_CACHE_ENTRIES; i++) {
setCached(`o200k_base:1:${i.toString(16).padStart(16, '0')}`, i);
}
// Overwriting an existing key must not trigger eviction.
setCached(firstKey, 999);
expect(getCached(firstKey)).toBe(999);
});
});
describe('per-repo seen markers (warm-likely heuristic)', () => {
// Regression set for the metrics-pool prewarm heuristic. The pool only
// drops eager warm-up to 1 when BOTH the shared cache file exists AND
// a per-repo marker exists. These tests pin three corners of that
// contract — without them a future refactor could silently regress the
// heuristic back to the "any cache file means warm" failure mode.
it('an unrelated repo stays cold-likely even after the shared cache is populated', async () => {
const repoA = ['/path/to/repo-a'];
const repoB = ['/path/to/repo-b'];
const key = contentCacheKey('o200k_base', 'shared content');
setCached(key, 12);
await saveTokenCountCache(repoA);
// The shared cache file now exists machine-wide.
expect(tokenCountCacheFileExistsSync()).toBe(true);
// But repo B has never written, so the marker for it must be absent.
expect(tokenCountCacheSeenMarkerExistsSync(repoB)).toBe(false);
// And repo A's marker should be present.
expect(tokenCountCacheSeenMarkerExistsSync(repoA)).toBe(true);
});
it('the same repo becomes warm-likely after a successful save', async () => {
const repo = ['/some/repo'];
// Before any save, neither file nor marker exist.
expect(tokenCountCacheFileExistsSync()).toBe(false);
expect(tokenCountCacheSeenMarkerExistsSync(repo)).toBe(false);
const key = contentCacheKey('o200k_base', 'a');
setCached(key, 1);
await saveTokenCountCache(repo);
// Both probes must agree the next pack is warm-likely.
expect(tokenCountCacheFileExistsSync()).toBe(true);
expect(tokenCountCacheSeenMarkerExistsSync(repo)).toBe(true);
});
it('a `--remote`-style fresh mkdtemp path does not see a stale marker', async () => {
// First `--remote` invocation clones to a random temp dir and saves.
const firstClone = ['/var/folders/T/repomix-AbCdEf'];
const key = contentCacheKey('o200k_base', 'cloned content');
setCached(key, 7);
await saveTokenCountCache(firstClone);
// Second `--remote` clones to a different random path (mkdtemp again).
// Even though the shared cache and the previous clone's marker still
// exist on disk, the new clone's marker is absent → prewarm decision
// must fall back to cold.
const secondClone = ['/var/folders/T/repomix-ZyXwVu'];
expect(tokenCountCacheFileExistsSync()).toBe(true);
expect(tokenCountCacheSeenMarkerExistsSync(firstClone)).toBe(true);
expect(tokenCountCacheSeenMarkerExistsSync(secondClone)).toBe(false);
});
it('the marker is NOT created when the save is a no-op (nothing dirty)', async () => {
const repo = ['/some/repo'];
// No setCached → save short-circuits without writing the cache file
// or touching the marker. Both must remain absent.
await saveTokenCountCache(repo);
expect(tokenCountCacheFileExistsSync()).toBe(false);
expect(tokenCountCacheSeenMarkerExistsSync(repo)).toBe(false);
});
it('marker path is sensitive to the rootDirs list but insensitive to ordering', async () => {
const a = getRepoSeenMarkerPath(['/a', '/b']);
const b = getRepoSeenMarkerPath(['/b', '/a']);
const c = getRepoSeenMarkerPath(['/a', '/c']);
expect(a).toBe(b);
expect(a).not.toBe(c);
});
});
});