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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:24:08 +08:00
commit 0d3cb498a3
5438 changed files with 1316560 additions and 0 deletions
+685
View File
@@ -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);
});
});
});
+172
View File
@@ -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();
});
});
+595
View File
@@ -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');
});
});
});
+41
View File
@@ -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([]);
});
});