Files
wehub-resource-sync 0d3cb498a3
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
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

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([]);
});
});