0d3cb498a3
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) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
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) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
184 lines
6.8 KiB
TypeScript
184 lines
6.8 KiB
TypeScript
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
readRuntimeNoticeLastShownAt,
|
|
withRuntimeNoticeStateLock,
|
|
writeRuntimeNoticeLastShownAt,
|
|
} from '../../src/globalConfig/runtimeNoticeState';
|
|
import { setConfigDirectoryPath } from '../../src/util/config/manage';
|
|
|
|
describe('runtime notice state', () => {
|
|
let configDirectory: string;
|
|
|
|
beforeEach(() => {
|
|
configDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-runtime-notice-'));
|
|
setConfigDirectoryPath(configDirectory);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
setConfigDirectoryPath(undefined);
|
|
fs.rmSync(configDirectory, { recursive: true, force: true });
|
|
});
|
|
|
|
it('stores notice timestamps separately without rewriting global config', () => {
|
|
const globalConfigPath = path.join(configDirectory, 'promptfoo.yaml');
|
|
const globalConfig = 'account:\n email: current@example.com\n';
|
|
fs.writeFileSync(globalConfigPath, globalConfig);
|
|
|
|
writeRuntimeNoticeLastShownAt('node20-removal-2026-07-30', '2026-06-22T12:00:00.000Z');
|
|
|
|
expect(readRuntimeNoticeLastShownAt('node20-removal-2026-07-30')).toBe(
|
|
'2026-06-22T12:00:00.000Z',
|
|
);
|
|
writeRuntimeNoticeLastShownAt('node20-removal-2026-07-30', '2026-06-29T12:00:00.000Z');
|
|
expect(readRuntimeNoticeLastShownAt('node20-removal-2026-07-30')).toBe(
|
|
'2026-06-29T12:00:00.000Z',
|
|
);
|
|
expect(fs.readFileSync(globalConfigPath, 'utf8')).toBe(globalConfig);
|
|
expect(fs.readdirSync(path.join(configDirectory, 'notices'))).toEqual([
|
|
'node20-removal-2026-07-30.last-shown',
|
|
]);
|
|
});
|
|
|
|
it('treats missing notice state as not previously shown', () => {
|
|
expect(readRuntimeNoticeLastShownAt('node20-removal-2026-07-30')).toBeUndefined();
|
|
});
|
|
|
|
it('preserves prior state and cleans up when atomic replacement fails', () => {
|
|
const noticeId = 'node20-removal-2026-07-30';
|
|
writeRuntimeNoticeLastShownAt(noticeId, '2026-06-22T12:00:00.000Z');
|
|
const renameSpy = vi.spyOn(fs, 'renameSync').mockImplementation(() => {
|
|
throw new Error('Atomic replacement failed');
|
|
});
|
|
|
|
try {
|
|
expect(() => writeRuntimeNoticeLastShownAt(noticeId, '2026-06-29T12:00:00.000Z')).toThrow(
|
|
'Atomic replacement failed',
|
|
);
|
|
} finally {
|
|
renameSpy.mockRestore();
|
|
}
|
|
|
|
expect(readRuntimeNoticeLastShownAt(noticeId)).toBe('2026-06-22T12:00:00.000Z');
|
|
expect(fs.readdirSync(path.join(configDirectory, 'notices'))).toEqual([
|
|
'node20-removal-2026-07-30.last-shown',
|
|
]);
|
|
});
|
|
|
|
it('rejects notice ids that could escape the state directory', () => {
|
|
expect(() => readRuntimeNoticeLastShownAt('../promptfoo.yaml')).toThrow(
|
|
'Invalid runtime notice id',
|
|
);
|
|
});
|
|
|
|
it('allows only one concurrent state transition for a notice', () => {
|
|
const noticeId = 'node20-removal-2026-07-30';
|
|
let nestedResult: boolean | undefined;
|
|
|
|
const outerResult = withRuntimeNoticeStateLock(noticeId, () => {
|
|
nestedResult = withRuntimeNoticeStateLock(noticeId, () => true);
|
|
return true;
|
|
});
|
|
|
|
expect(outerResult).toBe(true);
|
|
expect(nestedResult).toBeUndefined();
|
|
expect(fs.readdirSync(path.join(configDirectory, 'notices'))).toEqual([]);
|
|
});
|
|
|
|
it('releases held locks when the transition callback throws', () => {
|
|
const noticeId = 'node20-removal-2026-07-30';
|
|
|
|
expect(() =>
|
|
withRuntimeNoticeStateLock(noticeId, () => {
|
|
throw new Error('transition failed');
|
|
}),
|
|
).toThrow('transition failed');
|
|
|
|
// The finally block must release both generation locks even when the callback throws, so a
|
|
// failed transition (e.g. an unreadable config) cannot wedge the notice for the lease window.
|
|
expect(fs.readdirSync(path.join(configDirectory, 'notices'))).toEqual([]);
|
|
});
|
|
|
|
it('does not treat lock creation errors as ordinary contention', () => {
|
|
const lockError = Object.assign(new Error('Permission denied'), { code: 'EACCES' });
|
|
const openSpy = vi.spyOn(fs, 'openSync').mockImplementation(() => {
|
|
throw lockError;
|
|
});
|
|
|
|
try {
|
|
expect(() => withRuntimeNoticeStateLock('node20-removal-2026-07-30', () => true)).toThrow(
|
|
lockError,
|
|
);
|
|
} finally {
|
|
openSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('keeps a transition exclusive across a lease generation boundary', () => {
|
|
vi.useFakeTimers({ toFake: ['Date'] });
|
|
vi.setSystemTime(new Date('2026-06-22T12:00:29.999Z'));
|
|
const noticeId = 'node20-removal-2026-07-30';
|
|
let nextLeaseResult: string | undefined;
|
|
|
|
expect(
|
|
withRuntimeNoticeStateLock(noticeId, () => {
|
|
const noticeDirectory = path.join(configDirectory, 'notices');
|
|
expect(
|
|
fs.readdirSync(noticeDirectory).filter((entry) => entry.includes('.lock.')),
|
|
).toHaveLength(2);
|
|
vi.setSystemTime(new Date('2026-06-22T12:00:30.000Z'));
|
|
|
|
nextLeaseResult = withRuntimeNoticeStateLock(noticeId, () => 'claimed');
|
|
return 'original';
|
|
}),
|
|
).toBe('original');
|
|
|
|
expect(nextLeaseResult).toBeUndefined();
|
|
expect(withRuntimeNoticeStateLock(noticeId, () => 'claimed')).toBe('claimed');
|
|
expect(fs.readdirSync(path.join(configDirectory, 'notices'))).toEqual([]);
|
|
});
|
|
|
|
it('ignores abandoned lease files after the protected generations pass', () => {
|
|
vi.useFakeTimers({ toFake: ['Date'] });
|
|
const initialTime = new Date('2026-06-22T12:00:00.000Z');
|
|
vi.setSystemTime(initialTime);
|
|
const noticeId = 'node20-removal-2026-07-30';
|
|
const noticeDirectory = path.join(configDirectory, 'notices');
|
|
fs.mkdirSync(noticeDirectory, { recursive: true });
|
|
const generation = Math.floor(initialTime.getTime() / 30_000);
|
|
const abandonedLocks = [generation, generation + 1].map((value) =>
|
|
path.join(noticeDirectory, `${noticeId}.last-shown.lock.${value}`),
|
|
);
|
|
for (const lockPath of abandonedLocks) {
|
|
fs.writeFileSync(lockPath, 'abandoned\n');
|
|
}
|
|
|
|
expect(withRuntimeNoticeStateLock(noticeId, () => 'claimed')).toBeUndefined();
|
|
|
|
vi.setSystemTime(new Date(initialTime.getTime() + 60_000));
|
|
expect(withRuntimeNoticeStateLock(noticeId, () => 'claimed')).toBe('claimed');
|
|
expect(fs.readdirSync(noticeDirectory).sort()).toEqual(
|
|
abandonedLocks.map((lockPath) => path.basename(lockPath)).sort(),
|
|
);
|
|
});
|
|
|
|
it('keeps a transition exclusive across an adjacent clock rollback', () => {
|
|
vi.useFakeTimers({ toFake: ['Date'] });
|
|
vi.setSystemTime(new Date('2026-06-22T12:01:00.000Z'));
|
|
const noticeId = 'node20-removal-2026-07-30';
|
|
let rollbackResult: string | undefined;
|
|
|
|
withRuntimeNoticeStateLock(noticeId, () => {
|
|
vi.setSystemTime(new Date('2026-06-22T12:00:59.999Z'));
|
|
rollbackResult = withRuntimeNoticeStateLock(noticeId, () => 'claimed');
|
|
});
|
|
|
|
expect(rollbackResult).toBeUndefined();
|
|
expect(fs.readdirSync(path.join(configDirectory, 'notices'))).toEqual([]);
|
|
});
|
|
});
|