chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user