chore: import upstream snapshot with attribution
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,685 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import cliState from '../../src/cliState';
|
||||
import {
|
||||
closeDb,
|
||||
DrizzleLogWriter,
|
||||
getDb,
|
||||
getDbPath,
|
||||
getDbSignalPath,
|
||||
isDbOpen,
|
||||
} from '../../src/database/index';
|
||||
import { getEnvBool } from '../../src/envars';
|
||||
import logger from '../../src/logger';
|
||||
import { getConfigDirectoryPath } from '../../src/util/config/manage';
|
||||
import { mockProcessEnv } from '../util/utils';
|
||||
|
||||
vi.mock('../../src/envars', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/envars')>();
|
||||
return { ...actual, getEnvBool: vi.fn(actual.getEnvBool) };
|
||||
});
|
||||
vi.mock('../../src/logger');
|
||||
vi.mock('../../src/util/config/manage');
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('os')>();
|
||||
return { ...actual, homedir: vi.fn(actual.homedir) };
|
||||
});
|
||||
|
||||
// Passthrough fs mock with a fault-injection switch for statSync; the ESM namespace
|
||||
// itself cannot be spied on.
|
||||
const statSyncFault = vi.hoisted(() => ({ error: undefined as Error | undefined }));
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs')>();
|
||||
return {
|
||||
...actual,
|
||||
statSync: ((...args: Parameters<typeof actual.statSync>) => {
|
||||
if (statSyncFault.error) {
|
||||
throw statSyncFault.error;
|
||||
}
|
||||
return actual.statSync(...args);
|
||||
}) as typeof actual.statSync,
|
||||
};
|
||||
});
|
||||
|
||||
const ORIGINAL_HOME_DIR = os.homedir();
|
||||
const VITEST_WORKER_MARKER = '__vitest_worker__';
|
||||
const JEST_NATIVE_PROMISE_MARKER = Symbol.for('jest-native-promise');
|
||||
|
||||
function withTestRunnerMarkers<T>(
|
||||
markers: { vitest?: boolean; jest?: boolean },
|
||||
callback: () => T,
|
||||
): T {
|
||||
const markerKeys: PropertyKey[] = [VITEST_WORKER_MARKER, JEST_NATIVE_PROMISE_MARKER];
|
||||
const descriptors = markerKeys.map(
|
||||
(key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const,
|
||||
);
|
||||
|
||||
try {
|
||||
for (const key of markerKeys) {
|
||||
Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
if (markers.vitest) {
|
||||
Object.defineProperty(globalThis, VITEST_WORKER_MARKER, {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
}
|
||||
if (markers.jest) {
|
||||
Object.defineProperty(globalThis, JEST_NATIVE_PROMISE_MARKER, {
|
||||
configurable: true,
|
||||
value: Promise,
|
||||
});
|
||||
}
|
||||
return callback();
|
||||
} finally {
|
||||
for (const [key, descriptor] of descriptors) {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(globalThis, key, descriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('database', () => {
|
||||
let tempConfigDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
statSyncFault.error = undefined;
|
||||
vi.mocked(os.homedir).mockReset();
|
||||
vi.mocked(os.homedir).mockReturnValue(ORIGINAL_HOME_DIR);
|
||||
vi.mocked(getConfigDirectoryPath).mockReset();
|
||||
vi.mocked(getEnvBool).mockReset();
|
||||
await closeDb();
|
||||
cliState.config = undefined;
|
||||
tempConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-db-index-'));
|
||||
vi.mocked(getConfigDirectoryPath).mockReturnValue(tempConfigDir);
|
||||
vi.mocked(getEnvBool).mockImplementation((key) => {
|
||||
if (key === 'IS_TESTING') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await closeDb();
|
||||
cliState.config = undefined;
|
||||
vi.unstubAllEnvs();
|
||||
fs.rmSync(tempConfigDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
describe('getDbPath', () => {
|
||||
it('should return path in config directory', () => {
|
||||
const configPath = '/test/config/path';
|
||||
vi.mocked(getConfigDirectoryPath).mockReturnValue(configPath);
|
||||
|
||||
expect(getDbPath()).toBe(path.resolve(configPath, 'promptfoo.db'));
|
||||
});
|
||||
|
||||
it('should allow a missing isolated database directory', () => {
|
||||
const configPath = path.join(tempConfigDir, 'missing-config');
|
||||
vi.mocked(getConfigDirectoryPath).mockImplementation((createIfNotExists = false) => {
|
||||
if (createIfNotExists) {
|
||||
fs.mkdirSync(configPath, { recursive: true });
|
||||
}
|
||||
return configPath;
|
||||
});
|
||||
vi.stubEnv('VITEST', 'true');
|
||||
|
||||
expect(getDbPath()).toBe(path.join(configPath, 'promptfoo.db'));
|
||||
expect(fs.existsSync(configPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('should refuse to use the default user database when the process is running tests', () => {
|
||||
vi.mocked(getConfigDirectoryPath).mockReturnValue(path.join(os.homedir(), '.promptfoo'));
|
||||
vi.stubEnv('VITEST', 'true');
|
||||
|
||||
expect(() => withTestRunnerMarkers({}, () => getDbPath())).toThrow(
|
||||
'Refusing to open the default Promptfoo database while running tests',
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow the default user database when only NODE_ENV is test', () => {
|
||||
const defaultConfigDir = path.join(os.homedir(), '.promptfoo');
|
||||
vi.mocked(getConfigDirectoryPath).mockReturnValue(defaultConfigDir);
|
||||
vi.stubEnv('NODE_ENV', 'test');
|
||||
vi.stubEnv('VITEST', undefined);
|
||||
vi.stubEnv('JEST_WORKER_ID', undefined);
|
||||
|
||||
const dbPath = withTestRunnerMarkers({}, () => getDbPath());
|
||||
|
||||
expect(dbPath).toBe(path.join(defaultConfigDir, 'promptfoo.db'));
|
||||
});
|
||||
|
||||
it('should not use Promptfoo env overrides to identify a test process', () => {
|
||||
const defaultConfigDir = path.join(os.homedir(), '.promptfoo');
|
||||
vi.mocked(getConfigDirectoryPath).mockReturnValue(defaultConfigDir);
|
||||
cliState.config = { env: { VITEST: 'true', JEST_WORKER_ID: '1' } };
|
||||
vi.stubEnv('VITEST', undefined);
|
||||
vi.stubEnv('JEST_WORKER_ID', undefined);
|
||||
|
||||
const dbPath = withTestRunnerMarkers({}, () => getDbPath());
|
||||
|
||||
expect(dbPath).toBe(path.join(defaultConfigDir, 'promptfoo.db'));
|
||||
});
|
||||
|
||||
it('should detect Vitest after the process environment is cleared', () => {
|
||||
vi.mocked(getConfigDirectoryPath).mockReturnValue(path.join(os.homedir(), '.promptfoo'));
|
||||
const restoreEnv = mockProcessEnv({}, { clear: true });
|
||||
let error: unknown;
|
||||
|
||||
try {
|
||||
getDbPath();
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
} finally {
|
||||
restoreEnv();
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toContain(
|
||||
'Refusing to open the default Promptfoo database while running tests',
|
||||
);
|
||||
});
|
||||
|
||||
it('should detect Jest after the process environment is cleared', () => {
|
||||
vi.mocked(getConfigDirectoryPath).mockReturnValue(path.join(os.homedir(), '.promptfoo'));
|
||||
const restoreEnv = mockProcessEnv({}, { clear: true });
|
||||
let error: unknown;
|
||||
|
||||
try {
|
||||
withTestRunnerMarkers({ jest: true }, () => getDbPath());
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
} finally {
|
||||
restoreEnv();
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toContain(
|
||||
'Refusing to open the default Promptfoo database while running tests',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not identify generic Jest workers as test processes', () => {
|
||||
const defaultConfigDir = path.join(os.homedir(), '.promptfoo');
|
||||
vi.mocked(getConfigDirectoryPath).mockReturnValue(defaultConfigDir);
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
vi.stubEnv('VITEST', undefined);
|
||||
vi.stubEnv('JEST_WORKER_ID', '1');
|
||||
|
||||
const dbPath = withTestRunnerMarkers({}, () => getDbPath());
|
||||
|
||||
expect(dbPath).toBe(path.join(defaultConfigDir, 'promptfoo.db'));
|
||||
});
|
||||
|
||||
it.each(['', '0', 'false'])('should ignore VITEST=%j outside Vitest', (value) => {
|
||||
const defaultConfigDir = path.join(os.homedir(), '.promptfoo');
|
||||
vi.mocked(getConfigDirectoryPath).mockReturnValue(defaultConfigDir);
|
||||
vi.stubEnv('VITEST', value);
|
||||
vi.stubEnv('JEST_WORKER_ID', undefined);
|
||||
|
||||
const dbPath = withTestRunnerMarkers({}, () => getDbPath());
|
||||
|
||||
expect(dbPath).toBe(path.join(defaultConfigDir, 'promptfoo.db'));
|
||||
});
|
||||
|
||||
it('should refuse an alias that resolves to the default user database', () => {
|
||||
const fakeHomeDir = path.join(tempConfigDir, 'home');
|
||||
const defaultConfigDir = path.join(fakeHomeDir, '.promptfoo');
|
||||
const aliasedConfigDir = path.join(tempConfigDir, 'aliased-config');
|
||||
fs.mkdirSync(defaultConfigDir, { recursive: true });
|
||||
fs.symlinkSync(
|
||||
defaultConfigDir,
|
||||
aliasedConfigDir,
|
||||
process.platform === 'win32' ? 'junction' : 'dir',
|
||||
);
|
||||
vi.mocked(os.homedir).mockReturnValue(fakeHomeDir);
|
||||
vi.mocked(getConfigDirectoryPath).mockReturnValue(aliasedConfigDir);
|
||||
vi.stubEnv('VITEST', 'true');
|
||||
|
||||
expect(() => getDbPath()).toThrow(
|
||||
'Refusing to open the default Promptfoo database while running tests',
|
||||
);
|
||||
});
|
||||
|
||||
it('should refuse an alias that resolves to a missing default database directory', () => {
|
||||
const fakeHomeDir = path.join(tempConfigDir, 'missing-home');
|
||||
const aliasedHomeDir = path.join(tempConfigDir, 'aliased-home');
|
||||
const aliasedConfigDir = path.join(aliasedHomeDir, '.promptfoo');
|
||||
fs.mkdirSync(fakeHomeDir);
|
||||
fs.symlinkSync(
|
||||
fakeHomeDir,
|
||||
aliasedHomeDir,
|
||||
process.platform === 'win32' ? 'junction' : 'dir',
|
||||
);
|
||||
vi.mocked(os.homedir).mockReturnValue(fakeHomeDir);
|
||||
vi.mocked(getConfigDirectoryPath).mockImplementation((createIfNotExists = false) => {
|
||||
if (createIfNotExists) {
|
||||
fs.mkdirSync(aliasedConfigDir, { recursive: true });
|
||||
}
|
||||
return aliasedConfigDir;
|
||||
});
|
||||
vi.stubEnv('VITEST', 'true');
|
||||
|
||||
expect(() => getDbPath()).toThrow(
|
||||
'Refusing to open the default Promptfoo database while running tests',
|
||||
);
|
||||
expect(fs.existsSync(path.join(fakeHomeDir, '.promptfoo', 'promptfoo.db'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should refuse a hard link to the default user database', () => {
|
||||
const fakeHomeDir = path.join(tempConfigDir, 'hard-link-home');
|
||||
const defaultConfigDir = path.join(fakeHomeDir, '.promptfoo');
|
||||
const aliasedConfigDir = path.join(tempConfigDir, 'hard-link-config');
|
||||
fs.mkdirSync(defaultConfigDir, { recursive: true });
|
||||
fs.mkdirSync(aliasedConfigDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(defaultConfigDir, 'promptfoo.db'), 'database');
|
||||
fs.linkSync(
|
||||
path.join(defaultConfigDir, 'promptfoo.db'),
|
||||
path.join(aliasedConfigDir, 'promptfoo.db'),
|
||||
);
|
||||
vi.mocked(os.homedir).mockReturnValue(fakeHomeDir);
|
||||
vi.mocked(getConfigDirectoryPath).mockReturnValue(aliasedConfigDir);
|
||||
vi.stubEnv('VITEST', 'true');
|
||||
|
||||
expect(() => getDbPath()).toThrow(
|
||||
'Refusing to open the default Promptfoo database while running tests',
|
||||
);
|
||||
});
|
||||
|
||||
it('should refuse a dangling file symlink to the default user database', () => {
|
||||
const fakeHomeDir = path.join(tempConfigDir, 'dangling-link-home');
|
||||
const defaultConfigDir = path.join(fakeHomeDir, '.promptfoo');
|
||||
const aliasedConfigDir = path.join(tempConfigDir, 'dangling-link-config');
|
||||
const defaultDbPath = path.join(defaultConfigDir, 'promptfoo.db');
|
||||
fs.mkdirSync(defaultConfigDir, { recursive: true });
|
||||
fs.mkdirSync(aliasedConfigDir, { recursive: true });
|
||||
fs.symlinkSync(defaultDbPath, path.join(aliasedConfigDir, 'promptfoo.db'), 'file');
|
||||
vi.mocked(os.homedir).mockReturnValue(fakeHomeDir);
|
||||
vi.mocked(getConfigDirectoryPath).mockReturnValue(aliasedConfigDir);
|
||||
vi.stubEnv('VITEST', 'true');
|
||||
|
||||
expect(() => getDbPath()).toThrow(
|
||||
'Refusing to open the default Promptfoo database while running tests',
|
||||
);
|
||||
expect(fs.existsSync(defaultDbPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('should refuse a relative dangling file symlink chain to the default user database', () => {
|
||||
const fakeHomeDir = path.join(tempConfigDir, 'relative-link-home');
|
||||
const defaultConfigDir = path.join(fakeHomeDir, '.promptfoo');
|
||||
const aliasedConfigDir = path.join(tempConfigDir, 'relative-link-config');
|
||||
const linkDirectory = path.join(tempConfigDir, 'relative-link-hop');
|
||||
const defaultDbPath = path.join(defaultConfigDir, 'promptfoo.db');
|
||||
const intermediateDbPath = path.join(linkDirectory, 'promptfoo.db');
|
||||
fs.mkdirSync(defaultConfigDir, { recursive: true });
|
||||
fs.mkdirSync(aliasedConfigDir, { recursive: true });
|
||||
fs.mkdirSync(linkDirectory, { recursive: true });
|
||||
fs.symlinkSync(path.relative(linkDirectory, defaultDbPath), intermediateDbPath, 'file');
|
||||
fs.symlinkSync(
|
||||
path.relative(aliasedConfigDir, intermediateDbPath),
|
||||
path.join(aliasedConfigDir, 'promptfoo.db'),
|
||||
'file',
|
||||
);
|
||||
vi.mocked(os.homedir).mockReturnValue(fakeHomeDir);
|
||||
vi.mocked(getConfigDirectoryPath).mockReturnValue(aliasedConfigDir);
|
||||
vi.stubEnv('VITEST', 'true');
|
||||
|
||||
expect(() => getDbPath()).toThrow(
|
||||
'Refusing to open the default Promptfoo database while running tests',
|
||||
);
|
||||
expect(fs.existsSync(defaultDbPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('should preserve filesystem semantics for dangling symlinks containing dot-dot', () => {
|
||||
const fakeHomeDir = path.join(tempConfigDir, 'pivot-link-home');
|
||||
const defaultConfigDir = path.join(fakeHomeDir, '.promptfoo');
|
||||
const pivotTarget = path.join(fakeHomeDir, 'subdir');
|
||||
const aliasedConfigDir = path.join(tempConfigDir, 'pivot-link-config');
|
||||
const isolatedConfigDir = path.join(aliasedConfigDir, '.promptfoo');
|
||||
const defaultDbPath = path.join(defaultConfigDir, 'promptfoo.db');
|
||||
fs.mkdirSync(defaultConfigDir, { recursive: true });
|
||||
fs.mkdirSync(pivotTarget, { recursive: true });
|
||||
fs.mkdirSync(aliasedConfigDir, { recursive: true });
|
||||
fs.mkdirSync(isolatedConfigDir, { recursive: true });
|
||||
fs.symlinkSync(
|
||||
pivotTarget,
|
||||
path.join(aliasedConfigDir, 'pivot'),
|
||||
process.platform === 'win32' ? 'junction' : 'dir',
|
||||
);
|
||||
fs.symlinkSync(
|
||||
['pivot', '..', '.promptfoo', 'promptfoo.db'].join(path.sep),
|
||||
path.join(aliasedConfigDir, 'promptfoo.db'),
|
||||
'file',
|
||||
);
|
||||
vi.mocked(os.homedir).mockReturnValue(fakeHomeDir);
|
||||
vi.mocked(getConfigDirectoryPath).mockReturnValue(aliasedConfigDir);
|
||||
vi.stubEnv('VITEST', 'true');
|
||||
|
||||
const linkTarget = fs.readlinkSync(path.join(aliasedConfigDir, 'promptfoo.db'));
|
||||
const pivotedConfigDir = path.isAbsolute(linkTarget)
|
||||
? path.dirname(linkTarget)
|
||||
: `${aliasedConfigDir}${path.sep}${path.dirname(linkTarget)}`;
|
||||
const reachesDefaultConfig =
|
||||
fs.realpathSync.native(pivotedConfigDir) === fs.realpathSync.native(defaultConfigDir);
|
||||
|
||||
if (reachesDefaultConfig) {
|
||||
expect(() => getDbPath()).toThrow(
|
||||
'Refusing to open the default Promptfoo database while running tests',
|
||||
);
|
||||
} else {
|
||||
expect(getDbPath()).toBe(path.join(aliasedConfigDir, 'promptfoo.db'));
|
||||
}
|
||||
expect(fs.existsSync(defaultDbPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('should fail closed when stat-based identity checks error', () => {
|
||||
const fakeHomeDir = path.join(tempConfigDir, 'eio-home');
|
||||
const defaultConfigDir = path.join(fakeHomeDir, '.promptfoo');
|
||||
const aliasedConfigDir = path.join(tempConfigDir, 'eio-aliased-config');
|
||||
fs.mkdirSync(defaultConfigDir, { recursive: true });
|
||||
fs.symlinkSync(
|
||||
defaultConfigDir,
|
||||
aliasedConfigDir,
|
||||
process.platform === 'win32' ? 'junction' : 'dir',
|
||||
);
|
||||
vi.mocked(os.homedir).mockReturnValue(fakeHomeDir);
|
||||
vi.mocked(getConfigDirectoryPath).mockReturnValue(aliasedConfigDir);
|
||||
vi.stubEnv('VITEST', 'true');
|
||||
|
||||
statSyncFault.error = Object.assign(new Error('EIO: i/o error, stat'), { code: 'EIO' });
|
||||
|
||||
try {
|
||||
// An indeterminate identity error must propagate instead of being read
|
||||
// as "different files", which would let the alias reach the user DB.
|
||||
expect(() => getDbPath()).toThrow('EIO');
|
||||
} finally {
|
||||
statSyncFault.error = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('should fail closed when realpath-based identity checks error', () => {
|
||||
const fakeHomeDir = path.join(tempConfigDir, 'estale-home');
|
||||
const aliasedConfigDir = path.join(tempConfigDir, 'estale-aliased-config');
|
||||
fs.mkdirSync(path.join(fakeHomeDir, '.promptfoo'), { recursive: true });
|
||||
fs.mkdirSync(aliasedConfigDir, { recursive: true });
|
||||
vi.mocked(os.homedir).mockReturnValue(fakeHomeDir);
|
||||
vi.mocked(getConfigDirectoryPath).mockReturnValue(aliasedConfigDir);
|
||||
vi.stubEnv('VITEST', 'true');
|
||||
|
||||
const injectedError = Object.assign(new Error('ESTALE: stale file handle'), {
|
||||
code: 'ESTALE',
|
||||
});
|
||||
const realpathSpy = vi.spyOn(fs.realpathSync, 'native').mockImplementation(() => {
|
||||
throw injectedError;
|
||||
});
|
||||
|
||||
try {
|
||||
expect(() => getDbPath()).toThrow('ESTALE');
|
||||
} finally {
|
||||
realpathSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('should respect filesystem case sensitivity for existing databases', () => {
|
||||
const fakeHomeDir = path.join(tempConfigDir, 'case-home');
|
||||
const defaultConfigDir = path.join(fakeHomeDir, '.promptfoo');
|
||||
const differentlyCasedConfigDir = path.join(fakeHomeDir, '.PROMPTFOO');
|
||||
fs.mkdirSync(defaultConfigDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(defaultConfigDir, 'promptfoo.db'), 'default database');
|
||||
const isCaseInsensitive = fs.existsSync(differentlyCasedConfigDir);
|
||||
if (!isCaseInsensitive) {
|
||||
fs.mkdirSync(differentlyCasedConfigDir);
|
||||
fs.writeFileSync(path.join(differentlyCasedConfigDir, 'promptfoo.db'), 'other database');
|
||||
}
|
||||
|
||||
vi.mocked(os.homedir).mockReturnValue(fakeHomeDir);
|
||||
vi.mocked(getConfigDirectoryPath).mockReturnValue(differentlyCasedConfigDir);
|
||||
vi.stubEnv('VITEST', 'true');
|
||||
|
||||
if (isCaseInsensitive) {
|
||||
expect(() => getDbPath()).toThrow(
|
||||
'Refusing to open the default Promptfoo database while running tests',
|
||||
);
|
||||
} else {
|
||||
expect(getDbPath()).toBe(path.join(differentlyCasedConfigDir, 'promptfoo.db'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDbSignalPath', () => {
|
||||
it('should return evalLastWritten path in config directory', () => {
|
||||
const configPath = '/test/config/path';
|
||||
vi.mocked(getConfigDirectoryPath).mockReturnValue(configPath);
|
||||
|
||||
expect(getDbSignalPath()).toBe(path.resolve(configPath, 'evalLastWritten'));
|
||||
});
|
||||
|
||||
it('should allow the default signal path for in-memory tests', () => {
|
||||
const defaultConfigDir = path.join(os.homedir(), '.promptfoo');
|
||||
vi.mocked(getConfigDirectoryPath).mockReturnValue(defaultConfigDir);
|
||||
vi.stubEnv('VITEST', 'true');
|
||||
|
||||
expect(getDbSignalPath()).toBe(path.join(defaultConfigDir, 'evalLastWritten'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDb', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getEnvBool).mockImplementation((key) => {
|
||||
if (key === 'IS_TESTING') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
it('should return a database when testing', async () => {
|
||||
const db = await getDb();
|
||||
expect(db).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use an in-memory database when testing', async () => {
|
||||
await getDb();
|
||||
|
||||
expect(fs.existsSync(getDbPath())).toBe(false);
|
||||
});
|
||||
|
||||
it('should initialize database with WAL mode', async () => {
|
||||
const db = await getDb();
|
||||
expect(db).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return same instance on subsequent calls', async () => {
|
||||
const db1 = await getDb();
|
||||
const db2 = await getDb();
|
||||
expect(db1).toBe(db2);
|
||||
});
|
||||
|
||||
it('should serialize concurrent top-level transactions', async () => {
|
||||
const db = await getDb();
|
||||
await db.run('CREATE TABLE transaction_queue_test (id TEXT PRIMARY KEY)');
|
||||
|
||||
await Promise.all([
|
||||
db.transaction(async (tx) => {
|
||||
await tx.run("INSERT INTO transaction_queue_test (id) VALUES ('a')");
|
||||
}),
|
||||
db.transaction(async (tx) => {
|
||||
await tx.run("INSERT INTO transaction_queue_test (id) VALUES ('b')");
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
db.all<{ id: string }>('SELECT id FROM transaction_queue_test ORDER BY id'),
|
||||
).resolves.toEqual([{ id: 'a' }, { id: 'b' }]);
|
||||
});
|
||||
|
||||
it('should serialize plain statements with top-level transactions', async () => {
|
||||
const db = await getDb();
|
||||
await db.run('CREATE TABLE transaction_plain_statement_test (id TEXT PRIMARY KEY)');
|
||||
|
||||
let markTransactionStarted: () => void;
|
||||
const transactionStarted = new Promise<void>((resolve) => {
|
||||
markTransactionStarted = resolve;
|
||||
});
|
||||
let releaseTransaction!: () => void;
|
||||
const transactionRelease = new Promise<void>((resolve) => {
|
||||
releaseTransaction = resolve;
|
||||
});
|
||||
|
||||
const transactionPromise = db.transaction(async (tx) => {
|
||||
await tx.run("INSERT INTO transaction_plain_statement_test (id) VALUES ('transaction')");
|
||||
markTransactionStarted();
|
||||
await transactionRelease;
|
||||
});
|
||||
|
||||
await transactionStarted;
|
||||
const statementPromise = db.run(
|
||||
"INSERT INTO transaction_plain_statement_test (id) VALUES ('statement')",
|
||||
);
|
||||
releaseTransaction();
|
||||
|
||||
await Promise.all([transactionPromise, statementPromise]);
|
||||
await expect(
|
||||
db.all<{ id: string }>('SELECT id FROM transaction_plain_statement_test ORDER BY id'),
|
||||
).resolves.toEqual([{ id: 'statement' }, { id: 'transaction' }]);
|
||||
});
|
||||
|
||||
it('should reuse the active transaction for nested root transactions', async () => {
|
||||
const db = await getDb();
|
||||
await db.run('CREATE TABLE nested_transaction_test (id TEXT PRIMARY KEY)');
|
||||
|
||||
await expect(
|
||||
Promise.race([
|
||||
db.transaction(async (tx) => {
|
||||
await tx.run("INSERT INTO nested_transaction_test (id) VALUES ('outer')");
|
||||
await db.transaction(async (nestedTx) => {
|
||||
await nestedTx.run("INSERT INTO nested_transaction_test (id) VALUES ('inner')");
|
||||
});
|
||||
}),
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('nested transaction timed out')), 1_000);
|
||||
}),
|
||||
]),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await expect(
|
||||
db.all<{ id: string }>('SELECT id FROM nested_transaction_test ORDER BY id'),
|
||||
).resolves.toEqual([{ id: 'inner' }, { id: 'outer' }]);
|
||||
});
|
||||
|
||||
it('does not deadlock when a transaction callback calls root db.* helpers', async () => {
|
||||
const db = await getDb();
|
||||
await db.run('CREATE TABLE root_call_inside_tx_test (id INTEGER PRIMARY KEY, val TEXT)');
|
||||
|
||||
await expect(
|
||||
Promise.race([
|
||||
db.transaction(async (tx) => {
|
||||
await tx.run("INSERT INTO root_call_inside_tx_test (id, val) VALUES (1, 'in-tx')");
|
||||
const rows = await db.all<{ value: number }>('SELECT 1 AS value');
|
||||
expect(rows[0]?.value).toBe(1);
|
||||
}),
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(
|
||||
() => reject(new Error('root db call inside transaction deadlocked')),
|
||||
1_000,
|
||||
);
|
||||
}),
|
||||
]),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should enforce foreign keys inside top-level transactions', async () => {
|
||||
const db = await getDb();
|
||||
await db.run('CREATE TABLE transaction_fk_parent (id TEXT PRIMARY KEY)');
|
||||
await db.run(`
|
||||
CREATE TABLE transaction_fk_child (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_id TEXT NOT NULL REFERENCES transaction_fk_parent(id)
|
||||
)
|
||||
`);
|
||||
|
||||
await expect(
|
||||
db.transaction(async (tx) => {
|
||||
await tx.run(
|
||||
"INSERT INTO transaction_fk_child (id, parent_id) VALUES ('child', 'missing')",
|
||||
);
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DrizzleLogWriter', () => {
|
||||
it('should log debug message when database logs enabled', () => {
|
||||
vi.mocked(getEnvBool).mockImplementation((key) => {
|
||||
if (key === 'PROMPTFOO_ENABLE_DATABASE_LOGS') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const writer = new DrizzleLogWriter();
|
||||
writer.write('test message');
|
||||
expect(logger.debug).toHaveBeenCalledWith('Drizzle: test message');
|
||||
});
|
||||
|
||||
it('should not log debug message when database logs disabled', () => {
|
||||
vi.mocked(getEnvBool).mockReturnValue(false);
|
||||
const writer = new DrizzleLogWriter();
|
||||
writer.write('test message');
|
||||
expect(logger.debug).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('closeDb', () => {
|
||||
it('should close database connection and reset instances', async () => {
|
||||
const _db = await getDb();
|
||||
expect(isDbOpen()).toBe(true);
|
||||
await closeDb();
|
||||
expect(isDbOpen()).toBe(false);
|
||||
const newDb = await getDb();
|
||||
expect(newDb).toBeDefined();
|
||||
expect(isDbOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle errors when closing database', async () => {
|
||||
const _db = await getDb();
|
||||
await closeDb();
|
||||
await closeDb(); // Second close should be handled gracefully
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle close errors gracefully', async () => {
|
||||
const _db = await getDb();
|
||||
// Force an error by closing twice
|
||||
await closeDb();
|
||||
await closeDb();
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDbOpen', () => {
|
||||
it('should return false when database is not initialized', async () => {
|
||||
await closeDb(); // Ensure clean state
|
||||
expect(isDbOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when database is open', async () => {
|
||||
const _db = await getDb();
|
||||
expect(isDbOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false after closing database', async () => {
|
||||
const _db = await getDb();
|
||||
expect(isDbOpen()).toBe(true);
|
||||
await closeDb();
|
||||
expect(isDbOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { createClient } from '@libsql/client/node';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
closeTestDatabaseClient,
|
||||
closeTestDatabaseClients,
|
||||
registerTestDatabaseClient,
|
||||
resetTestDatabaseClient,
|
||||
} from '../../src/database/testing';
|
||||
import { sleep } from '../../src/util/time';
|
||||
import { mockProcessEnv } from '../util/utils';
|
||||
|
||||
// Mirror of the testing database URL in src/database/index.ts. The test database is a
|
||||
// process-wide shared-cache in-memory libsql DB so its internal connections see one schema.
|
||||
const SHARED_CACHE_MEMORY_URL = 'file::memory:?cache=shared';
|
||||
|
||||
describe('database test isolation', () => {
|
||||
afterEach(async () => {
|
||||
await closeTestDatabaseClients();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('keeps the testing database intact until the last isolated module instance closes', async () => {
|
||||
const firstDatabaseModule = await import('../../src/database/index');
|
||||
const firstDb = await firstDatabaseModule.getDb();
|
||||
await firstDb.run('CREATE TABLE isolated_module_marker (id TEXT PRIMARY KEY)');
|
||||
await firstDb.run("INSERT INTO isolated_module_marker VALUES ('first')");
|
||||
|
||||
vi.resetModules();
|
||||
const secondDatabaseModule = await import('../../src/database/index');
|
||||
const secondDb = await secondDatabaseModule.getDb();
|
||||
|
||||
await expect(secondDb.all('SELECT id FROM isolated_module_marker')).resolves.toEqual([
|
||||
{ id: 'first' },
|
||||
]);
|
||||
|
||||
await firstDatabaseModule.closeDb();
|
||||
await expect(secondDb.all('SELECT id FROM isolated_module_marker')).resolves.toEqual([
|
||||
{ id: 'first' },
|
||||
]);
|
||||
|
||||
await secondDatabaseModule.closeDb();
|
||||
vi.resetModules();
|
||||
|
||||
const thirdDatabaseModule = await import('../../src/database/index');
|
||||
const thirdDb = await thirdDatabaseModule.getDb();
|
||||
await expect(thirdDb.all('SELECT id FROM isolated_module_marker')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('drops supported schema objects while escaping their identifiers', async () => {
|
||||
const execute = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
rows: [
|
||||
{ type: 'table', name: 'table"name' },
|
||||
{ type: 'view', name: 'isolated_view' },
|
||||
{ type: 'trigger', name: 'isolated_trigger' },
|
||||
{ type: 'index', name: 'ignored_index' },
|
||||
],
|
||||
})
|
||||
.mockResolvedValue({ rows: [] });
|
||||
const client = {
|
||||
close: vi.fn(),
|
||||
execute,
|
||||
} as unknown as Parameters<typeof resetTestDatabaseClient>[0];
|
||||
|
||||
await resetTestDatabaseClient(client);
|
||||
|
||||
expect(execute).toHaveBeenCalledWith('DROP TABLE IF EXISTS "table""name"');
|
||||
expect(execute).toHaveBeenCalledWith('DROP VIEW IF EXISTS "isolated_view"');
|
||||
expect(execute).toHaveBeenCalledWith('DROP TRIGGER IF EXISTS "isolated_trigger"');
|
||||
expect(execute).not.toHaveBeenCalledWith('DROP INDEX IF EXISTS "ignored_index"');
|
||||
expect(execute).toHaveBeenLastCalledWith('PRAGMA foreign_keys = ON');
|
||||
});
|
||||
|
||||
it('waits for schema cleanup before registering another client', async () => {
|
||||
let resolveSchemaQuery!: (value: { rows: never[] }) => void;
|
||||
const firstClient = {
|
||||
close: vi.fn(),
|
||||
execute: vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSchemaQuery = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue({ rows: [] }),
|
||||
} as unknown as Parameters<typeof registerTestDatabaseClient>[0];
|
||||
const secondClient = {
|
||||
close: vi.fn(),
|
||||
execute: vi.fn().mockResolvedValue({ rows: [] }),
|
||||
} as unknown as Parameters<typeof registerTestDatabaseClient>[0];
|
||||
|
||||
await registerTestDatabaseClient(firstClient);
|
||||
const closePromise = closeTestDatabaseClient(firstClient);
|
||||
await vi.waitFor(() => expect(firstClient.execute).toHaveBeenCalledTimes(1));
|
||||
|
||||
let registrationFinished = false;
|
||||
const registerPromise = registerTestDatabaseClient(secondClient).then(() => {
|
||||
registrationFinished = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(registrationFinished).toBe(false);
|
||||
|
||||
resolveSchemaQuery({ rows: [] });
|
||||
await closePromise;
|
||||
await registerPromise;
|
||||
expect(registrationFinished).toBe(true);
|
||||
|
||||
await closeTestDatabaseClient(secondClient);
|
||||
expect(firstClient.close).toHaveBeenCalledOnce();
|
||||
expect(secondClient.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('uses the initialization mode when the environment changes before close', async () => {
|
||||
const firstDatabaseModule = await import('../../src/database/index');
|
||||
const firstDb = await firstDatabaseModule.getDb();
|
||||
await firstDb.run('CREATE TABLE env_change_marker (id TEXT PRIMARY KEY)');
|
||||
|
||||
const restoreEnv = mockProcessEnv({ IS_TESTING: undefined });
|
||||
try {
|
||||
await firstDatabaseModule.closeDb();
|
||||
} finally {
|
||||
restoreEnv();
|
||||
}
|
||||
|
||||
vi.resetModules();
|
||||
const secondDatabaseModule = await import('../../src/database/index');
|
||||
const secondDb = await secondDatabaseModule.getDb();
|
||||
|
||||
await expect(secondDb.all('SELECT id FROM env_change_marker')).rejects.toThrow();
|
||||
});
|
||||
|
||||
// Regression: shared-cache uses table-level locks, so a read that overlaps another
|
||||
// connection's open write transaction fails immediately with SQLITE_LOCKED_SHAREDCACHE
|
||||
// (busy_timeout only covers SQLITE_BUSY). libsql runs interactive transactions on their
|
||||
// own connections, so a prior writer's lock can briefly outlive the JS promise that
|
||||
// settled it — which is what made `import`'s collision check (Eval.findById's
|
||||
// `SELECT ... FROM evals`) flake. Top-level statements now retry the transient lock
|
||||
// instead of throwing, riding through the window without weakening isolation.
|
||||
it('retries a read locked by another connection until the lock releases, without dirty reads', async () => {
|
||||
const databaseModule = await import('../../src/database/index');
|
||||
const db = await databaseModule.getDb();
|
||||
await db.run('CREATE TABLE lock_probe (id TEXT PRIMARY KEY)');
|
||||
await db.run("INSERT INTO lock_probe VALUES ('committed')");
|
||||
|
||||
// Independent connection to the same shared cache, holding an open write lock on the
|
||||
// probe table — standing in for libsql's lingering interactive-transaction connection.
|
||||
const lockHolder = createClient({ url: SHARED_CACHE_MEMORY_URL });
|
||||
const writeTx = await lockHolder.transaction('write');
|
||||
await writeTx.execute("INSERT INTO lock_probe VALUES ('uncommitted')");
|
||||
|
||||
// Top-level read through the serialized main connection (exactly like Eval.findById).
|
||||
// It contends with the held lock; without the retry it rejects immediately with
|
||||
// SQLITE_LOCKED. Capture the outcome so the still-pending read never rejects unhandled.
|
||||
const readOutcome = db
|
||||
.all('SELECT id FROM lock_probe ORDER BY id')
|
||||
.then((rows) => ({ rows }))
|
||||
.catch((error) => ({ error }));
|
||||
|
||||
// Release the lock — discarding the uncommitted row — while the read is still retrying.
|
||||
await sleep(30);
|
||||
await writeTx.rollback();
|
||||
lockHolder.close();
|
||||
|
||||
// The read rode through the lock and observed only the committed row: the retry never
|
||||
// exposed the rolled-back 'uncommitted' write, so isolation stays production-like.
|
||||
expect(await readOutcome).toEqual({ rows: [{ id: 'committed' }] });
|
||||
|
||||
await databaseModule.closeDb();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,595 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { mockWriteFileSync, mockReadFileSync, mockGetDbSignalPath, mockLoggerWarn } = vi.hoisted(
|
||||
() => ({
|
||||
mockWriteFileSync: vi.fn(),
|
||||
mockReadFileSync: vi.fn(),
|
||||
mockGetDbSignalPath: vi.fn().mockReturnValue('/mock/path/signal.txt'),
|
||||
mockLoggerWarn: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
default: {
|
||||
writeFileSync: mockWriteFileSync,
|
||||
readFileSync: mockReadFileSync,
|
||||
existsSync: vi.fn(),
|
||||
watch: vi.fn(),
|
||||
},
|
||||
writeFileSync: mockWriteFileSync,
|
||||
readFileSync: mockReadFileSync,
|
||||
existsSync: vi.fn(),
|
||||
watch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/logger', () => ({
|
||||
default: {
|
||||
debug: vi.fn(),
|
||||
warn: mockLoggerWarn,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/database/index', () => ({
|
||||
getDbSignalPath: mockGetDbSignalPath,
|
||||
}));
|
||||
|
||||
import {
|
||||
readSignalEvalId,
|
||||
readSignalFile,
|
||||
updateSignalFile,
|
||||
updateSignalFileForDeletedEvals,
|
||||
} from '../../src/database/signal';
|
||||
|
||||
describe('signal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockWriteFileSync.mockReset();
|
||||
mockReadFileSync.mockReset();
|
||||
// Re-set the mock return value after reset
|
||||
mockGetDbSignalPath.mockReturnValue('/mock/path/signal.txt');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('updateSignalFile', () => {
|
||||
it('should write timestamp only when no evalId is provided', () => {
|
||||
updateSignalFile();
|
||||
|
||||
expect(mockWriteFileSync).toHaveBeenCalledTimes(1);
|
||||
const [, content] = mockWriteFileSync.mock.calls[0];
|
||||
expect(mockWriteFileSync).toHaveBeenCalledWith('/mock/path/signal.txt', expect.any(String));
|
||||
expect(content).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
||||
});
|
||||
|
||||
it('should write evalId and timestamp when evalId is provided', () => {
|
||||
updateSignalFile('eval-123-abc');
|
||||
|
||||
expect(mockWriteFileSync).toHaveBeenCalledTimes(1);
|
||||
const [, content] = mockWriteFileSync.mock.calls[0];
|
||||
expect(mockWriteFileSync).toHaveBeenCalledWith('/mock/path/signal.txt', expect.any(String));
|
||||
expect(content).toMatch(/^eval-123-abc:\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it('writes a short eval id as JSON so it survives the round trip', () => {
|
||||
// A short caller-provided id (e.g. an imported `demo`) can't be recovered from the legacy
|
||||
// `evalId:timestamp` text — readSignalFile would read it back as an unscoped refresh — so it
|
||||
// is stored explicitly as JSON instead.
|
||||
updateSignalFile('demo');
|
||||
|
||||
const written = mockWriteFileSync.mock.calls[0][1];
|
||||
expect(JSON.parse(written)).toMatchObject({ type: 'update', evalId: 'demo' });
|
||||
|
||||
mockReadFileSync.mockReturnValue(written);
|
||||
expect(readSignalEvalId()).toBe('demo');
|
||||
});
|
||||
|
||||
it('should log warning when write fails', () => {
|
||||
mockWriteFileSync.mockImplementation(() => {
|
||||
throw new Error('Write failed');
|
||||
});
|
||||
|
||||
updateSignalFile('eval-123');
|
||||
|
||||
expect(mockLoggerWarn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to write database signal file'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should write deleted eval IDs as a structured signal', () => {
|
||||
updateSignalFileForDeletedEvals(['eval-deleted-1', 'eval-deleted-2']);
|
||||
|
||||
expect(mockWriteFileSync).toHaveBeenCalledTimes(1);
|
||||
const [, content] = mockWriteFileSync.mock.calls[0];
|
||||
expect(JSON.parse(content)).toMatchObject({
|
||||
type: 'delete',
|
||||
deletedEvalIds: ['eval-deleted-1', 'eval-deleted-2'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should coalesce deleted eval IDs across rapid signal writes', () => {
|
||||
let content = '';
|
||||
mockReadFileSync.mockImplementation(() => content);
|
||||
mockWriteFileSync.mockImplementation((_path, nextContent) => {
|
||||
content = nextContent;
|
||||
});
|
||||
|
||||
updateSignalFileForDeletedEvals(['eval-deleted-1']);
|
||||
updateSignalFileForDeletedEvals(['eval-deleted-2']);
|
||||
|
||||
expect(JSON.parse(content)).toMatchObject({
|
||||
type: 'delete',
|
||||
deletedEvalIds: ['eval-deleted-1', 'eval-deleted-2'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not retain deleted eval IDs beyond the watcher debounce window', () => {
|
||||
mockReadFileSync.mockReturnValue(
|
||||
JSON.stringify({
|
||||
type: 'delete',
|
||||
deletedEvalIds: ['stale-eval'],
|
||||
timestamp: '2000-01-01T00:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
|
||||
updateSignalFileForDeletedEvals(['fresh-eval']);
|
||||
|
||||
const [, content] = mockWriteFileSync.mock.calls[0];
|
||||
expect(JSON.parse(content)).toMatchObject({
|
||||
type: 'delete',
|
||||
deletedEvalIds: ['fresh-eval'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('readSignalFile', () => {
|
||||
it('should return deleted eval IDs from a structured signal', () => {
|
||||
mockReadFileSync.mockReturnValue(
|
||||
JSON.stringify({
|
||||
type: 'delete',
|
||||
deletedEvalIds: ['eval-deleted-1', 'eval-deleted-2'],
|
||||
timestamp: '2026-05-30T22:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(readSignalFile()).toEqual({
|
||||
type: 'delete',
|
||||
deletedEvalIds: ['eval-deleted-1', 'eval-deleted-2'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should report a delete with no ids (delete-all) as undefined deletedEvalIds', () => {
|
||||
// updateSignalFileForDeletedEvals() with no args (deleteAllEvals) drops the
|
||||
// undefined key, so JSON.parse yields an object with no deletedEvalIds field.
|
||||
mockReadFileSync.mockReturnValue(
|
||||
JSON.stringify({ type: 'delete', timestamp: '2026-05-30T22:00:00.000Z' }),
|
||||
);
|
||||
|
||||
expect(readSignalFile()).toEqual({ type: 'delete', deletedEvalIds: undefined });
|
||||
});
|
||||
|
||||
it('should drop non-string entries from deletedEvalIds', () => {
|
||||
mockReadFileSync.mockReturnValue(
|
||||
JSON.stringify({ type: 'delete', deletedEvalIds: ['eval-ok', 42, null, 'eval-ok-2'] }),
|
||||
);
|
||||
|
||||
expect(readSignalFile()).toEqual({
|
||||
type: 'delete',
|
||||
deletedEvalIds: ['eval-ok', 'eval-ok-2'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should fall back to a legacy update for a scoped evalId:timestamp signal', () => {
|
||||
mockReadFileSync.mockReturnValue('eval-abc-2026-05-29T22:43:41:2026-05-29T22:43:42.000Z');
|
||||
|
||||
expect(readSignalFile()).toEqual({
|
||||
type: 'update',
|
||||
evalId: 'eval-abc-2026-05-29T22:43:41',
|
||||
});
|
||||
});
|
||||
|
||||
it('should fall back to an unscoped update for malformed JSON content', () => {
|
||||
// A partially-written delete file (writes are not atomic) must not throw; it should
|
||||
// degrade to an unscoped update rather than crash the watcher.
|
||||
mockReadFileSync.mockReturnValue('{"type":"delete","deletedEvalIds":[');
|
||||
|
||||
expect(readSignalFile()).toEqual({ type: 'update', evalId: undefined });
|
||||
});
|
||||
|
||||
it('parses a JSON update object (combined-signal format) into its components', () => {
|
||||
// Combined signals (an update that folded in a pending delete) are written as JSON with
|
||||
// type 'update', so readSignalFile parses both the evalId and any carried deletedEvalIds.
|
||||
mockReadFileSync.mockReturnValue(
|
||||
JSON.stringify({
|
||||
type: 'update',
|
||||
evalId: 'eval-12345678',
|
||||
deletedEvalIds: ['eval-removed-1'],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(readSignalFile()).toEqual({
|
||||
type: 'update',
|
||||
evalId: 'eval-12345678',
|
||||
deletedEvalIds: ['eval-removed-1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should fall back to an unscoped update for JSON arrays/primitives', () => {
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify(['eval-1', 'eval-2']));
|
||||
expect(readSignalFile()).toEqual({ type: 'update', evalId: undefined });
|
||||
|
||||
mockReadFileSync.mockReturnValue('42');
|
||||
expect(readSignalFile()).toEqual({ type: 'update', evalId: undefined });
|
||||
|
||||
mockReadFileSync.mockReturnValue('null');
|
||||
expect(readSignalFile()).toEqual({ type: 'update', evalId: undefined });
|
||||
});
|
||||
|
||||
it('should return an unscoped update when the file read fails', () => {
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
throw new Error('File not found');
|
||||
});
|
||||
|
||||
expect(readSignalFile()).toEqual({ type: 'update' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('signal coalescing within the debounce window', () => {
|
||||
function lastWrittenSignal(): string {
|
||||
const calls = mockWriteFileSync.mock.calls;
|
||||
return calls[calls.length - 1]?.[1] as string;
|
||||
}
|
||||
|
||||
it('folds a pending delete into a following update so neither refresh is lost', () => {
|
||||
// A delete written <250ms ago is still pending when an unrelated eval save fires.
|
||||
mockReadFileSync.mockReturnValue(
|
||||
JSON.stringify({
|
||||
type: 'delete',
|
||||
deletedEvalIds: ['eval-deleted-1'],
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
updateSignalFile('eval-newly-saved');
|
||||
|
||||
expect(JSON.parse(lastWrittenSignal())).toMatchObject({
|
||||
type: 'update',
|
||||
evalId: 'eval-newly-saved',
|
||||
deletedEvalIds: ['eval-deleted-1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('carries a pending scoped update into a following delete', () => {
|
||||
// A legacy `evalId:timestamp` update written <250ms ago is pending when a delete fires.
|
||||
mockReadFileSync.mockReturnValue(`eval-pending-update:${new Date().toISOString()}`);
|
||||
|
||||
updateSignalFileForDeletedEvals(['eval-deleted-2']);
|
||||
|
||||
expect(JSON.parse(lastWrittenSignal())).toMatchObject({
|
||||
type: 'delete',
|
||||
deletedEvalIds: ['eval-deleted-2'],
|
||||
evalId: 'eval-pending-update',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a stale pending signal outside the debounce window', () => {
|
||||
mockReadFileSync.mockReturnValue(
|
||||
JSON.stringify({
|
||||
type: 'delete',
|
||||
deletedEvalIds: ['eval-old'],
|
||||
timestamp: new Date(Date.now() - 60_000).toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
updateSignalFile('eval-fresh');
|
||||
|
||||
// The stale delete is not folded in; the update stays in the plain legacy text format.
|
||||
expect(lastWrittenSignal()).toMatch(/^eval-fresh:\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it('folds a pending delete-all into a following update as an empty id list', () => {
|
||||
// A pending delete-all has no deletedEvalIds; folding it must produce an empty array,
|
||||
// which clients read as "all evals deleted" (then reload to the freshly written latest).
|
||||
mockReadFileSync.mockReturnValue(
|
||||
JSON.stringify({ type: 'delete', timestamp: new Date().toISOString() }),
|
||||
);
|
||||
|
||||
updateSignalFile('eval-after-delete-all');
|
||||
|
||||
expect(JSON.parse(lastWrittenSignal())).toMatchObject({
|
||||
type: 'update',
|
||||
evalId: 'eval-after-delete-all',
|
||||
deletedEvalIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('folds a pending delete written just inside the window', () => {
|
||||
mockReadFileSync.mockReturnValue(
|
||||
JSON.stringify({
|
||||
type: 'delete',
|
||||
deletedEvalIds: ['eval-recent'],
|
||||
timestamp: new Date(Date.now() - 200).toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
updateSignalFile('eval-x');
|
||||
|
||||
expect(JSON.parse(lastWrittenSignal())).toMatchObject({
|
||||
type: 'update',
|
||||
evalId: 'eval-x',
|
||||
deletedEvalIds: ['eval-recent'],
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a future-dated pending signal (clock-skew guard)', () => {
|
||||
mockReadFileSync.mockReturnValue(
|
||||
JSON.stringify({
|
||||
type: 'delete',
|
||||
deletedEvalIds: ['eval-future'],
|
||||
timestamp: new Date(Date.now() + 60_000).toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
updateSignalFile('eval-fresh');
|
||||
|
||||
// A future timestamp (negative elapsed) is rejected; the update stays as legacy text.
|
||||
expect(lastWrittenSignal()).toMatch(/^eval-fresh:\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it('accumulates back-to-back scoped updates so neither eval is dropped', () => {
|
||||
// Regression for the single-update-slot overwrite: a scoped update Y written <250ms ago is
|
||||
// still pending when an update for a DIFFERENT eval Z fires; both ids must survive.
|
||||
mockReadFileSync.mockReturnValue(`eval-update-Y:${new Date().toISOString()}`);
|
||||
|
||||
updateSignalFile('eval-update-Z');
|
||||
|
||||
const written = JSON.parse(lastWrittenSignal());
|
||||
expect(written.type).toBe('update');
|
||||
expect(written.updatedEvalIds).toEqual(['eval-update-Y', 'eval-update-Z']);
|
||||
});
|
||||
|
||||
it('preserves the current unscoped refresh when a scoped update is pending', () => {
|
||||
// A bare updateSignalFile() lands while a scoped update is still pending. The JSON must keep
|
||||
// both the pending scoped id and a refreshLatest marker so a root /eval view still follows
|
||||
// the latest eval rather than only refreshing the pending scoped one.
|
||||
mockReadFileSync.mockReturnValue(`eval-pending-scoped:${new Date().toISOString()}`);
|
||||
|
||||
updateSignalFile();
|
||||
|
||||
expect(JSON.parse(lastWrittenSignal())).toMatchObject({
|
||||
type: 'update',
|
||||
updatedEvalIds: ['eval-pending-scoped'],
|
||||
refreshLatest: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves a pending unscoped refresh when a scoped update coalesces', () => {
|
||||
// The reverse: a bare (unscoped) update is pending when a scoped update fires. The scoped
|
||||
// write must keep the unscoped "refresh latest" intent so the root view is not dropped.
|
||||
mockReadFileSync.mockReturnValue(new Date().toISOString());
|
||||
|
||||
updateSignalFile('eval-scoped-1');
|
||||
|
||||
expect(JSON.parse(lastWrittenSignal())).toMatchObject({
|
||||
type: 'update',
|
||||
evalId: 'eval-scoped-1',
|
||||
refreshLatest: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries a pending unscoped refresh into a following delete', () => {
|
||||
// A bare (unscoped) update is pending when an unrelated delete fires. The delete JSON must
|
||||
// keep a refreshLatest marker so a root /eval view whose eval was not deleted still follows
|
||||
// the latest eval.
|
||||
mockReadFileSync.mockReturnValue(new Date().toISOString());
|
||||
|
||||
updateSignalFileForDeletedEvals(['eval-removed']);
|
||||
|
||||
expect(JSON.parse(lastWrittenSignal())).toMatchObject({
|
||||
type: 'delete',
|
||||
deletedEvalIds: ['eval-removed'],
|
||||
refreshLatest: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps repeated updates to the same eval on the compact legacy text format', () => {
|
||||
// The common per-result run case: same eval signaled twice in the window. Nothing new to
|
||||
// preserve, so it stays legacy text rather than ballooning into JSON.
|
||||
mockReadFileSync.mockReturnValue(`eval-same-id:${new Date().toISOString()}`);
|
||||
|
||||
updateSignalFile('eval-same-id');
|
||||
|
||||
expect(lastWrittenSignal()).toMatch(/^eval-same-id:\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it('treats a coalesced delete as "all deleted" when either side has no ids', () => {
|
||||
// A pending delete-all (no ids) folded with a specific delete must stay "all deleted".
|
||||
mockReadFileSync.mockReturnValue(
|
||||
JSON.stringify({ type: 'delete', timestamp: new Date().toISOString() }),
|
||||
);
|
||||
updateSignalFileForDeletedEvals(['eval-specific']);
|
||||
expect(JSON.parse(lastWrittenSignal()).deletedEvalIds).toBeUndefined();
|
||||
|
||||
// ...and the reverse: a specific pending delete folded with a delete-all stays "all".
|
||||
mockReadFileSync.mockReturnValue(
|
||||
JSON.stringify({
|
||||
type: 'delete',
|
||||
deletedEvalIds: ['eval-pending'],
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
updateSignalFileForDeletedEvals(undefined);
|
||||
expect(JSON.parse(lastWrittenSignal()).deletedEvalIds).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps "all deleted" when a folded delete-all later coalesces with a specific delete', () => {
|
||||
// Three-signal chain: a delete-all is first folded into a pending update as its "all" marker
|
||||
// (deletedEvalIds: []), then a specific delete coalesces with that update. The [] must still
|
||||
// read as "all deleted" so the specific id can't downgrade it to a single removed eval.
|
||||
mockReadFileSync.mockReturnValue(
|
||||
JSON.stringify({
|
||||
type: 'update',
|
||||
evalId: 'eval-after-delete-all',
|
||||
deletedEvalIds: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
updateSignalFileForDeletedEvals(['eval-specific']);
|
||||
|
||||
expect(JSON.parse(lastWrittenSignal()).deletedEvalIds).toBeUndefined();
|
||||
});
|
||||
|
||||
it('carries every pending scoped update into a following delete', () => {
|
||||
mockReadFileSync.mockReturnValue(
|
||||
JSON.stringify({
|
||||
type: 'update',
|
||||
evalId: 'eval-update-2',
|
||||
updatedEvalIds: ['eval-update-1', 'eval-update-2'],
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
updateSignalFileForDeletedEvals(['eval-removed']);
|
||||
|
||||
expect(JSON.parse(lastWrittenSignal())).toMatchObject({
|
||||
type: 'delete',
|
||||
deletedEvalIds: ['eval-removed'],
|
||||
updatedEvalIds: ['eval-update-1', 'eval-update-2'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed-version signal compatibility', () => {
|
||||
// Snapshot of the pre-PR reader (main's readSignalEvalId) so we can pin how an OLD
|
||||
// view server degrades when it reads signals written by a NEW CLI. This guards the
|
||||
// documented mixed-version contract without depending on git history.
|
||||
function legacyReadSignalEvalId(content: string): string | undefined {
|
||||
const trimmed = content.trim();
|
||||
if (/^\d{4}-\d{2}-\d{2}T/.test(trimmed)) {
|
||||
return undefined;
|
||||
}
|
||||
if (trimmed.includes(':')) {
|
||||
const evalId = trimmed.split(':')[0];
|
||||
if (evalId && evalId.length > 8) {
|
||||
return evalId;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
it('old reader degrades a new JSON delete payload to an unscoped update (Eval.latest fallback)', () => {
|
||||
const payload = JSON.stringify({
|
||||
type: 'delete',
|
||||
deletedEvalIds: ['eval-abc-2026-05-29T22:43:41'],
|
||||
timestamp: '2026-05-30T22:00:00.000Z',
|
||||
});
|
||||
|
||||
// split(':')[0] === '{"type"' (7 chars), which fails the length > 8 guard, so the
|
||||
// old server sees no scoped id and falls back to broadcasting the latest eval.
|
||||
expect(legacyReadSignalEvalId(payload)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('new reader correctly parses legacy scoped signals an old CLI would write', () => {
|
||||
mockReadFileSync.mockReturnValue('eval-abc-2026-05-29T22:43:41:2026-05-29T22:43:42.000Z');
|
||||
|
||||
// Regression guard: main's reader truncated colon-containing ids at the first colon
|
||||
// (eval-abc-2026-05-29T22). The new reader recovers the full id.
|
||||
expect(readSignalEvalId()).toBe('eval-abc-2026-05-29T22:43:41');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readSignalEvalId', () => {
|
||||
it('should return evalId when signal file contains evalId:timestamp format', () => {
|
||||
mockReadFileSync.mockReturnValue('eval-12345-abcdef:2024-01-01T00:00:00.000Z');
|
||||
|
||||
const result = readSignalEvalId();
|
||||
|
||||
expect(result).toBe('eval-12345-abcdef');
|
||||
});
|
||||
|
||||
it('should preserve colons in eval IDs through a write-read round trip', () => {
|
||||
const evalId = 'eval-abc-2026-05-29T22:43:41';
|
||||
mockWriteFileSync.mockImplementation((_path, content) => {
|
||||
mockReadFileSync.mockReturnValue(content);
|
||||
});
|
||||
|
||||
updateSignalFile(evalId);
|
||||
|
||||
expect(readSignalEvalId()).toBe(evalId);
|
||||
});
|
||||
|
||||
it('should return undefined when signal file contains only timestamp', () => {
|
||||
mockReadFileSync.mockReturnValue('2024-01-01T00:00:00.000Z');
|
||||
|
||||
const result = readSignalEvalId();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not treat structured deletion signals as scoped eval updates', () => {
|
||||
mockReadFileSync.mockReturnValue(
|
||||
JSON.stringify({
|
||||
type: 'delete',
|
||||
deletedEvalIds: ['eval-deleted-1'],
|
||||
timestamp: '2026-05-30T22:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(readSignalEvalId()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when evalId is too short (8 chars or less)', () => {
|
||||
mockReadFileSync.mockReturnValue('short:2024-01-01T00:00:00.000Z');
|
||||
|
||||
const result = readSignalEvalId();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return evalId when it is longer than 8 characters', () => {
|
||||
mockReadFileSync.mockReturnValue('123456789:2024-01-01T00:00:00.000Z');
|
||||
|
||||
const result = readSignalEvalId();
|
||||
|
||||
expect(result).toBe('123456789');
|
||||
});
|
||||
|
||||
it('should return undefined when file read fails', () => {
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
throw new Error('File not found');
|
||||
});
|
||||
|
||||
const result = readSignalEvalId();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle empty string content', () => {
|
||||
mockReadFileSync.mockReturnValue('');
|
||||
|
||||
const result = readSignalEvalId();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should trim whitespace from content', () => {
|
||||
mockReadFileSync.mockReturnValue(' eval-12345-abcdef:2024-01-01T00:00:00.000Z \n');
|
||||
|
||||
const result = readSignalEvalId();
|
||||
|
||||
expect(result).toBe('eval-12345-abcdef');
|
||||
});
|
||||
|
||||
it('should handle UUID-style eval IDs', () => {
|
||||
mockReadFileSync.mockReturnValue(
|
||||
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:2024-01-01T00:00:00.000Z',
|
||||
);
|
||||
|
||||
const result = readSignalEvalId();
|
||||
|
||||
expect(result).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const SOURCE_FILE_EXTENSIONS = /\.(?:[cm]?[jt]sx?)$/;
|
||||
const SQL_RAW_CALL = /\bsql\.raw\s*\(/;
|
||||
|
||||
function collectSourceFiles(rootDir: string): string[] {
|
||||
const results: string[] = [];
|
||||
|
||||
const walk = (dir: string) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(fullPath);
|
||||
} else if (SOURCE_FILE_EXTENSIONS.test(entry.name)) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(rootDir);
|
||||
return results;
|
||||
}
|
||||
|
||||
describe('SQL source safety', () => {
|
||||
it('keeps sql.raw() out of production source', () => {
|
||||
const srcRoot = path.join(process.cwd(), 'src');
|
||||
const offenders = collectSourceFiles(srcRoot)
|
||||
.filter((file) => SQL_RAW_CALL.test(fs.readFileSync(file, 'utf8')))
|
||||
.map((file) => path.relative(process.cwd(), file))
|
||||
.sort();
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user